diff --git a/.codecov.yml b/.codecov.yml index 432c1cff2a2..9a5261f985b 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -1,11 +1,12 @@ codecov: require_ci_to_pass: true -# The rust-strategy and rust-shielded suites run only on push/nightly (PRs -# skip them for speed); carryforward makes codecov reuse the base commit's -# coverage for a flag when a PR run doesn't upload it, so PR reports don't -# show a spurious project-coverage drop for tests that intentionally didn't -# run. +# Coverage is collected by the nightly run of tests.yml only (23:00 UTC on +# the dev branches); pull request and post-merge push runs execute the same +# tests uninstrumented and upload nothing. Every nightly uploads all three +# flags; carryforward only matters when one phase fails and its report is +# missing, so a single broken phase doesn't read as a coverage drop for the +# whole suite. flags: rust: carryforward: true @@ -52,6 +53,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/**" @@ -108,11 +110,6 @@ ignore: - "packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/**/v1_methods.rs" # Platform wallet — requires Core wallet integration, not unit-testable - "packages/rs-platform-wallet/src/**" - # Platform wallet storage — its tests run in the wallet fast-path - # workflow (tests-rs-wallet.yml), which intentionally omits coverage - # upload, so codecov never receives data for this crate on - # wallet-scoped PRs and patch status would fail spuriously - - "packages/rs-platform-wallet-storage/**" # Proof-verifier response types and unproved handling - "packages/rs-drive-proof-verifier/src/types.rs" - "packages/rs-drive-proof-verifier/src/unproved.rs" @@ -158,20 +155,20 @@ ignore: # Versioned dispatch methods — pure version routing with no logic - "packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs" +# Statuses attach to the nightly commit only (no PR ever uploads), so they +# are informational: a nightly dip shows on the Codecov commit page without +# painting the branch tip red. Patch coverage has no meaningful base without +# PR uploads, and PR comments have nothing to attach to. coverage: status: project: default: target: auto threshold: 2% - patch: - default: - target: 50% + informational: true + patch: off -comment: - layout: "condensed_header, diff, components, condensed_files" - behavior: default - require_changes: true +comment: false component_management: individual_components: diff --git a/.editorconfig b/.editorconfig index 4238a16b9d5..2e9dc07ba04 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,5 +11,9 @@ end_of_line = lf [*.rs] indent_size = 4 +# Preserve the existing indentation of the Swift SDK Python scripts. +[packages/swift-sdk/scripts/*.py] +indent_size = 4 + [*.{md,markdown}] trim_trailing_whitespace = false 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/swift-sdk-build.yml b/.github/workflows/swift-sdk-build.yml index 2502526fa37..6a5c952fb9c 100644 --- a/.github/workflows/swift-sdk-build.yml +++ b/.github/workflows/swift-sdk-build.yml @@ -20,6 +20,10 @@ jobs: || github.event.pull_request.head.repo.owner.login == 'thepastaclaw' runs-on: [self-hosted, macOS, ARM64] timeout-minutes: 90 + defaults: + run: + # The runner's work/temp directory can be on a volume with spaces. + shell: bash --noprofile --norc -e -o pipefail "{0}" steps: - name: Checkout repository @@ -36,7 +40,28 @@ jobs: # Rust + Cargo cache to speed up FFI build - name: Set up Rust toolchain (stable) - uses: dtolnay/rust-toolchain@stable + # Composite actions choose their own shell and do not inherit the + # quoted script path above. Keep setup here for space-safe execution. + env: + RUSTUP_PERMIT_COPY_RENAME: "1" + run: | + export CARGO_HOME="${CARGO_HOME:-$HOME/.cargo}" + export PATH="$CARGO_HOME/bin:$PATH" + if ! command -v rustup >/dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 --retry 10 --retry-connrefused \ + --location --silent --show-error --fail https://sh.rustup.rs \ + | sh -s -- --default-toolchain none --no-modify-path -y + fi + rustup toolchain install stable --profile minimal --no-self-update \ + --target aarch64-apple-ios --target aarch64-apple-ios-sim + { + echo "CARGO_HOME=$CARGO_HOME" + echo "RUSTUP_TOOLCHAIN=stable" + echo "CARGO_INCREMENTAL=${CARGO_INCREMENTAL-0}" + echo "CARGO_TERM_COLOR=${CARGO_TERM_COLOR-always}" + } >> "$GITHUB_ENV" + echo "$CARGO_HOME/bin" >> "$GITHUB_PATH" + rustc +stable --version --verbose - name: Restore cargo registry cache uses: actions/cache/restore@v5 @@ -48,10 +73,6 @@ jobs: restore-keys: | cargo-registry- - - name: Add iOS Rust targets - run: | - rustup target add aarch64-apple-ios aarch64-apple-ios-sim - - name: Restore cached Protobuf (protoc) id: cache-protoc uses: actions/cache@v5 @@ -88,7 +109,7 @@ jobs: - name: Verify protoc and export env run: | set -euxo pipefail - echo "$HOME/.local/protoc-32.0/bin" >> $GITHUB_PATH + echo "$HOME/.local/protoc-32.0/bin" >> "$GITHUB_PATH" echo "PROTOC=$HOME/.local/protoc-32.0/bin/protoc" >> "$GITHUB_ENV" "$HOME/.local/protoc-32.0/bin/protoc" --version diff --git a/.github/workflows/swift-sdk-freeze-release.yml b/.github/workflows/swift-sdk-freeze-release.yml new file mode 100644 index 00000000000..00dec5251bb --- /dev/null +++ b/.github/workflows/swift-sdk-freeze-release.yml @@ -0,0 +1,62 @@ +name: Freeze SwiftData App Store release + +on: + workflow_dispatch: + inputs: + release_id: + description: App Store version ID recorded by the iOS publication monitor + required: true + type: string + data_commit: + description: Full commit on dashwallet-ios/schema-release-data containing the publication proof + required: true + type: string + dry_run: + description: Validate and generate without committing, pushing or opening a PR + default: false + type: boolean + +permissions: + contents: read + +# Different releases may share a schema and therefore an automation branch. +concurrency: + group: swift-sdk-appstore-schema-freeze + cancel-in-progress: false + +jobs: + freeze: + if: github.repository == 'dashpay/platform' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout reviewed worker + uses: actions/checkout@v4 + with: + ref: v4.2-dev + path: platform + # Include immutable swift-schema-source/* tags and their history. + fetch-depth: 0 + persist-credentials: false + - name: Checkout publication records from the fixed iOS repository + uses: actions/checkout@v4 + with: + repository: dashpay/dashwallet-ios + ref: schema-release-data + path: release-data + fetch-depth: 0 + token: ${{ secrets.SCHEMA_RELEASE_TOKEN }} + persist-credentials: false + - name: Validate publication and prepare draft snapshot PR + env: + SCHEMA_RELEASE_TOKEN: ${{ secrets.SCHEMA_RELEASE_TOKEN }} + RELEASE_ID: ${{ inputs.release_id }} + DATA_COMMIT: ${{ inputs.data_commit }} + DRY_RUN: ${{ inputs.dry_run }} + working-directory: platform + shell: bash + run: | + set -euo pipefail + args=(--release-id "$RELEASE_ID" --data-commit "$DATA_COMMIT" --data-repo ../release-data) + if [[ "$DRY_RUN" == true ]]; then args+=(--dry-run); fi + python3 -I packages/swift-sdk/scripts/freeze_appstore_release.py "${args[@]}" diff --git a/.github/workflows/tests-rs-wallet.yml b/.github/workflows/tests-rs-wallet.yml index f8e95ea20ac..386d6bfe7be 100644 --- a/.github/workflows/tests-rs-wallet.yml +++ b/.github/workflows/tests-rs-wallet.yml @@ -18,13 +18,14 @@ # checks, and wallet-scoped doctests mirror the full job so nothing merges # through the fast path that the full path would have rejected. # -# Coverage upload is intentionally omitted (codecov never gates the merge — -# `fail_ci_if_error: false`). Known trade-offs: this fast path stays pinned to -# the macOS runners (unlike the full workspace job, which schedules onto any -# `rust-ci` self-hosted runner), so wallet PRs depend on a mac runner being -# online; and the scoped `-p` builds feature-unify shared deps differently -# than `--workspace` builds, so the shared target/ carries an extra artifact -# flavor. +# No coverage here: code coverage is collected only by the nightly run of the +# full workspace workflow (tests-rs-workspace.yml, `coverage` input), which +# also measures the wallet crates. Known trade-offs: this fast path stays +# pinned to the macOS runners (unlike the full workspace job, which schedules +# onto any `rust-ci` self-hosted runner), so wallet PRs depend on a mac +# runner being online; and the scoped `-p` builds feature-unify shared deps +# differently than `--workspace` builds, so the shared target/ carries an +# extra artifact flavor. on: workflow_call: inputs: @@ -51,12 +52,12 @@ jobs: with: clean: false - # target/llvm-cov-target is deliberately NOT pruned here: the workspace - # job (tests-rs-workspace.yml) keeps its coverage-instrumented build - # there for incremental compiles, and this job shares the same runner - # workspace — wiping it on a wallet-only PR would force the next - # workspace run into a ~2.5 min cold rebuild. The size guard below - # still removes the whole target/ if it outgrows the caps. + # target/llvm-cov-target is deliberately NOT pruned here: the nightly + # coverage run of the workspace job (tests-rs-workspace.yml) keeps its + # instrumented build there for incremental compiles, and this job + # shares the same runner workspace — wiping it on a wallet-only PR + # would force the next nightly into a ~2.5 min cold rebuild. The size + # guard below still removes the whole target/ if it outgrows the caps. - name: Prune macOS runner disk before tests run: | for path in ../target-backup-before-*-clean-*; do @@ -67,9 +68,9 @@ jobs: done # Purge per-run coverage profile data possibly left by a cancelled - # workspace run in the shared workspace — before measuring target/, - # so stale profraw cannot inflate the size check and cost the - # instrumented build. + # nightly coverage run in the shared workspace — before measuring + # target/, so stale profraw cannot inflate the size check and cost + # the instrumented build. if [ -d target/llvm-cov-target ]; then find target/llvm-cov-target \( -name '*.profraw' -o -name '*.profdata' \) -delete || true fi diff --git a/.github/workflows/tests-rs-workspace.yml b/.github/workflows/tests-rs-workspace.yml index f6a979e1402..ac2c782301c 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -8,14 +8,22 @@ on: shielded-changed: # Computed by the `changes` job in tests.yml (see the # "Check for shielded-relevant changes" step there for the heuristic - # and its safety net). When false, the shielded test phase is skipped - # and no rust-shielded coverage is uploaded — codecov carries the - # base commit's rust-shielded flag forward (see .codecov.yml). + # and its safety net). When false, the shielded test phase is skipped. # Defaults to true so any caller that doesn't compute the heuristic # gets the full suite. description: Whether shielded code (or anything affecting the shielded suite) changed type: boolean default: true + coverage: + # Code coverage is collected on the nightly schedule only (tests.yml + # sets this on `schedule`, and on `workflow_dispatch` when asked). PR + # and post-merge push runs execute the very same test phases with + # plain cargo-nextest / cargo-test: no instrumented build, no lcov + # report, no Codecov upload. Which phases run is decided by the + # event and the inputs above, not by this flag. + description: Instrument the test phases with cargo-llvm-cov and upload the reports to Codecov + type: boolean + default: false jobs: test: @@ -32,6 +40,11 @@ jobs: || github.event.pull_request.head.repo.full_name == github.repository || github.event.pull_request.head.repo.owner.login == 'thepastaclaw' timeout-minutes: 90 + defaults: + run: + # Self-hosted macOS runners may keep their work directory on a volume + # with spaces in its name. Quote the generated script path explicitly. + shell: bash --noprofile --norc -eo pipefail "{0}" steps: - name: Check out repo uses: actions/checkout@v4 @@ -46,9 +59,11 @@ jobs: fi # target/llvm-cov-target is deliberately NOT pruned here: it holds the - # coverage-instrumented build (~10GB) that makes the test step's compile - # incremental (~2.5 min saved per run). The size/free-disk guard below - # removes the whole target/ if it ever outgrows the caps. + # coverage-instrumented build (~10GB) the nightly coverage run compiles + # into, and keeping it makes that run's compile incremental (~2.5 min + # saved). PR and push runs build the plain dev profile into + # target/debug, which persists the same way. The size/free-disk guard + # below removes the whole target/ if it ever outgrows the caps. - name: Prune runner disk before tests run: | for path in ../target-backup-before-*-clean-*; do @@ -58,11 +73,11 @@ jobs: fi done - # Purge per-run profile data possibly left by a cancelled run whose - # end-of-job cleanup never executed — BEFORE measuring target/, so - # stale profraw can neither leak into this run's coverage report - # nor inflate the size check below and cost the whole instrumented - # build. + # Purge per-run profile data possibly left by a cancelled nightly + # coverage run whose end-of-job cleanup never executed — BEFORE + # measuring target/, so stale profraw can neither leak into the + # next coverage report nor inflate the size check below and cost + # the whole instrumented build. if [ -d target/llvm-cov-target ]; then find target/llvm-cov-target \( -name '*.profraw' -o -name '*.profdata' \) -delete || true fi @@ -159,13 +174,14 @@ jobs: cache: false components: llvm-tools, rustfmt, clippy - # Both tools are required by the test steps below. Install only when - # missing, and fail HERE, loudly, if the tool still doesn't work - # afterwards — the previous `cargo install ... 2>/dev/null || true` - # silently swallowed a failed nextest install on a freshly provisioned - # runner, and the job then died 10 minutes later in the test step with - # "no such command: nextest". + # Install only when missing, and fail HERE, loudly, if the tool still + # doesn't work afterwards — the previous `cargo install ... 2>/dev/null + # || true` silently swallowed a failed nextest install on a freshly + # provisioned runner, and the job then died 10 minutes later in the + # test step with "no such command: nextest". cargo-llvm-cov is only + # needed by the nightly coverage run. - name: Install cargo-llvm-cov + if: ${{ inputs.coverage }} run: | if ! cargo llvm-cov --version >/dev/null 2>&1; then cargo install cargo-llvm-cov --locked @@ -362,14 +378,22 @@ jobs: done echo "No immutable/append_only structure violations found" - - name: Check if coverage can be reused + # The marker written by "Record tests tree hash" below says this exact + # source tree already passed the COMPLETE suite on this runner, which + # lets the post-merge push run (whose tree is usually identical to the + # PR merge ref that just passed) skip the tests. A coverage run never + # reuses: it has to execute the tests to have anything to report. + - name: Check if the tests can be skipped for an already-tested tree id: coverage-cache run: | CURRENT_TREE=$(git rev-parse 'HEAD^{tree}') - CACHED_TREE=$(cat target/lcov-tree-hash 2>/dev/null || echo "none") - if [ "$CURRENT_TREE" = "$CACHED_TREE" ] && [ -f lcov-nonshielded.info ]; then + CACHED_TREE=$(cat target/tests-tree-hash 2>/dev/null || echo "none") + if [ "${{ inputs.coverage }}" = "true" ]; then + echo "reuse=false" >> "$GITHUB_OUTPUT" + echo "Coverage run — running tests regardless of the tree hash" + elif [ "$CURRENT_TREE" = "$CACHED_TREE" ]; then echo "reuse=true" >> "$GITHUB_OUTPUT" - echo "Tree hash matches ($CURRENT_TREE) — reusing coverage from previous run" + echo "Tree hash matches ($CURRENT_TREE) — this tree already passed the full suite, skipping tests" else echo "reuse=false" >> "$GITHUB_OUTPUT" echo "Tree hash changed ($CACHED_TREE -> $CURRENT_TREE) — running tests" @@ -378,7 +402,7 @@ jobs: # Leftover profile data from a cancelled run is handled earlier, in # "Prune runner disk before tests", unconditionally for every job. - name: Remove stale coverage data - if: steps.coverage-cache.outputs.reuse == 'false' + if: ${{ inputs.coverage }} run: rm -f lcov.info lcov-nonshielded.info lcov-strategy.info lcov-shielded.info # The drive-abci chain-simulation suite (strategy_tests, ~90 @@ -391,12 +415,21 @@ jobs: # combined. The remaining simulations run in their own phase # below on push and nightly only, so a regression in one of them is # caught minutes after merge — the same safety-net pattern as the - # shielded phase. Keeping this phase's filter identical across events - # keeps the `rust` codecov flag comparable between PR and push runs. + # shielded phase. + # + # Every test phase runs under cargo-llvm-cov only when `coverage` is + # set (the nightly); otherwise it is a plain nextest run of the very + # same packages and filter. Only the command prefix differs, so the two + # modes select identical tests. - name: Run non-shielded tests with nextest (parallel, compiles all packages) if: steps.coverage-cache.outputs.reuse == 'false' run: | - cargo llvm-cov nextest --no-report \ + if [ "${{ inputs.coverage }}" = "true" ]; then + RUN=(cargo llvm-cov nextest --no-report) + else + RUN=(cargo nextest run) + fi + "${RUN[@]}" \ --package drive \ --package dpp \ --package drive-abci \ @@ -419,6 +452,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))' @@ -431,21 +465,25 @@ jobs: # its codecov flag), so profraw files are dropped between phases to # keep the reports disjoint. - name: Generate non-shielded coverage report - if: steps.coverage-cache.outputs.reuse == 'false' + if: inputs.coverage && steps.coverage-cache.outputs.reuse == 'false' run: | cargo llvm-cov report --lcov --output-path lcov-nonshielded.info cargo llvm-cov clean --profraw-only # The chain simulations excluded from the phase above. PR runs skip - # them (and upload no rust-strategy coverage — codecov carries the base - # commit's forward); push, nightly, and dispatch runs execute them all. + # them; push, nightly, and dispatch runs execute them all. - name: Run full chain-simulation suite (push and nightly only) id: strategy-tests if: >- steps.coverage-cache.outputs.reuse == 'false' && github.event_name != 'pull_request' run: | - cargo llvm-cov nextest --no-report \ + if [ "${{ inputs.coverage }}" = "true" ]; then + RUN=(cargo llvm-cov nextest --no-report) + else + RUN=(cargo nextest run) + fi + "${RUN[@]}" \ --package drive-abci \ --all-features \ --locked \ @@ -456,21 +494,31 @@ jobs: CARGO_PROFILE_DEV_CODEGEN_UNITS: "256" - name: Generate chain-simulation coverage report - if: steps.strategy-tests.outcome == 'success' + if: inputs.coverage && steps.strategy-tests.outcome == 'success' run: | cargo llvm-cov report --lcov --output-path lcov-strategy.info cargo llvm-cov clean --profraw-only - # When nothing shielded-relevant changed, the phase is skipped and no - # rust-shielded coverage is uploaded — codecov carries the base - # commit's forward (see .codecov.yml). + # When nothing shielded-relevant changed, the phase is skipped (see the + # `shielded-changed` input). - name: Run shielded tests with cargo test (shared process for VK reuse) id: shielded-tests if: >- steps.coverage-cache.outputs.reuse == 'false' && inputs.shielded-changed run: | - cargo llvm-cov test --no-report \ + # `--tests` selects every test target except doctests, which have + # their own gated step at the end of the job. cargo-llvm-cov adds + # it itself when no target is selected; passing it explicitly in + # both modes pins the equivalence instead of relying on that + # default of an unpinned tool. + if [ "${{ inputs.coverage }}" = "true" ]; then + RUN=(cargo llvm-cov test --no-report) + else + RUN=(cargo test) + fi + "${RUN[@]}" \ + --tests \ --package dpp \ --package drive \ --package drive-abci \ @@ -484,42 +532,43 @@ jobs: CARGO_PROFILE_DEV_CODEGEN_UNITS: "256" - name: Generate shielded coverage report - if: steps.shielded-tests.outcome == 'success' + if: inputs.coverage && steps.shielded-tests.outcome == 'success' run: cargo llvm-cov report --lcov --output-path lcov-shielded.info - # The marker means "this exact tree passed the COMPLETE suite", so it - # is written only when both skippable phases (chain simulations and - # shielded) actually ran and succeeded. A PR fast-path run must not - # write it: the post-merge push commonly has the same tree as the PR - # merge ref, and on the same runner the marker would let the reuse - # check skip the push run's full phases — the safety net for changes - # the PR-path gating misses. - - name: Record coverage tree hash + # The marker means "this exact tree passed the COMPLETE suite in a + # plain run", so it is written only when both skippable phases (chain + # simulations and shielded) actually ran and succeeded. A PR fast-path + # run must not write it: the post-merge push commonly has the same + # tree as the PR merge ref, and on the same runner the marker would + # let the reuse check skip the push run's full phases — the safety net + # for changes the PR-path gating misses. A coverage run does not write + # it either: it never reads it, and the marker exists to dedup plain + # runs against each other, not against an instrumented pass. + - name: Record tests tree hash if: >- - steps.coverage-cache.outputs.reuse == 'false' + ${{ !inputs.coverage + && steps.coverage-cache.outputs.reuse == 'false' && steps.strategy-tests.outcome == 'success' - && steps.shielded-tests.outcome == 'success' - run: git rev-parse 'HEAD^{tree}' > target/lcov-tree-hash + && steps.shielded-tests.outcome == 'success' }} + run: git rev-parse 'HEAD^{tree}' > target/tests-tree-hash + # Needed by the Codecov upload below on the macOS runners. - name: Reset GPG state (fix stale keyboxd locks on the macOS runners) - if: always() && runner.os == 'macOS' + if: always() && inputs.coverage && runner.os == 'macOS' run: | gpgconf --kill all 2>/dev/null || true rm -rf ~/.gnupg/public-keys.d/*.lock 2>/dev/null || true rm -rf ~/.gnupg/.#* 2>/dev/null || true - # One upload per codecov flag. A phase that didn't run produces no file - # and uploads nothing — its flag is carried forward from the base - # commit by codecov (see .codecov.yml), so PR reports don't show a - # spurious coverage drop for suites that intentionally didn't run. - # File-existence gating (rather than step-outcome gating) also covers - # the tree-hash reuse path, where the files persist from the previous - # run of the identical tree. + # One upload per codecov flag, nightly only. A phase that failed + # produces no file and uploads nothing — its flag is carried forward + # from the previous upload by codecov (see .codecov.yml), so a single + # broken phase doesn't read as a coverage drop for the whole suite. # `files` is additive unless search is disabled. Persistent runners # also contain old reports and build artifacts named "coverage"; only # upload this phase's LCOV and do not generate unrelated plugin reports. - name: Upload coverage - if: always() && hashFiles('lcov-nonshielded.info') != '' + if: always() && inputs.coverage && hashFiles('lcov-nonshielded.info') != '' uses: codecov/codecov-action@v6 with: files: lcov-nonshielded.info @@ -530,7 +579,7 @@ jobs: fail_ci_if_error: false - name: Upload chain-simulation coverage - if: always() && hashFiles('lcov-strategy.info') != '' + if: always() && inputs.coverage && hashFiles('lcov-strategy.info') != '' uses: codecov/codecov-action@v6 with: files: lcov-strategy.info @@ -541,7 +590,7 @@ jobs: fail_ci_if_error: false - name: Upload shielded coverage - if: always() && hashFiles('lcov-shielded.info') != '' + if: always() && inputs.coverage && hashFiles('lcov-shielded.info') != '' uses: codecov/codecov-action@v6 with: files: lcov-shielded.info @@ -552,10 +601,11 @@ jobs: fail_ci_if_error: false # Keep the coverage-instrumented build (target/llvm-cov-target) between - # runs so the test step's compile stays incremental — a full wipe forces - # a ~2.5 min cold rebuild of ~700 crates every job. Only per-run profile + # nightly runs so that run's compile stays incremental — a full wipe + # forces a ~2.5 min cold rebuild of ~700 crates. Only per-run profile # data is removed: profraw files (one per test process, regenerated by - # every run) and the merged profdata. Disk pressure is handled by the + # every coverage run) and the merged profdata. A no-op on PR and push + # runs, which produce no profile data. Disk pressure is handled by the # size/free-disk guard in "Prune runner disk before tests", which # removes the whole target/ when it outgrows the caps. ("Prune runner # disk before tests" purges profile data again at the start of the next diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 86d9675f743..b318c0978bc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,6 +2,13 @@ name: Tests on: workflow_dispatch: + inputs: + coverage: + # What the nightly schedule does. Off by default so a manual "run + # everything on this branch" stays as fast as a PR run. + description: Instrument the Rust workspace tests with cargo-llvm-cov and upload the reports to Codecov + type: boolean + default: false push: branches: - master @@ -22,7 +29,11 @@ concurrency: jobs: check-secrets: name: Check secret availability - if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || !github.event.pull_request.draft }} + if: >- + ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' + || !github.event.pull_request.draft + || (github.event.pull_request.head.repo.full_name == github.repository + && startsWith(github.head_ref, 'codex/freeze-swift-schema-v')) }} runs-on: ubuntu-24.04 outputs: has_ecr: ${{ steps.check.outputs.has_ecr }} @@ -39,7 +50,11 @@ jobs: changes: name: Determine changed packages - if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || !github.event.pull_request.draft }} + if: >- + ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' + || !github.event.pull_request.draft + || (github.event.pull_request.head.repo.full_name == github.repository + && startsWith(github.head_ref, 'codex/freeze-swift-schema-v')) }} runs-on: ubuntu-24.04 outputs: js-packages: ${{ steps.override.outputs.js-packages || steps.prune-pr-matrix.outputs.js-packages || steps.filter-js.outputs.changes }} @@ -307,8 +322,10 @@ jobs: filters: | swift-sdk-changed: - .github/workflows/swift-sdk-build.yml + - .github/workflows/swift-sdk-freeze-release.yml - .github/workflows/tests.yml - packages/swift-sdk/** + - packages/app-connect-contract/** - packages/dapi-grpc/** - packages/dashpay-contract/** - packages/data-contracts/** @@ -433,7 +450,13 @@ jobs: name: Rust workspace tests needs: - changes - if: ${{ (needs.changes.outputs.rs-packages != '[]' || needs.changes.outputs.rs-workflows-changed == 'true') && needs.changes.outputs.rs-scope != 'wallet' }} + # The nightly schedule (and a manual dispatch) always runs this job: the + # nightly is the only run that collects code coverage, so it must not + # depend on the change filter. + if: >- + ${{ ((needs.changes.outputs.rs-packages != '[]' || needs.changes.outputs.rs-workflows-changed == 'true') && needs.changes.outputs.rs-scope != 'wallet') + || github.event_name == 'schedule' + || github.event_name == 'workflow_dispatch' }} secrets: inherit uses: ./.github/workflows/tests-rs-workspace.yml with: @@ -441,6 +464,9 @@ jobs: # Anything other than an explicit 'false' (including an empty output # from a skipped detector step) fails safe into running the suite. shielded-changed: ${{ needs.changes.outputs.shielded-changed != 'false' }} + # Code coverage runs nightly only. PR and push runs execute the same + # test phases without instrumentation and upload nothing to Codecov. + coverage: ${{ github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.coverage == true) }} # Fast path: only wallet crates changed, so run the scoped wallet suite # instead of the full workspace job above (the two are mutually exclusive @@ -495,6 +521,9 @@ jobs: - name: Check the frozen SwiftData models match FREEZES run: python3 packages/swift-sdk/scripts/freeze_schema_models.py --check + - name: Verify the historical migration fixture and pinned sources + run: python3 packages/swift-sdk/scripts/historical_schema_fixture.py --check + - name: Test the freeze generator run: python3 -m unittest discover -s packages/swift-sdk/scripts -p 'test_*.py' -v 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/AGENTS.md b/AGENTS.md index 5bf44aeccff..8b235bc094a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,9 @@ Platform uses data contracts to define application data schemas: - Run linters: `yarn lint` ## Coding Style & Naming Conventions -- Editor config: 2-space indent (4 for `*.rs`), LF, UTF‑8, final newline (`.editorconfig`). +- Follow `.editorconfig`: 2-space indent by default; 4 spaces for `*.rs` and + `packages/swift-sdk/scripts/*.py`, preserving the existing Python script style. + Use LF, UTF‑8, and a final newline. - JS/TS: ESLint (Airbnb/TypeScript rules via package configs). Use camelCase for variables/functions, PascalCase for classes; prefer kebab-case filenames within JS packages. - Rust: Follow rustfmt defaults; keep code clippy-clean. Modules `snake_case`, types `PascalCase`, constants `SCREAMING_SNAKE_CASE`. - Rust architecture rules live in The Dash Platform Book (`book/`). Read [book/src/contributing/coding-conventions.md](book/src/contributing/coding-conventions.md) before changing versioned behaviour, validation, errors, fees, or limits; it states each rule, why it exists, and links to the chapter with the mechanics. Key rules: shipped `vN` modules are frozen and new behaviour is a new `vN` selected only by the unreleased protocol version's tables; numbers go in `SystemLimits`, fees in named `FEE_VERSION*` schedules; `platform_version` is the last parameter; no `unwrap`/`expect` on block-execution paths (a panic halts the chain); imports at the top, no inline `crate::` paths; latest-generation tests use `PlatformVersion::latest()`. diff --git a/Cargo.lock b/Cargo.lock index 95988364714..b0e4a6ff648 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.3" +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,8 @@ dependencies = [ name = "data-contracts" version = "4.2.0-beta.3" dependencies = [ + "app-connect-contract", + "base58", "dashpay-contract", "document-history-contract", "dpns-contract", diff --git a/Cargo.toml b/Cargo.toml index c4baeee1181..09d41d94b47 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/SUMMARY.md b/book/src/SUMMARY.md index 06b3f3a79aa..c41f5122fa5 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -57,6 +57,7 @@ - [Contract Groups](data-model/contract-groups.md) - [Contract Moderation](data-model/contract-moderation.md) - [Documents](data-model/documents.md) +- [Contested Documents](data-model/contested-documents.md) - [Identities](data-model/identities.md) - [Key Budgets and Expiry](data-model/key-limits.md) 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/book/src/data-model/contested-documents.md b/book/src/data-model/contested-documents.md new file mode 100644 index 00000000000..6637722ba8d --- /dev/null +++ b/book/src/data-model/contested-documents.md @@ -0,0 +1,49 @@ +# Contested Documents + +A unique index may be declared `contested`. A document whose index values fall in the contested +range is not stored outright: it opens or joins a **contest**, a `ContestedDocumentResourceVotePoll` +that names the contract, the document type, the index and the index values, and masternodes and +evonodes decide who gets the value. A masternode's vote counts once, an evonode's four times. Every +vote is a `MasternodeVote` state transition carrying the poll and a `ResourceVoteChoice`. + +The contest is funded by the contenders' prefunded voting balances, and each vote costs a fixed +amount from that balance. Contenders may join for the **join window** (one week on mainnet) after +the first document; the contest runs for the **poll duration** (two weeks on mainnet). The first +document's owner may not be joined by the same identity twice. + +The index's `contested.resolution` says how the contest is decided. + +## Resolution 0: masternode vote + +The DPNS rule. The choices are a contender, abstain, or **lock**, which gives the value to nobody. +The contender with the most votes wins unless the lock tally exceeds it, in which case the value is +locked and may be contested again later. The contest always runs the full poll duration, even with +a single contender, so the masternodes may lock the value. + +## Resolution 1: masternode vote without locking + +`ContestedIndexResolution::MasternodeVoteNoLocking`, meta-schema v3 (protocol version 14). The +choices are a contender or abstain. A Lock vote is refused with `VoteChoiceNotAllowedForVotePollError` +(40307). The contest always ends with a winner: the contender with the most votes, no minimum. + +A contest without locking ends when its join window closes while it still has a single contender, +so that contender is awarded the value without a vote window. Its end-date entry is written at the +end of the join window when the contest opens; the first additional contender moves it to the full +poll duration, which opens the vote window. `getVotePollsByEndDate` shows whichever end applies. + +The moderation charters contract uses this resolution to elect moderation teams. + +## Ties + +From protocol version 14, a tie among the top contenders goes to the **earliest** contender: +creation time, then block height, then core block height, then document id. This holds for both +resolutions; contests ending before version 14 awarded the latest contender. + +## Storage + +A contest's state lives under `votes / contested_resource / active_polls`, laid out like the +contested index it decides: the contenders' documents, one votes sum tree per contender, and the +abstain and lock tallies. The masternodes' vote references live under +`votes / contested_resource / identity_votes`, and the end dates under `votes / end_date_queries`. +Once the contest ends, the winning document is awarded, the losers are removed, and the stored +result stays for the `getContestedResourceVoteState` query. diff --git a/book/src/data-model/contract-moderation.md b/book/src/data-model/contract-moderation.md index 767bce86ed7..582ce7969ea 100644 --- a/book/src/data-model/contract-moderation.md +++ b/book/src/data-model/contract-moderation.md @@ -2,15 +2,16 @@ An application that stores user content needs a way to keep an abusive identity out. Before protocol version 14 nothing in consensus state could do that: the contract owner could delete nothing the user wrote, could stop nothing the user would write next, and a client-side blocklist bound nobody but the client that kept it. Every other user's node still accepted the identity's documents. -**Contract moderation** is the answer. A data contract may declare, in its config, that it keeps a **banlist** and/or a **suspension list** of identities, and who may edit them. An identity on the banlist, or on the suspension list with a suspension that has not lapsed, cannot act on the contract at the document level: every document transition it signs against the contract is refused, paid, in the mempool and in a block. Token transitions are not affected. The lists live under the contract's own subtree in Drive, are edited by one new state transition, and are readable with one GroveDB proof. +**Contract moderation** is the answer. A data contract may declare, in its config, that it keeps a **banlist**, a **suspension list** and/or a **warning list** of identities, and who may edit them. An identity on the banlist, or on the suspension list with a suspension that has not lapsed, cannot act on the contract at the document level: every document transition it signs against the contract is refused, paid, in the mempool and in a block. Token transitions are not affected. A warning bars nothing: it is a record, with a reason and a block time, of a step short of a suspension, which the warned identity and everyone else can read and which accumulates until a moderator clears it. The lists live under the contract's own subtree in Drive, are edited by one new state transition, and are readable with one GroveDB proof. ## The Model -Three facts define a contract's moderation: +Four facts define a contract's moderation: -1. **The contract declares it.** `DataContractConfigV2::moderation` is an optional `ContractModerationConfig { banlist, suspensions, moderators }`. At least one list must be kept, unless a document type lets the moderators delete its documents (see Deleting Documents below), in which case the declaration may keep none. Which lists a contract keeps is decided when it is created and never changes: an update can not make an unmoderated contract moderated, turn a second list on, or turn a list off (`validate_config_update` 2, `DataContractConfigUpdateError`). Whoever writes documents under a contract knows from its first version whether and how they can be barred from it, which matters most where documents are assets: a ban also stops transfers and sales. And a list that is on may hold entries. Only the moderators may be changed by an update. -2. **The owner moderates, alone or with a fixed set.** `ContractModerators` is `ContractOwner` or `AppointedModerators(set)`: at most `SystemLimits::max_contract_moderators` (16) identities. The owner may always moderate and need not be named; it may be named, and then counts toward the 16. Naming it changes nothing about authority. Every identity named must exist: the contract create, and the contract update for the identities it adds, look each one up in state and refuse, paid, with `ContractModeratorIdentityNotFoundError` (41110). A moderator that does not exist can never sign, so naming one is a mistake, and catching it once at the declaration is cheaper than guarding every later reader of the set. Moderators act alone: there is no threshold and no vote. Neither the owner nor a moderator can be banned or suspended. An entry one of them already carries can still be lifted: a contract update may name as moderator an identity that is banned or suspended, the entry keeps binding it, and the owner or another moderator unbans or unsuspends it without demoting it first (never the identity itself: a moderation cannot target its own signer). +1. **The contract declares it.** `DataContractConfigV2::moderation` is an optional `ContractModerationConfig { banlist, suspensions, warnings, moderators }`. At least one list must be kept, unless a document type lets the moderators delete its documents (see Deleting Documents below), in which case the declaration may keep none. Which lists a contract keeps is decided when it is created and never changes: an update can not make an unmoderated contract moderated, turn a second list on, or turn a list off (`validate_config_update` 2, `DataContractConfigUpdateError`). Whoever writes documents under a contract knows from its first version whether and how they can be barred from it, which matters most where documents are assets: a ban also stops transfers and sales. And a list that is on may hold entries. Only the moderators may be changed by an update. +2. **The owner moderates, alone or with a fixed set, or an elected team does.** `ContractModerators` is `ContractOwner`, `AppointedModerators(set)` or `Elected(declaration)`; the third is its own section below. With the first two, a set is at most `SystemLimits::max_contract_moderators` (16) identities. The owner may always moderate and need not be named; it may be named, and then counts toward the 16. Naming it changes nothing about authority. Every identity named must exist: the contract create, and the contract update for the identities it adds, look each one up in state and refuse, paid, with `ContractModeratorIdentityNotFoundError` (41110). A moderator that does not exist can never sign, so naming one is a mistake, and catching it once at the declaration is cheaper than guarding every later reader of the set. Moderators act alone: there is no threshold and no vote. Neither the owner nor a moderator can be banned or suspended. An entry one of them already carries can still be lifted: a contract update may name as moderator an identity that is banned or suspended, the entry keeps binding it, and the owner or another moderator unbans or unsuspends it without demoting it first (never the identity itself: a moderation cannot target its own signer). 3. **A ban lasts until an unban; a suspension lasts until a block time.** A suspension names the block time, in milliseconds, at which it lapses. A lapsed suspension is not deleted by the clock: the first document transition of the identity that runs at or after that time executes normally and, in the same execution, sweeps the stale entry. An explicit unsuspend deletes it too. A ban supersedes a suspension: banning a suspended identity removes the suspension, and suspending a banned identity is refused. +4. **A warning is a record, not a bar.** A warn adds one warning, the block time and a reason, to the identity's entry on the warning list; the entry holds every warning the identity carries, oldest first, up to `SystemLimits::max_contract_warnings_per_identity` (16), past which a warn is refused until a clearWarnings deletes the entry whole. The document gate does not read the warning list, only the banlist and the suspension list (the warning list is read by a warn and a clearing alone); a ban leaves the warnings alone (they say how it came to that), and a banned or suspended identity may still be warned. What a warning is for is the client: an application shows the identity its warnings, and the moderators the count, before a suspension or a ban. ## The Types @@ -21,32 +22,39 @@ pub struct ContractModerationConfig { pub banlist: bool, pub suspensions: bool, pub moderators: ContractModerators, + pub warnings: bool, // last: a declaration stored before the warning list existed does not decode } pub enum ContractModerators { ContractOwner, AppointedModerators(BTreeSet), + Elected(Box), // see Elected Moderation below } -pub enum ContractModerationList { Banlist, Suspensions } +pub enum ContractModerationList { Banlist, Suspensions, Warnings } pub struct ContractModerationReason { pub code: Option, pub text: String, + pub documents: Vec, // { document_type_name, document_id } } pub struct ContractBan { pub reason: ContractModerationReason } pub struct ContractSuspension { pub until: TimestampMillis, pub reason: ContractModerationReason } +pub struct ContractWarning { pub warned_at: TimestampMillis, pub reason: ContractModerationReason } pub struct ContractModerationStatus { pub ban: Option, pub suspension: Option, + pub warnings: Vec, // oldest first, empty when none } ``` -Every ban and every suspension carries a **reason**, stored with the entry so that whoever reads the list reads why. The `text` is free: at most `SystemLimits::max_contract_moderation_reason_length` (1024) bytes of UTF-8, possibly empty. The `code` is reserved for the ban codes a contract may declare in a later protocol version. No contract declares any today, so it is expected to be `None`; a moderator may still write any `u16` there, and nothing checks it against anything. The moderator pays the storage of the reason, byte for byte, and gets it back when the entry is removed. +`ContractModerationConfig::lists` names the lists a contract keeps, in tree key order (banlist, suspension list, warning list); `barring_lists` the ones whose entries bar, which is every list but the warning list: what the document gate reads and what a ban's proof covers. -The config is `DataContractConfig::V2`, a new variant of the config's own bincode enum inside the contract. The config version follows the platform version, as V1 did from protocol version 9: from protocol version 14 every new contract carries a V2 config, moderated or not (`CONTRACT_VERSIONS_V6` sets both `max_version` and `default_current_version` to 2), and an existing V1 contract moves to V2 with its next update. `config_valid_for_platform_version` lowers a V2 only where the platform version does not admit it, never because of what it declares. Lowering would drop a moderation declaration, and moderation can never be turned on later, so that case is refused rather than dropped: serializing a contract whose config declares moderation at a platform version below 14 (`ensure_admitted_by_platform_version`), and parsing a config value with a `moderation` key at such a version, both fail with `ProtocolError::NotSupported`. For the same reason the declaration refuses an unknown key instead of skipping it (`deny_unknown_fields`, and the moderators' `$type` map likewise): a misspelled `suspensions` would otherwise leave the contract without the list for good. A contract create or update carrying a V2 config is active from protocol version 14 only (`StateTransition::active_version_range`): before that a node rejects it at decoding, unpaid, exactly as a binary that cannot decode the V2 discriminant does, so upgraded and older nodes agree on every block before activation. The JSON shape of the moderators is a flat `{"$type": "contractOwner"}` or `{"$type": "appointedModerators", "identities": [...]}`, the style of `AuthorizedActionTakers`. +Every ban, every suspension and every warning carries a **reason**, stored with the entry so that whoever reads the list reads why. The `text` is free: at most `SystemLimits::max_contract_moderation_reason_length` (1024) bytes of UTF-8, possibly empty. The `code` is reserved for the ban codes a contract may declare in a later protocol version. No contract declares any today, so it is expected to be `None`; a moderator may still write any `u16` there, and nothing checks it against anything. A reason may also cite the **documents** it is about, the posts a warning or a ban is for, each by its document type name and its id: at most `SystemLimits::max_contract_moderation_reason_documents` (16), none twice, with a type name a contract could admit (`InvalidContractModerationReasonDocumentsError`, 10904, unpaid). Nothing looks them up: a cited document may have been deleted since, by its author or by a moderator whose deletion left a record, or may never have existed, and a client that wants to show it fetches it or its removal record. The moderator pays the storage of the reason, documents included, byte for byte, and gets it back when the entry is removed. + +The config is `DataContractConfig::V2`, a new variant of the config's own bincode enum inside the contract. The config version follows the platform version, as V1 did from protocol version 9: from protocol version 14 every new contract carries a V2 config, moderated or not (`CONTRACT_VERSIONS_V6` sets both `max_version` and `default_current_version` to 2), and an existing V1 contract moves to V2 with its next update. `config_valid_for_platform_version` lowers a V2 only where the platform version does not admit it, never because of what it declares. Lowering would drop a moderation declaration, and moderation can never be turned on later, so that case is refused rather than dropped: serializing a contract whose config declares moderation at a platform version below 14 (`ensure_admitted_by_platform_version`), and parsing a config value with a `moderation` key at such a version, both fail with `ProtocolError::NotSupported`. For the same reason the declaration refuses an unknown key instead of skipping it (`deny_unknown_fields`, and the moderators' `$type` map likewise): a misspelled `suspensions` would otherwise leave the contract without the list for good. A contract create or update carrying a V2 config is active from protocol version 14 only (`StateTransition::active_version_range`): before that a node rejects it at decoding, unpaid, exactly as a binary that cannot decode the V2 discriminant does, so upgraded and older nodes agree on every block before activation. The JSON shape of the moderators is a flat `{"$type": "contractOwner"}`, `{"$type": "appointedModerators", "identities": [...]}` or `{"$type": "elected", ...}` with the declaration's keys beside its `$type`, the style of `AuthorizedActionTakers`. ## The Transition @@ -68,6 +76,10 @@ pub enum ContractUserModerationAction { Unban { identity_id }, Suspend { identity_id, until: TimestampMillis, reason: ContractModerationReason }, Unsuspend { identity_id }, + Warn { identity_id, reason: ContractModerationReason }, + ClearWarnings { identity_id }, + DeleteDocument { document_type_name, document_id, reason: ContractModerationReason }, + RestoreDocument { document_type_name, document: BinaryData }, } ``` @@ -77,11 +89,11 @@ It is signed like a contract update: a CRITICAL authentication key without contr | Tier | What | Codes | |---|---|---| -| Basic structure (unpaid) | the target is not the signer; a suspension ends at or before `SystemLimits::max_contract_suspension_until` (2^53 - 1 ms, the largest value JSON clients read exactly); the text of a ban's or a suspension's reason is at most `SystemLimits::max_contract_moderation_reason_length` bytes (its code is not checked) | 10901, 10700, 10903 | +| Basic structure (unpaid) | the target is not the signer; a suspension ends at or before `SystemLimits::max_contract_suspension_until` (2^53 - 1 ms, the largest value JSON clients read exactly); the text of a ban's, a suspension's or a warning's reason is at most `SystemLimits::max_contract_moderation_reason_length` bytes (its code is not checked) and the documents it cites are at most `max_contract_moderation_reason_documents`, well named and distinct | 10901, 10700, 10903, 10904 | | Signature and nonce | CRITICAL key, contract nonce | existing | -| Transform (state, paid) | the contract exists; it keeps the list the action edits; the signer is the owner or a moderator; the target of a ban or a suspend is neither; the target exists; the action fits the target's status | 41100-41106, 41109 | +| Transform (state, paid) | the contract exists; it keeps the list the action edits; the signer is the owner or a moderator; the target of a ban, a suspend or a warn is neither; the target exists; the action fits the target's status | 41100-41106, 41109, 41117, 41118 | -The transform reads the contract and the target's status and refuses, paid, by bumping the signer's contract nonce. The action carries the status as read, so Drive edits the lists without reading them again, and the mempool, which transforms without a state validation stage, refuses with the same codes as a block. A suspend must end after the block time (41106). Suspending an identity that carries a suspension replaces it, longer or shorter. +The transform reads the contract and the target's status and refuses, paid, by bumping the signer's contract nonce. The action carries the status as read (and, for a warn, the warnings the target carries and the block time the new one is stamped with), so Drive edits the lists without reading them again, and the mempool, which transforms without a state validation stage, refuses with the same codes as a block. A suspend must end after the block time (41106). Suspending an identity that carries a suspension replaces it, longer or shorter. A warn is refused once the target carries `max_contract_warnings_per_identity` warnings (`ContractUserWarningLimitReachedError`, 41118), and a clearWarnings of an identity that carries none with `ContractUserNotWarnedError` (41117). A ban and a suspend read the barring lists the contract keeps; a warn and a clearing read the warning list alone. ### The Document Gate @@ -95,7 +107,7 @@ Deletions (`Delete` and `IndexOnlyDelete`) are never refused: a barred identity ### The Errors -Basic, in their own band (10900-10949): `InvalidContractModerationConfigError` (10900), `ContractModerationSelfTargetError` (10901), `ContractModerationReasonTooLongError` (10903; 10902 is reserved). State, in their own sub-band: `ContractModerationNotEnabledError` (41100), `IdentityNotContractModeratorError` (41101), `ContractModerationTargetNotAllowedError` (41102), `ContractUserAlreadyBannedError` (41103), `ContractUserNotBannedError` (41104), `ContractUserNotSuspendedError` (41105), `ContractSuspensionNotInFutureError` (41106), `ContractUserBannedError` (41107), `ContractUserSuspendedError` (41108), `ContractModerationTargetNotFoundError` (41109), `ContractModeratorIdentityNotFoundError` (41110, from the contract create and update, not from the moderation transition), `ContractModerationCounterpartyBarredError` (41114, from the document gate; 41111 to 41113 are reserved). A contract update that turns a list on or off is refused with the existing `DataContractConfigUpdateError` (40002). +Basic, in their own band (10900-10949): `InvalidContractModerationConfigError` (10900), `ContractModerationSelfTargetError` (10901), `ContractModerationReasonTooLongError` (10903; 10902 is reserved), `InvalidContractModerationReasonDocumentsError` (10904). State, in their own sub-band: `ContractModerationNotEnabledError` (41100), `IdentityNotContractModeratorError` (41101), `ContractModerationTargetNotAllowedError` (41102), `ContractUserAlreadyBannedError` (41103), `ContractUserNotBannedError` (41104), `ContractUserNotSuspendedError` (41105), `ContractSuspensionNotInFutureError` (41106), `ContractUserBannedError` (41107), `ContractUserSuspendedError` (41108), `ContractModerationTargetNotFoundError` (41109), `ContractModeratorIdentityNotFoundError` (41110, from the contract create and update, not from the moderation transition), `ContractModerationCounterpartyBarredError` (41114, from the document gate; 41111 to 41113 are reserved), `ContractUserNotWarnedError` (41117), `ContractUserWarningLimitReachedError` (41118). A contract update that turns a list on or off is refused with the existing `DataContractConfigUpdateError` (40002). Elected moderation has its own band (41200-41299): `ContractModeratedDocumentTypeNotYetUsableError` (41200). ## Deleting Documents @@ -119,7 +131,7 @@ A banlist keeps an identity out; it does not take down what the identity already - **It may come with a window.** `canBeDeletedByModeratorsFor: 86400` lets the moderators delete a document for that many seconds after its last modification (`$updatedAt`), and no longer: once block time is past `$updatedAt` plus the window the document is settled, and no moderator deletes it any more, the contract owner included (`DocumentModerationWindowElapsedError`, 41116). At exactly `$updatedAt` plus the window the deletion still passes; the document's own owner still deletes it as `canBeDeleted` allows. Moderation acts on what was just written; it does not reach back into what has stood unchallenged. A replace or a price update moves `$updatedAt`, so new content opens the window again; a transfer or a purchase does not. The window needs the flag, at least one second, and the clock in the type's `required`, so that every document carries it: `$updatedAt`, or for a type with `documentsMutable: false` `$createdAt` instead, since nothing modifies such a document after its creation. A type whose documents can be replaced must require `$updatedAt`: measured from creation alone, an author could wait the window out and then rewrite a post into something no moderator can remove. The transform reads `$updatedAt` and falls back to `$createdAt`; like the flag it is fixed with the type, in both directions (a longer one would reopen documents that had settled). It is in seconds, as the other durations of a document type are, and it says nothing about a document's own owner, whose deletion `canBeDeleted` rules at any age. - **For references it counts as deletable.** A `permanentDocument` reference refuses such a type (`ReferencedDocumentTypeDeletableError`, 40122) whatever its `canBeDeleted` says, so the guarantee that a validated permanent reference never dangles holds; a `deletableDocument` reference accepts it, `canBeDeleted: false` included. Both checks, at contract registration and at document write, read "deletable" as deletable by anyone. A like or a reply that points at a post moderators can remove therefore declares `refersTo: deletableDocument`: the join reports a removed post as a missing id, and the removal record says why it is missing. -The deletion is a fifth action of the same transition: +The deletion is the seventh action of the same transition: ```rust ContractUserModerationAction::DeleteDocument { @@ -139,13 +151,34 @@ pub struct ContractDocumentRemoval { pub moderator_id: Identifier, pub reason: ContractModerationReason, pub removed_at: TimestampMillis, // the block time + pub document_hash: [u8; 32], // sha256d of the document as serialized under its type + pub restoration: Option, // { moderator_id, restored_at } once restored } ``` -The record is what is left to say that a document was removed, not lost: a client holding a dangling id (a reply whose parent is gone) can prove who removed it, whose it was, when and why. The moderator pays for it, reason included, and nothing ever deletes it. A record is final: from protocol version 14 a document id commits to the nonce of its create transition and is produced at most once, so the removed id can not be created again, no second removal can replace the record, and the record and a live document of that id never coexist. +The record is what is left to say that a document was removed, not lost: a client holding a dangling id (a reply whose parent is gone) can prove who removed it, whose it was, when, why and what it was. The moderator pays for it, reason included, and nothing ever deletes it. From protocol version 14 a document id commits to the nonce of its create transition and is produced at most once, so the removed id can not be created again by anyone: the only way the document comes back is a moderator's restore (see Restoring Documents below), which marks the record restored and leaves it in place. A record and a live document of the same id therefore coexist exactly when the record is marked restored, and a restored document deleted again gets a fresh record in place of the marked one. + +The hash is of the document as `Document::serialize` writes it under its document type and the protocol version of the deleting block, computed from the document as read, not from the bytes as stored: a document stored under an earlier version of its type or of the serialization would never re-serialize to its stored bytes, and a client keeping the document rather than the bytes could never match them. A client that may have to undo a deletion keeps the document it fetched before deleting; serialized under the same contract it gives the same bytes. **The deleted document's owner gets no storage refund.** The batch carries `ContractModerationOperationType::ForfeitStorageRefunds`, a marker that writes nothing, and `apply_drive_operations` generation 1 turns every removal such a batch attributes to an identity into a removal attributed to nobody: the bytes still leave the system (`FeeResult::removed_bytes_from_system`), no refund is computed, and the credits stay in the storage pools they were distributed to when the document was written. An estimate carries no refund to begin with (refunds come from the flags of what is really removed), so the mempool's fee check is the same with or without the forfeiture. Forfeiting the whole batch is exact: a moderator's deletion removes the document and nothing else, since its record is written once and the nonce it bumps keeps its size. An author who deletes the same document with an ordinary document transition is refunded as always. +## Restoring Documents + +A deletion can be undone. The eighth action of the same transition brings a deleted document back, as it was: + +```rust +ContractUserModerationAction::RestoreDocument { + document_type_name: String, + document: BinaryData, // the document serialized under its type, as it was when deleted +} +``` + +It names no identity and no id: the document is inside the bytes, and its id and owner with it. The transform checks, in order and each refusal paid: the document type exists (10406) and carries `canBeDeletedByModerators` (41115, only such a type keeps records), the signer is the owner or a moderator (41101, whoever deleted), the bytes decode under the type (a basic decoding error, never an execution error: the bytes are the signer's), the document has a removal record (`ContractDocumentRemovalNotFoundError`, 41119) that is not marked restored (`ContractDocumentAlreadyRestoredError`, 41122: the document is live), block time is within `SystemLimits::contract_document_restore_window_ms` of the removal, a week, the last millisecond included (`DocumentRestoreWindowElapsedError`, 41120), the bytes hash to what the record holds (`DocumentRestoreHashMismatchError`, 41121: the document as it was, not an edit of it), and no other document of the type holds a value of one of its unique indexes (`DuplicateUniqueIndexError`, 40105: the value was free while the document was gone, and someone may have taken it). The record read is billed; the hash pins everything else, so the owner, the revision, the timestamps, the references and the schema are not checked again. Whether the document's owner is banned is not asked either: a ban stops writes, it never removed documents. + +The action carries the contract, the decoded document and the record marked restored by the signer at the block's time, so Drive puts the document back through the ordinary insert (`DocumentOperationType::AddDocument`, every index and aggregate of the type included) and replaces the record in place, without reading again. The restored document's storage flags name its owner, as they did before the deletion: the signer pays for the bytes, and the refund of a later deletion is the owner's, as it always was. Nothing the document type prices is charged, neither its creation token cost nor its `actionFees` creation fee, and no fee agreement is asked: a moderator undoes a moderation, it does not create content. The restore is proved by the record, now marked restored and holding the hash of the bytes the transition carried; the verifier reads the document's id out of those bytes under the contract's document type, so it needs the contract, which the SDKs register with their context provider before broadcasting. + +Two consequences follow from the document coming back byte for byte. Its `$updatedAt` does not move, so a type with `canBeDeletedByModeratorsFor` may have settled it while it was gone: the moderators can not delete it again until its author edits it, though the author still can. And a type with a contested index can not carry `canBeDeletedByModerators` at all (`InvalidContractStructure`, 10231): a contested index only takes a document through a vote, which no restore can go through, so such a deletion could never be undone. The bytes are decoded under the type as the contract holds it when the restore is processed, and the hash is of the document serialized under the type as it was when the document was deleted: a contract update that changes the type's layout inside the window (a property added, say) leaves the record unrestorable, since bytes that decode under the new layout cannot hash to what the record holds. A client therefore serializes under the contract's current version, as Drive does, and keeps the document rather than the bytes. + ## Storage ```text @@ -155,35 +188,38 @@ The record is what is left to say that a document was removed, not lost: a clien ├── [1] documents └── [2] other ├── [16] document removals -> -> - │ -> Item(owner id ‖ moderator id ‖ removed at ‖ reason) (with such a document type) + │ -> Item(owner id ‖ moderator id ‖ removed at ‖ document hash ‖ restored? [‖ restored by ‖ restored at] ‖ reason) (with such a document type) ├── [64] contract version item (every contract) ├── [128] banlist -> -> Item(reason) (when declared) - └── [192] suspensions -> -> Item(until ‖ reason) (when declared) + ├── [192] suspensions -> -> Item(until ‖ reason) (when declared) + └── [224] warnings -> -> Item((warned at ‖ len ‖ reason)+) (when declared) ``` -`until` is a u64 of block time in milliseconds, big-endian. A reason is a tag byte (`0`: no code, `1`: a code), the code as a big-endian u16 when tagged, then the text as UTF-8 up to the end of the value, so an entry with an empty reason and no code costs one byte more than the bare entry would. A value without the tag byte is an entry written before entries carried a reason and reads as the empty reason. A document removal is the document owner's id, the moderator's id, `removed at` as a u64 of block time in milliseconds, big-endian, then the reason the same way (`types::encode_document_removal`). +`until` is a u64 of block time in milliseconds, big-endian. A reason is a tag byte (bit 0: a code follows, bit 1: documents follow), the code as a big-endian u16 when tagged, then when tagged the documents it cites (their count in one byte, then each one's type name as a length byte and the name, and its 32-byte id), then the text as UTF-8 up to the end of the value, so an entry with an empty reason and no code costs one byte more than the bare entry would. A value without the tag byte is an entry written before entries carried a reason and reads as the empty reason; a tag without bit 1 is a reason from before documents could be cited. A warning list entry holds every warning the identity carries, oldest first, each `warned at` as a u64 of block time in milliseconds, big-endian, the length of its encoded reason as a big-endian u16 (the prefix that lets one value hold several reasons, each of which would otherwise run to the end), then the reason as above (`types::encode_warnings`). A warn rewrites the entry one warning longer; a clearWarnings deletes it, so a stored entry never holds fewer than one warning. A document removal is the document owner's id, the moderator's id, `removed at` as a u64 of block time in milliseconds, big-endian, the 32 bytes of the document hash, a tag byte (`0`: not restored, `1`: restored) followed when restored by the restoring moderator's id and `restored at` as a u64, big-endian, then the reason the same way (`types::encode_document_removal`): 105 bytes before the reason, 145 once restored. + +A removal record is replaced in place, as a suspension is, by the restore that marks it and by the deletion of a restored document, which writes a fresh record over the marked one; the deletion transform reads the record, billed, to know which of the two it writes, and a record that is not marked restored beside a live document is a state no transition produces. The replacement's flags follow GroveDB's flag merge like a suspension's: the restore adds forty bytes and passes the record, with the refund of its removal, to the restoring moderator, who pays for them; the fresh record of a second deletion is shorter and stays with whoever held it. A fee estimate prices a replacement as a fresh insert of the whole record, for the reason a suspension's does, and the records a write walks past are estimated as restored, the larger shape. The document removals tree exists exactly when the contract has a document type that carries `canBeDeletedByModerators`: a contract without one keeps the other tree, and the shape, it would have had. One subtree per such document type is created with the type, by `insert_contract` generation 2 or by `update_contract` generation 2 for a type an update adds, and the tree above them with the first: whether it is there is read off the stored contract, since an existing type never changes the keyword, so the update needs no read. Nothing is created lazily by the first removal. The key sorts below `128`, as a key added later should: a contract that keeps both lists, the one whose other tree then holds four keys, still has the banlist on top. With fewer keys the version item is on top, and the list one level down. -The contract's own subtree holds three keys whatever the contract keeps, so its Merk keeps `1`, the documents, on top: every document proof and write goes through that key, and a fourth key beside it would have pushed it one level down (a Merk built from one sorted batch roots at the middle key). Everything else a contract keeps goes into `2`, its **other tree**, which protocol version 14 introduces together with the version item. Inside, the keys are spread like the root tree's, so the tree stays balanced as it fills and the most read entry sits on top: the banlist at `128`, read by every document transition on a moderated contract, the version item at `64`, the suspension list at `192`. A key added later should sort below `128` to keep the banlist on top when four keys are created at once. +The contract's own subtree holds three keys whatever the contract keeps, so its Merk keeps `1`, the documents, on top: every document proof and write goes through that key, and a fourth key beside it would have pushed it one level down (a Merk built from one sorted batch roots at the middle key). Everything else a contract keeps goes into `2`, its **other tree**, which protocol version 14 introduces together with the version item. Inside, the keys are spread like the root tree's, so the tree stays balanced as it fills and the most read entry sits on top: the banlist at `128`, read by every document transition on a moderated contract, the version item at `64`, the suspension list at `192`, the warning list at `224`. A Merk built from one sorted batch roots at the middle key, the upper middle of an even count, so a key added later goes where it keeps `128` the median of the keys created together in the likely combinations: the warning list, which no document transition reads, sits above `192`, which leaves the banlist on top for a contract keeping the banlist and a warning list, with or without the removal records tree at `16`, and for one keeping all three lists with that tree; a contract keeping all three lists and no removal records has the suspension list on top and the banlist one level down. The other tree is written by every contract insertion, and by the migration on the first block of protocol version 14 for the contracts stored before it. A contract update finds it there, so its fee estimate writes none; applied, the update reads key `2` once, billed, rather than fail inside a block: a tree is left alone (it may hold the lists), a missing one is written. The 4.2 betas kept the version item itself at key `2`, before the other tree existed: an update of a contract that still holds that item puts the tree in its place and the item under it (`add_contract_to_storage` generation 1). Until such a contract is updated, the unproved `getDataContractsLatestVersions` reads its version from that item, and the proved form shows no version item for it. The list trees are created by `insert_contract` generation 2 for a contract that declares them, and by nothing else: the lists are fixed at creation, so a contract update creates none and leaves the existing ones and their entries alone, and no tree is made lazily by the first ban. An entry's storage flags name the moderator that wrote it, so the storage refund of its deletion goes to that moderator whichever transition deletes it: the explicit unban or unsuspend, the ban over a suspension, or the document transition that sweeps a lapsed suspension. The sweep's processing fee is charged to the batch signer. -The writers, readers and provers live in `packages/rs-drive/src/drive/contract/moderation/`, versioned by `DriveContractModerationMethodVersions`. A suspend that replaces an entry is a `batch_replace`, because two operations on one key would fail the batch. The replacement brings its own reason, so the entry may change size: a longer replacement merges the flags as a document that changes hands does, the moderator that replaced it paying for the bytes it added and becoming the entry's owner, refunded when it is removed; a shorter or an equally long one stays the first moderator's, who is refunded the removed bytes at once and the rest on removal. A fee estimate prices a replacement as a fresh insert of the whole entry, because GroveDB's average-case replace assumes an item keeps its size and would price no storage for a longer reason; the entries a write walks past, and the one a delete removes, are estimated at a typical reason (128 bytes of text), not at the longest. +The writers, readers and provers live in `packages/rs-drive/src/drive/contract/moderation/`, versioned by `DriveContractModerationMethodVersions`. A suspend that replaces an entry is a `batch_replace`, because two operations on one key would fail the batch; so is a warn on an identity that already carries warnings, which rewrites the entry whole with the new warning last. The entry is then longer, so it passes, with the refund of its removal, to the moderator that warned last, who pays for the bytes the warning added; the same merge a longer suspension replacement goes through. The replacement brings its own reason, so the entry may change size: a longer replacement merges the flags as a document that changes hands does, the moderator that replaced it paying for the bytes it added and becoming the entry's owner, refunded when it is removed; a shorter or an equally long one stays the first moderator's, who is refunded the removed bytes at once and the rest on removal. A fee estimate prices a replacement as a fresh insert of the whole entry, because GroveDB's average-case replace assumes an item keeps its size and would price no storage for a longer reason; the entries a write walks past, and the one a delete removes, are estimated at a typical reason (128 bytes of text), not at the longest. ## Reading and Proving -`fetch_contract_moderation_status(contract, identity, lists)` reads the identity's entry on each list named, and the verifier of its proof rebuilds the same merged path query from the same lists. The lists are the ones the contract's config declares; an undeclared list has no tree and cannot be queried, so a status query names the lists it wants and the node refuses one the contract does not keep. `fetch_contract_moderation_entries` pages one list in identity id order, bounded by the platform version's `max_returned_elements` (the default page size too, and the number the proof verifier assumes when a request names no limit), with the last identity as the cursor. A page shorter than its limit is the last one and carries no cursor. +`fetch_contract_moderation_status(contract, identity, lists)` reads the identity's entry on each list named, the warning list included, and the verifier of its proof rebuilds the same merged path query from the same lists. The lists are the ones the contract's config declares; an undeclared list has no tree and cannot be queried, so a status query names the lists it wants and the node refuses one the contract does not keep. `fetch_contract_moderation_entries` pages one list in identity id order, bounded by the platform version's `max_returned_elements` (the default page size too, and the number the proof verifier assumes when a request names no limit), with the last identity as the cursor. A page shorter than its limit is the last one and carries no cursor. ### The DAPI Queries - `getContractModerationStatus(contract_id, identity_id, lists, prove)`: the identity's status on the lists named. -- `getContractModerationEntries(contract_id, list, start_after, limit, prove)`: one page of a list. -- `getContractDocumentRemovals(contract_id, document_type_name, document_ids | page, prove)`: the records of the documents moderators deleted, within one document type that carries `canBeDeletedByModerators` (no other keeps records, so the node refuses any other). By document ids, up to `max_returned_elements` of them and none twice: an id with no record is left out of the response, and proved absent by a proof. Or one page in document id order, with the last document id as the cursor. `Drive::verify_contract_document_removals` rebuilds the path query from the same request. +- `getContractModerationEntries(contract_id, list, start_after, limit, prove)`: one page of a list. An entry of the warning list carries every warning of the identity, and its `reason` is the latest warning's. +- `getContractDocumentRemovals(contract_id, document_type_name, document_ids | page, prove)`: the records of the documents moderators deleted, within one document type that carries `canBeDeletedByModerators` (no other keeps records, so the node refuses any other). By document ids, up to `max_returned_elements` of them and none twice: an id with no record is left out of the response, and proved absent by a proof. Or one page in document id order, with the last document id as the cursor. Each record carries the document hash and, once restored, who restored it and when. `Drive::verify_contract_document_removals` rebuilds the path query from the same request. -A status query answers for the lists it names and no others: `Drive::verify_contract_moderation_status` and the SDK result both return `ContractModerationListStatuses`, one `ContractModerationListStatus` per list queried, so a list that was not read is absent rather than reported as empty (`banned()` is `None` unless the banlist was queried). `ContractModerationStatusQuery::for_contract` names every list the contract keeps; the wasm-sdk does the same, fetching the contract, when the query names no list. Both have `Fetch` and `FetchUnproved` impls in the Rust SDK (`platform::contract_moderation`), wasm-sdk functions and `contracts.moderationStatus` / `contracts.moderationEntries` on the JavaScript SDK. The proof of a moderation transition's execution covers the lists the moderation touched and is classified as affected state: an earlier or later moderation leaving the same entries verifies just the same. A ban does two things, adds the ban and removes a suspension, so its proof covers every list the contract keeps (the banlist entry present, the suspension absent), which the prover and the verifier both read from the contract's config (so the SDKs fetch and cache the contract before broadcasting a ban, as they do for the contracts a document batch touches); an unban, a suspend and an unsuspend prove the one entry they edit. The result, `VerifiedContractModerationListStatuses`, holds one `ContractModerationListStatus` per list proved, never a full status: a list that was not proved is left unknown rather than reported as empty. An identity whose unsuspend was just proved may be banned; the status query answers that. +A status query answers for the lists it names and no others: `Drive::verify_contract_moderation_status` and the SDK result both return `ContractModerationListStatuses`, one `ContractModerationListStatus` per list queried, so a list that was not read is absent rather than reported as empty (`banned()` is `None` unless the banlist was queried). `ContractModerationStatusQuery::for_contract` names every list the contract keeps; the wasm-sdk does the same, fetching the contract, when the query names no list. Both have `Fetch` and `FetchUnproved` impls in the Rust SDK (`platform::contract_moderation`), wasm-sdk functions and `contracts.moderationStatus` / `contracts.moderationEntries` on the JavaScript SDK. The proof of a moderation transition's execution covers the lists the moderation touched and is classified as affected state: an earlier or later moderation leaving the same entries verifies just the same. A ban does two things, adds the ban and removes a suspension, so its proof covers every barring list the contract keeps (the banlist entry present, the suspension absent), which the prover and the verifier both read from the contract's config (so the SDKs fetch and cache the contract before broadcasting a ban, as they do for the contracts a document batch touches); an unban, a suspend, an unsuspend, a warn and a clearWarnings prove the one entry they edit. A warn's proof shows the entry with the transition's reason as its last warning (the block time is the block's, which the verifier does not know); a clearing's shows the entry absent. The result, `VerifiedContractModerationListStatuses`, holds one `ContractModerationListStatus` per list proved, never a full status: a list that was not proved is left unknown rather than reported as empty. An identity whose unsuspend was just proved may be banned; the status query answers that. ## Fee Pots and the Claim @@ -229,22 +265,57 @@ A recipient reads the pots to decide whether a claim is worth its gas: the credi The claim's proof is verified against the contract, which names who the pot pays, and the team can change by a contract update. So every client fetches the contract again before a claim instead of trusting a cached copy: `ClaimContractFees` in the Rust SDK, `contractClaimFees` in the wasm-sdk, and the wasm-sdk's generic `broadcastAndWait` for a `ContractFeeClaim` built by hand, which falls back to the cached copy when that fetch fails, because the transition is already broadcast by then. +## Elected Moderation + +A contract may hand the choice of its moderators to the network instead of keeping it: it declares that its moderators are a **team elected by masternodes and evonodes**. Teams will apply with a charter, masternodes will elect one, and the seated team will moderate under its charter, paid from the moderators pot. None of that exists yet. What exists is the declaration, frozen at the contract's creation, and the **interim**: how the contract is moderated until its first team is seated. + +```rust +pub struct ElectedModerators { + pub join_window: u32, // seconds; 1 day to 4 weeks, 1 week by default + pub vote_window: u32, // the same + pub challenge_cool_down: u32, // seconds; 2 weeks to 3 years, always declared + pub election_delay: Option, // seconds after creation before the first charter; unbounded, none = at once + pub moderated_document_types: BTreeMap>, // per type: DeleteDocuments, Ban, Suspend, Warn + pub interim: InterimModerators, // ContractOwner, AppointedModerators(set), NotYetUsable, NoModeration + pub owner_protected: bool, // false by default +} +``` + +The declaration lives in `packages/rs-dpp/src/data_contract/config/moderation/elected.rs`. On the wire it is the third `$type` of the moderators, flat: `{"$type": "elected", "challengeCoolDown": 1209600, "moderatedDocumentTypes": {"post": ["ban", "deleteDocuments"]}, "interim": {"$type": "notYetUsable"}}`, with `joinWindow`, `voteWindow`, `electionDelay` and `ownerProtected` optional. Its parts: + +- **The election parameters** are fixed once set (`SystemLimits`: `min_contract_moderation_election_window_seconds` and `max_contract_moderation_election_window_seconds` bound both windows, `min_contract_moderation_challenge_cool_down_seconds` and `max_contract_moderation_challenge_cool_down_seconds` the cool-down). The join window is how long applicants may join an election once the first one applied, the vote window how long masternodes then vote, and the cool-down how long a seated team is safe from a challenge after a seat change. Nothing reads them yet. The **election delay** is the one parameter the contract sets freely: how many seconds after its creation the first charter may be filed against it, the notice the contract gives before its first election can be called. It is optional and unbounded; left out, the election may be called at once. Because the declaration is made at the contract's creation and never changes, the creation is the declaration's own time. The charter contract's `targetContractId` reads it through the `moderation: "electionOpen"` requirement below. +- **The moderated set** is the document types the team moderates, each with the abilities a charter may claim on it: non-empty, each type a document type of the contract, each ability set non-empty and backed by the contract (`ban` needs the banlist, `suspend` the suspension list, `warn` the warning list, `deleteDocuments` the type itself flagged `canBeDeletedByModerators`, so deletions reach only flagged types, within their window). The charter of a team will say how those types are moderated, never which. The lists stay contract-wide: an ability on a type is what a team may do over the documents of that type. The set also bounds the interim block. A charter does not price the moderators part of an action: a type's own `actionFees.moderators` amount is the most a team may charge, a charter charges a share of it (the charter contract's business, not the declaration's), and the owner part stays what the type declares, immutable as before. +- **The interim** says who moderates until a team is seated. `ContractOwner` and `AppointedModerators(set)` are the merged kinds, with their authority, their limit and their existence check (41110 at create): they moderate, they are protected, and they are the team that claims the moderators pot. `NotYetUsable` names nobody: nobody moderates, nobody claims the pot (it accumulates for the team to come, `ContractFeeClaimNotAllowedError` for everyone), and the moderated document types can not be used. A contract that never attracts a team keeps those types unusable for good; the other types work as on an unmoderated contract. `NoModeration` names nobody too, with the moderated types usable meanwhile: nobody moderates and nobody claims the pot, and every type works as on an unmoderated contract until a team is seated. +- **The owner flag** says whether the contract owner is protected from the team once one is seated, as the owner and the moderators of the merged kinds are (41102 on a ban, a suspension or a deletion of its documents). Not protected by default. During the interim the owner is protected whenever it moderates, flag or not: `ContractModerationConfig::protects` is what the moderation transition checks, and it is `may_moderate` or the flag for the owner. + +**Validation.** `validate_moderation_config` v0 checks the declaration with the rest of the moderation config, from the raw document schemas of the create or update transition, and refuses with `InvalidContractModerationConfigError` (10900, unpaid): a window or the cool-down outside its bounds, an empty moderated set, a moderated type the contract does not have, an empty ability set or an ability the type can not back, and an empty or oversized interim set. The create then checks that every interim moderator exists (41110), as it does for an appointed set. + +**The update.** `validate_config_update` 2 refuses, with `DataContractConfigUpdateError` (40002), every change to the declaration, the interim included, and entering or leaving elected moderation. A contract that declares elected moderation is elected for good, and one that did not can not become so; the merged kinds still swap their moderators freely. An update may add document types; the declaration keeps naming the ones it named. + +**The interim block.** The batch transformer's `contract_moderation_gate` v0 runs it before the lists: on an elected contract whose interim is `NotYetUsable`, every document transition of a moderated document type, deletions included (nothing of those types was ever written), is refused, paid, with `ContractModeratedDocumentTypeNotYetUsableError` (41200) and its contract nonce bump, in a block and in the mempool. The lists are read only for the transitions on the other types, and not at all when nothing is left. The interim moderators of the other two kinds moderate through the same transition, the same gate and the same claim as the merged kinds; a moderation transition against a `NotYetUsable` contract fails as by a non-moderator (41101). + +**Referencing an elected contract.** A document type that must point at a contract of this kind says so in its reference: `"refersTo": { "type": "contract", "contractRequirements": { "moderation": "elected" } }`. `contractRequirements` holds what the referenced contract must declare beyond existing, each key an aspect of the contract with a closed set of values or a bound: `moderation: "elected"`, or `moderation: "electionOpen"`, which also requires the contract's own election delay to have passed since its creation, or the contract to declare none (the delay between a contract's creation and the first charter against it, so a team cannot be seated before anyone has seen the contract, set by each contract for itself). Both have a user in the charter contract: a charter proposal only needs the target to be `elected`, so teams can form during the notice, and the charter that opens the contest needs its election `electionOpen`; `minimumAgeSeconds`, a number of seconds the reference fixes, which requires the contract's recorded creation time to be at least that far before the block time of the write; `minimumSecondsSinceUpdate`, the same of the later of the contract's creation and last update times (any update restarts the clock; an elected declaration can not be added by an update, so this one is for other uses than the charter); `owner`, `"self"` requiring the referenced contract to be owned by the writer of the referring document (its `$ownerId`, a write gate like the `$ownerId` property agreement of a document reference) and `"other"` by anyone else (so a charter may forbid an owner from chartering its own team); `readonly: true`, requiring the referenced contract's config to be read-only, one that can never be updated again (which makes `minimumSecondsSinceUpdate` moot for the same target); `keepsHistory: true`, requiring its config to keep history (only `true` is declarable for either flag); and `ownerProtected`, requiring the contract's elected moderation declaration to protect the owner from the team (`true`) or to leave it unprotected (`false`), which implies elected moderation without the schema having to say so, a contract without an elected declaration meeting neither value. A contract created before contracts recorded their creation time never meets a duration, its own election delay included. Consensus checks them when the referring document is written, against the contract it has already fetched for the existence check and the write itself (its owner and block time), so they cost no further read; a contract that exists but does not meet a requirement refuses the write, paid, with `ReferencedContractRequirementNotMetError` (40135) naming the requirement, where a contract that does not exist is still 40120. A changed `contractRequirements` is an incompatible schema change on update, like the rest of a `refersTo`. The charter system contract's `targetContractId` is the first user. + +**What comes next.** The charter system contract, applications and the election (new vote poll kinds), the seated team under the contract with its per-ability powers, charter-priced moderators amounts within the maximums, and challenges and amendments. Issue #4865 holds the design. + ## Versioning Touchpoints -All in place for protocol version 14: `CONTRACT_VERSIONS_V6` makes config V2 the config of every new contract (`max_version` and `default_current_version` 2) and `validate_config_update` 2; `STATE_TRANSITION_SERIALIZATION_VERSIONS_V3` and `DRIVE_ABCI_VALIDATION_VERSIONS_V10` carry the transition's slots and `batch_state_transition.contract_moderation_gate`, and the contract update's basic structure moves to 2 to validate the declaration; `DRIVE_CONTRACT_METHOD_VERSIONS_V4` bumps `insert_contract` to 2 and adds the `moderation` table (its `update_contract` 2 belongs to token distribution and does nothing for moderation); `DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4` adds the converter slot and bumps `documents_batch_transition` to 1 for the sweep; `DRIVE_VERIFY_METHOD_VERSIONS` and `DRIVE_ABCI_QUERY_VERSIONS` gain their moderation tables; `SYSTEM_LIMITS_V4` gains `max_contract_moderators`, `max_contract_suspension_until` and `max_contract_moderation_reason_length`. +All in place for protocol version 14: `CONTRACT_VERSIONS_V6` makes config V2 the config of every new contract (`max_version` and `default_current_version` 2) and `validate_config_update` 2; `STATE_TRANSITION_SERIALIZATION_VERSIONS_V3` and `DRIVE_ABCI_VALIDATION_VERSIONS_V10` carry the transition's slots and `batch_state_transition.contract_moderation_gate`, and the contract update's basic structure moves to 2 to validate the declaration; `DRIVE_CONTRACT_METHOD_VERSIONS_V4` bumps `insert_contract` to 2 and adds the `moderation` table (its `update_contract` 2 belongs to token distribution and does nothing for moderation); `DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4` adds the converter slot and bumps `documents_batch_transition` to 1 for the sweep; `DRIVE_VERIFY_METHOD_VERSIONS` and `DRIVE_ABCI_QUERY_VERSIONS` gain their moderation tables; `SYSTEM_LIMITS_V4` gains `max_contract_moderators`, `max_contract_suspension_until`, `max_contract_moderation_reason_length` and `max_contract_warnings_per_identity`. The warning list adds two slots to `DriveContractModerationMethodVersions` (`add_contract_warning`, `remove_contract_warnings`) and nothing else: the list, the two actions and the two errors join a feature no release contains. The document deletion adds, all for protocol version 14 as well: the `canBeDeletedByModerators` keyword in meta-schema v3 (`CONTRACT_VERSIONS_V6` already selects it); five slots in `DriveContractModerationMethodVersions` and one in the verify and query tables; and `batch_operations.apply_drive_operations = 1` in `DRIVE_VERSION_V9`, the generation that forfeits the refund. The transition's own tables do not move: the action joins a transition no release contains. +Elected moderation moves no table: the declaration is a variant of the same config V2, `validate_moderation_config` v0 and `validate_config_update` 2 take it on in place while protocol version 14 is unreleased, `contract_moderation_gate` v0 runs the interim block, and `SYSTEM_LIMITS_V4` gains the four bounds of the windows and the cool-down. + ## What Is Not There Yet -Deleting indexOnly documents (the action would have to carry the owner and the values), deleting every document of an identity at once, action fees on token transitions, group-based moderators (`AuthorizedActionTakers::Group` through group actions), keys bound to the contract allowed to sign its moderation, ban codes declared by the contract (the reason's `code` is where they will go), further entry metadata such as a timestamp or the moderator's id, and the Swift and Kotlin SDKs. The refusal a barred identity receives (41107, 41108, 41114) does not repeat the reason: the status query does. +Deleting indexOnly documents (the action would have to carry the owner and the values), deleting every document of an identity at once, action fees on token transitions, group-based moderators (`AuthorizedActionTakers::Group` through group actions), keys bound to the contract allowed to sign its moderation, ban codes declared by the contract (the reason's `code` is where they will go), the moderator's id on a ban or a suspension (a warning carries its block time but not who issued it), retracting one warning rather than all, a warning that expires by the clock, a contract-declared strike count that turns warnings into a suspension, the election and the seated team of an elected contract, and the Swift and Kotlin SDKs. The refusal a barred identity receives (41107, 41108, 41114) does not repeat the reason: the status query does. ## Tests - `packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/moderators_delete_tests.rs`: the keyword's rules and the window's (it needs the flag and a clock, `$updatedAt` or for documents that never change `$createdAt`, and its shape is refused on the stored path too); `validate_update/common`: the keyword frozen across updates. - `packages/rs-drive/src/drive/contract/moderation/document_removal_tests.rs`: the trees created with the contract and with a document type an update adds, records written, replaced, read by ids and by page with proofs that verify to the same, the bounds of a read, estimate against applied cost, and a moderator's deletion refunding nobody where the author's own refunds the author. - `packages/rs-drive-abci/src/query/contract_moderation_queries/contract_document_removals`: the query by ids and by page, its proof read back by the verifier, and every request it refuses. -- `packages/rs-dpp/src/data_contract/config/moderation/mod.rs` and `config/methods/validate_update/v2`: the declaration's rules and the update rules. -- `packages/rs-drive/src/drive/contract/moderation/tests.rs`: tree creation on insert, the trees and their entries surviving a contract update, every writer with estimation, status and page proofs, paging, the refund going to the first moderator after another one replaces its suspension, and a status proof over one list saying nothing about the other. -- `packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/contract_moderation_gate/mod.rs`: the gate is silent before protocol version 14 and for an unmoderated contract, and refuses each barred operation of one batch on its own while keeping the deletions. -- `packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs`: the whole pipeline, including the moderators' window (a deletion to the millisecond it ends on, refused one later for the contract owner too while the author's own still passes, reopened by a replace, measured from `$createdAt` on a type that never changes, fixed on update), a moderator deleting a post (record, execution proof, the author's balance unchanged, and the control where the author deletes it and is refunded), every refusal of a deletion, an update adding a document type moderators can delete from, a permanent reference to such a type refused, the mempool refusal, the lapse sweep, the moderator set, every refusal code, the lists staying as the contract was created with them, a barred identity deleting its own documents in a block and in the mempool, a barred identity refused as the recipient of a transfer and as the seller of a purchase, the ban's proof covering the suspension it removed, lifting the entry of an identity an update made moderator, the per-list execution proof, a named owner, a create or an update naming a moderator that does not exist, an update keeping its moderators, and inactivity of the transition and of a moderated contract create or update before protocol version 14. +- `packages/rs-dpp/src/data_contract/config/moderation/mod.rs` and `config/methods/validate_update/v2`: the declaration's rules and the update rules, the elected declaration's among them (every bound, the moderated set, the envelope, the maximums, the interim set, the wire shape, and an update refused for each field and for entering or leaving); `moderation/elected.rs`: what each interim kind allows. +- `packages/rs-drive/src/drive/contract/moderation/tests.rs`: tree creation on insert, the trees and their entries surviving a contract update, every writer with estimation, status and page proofs, paging, the refund going to the first moderator after another one replaces its suspension, a status proof over one list saying nothing about the other, the warning list tree created only when declared, warnings accumulating under the moderator that warned last and cleared with a refund to it, a warn never estimated below its cost up to the fullest entry, and the banlist on top of the other tree with every combination of lists. +- `packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/contract_moderation_gate/mod.rs`: the gate is silent before protocol version 14 and for an unmoderated contract, refuses each barred operation of one batch on its own while keeping the deletions, and blocks the moderated types of an elected contract in its interim without reading the lists for them. +- `packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs`: the whole pipeline, including a warned user carrying on with its warnings accumulating, proved and cleared, the warning limit and the clearing that lifts it, warnings kept through a ban and out of the gate and the ban's proof, every refusal of a warn, and the warning list fixed at creation; the moderators' window (a deletion to the millisecond it ends on, refused one later for the contract owner too while the author's own still passes, reopened by a replace, measured from `$createdAt` on a type that never changes, fixed on update), a moderator deleting a post (record, execution proof, the author's balance unchanged, and the control where the author deletes it and is refunded), every refusal of a deletion, an update adding a document type moderators can delete from, a permanent reference to such a type refused, the mempool refusal, the lapse sweep, the moderator set, every refusal code, the lists staying as the contract was created with them, a barred identity deleting its own documents in a block and in the mempool, a barred identity refused as the recipient of a transfer and as the seller of a purchase, the ban's proof covering the suspension it removed, lifting the entry of an identity an update made moderator, the per-list execution proof, a named owner, a create or an update naming a moderator that does not exist, an update keeping its moderators, inactivity of the transition and of a moderated contract create or update before protocol version 14, and elected moderation (each interim kind moderating or not, the block of a moderated type next to an unmoderated one in a block and in the mempool, a create refused outside a bound and for an unknown type, and an update refused for a changed field and for entering or leaving). diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 34068e6d753..c56d6a8cd78 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -99,8 +99,9 @@ From protocol version 14 a reference to a document ID means that one document or The ID of a new document only exists once the nonce of its create transition is assigned, and it changes if the transition is rebuilt with another nonce: - The ID a `Document` carries before its create transition is built (for example the one `create_document_from_data` gives it) is a **placeholder**. `DocumentCreateTransitionV0::from_document` replaces it with the derived ID, so every transition built through dpp carries the right one. -- Read the ID from the transition, or from the confirmed document `put_to_platform_and_wait_for_response` returns, not from the document you passed in. +- On the Rust path (rs-sdk, or `from_document` directly) the `Document` you passed in keeps its placeholder: read the ID from the transition, or from the confirmed document `put_to_platform_and_wait_for_response` returns. - To know IDs up front (a chain of documents that reference each other), assign the nonces first: nonces may be used out of order within a window of 24. +- JavaScript gets the same through `wasm-dpp2`, with one difference: `new DocumentCreateTransition({ document, identityContractNonce })` derives the ID for the network's protocol version (`platformVersion` option, latest by default) and writes it both onto the transition and back onto `document`, so after construction `document.id` is the final ID and may be read from there. `Document.generateId(type, owner, contract, entropy, identityContractNonce)` and `document.setIdForCreation(identityContractNonce)` give the ID before the transition exists, and `new Document({ ..., identityContractNonce })` derives it at construction (an explicit `id` passed alongside the nonce must equal the derived one). A `Document` built without a nonce carries the entropy-only placeholder until it is passed to `DocumentCreateTransition`. No app needs to reimplement the hash. ## The Accessor Traits diff --git a/book/src/drive/index-only-document-types.md b/book/src/drive/index-only-document-types.md index 98903aabe97..34b72a4454a 100644 --- a/book/src/drive/index-only-document-types.md +++ b/book/src/drive/index-only-document-types.md @@ -28,11 +28,14 @@ stores nothing in primary storage. The index entries ARE the rows: → Item(, flags) ``` -The **terminal** — a per-index keyword defaulting to `$ownerId`, or any -refersTo-typed identifier property (identity, contract, token, permanent -or deletable document) — is the member key, sitting exactly where a normal non-unique -index keys by document id; the element is an `Item` instead of a -`Reference` because there is nothing to point at. The `0` storage marker, +The **terminal** is the member key, sitting exactly where a normal +non-unique index keys by document id; the element is an `Item` instead of +a `Reference` because there is nothing to point at. It is a per-index +keyword defaulting to `$ownerId`. It may name any schema property a prefix +position could carry (an identifier with or without a `refersTo`, a +bounded byte array or string, an integer, a boolean, a date), or an +ordered **list** of such properties (a *composite* terminal, whose member +key is their encoded values concatenated). The `0` storage marker, value-tree types, and the count/sum/ranked tree derivation are byte-identical to the ordinary non-unique layout, which is what lets the protocol v14 ranked machinery (see @@ -41,6 +44,87 @@ types unchanged: "the five most-liked posts in `#dash`" is an O(log n + k) read with an O(log n + k) proof, and Items count in count/ranked trees exactly as References do. +The member key is the terminal value's **tree-key encoding**, produced by +the same functions the prefix levels use (the walkers and probes through +`get_raw_for_document_type`, queries and executed proofs through +`serialize_value_for_key`, synthesis through `decode_value_for_tree_keys`), +so nothing about it is specific to a 32-byte identifier: a 33-byte public +key, a short string or an integer keys the `0` bucket exactly as it would +key a prefix level, and fee estimation sizes the member key by the +terminal property's declared bound (`index_only_terminal_max_key_size`) +rather than by a fixed 32. Structural uniqueness spans the terminal value: +one entry per (prefix values, terminal value), so two documents by one +owner that differ only in a scalar terminal are two entries under the +same prefix. + +**Composite terminals.** `"terminal": ["kind", "$ownerId"]` keys the +member by `encode(kind) ‖ owner`. Every component but the last must be +fixed width (a byte array with `minItems == maxItems`, an identifier, an +integer, a boolean, a date), so equality on the leading components is a +clean key range and synthesis can split the key back; a string can only +be the last component; the whole key is capped at 255 bytes. Uniqueness +spans the whole key. Queries bind the components in order: equality +clauses on the leading ones, then at most one range or `in` clause on the +next (ordered by it), nothing on the rest. The lowering pads the bound +prefix with `0xFF` to the key cap for the upper bound of "every key under +this prefix", and addresses the key itself when the bound component is +the last one. After equality-bound components are ignored, `orderBy` must +start at the first remaining component and follow component order without +gaps, with the same direction for every listed component. A single member-key +walk cannot sort by a later component alone or mix ascending and descending +components. + +**Flat indexes.** An index with no `properties` at all is *flat*: its +entries live directly under a level of their own, keyed by a zero byte +followed by each terminal component name preceded by a zero byte +(`"\0appEphemeralPubKeyHash\0$ownerId"`), which no property-name tree can +collide with since property names never contain a zero byte. This level key, +including its separators, must also fit within 255 bytes: + +```text +[DataContractDocuments, contract_id, 1, , "\0\0…", 0, ] + → Item( [‖ ], flags) +``` + +The flat level is registration-time structure, created with the +property-name trees and kept when the last entry goes (the prune stops at +its `0` bucket, as on a preallocated index), so every entry costs the same. +There is no prefix level for an aggregate, a ranking, a time grid, a skip +trigger or a preallocation to apply to, so a flat index admits none of +those keywords. A clause-free query on a type with a flat index scans the +flat level (every other indexOnly type refuses the by-id shape). Non-proof +responses require this index to cover every property, including optional +ones, just as filtered queries do; otherwise use a proved projection. + +**The entry payload.** `entryPayload: ["walletEphemeralPubKey", +"encryptedPayload"]` on the document type names top-level properties that +live in no index: every entry's item carries them after the 32-byte row +commitment, each length-framed (`u16` big-endian), in property-name order: +the type's value slot. Byte arrays store their raw bytes and strings store +UTF-8; other scalars use their tree-key encoding. The length frame preserves +empty byte arrays and strings without null sentinels, and distinguishes an +empty string from a NUL string. A payload +property must be required, scalar and bounded (the sum of the bounds is +capped by the field value limit), and appears in no index as a property +or a terminal component. It is still committed (the commitment hashes +every present property, a payload value through the uncapped payload +encoding), so the delete probes and the executed-transition verifier keep +comparing the item's first 32 bytes only, and synthesis decodes the rest +of the proved element. With more than one index the payload rides in +every entry; fee estimation sizes the item by the commitment plus the +payload bound. Together, a flat composite terminal and an entry payload +make a key-value table: + +```json +"indices": [{ "name": "byRequest", "terminal": ["appEphemeralPubKeyHash", "$ownerId"] }], +"entryPayload": ["walletEphemeralPubKey", "encryptedPayload"] +``` + +lands at `[…, "\0appEphemeralPubKeyHash\0$ownerId", 0, hash ‖ owner] → +Item(commitment ‖ len ‖ ciphertext ‖ len ‖ wallet key)`, and a query on +the hash returns every responder's owner id with the payload decoded off +the item, as one proof. + **`timeRange` buckets** compose too: a bucketed indexOnly index writes one commitment entry per containing bucket under the grid-qualified level, exactly as stored types do — the walkers' bucket fan-out, the @@ -103,7 +187,9 @@ aggregate keywords follow: | every non-trigger property appears in ≥ 1 **non-skip** index (prefix or terminal) | only indexed values exist, and a skip index carries no value for trigger-absent documents — covered only there, a property would be validated and committed yet written nowhere | | **every index embeds `$ownerId`** (prefix or terminal) | entries are self-authorizing: a delete computed with owner = signer can only ever address the signer's own entries | | ≥ 1 index is `$createdAt`-free AND non-`skipIfAbsent` — the **proof index** | executed-transition proofs locate entries from the transition's values alone: they can neither reproduce a block timestamp nor anchor on an entry that may not exist | -| terminal is `$ownerId` or a single-id refersTo property | the member key must alone be a referable entity id (`identityPublicKey` is compound and rejected) | +| every terminal component is `$ownerId` or a schema property passing the indexed-shape limits (no arrays or objects; byte arrays ≤ 255 bytes, strings ≤ 63 characters); every component but the last is fixed width; the whole key ≤ 255 bytes | the member key is the components' tree-key encodings concatenated, derived by the same functions the prefix levels use; a leading component must be splittable back and rangeable; grovedb caps keys at 255 bytes; other system properties are refused because the `$createdAt` rules walk the prefix properties | +| a flat index (no `properties`) admits no countable / summable / ranked / `timeRange` / `skipIfAbsent` / `preallocated` keyword | there is no prefix level for them to apply to | +| every `entryPayload` property is a required, bounded, top-level scalar in no index | the entry value has no representation for an absent property, estimation sizes the item by the bounds, and a property is either a key or a value | | indexed `$createdAt` requires `$createdAt` in `required` | creation only assigns timestamps for required system times | | `documentsMutable: false`, no transfers/trading/history/transient | no stored row, no revision | | non-unique, non-contested, `nullSearchable` default | v1 scope | diff --git a/book/src/error-handling/error-codes.md b/book/src/error-handling/error-codes.md index 10773fb51cd..4b9a1c34d97 100644 --- a/book/src/error-handling/error-codes.md +++ b/book/src/error-handling/error-codes.md @@ -60,7 +60,7 @@ Error codes are organized into ranges that correspond to error categories and su | 10700-10700 | General | `OverflowError` (10700) | | 10800-10818 | Address | `TransitionOverMaxInputsError` (10800), `WithdrawalBelowMinAmountError` (10818) | | 10819-10827 | Shielded | `ShieldedNoActionsError` (10819), `ShieldedTooManyActionsError` (10825), `ShieldedImplicitFeeCapExceededError` (10826), `ShieldedInvalidDenominationError` (10827 — `IdentityCreateFromShieldedPool` exit amount not a member of the versioned denomination set) | -| 10900-10949 | Contract Moderation | `InvalidContractModerationConfigError` (10900), `ContractModerationSelfTargetError` (10901), `DocumentActionFeesWithoutModerationError` (10902), `ContractModerationReasonTooLongError` (10903) | +| 10900-10949 | Contract Moderation | `InvalidContractModerationConfigError` (10900), `ContractModerationSelfTargetError` (10901), `DocumentActionFeesWithoutModerationError` (10902), `ContractModerationReasonTooLongError` (10903), `InvalidContractModerationReasonDocumentsError` (10904) | ### SignatureError codes (20000-20012) @@ -102,14 +102,14 @@ impl ErrorWithCode for FeeError { The fee category currently has a single code. The 30000 range is reserved for future fee-related errors. -### StateError codes (40000-41099) +### StateError codes (40000-41299) | Range | Category | Examples | |-------|----------|----------| | 40000-40009 | Data Contract | `DataContractAlreadyPresentError` (40000), `DataContractIsReadonlyError` (40001), `DataContractNotFoundError` (40008) | | 40100-40134 | Documents | `DocumentAlreadyPresentError` (40100), `DocumentNotFoundError` (40101), `DuplicateUniqueIndexError` (40105), `DocumentActionFeeAgreementNotSetError` (40132), `DocumentActionFeeAgreementMismatchError` (40133), `DocumentActionFeeMultiplierNotToleratedError` (40134) | | 40200-40217 | Identity | `IdentityAlreadyExistsError` (40200), `InvalidIdentityRevisionError` (40203), `IdentityInsufficientBalanceError` (40210) | -| 40300-40306 | Voting | `MasternodeNotFoundError` (40300), `MasternodeVoteAlreadyPresentError` (40304) | +| 40300-40307 | Voting | `MasternodeNotFoundError` (40300), `MasternodeVoteAlreadyPresentError` (40304), `VoteChoiceNotAllowedForVotePollError` (40307) | | 40400-40401 | Prefunded Balances | `PrefundedSpecializedBalanceInsufficientError` (40400) | | 40500-40502 | Data Triggers | `DataTriggerConditionError` (40500), `DataTriggerExecutionError` (40501) | | 40600-40603 | Addresses | `AddressDoesNotExistError` (40600), `AddressNotEnoughFundsError` (40601) | @@ -117,7 +117,8 @@ The fee category currently has a single code. The 30000 range is reserved for fu | 40800-40804 | Groups | `IdentityNotMemberOfGroupError` (40800), `GroupActionAlreadyCompletedError` (40802) | | 40900-40904 | Shielded | `InvalidAnchorError` (40900), `NullifierAlreadySpentError` (40901), `InsufficientShieldedFeeError` (40904) | | 41000-41003 | Contract Groups | `ContractGroupAlreadyExistsError` (41000), `ContractGroupNotFoundError` (41001), `IdentityNotContractGroupOwnerOrAdminError` (41002), `ContractGroupAdminNotFoundError` (41003) | -| 41100-41116 | Contract Moderation | `ContractModerationNotEnabledError` (41100), `IdentityNotContractModeratorError` (41101), `ContractUserBannedError` (41107), `ContractUserSuspendedError` (41108), `ContractModerationTargetNotFoundError` (41109), `ContractModeratorIdentityNotFoundError` (41110), `ContractFeesAlreadyClaimedThisEpochError` (41111), `ContractFeesNothingToClaimError` (41112), `ContractFeeClaimNotAllowedError` (41113), `ContractModerationCounterpartyBarredError` (41114), `DocumentTypeNotDeletableByModeratorsError` (41115), `DocumentModerationWindowElapsedError` (41116) | +| 41100-41122 | Contract Moderation | `ContractModerationNotEnabledError` (41100), `IdentityNotContractModeratorError` (41101), `ContractUserBannedError` (41107), `ContractUserSuspendedError` (41108), `ContractModerationTargetNotFoundError` (41109), `ContractModeratorIdentityNotFoundError` (41110), `ContractFeesAlreadyClaimedThisEpochError` (41111), `ContractFeesNothingToClaimError` (41112), `ContractFeeClaimNotAllowedError` (41113), `ContractModerationCounterpartyBarredError` (41114), `DocumentTypeNotDeletableByModeratorsError` (41115), `DocumentModerationWindowElapsedError` (41116), `ContractUserNotWarnedError` (41117), `ContractUserWarningLimitReachedError` (41118), `ContractDocumentRemovalNotFoundError` (41119), `DocumentRestoreWindowElapsedError` (41120), `DocumentRestoreHashMismatchError` (41121), `ContractDocumentAlreadyRestoredError` (41122) | +| 41200-41299 | Contract Moderation Teams | `ContractModeratedDocumentTypeNotYetUsableError` (41200) | Notice how the `DataTriggerError` sub-enum has its own `ErrorWithCode` implementation that the `StateError` delegates to: diff --git a/book/src/fees/overview.md b/book/src/fees/overview.md index 15386f7159a..18cb9f962e4 100644 --- a/book/src/fees/overview.md +++ b/book/src/fees/overview.md @@ -317,9 +317,12 @@ judged in the batch's advanced structure validation (`BatchTransitionAction::validate_action_fee_agreements`), off the action alone: the base action carries the declaration beside the agreement, and the batch action the multiplier its transformer read. The mempool runs the same -check. A client builds the agreement from the contract it showed its user -with `DocumentActionFeeAgreement::for_document_type_action`, never from a -contract fetched behind their back at signing time. +check when a transition arrives and again on every recheck, so a transition +whose agreement no longer holds leaves the mempool with the same error instead +of failing in the block that would have refused it. A client builds the +agreement from the contract it showed its user with +`DocumentActionFeeAgreement::for_document_type_action`, never from a contract +fetched behind their back at signing time. **The amounts do not change yet.** A contract update may not add, change or remove the `actionFees` of an existing document type, nor switch their pricing diff --git a/book/src/sdk/put-operations.md b/book/src/sdk/put-operations.md index 67eb6c142fe..5c37b5413d0 100644 --- a/book/src/sdk/put-operations.md +++ b/book/src/sdk/put-operations.md @@ -151,6 +151,16 @@ so the SDK switches derivation when the network does. Because the ID depends on nonce, the ID on the document you pass in is a placeholder: use the ID of the confirmed document that `put_to_platform_and_wait_for_response` returns. +The JavaScript SDK follows the same pipeline. `sdk.documents.create` goes through +`put_to_platform_and_wait_for_response` and hands the confirmed document back. An app +that builds the transition itself (to sign it separately or cache the signed bytes) +gets the derivation from `wasm-dpp2`: `new DocumentCreateTransition({ document, +identityContractNonce })` derives the ID from the document's entropy and the nonce for +the network's protocol version (`platformVersion` option, latest by default), writes it +onto the transition and back onto `document`, and the transition is then batched, signed +and broadcast as before. The IDs such a transition carries are final; nothing has to be +hashed on the app side. + ### Step 3: Validate Structure Before broadcasting, the SDK validates the transition's basic structure: diff --git a/book/src/state-transitions/validation-pipeline.md b/book/src/state-transitions/validation-pipeline.md index 75788a55011..499beab5ec5 100644 --- a/book/src/state-transitions/validation-pipeline.md +++ b/book/src/state-transitions/validation-pipeline.md @@ -231,6 +231,12 @@ if state_transition.has_identity_minimum_balance_pre_check_validation() { } ``` +A `MasternodeVote` is paid by its vote poll's prefunded specialized balance, not by +the voter, so its pre-check is on that pot: a vote on a poll whose pot does not exist, +or holds less than the single vote cost, is refused unpaid with +`PrefundedSpecializedBalanceNotFoundError` or +`PrefundedSpecializedBalanceInsufficientError`. + ## Stage 8: Advanced Structure Validation (without State) Some transitions need structural validation that goes beyond basic checks but does not diff --git a/docs/protocol/app-connect.md b/docs/protocol/app-connect.md new file mode 100644 index 00000000000..37d5ac62177 --- /dev/null +++ b/docs/protocol/app-connect.md @@ -0,0 +1,86 @@ +# App Connect login responses + +The App Connect system contract gives wallet-to-app login responses a fixed +home on every network. It activates at protocol version 14, both at genesis +and when an existing chain upgrades from version 13. + +- Contract ID: `H8F9mP1BM55TE1ShsxPZHzhyinaMdY9bMmP85mkDhcJJ` +- Owner: the all-zero system identity +- Registry entry: `SystemDataContract::AppConnect = 9` +- Schema version: 1 +- Document type: `loginKeyResponse` + +## Response schema + +A wallet publishes a response under the identity whose keys the user approved +sharing with the app. Ordinary identities may create responses. + +| Property | Type | Meaning | +| --- | --- | --- | +| `appEphemeralPubKeyHash` | 20 bytes, required | `hash160` of the ephemeral public key in the app's request; identifies that request | +| `walletEphemeralPubKey` | 33 bytes, required | Compressed wallet ephemeral public key; the app combines it with its own ephemeral private key to derive the shared secret | +| `encryptedPayload` | 60–572 bytes, required | Granted private keys encrypted to the app: a 28-byte envelope plus 32 bytes per key, for 1–17 keys (session key plus up to eight bindings with both purposes) | + +Additional properties are rejected. The schema enforces lengths, but does not +parse secp256k1 points, validate an encryption tag, or enforce 32-byte increments +inside the payload range. Those are client protocol checks. The app's contract +ID, requested permissions and any manifest are outside this response schema. + +## Storage and lookup + +The type is immutable, deletable and `indexOnly`: + +```json +{ + "indexOnly": true, + "documentsMutable": false, + "canBeDeleted": true, + "creationRestrictionMode": 0, + "indices": [{ + "name": "byRequest", + "terminal": ["appEphemeralPubKeyHash", "$ownerId"] + }], + "entryPayload": ["walletEphemeralPubKey", "encryptedPayload"] +} +``` + +The flat index stores one entry at +`[…, loginKeyResponse, "\0appEphemeralPubKeyHash\0$ownerId", 0, hash ‖ owner]`. +Its value contains the row commitment followed by the framed payload +properties in name order (`encryptedPayload`, then `walletEphemeralPubKey`). +There is no primary document row, document-ID lookup, or tree per request. + +The composite key enforces one response per request **per identity**. The +request hash is public, so uniqueness on the hash alone would allow an +observer to block the wallet. Including `$ownerId` lets other identities +respond without reserving the wallet's entry. + +Apps query `appEphemeralPubKeyHash == requestHash` to retrieve candidates and +use `$ownerId` as the next ordered component for pagination. Once the identity +is known, equality on both fields selects one response. Query proofs +reconstruct all three properties and the owner from the entry. A synthesized +`$id` is not a primary row identifier or sufficient input for deletion. + +## Approval and re-login + +1. The app creates a fresh ephemeral key pair and keeps the request context. +2. After user approval, the wallet provisions the granted identity keys and + waits for them to be confirmed before publishing the response. The wallet + and app agree on encryption and how the grant binds to the request context. +3. The app checks each candidate's encryption tag and framing, then verifies + that the granted keys are live on that candidate's `$ownerId`, with the + approved purposes, bounds, expiry and budget. ECDH to a public ephemeral key + alone does not authenticate the responding user; pairing and approval must + establish which identity the app intends to log in. +4. The wallet retains its previous response values per identity and app. On + re-login it deletes that response using an index-only delete carrying + **all three original values**, then creates a response for the fresh + request. An ordinary document replace is not supported. Deletion checks + the stored commitment, so a changed ciphertext cannot delete the old entry. +5. If local tracking is lost, the wallet can create the fresh response. An old + entry remains until its owner deletes it with the original values. There is + no owner-only or per-app secondary index and no automatic cleanup. + +Platform treats responses as per-request entries. The wallet manages their +per-app lifecycle and any identity-key revocation separately; deleting a +response does not revoke keys that an app has already received. diff --git a/package.json b/package.json index c493b06cb7d..497e861f4dd 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..439a431aca4 --- /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 wallet's encrypted response to an app login request" +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..c2289e5971c --- /dev/null +++ b/packages/app-connect-contract/README.md @@ -0,0 +1,39 @@ +# App Connect Contract + +The App Connect system contract carries the wallet's encrypted response to an +app login request. It activates at protocol version 14 and has the same ID on +every network: `H8F9mP1BM55TE1ShsxPZHzhyinaMdY9bMmP85mkDhcJJ`. + +Its only document type is `loginKeyResponse`, written by the wallet's identity +after the user approves: + +| Property | Bytes | Meaning | +| --- | --- | --- | +| `appEphemeralPubKeyHash` | 20 | `hash160` of the ephemeral public key in the app's request | +| `walletEphemeralPubKey` | 33 | Compressed ephemeral public key used to derive the shared secret | +| `encryptedPayload` | 60–572 | Encrypted granted private keys: 28-byte envelope plus 32 bytes per key, for 1–17 keys (session key plus up to eight bindings with both purposes) | + +All three properties are required. The contract checks their byte lengths; +clients validate the public key, ciphertext framing and decrypted grant. + +The type is `indexOnly`, immutable and deletable. Its `byRequest` index is a +flat composite terminal `(appEphemeralPubKeyHash, $ownerId)`. The wallet key and +ciphertext are `entryPayload` values, so there is no primary document row or +per-request tree. Each identity may answer a request once; another identity's +answer cannot reserve the wallet's entry. Apps query by request hash and +validate each candidate's ciphertext and granted keys against its `$ownerId`. + +On re-login the wallet deletes its old response **by values** and creates the +new response. It retains the previous request hash, wallet key and ciphertext +for deletion; retaining only a document ID is insufficient for an index-only +type. See [the protocol guide](../../docs/protocol/app-connect.md) for details. + +## Install + +```sh +npm install @dashevo/app-connect-contract +``` + +## 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..2b4b4be7f81 --- /dev/null +++ b/packages/app-connect-contract/package.json @@ -0,0 +1,27 @@ +{ + "name": "@dashevo/app-connect-contract", + "version": "4.2.0-beta.3", + "description": "A system contract for encrypted wallet-to-app login responses", + "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..2148a46efab --- /dev/null +++ b/packages/app-connect-contract/schema/v1/app-connect-contract-documents.json @@ -0,0 +1,55 @@ +{ + "loginKeyResponse": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": true, + "creationRestrictionMode": 0, + "indices": [ + { + "name": "byRequest", + "terminal": [ + "appEphemeralPubKeyHash", + "$ownerId" + ] + } + ], + "properties": { + "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": 0 + }, + "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": 1 + }, + "encryptedPayload": { + "type": "array", + "byteArray": true, + "minItems": 60, + "maxItems": 572, + "description": "The granted private keys encrypted to the app: a 28-byte envelope plus 32 bytes per key, one to seventeen keys (the session key plus up to eight bindings with both purposes)", + "position": 2 + } + }, + "required": [ + "appEphemeralPubKeyHash", + "walletEphemeralPubKey", + "encryptedPayload" + ], + "description": "A wallet's response after approving a login request. The flat entry is keyed by the request hash and wallet identity, so other identities cannot block its write. On re-login the wallet deletes its previous response by values and creates a response for the new request.", + "additionalProperties": false, + "indexOnly": true, + "entryPayload": [ + "walletEphemeralPubKey", + "encryptedPayload" + ] + } +} 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..6aef3039086 --- /dev/null +++ b/packages/app-connect-contract/src/lib.rs @@ -0,0 +1,66 @@ +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)); +/// The contract's definitions for `platform_version`. Version 0, the value the tables of +/// protocol versions below 14 carry, names no schema generation and is refused like any +/// unknown version: the contract does not exist before its activation. +pub fn load_definitions(platform_version: &PlatformVersion) -> Result, Error> { + match platform_version.system_data_contracts.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 should_match_the_published_system_contract_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..0d320637f6c --- /dev/null +++ b/packages/app-connect-contract/src/v1/mod.rs @@ -0,0 +1,25 @@ +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 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_REQUEST: &str = "byRequest"; + } + } +} + +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..0d4e197f439 --- /dev/null +++ b/packages/app-connect-contract/test/unit/appConnectContract.spec.js @@ -0,0 +1,199 @@ +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 = { + appEphemeralPubKeyHash: crypto.randomBytes(20), + walletEphemeralPubKey: crypto.randomBytes(33), + encryptedPayload: crypto.randomBytes(60), + }; + }); + + 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 572 bytes', async () => { + rawLoginKeyResponseDocument.encryptedPayload = crypto.randomBytes(573); + + 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 the maximum grant of seventeen keys', async () => { + // Session key plus eight bindings 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); + + 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(); + }); + }); + }); +}); diff --git a/packages/dapi-grpc/clients/drive/v0/nodejs/drive_pbjs.js b/packages/dapi-grpc/clients/drive/v0/nodejs/drive_pbjs.js index 03cdabdc17a..daba28b97b4 100644 --- a/packages/dapi-grpc/clients/drive/v0/nodejs/drive_pbjs.js +++ b/packages/dapi-grpc/clients/drive/v0/nodejs/drive_pbjs.js @@ -24072,15 +24072,236 @@ $root.org = (function() { * @property {number} CONTRACT_MODERATION_LIST_UNSPECIFIED=0 CONTRACT_MODERATION_LIST_UNSPECIFIED value * @property {number} CONTRACT_MODERATION_LIST_BANLIST=1 CONTRACT_MODERATION_LIST_BANLIST value * @property {number} CONTRACT_MODERATION_LIST_SUSPENSIONS=2 CONTRACT_MODERATION_LIST_SUSPENSIONS value + * @property {number} CONTRACT_MODERATION_LIST_WARNINGS=3 CONTRACT_MODERATION_LIST_WARNINGS value */ v0.ContractModerationList = (function() { var valuesById = {}, values = Object.create(valuesById); values[valuesById[0] = "CONTRACT_MODERATION_LIST_UNSPECIFIED"] = 0; values[valuesById[1] = "CONTRACT_MODERATION_LIST_BANLIST"] = 1; values[valuesById[2] = "CONTRACT_MODERATION_LIST_SUSPENSIONS"] = 2; + values[valuesById[3] = "CONTRACT_MODERATION_LIST_WARNINGS"] = 3; return values; })(); + v0.ContractModerationDocument = (function() { + + /** + * Properties of a ContractModerationDocument. + * @memberof org.dash.platform.dapi.v0 + * @interface IContractModerationDocument + * @property {string|null} [documentTypeName] ContractModerationDocument documentTypeName + * @property {Uint8Array|null} [documentId] ContractModerationDocument documentId + */ + + /** + * Constructs a new ContractModerationDocument. + * @memberof org.dash.platform.dapi.v0 + * @classdesc Represents a ContractModerationDocument. + * @implements IContractModerationDocument + * @constructor + * @param {org.dash.platform.dapi.v0.IContractModerationDocument=} [properties] Properties to set + */ + function ContractModerationDocument(properties) { + if (properties) + for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i) + if (properties[keys[i]] != null) + this[keys[i]] = properties[keys[i]]; + } + + /** + * ContractModerationDocument documentTypeName. + * @member {string} documentTypeName + * @memberof org.dash.platform.dapi.v0.ContractModerationDocument + * @instance + */ + ContractModerationDocument.prototype.documentTypeName = ""; + + /** + * ContractModerationDocument documentId. + * @member {Uint8Array} documentId + * @memberof org.dash.platform.dapi.v0.ContractModerationDocument + * @instance + */ + ContractModerationDocument.prototype.documentId = $util.newBuffer([]); + + /** + * Creates a new ContractModerationDocument instance using the specified properties. + * @function create + * @memberof org.dash.platform.dapi.v0.ContractModerationDocument + * @static + * @param {org.dash.platform.dapi.v0.IContractModerationDocument=} [properties] Properties to set + * @returns {org.dash.platform.dapi.v0.ContractModerationDocument} ContractModerationDocument instance + */ + ContractModerationDocument.create = function create(properties) { + return new ContractModerationDocument(properties); + }; + + /** + * Encodes the specified ContractModerationDocument message. Does not implicitly {@link org.dash.platform.dapi.v0.ContractModerationDocument.verify|verify} messages. + * @function encode + * @memberof org.dash.platform.dapi.v0.ContractModerationDocument + * @static + * @param {org.dash.platform.dapi.v0.IContractModerationDocument} message ContractModerationDocument message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + ContractModerationDocument.encode = function encode(message, writer) { + if (!writer) + writer = $Writer.create(); + if (message.documentTypeName != null && Object.hasOwnProperty.call(message, "documentTypeName")) + writer.uint32(/* id 1, wireType 2 =*/10).string(message.documentTypeName); + if (message.documentId != null && Object.hasOwnProperty.call(message, "documentId")) + writer.uint32(/* id 2, wireType 2 =*/18).bytes(message.documentId); + return writer; + }; + + /** + * Encodes the specified ContractModerationDocument message, length delimited. Does not implicitly {@link org.dash.platform.dapi.v0.ContractModerationDocument.verify|verify} messages. + * @function encodeDelimited + * @memberof org.dash.platform.dapi.v0.ContractModerationDocument + * @static + * @param {org.dash.platform.dapi.v0.IContractModerationDocument} message ContractModerationDocument message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + ContractModerationDocument.encodeDelimited = function encodeDelimited(message, writer) { + return this.encode(message, writer).ldelim(); + }; + + /** + * Decodes a ContractModerationDocument message from the specified reader or buffer. + * @function decode + * @memberof org.dash.platform.dapi.v0.ContractModerationDocument + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {org.dash.platform.dapi.v0.ContractModerationDocument} ContractModerationDocument + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + ContractModerationDocument.decode = function decode(reader, length) { + if (!(reader instanceof $Reader)) + reader = $Reader.create(reader); + var end = length === undefined ? reader.len : reader.pos + length, message = new $root.org.dash.platform.dapi.v0.ContractModerationDocument(); + while (reader.pos < end) { + var tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + message.documentTypeName = reader.string(); + break; + case 2: + message.documentId = reader.bytes(); + break; + default: + reader.skipType(tag & 7); + break; + } + } + return message; + }; + + /** + * Decodes a ContractModerationDocument message from the specified reader or buffer, length delimited. + * @function decodeDelimited + * @memberof org.dash.platform.dapi.v0.ContractModerationDocument + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @returns {org.dash.platform.dapi.v0.ContractModerationDocument} ContractModerationDocument + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + ContractModerationDocument.decodeDelimited = function decodeDelimited(reader) { + if (!(reader instanceof $Reader)) + reader = new $Reader(reader); + return this.decode(reader, reader.uint32()); + }; + + /** + * Verifies a ContractModerationDocument message. + * @function verify + * @memberof org.dash.platform.dapi.v0.ContractModerationDocument + * @static + * @param {Object.} message Plain object to verify + * @returns {string|null} `null` if valid, otherwise the reason why it is not + */ + ContractModerationDocument.verify = function verify(message) { + if (typeof message !== "object" || message === null) + return "object expected"; + if (message.documentTypeName != null && message.hasOwnProperty("documentTypeName")) + if (!$util.isString(message.documentTypeName)) + return "documentTypeName: string expected"; + if (message.documentId != null && message.hasOwnProperty("documentId")) + if (!(message.documentId && typeof message.documentId.length === "number" || $util.isString(message.documentId))) + return "documentId: buffer expected"; + return null; + }; + + /** + * Creates a ContractModerationDocument message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof org.dash.platform.dapi.v0.ContractModerationDocument + * @static + * @param {Object.} object Plain object + * @returns {org.dash.platform.dapi.v0.ContractModerationDocument} ContractModerationDocument + */ + ContractModerationDocument.fromObject = function fromObject(object) { + if (object instanceof $root.org.dash.platform.dapi.v0.ContractModerationDocument) + return object; + var message = new $root.org.dash.platform.dapi.v0.ContractModerationDocument(); + if (object.documentTypeName != null) + message.documentTypeName = String(object.documentTypeName); + if (object.documentId != null) + if (typeof object.documentId === "string") + $util.base64.decode(object.documentId, message.documentId = $util.newBuffer($util.base64.length(object.documentId)), 0); + else if (object.documentId.length >= 0) + message.documentId = object.documentId; + return message; + }; + + /** + * Creates a plain object from a ContractModerationDocument message. Also converts values to other types if specified. + * @function toObject + * @memberof org.dash.platform.dapi.v0.ContractModerationDocument + * @static + * @param {org.dash.platform.dapi.v0.ContractModerationDocument} message ContractModerationDocument + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + ContractModerationDocument.toObject = function toObject(message, options) { + if (!options) + options = {}; + var object = {}; + if (options.defaults) { + object.documentTypeName = ""; + if (options.bytes === String) + object.documentId = ""; + else { + object.documentId = []; + if (options.bytes !== Array) + object.documentId = $util.newBuffer(object.documentId); + } + } + if (message.documentTypeName != null && message.hasOwnProperty("documentTypeName")) + object.documentTypeName = message.documentTypeName; + if (message.documentId != null && message.hasOwnProperty("documentId")) + object.documentId = options.bytes === String ? $util.base64.encode(message.documentId, 0, message.documentId.length) : options.bytes === Array ? Array.prototype.slice.call(message.documentId) : message.documentId; + return object; + }; + + /** + * Converts this ContractModerationDocument to JSON. + * @function toJSON + * @memberof org.dash.platform.dapi.v0.ContractModerationDocument + * @instance + * @returns {Object.} JSON object + */ + ContractModerationDocument.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + return ContractModerationDocument; + })(); + v0.ContractModerationReason = (function() { /** @@ -24089,6 +24310,7 @@ $root.org = (function() { * @interface IContractModerationReason * @property {number|null} [code] ContractModerationReason code * @property {string|null} [text] ContractModerationReason text + * @property {Array.|null} [documents] ContractModerationReason documents */ /** @@ -24100,6 +24322,7 @@ $root.org = (function() { * @param {org.dash.platform.dapi.v0.IContractModerationReason=} [properties] Properties to set */ function ContractModerationReason(properties) { + this.documents = []; if (properties) for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i) if (properties[keys[i]] != null) @@ -24122,6 +24345,14 @@ $root.org = (function() { */ ContractModerationReason.prototype.text = ""; + /** + * ContractModerationReason documents. + * @member {Array.} documents + * @memberof org.dash.platform.dapi.v0.ContractModerationReason + * @instance + */ + ContractModerationReason.prototype.documents = $util.emptyArray; + /** * Creates a new ContractModerationReason instance using the specified properties. * @function create @@ -24150,6 +24381,9 @@ $root.org = (function() { writer.uint32(/* id 1, wireType 0 =*/8).uint32(message.code); if (message.text != null && Object.hasOwnProperty.call(message, "text")) writer.uint32(/* id 2, wireType 2 =*/18).string(message.text); + if (message.documents != null && message.documents.length) + for (var i = 0; i < message.documents.length; ++i) + $root.org.dash.platform.dapi.v0.ContractModerationDocument.encode(message.documents[i], writer.uint32(/* id 3, wireType 2 =*/26).fork()).ldelim(); return writer; }; @@ -24190,6 +24424,11 @@ $root.org = (function() { case 2: message.text = reader.string(); break; + case 3: + if (!(message.documents && message.documents.length)) + message.documents = []; + message.documents.push($root.org.dash.platform.dapi.v0.ContractModerationDocument.decode(reader, reader.uint32())); + break; default: reader.skipType(tag & 7); break; @@ -24215,80 +24454,335 @@ $root.org = (function() { }; /** - * Verifies a ContractModerationReason message. + * Verifies a ContractModerationReason message. + * @function verify + * @memberof org.dash.platform.dapi.v0.ContractModerationReason + * @static + * @param {Object.} message Plain object to verify + * @returns {string|null} `null` if valid, otherwise the reason why it is not + */ + ContractModerationReason.verify = function verify(message) { + if (typeof message !== "object" || message === null) + return "object expected"; + if (message.code != null && message.hasOwnProperty("code")) + if (!$util.isInteger(message.code)) + return "code: integer expected"; + if (message.text != null && message.hasOwnProperty("text")) + if (!$util.isString(message.text)) + return "text: string expected"; + if (message.documents != null && message.hasOwnProperty("documents")) { + if (!Array.isArray(message.documents)) + return "documents: array expected"; + for (var i = 0; i < message.documents.length; ++i) { + var error = $root.org.dash.platform.dapi.v0.ContractModerationDocument.verify(message.documents[i]); + if (error) + return "documents." + error; + } + } + return null; + }; + + /** + * Creates a ContractModerationReason message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof org.dash.platform.dapi.v0.ContractModerationReason + * @static + * @param {Object.} object Plain object + * @returns {org.dash.platform.dapi.v0.ContractModerationReason} ContractModerationReason + */ + ContractModerationReason.fromObject = function fromObject(object) { + if (object instanceof $root.org.dash.platform.dapi.v0.ContractModerationReason) + return object; + var message = new $root.org.dash.platform.dapi.v0.ContractModerationReason(); + if (object.code != null) + message.code = object.code >>> 0; + if (object.text != null) + message.text = String(object.text); + if (object.documents) { + if (!Array.isArray(object.documents)) + throw TypeError(".org.dash.platform.dapi.v0.ContractModerationReason.documents: array expected"); + message.documents = []; + for (var i = 0; i < object.documents.length; ++i) { + if (typeof object.documents[i] !== "object") + throw TypeError(".org.dash.platform.dapi.v0.ContractModerationReason.documents: object expected"); + message.documents[i] = $root.org.dash.platform.dapi.v0.ContractModerationDocument.fromObject(object.documents[i]); + } + } + return message; + }; + + /** + * Creates a plain object from a ContractModerationReason message. Also converts values to other types if specified. + * @function toObject + * @memberof org.dash.platform.dapi.v0.ContractModerationReason + * @static + * @param {org.dash.platform.dapi.v0.ContractModerationReason} message ContractModerationReason + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + ContractModerationReason.toObject = function toObject(message, options) { + if (!options) + options = {}; + var object = {}; + if (options.arrays || options.defaults) + object.documents = []; + if (options.defaults) { + object.code = 0; + object.text = ""; + } + if (message.code != null && message.hasOwnProperty("code")) + object.code = message.code; + if (message.text != null && message.hasOwnProperty("text")) + object.text = message.text; + if (message.documents && message.documents.length) { + object.documents = []; + for (var j = 0; j < message.documents.length; ++j) + object.documents[j] = $root.org.dash.platform.dapi.v0.ContractModerationDocument.toObject(message.documents[j], options); + } + return object; + }; + + /** + * Converts this ContractModerationReason to JSON. + * @function toJSON + * @memberof org.dash.platform.dapi.v0.ContractModerationReason + * @instance + * @returns {Object.} JSON object + */ + ContractModerationReason.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + return ContractModerationReason; + })(); + + v0.ContractWarning = (function() { + + /** + * Properties of a ContractWarning. + * @memberof org.dash.platform.dapi.v0 + * @interface IContractWarning + * @property {number|Long|null} [warnedAt] ContractWarning warnedAt + * @property {org.dash.platform.dapi.v0.IContractModerationReason|null} [reason] ContractWarning reason + */ + + /** + * Constructs a new ContractWarning. + * @memberof org.dash.platform.dapi.v0 + * @classdesc Represents a ContractWarning. + * @implements IContractWarning + * @constructor + * @param {org.dash.platform.dapi.v0.IContractWarning=} [properties] Properties to set + */ + function ContractWarning(properties) { + if (properties) + for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i) + if (properties[keys[i]] != null) + this[keys[i]] = properties[keys[i]]; + } + + /** + * ContractWarning warnedAt. + * @member {number|Long} warnedAt + * @memberof org.dash.platform.dapi.v0.ContractWarning + * @instance + */ + ContractWarning.prototype.warnedAt = $util.Long ? $util.Long.fromBits(0,0,true) : 0; + + /** + * ContractWarning reason. + * @member {org.dash.platform.dapi.v0.IContractModerationReason|null|undefined} reason + * @memberof org.dash.platform.dapi.v0.ContractWarning + * @instance + */ + ContractWarning.prototype.reason = null; + + /** + * Creates a new ContractWarning instance using the specified properties. + * @function create + * @memberof org.dash.platform.dapi.v0.ContractWarning + * @static + * @param {org.dash.platform.dapi.v0.IContractWarning=} [properties] Properties to set + * @returns {org.dash.platform.dapi.v0.ContractWarning} ContractWarning instance + */ + ContractWarning.create = function create(properties) { + return new ContractWarning(properties); + }; + + /** + * Encodes the specified ContractWarning message. Does not implicitly {@link org.dash.platform.dapi.v0.ContractWarning.verify|verify} messages. + * @function encode + * @memberof org.dash.platform.dapi.v0.ContractWarning + * @static + * @param {org.dash.platform.dapi.v0.IContractWarning} message ContractWarning message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + ContractWarning.encode = function encode(message, writer) { + if (!writer) + writer = $Writer.create(); + if (message.warnedAt != null && Object.hasOwnProperty.call(message, "warnedAt")) + writer.uint32(/* id 1, wireType 0 =*/8).uint64(message.warnedAt); + if (message.reason != null && Object.hasOwnProperty.call(message, "reason")) + $root.org.dash.platform.dapi.v0.ContractModerationReason.encode(message.reason, writer.uint32(/* id 2, wireType 2 =*/18).fork()).ldelim(); + return writer; + }; + + /** + * Encodes the specified ContractWarning message, length delimited. Does not implicitly {@link org.dash.platform.dapi.v0.ContractWarning.verify|verify} messages. + * @function encodeDelimited + * @memberof org.dash.platform.dapi.v0.ContractWarning + * @static + * @param {org.dash.platform.dapi.v0.IContractWarning} message ContractWarning message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + ContractWarning.encodeDelimited = function encodeDelimited(message, writer) { + return this.encode(message, writer).ldelim(); + }; + + /** + * Decodes a ContractWarning message from the specified reader or buffer. + * @function decode + * @memberof org.dash.platform.dapi.v0.ContractWarning + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {org.dash.platform.dapi.v0.ContractWarning} ContractWarning + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + ContractWarning.decode = function decode(reader, length) { + if (!(reader instanceof $Reader)) + reader = $Reader.create(reader); + var end = length === undefined ? reader.len : reader.pos + length, message = new $root.org.dash.platform.dapi.v0.ContractWarning(); + while (reader.pos < end) { + var tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + message.warnedAt = reader.uint64(); + break; + case 2: + message.reason = $root.org.dash.platform.dapi.v0.ContractModerationReason.decode(reader, reader.uint32()); + break; + default: + reader.skipType(tag & 7); + break; + } + } + return message; + }; + + /** + * Decodes a ContractWarning message from the specified reader or buffer, length delimited. + * @function decodeDelimited + * @memberof org.dash.platform.dapi.v0.ContractWarning + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @returns {org.dash.platform.dapi.v0.ContractWarning} ContractWarning + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + ContractWarning.decodeDelimited = function decodeDelimited(reader) { + if (!(reader instanceof $Reader)) + reader = new $Reader(reader); + return this.decode(reader, reader.uint32()); + }; + + /** + * Verifies a ContractWarning message. * @function verify - * @memberof org.dash.platform.dapi.v0.ContractModerationReason + * @memberof org.dash.platform.dapi.v0.ContractWarning * @static * @param {Object.} message Plain object to verify * @returns {string|null} `null` if valid, otherwise the reason why it is not */ - ContractModerationReason.verify = function verify(message) { + ContractWarning.verify = function verify(message) { if (typeof message !== "object" || message === null) return "object expected"; - if (message.code != null && message.hasOwnProperty("code")) - if (!$util.isInteger(message.code)) - return "code: integer expected"; - if (message.text != null && message.hasOwnProperty("text")) - if (!$util.isString(message.text)) - return "text: string expected"; + if (message.warnedAt != null && message.hasOwnProperty("warnedAt")) + if (!$util.isInteger(message.warnedAt) && !(message.warnedAt && $util.isInteger(message.warnedAt.low) && $util.isInteger(message.warnedAt.high))) + return "warnedAt: integer|Long expected"; + if (message.reason != null && message.hasOwnProperty("reason")) { + var error = $root.org.dash.platform.dapi.v0.ContractModerationReason.verify(message.reason); + if (error) + return "reason." + error; + } return null; }; /** - * Creates a ContractModerationReason message from a plain object. Also converts values to their respective internal types. + * Creates a ContractWarning message from a plain object. Also converts values to their respective internal types. * @function fromObject - * @memberof org.dash.platform.dapi.v0.ContractModerationReason + * @memberof org.dash.platform.dapi.v0.ContractWarning * @static * @param {Object.} object Plain object - * @returns {org.dash.platform.dapi.v0.ContractModerationReason} ContractModerationReason + * @returns {org.dash.platform.dapi.v0.ContractWarning} ContractWarning */ - ContractModerationReason.fromObject = function fromObject(object) { - if (object instanceof $root.org.dash.platform.dapi.v0.ContractModerationReason) + ContractWarning.fromObject = function fromObject(object) { + if (object instanceof $root.org.dash.platform.dapi.v0.ContractWarning) return object; - var message = new $root.org.dash.platform.dapi.v0.ContractModerationReason(); - if (object.code != null) - message.code = object.code >>> 0; - if (object.text != null) - message.text = String(object.text); + var message = new $root.org.dash.platform.dapi.v0.ContractWarning(); + if (object.warnedAt != null) + if ($util.Long) + (message.warnedAt = $util.Long.fromValue(object.warnedAt)).unsigned = true; + else if (typeof object.warnedAt === "string") + message.warnedAt = parseInt(object.warnedAt, 10); + else if (typeof object.warnedAt === "number") + message.warnedAt = object.warnedAt; + else if (typeof object.warnedAt === "object") + message.warnedAt = new $util.LongBits(object.warnedAt.low >>> 0, object.warnedAt.high >>> 0).toNumber(true); + if (object.reason != null) { + if (typeof object.reason !== "object") + throw TypeError(".org.dash.platform.dapi.v0.ContractWarning.reason: object expected"); + message.reason = $root.org.dash.platform.dapi.v0.ContractModerationReason.fromObject(object.reason); + } return message; }; /** - * Creates a plain object from a ContractModerationReason message. Also converts values to other types if specified. + * Creates a plain object from a ContractWarning message. Also converts values to other types if specified. * @function toObject - * @memberof org.dash.platform.dapi.v0.ContractModerationReason + * @memberof org.dash.platform.dapi.v0.ContractWarning * @static - * @param {org.dash.platform.dapi.v0.ContractModerationReason} message ContractModerationReason + * @param {org.dash.platform.dapi.v0.ContractWarning} message ContractWarning * @param {$protobuf.IConversionOptions} [options] Conversion options * @returns {Object.} Plain object */ - ContractModerationReason.toObject = function toObject(message, options) { + ContractWarning.toObject = function toObject(message, options) { if (!options) options = {}; var object = {}; if (options.defaults) { - object.code = 0; - object.text = ""; + if ($util.Long) { + var long = new $util.Long(0, 0, true); + object.warnedAt = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.warnedAt = options.longs === String ? "0" : 0; + object.reason = null; } - if (message.code != null && message.hasOwnProperty("code")) - object.code = message.code; - if (message.text != null && message.hasOwnProperty("text")) - object.text = message.text; + if (message.warnedAt != null && message.hasOwnProperty("warnedAt")) + if (typeof message.warnedAt === "number") + object.warnedAt = options.longs === String ? String(message.warnedAt) : message.warnedAt; + else + object.warnedAt = options.longs === String ? $util.Long.prototype.toString.call(message.warnedAt) : options.longs === Number ? new $util.LongBits(message.warnedAt.low >>> 0, message.warnedAt.high >>> 0).toNumber(true) : message.warnedAt; + if (message.reason != null && message.hasOwnProperty("reason")) + object.reason = $root.org.dash.platform.dapi.v0.ContractModerationReason.toObject(message.reason, options); return object; }; /** - * Converts this ContractModerationReason to JSON. + * Converts this ContractWarning to JSON. * @function toJSON - * @memberof org.dash.platform.dapi.v0.ContractModerationReason + * @memberof org.dash.platform.dapi.v0.ContractWarning * @instance * @returns {Object.} JSON object */ - ContractModerationReason.prototype.toJSON = function toJSON() { + ContractWarning.prototype.toJSON = function toJSON() { return this.constructor.toObject(this, $protobuf.util.toJSONOptions); }; - return ContractModerationReason; + return ContractWarning; })(); v0.GetContractModerationStatusRequest = (function() { @@ -24699,6 +25193,7 @@ $root.org = (function() { case 0: case 1: case 2: + case 3: break; } } @@ -24749,6 +25244,10 @@ $root.org = (function() { case 2: message.lists[i] = 2; break; + case "CONTRACT_MODERATION_LIST_WARNINGS": + case 3: + message.lists[i] = 3; + break; } } if (object.prove != null) @@ -25038,6 +25537,7 @@ $root.org = (function() { * @property {Array.|null} [lists] ContractModerationStatus lists * @property {org.dash.platform.dapi.v0.IContractModerationReason|null} [banReason] ContractModerationStatus banReason * @property {org.dash.platform.dapi.v0.IContractModerationReason|null} [suspensionReason] ContractModerationStatus suspensionReason + * @property {Array.|null} [warnings] ContractModerationStatus warnings */ /** @@ -25050,6 +25550,7 @@ $root.org = (function() { */ function ContractModerationStatus(properties) { this.lists = []; + this.warnings = []; if (properties) for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i) if (properties[keys[i]] != null) @@ -25096,6 +25597,14 @@ $root.org = (function() { */ ContractModerationStatus.prototype.suspensionReason = null; + /** + * ContractModerationStatus warnings. + * @member {Array.} warnings + * @memberof org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractModerationStatus + * @instance + */ + ContractModerationStatus.prototype.warnings = $util.emptyArray; + /** * Creates a new ContractModerationStatus instance using the specified properties. * @function create @@ -25134,6 +25643,9 @@ $root.org = (function() { $root.org.dash.platform.dapi.v0.ContractModerationReason.encode(message.banReason, writer.uint32(/* id 4, wireType 2 =*/34).fork()).ldelim(); if (message.suspensionReason != null && Object.hasOwnProperty.call(message, "suspensionReason")) $root.org.dash.platform.dapi.v0.ContractModerationReason.encode(message.suspensionReason, writer.uint32(/* id 5, wireType 2 =*/42).fork()).ldelim(); + if (message.warnings != null && message.warnings.length) + for (var i = 0; i < message.warnings.length; ++i) + $root.org.dash.platform.dapi.v0.ContractWarning.encode(message.warnings[i], writer.uint32(/* id 6, wireType 2 =*/50).fork()).ldelim(); return writer; }; @@ -25190,6 +25702,11 @@ $root.org = (function() { case 5: message.suspensionReason = $root.org.dash.platform.dapi.v0.ContractModerationReason.decode(reader, reader.uint32()); break; + case 6: + if (!(message.warnings && message.warnings.length)) + message.warnings = []; + message.warnings.push($root.org.dash.platform.dapi.v0.ContractWarning.decode(reader, reader.uint32())); + break; default: reader.skipType(tag & 7); break; @@ -25241,6 +25758,7 @@ $root.org = (function() { case 0: case 1: case 2: + case 3: break; } } @@ -25254,6 +25772,15 @@ $root.org = (function() { if (error) return "suspensionReason." + error; } + if (message.warnings != null && message.hasOwnProperty("warnings")) { + if (!Array.isArray(message.warnings)) + return "warnings: array expected"; + for (var i = 0; i < message.warnings.length; ++i) { + var error = $root.org.dash.platform.dapi.v0.ContractWarning.verify(message.warnings[i]); + if (error) + return "warnings." + error; + } + } return null; }; @@ -25299,6 +25826,10 @@ $root.org = (function() { case 2: message.lists[i] = 2; break; + case "CONTRACT_MODERATION_LIST_WARNINGS": + case 3: + message.lists[i] = 3; + break; } } if (object.banReason != null) { @@ -25311,6 +25842,16 @@ $root.org = (function() { throw TypeError(".org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractModerationStatus.suspensionReason: object expected"); message.suspensionReason = $root.org.dash.platform.dapi.v0.ContractModerationReason.fromObject(object.suspensionReason); } + if (object.warnings) { + if (!Array.isArray(object.warnings)) + throw TypeError(".org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractModerationStatus.warnings: array expected"); + message.warnings = []; + for (var i = 0; i < object.warnings.length; ++i) { + if (typeof object.warnings[i] !== "object") + throw TypeError(".org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractModerationStatus.warnings: object expected"); + message.warnings[i] = $root.org.dash.platform.dapi.v0.ContractWarning.fromObject(object.warnings[i]); + } + } return message; }; @@ -25327,8 +25868,10 @@ $root.org = (function() { if (!options) options = {}; var object = {}; - if (options.arrays || options.defaults) + if (options.arrays || options.defaults) { object.lists = []; + object.warnings = []; + } if (options.defaults) { object.banned = false; if ($util.Long) { @@ -25355,6 +25898,11 @@ $root.org = (function() { object.banReason = $root.org.dash.platform.dapi.v0.ContractModerationReason.toObject(message.banReason, options); if (message.suspensionReason != null && message.hasOwnProperty("suspensionReason")) object.suspensionReason = $root.org.dash.platform.dapi.v0.ContractModerationReason.toObject(message.suspensionReason, options); + if (message.warnings && message.warnings.length) { + object.warnings = []; + for (var j = 0; j < message.warnings.length; ++j) + object.warnings[j] = $root.org.dash.platform.dapi.v0.ContractWarning.toObject(message.warnings[j], options); + } return object; }; @@ -26052,6 +26600,7 @@ $root.org = (function() { case 0: case 1: case 2: + case 3: break; } if (message.startAfter != null && message.hasOwnProperty("startAfter")) @@ -26096,6 +26645,10 @@ $root.org = (function() { case 2: message.list = 2; break; + case "CONTRACT_MODERATION_LIST_WARNINGS": + case 3: + message.list = 3; + break; } if (object.startAfter != null) if (typeof object.startAfter === "string") @@ -26388,6 +26941,7 @@ $root.org = (function() { * @property {Uint8Array|null} [identityId] ContractModerationEntry identityId * @property {number|Long|null} [until] ContractModerationEntry until * @property {org.dash.platform.dapi.v0.IContractModerationReason|null} [reason] ContractModerationEntry reason + * @property {Array.|null} [warnings] ContractModerationEntry warnings */ /** @@ -26399,6 +26953,7 @@ $root.org = (function() { * @param {org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.IContractModerationEntry=} [properties] Properties to set */ function ContractModerationEntry(properties) { + this.warnings = []; if (properties) for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i) if (properties[keys[i]] != null) @@ -26429,6 +26984,14 @@ $root.org = (function() { */ ContractModerationEntry.prototype.reason = null; + /** + * ContractModerationEntry warnings. + * @member {Array.} warnings + * @memberof org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry + * @instance + */ + ContractModerationEntry.prototype.warnings = $util.emptyArray; + /** * Creates a new ContractModerationEntry instance using the specified properties. * @function create @@ -26459,6 +27022,9 @@ $root.org = (function() { writer.uint32(/* id 2, wireType 0 =*/16).uint64(message.until); if (message.reason != null && Object.hasOwnProperty.call(message, "reason")) $root.org.dash.platform.dapi.v0.ContractModerationReason.encode(message.reason, writer.uint32(/* id 3, wireType 2 =*/26).fork()).ldelim(); + if (message.warnings != null && message.warnings.length) + for (var i = 0; i < message.warnings.length; ++i) + $root.org.dash.platform.dapi.v0.ContractWarning.encode(message.warnings[i], writer.uint32(/* id 4, wireType 2 =*/34).fork()).ldelim(); return writer; }; @@ -26502,6 +27068,11 @@ $root.org = (function() { case 3: message.reason = $root.org.dash.platform.dapi.v0.ContractModerationReason.decode(reader, reader.uint32()); break; + case 4: + if (!(message.warnings && message.warnings.length)) + message.warnings = []; + message.warnings.push($root.org.dash.platform.dapi.v0.ContractWarning.decode(reader, reader.uint32())); + break; default: reader.skipType(tag & 7); break; @@ -26548,6 +27119,15 @@ $root.org = (function() { if (error) return "reason." + error; } + if (message.warnings != null && message.hasOwnProperty("warnings")) { + if (!Array.isArray(message.warnings)) + return "warnings: array expected"; + for (var i = 0; i < message.warnings.length; ++i) { + var error = $root.org.dash.platform.dapi.v0.ContractWarning.verify(message.warnings[i]); + if (error) + return "warnings." + error; + } + } return null; }; @@ -26582,6 +27162,16 @@ $root.org = (function() { throw TypeError(".org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry.reason: object expected"); message.reason = $root.org.dash.platform.dapi.v0.ContractModerationReason.fromObject(object.reason); } + if (object.warnings) { + if (!Array.isArray(object.warnings)) + throw TypeError(".org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry.warnings: array expected"); + message.warnings = []; + for (var i = 0; i < object.warnings.length; ++i) { + if (typeof object.warnings[i] !== "object") + throw TypeError(".org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry.warnings: object expected"); + message.warnings[i] = $root.org.dash.platform.dapi.v0.ContractWarning.fromObject(object.warnings[i]); + } + } return message; }; @@ -26598,6 +27188,8 @@ $root.org = (function() { if (!options) options = {}; var object = {}; + if (options.arrays || options.defaults) + object.warnings = []; if (options.defaults) { if (options.bytes === String) object.identityId = ""; @@ -26622,6 +27214,11 @@ $root.org = (function() { object.until = options.longs === String ? $util.Long.prototype.toString.call(message.until) : options.longs === Number ? new $util.LongBits(message.until.low >>> 0, message.until.high >>> 0).toNumber(true) : message.until; if (message.reason != null && message.hasOwnProperty("reason")) object.reason = $root.org.dash.platform.dapi.v0.ContractModerationReason.toObject(message.reason, options); + if (message.warnings && message.warnings.length) { + object.warnings = []; + for (var j = 0; j < message.warnings.length; ++j) + object.warnings[j] = $root.org.dash.platform.dapi.v0.ContractWarning.toObject(message.warnings[j], options); + } return object; }; @@ -28289,6 +28886,239 @@ $root.org = (function() { return this.constructor.toObject(this, $protobuf.util.toJSONOptions); }; + GetContractDocumentRemovalsResponse.ContractDocumentRestoration = (function() { + + /** + * Properties of a ContractDocumentRestoration. + * @memberof org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse + * @interface IContractDocumentRestoration + * @property {Uint8Array|null} [moderatorId] ContractDocumentRestoration moderatorId + * @property {number|Long|null} [restoredAt] ContractDocumentRestoration restoredAt + */ + + /** + * Constructs a new ContractDocumentRestoration. + * @memberof org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse + * @classdesc Represents a ContractDocumentRestoration. + * @implements IContractDocumentRestoration + * @constructor + * @param {org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.IContractDocumentRestoration=} [properties] Properties to set + */ + function ContractDocumentRestoration(properties) { + if (properties) + for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i) + if (properties[keys[i]] != null) + this[keys[i]] = properties[keys[i]]; + } + + /** + * ContractDocumentRestoration moderatorId. + * @member {Uint8Array} moderatorId + * @memberof org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration + * @instance + */ + ContractDocumentRestoration.prototype.moderatorId = $util.newBuffer([]); + + /** + * ContractDocumentRestoration restoredAt. + * @member {number|Long} restoredAt + * @memberof org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration + * @instance + */ + ContractDocumentRestoration.prototype.restoredAt = $util.Long ? $util.Long.fromBits(0,0,true) : 0; + + /** + * Creates a new ContractDocumentRestoration instance using the specified properties. + * @function create + * @memberof org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration + * @static + * @param {org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.IContractDocumentRestoration=} [properties] Properties to set + * @returns {org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration} ContractDocumentRestoration instance + */ + ContractDocumentRestoration.create = function create(properties) { + return new ContractDocumentRestoration(properties); + }; + + /** + * Encodes the specified ContractDocumentRestoration message. Does not implicitly {@link org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.verify|verify} messages. + * @function encode + * @memberof org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration + * @static + * @param {org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.IContractDocumentRestoration} message ContractDocumentRestoration message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + ContractDocumentRestoration.encode = function encode(message, writer) { + if (!writer) + writer = $Writer.create(); + if (message.moderatorId != null && Object.hasOwnProperty.call(message, "moderatorId")) + writer.uint32(/* id 1, wireType 2 =*/10).bytes(message.moderatorId); + if (message.restoredAt != null && Object.hasOwnProperty.call(message, "restoredAt")) + writer.uint32(/* id 2, wireType 0 =*/16).uint64(message.restoredAt); + return writer; + }; + + /** + * Encodes the specified ContractDocumentRestoration message, length delimited. Does not implicitly {@link org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.verify|verify} messages. + * @function encodeDelimited + * @memberof org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration + * @static + * @param {org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.IContractDocumentRestoration} message ContractDocumentRestoration message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + ContractDocumentRestoration.encodeDelimited = function encodeDelimited(message, writer) { + return this.encode(message, writer).ldelim(); + }; + + /** + * Decodes a ContractDocumentRestoration message from the specified reader or buffer. + * @function decode + * @memberof org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration} ContractDocumentRestoration + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + ContractDocumentRestoration.decode = function decode(reader, length) { + if (!(reader instanceof $Reader)) + reader = $Reader.create(reader); + var end = length === undefined ? reader.len : reader.pos + length, message = new $root.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration(); + while (reader.pos < end) { + var tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + message.moderatorId = reader.bytes(); + break; + case 2: + message.restoredAt = reader.uint64(); + break; + default: + reader.skipType(tag & 7); + break; + } + } + return message; + }; + + /** + * Decodes a ContractDocumentRestoration message from the specified reader or buffer, length delimited. + * @function decodeDelimited + * @memberof org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @returns {org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration} ContractDocumentRestoration + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + ContractDocumentRestoration.decodeDelimited = function decodeDelimited(reader) { + if (!(reader instanceof $Reader)) + reader = new $Reader(reader); + return this.decode(reader, reader.uint32()); + }; + + /** + * Verifies a ContractDocumentRestoration message. + * @function verify + * @memberof org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration + * @static + * @param {Object.} message Plain object to verify + * @returns {string|null} `null` if valid, otherwise the reason why it is not + */ + ContractDocumentRestoration.verify = function verify(message) { + if (typeof message !== "object" || message === null) + return "object expected"; + if (message.moderatorId != null && message.hasOwnProperty("moderatorId")) + if (!(message.moderatorId && typeof message.moderatorId.length === "number" || $util.isString(message.moderatorId))) + return "moderatorId: buffer expected"; + if (message.restoredAt != null && message.hasOwnProperty("restoredAt")) + if (!$util.isInteger(message.restoredAt) && !(message.restoredAt && $util.isInteger(message.restoredAt.low) && $util.isInteger(message.restoredAt.high))) + return "restoredAt: integer|Long expected"; + return null; + }; + + /** + * Creates a ContractDocumentRestoration message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration + * @static + * @param {Object.} object Plain object + * @returns {org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration} ContractDocumentRestoration + */ + ContractDocumentRestoration.fromObject = function fromObject(object) { + if (object instanceof $root.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration) + return object; + var message = new $root.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration(); + if (object.moderatorId != null) + if (typeof object.moderatorId === "string") + $util.base64.decode(object.moderatorId, message.moderatorId = $util.newBuffer($util.base64.length(object.moderatorId)), 0); + else if (object.moderatorId.length >= 0) + message.moderatorId = object.moderatorId; + if (object.restoredAt != null) + if ($util.Long) + (message.restoredAt = $util.Long.fromValue(object.restoredAt)).unsigned = true; + else if (typeof object.restoredAt === "string") + message.restoredAt = parseInt(object.restoredAt, 10); + else if (typeof object.restoredAt === "number") + message.restoredAt = object.restoredAt; + else if (typeof object.restoredAt === "object") + message.restoredAt = new $util.LongBits(object.restoredAt.low >>> 0, object.restoredAt.high >>> 0).toNumber(true); + return message; + }; + + /** + * Creates a plain object from a ContractDocumentRestoration message. Also converts values to other types if specified. + * @function toObject + * @memberof org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration + * @static + * @param {org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration} message ContractDocumentRestoration + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + ContractDocumentRestoration.toObject = function toObject(message, options) { + if (!options) + options = {}; + var object = {}; + if (options.defaults) { + if (options.bytes === String) + object.moderatorId = ""; + else { + object.moderatorId = []; + if (options.bytes !== Array) + object.moderatorId = $util.newBuffer(object.moderatorId); + } + if ($util.Long) { + var long = new $util.Long(0, 0, true); + object.restoredAt = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.restoredAt = options.longs === String ? "0" : 0; + } + if (message.moderatorId != null && message.hasOwnProperty("moderatorId")) + object.moderatorId = options.bytes === String ? $util.base64.encode(message.moderatorId, 0, message.moderatorId.length) : options.bytes === Array ? Array.prototype.slice.call(message.moderatorId) : message.moderatorId; + if (message.restoredAt != null && message.hasOwnProperty("restoredAt")) + if (typeof message.restoredAt === "number") + object.restoredAt = options.longs === String ? String(message.restoredAt) : message.restoredAt; + else + object.restoredAt = options.longs === String ? $util.Long.prototype.toString.call(message.restoredAt) : options.longs === Number ? new $util.LongBits(message.restoredAt.low >>> 0, message.restoredAt.high >>> 0).toNumber(true) : message.restoredAt; + return object; + }; + + /** + * Converts this ContractDocumentRestoration to JSON. + * @function toJSON + * @memberof org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration + * @instance + * @returns {Object.} JSON object + */ + ContractDocumentRestoration.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + return ContractDocumentRestoration; + })(); + GetContractDocumentRemovalsResponse.ContractDocumentRemoval = (function() { /** @@ -28300,6 +29130,8 @@ $root.org = (function() { * @property {Uint8Array|null} [moderatorId] ContractDocumentRemoval moderatorId * @property {number|Long|null} [removedAt] ContractDocumentRemoval removedAt * @property {org.dash.platform.dapi.v0.IContractModerationReason|null} [reason] ContractDocumentRemoval reason + * @property {Uint8Array|null} [documentHash] ContractDocumentRemoval documentHash + * @property {org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.IContractDocumentRestoration|null} [restoration] ContractDocumentRemoval restoration */ /** @@ -28357,6 +29189,22 @@ $root.org = (function() { */ ContractDocumentRemoval.prototype.reason = null; + /** + * ContractDocumentRemoval documentHash. + * @member {Uint8Array} documentHash + * @memberof org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval + * @instance + */ + ContractDocumentRemoval.prototype.documentHash = $util.newBuffer([]); + + /** + * ContractDocumentRemoval restoration. + * @member {org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.IContractDocumentRestoration|null|undefined} restoration + * @memberof org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval + * @instance + */ + ContractDocumentRemoval.prototype.restoration = null; + /** * Creates a new ContractDocumentRemoval instance using the specified properties. * @function create @@ -28391,6 +29239,10 @@ $root.org = (function() { writer.uint32(/* id 4, wireType 0 =*/32).uint64(message.removedAt); if (message.reason != null && Object.hasOwnProperty.call(message, "reason")) $root.org.dash.platform.dapi.v0.ContractModerationReason.encode(message.reason, writer.uint32(/* id 5, wireType 2 =*/42).fork()).ldelim(); + if (message.documentHash != null && Object.hasOwnProperty.call(message, "documentHash")) + writer.uint32(/* id 6, wireType 2 =*/50).bytes(message.documentHash); + if (message.restoration != null && Object.hasOwnProperty.call(message, "restoration")) + $root.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.encode(message.restoration, writer.uint32(/* id 7, wireType 2 =*/58).fork()).ldelim(); return writer; }; @@ -28440,6 +29292,12 @@ $root.org = (function() { case 5: message.reason = $root.org.dash.platform.dapi.v0.ContractModerationReason.decode(reader, reader.uint32()); break; + case 6: + message.documentHash = reader.bytes(); + break; + case 7: + message.restoration = $root.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.decode(reader, reader.uint32()); + break; default: reader.skipType(tag & 7); break; @@ -28492,6 +29350,14 @@ $root.org = (function() { if (error) return "reason." + error; } + if (message.documentHash != null && message.hasOwnProperty("documentHash")) + if (!(message.documentHash && typeof message.documentHash.length === "number" || $util.isString(message.documentHash))) + return "documentHash: buffer expected"; + if (message.restoration != null && message.hasOwnProperty("restoration")) { + var error = $root.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.verify(message.restoration); + if (error) + return "restoration." + error; + } return null; }; @@ -28536,6 +29402,16 @@ $root.org = (function() { throw TypeError(".org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval.reason: object expected"); message.reason = $root.org.dash.platform.dapi.v0.ContractModerationReason.fromObject(object.reason); } + if (object.documentHash != null) + if (typeof object.documentHash === "string") + $util.base64.decode(object.documentHash, message.documentHash = $util.newBuffer($util.base64.length(object.documentHash)), 0); + else if (object.documentHash.length >= 0) + message.documentHash = object.documentHash; + if (object.restoration != null) { + if (typeof object.restoration !== "object") + throw TypeError(".org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval.restoration: object expected"); + message.restoration = $root.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.fromObject(object.restoration); + } return message; }; @@ -28580,6 +29456,14 @@ $root.org = (function() { } else object.removedAt = options.longs === String ? "0" : 0; object.reason = null; + if (options.bytes === String) + object.documentHash = ""; + else { + object.documentHash = []; + if (options.bytes !== Array) + object.documentHash = $util.newBuffer(object.documentHash); + } + object.restoration = null; } if (message.documentId != null && message.hasOwnProperty("documentId")) object.documentId = options.bytes === String ? $util.base64.encode(message.documentId, 0, message.documentId.length) : options.bytes === Array ? Array.prototype.slice.call(message.documentId) : message.documentId; @@ -28594,6 +29478,10 @@ $root.org = (function() { object.removedAt = options.longs === String ? $util.Long.prototype.toString.call(message.removedAt) : options.longs === Number ? new $util.LongBits(message.removedAt.low >>> 0, message.removedAt.high >>> 0).toNumber(true) : message.removedAt; if (message.reason != null && message.hasOwnProperty("reason")) object.reason = $root.org.dash.platform.dapi.v0.ContractModerationReason.toObject(message.reason, options); + if (message.documentHash != null && message.hasOwnProperty("documentHash")) + object.documentHash = options.bytes === String ? $util.base64.encode(message.documentHash, 0, message.documentHash.length) : options.bytes === Array ? Array.prototype.slice.call(message.documentHash) : message.documentHash; + if (message.restoration != null && message.hasOwnProperty("restoration")) + object.restoration = $root.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.toObject(message.restoration, options); return object; }; diff --git a/packages/dapi-grpc/clients/platform/v0/nodejs/platform_pbjs.js b/packages/dapi-grpc/clients/platform/v0/nodejs/platform_pbjs.js index ac244335653..fa98490966b 100644 --- a/packages/dapi-grpc/clients/platform/v0/nodejs/platform_pbjs.js +++ b/packages/dapi-grpc/clients/platform/v0/nodejs/platform_pbjs.js @@ -23564,15 +23564,236 @@ $root.org = (function() { * @property {number} CONTRACT_MODERATION_LIST_UNSPECIFIED=0 CONTRACT_MODERATION_LIST_UNSPECIFIED value * @property {number} CONTRACT_MODERATION_LIST_BANLIST=1 CONTRACT_MODERATION_LIST_BANLIST value * @property {number} CONTRACT_MODERATION_LIST_SUSPENSIONS=2 CONTRACT_MODERATION_LIST_SUSPENSIONS value + * @property {number} CONTRACT_MODERATION_LIST_WARNINGS=3 CONTRACT_MODERATION_LIST_WARNINGS value */ v0.ContractModerationList = (function() { var valuesById = {}, values = Object.create(valuesById); values[valuesById[0] = "CONTRACT_MODERATION_LIST_UNSPECIFIED"] = 0; values[valuesById[1] = "CONTRACT_MODERATION_LIST_BANLIST"] = 1; values[valuesById[2] = "CONTRACT_MODERATION_LIST_SUSPENSIONS"] = 2; + values[valuesById[3] = "CONTRACT_MODERATION_LIST_WARNINGS"] = 3; return values; })(); + v0.ContractModerationDocument = (function() { + + /** + * Properties of a ContractModerationDocument. + * @memberof org.dash.platform.dapi.v0 + * @interface IContractModerationDocument + * @property {string|null} [documentTypeName] ContractModerationDocument documentTypeName + * @property {Uint8Array|null} [documentId] ContractModerationDocument documentId + */ + + /** + * Constructs a new ContractModerationDocument. + * @memberof org.dash.platform.dapi.v0 + * @classdesc Represents a ContractModerationDocument. + * @implements IContractModerationDocument + * @constructor + * @param {org.dash.platform.dapi.v0.IContractModerationDocument=} [properties] Properties to set + */ + function ContractModerationDocument(properties) { + if (properties) + for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i) + if (properties[keys[i]] != null) + this[keys[i]] = properties[keys[i]]; + } + + /** + * ContractModerationDocument documentTypeName. + * @member {string} documentTypeName + * @memberof org.dash.platform.dapi.v0.ContractModerationDocument + * @instance + */ + ContractModerationDocument.prototype.documentTypeName = ""; + + /** + * ContractModerationDocument documentId. + * @member {Uint8Array} documentId + * @memberof org.dash.platform.dapi.v0.ContractModerationDocument + * @instance + */ + ContractModerationDocument.prototype.documentId = $util.newBuffer([]); + + /** + * Creates a new ContractModerationDocument instance using the specified properties. + * @function create + * @memberof org.dash.platform.dapi.v0.ContractModerationDocument + * @static + * @param {org.dash.platform.dapi.v0.IContractModerationDocument=} [properties] Properties to set + * @returns {org.dash.platform.dapi.v0.ContractModerationDocument} ContractModerationDocument instance + */ + ContractModerationDocument.create = function create(properties) { + return new ContractModerationDocument(properties); + }; + + /** + * Encodes the specified ContractModerationDocument message. Does not implicitly {@link org.dash.platform.dapi.v0.ContractModerationDocument.verify|verify} messages. + * @function encode + * @memberof org.dash.platform.dapi.v0.ContractModerationDocument + * @static + * @param {org.dash.platform.dapi.v0.IContractModerationDocument} message ContractModerationDocument message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + ContractModerationDocument.encode = function encode(message, writer) { + if (!writer) + writer = $Writer.create(); + if (message.documentTypeName != null && Object.hasOwnProperty.call(message, "documentTypeName")) + writer.uint32(/* id 1, wireType 2 =*/10).string(message.documentTypeName); + if (message.documentId != null && Object.hasOwnProperty.call(message, "documentId")) + writer.uint32(/* id 2, wireType 2 =*/18).bytes(message.documentId); + return writer; + }; + + /** + * Encodes the specified ContractModerationDocument message, length delimited. Does not implicitly {@link org.dash.platform.dapi.v0.ContractModerationDocument.verify|verify} messages. + * @function encodeDelimited + * @memberof org.dash.platform.dapi.v0.ContractModerationDocument + * @static + * @param {org.dash.platform.dapi.v0.IContractModerationDocument} message ContractModerationDocument message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + ContractModerationDocument.encodeDelimited = function encodeDelimited(message, writer) { + return this.encode(message, writer).ldelim(); + }; + + /** + * Decodes a ContractModerationDocument message from the specified reader or buffer. + * @function decode + * @memberof org.dash.platform.dapi.v0.ContractModerationDocument + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {org.dash.platform.dapi.v0.ContractModerationDocument} ContractModerationDocument + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + ContractModerationDocument.decode = function decode(reader, length) { + if (!(reader instanceof $Reader)) + reader = $Reader.create(reader); + var end = length === undefined ? reader.len : reader.pos + length, message = new $root.org.dash.platform.dapi.v0.ContractModerationDocument(); + while (reader.pos < end) { + var tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + message.documentTypeName = reader.string(); + break; + case 2: + message.documentId = reader.bytes(); + break; + default: + reader.skipType(tag & 7); + break; + } + } + return message; + }; + + /** + * Decodes a ContractModerationDocument message from the specified reader or buffer, length delimited. + * @function decodeDelimited + * @memberof org.dash.platform.dapi.v0.ContractModerationDocument + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @returns {org.dash.platform.dapi.v0.ContractModerationDocument} ContractModerationDocument + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + ContractModerationDocument.decodeDelimited = function decodeDelimited(reader) { + if (!(reader instanceof $Reader)) + reader = new $Reader(reader); + return this.decode(reader, reader.uint32()); + }; + + /** + * Verifies a ContractModerationDocument message. + * @function verify + * @memberof org.dash.platform.dapi.v0.ContractModerationDocument + * @static + * @param {Object.} message Plain object to verify + * @returns {string|null} `null` if valid, otherwise the reason why it is not + */ + ContractModerationDocument.verify = function verify(message) { + if (typeof message !== "object" || message === null) + return "object expected"; + if (message.documentTypeName != null && message.hasOwnProperty("documentTypeName")) + if (!$util.isString(message.documentTypeName)) + return "documentTypeName: string expected"; + if (message.documentId != null && message.hasOwnProperty("documentId")) + if (!(message.documentId && typeof message.documentId.length === "number" || $util.isString(message.documentId))) + return "documentId: buffer expected"; + return null; + }; + + /** + * Creates a ContractModerationDocument message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof org.dash.platform.dapi.v0.ContractModerationDocument + * @static + * @param {Object.} object Plain object + * @returns {org.dash.platform.dapi.v0.ContractModerationDocument} ContractModerationDocument + */ + ContractModerationDocument.fromObject = function fromObject(object) { + if (object instanceof $root.org.dash.platform.dapi.v0.ContractModerationDocument) + return object; + var message = new $root.org.dash.platform.dapi.v0.ContractModerationDocument(); + if (object.documentTypeName != null) + message.documentTypeName = String(object.documentTypeName); + if (object.documentId != null) + if (typeof object.documentId === "string") + $util.base64.decode(object.documentId, message.documentId = $util.newBuffer($util.base64.length(object.documentId)), 0); + else if (object.documentId.length >= 0) + message.documentId = object.documentId; + return message; + }; + + /** + * Creates a plain object from a ContractModerationDocument message. Also converts values to other types if specified. + * @function toObject + * @memberof org.dash.platform.dapi.v0.ContractModerationDocument + * @static + * @param {org.dash.platform.dapi.v0.ContractModerationDocument} message ContractModerationDocument + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + ContractModerationDocument.toObject = function toObject(message, options) { + if (!options) + options = {}; + var object = {}; + if (options.defaults) { + object.documentTypeName = ""; + if (options.bytes === String) + object.documentId = ""; + else { + object.documentId = []; + if (options.bytes !== Array) + object.documentId = $util.newBuffer(object.documentId); + } + } + if (message.documentTypeName != null && message.hasOwnProperty("documentTypeName")) + object.documentTypeName = message.documentTypeName; + if (message.documentId != null && message.hasOwnProperty("documentId")) + object.documentId = options.bytes === String ? $util.base64.encode(message.documentId, 0, message.documentId.length) : options.bytes === Array ? Array.prototype.slice.call(message.documentId) : message.documentId; + return object; + }; + + /** + * Converts this ContractModerationDocument to JSON. + * @function toJSON + * @memberof org.dash.platform.dapi.v0.ContractModerationDocument + * @instance + * @returns {Object.} JSON object + */ + ContractModerationDocument.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + return ContractModerationDocument; + })(); + v0.ContractModerationReason = (function() { /** @@ -23581,6 +23802,7 @@ $root.org = (function() { * @interface IContractModerationReason * @property {number|null} [code] ContractModerationReason code * @property {string|null} [text] ContractModerationReason text + * @property {Array.|null} [documents] ContractModerationReason documents */ /** @@ -23592,6 +23814,7 @@ $root.org = (function() { * @param {org.dash.platform.dapi.v0.IContractModerationReason=} [properties] Properties to set */ function ContractModerationReason(properties) { + this.documents = []; if (properties) for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i) if (properties[keys[i]] != null) @@ -23614,6 +23837,14 @@ $root.org = (function() { */ ContractModerationReason.prototype.text = ""; + /** + * ContractModerationReason documents. + * @member {Array.} documents + * @memberof org.dash.platform.dapi.v0.ContractModerationReason + * @instance + */ + ContractModerationReason.prototype.documents = $util.emptyArray; + /** * Creates a new ContractModerationReason instance using the specified properties. * @function create @@ -23642,6 +23873,9 @@ $root.org = (function() { writer.uint32(/* id 1, wireType 0 =*/8).uint32(message.code); if (message.text != null && Object.hasOwnProperty.call(message, "text")) writer.uint32(/* id 2, wireType 2 =*/18).string(message.text); + if (message.documents != null && message.documents.length) + for (var i = 0; i < message.documents.length; ++i) + $root.org.dash.platform.dapi.v0.ContractModerationDocument.encode(message.documents[i], writer.uint32(/* id 3, wireType 2 =*/26).fork()).ldelim(); return writer; }; @@ -23682,6 +23916,11 @@ $root.org = (function() { case 2: message.text = reader.string(); break; + case 3: + if (!(message.documents && message.documents.length)) + message.documents = []; + message.documents.push($root.org.dash.platform.dapi.v0.ContractModerationDocument.decode(reader, reader.uint32())); + break; default: reader.skipType(tag & 7); break; @@ -23707,80 +23946,335 @@ $root.org = (function() { }; /** - * Verifies a ContractModerationReason message. + * Verifies a ContractModerationReason message. + * @function verify + * @memberof org.dash.platform.dapi.v0.ContractModerationReason + * @static + * @param {Object.} message Plain object to verify + * @returns {string|null} `null` if valid, otherwise the reason why it is not + */ + ContractModerationReason.verify = function verify(message) { + if (typeof message !== "object" || message === null) + return "object expected"; + if (message.code != null && message.hasOwnProperty("code")) + if (!$util.isInteger(message.code)) + return "code: integer expected"; + if (message.text != null && message.hasOwnProperty("text")) + if (!$util.isString(message.text)) + return "text: string expected"; + if (message.documents != null && message.hasOwnProperty("documents")) { + if (!Array.isArray(message.documents)) + return "documents: array expected"; + for (var i = 0; i < message.documents.length; ++i) { + var error = $root.org.dash.platform.dapi.v0.ContractModerationDocument.verify(message.documents[i]); + if (error) + return "documents." + error; + } + } + return null; + }; + + /** + * Creates a ContractModerationReason message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof org.dash.platform.dapi.v0.ContractModerationReason + * @static + * @param {Object.} object Plain object + * @returns {org.dash.platform.dapi.v0.ContractModerationReason} ContractModerationReason + */ + ContractModerationReason.fromObject = function fromObject(object) { + if (object instanceof $root.org.dash.platform.dapi.v0.ContractModerationReason) + return object; + var message = new $root.org.dash.platform.dapi.v0.ContractModerationReason(); + if (object.code != null) + message.code = object.code >>> 0; + if (object.text != null) + message.text = String(object.text); + if (object.documents) { + if (!Array.isArray(object.documents)) + throw TypeError(".org.dash.platform.dapi.v0.ContractModerationReason.documents: array expected"); + message.documents = []; + for (var i = 0; i < object.documents.length; ++i) { + if (typeof object.documents[i] !== "object") + throw TypeError(".org.dash.platform.dapi.v0.ContractModerationReason.documents: object expected"); + message.documents[i] = $root.org.dash.platform.dapi.v0.ContractModerationDocument.fromObject(object.documents[i]); + } + } + return message; + }; + + /** + * Creates a plain object from a ContractModerationReason message. Also converts values to other types if specified. + * @function toObject + * @memberof org.dash.platform.dapi.v0.ContractModerationReason + * @static + * @param {org.dash.platform.dapi.v0.ContractModerationReason} message ContractModerationReason + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + ContractModerationReason.toObject = function toObject(message, options) { + if (!options) + options = {}; + var object = {}; + if (options.arrays || options.defaults) + object.documents = []; + if (options.defaults) { + object.code = 0; + object.text = ""; + } + if (message.code != null && message.hasOwnProperty("code")) + object.code = message.code; + if (message.text != null && message.hasOwnProperty("text")) + object.text = message.text; + if (message.documents && message.documents.length) { + object.documents = []; + for (var j = 0; j < message.documents.length; ++j) + object.documents[j] = $root.org.dash.platform.dapi.v0.ContractModerationDocument.toObject(message.documents[j], options); + } + return object; + }; + + /** + * Converts this ContractModerationReason to JSON. + * @function toJSON + * @memberof org.dash.platform.dapi.v0.ContractModerationReason + * @instance + * @returns {Object.} JSON object + */ + ContractModerationReason.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + return ContractModerationReason; + })(); + + v0.ContractWarning = (function() { + + /** + * Properties of a ContractWarning. + * @memberof org.dash.platform.dapi.v0 + * @interface IContractWarning + * @property {number|Long|null} [warnedAt] ContractWarning warnedAt + * @property {org.dash.platform.dapi.v0.IContractModerationReason|null} [reason] ContractWarning reason + */ + + /** + * Constructs a new ContractWarning. + * @memberof org.dash.platform.dapi.v0 + * @classdesc Represents a ContractWarning. + * @implements IContractWarning + * @constructor + * @param {org.dash.platform.dapi.v0.IContractWarning=} [properties] Properties to set + */ + function ContractWarning(properties) { + if (properties) + for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i) + if (properties[keys[i]] != null) + this[keys[i]] = properties[keys[i]]; + } + + /** + * ContractWarning warnedAt. + * @member {number|Long} warnedAt + * @memberof org.dash.platform.dapi.v0.ContractWarning + * @instance + */ + ContractWarning.prototype.warnedAt = $util.Long ? $util.Long.fromBits(0,0,true) : 0; + + /** + * ContractWarning reason. + * @member {org.dash.platform.dapi.v0.IContractModerationReason|null|undefined} reason + * @memberof org.dash.platform.dapi.v0.ContractWarning + * @instance + */ + ContractWarning.prototype.reason = null; + + /** + * Creates a new ContractWarning instance using the specified properties. + * @function create + * @memberof org.dash.platform.dapi.v0.ContractWarning + * @static + * @param {org.dash.platform.dapi.v0.IContractWarning=} [properties] Properties to set + * @returns {org.dash.platform.dapi.v0.ContractWarning} ContractWarning instance + */ + ContractWarning.create = function create(properties) { + return new ContractWarning(properties); + }; + + /** + * Encodes the specified ContractWarning message. Does not implicitly {@link org.dash.platform.dapi.v0.ContractWarning.verify|verify} messages. + * @function encode + * @memberof org.dash.platform.dapi.v0.ContractWarning + * @static + * @param {org.dash.platform.dapi.v0.IContractWarning} message ContractWarning message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + ContractWarning.encode = function encode(message, writer) { + if (!writer) + writer = $Writer.create(); + if (message.warnedAt != null && Object.hasOwnProperty.call(message, "warnedAt")) + writer.uint32(/* id 1, wireType 0 =*/8).uint64(message.warnedAt); + if (message.reason != null && Object.hasOwnProperty.call(message, "reason")) + $root.org.dash.platform.dapi.v0.ContractModerationReason.encode(message.reason, writer.uint32(/* id 2, wireType 2 =*/18).fork()).ldelim(); + return writer; + }; + + /** + * Encodes the specified ContractWarning message, length delimited. Does not implicitly {@link org.dash.platform.dapi.v0.ContractWarning.verify|verify} messages. + * @function encodeDelimited + * @memberof org.dash.platform.dapi.v0.ContractWarning + * @static + * @param {org.dash.platform.dapi.v0.IContractWarning} message ContractWarning message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + ContractWarning.encodeDelimited = function encodeDelimited(message, writer) { + return this.encode(message, writer).ldelim(); + }; + + /** + * Decodes a ContractWarning message from the specified reader or buffer. + * @function decode + * @memberof org.dash.platform.dapi.v0.ContractWarning + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {org.dash.platform.dapi.v0.ContractWarning} ContractWarning + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + ContractWarning.decode = function decode(reader, length) { + if (!(reader instanceof $Reader)) + reader = $Reader.create(reader); + var end = length === undefined ? reader.len : reader.pos + length, message = new $root.org.dash.platform.dapi.v0.ContractWarning(); + while (reader.pos < end) { + var tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + message.warnedAt = reader.uint64(); + break; + case 2: + message.reason = $root.org.dash.platform.dapi.v0.ContractModerationReason.decode(reader, reader.uint32()); + break; + default: + reader.skipType(tag & 7); + break; + } + } + return message; + }; + + /** + * Decodes a ContractWarning message from the specified reader or buffer, length delimited. + * @function decodeDelimited + * @memberof org.dash.platform.dapi.v0.ContractWarning + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @returns {org.dash.platform.dapi.v0.ContractWarning} ContractWarning + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + ContractWarning.decodeDelimited = function decodeDelimited(reader) { + if (!(reader instanceof $Reader)) + reader = new $Reader(reader); + return this.decode(reader, reader.uint32()); + }; + + /** + * Verifies a ContractWarning message. * @function verify - * @memberof org.dash.platform.dapi.v0.ContractModerationReason + * @memberof org.dash.platform.dapi.v0.ContractWarning * @static * @param {Object.} message Plain object to verify * @returns {string|null} `null` if valid, otherwise the reason why it is not */ - ContractModerationReason.verify = function verify(message) { + ContractWarning.verify = function verify(message) { if (typeof message !== "object" || message === null) return "object expected"; - if (message.code != null && message.hasOwnProperty("code")) - if (!$util.isInteger(message.code)) - return "code: integer expected"; - if (message.text != null && message.hasOwnProperty("text")) - if (!$util.isString(message.text)) - return "text: string expected"; + if (message.warnedAt != null && message.hasOwnProperty("warnedAt")) + if (!$util.isInteger(message.warnedAt) && !(message.warnedAt && $util.isInteger(message.warnedAt.low) && $util.isInteger(message.warnedAt.high))) + return "warnedAt: integer|Long expected"; + if (message.reason != null && message.hasOwnProperty("reason")) { + var error = $root.org.dash.platform.dapi.v0.ContractModerationReason.verify(message.reason); + if (error) + return "reason." + error; + } return null; }; /** - * Creates a ContractModerationReason message from a plain object. Also converts values to their respective internal types. + * Creates a ContractWarning message from a plain object. Also converts values to their respective internal types. * @function fromObject - * @memberof org.dash.platform.dapi.v0.ContractModerationReason + * @memberof org.dash.platform.dapi.v0.ContractWarning * @static * @param {Object.} object Plain object - * @returns {org.dash.platform.dapi.v0.ContractModerationReason} ContractModerationReason + * @returns {org.dash.platform.dapi.v0.ContractWarning} ContractWarning */ - ContractModerationReason.fromObject = function fromObject(object) { - if (object instanceof $root.org.dash.platform.dapi.v0.ContractModerationReason) + ContractWarning.fromObject = function fromObject(object) { + if (object instanceof $root.org.dash.platform.dapi.v0.ContractWarning) return object; - var message = new $root.org.dash.platform.dapi.v0.ContractModerationReason(); - if (object.code != null) - message.code = object.code >>> 0; - if (object.text != null) - message.text = String(object.text); + var message = new $root.org.dash.platform.dapi.v0.ContractWarning(); + if (object.warnedAt != null) + if ($util.Long) + (message.warnedAt = $util.Long.fromValue(object.warnedAt)).unsigned = true; + else if (typeof object.warnedAt === "string") + message.warnedAt = parseInt(object.warnedAt, 10); + else if (typeof object.warnedAt === "number") + message.warnedAt = object.warnedAt; + else if (typeof object.warnedAt === "object") + message.warnedAt = new $util.LongBits(object.warnedAt.low >>> 0, object.warnedAt.high >>> 0).toNumber(true); + if (object.reason != null) { + if (typeof object.reason !== "object") + throw TypeError(".org.dash.platform.dapi.v0.ContractWarning.reason: object expected"); + message.reason = $root.org.dash.platform.dapi.v0.ContractModerationReason.fromObject(object.reason); + } return message; }; /** - * Creates a plain object from a ContractModerationReason message. Also converts values to other types if specified. + * Creates a plain object from a ContractWarning message. Also converts values to other types if specified. * @function toObject - * @memberof org.dash.platform.dapi.v0.ContractModerationReason + * @memberof org.dash.platform.dapi.v0.ContractWarning * @static - * @param {org.dash.platform.dapi.v0.ContractModerationReason} message ContractModerationReason + * @param {org.dash.platform.dapi.v0.ContractWarning} message ContractWarning * @param {$protobuf.IConversionOptions} [options] Conversion options * @returns {Object.} Plain object */ - ContractModerationReason.toObject = function toObject(message, options) { + ContractWarning.toObject = function toObject(message, options) { if (!options) options = {}; var object = {}; if (options.defaults) { - object.code = 0; - object.text = ""; + if ($util.Long) { + var long = new $util.Long(0, 0, true); + object.warnedAt = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.warnedAt = options.longs === String ? "0" : 0; + object.reason = null; } - if (message.code != null && message.hasOwnProperty("code")) - object.code = message.code; - if (message.text != null && message.hasOwnProperty("text")) - object.text = message.text; + if (message.warnedAt != null && message.hasOwnProperty("warnedAt")) + if (typeof message.warnedAt === "number") + object.warnedAt = options.longs === String ? String(message.warnedAt) : message.warnedAt; + else + object.warnedAt = options.longs === String ? $util.Long.prototype.toString.call(message.warnedAt) : options.longs === Number ? new $util.LongBits(message.warnedAt.low >>> 0, message.warnedAt.high >>> 0).toNumber(true) : message.warnedAt; + if (message.reason != null && message.hasOwnProperty("reason")) + object.reason = $root.org.dash.platform.dapi.v0.ContractModerationReason.toObject(message.reason, options); return object; }; /** - * Converts this ContractModerationReason to JSON. + * Converts this ContractWarning to JSON. * @function toJSON - * @memberof org.dash.platform.dapi.v0.ContractModerationReason + * @memberof org.dash.platform.dapi.v0.ContractWarning * @instance * @returns {Object.} JSON object */ - ContractModerationReason.prototype.toJSON = function toJSON() { + ContractWarning.prototype.toJSON = function toJSON() { return this.constructor.toObject(this, $protobuf.util.toJSONOptions); }; - return ContractModerationReason; + return ContractWarning; })(); v0.GetContractModerationStatusRequest = (function() { @@ -24191,6 +24685,7 @@ $root.org = (function() { case 0: case 1: case 2: + case 3: break; } } @@ -24241,6 +24736,10 @@ $root.org = (function() { case 2: message.lists[i] = 2; break; + case "CONTRACT_MODERATION_LIST_WARNINGS": + case 3: + message.lists[i] = 3; + break; } } if (object.prove != null) @@ -24530,6 +25029,7 @@ $root.org = (function() { * @property {Array.|null} [lists] ContractModerationStatus lists * @property {org.dash.platform.dapi.v0.IContractModerationReason|null} [banReason] ContractModerationStatus banReason * @property {org.dash.platform.dapi.v0.IContractModerationReason|null} [suspensionReason] ContractModerationStatus suspensionReason + * @property {Array.|null} [warnings] ContractModerationStatus warnings */ /** @@ -24542,6 +25042,7 @@ $root.org = (function() { */ function ContractModerationStatus(properties) { this.lists = []; + this.warnings = []; if (properties) for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i) if (properties[keys[i]] != null) @@ -24588,6 +25089,14 @@ $root.org = (function() { */ ContractModerationStatus.prototype.suspensionReason = null; + /** + * ContractModerationStatus warnings. + * @member {Array.} warnings + * @memberof org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractModerationStatus + * @instance + */ + ContractModerationStatus.prototype.warnings = $util.emptyArray; + /** * Creates a new ContractModerationStatus instance using the specified properties. * @function create @@ -24626,6 +25135,9 @@ $root.org = (function() { $root.org.dash.platform.dapi.v0.ContractModerationReason.encode(message.banReason, writer.uint32(/* id 4, wireType 2 =*/34).fork()).ldelim(); if (message.suspensionReason != null && Object.hasOwnProperty.call(message, "suspensionReason")) $root.org.dash.platform.dapi.v0.ContractModerationReason.encode(message.suspensionReason, writer.uint32(/* id 5, wireType 2 =*/42).fork()).ldelim(); + if (message.warnings != null && message.warnings.length) + for (var i = 0; i < message.warnings.length; ++i) + $root.org.dash.platform.dapi.v0.ContractWarning.encode(message.warnings[i], writer.uint32(/* id 6, wireType 2 =*/50).fork()).ldelim(); return writer; }; @@ -24682,6 +25194,11 @@ $root.org = (function() { case 5: message.suspensionReason = $root.org.dash.platform.dapi.v0.ContractModerationReason.decode(reader, reader.uint32()); break; + case 6: + if (!(message.warnings && message.warnings.length)) + message.warnings = []; + message.warnings.push($root.org.dash.platform.dapi.v0.ContractWarning.decode(reader, reader.uint32())); + break; default: reader.skipType(tag & 7); break; @@ -24733,6 +25250,7 @@ $root.org = (function() { case 0: case 1: case 2: + case 3: break; } } @@ -24746,6 +25264,15 @@ $root.org = (function() { if (error) return "suspensionReason." + error; } + if (message.warnings != null && message.hasOwnProperty("warnings")) { + if (!Array.isArray(message.warnings)) + return "warnings: array expected"; + for (var i = 0; i < message.warnings.length; ++i) { + var error = $root.org.dash.platform.dapi.v0.ContractWarning.verify(message.warnings[i]); + if (error) + return "warnings." + error; + } + } return null; }; @@ -24791,6 +25318,10 @@ $root.org = (function() { case 2: message.lists[i] = 2; break; + case "CONTRACT_MODERATION_LIST_WARNINGS": + case 3: + message.lists[i] = 3; + break; } } if (object.banReason != null) { @@ -24803,6 +25334,16 @@ $root.org = (function() { throw TypeError(".org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractModerationStatus.suspensionReason: object expected"); message.suspensionReason = $root.org.dash.platform.dapi.v0.ContractModerationReason.fromObject(object.suspensionReason); } + if (object.warnings) { + if (!Array.isArray(object.warnings)) + throw TypeError(".org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractModerationStatus.warnings: array expected"); + message.warnings = []; + for (var i = 0; i < object.warnings.length; ++i) { + if (typeof object.warnings[i] !== "object") + throw TypeError(".org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractModerationStatus.warnings: object expected"); + message.warnings[i] = $root.org.dash.platform.dapi.v0.ContractWarning.fromObject(object.warnings[i]); + } + } return message; }; @@ -24819,8 +25360,10 @@ $root.org = (function() { if (!options) options = {}; var object = {}; - if (options.arrays || options.defaults) + if (options.arrays || options.defaults) { object.lists = []; + object.warnings = []; + } if (options.defaults) { object.banned = false; if ($util.Long) { @@ -24847,6 +25390,11 @@ $root.org = (function() { object.banReason = $root.org.dash.platform.dapi.v0.ContractModerationReason.toObject(message.banReason, options); if (message.suspensionReason != null && message.hasOwnProperty("suspensionReason")) object.suspensionReason = $root.org.dash.platform.dapi.v0.ContractModerationReason.toObject(message.suspensionReason, options); + if (message.warnings && message.warnings.length) { + object.warnings = []; + for (var j = 0; j < message.warnings.length; ++j) + object.warnings[j] = $root.org.dash.platform.dapi.v0.ContractWarning.toObject(message.warnings[j], options); + } return object; }; @@ -25544,6 +26092,7 @@ $root.org = (function() { case 0: case 1: case 2: + case 3: break; } if (message.startAfter != null && message.hasOwnProperty("startAfter")) @@ -25588,6 +26137,10 @@ $root.org = (function() { case 2: message.list = 2; break; + case "CONTRACT_MODERATION_LIST_WARNINGS": + case 3: + message.list = 3; + break; } if (object.startAfter != null) if (typeof object.startAfter === "string") @@ -25880,6 +26433,7 @@ $root.org = (function() { * @property {Uint8Array|null} [identityId] ContractModerationEntry identityId * @property {number|Long|null} [until] ContractModerationEntry until * @property {org.dash.platform.dapi.v0.IContractModerationReason|null} [reason] ContractModerationEntry reason + * @property {Array.|null} [warnings] ContractModerationEntry warnings */ /** @@ -25891,6 +26445,7 @@ $root.org = (function() { * @param {org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.IContractModerationEntry=} [properties] Properties to set */ function ContractModerationEntry(properties) { + this.warnings = []; if (properties) for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i) if (properties[keys[i]] != null) @@ -25921,6 +26476,14 @@ $root.org = (function() { */ ContractModerationEntry.prototype.reason = null; + /** + * ContractModerationEntry warnings. + * @member {Array.} warnings + * @memberof org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry + * @instance + */ + ContractModerationEntry.prototype.warnings = $util.emptyArray; + /** * Creates a new ContractModerationEntry instance using the specified properties. * @function create @@ -25951,6 +26514,9 @@ $root.org = (function() { writer.uint32(/* id 2, wireType 0 =*/16).uint64(message.until); if (message.reason != null && Object.hasOwnProperty.call(message, "reason")) $root.org.dash.platform.dapi.v0.ContractModerationReason.encode(message.reason, writer.uint32(/* id 3, wireType 2 =*/26).fork()).ldelim(); + if (message.warnings != null && message.warnings.length) + for (var i = 0; i < message.warnings.length; ++i) + $root.org.dash.platform.dapi.v0.ContractWarning.encode(message.warnings[i], writer.uint32(/* id 4, wireType 2 =*/34).fork()).ldelim(); return writer; }; @@ -25994,6 +26560,11 @@ $root.org = (function() { case 3: message.reason = $root.org.dash.platform.dapi.v0.ContractModerationReason.decode(reader, reader.uint32()); break; + case 4: + if (!(message.warnings && message.warnings.length)) + message.warnings = []; + message.warnings.push($root.org.dash.platform.dapi.v0.ContractWarning.decode(reader, reader.uint32())); + break; default: reader.skipType(tag & 7); break; @@ -26040,6 +26611,15 @@ $root.org = (function() { if (error) return "reason." + error; } + if (message.warnings != null && message.hasOwnProperty("warnings")) { + if (!Array.isArray(message.warnings)) + return "warnings: array expected"; + for (var i = 0; i < message.warnings.length; ++i) { + var error = $root.org.dash.platform.dapi.v0.ContractWarning.verify(message.warnings[i]); + if (error) + return "warnings." + error; + } + } return null; }; @@ -26074,6 +26654,16 @@ $root.org = (function() { throw TypeError(".org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry.reason: object expected"); message.reason = $root.org.dash.platform.dapi.v0.ContractModerationReason.fromObject(object.reason); } + if (object.warnings) { + if (!Array.isArray(object.warnings)) + throw TypeError(".org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry.warnings: array expected"); + message.warnings = []; + for (var i = 0; i < object.warnings.length; ++i) { + if (typeof object.warnings[i] !== "object") + throw TypeError(".org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry.warnings: object expected"); + message.warnings[i] = $root.org.dash.platform.dapi.v0.ContractWarning.fromObject(object.warnings[i]); + } + } return message; }; @@ -26090,6 +26680,8 @@ $root.org = (function() { if (!options) options = {}; var object = {}; + if (options.arrays || options.defaults) + object.warnings = []; if (options.defaults) { if (options.bytes === String) object.identityId = ""; @@ -26114,6 +26706,11 @@ $root.org = (function() { object.until = options.longs === String ? $util.Long.prototype.toString.call(message.until) : options.longs === Number ? new $util.LongBits(message.until.low >>> 0, message.until.high >>> 0).toNumber(true) : message.until; if (message.reason != null && message.hasOwnProperty("reason")) object.reason = $root.org.dash.platform.dapi.v0.ContractModerationReason.toObject(message.reason, options); + if (message.warnings && message.warnings.length) { + object.warnings = []; + for (var j = 0; j < message.warnings.length; ++j) + object.warnings[j] = $root.org.dash.platform.dapi.v0.ContractWarning.toObject(message.warnings[j], options); + } return object; }; @@ -27781,6 +28378,239 @@ $root.org = (function() { return this.constructor.toObject(this, $protobuf.util.toJSONOptions); }; + GetContractDocumentRemovalsResponse.ContractDocumentRestoration = (function() { + + /** + * Properties of a ContractDocumentRestoration. + * @memberof org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse + * @interface IContractDocumentRestoration + * @property {Uint8Array|null} [moderatorId] ContractDocumentRestoration moderatorId + * @property {number|Long|null} [restoredAt] ContractDocumentRestoration restoredAt + */ + + /** + * Constructs a new ContractDocumentRestoration. + * @memberof org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse + * @classdesc Represents a ContractDocumentRestoration. + * @implements IContractDocumentRestoration + * @constructor + * @param {org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.IContractDocumentRestoration=} [properties] Properties to set + */ + function ContractDocumentRestoration(properties) { + if (properties) + for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i) + if (properties[keys[i]] != null) + this[keys[i]] = properties[keys[i]]; + } + + /** + * ContractDocumentRestoration moderatorId. + * @member {Uint8Array} moderatorId + * @memberof org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration + * @instance + */ + ContractDocumentRestoration.prototype.moderatorId = $util.newBuffer([]); + + /** + * ContractDocumentRestoration restoredAt. + * @member {number|Long} restoredAt + * @memberof org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration + * @instance + */ + ContractDocumentRestoration.prototype.restoredAt = $util.Long ? $util.Long.fromBits(0,0,true) : 0; + + /** + * Creates a new ContractDocumentRestoration instance using the specified properties. + * @function create + * @memberof org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration + * @static + * @param {org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.IContractDocumentRestoration=} [properties] Properties to set + * @returns {org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration} ContractDocumentRestoration instance + */ + ContractDocumentRestoration.create = function create(properties) { + return new ContractDocumentRestoration(properties); + }; + + /** + * Encodes the specified ContractDocumentRestoration message. Does not implicitly {@link org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.verify|verify} messages. + * @function encode + * @memberof org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration + * @static + * @param {org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.IContractDocumentRestoration} message ContractDocumentRestoration message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + ContractDocumentRestoration.encode = function encode(message, writer) { + if (!writer) + writer = $Writer.create(); + if (message.moderatorId != null && Object.hasOwnProperty.call(message, "moderatorId")) + writer.uint32(/* id 1, wireType 2 =*/10).bytes(message.moderatorId); + if (message.restoredAt != null && Object.hasOwnProperty.call(message, "restoredAt")) + writer.uint32(/* id 2, wireType 0 =*/16).uint64(message.restoredAt); + return writer; + }; + + /** + * Encodes the specified ContractDocumentRestoration message, length delimited. Does not implicitly {@link org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.verify|verify} messages. + * @function encodeDelimited + * @memberof org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration + * @static + * @param {org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.IContractDocumentRestoration} message ContractDocumentRestoration message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + ContractDocumentRestoration.encodeDelimited = function encodeDelimited(message, writer) { + return this.encode(message, writer).ldelim(); + }; + + /** + * Decodes a ContractDocumentRestoration message from the specified reader or buffer. + * @function decode + * @memberof org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration} ContractDocumentRestoration + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + ContractDocumentRestoration.decode = function decode(reader, length) { + if (!(reader instanceof $Reader)) + reader = $Reader.create(reader); + var end = length === undefined ? reader.len : reader.pos + length, message = new $root.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration(); + while (reader.pos < end) { + var tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + message.moderatorId = reader.bytes(); + break; + case 2: + message.restoredAt = reader.uint64(); + break; + default: + reader.skipType(tag & 7); + break; + } + } + return message; + }; + + /** + * Decodes a ContractDocumentRestoration message from the specified reader or buffer, length delimited. + * @function decodeDelimited + * @memberof org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @returns {org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration} ContractDocumentRestoration + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + ContractDocumentRestoration.decodeDelimited = function decodeDelimited(reader) { + if (!(reader instanceof $Reader)) + reader = new $Reader(reader); + return this.decode(reader, reader.uint32()); + }; + + /** + * Verifies a ContractDocumentRestoration message. + * @function verify + * @memberof org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration + * @static + * @param {Object.} message Plain object to verify + * @returns {string|null} `null` if valid, otherwise the reason why it is not + */ + ContractDocumentRestoration.verify = function verify(message) { + if (typeof message !== "object" || message === null) + return "object expected"; + if (message.moderatorId != null && message.hasOwnProperty("moderatorId")) + if (!(message.moderatorId && typeof message.moderatorId.length === "number" || $util.isString(message.moderatorId))) + return "moderatorId: buffer expected"; + if (message.restoredAt != null && message.hasOwnProperty("restoredAt")) + if (!$util.isInteger(message.restoredAt) && !(message.restoredAt && $util.isInteger(message.restoredAt.low) && $util.isInteger(message.restoredAt.high))) + return "restoredAt: integer|Long expected"; + return null; + }; + + /** + * Creates a ContractDocumentRestoration message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration + * @static + * @param {Object.} object Plain object + * @returns {org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration} ContractDocumentRestoration + */ + ContractDocumentRestoration.fromObject = function fromObject(object) { + if (object instanceof $root.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration) + return object; + var message = new $root.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration(); + if (object.moderatorId != null) + if (typeof object.moderatorId === "string") + $util.base64.decode(object.moderatorId, message.moderatorId = $util.newBuffer($util.base64.length(object.moderatorId)), 0); + else if (object.moderatorId.length >= 0) + message.moderatorId = object.moderatorId; + if (object.restoredAt != null) + if ($util.Long) + (message.restoredAt = $util.Long.fromValue(object.restoredAt)).unsigned = true; + else if (typeof object.restoredAt === "string") + message.restoredAt = parseInt(object.restoredAt, 10); + else if (typeof object.restoredAt === "number") + message.restoredAt = object.restoredAt; + else if (typeof object.restoredAt === "object") + message.restoredAt = new $util.LongBits(object.restoredAt.low >>> 0, object.restoredAt.high >>> 0).toNumber(true); + return message; + }; + + /** + * Creates a plain object from a ContractDocumentRestoration message. Also converts values to other types if specified. + * @function toObject + * @memberof org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration + * @static + * @param {org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration} message ContractDocumentRestoration + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + ContractDocumentRestoration.toObject = function toObject(message, options) { + if (!options) + options = {}; + var object = {}; + if (options.defaults) { + if (options.bytes === String) + object.moderatorId = ""; + else { + object.moderatorId = []; + if (options.bytes !== Array) + object.moderatorId = $util.newBuffer(object.moderatorId); + } + if ($util.Long) { + var long = new $util.Long(0, 0, true); + object.restoredAt = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.restoredAt = options.longs === String ? "0" : 0; + } + if (message.moderatorId != null && message.hasOwnProperty("moderatorId")) + object.moderatorId = options.bytes === String ? $util.base64.encode(message.moderatorId, 0, message.moderatorId.length) : options.bytes === Array ? Array.prototype.slice.call(message.moderatorId) : message.moderatorId; + if (message.restoredAt != null && message.hasOwnProperty("restoredAt")) + if (typeof message.restoredAt === "number") + object.restoredAt = options.longs === String ? String(message.restoredAt) : message.restoredAt; + else + object.restoredAt = options.longs === String ? $util.Long.prototype.toString.call(message.restoredAt) : options.longs === Number ? new $util.LongBits(message.restoredAt.low >>> 0, message.restoredAt.high >>> 0).toNumber(true) : message.restoredAt; + return object; + }; + + /** + * Converts this ContractDocumentRestoration to JSON. + * @function toJSON + * @memberof org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration + * @instance + * @returns {Object.} JSON object + */ + ContractDocumentRestoration.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + return ContractDocumentRestoration; + })(); + GetContractDocumentRemovalsResponse.ContractDocumentRemoval = (function() { /** @@ -27792,6 +28622,8 @@ $root.org = (function() { * @property {Uint8Array|null} [moderatorId] ContractDocumentRemoval moderatorId * @property {number|Long|null} [removedAt] ContractDocumentRemoval removedAt * @property {org.dash.platform.dapi.v0.IContractModerationReason|null} [reason] ContractDocumentRemoval reason + * @property {Uint8Array|null} [documentHash] ContractDocumentRemoval documentHash + * @property {org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.IContractDocumentRestoration|null} [restoration] ContractDocumentRemoval restoration */ /** @@ -27849,6 +28681,22 @@ $root.org = (function() { */ ContractDocumentRemoval.prototype.reason = null; + /** + * ContractDocumentRemoval documentHash. + * @member {Uint8Array} documentHash + * @memberof org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval + * @instance + */ + ContractDocumentRemoval.prototype.documentHash = $util.newBuffer([]); + + /** + * ContractDocumentRemoval restoration. + * @member {org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.IContractDocumentRestoration|null|undefined} restoration + * @memberof org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval + * @instance + */ + ContractDocumentRemoval.prototype.restoration = null; + /** * Creates a new ContractDocumentRemoval instance using the specified properties. * @function create @@ -27883,6 +28731,10 @@ $root.org = (function() { writer.uint32(/* id 4, wireType 0 =*/32).uint64(message.removedAt); if (message.reason != null && Object.hasOwnProperty.call(message, "reason")) $root.org.dash.platform.dapi.v0.ContractModerationReason.encode(message.reason, writer.uint32(/* id 5, wireType 2 =*/42).fork()).ldelim(); + if (message.documentHash != null && Object.hasOwnProperty.call(message, "documentHash")) + writer.uint32(/* id 6, wireType 2 =*/50).bytes(message.documentHash); + if (message.restoration != null && Object.hasOwnProperty.call(message, "restoration")) + $root.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.encode(message.restoration, writer.uint32(/* id 7, wireType 2 =*/58).fork()).ldelim(); return writer; }; @@ -27932,6 +28784,12 @@ $root.org = (function() { case 5: message.reason = $root.org.dash.platform.dapi.v0.ContractModerationReason.decode(reader, reader.uint32()); break; + case 6: + message.documentHash = reader.bytes(); + break; + case 7: + message.restoration = $root.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.decode(reader, reader.uint32()); + break; default: reader.skipType(tag & 7); break; @@ -27984,6 +28842,14 @@ $root.org = (function() { if (error) return "reason." + error; } + if (message.documentHash != null && message.hasOwnProperty("documentHash")) + if (!(message.documentHash && typeof message.documentHash.length === "number" || $util.isString(message.documentHash))) + return "documentHash: buffer expected"; + if (message.restoration != null && message.hasOwnProperty("restoration")) { + var error = $root.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.verify(message.restoration); + if (error) + return "restoration." + error; + } return null; }; @@ -28028,6 +28894,16 @@ $root.org = (function() { throw TypeError(".org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval.reason: object expected"); message.reason = $root.org.dash.platform.dapi.v0.ContractModerationReason.fromObject(object.reason); } + if (object.documentHash != null) + if (typeof object.documentHash === "string") + $util.base64.decode(object.documentHash, message.documentHash = $util.newBuffer($util.base64.length(object.documentHash)), 0); + else if (object.documentHash.length >= 0) + message.documentHash = object.documentHash; + if (object.restoration != null) { + if (typeof object.restoration !== "object") + throw TypeError(".org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval.restoration: object expected"); + message.restoration = $root.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.fromObject(object.restoration); + } return message; }; @@ -28072,6 +28948,14 @@ $root.org = (function() { } else object.removedAt = options.longs === String ? "0" : 0; object.reason = null; + if (options.bytes === String) + object.documentHash = ""; + else { + object.documentHash = []; + if (options.bytes !== Array) + object.documentHash = $util.newBuffer(object.documentHash); + } + object.restoration = null; } if (message.documentId != null && message.hasOwnProperty("documentId")) object.documentId = options.bytes === String ? $util.base64.encode(message.documentId, 0, message.documentId.length) : options.bytes === Array ? Array.prototype.slice.call(message.documentId) : message.documentId; @@ -28086,6 +28970,10 @@ $root.org = (function() { object.removedAt = options.longs === String ? $util.Long.prototype.toString.call(message.removedAt) : options.longs === Number ? new $util.LongBits(message.removedAt.low >>> 0, message.removedAt.high >>> 0).toNumber(true) : message.removedAt; if (message.reason != null && message.hasOwnProperty("reason")) object.reason = $root.org.dash.platform.dapi.v0.ContractModerationReason.toObject(message.reason, options); + if (message.documentHash != null && message.hasOwnProperty("documentHash")) + object.documentHash = options.bytes === String ? $util.base64.encode(message.documentHash, 0, message.documentHash.length) : options.bytes === Array ? Array.prototype.slice.call(message.documentHash) : message.documentHash; + if (message.restoration != null && message.hasOwnProperty("restoration")) + object.restoration = $root.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.toObject(message.restoration, options); return object; }; diff --git a/packages/dapi-grpc/clients/platform/v0/nodejs/platform_protoc.js b/packages/dapi-grpc/clients/platform/v0/nodejs/platform_protoc.js index 150c073de43..8fe2b482632 100644 --- a/packages/dapi-grpc/clients/platform/v0/nodejs/platform_protoc.js +++ b/packages/dapi-grpc/clients/platform/v0/nodejs/platform_protoc.js @@ -39,8 +39,10 @@ goog.exportSymbol('proto.org.dash.platform.dapi.v0.CompactedAddressBalanceUpdate goog.exportSymbol('proto.org.dash.platform.dapi.v0.CompactedBlockAddressBalanceChanges', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.ContractGroupDocumentTypeMember', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.ContractGroupTokenMember', null, { proto }); +goog.exportSymbol('proto.org.dash.platform.dapi.v0.ContractModerationDocument', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.ContractModerationList', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.ContractModerationReason', null, { proto }); +goog.exportSymbol('proto.org.dash.platform.dapi.v0.ContractWarning', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.GetAddressInfoRequest', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.GetAddressInfoRequest.GetAddressInfoRequestV0', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.GetAddressInfoRequest.VersionCase', null, { proto }); @@ -127,6 +129,7 @@ goog.exportSymbol('proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsRe goog.exportSymbol('proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemovals', null, { proto }); +goog.exportSymbol('proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.GetContractDocumentRemovalsResponseV0', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.GetContractDocumentRemovalsResponseV0.ResultCase', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.VersionCase', null, { proto }); @@ -2628,9 +2631,30 @@ if (goog.DEBUG && !COMPILED) { * @extends {jspb.Message} * @constructor */ -proto.org.dash.platform.dapi.v0.ContractModerationReason = function(opt_data) { +proto.org.dash.platform.dapi.v0.ContractModerationDocument = function(opt_data) { jspb.Message.initialize(this, opt_data, 0, -1, null, null); }; +goog.inherits(proto.org.dash.platform.dapi.v0.ContractModerationDocument, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.org.dash.platform.dapi.v0.ContractModerationDocument.displayName = 'proto.org.dash.platform.dapi.v0.ContractModerationDocument'; +} +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.org.dash.platform.dapi.v0.ContractModerationReason = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, proto.org.dash.platform.dapi.v0.ContractModerationReason.repeatedFields_, null); +}; goog.inherits(proto.org.dash.platform.dapi.v0.ContractModerationReason, jspb.Message); if (goog.DEBUG && !COMPILED) { /** @@ -2639,6 +2663,27 @@ if (goog.DEBUG && !COMPILED) { */ proto.org.dash.platform.dapi.v0.ContractModerationReason.displayName = 'proto.org.dash.platform.dapi.v0.ContractModerationReason'; } +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.org.dash.platform.dapi.v0.ContractWarning = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, null, null); +}; +goog.inherits(proto.org.dash.platform.dapi.v0.ContractWarning, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.org.dash.platform.dapi.v0.ContractWarning.displayName = 'proto.org.dash.platform.dapi.v0.ContractWarning'; +} /** * Generated by JsPbCodeGenerator. * @param {Array=} opt_data Optional initial data array, typically from a @@ -2818,7 +2863,7 @@ if (goog.DEBUG && !COMPILED) { * @constructor */ proto.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry = function(opt_data) { - jspb.Message.initialize(this, opt_data, 0, -1, null, null); + jspb.Message.initialize(this, opt_data, 0, -1, proto.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry.repeatedFields_, null); }; goog.inherits(proto.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry, jspb.Message); if (goog.DEBUG && !COMPILED) { @@ -2975,6 +3020,27 @@ if (goog.DEBUG && !COMPILED) { */ proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.displayName = 'proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse'; } +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, null, null); +}; +goog.inherits(proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.displayName = 'proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration'; +} /** * Generated by JsPbCodeGenerator. * @param {Array=} opt_data Optional initial data array, typically from a @@ -29518,6 +29584,197 @@ proto.org.dash.platform.dapi.v0.GetContractGroupMembersResponse.prototype.hasV0 +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.org.dash.platform.dapi.v0.ContractModerationDocument.prototype.toObject = function(opt_includeInstance) { + return proto.org.dash.platform.dapi.v0.ContractModerationDocument.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.org.dash.platform.dapi.v0.ContractModerationDocument} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.org.dash.platform.dapi.v0.ContractModerationDocument.toObject = function(includeInstance, msg) { + var f, obj = { + documentTypeName: jspb.Message.getFieldWithDefault(msg, 1, ""), + documentId: msg.getDocumentId_asB64() + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.org.dash.platform.dapi.v0.ContractModerationDocument} + */ +proto.org.dash.platform.dapi.v0.ContractModerationDocument.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.org.dash.platform.dapi.v0.ContractModerationDocument; + return proto.org.dash.platform.dapi.v0.ContractModerationDocument.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.org.dash.platform.dapi.v0.ContractModerationDocument} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.org.dash.platform.dapi.v0.ContractModerationDocument} + */ +proto.org.dash.platform.dapi.v0.ContractModerationDocument.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = /** @type {string} */ (reader.readString()); + msg.setDocumentTypeName(value); + break; + case 2: + var value = /** @type {!Uint8Array} */ (reader.readBytes()); + msg.setDocumentId(value); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.org.dash.platform.dapi.v0.ContractModerationDocument.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.org.dash.platform.dapi.v0.ContractModerationDocument.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.org.dash.platform.dapi.v0.ContractModerationDocument} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.org.dash.platform.dapi.v0.ContractModerationDocument.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = message.getDocumentTypeName(); + if (f.length > 0) { + writer.writeString( + 1, + f + ); + } + f = message.getDocumentId_asU8(); + if (f.length > 0) { + writer.writeBytes( + 2, + f + ); + } +}; + + +/** + * optional string document_type_name = 1; + * @return {string} + */ +proto.org.dash.platform.dapi.v0.ContractModerationDocument.prototype.getDocumentTypeName = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 1, "")); +}; + + +/** + * @param {string} value + * @return {!proto.org.dash.platform.dapi.v0.ContractModerationDocument} returns this + */ +proto.org.dash.platform.dapi.v0.ContractModerationDocument.prototype.setDocumentTypeName = function(value) { + return jspb.Message.setProto3StringField(this, 1, value); +}; + + +/** + * optional bytes document_id = 2; + * @return {string} + */ +proto.org.dash.platform.dapi.v0.ContractModerationDocument.prototype.getDocumentId = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 2, "")); +}; + + +/** + * optional bytes document_id = 2; + * This is a type-conversion wrapper around `getDocumentId()` + * @return {string} + */ +proto.org.dash.platform.dapi.v0.ContractModerationDocument.prototype.getDocumentId_asB64 = function() { + return /** @type {string} */ (jspb.Message.bytesAsB64( + this.getDocumentId())); +}; + + +/** + * optional bytes document_id = 2; + * Note that Uint8Array is not supported on all browsers. + * @see http://caniuse.com/Uint8Array + * This is a type-conversion wrapper around `getDocumentId()` + * @return {!Uint8Array} + */ +proto.org.dash.platform.dapi.v0.ContractModerationDocument.prototype.getDocumentId_asU8 = function() { + return /** @type {!Uint8Array} */ (jspb.Message.bytesAsU8( + this.getDocumentId())); +}; + + +/** + * @param {!(string|Uint8Array)} value + * @return {!proto.org.dash.platform.dapi.v0.ContractModerationDocument} returns this + */ +proto.org.dash.platform.dapi.v0.ContractModerationDocument.prototype.setDocumentId = function(value) { + return jspb.Message.setProto3BytesField(this, 2, value); +}; + + + +/** + * List of repeated fields within this message type. + * @private {!Array} + * @const + */ +proto.org.dash.platform.dapi.v0.ContractModerationReason.repeatedFields_ = [3]; + + + if (jspb.Message.GENERATE_TO_OBJECT) { /** * Creates an object representation of this proto. @@ -29548,7 +29805,9 @@ proto.org.dash.platform.dapi.v0.ContractModerationReason.prototype.toObject = fu proto.org.dash.platform.dapi.v0.ContractModerationReason.toObject = function(includeInstance, msg) { var f, obj = { code: jspb.Message.getFieldWithDefault(msg, 1, 0), - text: jspb.Message.getFieldWithDefault(msg, 2, "") + text: jspb.Message.getFieldWithDefault(msg, 2, ""), + documentsList: jspb.Message.toObjectList(msg.getDocumentsList(), + proto.org.dash.platform.dapi.v0.ContractModerationDocument.toObject, includeInstance) }; if (includeInstance) { @@ -29593,6 +29852,11 @@ proto.org.dash.platform.dapi.v0.ContractModerationReason.deserializeBinaryFromRe var value = /** @type {string} */ (reader.readString()); msg.setText(value); break; + case 3: + var value = new proto.org.dash.platform.dapi.v0.ContractModerationDocument; + reader.readMessage(value,proto.org.dash.platform.dapi.v0.ContractModerationDocument.deserializeBinaryFromReader); + msg.addDocuments(value); + break; default: reader.skipField(); break; @@ -29636,6 +29900,14 @@ proto.org.dash.platform.dapi.v0.ContractModerationReason.serializeBinaryToWriter f ); } + f = message.getDocumentsList(); + if (f.length > 0) { + writer.writeRepeatedMessage( + 3, + f, + proto.org.dash.platform.dapi.v0.ContractModerationDocument.serializeBinaryToWriter + ); + } }; @@ -29693,6 +29965,225 @@ proto.org.dash.platform.dapi.v0.ContractModerationReason.prototype.setText = fun }; +/** + * repeated ContractModerationDocument documents = 3; + * @return {!Array} + */ +proto.org.dash.platform.dapi.v0.ContractModerationReason.prototype.getDocumentsList = function() { + return /** @type{!Array} */ ( + jspb.Message.getRepeatedWrapperField(this, proto.org.dash.platform.dapi.v0.ContractModerationDocument, 3)); +}; + + +/** + * @param {!Array} value + * @return {!proto.org.dash.platform.dapi.v0.ContractModerationReason} returns this +*/ +proto.org.dash.platform.dapi.v0.ContractModerationReason.prototype.setDocumentsList = function(value) { + return jspb.Message.setRepeatedWrapperField(this, 3, value); +}; + + +/** + * @param {!proto.org.dash.platform.dapi.v0.ContractModerationDocument=} opt_value + * @param {number=} opt_index + * @return {!proto.org.dash.platform.dapi.v0.ContractModerationDocument} + */ +proto.org.dash.platform.dapi.v0.ContractModerationReason.prototype.addDocuments = function(opt_value, opt_index) { + return jspb.Message.addToRepeatedWrapperField(this, 3, opt_value, proto.org.dash.platform.dapi.v0.ContractModerationDocument, opt_index); +}; + + +/** + * Clears the list making it empty but non-null. + * @return {!proto.org.dash.platform.dapi.v0.ContractModerationReason} returns this + */ +proto.org.dash.platform.dapi.v0.ContractModerationReason.prototype.clearDocumentsList = function() { + return this.setDocumentsList([]); +}; + + + + + +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.org.dash.platform.dapi.v0.ContractWarning.prototype.toObject = function(opt_includeInstance) { + return proto.org.dash.platform.dapi.v0.ContractWarning.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.org.dash.platform.dapi.v0.ContractWarning} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.org.dash.platform.dapi.v0.ContractWarning.toObject = function(includeInstance, msg) { + var f, obj = { + warnedAt: jspb.Message.getFieldWithDefault(msg, 1, 0), + reason: (f = msg.getReason()) && proto.org.dash.platform.dapi.v0.ContractModerationReason.toObject(includeInstance, f) + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.org.dash.platform.dapi.v0.ContractWarning} + */ +proto.org.dash.platform.dapi.v0.ContractWarning.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.org.dash.platform.dapi.v0.ContractWarning; + return proto.org.dash.platform.dapi.v0.ContractWarning.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.org.dash.platform.dapi.v0.ContractWarning} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.org.dash.platform.dapi.v0.ContractWarning} + */ +proto.org.dash.platform.dapi.v0.ContractWarning.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = /** @type {number} */ (reader.readUint64()); + msg.setWarnedAt(value); + break; + case 2: + var value = new proto.org.dash.platform.dapi.v0.ContractModerationReason; + reader.readMessage(value,proto.org.dash.platform.dapi.v0.ContractModerationReason.deserializeBinaryFromReader); + msg.setReason(value); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.org.dash.platform.dapi.v0.ContractWarning.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.org.dash.platform.dapi.v0.ContractWarning.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.org.dash.platform.dapi.v0.ContractWarning} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.org.dash.platform.dapi.v0.ContractWarning.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = message.getWarnedAt(); + if (f !== 0) { + writer.writeUint64( + 1, + f + ); + } + f = message.getReason(); + if (f != null) { + writer.writeMessage( + 2, + f, + proto.org.dash.platform.dapi.v0.ContractModerationReason.serializeBinaryToWriter + ); + } +}; + + +/** + * optional uint64 warned_at = 1; + * @return {number} + */ +proto.org.dash.platform.dapi.v0.ContractWarning.prototype.getWarnedAt = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 1, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.org.dash.platform.dapi.v0.ContractWarning} returns this + */ +proto.org.dash.platform.dapi.v0.ContractWarning.prototype.setWarnedAt = function(value) { + return jspb.Message.setProto3IntField(this, 1, value); +}; + + +/** + * optional ContractModerationReason reason = 2; + * @return {?proto.org.dash.platform.dapi.v0.ContractModerationReason} + */ +proto.org.dash.platform.dapi.v0.ContractWarning.prototype.getReason = function() { + return /** @type{?proto.org.dash.platform.dapi.v0.ContractModerationReason} */ ( + jspb.Message.getWrapperField(this, proto.org.dash.platform.dapi.v0.ContractModerationReason, 2)); +}; + + +/** + * @param {?proto.org.dash.platform.dapi.v0.ContractModerationReason|undefined} value + * @return {!proto.org.dash.platform.dapi.v0.ContractWarning} returns this +*/ +proto.org.dash.platform.dapi.v0.ContractWarning.prototype.setReason = function(value) { + return jspb.Message.setWrapperField(this, 2, value); +}; + + +/** + * Clears the message field making it undefined. + * @return {!proto.org.dash.platform.dapi.v0.ContractWarning} returns this + */ +proto.org.dash.platform.dapi.v0.ContractWarning.prototype.clearReason = function() { + return this.setReason(undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.org.dash.platform.dapi.v0.ContractWarning.prototype.hasReason = function() { + return jspb.Message.getField(this, 2) != null; +}; + + /** * Oneof group definitions for this message. Each group defines the field @@ -30310,7 +30801,7 @@ proto.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.serializeBin * @private {!Array} * @const */ -proto.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractModerationStatus.repeatedFields_ = [3]; +proto.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractModerationStatus.repeatedFields_ = [3,6]; @@ -30347,7 +30838,9 @@ proto.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractMode suspendedUntil: jspb.Message.getFieldWithDefault(msg, 2, 0), listsList: (f = jspb.Message.getRepeatedField(msg, 3)) == null ? undefined : f, banReason: (f = msg.getBanReason()) && proto.org.dash.platform.dapi.v0.ContractModerationReason.toObject(includeInstance, f), - suspensionReason: (f = msg.getSuspensionReason()) && proto.org.dash.platform.dapi.v0.ContractModerationReason.toObject(includeInstance, f) + suspensionReason: (f = msg.getSuspensionReason()) && proto.org.dash.platform.dapi.v0.ContractModerationReason.toObject(includeInstance, f), + warningsList: jspb.Message.toObjectList(msg.getWarningsList(), + proto.org.dash.platform.dapi.v0.ContractWarning.toObject, includeInstance) }; if (includeInstance) { @@ -30408,6 +30901,11 @@ proto.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractMode reader.readMessage(value,proto.org.dash.platform.dapi.v0.ContractModerationReason.deserializeBinaryFromReader); msg.setSuspensionReason(value); break; + case 6: + var value = new proto.org.dash.platform.dapi.v0.ContractWarning; + reader.readMessage(value,proto.org.dash.platform.dapi.v0.ContractWarning.deserializeBinaryFromReader); + msg.addWarnings(value); + break; default: reader.skipField(); break; @@ -30474,6 +30972,14 @@ proto.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractMode proto.org.dash.platform.dapi.v0.ContractModerationReason.serializeBinaryToWriter ); } + f = message.getWarningsList(); + if (f.length > 0) { + writer.writeRepeatedMessage( + 6, + f, + proto.org.dash.platform.dapi.v0.ContractWarning.serializeBinaryToWriter + ); + } }; @@ -30660,6 +31166,44 @@ proto.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractMode }; +/** + * repeated ContractWarning warnings = 6; + * @return {!Array} + */ +proto.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractModerationStatus.prototype.getWarningsList = function() { + return /** @type{!Array} */ ( + jspb.Message.getRepeatedWrapperField(this, proto.org.dash.platform.dapi.v0.ContractWarning, 6)); +}; + + +/** + * @param {!Array} value + * @return {!proto.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractModerationStatus} returns this +*/ +proto.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractModerationStatus.prototype.setWarningsList = function(value) { + return jspb.Message.setRepeatedWrapperField(this, 6, value); +}; + + +/** + * @param {!proto.org.dash.platform.dapi.v0.ContractWarning=} opt_value + * @param {number=} opt_index + * @return {!proto.org.dash.platform.dapi.v0.ContractWarning} + */ +proto.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractModerationStatus.prototype.addWarnings = function(opt_value, opt_index) { + return jspb.Message.addToRepeatedWrapperField(this, 6, opt_value, proto.org.dash.platform.dapi.v0.ContractWarning, opt_index); +}; + + +/** + * Clears the list making it empty but non-null. + * @return {!proto.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractModerationStatus} returns this + */ +proto.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractModerationStatus.prototype.clearWarningsList = function() { + return this.setWarningsList([]); +}; + + /** * Oneof group definitions for this message. Each group defines the field @@ -31626,6 +32170,13 @@ proto.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.serializeBi +/** + * List of repeated fields within this message type. + * @private {!Array} + * @const + */ +proto.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry.repeatedFields_ = [4]; + if (jspb.Message.GENERATE_TO_OBJECT) { @@ -31659,7 +32210,9 @@ proto.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractMod var f, obj = { identityId: msg.getIdentityId_asB64(), until: jspb.Message.getFieldWithDefault(msg, 2, 0), - reason: (f = msg.getReason()) && proto.org.dash.platform.dapi.v0.ContractModerationReason.toObject(includeInstance, f) + reason: (f = msg.getReason()) && proto.org.dash.platform.dapi.v0.ContractModerationReason.toObject(includeInstance, f), + warningsList: jspb.Message.toObjectList(msg.getWarningsList(), + proto.org.dash.platform.dapi.v0.ContractWarning.toObject, includeInstance) }; if (includeInstance) { @@ -31709,6 +32262,11 @@ proto.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractMod reader.readMessage(value,proto.org.dash.platform.dapi.v0.ContractModerationReason.deserializeBinaryFromReader); msg.setReason(value); break; + case 4: + var value = new proto.org.dash.platform.dapi.v0.ContractWarning; + reader.readMessage(value,proto.org.dash.platform.dapi.v0.ContractWarning.deserializeBinaryFromReader); + msg.addWarnings(value); + break; default: reader.skipField(); break; @@ -31760,6 +32318,14 @@ proto.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractMod proto.org.dash.platform.dapi.v0.ContractModerationReason.serializeBinaryToWriter ); } + f = message.getWarningsList(); + if (f.length > 0) { + writer.writeRepeatedMessage( + 4, + f, + proto.org.dash.platform.dapi.v0.ContractWarning.serializeBinaryToWriter + ); + } }; @@ -31878,6 +32444,44 @@ proto.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractMod }; +/** + * repeated ContractWarning warnings = 4; + * @return {!Array} + */ +proto.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry.prototype.getWarningsList = function() { + return /** @type{!Array} */ ( + jspb.Message.getRepeatedWrapperField(this, proto.org.dash.platform.dapi.v0.ContractWarning, 4)); +}; + + +/** + * @param {!Array} value + * @return {!proto.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry} returns this +*/ +proto.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry.prototype.setWarningsList = function(value) { + return jspb.Message.setRepeatedWrapperField(this, 4, value); +}; + + +/** + * @param {!proto.org.dash.platform.dapi.v0.ContractWarning=} opt_value + * @param {number=} opt_index + * @return {!proto.org.dash.platform.dapi.v0.ContractWarning} + */ +proto.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry.prototype.addWarnings = function(opt_value, opt_index) { + return jspb.Message.addToRepeatedWrapperField(this, 4, opt_value, proto.org.dash.platform.dapi.v0.ContractWarning, opt_index); +}; + + +/** + * Clears the list making it empty but non-null. + * @return {!proto.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry} returns this + */ +proto.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry.prototype.clearWarningsList = function() { + return this.setWarningsList([]); +}; + + /** * List of repeated fields within this message type. @@ -33414,6 +34018,190 @@ proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.serializeBin +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.prototype.toObject = function(opt_includeInstance) { + return proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.toObject = function(includeInstance, msg) { + var f, obj = { + moderatorId: msg.getModeratorId_asB64(), + restoredAt: jspb.Message.getFieldWithDefault(msg, 2, 0) + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration} + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration; + return proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration} + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = /** @type {!Uint8Array} */ (reader.readBytes()); + msg.setModeratorId(value); + break; + case 2: + var value = /** @type {number} */ (reader.readUint64()); + msg.setRestoredAt(value); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = message.getModeratorId_asU8(); + if (f.length > 0) { + writer.writeBytes( + 1, + f + ); + } + f = message.getRestoredAt(); + if (f !== 0) { + writer.writeUint64( + 2, + f + ); + } +}; + + +/** + * optional bytes moderator_id = 1; + * @return {string} + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.prototype.getModeratorId = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 1, "")); +}; + + +/** + * optional bytes moderator_id = 1; + * This is a type-conversion wrapper around `getModeratorId()` + * @return {string} + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.prototype.getModeratorId_asB64 = function() { + return /** @type {string} */ (jspb.Message.bytesAsB64( + this.getModeratorId())); +}; + + +/** + * optional bytes moderator_id = 1; + * Note that Uint8Array is not supported on all browsers. + * @see http://caniuse.com/Uint8Array + * This is a type-conversion wrapper around `getModeratorId()` + * @return {!Uint8Array} + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.prototype.getModeratorId_asU8 = function() { + return /** @type {!Uint8Array} */ (jspb.Message.bytesAsU8( + this.getModeratorId())); +}; + + +/** + * @param {!(string|Uint8Array)} value + * @return {!proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration} returns this + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.prototype.setModeratorId = function(value) { + return jspb.Message.setProto3BytesField(this, 1, value); +}; + + +/** + * optional uint64 restored_at = 2; + * @return {number} + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.prototype.getRestoredAt = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 2, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration} returns this + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.prototype.setRestoredAt = function(value) { + return jspb.Message.setProto3IntField(this, 2, value); +}; + + + + + if (jspb.Message.GENERATE_TO_OBJECT) { /** * Creates an object representation of this proto. @@ -33447,7 +34235,9 @@ proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocu documentOwnerId: msg.getDocumentOwnerId_asB64(), moderatorId: msg.getModeratorId_asB64(), removedAt: jspb.Message.getFieldWithDefault(msg, 4, 0), - reason: (f = msg.getReason()) && proto.org.dash.platform.dapi.v0.ContractModerationReason.toObject(includeInstance, f) + reason: (f = msg.getReason()) && proto.org.dash.platform.dapi.v0.ContractModerationReason.toObject(includeInstance, f), + documentHash: msg.getDocumentHash_asB64(), + restoration: (f = msg.getRestoration()) && proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.toObject(includeInstance, f) }; if (includeInstance) { @@ -33505,6 +34295,15 @@ proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocu reader.readMessage(value,proto.org.dash.platform.dapi.v0.ContractModerationReason.deserializeBinaryFromReader); msg.setReason(value); break; + case 6: + var value = /** @type {!Uint8Array} */ (reader.readBytes()); + msg.setDocumentHash(value); + break; + case 7: + var value = new proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration; + reader.readMessage(value,proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.deserializeBinaryFromReader); + msg.setRestoration(value); + break; default: reader.skipField(); break; @@ -33570,6 +34369,21 @@ proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocu proto.org.dash.platform.dapi.v0.ContractModerationReason.serializeBinaryToWriter ); } + f = message.getDocumentHash_asU8(); + if (f.length > 0) { + writer.writeBytes( + 6, + f + ); + } + f = message.getRestoration(); + if (f != null) { + writer.writeMessage( + 7, + f, + proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.serializeBinaryToWriter + ); + } }; @@ -33754,6 +34568,85 @@ proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocu }; +/** + * optional bytes document_hash = 6; + * @return {string} + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval.prototype.getDocumentHash = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 6, "")); +}; + + +/** + * optional bytes document_hash = 6; + * This is a type-conversion wrapper around `getDocumentHash()` + * @return {string} + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval.prototype.getDocumentHash_asB64 = function() { + return /** @type {string} */ (jspb.Message.bytesAsB64( + this.getDocumentHash())); +}; + + +/** + * optional bytes document_hash = 6; + * Note that Uint8Array is not supported on all browsers. + * @see http://caniuse.com/Uint8Array + * This is a type-conversion wrapper around `getDocumentHash()` + * @return {!Uint8Array} + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval.prototype.getDocumentHash_asU8 = function() { + return /** @type {!Uint8Array} */ (jspb.Message.bytesAsU8( + this.getDocumentHash())); +}; + + +/** + * @param {!(string|Uint8Array)} value + * @return {!proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval} returns this + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval.prototype.setDocumentHash = function(value) { + return jspb.Message.setProto3BytesField(this, 6, value); +}; + + +/** + * optional ContractDocumentRestoration restoration = 7; + * @return {?proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration} + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval.prototype.getRestoration = function() { + return /** @type{?proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration} */ ( + jspb.Message.getWrapperField(this, proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration, 7)); +}; + + +/** + * @param {?proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration|undefined} value + * @return {!proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval} returns this +*/ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval.prototype.setRestoration = function(value) { + return jspb.Message.setWrapperField(this, 7, value); +}; + + +/** + * Clears the message field making it undefined. + * @return {!proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval} returns this + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval.prototype.clearRestoration = function() { + return this.setRestoration(undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval.prototype.hasRestoration = function() { + return jspb.Message.getField(this, 7) != null; +}; + + /** * List of repeated fields within this message type. @@ -112676,7 +113569,8 @@ proto.org.dash.platform.dapi.v0.KeyPurpose = { proto.org.dash.platform.dapi.v0.ContractModerationList = { CONTRACT_MODERATION_LIST_UNSPECIFIED: 0, CONTRACT_MODERATION_LIST_BANLIST: 1, - CONTRACT_MODERATION_LIST_SUSPENSIONS: 2 + CONTRACT_MODERATION_LIST_SUSPENSIONS: 2, + CONTRACT_MODERATION_LIST_WARNINGS: 3 }; goog.object.extend(exports, proto.org.dash.platform.dapi.v0); diff --git a/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.h b/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.h index 3636cc20ee2..f54c64a2361 100644 --- a/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.h +++ b/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.h @@ -41,7 +41,9 @@ CF_EXTERN_C_BEGIN @class CompactedBlockAddressBalanceChanges; @class ContractGroupDocumentTypeMember; @class ContractGroupTokenMember; +@class ContractModerationDocument; @class ContractModerationReason; +@class ContractWarning; @class GPBBytesValue; @class GPBUInt32Value; @class GetAddressInfoRequest_GetAddressInfoRequestV0; @@ -81,6 +83,7 @@ CF_EXTERN_C_BEGIN @class GetContractDocumentRemovalsRequest_Page; @class GetContractDocumentRemovalsResponse_ContractDocumentRemoval; @class GetContractDocumentRemovalsResponse_ContractDocumentRemovals; +@class GetContractDocumentRemovalsResponse_ContractDocumentRestoration; @class GetContractDocumentRemovalsResponse_GetContractDocumentRemovalsResponseV0; @class GetContractFeePotsRequest_GetContractFeePotsRequestV0; @class GetContractFeePotsResponse_ContractFeePot; @@ -386,7 +389,7 @@ BOOL KeyPurpose_IsValidValue(int32_t value); #pragma mark - Enum ContractModerationList -/** One of the two moderation lists a moderated data contract may keep (protocol version 14). */ +/** One of the moderation lists a moderated data contract may keep (protocol version 14). */ typedef GPB_ENUM(ContractModerationList) { /** * Value used if any message's field encounters a value that is not defined @@ -402,6 +405,9 @@ typedef GPB_ENUM(ContractModerationList) { /** Identities barred until a block time */ ContractModerationList_ContractModerationListSuspensions = 2, + + /** Identities warned, barred from nothing */ + ContractModerationList_ContractModerationListWarnings = 3, }; GPBEnumDescriptor *ContractModerationList_EnumDescriptor(void); @@ -2973,16 +2979,38 @@ GPB_FINAL @interface GetContractGroupMembersResponse_GetContractGroupMembersResp **/ void GetContractGroupMembersResponse_GetContractGroupMembersResponseV0_ClearResultOneOfCase(GetContractGroupMembersResponse_GetContractGroupMembersResponseV0 *message); +#pragma mark - ContractModerationDocument + +typedef GPB_ENUM(ContractModerationDocument_FieldNumber) { + ContractModerationDocument_FieldNumber_DocumentTypeName = 1, + ContractModerationDocument_FieldNumber_DocumentId = 2, +}; + +/** + * A document a moderation reason is about. + **/ +GPB_FINAL @interface ContractModerationDocument : GPBMessage + +/** The document type, on the moderated contract */ +@property(nonatomic, readwrite, copy, null_resettable) NSString *documentTypeName; + +/** The 32-byte id of the document */ +@property(nonatomic, readwrite, copy, null_resettable) NSData *documentId; + +@end + #pragma mark - ContractModerationReason typedef GPB_ENUM(ContractModerationReason_FieldNumber) { ContractModerationReason_FieldNumber_Code = 1, ContractModerationReason_FieldNumber_Text = 2, + ContractModerationReason_FieldNumber_DocumentsArray = 3, }; /** - * Why a moderator banned or suspended an identity. Nothing checks what a - * moderator writes. + * Why a moderator banned, suspended or warned an identity, or deleted a + * document. Nothing checks what a moderator writes, and the documents cited + * are not looked up. **/ GPB_FINAL @interface ContractModerationReason : GPBMessage @@ -2993,6 +3021,33 @@ GPB_FINAL @interface ContractModerationReason : GPBMessage /** protocol version; a u16, expected unset today, never checked */ @property(nonatomic, readwrite, copy, null_resettable) NSString *text; +/** The documents the reason is about, at most 16, none twice */ +@property(nonatomic, readwrite, strong, null_resettable) NSMutableArray *documentsArray; +/** The number of items in @c documentsArray without causing the array to be created. */ +@property(nonatomic, readonly) NSUInteger documentsArray_Count; + +@end + +#pragma mark - ContractWarning + +typedef GPB_ENUM(ContractWarning_FieldNumber) { + ContractWarning_FieldNumber_WarnedAt = 1, + ContractWarning_FieldNumber_Reason = 2, +}; + +/** + * One warning an identity carries on a contract's warning list. + **/ +GPB_FINAL @interface ContractWarning : GPBMessage + +/** The time of the block that issued it, in milliseconds */ +@property(nonatomic, readwrite) uint64_t warnedAt; + +/** Why */ +@property(nonatomic, readwrite, strong, null_resettable) ContractModerationReason *reason; +/** Test to see if @c reason has been set. */ +@property(nonatomic, readwrite) BOOL hasReason; + @end #pragma mark - GetContractModerationStatusRequest @@ -3079,6 +3134,7 @@ typedef GPB_ENUM(GetContractModerationStatusResponse_ContractModerationStatus_Fi GetContractModerationStatusResponse_ContractModerationStatus_FieldNumber_ListsArray = 3, GetContractModerationStatusResponse_ContractModerationStatus_FieldNumber_BanReason = 4, GetContractModerationStatusResponse_ContractModerationStatus_FieldNumber_SuspensionReason = 5, + GetContractModerationStatusResponse_ContractModerationStatus_FieldNumber_WarningsArray = 6, }; /** @@ -3114,6 +3170,11 @@ GPB_FINAL @interface GetContractModerationStatusResponse_ContractModerationStatu /** Test to see if @c suspensionReason has been set. */ @property(nonatomic, readwrite) BOOL hasSuspensionReason; +/** When the warning list was read: the identity's warnings, oldest */ +@property(nonatomic, readwrite, strong, null_resettable) NSMutableArray *warningsArray; +/** The number of items in @c warningsArray without causing the array to be created. */ +@property(nonatomic, readonly) NSUInteger warningsArray_Count; + @end #pragma mark - GetContractModerationStatusResponse_GetContractModerationStatusResponseV0 @@ -3250,11 +3311,12 @@ typedef GPB_ENUM(GetContractModerationEntriesResponse_ContractModerationEntry_Fi GetContractModerationEntriesResponse_ContractModerationEntry_FieldNumber_IdentityId = 1, GetContractModerationEntriesResponse_ContractModerationEntry_FieldNumber_Until = 2, GetContractModerationEntriesResponse_ContractModerationEntry_FieldNumber_Reason = 3, + GetContractModerationEntriesResponse_ContractModerationEntry_FieldNumber_WarningsArray = 4, }; GPB_FINAL @interface GetContractModerationEntriesResponse_ContractModerationEntry : GPBMessage -/** The barred identity */ +/** The identity on the list */ @property(nonatomic, readwrite, copy, null_resettable) NSData *identityId; /** For a suspension list entry: the block time, in milliseconds, at */ @@ -3266,6 +3328,14 @@ GPB_FINAL @interface GetContractModerationEntriesResponse_ContractModerationEntr /** Test to see if @c reason has been set. */ @property(nonatomic, readwrite) BOOL hasReason; +/** + * suspension's. Unset for a warning list entry, whose reasons + * are its warnings' + **/ +@property(nonatomic, readwrite, strong, null_resettable) NSMutableArray *warningsArray; +/** The number of items in @c warningsArray without causing the array to be created. */ +@property(nonatomic, readonly) NSUInteger warningsArray_Count; + @end #pragma mark - GetContractModerationEntriesResponse_ContractModerationEntries @@ -3449,6 +3519,23 @@ GPB_FINAL @interface GetContractDocumentRemovalsResponse : GPBMessage **/ void GetContractDocumentRemovalsResponse_ClearVersionOneOfCase(GetContractDocumentRemovalsResponse *message); +#pragma mark - GetContractDocumentRemovalsResponse_ContractDocumentRestoration + +typedef GPB_ENUM(GetContractDocumentRemovalsResponse_ContractDocumentRestoration_FieldNumber) { + GetContractDocumentRemovalsResponse_ContractDocumentRestoration_FieldNumber_ModeratorId = 1, + GetContractDocumentRemovalsResponse_ContractDocumentRestoration_FieldNumber_RestoredAt = 2, +}; + +GPB_FINAL @interface GetContractDocumentRemovalsResponse_ContractDocumentRestoration : GPBMessage + +/** The contract owner or moderator that restored the document */ +@property(nonatomic, readwrite, copy, null_resettable) NSData *moderatorId; + +/** The time of the block that restored it, in milliseconds */ +@property(nonatomic, readwrite) uint64_t restoredAt; + +@end + #pragma mark - GetContractDocumentRemovalsResponse_ContractDocumentRemoval typedef GPB_ENUM(GetContractDocumentRemovalsResponse_ContractDocumentRemoval_FieldNumber) { @@ -3457,6 +3544,8 @@ typedef GPB_ENUM(GetContractDocumentRemovalsResponse_ContractDocumentRemoval_Fie GetContractDocumentRemovalsResponse_ContractDocumentRemoval_FieldNumber_ModeratorId = 3, GetContractDocumentRemovalsResponse_ContractDocumentRemoval_FieldNumber_RemovedAt = 4, GetContractDocumentRemovalsResponse_ContractDocumentRemoval_FieldNumber_Reason = 5, + GetContractDocumentRemovalsResponse_ContractDocumentRemoval_FieldNumber_DocumentHash = 6, + GetContractDocumentRemovalsResponse_ContractDocumentRemoval_FieldNumber_Restoration = 7, }; GPB_FINAL @interface GetContractDocumentRemovalsResponse_ContractDocumentRemoval : GPBMessage @@ -3478,6 +3567,17 @@ GPB_FINAL @interface GetContractDocumentRemovalsResponse_ContractDocumentRemoval /** Test to see if @c reason has been set. */ @property(nonatomic, readwrite) BOOL hasReason; +/** A 32-byte double SHA-256 of the document as it was serialized */ +@property(nonatomic, readwrite, copy, null_resettable) NSData *documentHash; + +/** + * under its document type when it was removed: what a restore must + * bring back byte for byte + **/ +@property(nonatomic, readwrite, strong, null_resettable) GetContractDocumentRemovalsResponse_ContractDocumentRestoration *restoration; +/** Test to see if @c restoration has been set. */ +@property(nonatomic, readwrite) BOOL hasRestoration; + @end #pragma mark - GetContractDocumentRemovalsResponse_ContractDocumentRemovals diff --git a/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.m b/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.m index 893a2078f3e..838e7fcfe14 100644 --- a/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.m +++ b/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.m @@ -41,7 +41,9 @@ GPBObjCClassDeclaration(CompactedBlockAddressBalanceChanges); GPBObjCClassDeclaration(ContractGroupDocumentTypeMember); GPBObjCClassDeclaration(ContractGroupTokenMember); +GPBObjCClassDeclaration(ContractModerationDocument); GPBObjCClassDeclaration(ContractModerationReason); +GPBObjCClassDeclaration(ContractWarning); GPBObjCClassDeclaration(GPBBytesValue); GPBObjCClassDeclaration(GPBUInt32Value); GPBObjCClassDeclaration(GetAddressInfoRequest); @@ -101,6 +103,7 @@ GPBObjCClassDeclaration(GetContractDocumentRemovalsResponse); GPBObjCClassDeclaration(GetContractDocumentRemovalsResponse_ContractDocumentRemoval); GPBObjCClassDeclaration(GetContractDocumentRemovalsResponse_ContractDocumentRemovals); +GPBObjCClassDeclaration(GetContractDocumentRemovalsResponse_ContractDocumentRestoration); GPBObjCClassDeclaration(GetContractDocumentRemovalsResponse_GetContractDocumentRemovalsResponseV0); GPBObjCClassDeclaration(GetContractFeePotsRequest); GPBObjCClassDeclaration(GetContractFeePotsRequest_GetContractFeePotsRequestV0); @@ -567,11 +570,13 @@ BOOL KeyPurpose_IsValidValue(int32_t value__) { static const char *valueNames = "ContractModerationListUnspecified\000Contra" "ctModerationListBanlist\000ContractModerati" - "onListSuspensions\000"; + "onListSuspensions\000ContractModerationList" + "Warnings\000"; static const int32_t values[] = { ContractModerationList_ContractModerationListUnspecified, ContractModerationList_ContractModerationListBanlist, ContractModerationList_ContractModerationListSuspensions, + ContractModerationList_ContractModerationListWarnings, }; GPBEnumDescriptor *worker = [GPBEnumDescriptor allocDescriptorForName:GPBNSStringifySymbol(ContractModerationList) @@ -592,6 +597,7 @@ BOOL ContractModerationList_IsValidValue(int32_t value__) { case ContractModerationList_ContractModerationListUnspecified: case ContractModerationList_ContractModerationListBanlist: case ContractModerationList_ContractModerationListSuspensions: + case ContractModerationList_ContractModerationListWarnings: return YES; default: return NO; @@ -6234,17 +6240,75 @@ void GetContractGroupMembersResponse_GetContractGroupMembersResponseV0_ClearResu GPBOneofDescriptor *oneof = [descriptor.oneofs objectAtIndex:0]; GPBClearOneof(message, oneof); } +#pragma mark - ContractModerationDocument + +@implementation ContractModerationDocument + +@dynamic documentTypeName; +@dynamic documentId; + +typedef struct ContractModerationDocument__storage_ { + uint32_t _has_storage_[1]; + NSString *documentTypeName; + NSData *documentId; +} ContractModerationDocument__storage_; + +// This method is threadsafe because it is initially called +// in +initialize for each subclass. ++ (GPBDescriptor *)descriptor { + static GPBDescriptor *descriptor = nil; + if (!descriptor) { + static GPBMessageFieldDescription fields[] = { + { + .name = "documentTypeName", + .dataTypeSpecific.clazz = Nil, + .number = ContractModerationDocument_FieldNumber_DocumentTypeName, + .hasIndex = 0, + .offset = (uint32_t)offsetof(ContractModerationDocument__storage_, documentTypeName), + .flags = (GPBFieldFlags)(GPBFieldOptional | GPBFieldClearHasIvarOnZero), + .dataType = GPBDataTypeString, + }, + { + .name = "documentId", + .dataTypeSpecific.clazz = Nil, + .number = ContractModerationDocument_FieldNumber_DocumentId, + .hasIndex = 1, + .offset = (uint32_t)offsetof(ContractModerationDocument__storage_, documentId), + .flags = (GPBFieldFlags)(GPBFieldOptional | GPBFieldClearHasIvarOnZero), + .dataType = GPBDataTypeBytes, + }, + }; + GPBDescriptor *localDescriptor = + [GPBDescriptor allocDescriptorForClass:[ContractModerationDocument class] + rootClass:[PlatformRoot class] + file:PlatformRoot_FileDescriptor() + fields:fields + fieldCount:(uint32_t)(sizeof(fields) / sizeof(GPBMessageFieldDescription)) + storageSize:sizeof(ContractModerationDocument__storage_) + flags:(GPBDescriptorInitializationFlags)(GPBDescriptorInitializationFlag_UsesClassRefs | GPBDescriptorInitializationFlag_Proto3OptionalKnown)]; + #if defined(DEBUG) && DEBUG + NSAssert(descriptor == nil, @"Startup recursed!"); + #endif // DEBUG + descriptor = localDescriptor; + } + return descriptor; +} + +@end + #pragma mark - ContractModerationReason @implementation ContractModerationReason @dynamic hasCode, code; @dynamic text; +@dynamic documentsArray, documentsArray_Count; typedef struct ContractModerationReason__storage_ { uint32_t _has_storage_[1]; uint32_t code; NSString *text; + NSMutableArray *documentsArray; } ContractModerationReason__storage_; // This method is threadsafe because it is initially called @@ -6271,6 +6335,15 @@ + (GPBDescriptor *)descriptor { .flags = (GPBFieldFlags)(GPBFieldOptional | GPBFieldClearHasIvarOnZero), .dataType = GPBDataTypeString, }, + { + .name = "documentsArray", + .dataTypeSpecific.clazz = GPBObjCClass(ContractModerationDocument), + .number = ContractModerationReason_FieldNumber_DocumentsArray, + .hasIndex = GPBNoHasBit, + .offset = (uint32_t)offsetof(ContractModerationReason__storage_, documentsArray), + .flags = GPBFieldRepeated, + .dataType = GPBDataTypeMessage, + }, }; GPBDescriptor *localDescriptor = [GPBDescriptor allocDescriptorForClass:[ContractModerationReason class] @@ -6290,6 +6363,62 @@ + (GPBDescriptor *)descriptor { @end +#pragma mark - ContractWarning + +@implementation ContractWarning + +@dynamic warnedAt; +@dynamic hasReason, reason; + +typedef struct ContractWarning__storage_ { + uint32_t _has_storage_[1]; + ContractModerationReason *reason; + uint64_t warnedAt; +} ContractWarning__storage_; + +// This method is threadsafe because it is initially called +// in +initialize for each subclass. ++ (GPBDescriptor *)descriptor { + static GPBDescriptor *descriptor = nil; + if (!descriptor) { + static GPBMessageFieldDescription fields[] = { + { + .name = "warnedAt", + .dataTypeSpecific.clazz = Nil, + .number = ContractWarning_FieldNumber_WarnedAt, + .hasIndex = 0, + .offset = (uint32_t)offsetof(ContractWarning__storage_, warnedAt), + .flags = (GPBFieldFlags)(GPBFieldOptional | GPBFieldClearHasIvarOnZero), + .dataType = GPBDataTypeUInt64, + }, + { + .name = "reason", + .dataTypeSpecific.clazz = GPBObjCClass(ContractModerationReason), + .number = ContractWarning_FieldNumber_Reason, + .hasIndex = 1, + .offset = (uint32_t)offsetof(ContractWarning__storage_, reason), + .flags = GPBFieldOptional, + .dataType = GPBDataTypeMessage, + }, + }; + GPBDescriptor *localDescriptor = + [GPBDescriptor allocDescriptorForClass:[ContractWarning class] + rootClass:[PlatformRoot class] + file:PlatformRoot_FileDescriptor() + fields:fields + fieldCount:(uint32_t)(sizeof(fields) / sizeof(GPBMessageFieldDescription)) + storageSize:sizeof(ContractWarning__storage_) + flags:(GPBDescriptorInitializationFlags)(GPBDescriptorInitializationFlag_UsesClassRefs | GPBDescriptorInitializationFlag_Proto3OptionalKnown)]; + #if defined(DEBUG) && DEBUG + NSAssert(descriptor == nil, @"Startup recursed!"); + #endif // DEBUG + descriptor = localDescriptor; + } + return descriptor; +} + +@end + #pragma mark - GetContractModerationStatusRequest @implementation GetContractModerationStatusRequest @@ -6491,12 +6620,14 @@ @implementation GetContractModerationStatusResponse_ContractModerationStatus @dynamic listsArray, listsArray_Count; @dynamic hasBanReason, banReason; @dynamic hasSuspensionReason, suspensionReason; +@dynamic warningsArray, warningsArray_Count; typedef struct GetContractModerationStatusResponse_ContractModerationStatus__storage_ { uint32_t _has_storage_[1]; GPBEnumArray *listsArray; ContractModerationReason *banReason; ContractModerationReason *suspensionReason; + NSMutableArray *warningsArray; uint64_t suspendedUntil; } GetContractModerationStatusResponse_ContractModerationStatus__storage_; @@ -6551,6 +6682,15 @@ + (GPBDescriptor *)descriptor { .flags = GPBFieldOptional, .dataType = GPBDataTypeMessage, }, + { + .name = "warningsArray", + .dataTypeSpecific.clazz = GPBObjCClass(ContractWarning), + .number = GetContractModerationStatusResponse_ContractModerationStatus_FieldNumber_WarningsArray, + .hasIndex = GPBNoHasBit, + .offset = (uint32_t)offsetof(GetContractModerationStatusResponse_ContractModerationStatus__storage_, warningsArray), + .flags = GPBFieldRepeated, + .dataType = GPBDataTypeMessage, + }, }; GPBDescriptor *localDescriptor = [GPBDescriptor allocDescriptorForClass:[GetContractModerationStatusResponse_ContractModerationStatus class] @@ -6873,11 +7013,13 @@ @implementation GetContractModerationEntriesResponse_ContractModerationEntry @dynamic identityId; @dynamic hasUntil, until; @dynamic hasReason, reason; +@dynamic warningsArray, warningsArray_Count; typedef struct GetContractModerationEntriesResponse_ContractModerationEntry__storage_ { uint32_t _has_storage_[1]; NSData *identityId; ContractModerationReason *reason; + NSMutableArray *warningsArray; uint64_t until; } GetContractModerationEntriesResponse_ContractModerationEntry__storage_; @@ -6914,6 +7056,15 @@ + (GPBDescriptor *)descriptor { .flags = GPBFieldOptional, .dataType = GPBDataTypeMessage, }, + { + .name = "warningsArray", + .dataTypeSpecific.clazz = GPBObjCClass(ContractWarning), + .number = GetContractModerationEntriesResponse_ContractModerationEntry_FieldNumber_WarningsArray, + .hasIndex = GPBNoHasBit, + .offset = (uint32_t)offsetof(GetContractModerationEntriesResponse_ContractModerationEntry__storage_, warningsArray), + .flags = GPBFieldRepeated, + .dataType = GPBDataTypeMessage, + }, }; GPBDescriptor *localDescriptor = [GPBDescriptor allocDescriptorForClass:[GetContractModerationEntriesResponse_ContractModerationEntry class] @@ -7378,6 +7529,63 @@ void GetContractDocumentRemovalsResponse_ClearVersionOneOfCase(GetContractDocume GPBOneofDescriptor *oneof = [descriptor.oneofs objectAtIndex:0]; GPBClearOneof(message, oneof); } +#pragma mark - GetContractDocumentRemovalsResponse_ContractDocumentRestoration + +@implementation GetContractDocumentRemovalsResponse_ContractDocumentRestoration + +@dynamic moderatorId; +@dynamic restoredAt; + +typedef struct GetContractDocumentRemovalsResponse_ContractDocumentRestoration__storage_ { + uint32_t _has_storage_[1]; + NSData *moderatorId; + uint64_t restoredAt; +} GetContractDocumentRemovalsResponse_ContractDocumentRestoration__storage_; + +// This method is threadsafe because it is initially called +// in +initialize for each subclass. ++ (GPBDescriptor *)descriptor { + static GPBDescriptor *descriptor = nil; + if (!descriptor) { + static GPBMessageFieldDescription fields[] = { + { + .name = "moderatorId", + .dataTypeSpecific.clazz = Nil, + .number = GetContractDocumentRemovalsResponse_ContractDocumentRestoration_FieldNumber_ModeratorId, + .hasIndex = 0, + .offset = (uint32_t)offsetof(GetContractDocumentRemovalsResponse_ContractDocumentRestoration__storage_, moderatorId), + .flags = (GPBFieldFlags)(GPBFieldOptional | GPBFieldClearHasIvarOnZero), + .dataType = GPBDataTypeBytes, + }, + { + .name = "restoredAt", + .dataTypeSpecific.clazz = Nil, + .number = GetContractDocumentRemovalsResponse_ContractDocumentRestoration_FieldNumber_RestoredAt, + .hasIndex = 1, + .offset = (uint32_t)offsetof(GetContractDocumentRemovalsResponse_ContractDocumentRestoration__storage_, restoredAt), + .flags = (GPBFieldFlags)(GPBFieldOptional | GPBFieldClearHasIvarOnZero), + .dataType = GPBDataTypeUInt64, + }, + }; + GPBDescriptor *localDescriptor = + [GPBDescriptor allocDescriptorForClass:[GetContractDocumentRemovalsResponse_ContractDocumentRestoration class] + rootClass:[PlatformRoot class] + file:PlatformRoot_FileDescriptor() + fields:fields + fieldCount:(uint32_t)(sizeof(fields) / sizeof(GPBMessageFieldDescription)) + storageSize:sizeof(GetContractDocumentRemovalsResponse_ContractDocumentRestoration__storage_) + flags:(GPBDescriptorInitializationFlags)(GPBDescriptorInitializationFlag_UsesClassRefs | GPBDescriptorInitializationFlag_Proto3OptionalKnown)]; + [localDescriptor setupContainingMessageClass:GPBObjCClass(GetContractDocumentRemovalsResponse)]; + #if defined(DEBUG) && DEBUG + NSAssert(descriptor == nil, @"Startup recursed!"); + #endif // DEBUG + descriptor = localDescriptor; + } + return descriptor; +} + +@end + #pragma mark - GetContractDocumentRemovalsResponse_ContractDocumentRemoval @implementation GetContractDocumentRemovalsResponse_ContractDocumentRemoval @@ -7387,6 +7595,8 @@ @implementation GetContractDocumentRemovalsResponse_ContractDocumentRemoval @dynamic moderatorId; @dynamic removedAt; @dynamic hasReason, reason; +@dynamic documentHash; +@dynamic hasRestoration, restoration; typedef struct GetContractDocumentRemovalsResponse_ContractDocumentRemoval__storage_ { uint32_t _has_storage_[1]; @@ -7394,6 +7604,8 @@ @implementation GetContractDocumentRemovalsResponse_ContractDocumentRemoval NSData *documentOwnerId; NSData *moderatorId; ContractModerationReason *reason; + NSData *documentHash; + GetContractDocumentRemovalsResponse_ContractDocumentRestoration *restoration; uint64_t removedAt; } GetContractDocumentRemovalsResponse_ContractDocumentRemoval__storage_; @@ -7448,6 +7660,24 @@ + (GPBDescriptor *)descriptor { .flags = GPBFieldOptional, .dataType = GPBDataTypeMessage, }, + { + .name = "documentHash", + .dataTypeSpecific.clazz = Nil, + .number = GetContractDocumentRemovalsResponse_ContractDocumentRemoval_FieldNumber_DocumentHash, + .hasIndex = 5, + .offset = (uint32_t)offsetof(GetContractDocumentRemovalsResponse_ContractDocumentRemoval__storage_, documentHash), + .flags = (GPBFieldFlags)(GPBFieldOptional | GPBFieldClearHasIvarOnZero), + .dataType = GPBDataTypeBytes, + }, + { + .name = "restoration", + .dataTypeSpecific.clazz = GPBObjCClass(GetContractDocumentRemovalsResponse_ContractDocumentRestoration), + .number = GetContractDocumentRemovalsResponse_ContractDocumentRemoval_FieldNumber_Restoration, + .hasIndex = 6, + .offset = (uint32_t)offsetof(GetContractDocumentRemovalsResponse_ContractDocumentRemoval__storage_, restoration), + .flags = GPBFieldOptional, + .dataType = GPBDataTypeMessage, + }, }; GPBDescriptor *localDescriptor = [GPBDescriptor allocDescriptorForClass:[GetContractDocumentRemovalsResponse_ContractDocumentRemoval class] diff --git a/packages/dapi-grpc/clients/platform/v0/python/platform_pb2.py b/packages/dapi-grpc/clients/platform/v0/python/platform_pb2.py index 1012ca63427..56f0877d1ad 100644 --- a/packages/dapi-grpc/clients/platform/v0/python/platform_pb2.py +++ b/packages/dapi-grpc/clients/platform/v0/python/platform_pb2.py @@ -23,7 +23,7 @@ syntax='proto3', serialized_options=None, create_key=_descriptor._internal_create_key, - serialized_pb=b'\n\x0eplatform.proto\x12\x19org.dash.platform.dapi.v0\x1a\x1egoogle/protobuf/wrappers.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x81\x01\n\x05Proof\x12\x15\n\rgrovedb_proof\x18\x01 \x01(\x0c\x12\x13\n\x0bquorum_hash\x18\x02 \x01(\x0c\x12\x11\n\tsignature\x18\x03 \x01(\x0c\x12\r\n\x05round\x18\x04 \x01(\r\x12\x15\n\rblock_id_hash\x18\x05 \x01(\x0c\x12\x13\n\x0bquorum_type\x18\x06 \x01(\r\"\x98\x01\n\x10ResponseMetadata\x12\x12\n\x06height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12 \n\x18\x63ore_chain_locked_height\x18\x02 \x01(\r\x12\r\n\x05\x65poch\x18\x03 \x01(\r\x12\x13\n\x07time_ms\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\x18\n\x10protocol_version\x18\x05 \x01(\r\x12\x10\n\x08\x63hain_id\x18\x06 \x01(\t\"L\n\x1dStateTransitionBroadcastError\x12\x0c\n\x04\x63ode\x18\x01 \x01(\r\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\";\n\x1f\x42roadcastStateTransitionRequest\x12\x18\n\x10state_transition\x18\x01 \x01(\x0c\"\"\n BroadcastStateTransitionResponse\"\xa4\x01\n\x12GetIdentityRequest\x12P\n\x02v0\x18\x01 \x01(\x0b\x32\x42.org.dash.platform.dapi.v0.GetIdentityRequest.GetIdentityRequestV0H\x00\x1a\x31\n\x14GetIdentityRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xc1\x01\n\x17GetIdentityNonceRequest\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetIdentityNonceRequest.GetIdentityNonceRequestV0H\x00\x1a?\n\x19GetIdentityNonceRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xf6\x01\n\x1fGetIdentityContractNonceRequest\x12j\n\x02v0\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetIdentityContractNonceRequest.GetIdentityContractNonceRequestV0H\x00\x1a\\\n!GetIdentityContractNonceRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x13\n\x0b\x63ontract_id\x18\x02 \x01(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xc0\x01\n\x19GetIdentityBalanceRequest\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetIdentityBalanceRequest.GetIdentityBalanceRequestV0H\x00\x1a\x38\n\x1bGetIdentityBalanceRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xec\x01\n$GetIdentityBalanceAndRevisionRequest\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetIdentityBalanceAndRevisionRequest.GetIdentityBalanceAndRevisionRequestV0H\x00\x1a\x43\n&GetIdentityBalanceAndRevisionRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x9e\x02\n\x13GetIdentityResponse\x12R\n\x02v0\x18\x01 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetIdentityResponse.GetIdentityResponseV0H\x00\x1a\xa7\x01\n\x15GetIdentityResponseV0\x12\x12\n\x08identity\x18\x01 \x01(\x0cH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xbc\x02\n\x18GetIdentityNonceResponse\x12\\\n\x02v0\x18\x01 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetIdentityNonceResponse.GetIdentityNonceResponseV0H\x00\x1a\xb6\x01\n\x1aGetIdentityNonceResponseV0\x12\x1c\n\x0eidentity_nonce\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xe5\x02\n GetIdentityContractNonceResponse\x12l\n\x02v0\x18\x01 \x01(\x0b\x32^.org.dash.platform.dapi.v0.GetIdentityContractNonceResponse.GetIdentityContractNonceResponseV0H\x00\x1a\xc7\x01\n\"GetIdentityContractNonceResponseV0\x12%\n\x17identity_contract_nonce\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\x8e\x02\n&GetIdentityKeysRemainingBudgetsRequest\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetIdentityKeysRemainingBudgetsRequest.GetIdentityKeysRemainingBudgetsRequestV0H\x00\x1a_\n(GetIdentityKeysRemainingBudgetsRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x0f\n\x07key_ids\x18\x02 \x03(\r\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\x96\x06\n\'GetIdentityKeysRemainingBudgetsResponse\x12z\n\x02v0\x18\x01 \x01(\x0b\x32l.org.dash.platform.dapi.v0.GetIdentityKeysRemainingBudgetsResponse.GetIdentityKeysRemainingBudgetsResponseV0H\x00\x1a\xe3\x04\n)GetIdentityKeysRemainingBudgetsResponseV0\x12\xa4\x01\n\x16keys_remaining_budgets\x18\x01 \x01(\x0b\x32\x81\x01.org.dash.platform.dapi.v0.GetIdentityKeysRemainingBudgetsResponse.GetIdentityKeysRemainingBudgetsResponseV0.KeysRemainingBudgetsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x61\n\x17KeyRemainingBudgetEntry\x12\x0e\n\x06key_id\x18\x01 \x01(\r\x12!\n\x10remaining_budget\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x88\x01\x01\x42\x13\n\x11_remaining_budget\x1a\xaf\x01\n\x14KeysRemainingBudgets\x12\x96\x01\n\x07\x65ntries\x18\x01 \x03(\x0b\x32\x84\x01.org.dash.platform.dapi.v0.GetIdentityKeysRemainingBudgetsResponse.GetIdentityKeysRemainingBudgetsResponseV0.KeyRemainingBudgetEntryB\x08\n\x06resultB\t\n\x07version\"\xbd\x02\n\x1aGetIdentityBalanceResponse\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetIdentityBalanceResponse.GetIdentityBalanceResponseV0H\x00\x1a\xb1\x01\n\x1cGetIdentityBalanceResponseV0\x12\x15\n\x07\x62\x61lance\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xb1\x04\n%GetIdentityBalanceAndRevisionResponse\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetIdentityBalanceAndRevisionResponse.GetIdentityBalanceAndRevisionResponseV0H\x00\x1a\x84\x03\n\'GetIdentityBalanceAndRevisionResponseV0\x12\x9b\x01\n\x14\x62\x61lance_and_revision\x18\x01 \x01(\x0b\x32{.org.dash.platform.dapi.v0.GetIdentityBalanceAndRevisionResponse.GetIdentityBalanceAndRevisionResponseV0.BalanceAndRevisionH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a?\n\x12\x42\x61lanceAndRevision\x12\x13\n\x07\x62\x61lance\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x14\n\x08revision\x18\x02 \x01(\x04\x42\x02\x30\x01\x42\x08\n\x06resultB\t\n\x07version\"\xd1\x01\n\x0eKeyRequestType\x12\x36\n\x08\x61ll_keys\x18\x01 \x01(\x0b\x32\".org.dash.platform.dapi.v0.AllKeysH\x00\x12@\n\rspecific_keys\x18\x02 \x01(\x0b\x32\'.org.dash.platform.dapi.v0.SpecificKeysH\x00\x12:\n\nsearch_key\x18\x03 \x01(\x0b\x32$.org.dash.platform.dapi.v0.SearchKeyH\x00\x42\t\n\x07request\"\t\n\x07\x41llKeys\"\x1f\n\x0cSpecificKeys\x12\x0f\n\x07key_ids\x18\x01 \x03(\r\"\xb6\x01\n\tSearchKey\x12I\n\x0bpurpose_map\x18\x01 \x03(\x0b\x32\x34.org.dash.platform.dapi.v0.SearchKey.PurposeMapEntry\x1a^\n\x0fPurposeMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\r\x12:\n\x05value\x18\x02 \x01(\x0b\x32+.org.dash.platform.dapi.v0.SecurityLevelMap:\x02\x38\x01\"\xbf\x02\n\x10SecurityLevelMap\x12]\n\x12security_level_map\x18\x01 \x03(\x0b\x32\x41.org.dash.platform.dapi.v0.SecurityLevelMap.SecurityLevelMapEntry\x1aw\n\x15SecurityLevelMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\r\x12M\n\x05value\x18\x02 \x01(\x0e\x32>.org.dash.platform.dapi.v0.SecurityLevelMap.KeyKindRequestType:\x02\x38\x01\"S\n\x12KeyKindRequestType\x12\x1f\n\x1b\x43URRENT_KEY_OF_KIND_REQUEST\x10\x00\x12\x1c\n\x18\x41LL_KEYS_OF_KIND_REQUEST\x10\x01\"\xda\x02\n\x16GetIdentityKeysRequest\x12X\n\x02v0\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetIdentityKeysRequest.GetIdentityKeysRequestV0H\x00\x1a\xda\x01\n\x18GetIdentityKeysRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12?\n\x0crequest_type\x18\x02 \x01(\x0b\x32).org.dash.platform.dapi.v0.KeyRequestType\x12+\n\x05limit\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12,\n\x06offset\x18\x04 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\t\n\x07version\"\x99\x03\n\x17GetIdentityKeysResponse\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetIdentityKeysResponse.GetIdentityKeysResponseV0H\x00\x1a\x96\x02\n\x19GetIdentityKeysResponseV0\x12\x61\n\x04keys\x18\x01 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetIdentityKeysResponse.GetIdentityKeysResponseV0.KeysH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x1a\n\x04Keys\x12\x12\n\nkeys_bytes\x18\x01 \x03(\x0c\x42\x08\n\x06resultB\t\n\x07version\"\xef\x02\n GetIdentitiesContractKeysRequest\x12l\n\x02v0\x18\x01 \x01(\x0b\x32^.org.dash.platform.dapi.v0.GetIdentitiesContractKeysRequest.GetIdentitiesContractKeysRequestV0H\x00\x1a\xd1\x01\n\"GetIdentitiesContractKeysRequestV0\x12\x16\n\x0eidentities_ids\x18\x01 \x03(\x0c\x12\x13\n\x0b\x63ontract_id\x18\x02 \x01(\x0c\x12\x1f\n\x12\x64ocument_type_name\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x37\n\x08purposes\x18\x04 \x03(\x0e\x32%.org.dash.platform.dapi.v0.KeyPurpose\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\x15\n\x13_document_type_nameB\t\n\x07version\"\xdf\x06\n!GetIdentitiesContractKeysResponse\x12n\n\x02v0\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetIdentitiesContractKeysResponse.GetIdentitiesContractKeysResponseV0H\x00\x1a\xbe\x05\n#GetIdentitiesContractKeysResponseV0\x12\x8a\x01\n\x0fidentities_keys\x18\x01 \x01(\x0b\x32o.org.dash.platform.dapi.v0.GetIdentitiesContractKeysResponse.GetIdentitiesContractKeysResponseV0.IdentitiesKeysH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aY\n\x0bPurposeKeys\x12\x36\n\x07purpose\x18\x01 \x01(\x0e\x32%.org.dash.platform.dapi.v0.KeyPurpose\x12\x12\n\nkeys_bytes\x18\x02 \x03(\x0c\x1a\x9f\x01\n\x0cIdentityKeys\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12z\n\x04keys\x18\x02 \x03(\x0b\x32l.org.dash.platform.dapi.v0.GetIdentitiesContractKeysResponse.GetIdentitiesContractKeysResponseV0.PurposeKeys\x1a\x90\x01\n\x0eIdentitiesKeys\x12~\n\x07\x65ntries\x18\x01 \x03(\x0b\x32m.org.dash.platform.dapi.v0.GetIdentitiesContractKeysResponse.GetIdentitiesContractKeysResponseV0.IdentityKeysB\x08\n\x06resultB\t\n\x07version\"\xa4\x02\n*GetEvonodesProposedEpochBlocksByIdsRequest\x12\x80\x01\n\x02v0\x18\x01 \x01(\x0b\x32r.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksByIdsRequest.GetEvonodesProposedEpochBlocksByIdsRequestV0H\x00\x1ah\n,GetEvonodesProposedEpochBlocksByIdsRequestV0\x12\x12\n\x05\x65poch\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x0b\n\x03ids\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\x08\n\x06_epochB\t\n\x07version\"\x92\x06\n&GetEvonodesProposedEpochBlocksResponse\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksResponse.GetEvonodesProposedEpochBlocksResponseV0H\x00\x1a\xe2\x04\n(GetEvonodesProposedEpochBlocksResponseV0\x12\xb1\x01\n#evonodes_proposed_block_counts_info\x18\x01 \x01(\x0b\x32\x81\x01.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksResponse.GetEvonodesProposedEpochBlocksResponseV0.EvonodesProposedBlocksH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a?\n\x15\x45vonodeProposedBlocks\x12\x13\n\x0bpro_tx_hash\x18\x01 \x01(\x0c\x12\x11\n\x05\x63ount\x18\x02 \x01(\x04\x42\x02\x30\x01\x1a\xc4\x01\n\x16\x45vonodesProposedBlocks\x12\xa9\x01\n\x1e\x65vonodes_proposed_block_counts\x18\x01 \x03(\x0b\x32\x80\x01.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksResponse.GetEvonodesProposedEpochBlocksResponseV0.EvonodeProposedBlocksB\x08\n\x06resultB\t\n\x07version\"\xf2\x02\n,GetEvonodesProposedEpochBlocksByRangeRequest\x12\x84\x01\n\x02v0\x18\x01 \x01(\x0b\x32v.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksByRangeRequest.GetEvonodesProposedEpochBlocksByRangeRequestV0H\x00\x1a\xaf\x01\n.GetEvonodesProposedEpochBlocksByRangeRequestV0\x12\x12\n\x05\x65poch\x18\x01 \x01(\rH\x01\x88\x01\x01\x12\x12\n\x05limit\x18\x02 \x01(\rH\x02\x88\x01\x01\x12\x15\n\x0bstart_after\x18\x03 \x01(\x0cH\x00\x12\x12\n\x08start_at\x18\x04 \x01(\x0cH\x00\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\x07\n\x05startB\x08\n\x06_epochB\x08\n\x06_limitB\t\n\x07version\"\xcd\x01\n\x1cGetIdentitiesBalancesRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetIdentitiesBalancesRequest.GetIdentitiesBalancesRequestV0H\x00\x1a<\n\x1eGetIdentitiesBalancesRequestV0\x12\x0b\n\x03ids\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x9f\x05\n\x1dGetIdentitiesBalancesResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetIdentitiesBalancesResponse.GetIdentitiesBalancesResponseV0H\x00\x1a\x8a\x04\n\x1fGetIdentitiesBalancesResponseV0\x12\x8a\x01\n\x13identities_balances\x18\x01 \x01(\x0b\x32k.org.dash.platform.dapi.v0.GetIdentitiesBalancesResponse.GetIdentitiesBalancesResponseV0.IdentitiesBalancesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aL\n\x0fIdentityBalance\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x18\n\x07\x62\x61lance\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x88\x01\x01\x42\n\n\x08_balance\x1a\x8f\x01\n\x12IdentitiesBalances\x12y\n\x07\x65ntries\x18\x01 \x03(\x0b\x32h.org.dash.platform.dapi.v0.GetIdentitiesBalancesResponse.GetIdentitiesBalancesResponseV0.IdentityBalanceB\x08\n\x06resultB\t\n\x07version\"\xb4\x01\n\x16GetDataContractRequest\x12X\n\x02v0\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetDataContractRequest.GetDataContractRequestV0H\x00\x1a\x35\n\x18GetDataContractRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xb3\x02\n\x17GetDataContractResponse\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetDataContractResponse.GetDataContractResponseV0H\x00\x1a\xb0\x01\n\x19GetDataContractResponseV0\x12\x17\n\rdata_contract\x18\x01 \x01(\x0cH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\x8c\x02\n%GetDataContractsLatestVersionsRequest\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetDataContractsLatestVersionsRequest.GetDataContractsLatestVersionsRequestV0H\x00\x1a`\n\'GetDataContractsLatestVersionsRequestV0\x12\x0b\n\x03ids\x18\x01 \x03(\x0c\x12\x19\n\x11include_contracts\x18\x02 \x01(\x08\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xfa\x05\n&GetDataContractsLatestVersionsResponse\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetDataContractsLatestVersionsResponse.GetDataContractsLatestVersionsResponseV0H\x00\x1a\x84\x01\n\x1e\x44\x61taContractLatestVersionEntry\x12\x12\n\nidentifier\x18\x01 \x01(\x0c\x12\x14\n\x07version\x18\x02 \x01(\rH\x00\x88\x01\x01\x12\x1a\n\rdata_contract\x18\x03 \x01(\x0cH\x01\x88\x01\x01\x42\n\n\x08_versionB\x10\n\x0e_data_contract\x1a\x90\x01\n\x1b\x44\x61taContractsLatestVersions\x12q\n\x07\x65ntries\x18\x01 \x03(\x0b\x32`.org.dash.platform.dapi.v0.GetDataContractsLatestVersionsResponse.DataContractLatestVersionEntry\x1a\xb0\x02\n(GetDataContractsLatestVersionsResponseV0\x12\x87\x01\n\x1e\x64\x61ta_contracts_latest_versions\x18\x01 \x01(\x0b\x32].org.dash.platform.dapi.v0.GetDataContractsLatestVersionsResponse.DataContractsLatestVersionsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"R\n\x1f\x43ontractGroupDocumentTypeMember\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\"G\n\x18\x43ontractGroupTokenMember\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x16\n\x0etoken_position\x18\x02 \x01(\r\"\xd7\x01\n\x1bGetContractGroupInfoRequest\x12\x62\n\x02v0\x18\x01 \x01(\x0b\x32T.org.dash.platform.dapi.v0.GetContractGroupInfoRequest.GetContractGroupInfoRequestV0H\x00\x1aI\n\x1dGetContractGroupInfoRequestV0\x12\x19\n\x11\x63ontract_group_id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x98\x04\n\x1cGetContractGroupInfoResponse\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetContractGroupInfoResponse.GetContractGroupInfoResponseV0H\x00\x1a~\n\x11\x43ontractGroupInfo\x12\x10\n\x08owner_id\x18\x01 \x01(\x0c\x12\x11\n\tadmin_ids\x18\x02 \x03(\x0c\x12\x11\n\x04name\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x18\n\x0b\x64\x65scription\x18\x04 \x01(\tH\x01\x88\x01\x01\x42\x07\n\x05_nameB\x0e\n\x0c_description\x1a\x86\x02\n\x1eGetContractGroupInfoResponseV0\x12h\n\x13\x63ontract_group_info\x18\x01 \x01(\x0b\x32I.org.dash.platform.dapi.v0.GetContractGroupInfoResponse.ContractGroupInfoH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xf8\x06\n\x1eGetContractGroupMembersRequest\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetContractGroupMembersRequest.GetContractGroupMembersRequestV0H\x00\x1a@\n\x14\x43ontractMembersQuery\x12\x18\n\x0bstart_after\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x42\x0e\n\x0c_start_after\x1a\x80\x01\n\x18\x44ocumentTypeMembersQuery\x12T\n\x0bstart_after\x18\x01 \x01(\x0b\x32:.org.dash.platform.dapi.v0.ContractGroupDocumentTypeMemberH\x00\x88\x01\x01\x42\x0e\n\x0c_start_after\x1ar\n\x11TokenMembersQuery\x12M\n\x0bstart_after\x18\x01 \x01(\x0b\x32\x33.org.dash.platform.dapi.v0.ContractGroupTokenMemberH\x00\x88\x01\x01\x42\x0e\n\x0c_start_after\x1a\xa7\x03\n GetContractGroupMembersRequestV0\x12\x19\n\x11\x63ontract_group_id\x18\x01 \x01(\x0c\x12\x63\n\tcontracts\x18\x02 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetContractGroupMembersRequest.ContractMembersQueryH\x00\x12l\n\x0e\x64ocument_types\x18\x03 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetContractGroupMembersRequest.DocumentTypeMembersQueryH\x00\x12]\n\x06tokens\x18\x04 \x01(\x0b\x32K.org.dash.platform.dapi.v0.GetContractGroupMembersRequest.TokenMembersQueryH\x00\x12\x12\n\x05limit\x18\x05 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\x06 \x01(\x08\x42\t\n\x07membersB\x08\n\x06_limitB\t\n\x07version\"\xc9\x06\n\x1fGetContractGroupMembersResponse\x12j\n\x02v0\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetContractGroupMembersResponse.GetContractGroupMembersResponseV0H\x00\x1a\'\n\x0f\x43ontractMembers\x12\x14\n\x0c\x63ontract_ids\x18\x01 \x03(\x0c\x1ai\n\x13\x44ocumentTypeMembers\x12R\n\x0e\x64ocument_types\x18\x01 \x03(\x0b\x32:.org.dash.platform.dapi.v0.ContractGroupDocumentTypeMember\x1aS\n\x0cTokenMembers\x12\x43\n\x06tokens\x18\x01 \x03(\x0b\x32\x33.org.dash.platform.dapi.v0.ContractGroupTokenMember\x1a\xc5\x03\n!GetContractGroupMembersResponseV0\x12_\n\tcontracts\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetContractGroupMembersResponse.ContractMembersH\x00\x12h\n\x0e\x64ocument_types\x18\x02 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetContractGroupMembersResponse.DocumentTypeMembersH\x00\x12Y\n\x06tokens\x18\x03 \x01(\x0b\x32G.org.dash.platform.dapi.v0.GetContractGroupMembersResponse.TokenMembersH\x00\x12\x31\n\x05proof\x18\x04 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x05 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"D\n\x18\x43ontractModerationReason\x12\x11\n\x04\x63ode\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x0c\n\x04text\x18\x02 \x01(\tB\x07\n\x05_code\"\xc5\x02\n\"GetContractModerationStatusRequest\x12p\n\x02v0\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetContractModerationStatusRequest.GetContractModerationStatusRequestV0H\x00\x1a\xa1\x01\n$GetContractModerationStatusRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x13\n\x0bidentity_id\x18\x02 \x01(\x0c\x12@\n\x05lists\x18\x03 \x03(\x0e\x32\x31.org.dash.platform.dapi.v0.ContractModerationList\x12\r\n\x05prove\x18\x04 \x01(\x08\x42\t\n\x07version\"\xae\x06\n#GetContractModerationStatusResponse\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.GetContractModerationStatusResponseV0H\x00\x1a\xf6\x02\n\x18\x43ontractModerationStatus\x12\x13\n\x06\x62\x61nned\x18\x01 \x01(\x08H\x00\x88\x01\x01\x12\x1c\n\x0fsuspended_until\x18\x02 \x01(\x04H\x01\x88\x01\x01\x12@\n\x05lists\x18\x03 \x03(\x0e\x32\x31.org.dash.platform.dapi.v0.ContractModerationList\x12L\n\nban_reason\x18\x04 \x01(\x0b\x32\x33.org.dash.platform.dapi.v0.ContractModerationReasonH\x02\x88\x01\x01\x12S\n\x11suspension_reason\x18\x05 \x01(\x0b\x32\x33.org.dash.platform.dapi.v0.ContractModerationReasonH\x03\x88\x01\x01\x42\t\n\x07_bannedB\x12\n\x10_suspended_untilB\r\n\x0b_ban_reasonB\x14\n\x12_suspension_reason\x1a\x8e\x02\n%GetContractModerationStatusResponseV0\x12i\n\x06status\x18\x01 \x01(\x0b\x32W.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractModerationStatusH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xfb\x02\n#GetContractModerationEntriesRequest\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetContractModerationEntriesRequest.GetContractModerationEntriesRequestV0H\x00\x1a\xd4\x01\n%GetContractModerationEntriesRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12?\n\x04list\x18\x02 \x01(\x0e\x32\x31.org.dash.platform.dapi.v0.ContractModerationList\x12\x18\n\x0bstart_after\x18\x03 \x01(\x0cH\x00\x88\x01\x01\x12\x12\n\x05limit\x18\x04 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\x0e\n\x0c_start_afterB\x08\n\x06_limitB\t\n\x07version\"\xd8\x05\n$GetContractModerationEntriesResponse\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.GetContractModerationEntriesResponseV0H\x00\x1a\x91\x01\n\x17\x43ontractModerationEntry\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x12\n\x05until\x18\x02 \x01(\x04H\x00\x88\x01\x01\x12\x43\n\x06reason\x18\x03 \x01(\x0b\x32\x33.org.dash.platform.dapi.v0.ContractModerationReasonB\x08\n\x06_until\x1a\x85\x01\n\x19\x43ontractModerationEntries\x12h\n\x07\x65ntries\x18\x01 \x03(\x0b\x32W.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry\x1a\x92\x02\n&GetContractModerationEntriesResponseV0\x12l\n\x07\x65ntries\x18\x01 \x01(\x0b\x32Y.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntriesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc3\x04\n\"GetContractDocumentRemovalsRequest\x12p\n\x02v0\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetContractDocumentRemovalsRequest.GetContractDocumentRemovalsRequestV0H\x00\x1a#\n\x0b\x44ocumentIds\x12\x14\n\x0c\x64ocument_ids\x18\x01 \x03(\x0c\x1aN\n\x04Page\x12\x18\n\x0bstart_after\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x12\x12\n\x05limit\x18\x02 \x01(\rH\x01\x88\x01\x01\x42\x0e\n\x0c_start_afterB\x08\n\x06_limit\x1a\xaa\x02\n$GetContractDocumentRemovalsRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\x61\n\x0c\x64ocument_ids\x18\x03 \x01(\x0b\x32I.org.dash.platform.dapi.v0.GetContractDocumentRemovalsRequest.DocumentIdsH\x00\x12R\n\x04page\x18\x04 \x01(\x0b\x32\x42.org.dash.platform.dapi.v0.GetContractDocumentRemovalsRequest.PageH\x00\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\x0b\n\tselectionB\t\n\x07version\"\xf9\x05\n#GetContractDocumentRemovalsResponse\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.GetContractDocumentRemovalsResponseV0H\x00\x1a\xb8\x01\n\x17\x43ontractDocumentRemoval\x12\x13\n\x0b\x64ocument_id\x18\x01 \x01(\x0c\x12\x19\n\x11\x64ocument_owner_id\x18\x02 \x01(\x0c\x12\x14\n\x0cmoderator_id\x18\x03 \x01(\x0c\x12\x12\n\nremoved_at\x18\x04 \x01(\x04\x12\x43\n\x06reason\x18\x05 \x01(\x0b\x32\x33.org.dash.platform.dapi.v0.ContractModerationReason\x1a\x84\x01\n\x18\x43ontractDocumentRemovals\x12h\n\x08removals\x18\x01 \x03(\x0b\x32V.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval\x1a\x90\x02\n%GetContractDocumentRemovalsResponseV0\x12k\n\x08removals\x18\x01 \x01(\x0b\x32W.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemovalsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc9\x01\n\x19GetContractFeePotsRequest\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetContractFeePotsRequest.GetContractFeePotsRequestV0H\x00\x1a\x41\n\x1bGetContractFeePotsRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x9f\x06\n\x1aGetContractFeePotsResponse\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetContractFeePotsResponse.GetContractFeePotsResponseV0H\x00\x1aR\n\x17\x43ontractFeePotLastClaim\x12\r\n\x05\x65poch\x18\x01 \x01(\r\x12\x13\n\x07time_ms\x18\x02 \x01(\x04\x42\x02\x30\x01\x12\x13\n\x0b\x63laimant_id\x18\x03 \x01(\x0c\x1a\x88\x01\n\x0e\x43ontractFeePot\x12\x13\n\x07\x63redits\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x61\n\nlast_claim\x18\x02 \x01(\x0b\x32M.org.dash.platform.dapi.v0.GetContractFeePotsResponse.ContractFeePotLastClaim\x1a\xc0\x01\n\x0f\x43ontractFeePots\x12S\n\x05owner\x18\x01 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetContractFeePotsResponse.ContractFeePot\x12X\n\nmoderators\x18\x02 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetContractFeePotsResponse.ContractFeePot\x1a\xf1\x01\n\x1cGetContractFeePotsResponseV0\x12U\n\x04pots\x18\x01 \x01(\x0b\x32\x45.org.dash.platform.dapi.v0.GetContractFeePotsResponse.ContractFeePotsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xf1\x01\n#GetContractGroupsForContractRequest\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetContractGroupsForContractRequest.GetContractGroupsForContractRequestV0H\x00\x1aK\n%GetContractGroupsForContractRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xf5\x06\n$GetContractGroupsForContractResponse\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetContractGroupsForContractResponse.GetContractGroupsForContractResponseV0H\x00\x1aQ\n\x17\x44ocumentTypeMemberships\x12\x1a\n\x12\x64ocument_type_name\x18\x01 \x01(\t\x12\x1a\n\x12\x63ontract_group_ids\x18\x02 \x03(\x0c\x1a\x46\n\x10TokenMemberships\x12\x16\n\x0etoken_position\x18\x01 \x01(\r\x12\x1a\n\x12\x63ontract_group_ids\x18\x02 \x03(\x0c\x1a\x89\x02\n\x18\x43ontractGroupMemberships\x12\x1a\n\x12\x63ontract_group_ids\x18\x01 \x03(\x0c\x12o\n\x0e\x64ocument_types\x18\x02 \x03(\x0b\x32W.org.dash.platform.dapi.v0.GetContractGroupsForContractResponse.DocumentTypeMemberships\x12`\n\x06tokens\x18\x03 \x03(\x0b\x32P.org.dash.platform.dapi.v0.GetContractGroupsForContractResponse.TokenMemberships\x1a\xa4\x02\n&GetContractGroupsForContractResponseV0\x12~\n\x1a\x63ontract_group_memberships\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetContractGroupsForContractResponse.ContractGroupMembershipsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xb9\x01\n\x17GetDataContractsRequest\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetDataContractsRequest.GetDataContractsRequestV0H\x00\x1a\x37\n\x19GetDataContractsRequestV0\x12\x0b\n\x03ids\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xad\x02\n\x1eGetDataContractsByRangeRequest\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetDataContractsByRangeRequest.GetDataContractsByRangeRequestV0H\x00\x1a\x95\x01\n GetDataContractsByRangeRequestV0\x12\x12\n\x05limit\x18\x01 \x01(\rH\x01\x88\x01\x01\x12\x15\n\x0bstart_after\x18\x02 \x01(\x0cH\x00\x12\x12\n\x08start_at\x18\x03 \x01(\x0cH\x00\x12\x10\n\x08ids_only\x18\x04 \x01(\x08\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\x07\n\x05startB\x08\n\x06_limitB\t\n\x07version\"\xcf\x04\n\x18GetDataContractsResponse\x12\\\n\x02v0\x18\x01 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetDataContractsResponse.GetDataContractsResponseV0H\x00\x1a[\n\x11\x44\x61taContractEntry\x12\x12\n\nidentifier\x18\x01 \x01(\x0c\x12\x32\n\rdata_contract\x18\x02 \x01(\x0b\x32\x1b.google.protobuf.BytesValue\x1au\n\rDataContracts\x12\x64\n\x15\x64\x61ta_contract_entries\x18\x01 \x03(\x0b\x32\x45.org.dash.platform.dapi.v0.GetDataContractsResponse.DataContractEntry\x1a\xf5\x01\n\x1aGetDataContractsResponseV0\x12[\n\x0e\x64\x61ta_contracts\x18\x01 \x01(\x0b\x32\x41.org.dash.platform.dapi.v0.GetDataContractsResponse.DataContractsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc5\x02\n\x1dGetDataContractHistoryRequest\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetDataContractHistoryRequest.GetDataContractHistoryRequestV0H\x00\x1a\xb0\x01\n\x1fGetDataContractHistoryRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12+\n\x05limit\x18\x02 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12,\n\x06offset\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\x17\n\x0bstart_at_ms\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\t\n\x07version\"\xb2\x05\n\x1eGetDataContractHistoryResponse\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetDataContractHistoryResponse.GetDataContractHistoryResponseV0H\x00\x1a\x9a\x04\n GetDataContractHistoryResponseV0\x12\x8f\x01\n\x15\x64\x61ta_contract_history\x18\x01 \x01(\x0b\x32n.org.dash.platform.dapi.v0.GetDataContractHistoryResponse.GetDataContractHistoryResponseV0.DataContractHistoryH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a;\n\x18\x44\x61taContractHistoryEntry\x12\x10\n\x04\x64\x61te\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05value\x18\x02 \x01(\x0c\x1a\xaa\x01\n\x13\x44\x61taContractHistory\x12\x92\x01\n\x15\x64\x61ta_contract_entries\x18\x01 \x03(\x0b\x32s.org.dash.platform.dapi.v0.GetDataContractHistoryResponse.GetDataContractHistoryResponseV0.DataContractHistoryEntryB\x08\n\x06resultB\t\n\x07version\"\xe0\x1f\n\x13GetDocumentsRequest\x12R\n\x02v0\x18\x01 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV0H\x00\x12R\n\x02v1\x18\x02 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1H\x00\x1a\xfe\x02\n\x12\x44ocumentFieldValue\x12\x14\n\nbool_value\x18\x01 \x01(\x08H\x00\x12\x19\n\x0bint64_value\x18\x02 \x01(\x12\x42\x02\x30\x01H\x00\x12\x1a\n\x0cuint64_value\x18\x03 \x01(\x04\x42\x02\x30\x01H\x00\x12\x16\n\x0c\x64ouble_value\x18\x04 \x01(\x01H\x00\x12\x0e\n\x04text\x18\x05 \x01(\tH\x00\x12\x15\n\x0b\x62ytes_value\x18\x06 \x01(\x0cH\x00\x12[\n\x04list\x18\x07 \x01(\x0b\x32K.org.dash.platform.dapi.v0.GetDocumentsRequest.DocumentFieldValue.ValueListH\x00\x12\x14\n\nnull_value\x18\x08 \x01(\x08H\x00\x1a^\n\tValueList\x12Q\n\x06values\x18\x01 \x03(\x0b\x32\x41.org.dash.platform.dapi.v0.GetDocumentsRequest.DocumentFieldValueB\t\n\x07variant\x1a\xe2\x02\n\x12TimeRangeSelection\x12\\\n\x08selector\x18\x01 \x01(\x0e\x32J.org.dash.platform.dapi.v0.GetDocumentsRequest.TimeRangeSelection.Selector\x12\x19\n\x08start_ms\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x88\x01\x01\x12T\n\x04grid\x18\x03 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetDocumentsRequest.TimeRangeSelection.Grid\x1a>\n\x04Grid\x12\x11\n\x05range\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x10\n\x04step\x18\x02 \x01(\x04\x42\x02\x30\x01\x12\x11\n\x05phase\x18\x03 \x01(\x04\x42\x02\x30\x01\"0\n\x08Selector\x12\n\n\x06NEWEST\x10\x00\x12\n\n\x06OLDEST\x10\x01\x12\x0c\n\x08\x42Y_START\x10\x02\x42\x0b\n\t_start_ms\x1a\x95\x02\n\x0bWhereClause\x12\r\n\x05\x66ield\x18\x01 \x01(\t\x12N\n\x08operator\x18\x02 \x01(\x0e\x32<.org.dash.platform.dapi.v0.GetDocumentsRequest.WhereOperator\x12P\n\x05value\x18\x03 \x01(\x0b\x32\x41.org.dash.platform.dapi.v0.GetDocumentsRequest.DocumentFieldValue\x12U\n\ntime_range\x18\x04 \x01(\x0b\x32\x41.org.dash.platform.dapi.v0.GetDocumentsRequest.TimeRangeSelection\x1a\xa4\x01\n\x0fHavingAggregate\x12Y\n\x08\x66unction\x18\x01 \x01(\x0e\x32G.org.dash.platform.dapi.v0.GetDocumentsRequest.HavingAggregate.Function\x12\r\n\x05\x66ield\x18\x02 \x01(\t\"\'\n\x08\x46unction\x12\t\n\x05\x43OUNT\x10\x00\x12\x07\n\x03SUM\x10\x01\x12\x07\n\x03\x41VG\x10\x02\x1a\xf9\x03\n\x0cHavingClause\x12Q\n\taggregate\x18\x01 \x01(\x0b\x32>.org.dash.platform.dapi.v0.GetDocumentsRequest.HavingAggregate\x12V\n\x08operator\x18\x02 \x01(\x0e\x32\x44.org.dash.platform.dapi.v0.GetDocumentsRequest.HavingClause.Operator\x12R\n\x05value\x18\x03 \x01(\x0b\x32\x41.org.dash.platform.dapi.v0.GetDocumentsRequest.DocumentFieldValueH\x00\"\xe0\x01\n\x08Operator\x12\t\n\x05\x45QUAL\x10\x00\x12\r\n\tNOT_EQUAL\x10\x01\x12\x10\n\x0cGREATER_THAN\x10\x02\x12\x1a\n\x16GREATER_THAN_OR_EQUALS\x10\x03\x12\r\n\tLESS_THAN\x10\x04\x12\x17\n\x13LESS_THAN_OR_EQUALS\x10\x05\x12\x0b\n\x07\x42\x45TWEEN\x10\x06\x12\x1a\n\x16\x42\x45TWEEN_EXCLUDE_BOUNDS\x10\x07\x12\x18\n\x14\x42\x45TWEEN_EXCLUDE_LEFT\x10\x08\x12\x19\n\x15\x42\x45TWEEN_EXCLUDE_RIGHT\x10\t\x12\x06\n\x02IN\x10\nB\x07\n\x05right\x1a\x90\x01\n\x0bOrderClause\x12\x0f\n\x05\x66ield\x18\x01 \x01(\tH\x00\x12S\n\taggregate\x18\x03 \x01(\x0b\x32>.org.dash.platform.dapi.v0.GetDocumentsRequest.HavingAggregateH\x00\x12\x11\n\tascending\x18\x02 \x01(\x08\x42\x08\n\x06target\x1a\xbb\x01\n\x15GetDocumentsRequestV0\x12\x18\n\x10\x64\x61ta_contract_id\x18\x01 \x01(\x0c\x12\x15\n\rdocument_type\x18\x02 \x01(\t\x12\r\n\x05where\x18\x03 \x01(\x0c\x12\x10\n\x08order_by\x18\x04 \x01(\x0c\x12\r\n\x05limit\x18\x05 \x01(\r\x12\x15\n\x0bstart_after\x18\x06 \x01(\x0cH\x00\x12\x12\n\x08start_at\x18\x07 \x01(\x0cH\x00\x12\r\n\x05prove\x18\x08 \x01(\x08\x42\x07\n\x05start\x1a\xa6\x0c\n\x15GetDocumentsRequestV1\x12\x18\n\x10\x64\x61ta_contract_id\x18\x01 \x01(\x0c\x12\x15\n\rdocument_type\x18\x02 \x01(\t\x12Q\n\rwhere_clauses\x18\x03 \x03(\x0b\x32:.org.dash.platform.dapi.v0.GetDocumentsRequest.WhereClause\x12L\n\x08order_by\x18\x04 \x03(\x0b\x32:.org.dash.platform.dapi.v0.GetDocumentsRequest.OrderClause\x12\x12\n\x05limit\x18\x05 \x01(\rH\x01\x88\x01\x01\x12\x15\n\x0bstart_after\x18\x06 \x01(\x0cH\x00\x12\x12\n\x08start_at\x18\x07 \x01(\x0cH\x00\x12\r\n\x05prove\x18\x08 \x01(\x08\x12\\\n\x07selects\x18\t \x03(\x0b\x32K.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1.Select\x12\x10\n\x08group_by\x18\n \x03(\t\x12K\n\x06having\x18\x0b \x03(\x0b\x32;.org.dash.platform.dapi.v0.GetDocumentsRequest.HavingClause\x12\x13\n\x06offset\x18\x0c \x01(\rH\x02\x88\x01\x01\x12\x61\n\x07\x63hained\x18\r \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1.ChainedJoin\x12\x62\n\x0bsub_queries\x18\x0e \x03(\x0b\x32M.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1.SubQuery\x1a\xc9\x01\n\x06Select\x12\x66\n\x08\x66unction\x18\x01 \x01(\x0e\x32T.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1.Select.Function\x12\r\n\x05\x66ield\x18\x02 \x01(\t\"H\n\x08\x46unction\x12\r\n\tDOCUMENTS\x10\x00\x12\t\n\x05\x43OUNT\x10\x01\x12\x07\n\x03SUM\x10\x02\x12\x07\n\x03\x41VG\x10\x03\x12\x07\n\x03MIN\x10\x04\x12\x07\n\x03MAX\x10\x05\x1a\x41\n\x0b\x43hainedJoin\x12\x15\n\rjoin_property\x18\x01 \x01(\t\x12\x1b\n\x13outer_document_type\x18\x02 \x01(\t\x1a\xa6\x04\n\x08SubQuery\x12\x18\n\x10\x64\x61ta_contract_id\x18\x01 \x01(\x0c\x12\x15\n\rdocument_type\x18\x02 \x01(\t\x12Q\n\rwhere_clauses\x18\x03 \x03(\x0b\x32:.org.dash.platform.dapi.v0.GetDocumentsRequest.WhereClause\x12L\n\x08order_by\x18\x04 \x03(\x0b\x32:.org.dash.platform.dapi.v0.GetDocumentsRequest.OrderClause\x12\x12\n\x05limit\x18\x05 \x01(\rH\x00\x88\x01\x01\x12`\n\x04kind\x18\x06 \x01(\x0e\x32R.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1.SubQuery.Kind\x12\x63\n\x04\x62ind\x18\x07 \x01(\x0b\x32U.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1.SubQuery.Binding\x1a\x41\n\x07\x42inding\x12\x0e\n\x06source\x18\x01 \x01(\r\x12\x17\n\x0fsource_property\x18\x02 \x01(\t\x12\r\n\x05\x66ield\x18\x03 \x01(\t\" \n\x04Kind\x12\r\n\tDOCUMENTS\x10\x00\x12\t\n\x05\x43OUNT\x10\x01\x42\x08\n\x06_limitB\x07\n\x05startB\x08\n\x06_limitB\t\n\x07_offset\"\xfa\x01\n\rWhereOperator\x12\t\n\x05\x45QUAL\x10\x00\x12\x10\n\x0cGREATER_THAN\x10\x01\x12\x1a\n\x16GREATER_THAN_OR_EQUALS\x10\x02\x12\r\n\tLESS_THAN\x10\x03\x12\x17\n\x13LESS_THAN_OR_EQUALS\x10\x04\x12\x0b\n\x07\x42\x45TWEEN\x10\x05\x12\x1a\n\x16\x42\x45TWEEN_EXCLUDE_BOUNDS\x10\x06\x12\x18\n\x14\x42\x45TWEEN_EXCLUDE_LEFT\x10\x07\x12\x19\n\x15\x42\x45TWEEN_EXCLUDE_RIGHT\x10\x08\x12\x06\n\x02IN\x10\t\x12\x0f\n\x0bSTARTS_WITH\x10\n\x12\x11\n\rIN_TIME_RANGE\x10\x0b\x42\t\n\x07version\"\xf2\x1b\n\x14GetDocumentsResponse\x12T\n\x02v0\x18\x01 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV0H\x00\x12T\n\x02v1\x18\x02 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1H\x00\x1a\x9b\x02\n\x16GetDocumentsResponseV0\x12\x65\n\tdocuments\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV0.DocumentsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x1e\n\tDocuments\x12\x11\n\tdocuments\x18\x01 \x03(\x0c\x42\x08\n\x06result\x1a\x84\x18\n\x16GetDocumentsResponseV1\x12\x61\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.ResultDataH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x1e\n\tDocuments\x12\x11\n\tdocuments\x18\x01 \x03(\x0c\x1aL\n\nCountEntry\x12\x13\n\x06in_key\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x12\x0b\n\x03key\x18\x02 \x01(\x0c\x12\x11\n\x05\x63ount\x18\x03 \x01(\x04\x42\x02\x30\x01\x42\t\n\x07_in_key\x1ar\n\x0c\x43ountEntries\x12\x62\n\x07\x65ntries\x18\x01 \x03(\x0b\x32Q.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.CountEntry\x1a\xa0\x01\n\x0c\x43ountResults\x12\x1d\n\x0f\x61ggregate_count\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x66\n\x07\x65ntries\x18\x02 \x01(\x0b\x32S.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.CountEntriesH\x00\x42\t\n\x07variant\x1aH\n\x08SumEntry\x12\x13\n\x06in_key\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x12\x0b\n\x03key\x18\x02 \x01(\x0c\x12\x0f\n\x03sum\x18\x03 \x01(\x12\x42\x02\x30\x01\x42\t\n\x07_in_key\x1an\n\nSumEntries\x12`\n\x07\x65ntries\x18\x01 \x03(\x0b\x32O.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.SumEntry\x1a\x9a\x01\n\nSumResults\x12\x1b\n\raggregate_sum\x18\x01 \x01(\x12\x42\x02\x30\x01H\x00\x12\x64\n\x07\x65ntries\x18\x02 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.SumEntriesH\x00\x42\t\n\x07variant\x1a_\n\x0c\x41verageEntry\x12\x13\n\x06in_key\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x12\x0b\n\x03key\x18\x02 \x01(\x0c\x12\x11\n\x05\x63ount\x18\x03 \x01(\x04\x42\x02\x30\x01\x12\x0f\n\x03sum\x18\x04 \x01(\x12\x42\x02\x30\x01\x42\t\n\x07_in_key\x1av\n\x0e\x41verageEntries\x12\x64\n\x07\x65ntries\x18\x01 \x03(\x0b\x32S.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.AverageEntry\x1a\x36\n\x10\x41verageAggregate\x12\x11\n\x05\x63ount\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x0f\n\x03sum\x18\x02 \x01(\x12\x42\x02\x30\x01\x1a\xfb\x01\n\x0e\x41verageResults\x12t\n\x11\x61ggregate_average\x18\x01 \x01(\x0b\x32W.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.AverageAggregateH\x00\x12h\n\x07\x65ntries\x18\x02 \x01(\x0b\x32U.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.AverageEntriesH\x00\x42\t\n\x07variant\x1az\n\x0bRankedEntry\x12\x0b\n\x03key\x18\x01 \x01(\x0c\x12\x13\n\x05\x63ount\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x12\x11\n\x03sum\x18\x03 \x01(\x12\x42\x02\x30\x01H\x00\x12\r\n\x03\x61vg\x18\x04 \x01(\x01H\x00\x12\x13\n\x06in_key\x18\x05 \x01(\x0cH\x01\x88\x01\x01\x42\x07\n\x05valueB\t\n\x07_in_key\x1a\x9a\x01\n\rRankedEntries\x12\x63\n\x07\x65ntries\x18\x01 \x03(\x0b\x32R.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.RankedEntry\x12\x18\n\x07skipped\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x88\x01\x01\x42\n\n\x08_skipped\x1a\xf7\x05\n\nResultData\x12\x65\n\tdocuments\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.DocumentsH\x00\x12\x65\n\x06\x63ounts\x18\x02 \x01(\x0b\x32S.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.CountResultsH\x00\x12\x61\n\x04sums\x18\x03 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.SumResultsH\x00\x12i\n\x08\x61verages\x18\x04 \x01(\x0b\x32U.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.AverageResultsH\x00\x12\x66\n\x06ranked\x18\x05 \x01(\x0b\x32T.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.RankedEntriesH\x00\x12j\n\x07\x63hained\x18\x06 \x01(\x0b\x32W.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.ChainedDocumentsH\x00\x12n\n\tcomposite\x18\x07 \x01(\x0b\x32Y.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.CompositeDocumentsH\x00\x42\t\n\x07variant\x1a_\n\x10\x43hainedDocuments\x12\x17\n\x0finner_documents\x18\x01 \x03(\x0c\x12\x17\n\x0fouter_documents\x18\x02 \x03(\x0c\x12\x19\n\x11missing_outer_ids\x18\x03 \x03(\x0c\x1a\xab\x03\n\x12\x43ompositeDocuments\x12\x16\n\x0epage_documents\x18\x01 \x03(\x0c\x12}\n\x0bsub_results\x18\x02 \x03(\x0b\x32h.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.CompositeDocuments.SubQueryResult\x1a\xfd\x01\n\x0eSubQueryResult\x12\x65\n\tdocuments\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.DocumentsH\x00\x12\x65\n\x06\x63ounts\x18\x02 \x01(\x0b\x32S.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.CountEntriesH\x00\x12\x13\n\x0bmissing_ids\x18\x03 \x03(\x0c\x42\x08\n\x06resultB\x08\n\x06resultB\t\n\x07version\"\xf4\x02\n\x19GetDocumentHistoryRequest\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetDocumentHistoryRequest.GetDocumentHistoryRequestV0H\x00\x1a\xeb\x01\n\x1bGetDocumentHistoryRequestV0\x12\x18\n\x10\x64\x61ta_contract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\x13\n\x0b\x64ocument_id\x18\x03 \x01(\x0c\x12+\n\x05limit\x18\x04 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12,\n\x06offset\x18\x05 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\x17\n\x0bstart_at_ms\x18\x06 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05prove\x18\x07 \x01(\x08\x42\t\n\x07version\"\xf7\x04\n\x1aGetDocumentHistoryResponse\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetDocumentHistoryResponse.GetDocumentHistoryResponseV0H\x00\x1a\xeb\x03\n\x1cGetDocumentHistoryResponseV0\x12~\n\x10\x64ocument_history\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetDocumentHistoryResponse.GetDocumentHistoryResponseV0.DocumentHistoryH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x37\n\x14\x44ocumentHistoryEntry\x12\x10\n\x04\x64\x61te\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05value\x18\x02 \x01(\x0c\x1a\x95\x01\n\x0f\x44ocumentHistory\x12\x81\x01\n\x10\x64ocument_entries\x18\x01 \x03(\x0b\x32g.org.dash.platform.dapi.v0.GetDocumentHistoryResponse.GetDocumentHistoryResponseV0.DocumentHistoryEntryB\x08\n\x06resultB\t\n\x07version\"\xed\x01\n!GetIdentityByPublicKeyHashRequest\x12n\n\x02v0\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetIdentityByPublicKeyHashRequest.GetIdentityByPublicKeyHashRequestV0H\x00\x1aM\n#GetIdentityByPublicKeyHashRequestV0\x12\x17\n\x0fpublic_key_hash\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xda\x02\n\"GetIdentityByPublicKeyHashResponse\x12p\n\x02v0\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetIdentityByPublicKeyHashResponse.GetIdentityByPublicKeyHashResponseV0H\x00\x1a\xb6\x01\n$GetIdentityByPublicKeyHashResponseV0\x12\x12\n\x08identity\x18\x01 \x01(\x0cH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xbd\x02\n*GetIdentityByNonUniquePublicKeyHashRequest\x12\x80\x01\n\x02v0\x18\x01 \x01(\x0b\x32r.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashRequest.GetIdentityByNonUniquePublicKeyHashRequestV0H\x00\x1a\x80\x01\n,GetIdentityByNonUniquePublicKeyHashRequestV0\x12\x17\n\x0fpublic_key_hash\x18\x01 \x01(\x0c\x12\x18\n\x0bstart_after\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\x0e\n\x0c_start_afterB\t\n\x07version\"\xd6\x06\n+GetIdentityByNonUniquePublicKeyHashResponse\x12\x82\x01\n\x02v0\x18\x01 \x01(\x0b\x32t.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashResponse.GetIdentityByNonUniquePublicKeyHashResponseV0H\x00\x1a\x96\x05\n-GetIdentityByNonUniquePublicKeyHashResponseV0\x12\x9a\x01\n\x08identity\x18\x01 \x01(\x0b\x32\x85\x01.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashResponse.GetIdentityByNonUniquePublicKeyHashResponseV0.IdentityResponseH\x00\x12\x9d\x01\n\x05proof\x18\x02 \x01(\x0b\x32\x8b\x01.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashResponse.GetIdentityByNonUniquePublicKeyHashResponseV0.IdentityProvedResponseH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x36\n\x10IdentityResponse\x12\x15\n\x08identity\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x42\x0b\n\t_identity\x1a\xa6\x01\n\x16IdentityProvedResponse\x12P\n&grovedb_identity_public_key_hash_proof\x18\x01 \x01(\x0b\x32 .org.dash.platform.dapi.v0.Proof\x12!\n\x14identity_proof_bytes\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x42\x17\n\x15_identity_proof_bytesB\x08\n\x06resultB\t\n\x07version\"\xfb\x01\n#WaitForStateTransitionResultRequest\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.WaitForStateTransitionResultRequestV0H\x00\x1aU\n%WaitForStateTransitionResultRequestV0\x12\x1d\n\x15state_transition_hash\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x99\x03\n$WaitForStateTransitionResultResponse\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0H\x00\x1a\xef\x01\n&WaitForStateTransitionResultResponseV0\x12I\n\x05\x65rror\x18\x01 \x01(\x0b\x32\x38.org.dash.platform.dapi.v0.StateTransitionBroadcastErrorH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc4\x01\n\x19GetConsensusParamsRequest\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetConsensusParamsRequest.GetConsensusParamsRequestV0H\x00\x1a<\n\x1bGetConsensusParamsRequestV0\x12\x0e\n\x06height\x18\x01 \x01(\x05\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x9c\x04\n\x1aGetConsensusParamsResponse\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetConsensusParamsResponse.GetConsensusParamsResponseV0H\x00\x1aP\n\x14\x43onsensusParamsBlock\x12\x11\n\tmax_bytes\x18\x01 \x01(\t\x12\x0f\n\x07max_gas\x18\x02 \x01(\t\x12\x14\n\x0ctime_iota_ms\x18\x03 \x01(\t\x1a\x62\n\x17\x43onsensusParamsEvidence\x12\x1a\n\x12max_age_num_blocks\x18\x01 \x01(\t\x12\x18\n\x10max_age_duration\x18\x02 \x01(\t\x12\x11\n\tmax_bytes\x18\x03 \x01(\t\x1a\xda\x01\n\x1cGetConsensusParamsResponseV0\x12Y\n\x05\x62lock\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetConsensusParamsResponse.ConsensusParamsBlock\x12_\n\x08\x65vidence\x18\x02 \x01(\x0b\x32M.org.dash.platform.dapi.v0.GetConsensusParamsResponse.ConsensusParamsEvidenceB\t\n\x07version\"\xe4\x01\n%GetProtocolVersionUpgradeStateRequest\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateRequest.GetProtocolVersionUpgradeStateRequestV0H\x00\x1a\x38\n\'GetProtocolVersionUpgradeStateRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xb5\x05\n&GetProtocolVersionUpgradeStateResponse\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateResponse.GetProtocolVersionUpgradeStateResponseV0H\x00\x1a\x85\x04\n(GetProtocolVersionUpgradeStateResponseV0\x12\x87\x01\n\x08versions\x18\x01 \x01(\x0b\x32s.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateResponse.GetProtocolVersionUpgradeStateResponseV0.VersionsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x96\x01\n\x08Versions\x12\x89\x01\n\x08versions\x18\x01 \x03(\x0b\x32w.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateResponse.GetProtocolVersionUpgradeStateResponseV0.VersionEntry\x1a:\n\x0cVersionEntry\x12\x16\n\x0eversion_number\x18\x01 \x01(\r\x12\x12\n\nvote_count\x18\x02 \x01(\rB\x08\n\x06resultB\t\n\x07version\"\xa3\x02\n*GetProtocolVersionUpgradeVoteStatusRequest\x12\x80\x01\n\x02v0\x18\x01 \x01(\x0b\x32r.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusRequest.GetProtocolVersionUpgradeVoteStatusRequestV0H\x00\x1ag\n,GetProtocolVersionUpgradeVoteStatusRequestV0\x12\x19\n\x11start_pro_tx_hash\x18\x01 \x01(\x0c\x12\r\n\x05\x63ount\x18\x02 \x01(\r\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xef\x05\n+GetProtocolVersionUpgradeVoteStatusResponse\x12\x82\x01\n\x02v0\x18\x01 \x01(\x0b\x32t.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusResponse.GetProtocolVersionUpgradeVoteStatusResponseV0H\x00\x1a\xaf\x04\n-GetProtocolVersionUpgradeVoteStatusResponseV0\x12\x98\x01\n\x08versions\x18\x01 \x01(\x0b\x32\x83\x01.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusResponse.GetProtocolVersionUpgradeVoteStatusResponseV0.VersionSignalsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\xaf\x01\n\x0eVersionSignals\x12\x9c\x01\n\x0fversion_signals\x18\x01 \x03(\x0b\x32\x82\x01.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusResponse.GetProtocolVersionUpgradeVoteStatusResponseV0.VersionSignal\x1a\x35\n\rVersionSignal\x12\x13\n\x0bpro_tx_hash\x18\x01 \x01(\x0c\x12\x0f\n\x07version\x18\x02 \x01(\rB\x08\n\x06resultB\t\n\x07version\"\xf5\x01\n\x14GetEpochsInfoRequest\x12T\n\x02v0\x18\x01 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetEpochsInfoRequest.GetEpochsInfoRequestV0H\x00\x1a|\n\x16GetEpochsInfoRequestV0\x12\x31\n\x0bstart_epoch\x18\x01 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\r\n\x05\x63ount\x18\x02 \x01(\r\x12\x11\n\tascending\x18\x03 \x01(\x08\x12\r\n\x05prove\x18\x04 \x01(\x08\x42\t\n\x07version\"\x99\x05\n\x15GetEpochsInfoResponse\x12V\n\x02v0\x18\x01 \x01(\x0b\x32H.org.dash.platform.dapi.v0.GetEpochsInfoResponse.GetEpochsInfoResponseV0H\x00\x1a\x9c\x04\n\x17GetEpochsInfoResponseV0\x12\x65\n\x06\x65pochs\x18\x01 \x01(\x0b\x32S.org.dash.platform.dapi.v0.GetEpochsInfoResponse.GetEpochsInfoResponseV0.EpochInfosH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1au\n\nEpochInfos\x12g\n\x0b\x65poch_infos\x18\x01 \x03(\x0b\x32R.org.dash.platform.dapi.v0.GetEpochsInfoResponse.GetEpochsInfoResponseV0.EpochInfo\x1a\xa6\x01\n\tEpochInfo\x12\x0e\n\x06number\x18\x01 \x01(\r\x12\x1e\n\x12\x66irst_block_height\x18\x02 \x01(\x04\x42\x02\x30\x01\x12\x1f\n\x17\x66irst_core_block_height\x18\x03 \x01(\r\x12\x16\n\nstart_time\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\x16\n\x0e\x66\x65\x65_multiplier\x18\x05 \x01(\x01\x12\x18\n\x10protocol_version\x18\x06 \x01(\rB\x08\n\x06resultB\t\n\x07version\"\xbf\x02\n\x1dGetFinalizedEpochInfosRequest\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetFinalizedEpochInfosRequest.GetFinalizedEpochInfosRequestV0H\x00\x1a\xaa\x01\n\x1fGetFinalizedEpochInfosRequestV0\x12\x19\n\x11start_epoch_index\x18\x01 \x01(\r\x12\"\n\x1astart_epoch_index_included\x18\x02 \x01(\x08\x12\x17\n\x0f\x65nd_epoch_index\x18\x03 \x01(\r\x12 \n\x18\x65nd_epoch_index_included\x18\x04 \x01(\x08\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\t\n\x07version\"\xbd\t\n\x1eGetFinalizedEpochInfosResponse\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetFinalizedEpochInfosResponse.GetFinalizedEpochInfosResponseV0H\x00\x1a\xa5\x08\n GetFinalizedEpochInfosResponseV0\x12\x80\x01\n\x06\x65pochs\x18\x01 \x01(\x0b\x32n.org.dash.platform.dapi.v0.GetFinalizedEpochInfosResponse.GetFinalizedEpochInfosResponseV0.FinalizedEpochInfosH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\xa4\x01\n\x13\x46inalizedEpochInfos\x12\x8c\x01\n\x15\x66inalized_epoch_infos\x18\x01 \x03(\x0b\x32m.org.dash.platform.dapi.v0.GetFinalizedEpochInfosResponse.GetFinalizedEpochInfosResponseV0.FinalizedEpochInfo\x1a\x9f\x04\n\x12\x46inalizedEpochInfo\x12\x0e\n\x06number\x18\x01 \x01(\r\x12\x1e\n\x12\x66irst_block_height\x18\x02 \x01(\x04\x42\x02\x30\x01\x12\x1f\n\x17\x66irst_core_block_height\x18\x03 \x01(\r\x12\x1c\n\x10\x66irst_block_time\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\x16\n\x0e\x66\x65\x65_multiplier\x18\x05 \x01(\x01\x12\x18\n\x10protocol_version\x18\x06 \x01(\r\x12!\n\x15total_blocks_in_epoch\x18\x07 \x01(\x04\x42\x02\x30\x01\x12*\n\"next_epoch_start_core_block_height\x18\x08 \x01(\r\x12!\n\x15total_processing_fees\x18\t \x01(\x04\x42\x02\x30\x01\x12*\n\x1etotal_distributed_storage_fees\x18\n \x01(\x04\x42\x02\x30\x01\x12&\n\x1atotal_created_storage_fees\x18\x0b \x01(\x04\x42\x02\x30\x01\x12\x1e\n\x12\x63ore_block_rewards\x18\x0c \x01(\x04\x42\x02\x30\x01\x12\x81\x01\n\x0f\x62lock_proposers\x18\r \x03(\x0b\x32h.org.dash.platform.dapi.v0.GetFinalizedEpochInfosResponse.GetFinalizedEpochInfosResponseV0.BlockProposer\x1a\x39\n\rBlockProposer\x12\x13\n\x0bproposer_id\x18\x01 \x01(\x0c\x12\x13\n\x0b\x62lock_count\x18\x02 \x01(\rB\x08\n\x06resultB\t\n\x07version\"\xde\x04\n\x1cGetContestedResourcesRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetContestedResourcesRequest.GetContestedResourcesRequestV0H\x00\x1a\xcc\x03\n\x1eGetContestedResourcesRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\x12\n\nindex_name\x18\x03 \x01(\t\x12\x1a\n\x12start_index_values\x18\x04 \x03(\x0c\x12\x18\n\x10\x65nd_index_values\x18\x05 \x03(\x0c\x12\x89\x01\n\x13start_at_value_info\x18\x06 \x01(\x0b\x32g.org.dash.platform.dapi.v0.GetContestedResourcesRequest.GetContestedResourcesRequestV0.StartAtValueInfoH\x00\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x07 \x01(\rH\x01\x88\x01\x01\x12\x17\n\x0forder_ascending\x18\x08 \x01(\x08\x12\r\n\x05prove\x18\t \x01(\x08\x1a\x45\n\x10StartAtValueInfo\x12\x13\n\x0bstart_value\x18\x01 \x01(\x0c\x12\x1c\n\x14start_value_included\x18\x02 \x01(\x08\x42\x16\n\x14_start_at_value_infoB\x08\n\x06_countB\t\n\x07version\"\x88\x04\n\x1dGetContestedResourcesResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetContestedResourcesResponse.GetContestedResourcesResponseV0H\x00\x1a\xf3\x02\n\x1fGetContestedResourcesResponseV0\x12\x95\x01\n\x19\x63ontested_resource_values\x18\x01 \x01(\x0b\x32p.org.dash.platform.dapi.v0.GetContestedResourcesResponse.GetContestedResourcesResponseV0.ContestedResourceValuesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a<\n\x17\x43ontestedResourceValues\x12!\n\x19\x63ontested_resource_values\x18\x01 \x03(\x0c\x42\x08\n\x06resultB\t\n\x07version\"\xd2\x05\n\x1cGetVotePollsByEndDateRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetVotePollsByEndDateRequest.GetVotePollsByEndDateRequestV0H\x00\x1a\xc0\x04\n\x1eGetVotePollsByEndDateRequestV0\x12\x84\x01\n\x0fstart_time_info\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetVotePollsByEndDateRequest.GetVotePollsByEndDateRequestV0.StartAtTimeInfoH\x00\x88\x01\x01\x12\x80\x01\n\rend_time_info\x18\x02 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetVotePollsByEndDateRequest.GetVotePollsByEndDateRequestV0.EndAtTimeInfoH\x01\x88\x01\x01\x12\x12\n\x05limit\x18\x03 \x01(\rH\x02\x88\x01\x01\x12\x13\n\x06offset\x18\x04 \x01(\rH\x03\x88\x01\x01\x12\x11\n\tascending\x18\x05 \x01(\x08\x12\r\n\x05prove\x18\x06 \x01(\x08\x1aI\n\x0fStartAtTimeInfo\x12\x19\n\rstart_time_ms\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x1b\n\x13start_time_included\x18\x02 \x01(\x08\x1a\x43\n\rEndAtTimeInfo\x12\x17\n\x0b\x65nd_time_ms\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x19\n\x11\x65nd_time_included\x18\x02 \x01(\x08\x42\x12\n\x10_start_time_infoB\x10\n\x0e_end_time_infoB\x08\n\x06_limitB\t\n\x07_offsetB\t\n\x07version\"\x83\x06\n\x1dGetVotePollsByEndDateResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetVotePollsByEndDateResponse.GetVotePollsByEndDateResponseV0H\x00\x1a\xee\x04\n\x1fGetVotePollsByEndDateResponseV0\x12\x9c\x01\n\x18vote_polls_by_timestamps\x18\x01 \x01(\x0b\x32x.org.dash.platform.dapi.v0.GetVotePollsByEndDateResponse.GetVotePollsByEndDateResponseV0.SerializedVotePollsByTimestampsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aV\n\x1eSerializedVotePollsByTimestamp\x12\x15\n\ttimestamp\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x1d\n\x15serialized_vote_polls\x18\x02 \x03(\x0c\x1a\xd7\x01\n\x1fSerializedVotePollsByTimestamps\x12\x99\x01\n\x18vote_polls_by_timestamps\x18\x01 \x03(\x0b\x32w.org.dash.platform.dapi.v0.GetVotePollsByEndDateResponse.GetVotePollsByEndDateResponseV0.SerializedVotePollsByTimestamp\x12\x18\n\x10\x66inished_results\x18\x02 \x01(\x08\x42\x08\n\x06resultB\t\n\x07version\"\xff\x06\n$GetContestedResourceVoteStateRequest\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetContestedResourceVoteStateRequest.GetContestedResourceVoteStateRequestV0H\x00\x1a\xd5\x05\n&GetContestedResourceVoteStateRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\x12\n\nindex_name\x18\x03 \x01(\t\x12\x14\n\x0cindex_values\x18\x04 \x03(\x0c\x12\x86\x01\n\x0bresult_type\x18\x05 \x01(\x0e\x32q.org.dash.platform.dapi.v0.GetContestedResourceVoteStateRequest.GetContestedResourceVoteStateRequestV0.ResultType\x12\x36\n.allow_include_locked_and_abstaining_vote_tally\x18\x06 \x01(\x08\x12\xa3\x01\n\x18start_at_identifier_info\x18\x07 \x01(\x0b\x32|.org.dash.platform.dapi.v0.GetContestedResourceVoteStateRequest.GetContestedResourceVoteStateRequestV0.StartAtIdentifierInfoH\x00\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x08 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\t \x01(\x08\x1aT\n\x15StartAtIdentifierInfo\x12\x18\n\x10start_identifier\x18\x01 \x01(\x0c\x12!\n\x19start_identifier_included\x18\x02 \x01(\x08\"I\n\nResultType\x12\r\n\tDOCUMENTS\x10\x00\x12\x0e\n\nVOTE_TALLY\x10\x01\x12\x1c\n\x18\x44OCUMENTS_AND_VOTE_TALLY\x10\x02\x42\x1b\n\x19_start_at_identifier_infoB\x08\n\x06_countB\t\n\x07version\"\x94\x0c\n%GetContestedResourceVoteStateResponse\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse.GetContestedResourceVoteStateResponseV0H\x00\x1a\xe7\n\n\'GetContestedResourceVoteStateResponseV0\x12\xae\x01\n\x1d\x63ontested_resource_contenders\x18\x01 \x01(\x0b\x32\x84\x01.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse.GetContestedResourceVoteStateResponseV0.ContestedResourceContendersH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\xda\x03\n\x10\x46inishedVoteInfo\x12\xad\x01\n\x15\x66inished_vote_outcome\x18\x01 \x01(\x0e\x32\x8d\x01.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse.GetContestedResourceVoteStateResponseV0.FinishedVoteInfo.FinishedVoteOutcome\x12\x1f\n\x12won_by_identity_id\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x12$\n\x18\x66inished_at_block_height\x18\x03 \x01(\x04\x42\x02\x30\x01\x12%\n\x1d\x66inished_at_core_block_height\x18\x04 \x01(\r\x12%\n\x19\x66inished_at_block_time_ms\x18\x05 \x01(\x04\x42\x02\x30\x01\x12\x19\n\x11\x66inished_at_epoch\x18\x06 \x01(\r\"O\n\x13\x46inishedVoteOutcome\x12\x14\n\x10TOWARDS_IDENTITY\x10\x00\x12\n\n\x06LOCKED\x10\x01\x12\x16\n\x12NO_PREVIOUS_WINNER\x10\x02\x42\x15\n\x13_won_by_identity_id\x1a\xc4\x03\n\x1b\x43ontestedResourceContenders\x12\x86\x01\n\ncontenders\x18\x01 \x03(\x0b\x32r.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse.GetContestedResourceVoteStateResponseV0.Contender\x12\x1f\n\x12\x61\x62stain_vote_tally\x18\x02 \x01(\rH\x00\x88\x01\x01\x12\x1c\n\x0flock_vote_tally\x18\x03 \x01(\rH\x01\x88\x01\x01\x12\x9a\x01\n\x12\x66inished_vote_info\x18\x04 \x01(\x0b\x32y.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse.GetContestedResourceVoteStateResponseV0.FinishedVoteInfoH\x02\x88\x01\x01\x42\x15\n\x13_abstain_vote_tallyB\x12\n\x10_lock_vote_tallyB\x15\n\x13_finished_vote_info\x1ak\n\tContender\x12\x12\n\nidentifier\x18\x01 \x01(\x0c\x12\x17\n\nvote_count\x18\x02 \x01(\rH\x00\x88\x01\x01\x12\x15\n\x08\x64ocument\x18\x03 \x01(\x0cH\x01\x88\x01\x01\x42\r\n\x0b_vote_countB\x0b\n\t_documentB\x08\n\x06resultB\t\n\x07version\"\xd5\x05\n,GetContestedResourceVotersForIdentityRequest\x12\x84\x01\n\x02v0\x18\x01 \x01(\x0b\x32v.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityRequest.GetContestedResourceVotersForIdentityRequestV0H\x00\x1a\x92\x04\n.GetContestedResourceVotersForIdentityRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\x12\n\nindex_name\x18\x03 \x01(\t\x12\x14\n\x0cindex_values\x18\x04 \x03(\x0c\x12\x15\n\rcontestant_id\x18\x05 \x01(\x0c\x12\xb4\x01\n\x18start_at_identifier_info\x18\x06 \x01(\x0b\x32\x8c\x01.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityRequest.GetContestedResourceVotersForIdentityRequestV0.StartAtIdentifierInfoH\x00\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x07 \x01(\rH\x01\x88\x01\x01\x12\x17\n\x0forder_ascending\x18\x08 \x01(\x08\x12\r\n\x05prove\x18\t \x01(\x08\x1aT\n\x15StartAtIdentifierInfo\x12\x18\n\x10start_identifier\x18\x01 \x01(\x0c\x12!\n\x19start_identifier_included\x18\x02 \x01(\x08\x42\x1b\n\x19_start_at_identifier_infoB\x08\n\x06_countB\t\n\x07version\"\xf1\x04\n-GetContestedResourceVotersForIdentityResponse\x12\x86\x01\n\x02v0\x18\x01 \x01(\x0b\x32x.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityResponse.GetContestedResourceVotersForIdentityResponseV0H\x00\x1a\xab\x03\n/GetContestedResourceVotersForIdentityResponseV0\x12\xb6\x01\n\x19\x63ontested_resource_voters\x18\x01 \x01(\x0b\x32\x90\x01.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityResponse.GetContestedResourceVotersForIdentityResponseV0.ContestedResourceVotersH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x43\n\x17\x43ontestedResourceVoters\x12\x0e\n\x06voters\x18\x01 \x03(\x0c\x12\x18\n\x10\x66inished_results\x18\x02 \x01(\x08\x42\x08\n\x06resultB\t\n\x07version\"\xad\x05\n(GetContestedResourceIdentityVotesRequest\x12|\n\x02v0\x18\x01 \x01(\x0b\x32n.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesRequest.GetContestedResourceIdentityVotesRequestV0H\x00\x1a\xf7\x03\n*GetContestedResourceIdentityVotesRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12+\n\x05limit\x18\x02 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12,\n\x06offset\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\x17\n\x0forder_ascending\x18\x04 \x01(\x08\x12\xae\x01\n\x1astart_at_vote_poll_id_info\x18\x05 \x01(\x0b\x32\x84\x01.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesRequest.GetContestedResourceIdentityVotesRequestV0.StartAtVotePollIdInfoH\x00\x88\x01\x01\x12\r\n\x05prove\x18\x06 \x01(\x08\x1a\x61\n\x15StartAtVotePollIdInfo\x12 \n\x18start_at_poll_identifier\x18\x01 \x01(\x0c\x12&\n\x1estart_poll_identifier_included\x18\x02 \x01(\x08\x42\x1d\n\x1b_start_at_vote_poll_id_infoB\t\n\x07version\"\xc8\n\n)GetContestedResourceIdentityVotesResponse\x12~\n\x02v0\x18\x01 \x01(\x0b\x32p.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse.GetContestedResourceIdentityVotesResponseV0H\x00\x1a\x8f\t\n+GetContestedResourceIdentityVotesResponseV0\x12\xa1\x01\n\x05votes\x18\x01 \x01(\x0b\x32\x8f\x01.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse.GetContestedResourceIdentityVotesResponseV0.ContestedResourceIdentityVotesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\xf7\x01\n\x1e\x43ontestedResourceIdentityVotes\x12\xba\x01\n!contested_resource_identity_votes\x18\x01 \x03(\x0b\x32\x8e\x01.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse.GetContestedResourceIdentityVotesResponseV0.ContestedResourceIdentityVote\x12\x18\n\x10\x66inished_results\x18\x02 \x01(\x08\x1a\xad\x02\n\x12ResourceVoteChoice\x12\xad\x01\n\x10vote_choice_type\x18\x01 \x01(\x0e\x32\x92\x01.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse.GetContestedResourceIdentityVotesResponseV0.ResourceVoteChoice.VoteChoiceType\x12\x18\n\x0bidentity_id\x18\x02 \x01(\x0cH\x00\x88\x01\x01\"=\n\x0eVoteChoiceType\x12\x14\n\x10TOWARDS_IDENTITY\x10\x00\x12\x0b\n\x07\x41\x42STAIN\x10\x01\x12\x08\n\x04LOCK\x10\x02\x42\x0e\n\x0c_identity_id\x1a\x95\x02\n\x1d\x43ontestedResourceIdentityVote\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\'\n\x1fserialized_index_storage_values\x18\x03 \x03(\x0c\x12\x99\x01\n\x0bvote_choice\x18\x04 \x01(\x0b\x32\x83\x01.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse.GetContestedResourceIdentityVotesResponseV0.ResourceVoteChoiceB\x08\n\x06resultB\t\n\x07version\"\xf0\x01\n%GetPrefundedSpecializedBalanceRequest\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetPrefundedSpecializedBalanceRequest.GetPrefundedSpecializedBalanceRequestV0H\x00\x1a\x44\n\'GetPrefundedSpecializedBalanceRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xed\x02\n&GetPrefundedSpecializedBalanceResponse\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetPrefundedSpecializedBalanceResponse.GetPrefundedSpecializedBalanceResponseV0H\x00\x1a\xbd\x01\n(GetPrefundedSpecializedBalanceResponseV0\x12\x15\n\x07\x62\x61lance\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xd0\x01\n GetTotalCreditsInPlatformRequest\x12l\n\x02v0\x18\x01 \x01(\x0b\x32^.org.dash.platform.dapi.v0.GetTotalCreditsInPlatformRequest.GetTotalCreditsInPlatformRequestV0H\x00\x1a\x33\n\"GetTotalCreditsInPlatformRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xd9\x02\n!GetTotalCreditsInPlatformResponse\x12n\n\x02v0\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetTotalCreditsInPlatformResponse.GetTotalCreditsInPlatformResponseV0H\x00\x1a\xb8\x01\n#GetTotalCreditsInPlatformResponseV0\x12\x15\n\x07\x63redits\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc4\x01\n\x16GetPathElementsRequest\x12X\n\x02v0\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetPathElementsRequest.GetPathElementsRequestV0H\x00\x1a\x45\n\x18GetPathElementsRequestV0\x12\x0c\n\x04path\x18\x01 \x03(\x0c\x12\x0c\n\x04keys\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xa3\x03\n\x17GetPathElementsResponse\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetPathElementsResponse.GetPathElementsResponseV0H\x00\x1a\xa0\x02\n\x19GetPathElementsResponseV0\x12i\n\x08\x65lements\x18\x01 \x01(\x0b\x32U.org.dash.platform.dapi.v0.GetPathElementsResponse.GetPathElementsResponseV0.ElementsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x1c\n\x08\x45lements\x12\x10\n\x08\x65lements\x18\x01 \x03(\x0c\x42\x08\n\x06resultB\t\n\x07version\"\x81\x01\n\x10GetStatusRequest\x12L\n\x02v0\x18\x01 \x01(\x0b\x32>.org.dash.platform.dapi.v0.GetStatusRequest.GetStatusRequestV0H\x00\x1a\x14\n\x12GetStatusRequestV0B\t\n\x07version\"\xe4\x10\n\x11GetStatusResponse\x12N\n\x02v0\x18\x01 \x01(\x0b\x32@.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0H\x00\x1a\xf3\x0f\n\x13GetStatusResponseV0\x12Y\n\x07version\x18\x01 \x01(\x0b\x32H.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Version\x12S\n\x04node\x18\x02 \x01(\x0b\x32\x45.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Node\x12U\n\x05\x63hain\x18\x03 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Chain\x12Y\n\x07network\x18\x04 \x01(\x0b\x32H.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Network\x12^\n\nstate_sync\x18\x05 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.StateSync\x12S\n\x04time\x18\x06 \x01(\x0b\x32\x45.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Time\x1a\x82\x05\n\x07Version\x12\x63\n\x08software\x18\x01 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Version.Software\x12\x63\n\x08protocol\x18\x02 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Version.Protocol\x1a^\n\x08Software\x12\x0c\n\x04\x64\x61pi\x18\x01 \x01(\t\x12\x12\n\x05\x64rive\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x17\n\ntenderdash\x18\x03 \x01(\tH\x01\x88\x01\x01\x42\x08\n\x06_driveB\r\n\x0b_tenderdash\x1a\xcc\x02\n\x08Protocol\x12p\n\ntenderdash\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Version.Protocol.Tenderdash\x12\x66\n\x05\x64rive\x18\x02 \x01(\x0b\x32W.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Version.Protocol.Drive\x1a(\n\nTenderdash\x12\x0b\n\x03p2p\x18\x01 \x01(\r\x12\r\n\x05\x62lock\x18\x02 \x01(\r\x1a<\n\x05\x44rive\x12\x0e\n\x06latest\x18\x03 \x01(\r\x12\x0f\n\x07\x63urrent\x18\x04 \x01(\r\x12\x12\n\nnext_epoch\x18\x05 \x01(\r\x1a\x7f\n\x04Time\x12\x11\n\x05local\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x16\n\x05\x62lock\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x88\x01\x01\x12\x18\n\x07genesis\x18\x03 \x01(\x04\x42\x02\x30\x01H\x01\x88\x01\x01\x12\x12\n\x05\x65poch\x18\x04 \x01(\rH\x02\x88\x01\x01\x42\x08\n\x06_blockB\n\n\x08_genesisB\x08\n\x06_epoch\x1a<\n\x04Node\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\x18\n\x0bpro_tx_hash\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x42\x0e\n\x0c_pro_tx_hash\x1a\xb3\x02\n\x05\x43hain\x12\x13\n\x0b\x63\x61tching_up\x18\x01 \x01(\x08\x12\x19\n\x11latest_block_hash\x18\x02 \x01(\x0c\x12\x17\n\x0flatest_app_hash\x18\x03 \x01(\x0c\x12\x1f\n\x13latest_block_height\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\x1b\n\x13\x65\x61rliest_block_hash\x18\x05 \x01(\x0c\x12\x19\n\x11\x65\x61rliest_app_hash\x18\x06 \x01(\x0c\x12!\n\x15\x65\x61rliest_block_height\x18\x07 \x01(\x04\x42\x02\x30\x01\x12!\n\x15max_peer_block_height\x18\t \x01(\x04\x42\x02\x30\x01\x12%\n\x18\x63ore_chain_locked_height\x18\n \x01(\rH\x00\x88\x01\x01\x42\x1b\n\x19_core_chain_locked_height\x1a\x43\n\x07Network\x12\x10\n\x08\x63hain_id\x18\x01 \x01(\t\x12\x13\n\x0bpeers_count\x18\x02 \x01(\r\x12\x11\n\tlistening\x18\x03 \x01(\x08\x1a\x85\x02\n\tStateSync\x12\x1d\n\x11total_synced_time\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x1a\n\x0eremaining_time\x18\x02 \x01(\x04\x42\x02\x30\x01\x12\x17\n\x0ftotal_snapshots\x18\x03 \x01(\r\x12\"\n\x16\x63hunk_process_avg_time\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\x1b\n\x0fsnapshot_height\x18\x05 \x01(\x04\x42\x02\x30\x01\x12!\n\x15snapshot_chunks_count\x18\x06 \x01(\x04\x42\x02\x30\x01\x12\x1d\n\x11\x62\x61\x63kfilled_blocks\x18\x07 \x01(\x04\x42\x02\x30\x01\x12!\n\x15\x62\x61\x63kfill_blocks_total\x18\x08 \x01(\x04\x42\x02\x30\x01\x42\t\n\x07version\"\xb1\x01\n\x1cGetCurrentQuorumsInfoRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoRequest.GetCurrentQuorumsInfoRequestV0H\x00\x1a \n\x1eGetCurrentQuorumsInfoRequestV0B\t\n\x07version\"\xa1\x05\n\x1dGetCurrentQuorumsInfoResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoResponse.GetCurrentQuorumsInfoResponseV0H\x00\x1a\x46\n\x0bValidatorV0\x12\x13\n\x0bpro_tx_hash\x18\x01 \x01(\x0c\x12\x0f\n\x07node_ip\x18\x02 \x01(\t\x12\x11\n\tis_banned\x18\x03 \x01(\x08\x1a\xaf\x01\n\x0eValidatorSetV0\x12\x13\n\x0bquorum_hash\x18\x01 \x01(\x0c\x12\x13\n\x0b\x63ore_height\x18\x02 \x01(\r\x12U\n\x07members\x18\x03 \x03(\x0b\x32\x44.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoResponse.ValidatorV0\x12\x1c\n\x14threshold_public_key\x18\x04 \x01(\x0c\x1a\x92\x02\n\x1fGetCurrentQuorumsInfoResponseV0\x12\x15\n\rquorum_hashes\x18\x01 \x03(\x0c\x12\x1b\n\x13\x63urrent_quorum_hash\x18\x02 \x01(\x0c\x12_\n\x0evalidator_sets\x18\x03 \x03(\x0b\x32G.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoResponse.ValidatorSetV0\x12\x1b\n\x13last_block_proposer\x18\x04 \x01(\x0c\x12=\n\x08metadata\x18\x05 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\t\n\x07version\"\xf4\x01\n\x1fGetIdentityTokenBalancesRequest\x12j\n\x02v0\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetIdentityTokenBalancesRequest.GetIdentityTokenBalancesRequestV0H\x00\x1aZ\n!GetIdentityTokenBalancesRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x11\n\ttoken_ids\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xad\x05\n GetIdentityTokenBalancesResponse\x12l\n\x02v0\x18\x01 \x01(\x0b\x32^.org.dash.platform.dapi.v0.GetIdentityTokenBalancesResponse.GetIdentityTokenBalancesResponseV0H\x00\x1a\x8f\x04\n\"GetIdentityTokenBalancesResponseV0\x12\x86\x01\n\x0etoken_balances\x18\x01 \x01(\x0b\x32l.org.dash.platform.dapi.v0.GetIdentityTokenBalancesResponse.GetIdentityTokenBalancesResponseV0.TokenBalancesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aG\n\x11TokenBalanceEntry\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x14\n\x07\x62\x61lance\x18\x02 \x01(\x04H\x00\x88\x01\x01\x42\n\n\x08_balance\x1a\x9a\x01\n\rTokenBalances\x12\x88\x01\n\x0etoken_balances\x18\x01 \x03(\x0b\x32p.org.dash.platform.dapi.v0.GetIdentityTokenBalancesResponse.GetIdentityTokenBalancesResponseV0.TokenBalanceEntryB\x08\n\x06resultB\t\n\x07version\"\xfc\x01\n!GetIdentitiesTokenBalancesRequest\x12n\n\x02v0\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesRequest.GetIdentitiesTokenBalancesRequestV0H\x00\x1a\\\n#GetIdentitiesTokenBalancesRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x14\n\x0cidentity_ids\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xf2\x05\n\"GetIdentitiesTokenBalancesResponse\x12p\n\x02v0\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesResponse.GetIdentitiesTokenBalancesResponseV0H\x00\x1a\xce\x04\n$GetIdentitiesTokenBalancesResponseV0\x12\x9b\x01\n\x17identity_token_balances\x18\x01 \x01(\x0b\x32x.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesResponse.GetIdentitiesTokenBalancesResponseV0.IdentityTokenBalancesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aR\n\x19IdentityTokenBalanceEntry\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x14\n\x07\x62\x61lance\x18\x02 \x01(\x04H\x00\x88\x01\x01\x42\n\n\x08_balance\x1a\xb7\x01\n\x15IdentityTokenBalances\x12\x9d\x01\n\x17identity_token_balances\x18\x01 \x03(\x0b\x32|.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesResponse.GetIdentitiesTokenBalancesResponseV0.IdentityTokenBalanceEntryB\x08\n\x06resultB\t\n\x07version\"\xe8\x01\n\x1cGetIdentityTokenInfosRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetIdentityTokenInfosRequest.GetIdentityTokenInfosRequestV0H\x00\x1aW\n\x1eGetIdentityTokenInfosRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x11\n\ttoken_ids\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\x98\x06\n\x1dGetIdentityTokenInfosResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetIdentityTokenInfosResponse.GetIdentityTokenInfosResponseV0H\x00\x1a\x83\x05\n\x1fGetIdentityTokenInfosResponseV0\x12z\n\x0btoken_infos\x18\x01 \x01(\x0b\x32\x63.org.dash.platform.dapi.v0.GetIdentityTokenInfosResponse.GetIdentityTokenInfosResponseV0.TokenInfosH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a(\n\x16TokenIdentityInfoEntry\x12\x0e\n\x06\x66rozen\x18\x01 \x01(\x08\x1a\xb0\x01\n\x0eTokenInfoEntry\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x82\x01\n\x04info\x18\x02 \x01(\x0b\x32o.org.dash.platform.dapi.v0.GetIdentityTokenInfosResponse.GetIdentityTokenInfosResponseV0.TokenIdentityInfoEntryH\x00\x88\x01\x01\x42\x07\n\x05_info\x1a\x8a\x01\n\nTokenInfos\x12|\n\x0btoken_infos\x18\x01 \x03(\x0b\x32g.org.dash.platform.dapi.v0.GetIdentityTokenInfosResponse.GetIdentityTokenInfosResponseV0.TokenInfoEntryB\x08\n\x06resultB\t\n\x07version\"\xf0\x01\n\x1eGetIdentitiesTokenInfosRequest\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosRequest.GetIdentitiesTokenInfosRequestV0H\x00\x1aY\n GetIdentitiesTokenInfosRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x14\n\x0cidentity_ids\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xca\x06\n\x1fGetIdentitiesTokenInfosResponse\x12j\n\x02v0\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosResponse.GetIdentitiesTokenInfosResponseV0H\x00\x1a\xaf\x05\n!GetIdentitiesTokenInfosResponseV0\x12\x8f\x01\n\x14identity_token_infos\x18\x01 \x01(\x0b\x32o.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosResponse.GetIdentitiesTokenInfosResponseV0.IdentityTokenInfosH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a(\n\x16TokenIdentityInfoEntry\x12\x0e\n\x06\x66rozen\x18\x01 \x01(\x08\x1a\xb7\x01\n\x0eTokenInfoEntry\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x86\x01\n\x04info\x18\x02 \x01(\x0b\x32s.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosResponse.GetIdentitiesTokenInfosResponseV0.TokenIdentityInfoEntryH\x00\x88\x01\x01\x42\x07\n\x05_info\x1a\x97\x01\n\x12IdentityTokenInfos\x12\x80\x01\n\x0btoken_infos\x18\x01 \x03(\x0b\x32k.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosResponse.GetIdentitiesTokenInfosResponseV0.TokenInfoEntryB\x08\n\x06resultB\t\n\x07version\"\xbf\x01\n\x17GetTokenStatusesRequest\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetTokenStatusesRequest.GetTokenStatusesRequestV0H\x00\x1a=\n\x19GetTokenStatusesRequestV0\x12\x11\n\ttoken_ids\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xe7\x04\n\x18GetTokenStatusesResponse\x12\\\n\x02v0\x18\x01 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetTokenStatusesResponse.GetTokenStatusesResponseV0H\x00\x1a\xe1\x03\n\x1aGetTokenStatusesResponseV0\x12v\n\x0etoken_statuses\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetTokenStatusesResponse.GetTokenStatusesResponseV0.TokenStatusesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x44\n\x10TokenStatusEntry\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x13\n\x06paused\x18\x02 \x01(\x08H\x00\x88\x01\x01\x42\t\n\x07_paused\x1a\x88\x01\n\rTokenStatuses\x12w\n\x0etoken_statuses\x18\x01 \x03(\x0b\x32_.org.dash.platform.dapi.v0.GetTokenStatusesResponse.GetTokenStatusesResponseV0.TokenStatusEntryB\x08\n\x06resultB\t\n\x07version\"\xef\x01\n#GetTokenDirectPurchasePricesRequest\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesRequest.GetTokenDirectPurchasePricesRequestV0H\x00\x1aI\n%GetTokenDirectPurchasePricesRequestV0\x12\x11\n\ttoken_ids\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x8b\t\n$GetTokenDirectPurchasePricesResponse\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse.GetTokenDirectPurchasePricesResponseV0H\x00\x1a\xe1\x07\n&GetTokenDirectPurchasePricesResponseV0\x12\xa9\x01\n\x1ctoken_direct_purchase_prices\x18\x01 \x01(\x0b\x32\x80\x01.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse.GetTokenDirectPurchasePricesResponseV0.TokenDirectPurchasePricesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x33\n\x10PriceForQuantity\x12\x10\n\x08quantity\x18\x01 \x01(\x04\x12\r\n\x05price\x18\x02 \x01(\x04\x1a\xa7\x01\n\x0fPricingSchedule\x12\x93\x01\n\x12price_for_quantity\x18\x01 \x03(\x0b\x32w.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse.GetTokenDirectPurchasePricesResponseV0.PriceForQuantity\x1a\xe4\x01\n\x1dTokenDirectPurchasePriceEntry\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x15\n\x0b\x66ixed_price\x18\x02 \x01(\x04H\x00\x12\x90\x01\n\x0evariable_price\x18\x03 \x01(\x0b\x32v.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse.GetTokenDirectPurchasePricesResponseV0.PricingScheduleH\x00\x42\x07\n\x05price\x1a\xc8\x01\n\x19TokenDirectPurchasePrices\x12\xaa\x01\n\x1btoken_direct_purchase_price\x18\x01 \x03(\x0b\x32\x84\x01.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse.GetTokenDirectPurchasePricesResponseV0.TokenDirectPurchasePriceEntryB\x08\n\x06resultB\t\n\x07version\"\xce\x01\n\x1bGetTokenContractInfoRequest\x12\x62\n\x02v0\x18\x01 \x01(\x0b\x32T.org.dash.platform.dapi.v0.GetTokenContractInfoRequest.GetTokenContractInfoRequestV0H\x00\x1a@\n\x1dGetTokenContractInfoRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xfb\x03\n\x1cGetTokenContractInfoResponse\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetTokenContractInfoResponse.GetTokenContractInfoResponseV0H\x00\x1a\xe9\x02\n\x1eGetTokenContractInfoResponseV0\x12|\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32l.org.dash.platform.dapi.v0.GetTokenContractInfoResponse.GetTokenContractInfoResponseV0.TokenContractInfoDataH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aM\n\x15TokenContractInfoData\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1f\n\x17token_contract_position\x18\x02 \x01(\rB\x08\n\x06resultB\t\n\x07version\"\xef\x04\n)GetTokenPreProgrammedDistributionsRequest\x12~\n\x02v0\x18\x01 \x01(\x0b\x32p.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsRequest.GetTokenPreProgrammedDistributionsRequestV0H\x00\x1a\xb6\x03\n+GetTokenPreProgrammedDistributionsRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x98\x01\n\rstart_at_info\x18\x02 \x01(\x0b\x32|.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsRequest.GetTokenPreProgrammedDistributionsRequestV0.StartAtInfoH\x00\x88\x01\x01\x12\x12\n\x05limit\x18\x03 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\x04 \x01(\x08\x1a\x9a\x01\n\x0bStartAtInfo\x12\x15\n\rstart_time_ms\x18\x01 \x01(\x04\x12\x1c\n\x0fstart_recipient\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x12%\n\x18start_recipient_included\x18\x03 \x01(\x08H\x01\x88\x01\x01\x42\x12\n\x10_start_recipientB\x1b\n\x19_start_recipient_includedB\x10\n\x0e_start_at_infoB\x08\n\x06_limitB\t\n\x07version\"\xec\x07\n*GetTokenPreProgrammedDistributionsResponse\x12\x80\x01\n\x02v0\x18\x01 \x01(\x0b\x32r.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsResponse.GetTokenPreProgrammedDistributionsResponseV0H\x00\x1a\xaf\x06\n,GetTokenPreProgrammedDistributionsResponseV0\x12\xa5\x01\n\x13token_distributions\x18\x01 \x01(\x0b\x32\x85\x01.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsResponse.GetTokenPreProgrammedDistributionsResponseV0.TokenDistributionsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a>\n\x16TokenDistributionEntry\x12\x14\n\x0crecipient_id\x18\x01 \x01(\x0c\x12\x0e\n\x06\x61mount\x18\x02 \x01(\x04\x1a\xd4\x01\n\x1bTokenTimedDistributionEntry\x12\x11\n\ttimestamp\x18\x01 \x01(\x04\x12\xa1\x01\n\rdistributions\x18\x02 \x03(\x0b\x32\x89\x01.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsResponse.GetTokenPreProgrammedDistributionsResponseV0.TokenDistributionEntry\x1a\xc3\x01\n\x12TokenDistributions\x12\xac\x01\n\x13token_distributions\x18\x01 \x03(\x0b\x32\x8e\x01.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsResponse.GetTokenPreProgrammedDistributionsResponseV0.TokenTimedDistributionEntryB\x08\n\x06resultB\t\n\x07version\"\x82\x04\n-GetTokenPerpetualDistributionLastClaimRequest\x12\x86\x01\n\x02v0\x18\x01 \x01(\x0b\x32x.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimRequest.GetTokenPerpetualDistributionLastClaimRequestV0H\x00\x1aI\n\x11\x43ontractTokenInfo\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1f\n\x17token_contract_position\x18\x02 \x01(\r\x1a\xf1\x01\n/GetTokenPerpetualDistributionLastClaimRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12v\n\rcontract_info\x18\x02 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimRequest.ContractTokenInfoH\x00\x88\x01\x01\x12\x13\n\x0bidentity_id\x18\x04 \x01(\x0c\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\x10\n\x0e_contract_infoB\t\n\x07version\"\x93\x05\n.GetTokenPerpetualDistributionLastClaimResponse\x12\x88\x01\n\x02v0\x18\x01 \x01(\x0b\x32z.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimResponse.GetTokenPerpetualDistributionLastClaimResponseV0H\x00\x1a\xca\x03\n0GetTokenPerpetualDistributionLastClaimResponseV0\x12\x9f\x01\n\nlast_claim\x18\x01 \x01(\x0b\x32\x88\x01.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimResponse.GetTokenPerpetualDistributionLastClaimResponseV0.LastClaimInfoH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1ax\n\rLastClaimInfo\x12\x1a\n\x0ctimestamp_ms\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x1a\n\x0c\x62lock_height\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x12\x0f\n\x05\x65poch\x18\x03 \x01(\rH\x00\x12\x13\n\traw_bytes\x18\x04 \x01(\x0cH\x00\x42\t\n\x07paid_atB\x08\n\x06resultB\t\n\x07version\"\xca\x01\n\x1aGetTokenTotalSupplyRequest\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetTokenTotalSupplyRequest.GetTokenTotalSupplyRequestV0H\x00\x1a?\n\x1cGetTokenTotalSupplyRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xaf\x04\n\x1bGetTokenTotalSupplyResponse\x12\x62\n\x02v0\x18\x01 \x01(\x0b\x32T.org.dash.platform.dapi.v0.GetTokenTotalSupplyResponse.GetTokenTotalSupplyResponseV0H\x00\x1a\xa0\x03\n\x1dGetTokenTotalSupplyResponseV0\x12\x88\x01\n\x12token_total_supply\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetTokenTotalSupplyResponse.GetTokenTotalSupplyResponseV0.TokenTotalSupplyEntryH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1ax\n\x15TokenTotalSupplyEntry\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x30\n(total_aggregated_amount_in_user_accounts\x18\x02 \x01(\x04\x12\x1b\n\x13total_system_amount\x18\x03 \x01(\x04\x42\x08\n\x06resultB\t\n\x07version\"\xd2\x01\n\x13GetGroupInfoRequest\x12R\n\x02v0\x18\x01 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetGroupInfoRequest.GetGroupInfoRequestV0H\x00\x1a\\\n\x15GetGroupInfoRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1f\n\x17group_contract_position\x18\x02 \x01(\r\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xd4\x05\n\x14GetGroupInfoResponse\x12T\n\x02v0\x18\x01 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetGroupInfoResponse.GetGroupInfoResponseV0H\x00\x1a\xda\x04\n\x16GetGroupInfoResponseV0\x12\x66\n\ngroup_info\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetGroupInfoResponse.GetGroupInfoResponseV0.GroupInfoH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x04 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x34\n\x10GroupMemberEntry\x12\x11\n\tmember_id\x18\x01 \x01(\x0c\x12\r\n\x05power\x18\x02 \x01(\r\x1a\x98\x01\n\x0eGroupInfoEntry\x12h\n\x07members\x18\x01 \x03(\x0b\x32W.org.dash.platform.dapi.v0.GetGroupInfoResponse.GetGroupInfoResponseV0.GroupMemberEntry\x12\x1c\n\x14group_required_power\x18\x02 \x01(\r\x1a\x8a\x01\n\tGroupInfo\x12n\n\ngroup_info\x18\x01 \x01(\x0b\x32U.org.dash.platform.dapi.v0.GetGroupInfoResponse.GetGroupInfoResponseV0.GroupInfoEntryH\x00\x88\x01\x01\x42\r\n\x0b_group_infoB\x08\n\x06resultB\t\n\x07version\"\xed\x03\n\x14GetGroupInfosRequest\x12T\n\x02v0\x18\x01 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetGroupInfosRequest.GetGroupInfosRequestV0H\x00\x1au\n\x1cStartAtGroupContractPosition\x12%\n\x1dstart_group_contract_position\x18\x01 \x01(\r\x12.\n&start_group_contract_position_included\x18\x02 \x01(\x08\x1a\xfc\x01\n\x16GetGroupInfosRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12{\n start_at_group_contract_position\x18\x02 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetGroupInfosRequest.StartAtGroupContractPositionH\x00\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x03 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\x04 \x01(\x08\x42#\n!_start_at_group_contract_positionB\x08\n\x06_countB\t\n\x07version\"\xff\x05\n\x15GetGroupInfosResponse\x12V\n\x02v0\x18\x01 \x01(\x0b\x32H.org.dash.platform.dapi.v0.GetGroupInfosResponse.GetGroupInfosResponseV0H\x00\x1a\x82\x05\n\x17GetGroupInfosResponseV0\x12j\n\x0bgroup_infos\x18\x01 \x01(\x0b\x32S.org.dash.platform.dapi.v0.GetGroupInfosResponse.GetGroupInfosResponseV0.GroupInfosH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x04 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x34\n\x10GroupMemberEntry\x12\x11\n\tmember_id\x18\x01 \x01(\x0c\x12\r\n\x05power\x18\x02 \x01(\r\x1a\xc3\x01\n\x16GroupPositionInfoEntry\x12\x1f\n\x17group_contract_position\x18\x01 \x01(\r\x12j\n\x07members\x18\x02 \x03(\x0b\x32Y.org.dash.platform.dapi.v0.GetGroupInfosResponse.GetGroupInfosResponseV0.GroupMemberEntry\x12\x1c\n\x14group_required_power\x18\x03 \x01(\r\x1a\x82\x01\n\nGroupInfos\x12t\n\x0bgroup_infos\x18\x01 \x03(\x0b\x32_.org.dash.platform.dapi.v0.GetGroupInfosResponse.GetGroupInfosResponseV0.GroupPositionInfoEntryB\x08\n\x06resultB\t\n\x07version\"\xbe\x04\n\x16GetGroupActionsRequest\x12X\n\x02v0\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetGroupActionsRequest.GetGroupActionsRequestV0H\x00\x1aL\n\x0fStartAtActionId\x12\x17\n\x0fstart_action_id\x18\x01 \x01(\x0c\x12 \n\x18start_action_id_included\x18\x02 \x01(\x08\x1a\xc8\x02\n\x18GetGroupActionsRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1f\n\x17group_contract_position\x18\x02 \x01(\r\x12N\n\x06status\x18\x03 \x01(\x0e\x32>.org.dash.platform.dapi.v0.GetGroupActionsRequest.ActionStatus\x12\x62\n\x12start_at_action_id\x18\x04 \x01(\x0b\x32\x41.org.dash.platform.dapi.v0.GetGroupActionsRequest.StartAtActionIdH\x00\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x05 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\x06 \x01(\x08\x42\x15\n\x13_start_at_action_idB\x08\n\x06_count\"&\n\x0c\x41\x63tionStatus\x12\n\n\x06\x41\x43TIVE\x10\x00\x12\n\n\x06\x43LOSED\x10\x01\x42\t\n\x07version\"\xd6\x1e\n\x17GetGroupActionsResponse\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0H\x00\x1a\xd3\x1d\n\x19GetGroupActionsResponseV0\x12r\n\rgroup_actions\x18\x01 \x01(\x0b\x32Y.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.GroupActionsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a[\n\tMintEvent\x12\x0e\n\x06\x61mount\x18\x01 \x01(\x04\x12\x14\n\x0crecipient_id\x18\x02 \x01(\x0c\x12\x18\n\x0bpublic_note\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1a[\n\tBurnEvent\x12\x0e\n\x06\x61mount\x18\x01 \x01(\x04\x12\x14\n\x0c\x62urn_from_id\x18\x02 \x01(\x0c\x12\x18\n\x0bpublic_note\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1aJ\n\x0b\x46reezeEvent\x12\x11\n\tfrozen_id\x18\x01 \x01(\x0c\x12\x18\n\x0bpublic_note\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1aL\n\rUnfreezeEvent\x12\x11\n\tfrozen_id\x18\x01 \x01(\x0c\x12\x18\n\x0bpublic_note\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1a\x66\n\x17\x44\x65stroyFrozenFundsEvent\x12\x11\n\tfrozen_id\x18\x01 \x01(\x0c\x12\x0e\n\x06\x61mount\x18\x02 \x01(\x04\x12\x18\n\x0bpublic_note\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1a\x64\n\x13SharedEncryptedNote\x12\x18\n\x10sender_key_index\x18\x01 \x01(\r\x12\x1b\n\x13recipient_key_index\x18\x02 \x01(\r\x12\x16\n\x0e\x65ncrypted_data\x18\x03 \x01(\x0c\x1a{\n\x15PersonalEncryptedNote\x12!\n\x19root_encryption_key_index\x18\x01 \x01(\r\x12\'\n\x1f\x64\x65rivation_encryption_key_index\x18\x02 \x01(\r\x12\x16\n\x0e\x65ncrypted_data\x18\x03 \x01(\x0c\x1a\xe9\x01\n\x14\x45mergencyActionEvent\x12\x81\x01\n\x0b\x61\x63tion_type\x18\x01 \x01(\x0e\x32l.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.EmergencyActionEvent.ActionType\x12\x18\n\x0bpublic_note\x18\x02 \x01(\tH\x00\x88\x01\x01\"#\n\nActionType\x12\t\n\x05PAUSE\x10\x00\x12\n\n\x06RESUME\x10\x01\x42\x0e\n\x0c_public_note\x1a\x64\n\x16TokenConfigUpdateEvent\x12 \n\x18token_config_update_item\x18\x01 \x01(\x0c\x12\x18\n\x0bpublic_note\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1a\xe6\x03\n\x1eUpdateDirectPurchasePriceEvent\x12\x15\n\x0b\x66ixed_price\x18\x01 \x01(\x04H\x00\x12\x95\x01\n\x0evariable_price\x18\x02 \x01(\x0b\x32{.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.UpdateDirectPurchasePriceEvent.PricingScheduleH\x00\x12\x18\n\x0bpublic_note\x18\x03 \x01(\tH\x01\x88\x01\x01\x1a\x33\n\x10PriceForQuantity\x12\x10\n\x08quantity\x18\x01 \x01(\x04\x12\r\n\x05price\x18\x02 \x01(\x04\x1a\xac\x01\n\x0fPricingSchedule\x12\x98\x01\n\x12price_for_quantity\x18\x01 \x03(\x0b\x32|.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.UpdateDirectPurchasePriceEvent.PriceForQuantityB\x07\n\x05priceB\x0e\n\x0c_public_note\x1a\xfc\x02\n\x10GroupActionEvent\x12n\n\x0btoken_event\x18\x01 \x01(\x0b\x32W.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.TokenEventH\x00\x12t\n\x0e\x64ocument_event\x18\x02 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.DocumentEventH\x00\x12t\n\x0e\x63ontract_event\x18\x03 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.ContractEventH\x00\x42\x0c\n\nevent_type\x1a\x8b\x01\n\rDocumentEvent\x12r\n\x06\x63reate\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.DocumentCreateEventH\x00\x42\x06\n\x04type\x1a/\n\x13\x44ocumentCreateEvent\x12\x18\n\x10\x63reated_document\x18\x01 \x01(\x0c\x1a/\n\x13\x43ontractUpdateEvent\x12\x18\n\x10updated_contract\x18\x01 \x01(\x0c\x1a\x8b\x01\n\rContractEvent\x12r\n\x06update\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.ContractUpdateEventH\x00\x42\x06\n\x04type\x1a\xd1\x07\n\nTokenEvent\x12\x66\n\x04mint\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.MintEventH\x00\x12\x66\n\x04\x62urn\x18\x02 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.BurnEventH\x00\x12j\n\x06\x66reeze\x18\x03 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.FreezeEventH\x00\x12n\n\x08unfreeze\x18\x04 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.UnfreezeEventH\x00\x12\x84\x01\n\x14\x64\x65stroy_frozen_funds\x18\x05 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.DestroyFrozenFundsEventH\x00\x12}\n\x10\x65mergency_action\x18\x06 \x01(\x0b\x32\x61.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.EmergencyActionEventH\x00\x12\x82\x01\n\x13token_config_update\x18\x07 \x01(\x0b\x32\x63.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.TokenConfigUpdateEventH\x00\x12\x83\x01\n\x0cupdate_price\x18\x08 \x01(\x0b\x32k.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.UpdateDirectPurchasePriceEventH\x00\x42\x06\n\x04type\x1a\x93\x01\n\x10GroupActionEntry\x12\x11\n\taction_id\x18\x01 \x01(\x0c\x12l\n\x05\x65vent\x18\x02 \x01(\x0b\x32].org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.GroupActionEvent\x1a\x84\x01\n\x0cGroupActions\x12t\n\rgroup_actions\x18\x01 \x03(\x0b\x32].org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.GroupActionEntryB\x08\n\x06resultB\t\n\x07version\"\x88\x03\n\x1cGetGroupActionSignersRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetGroupActionSignersRequest.GetGroupActionSignersRequestV0H\x00\x1a\xce\x01\n\x1eGetGroupActionSignersRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1f\n\x17group_contract_position\x18\x02 \x01(\r\x12T\n\x06status\x18\x03 \x01(\x0e\x32\x44.org.dash.platform.dapi.v0.GetGroupActionSignersRequest.ActionStatus\x12\x11\n\taction_id\x18\x04 \x01(\x0c\x12\r\n\x05prove\x18\x05 \x01(\x08\"&\n\x0c\x41\x63tionStatus\x12\n\n\x06\x41\x43TIVE\x10\x00\x12\n\n\x06\x43LOSED\x10\x01\x42\t\n\x07version\"\x8b\x05\n\x1dGetGroupActionSignersResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetGroupActionSignersResponse.GetGroupActionSignersResponseV0H\x00\x1a\xf6\x03\n\x1fGetGroupActionSignersResponseV0\x12\x8b\x01\n\x14group_action_signers\x18\x01 \x01(\x0b\x32k.org.dash.platform.dapi.v0.GetGroupActionSignersResponse.GetGroupActionSignersResponseV0.GroupActionSignersH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x35\n\x11GroupActionSigner\x12\x11\n\tsigner_id\x18\x01 \x01(\x0c\x12\r\n\x05power\x18\x02 \x01(\r\x1a\x91\x01\n\x12GroupActionSigners\x12{\n\x07signers\x18\x01 \x03(\x0b\x32j.org.dash.platform.dapi.v0.GetGroupActionSignersResponse.GetGroupActionSignersResponseV0.GroupActionSignerB\x08\n\x06resultB\t\n\x07version\"\xb5\x01\n\x15GetAddressInfoRequest\x12V\n\x02v0\x18\x01 \x01(\x0b\x32H.org.dash.platform.dapi.v0.GetAddressInfoRequest.GetAddressInfoRequestV0H\x00\x1a\x39\n\x17GetAddressInfoRequestV0\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x85\x01\n\x10\x41\x64\x64ressInfoEntry\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12J\n\x11\x62\x61lance_and_nonce\x18\x02 \x01(\x0b\x32*.org.dash.platform.dapi.v0.BalanceAndNonceH\x00\x88\x01\x01\x42\x14\n\x12_balance_and_nonce\"1\n\x0f\x42\x61lanceAndNonce\x12\x0f\n\x07\x62\x61lance\x18\x01 \x01(\x04\x12\r\n\x05nonce\x18\x02 \x01(\r\"_\n\x12\x41\x64\x64ressInfoEntries\x12I\n\x14\x61\x64\x64ress_info_entries\x18\x01 \x03(\x0b\x32+.org.dash.platform.dapi.v0.AddressInfoEntry\"m\n\x14\x41\x64\x64ressBalanceChange\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12\x19\n\x0bset_balance\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x12\x1c\n\x0e\x61\x64\x64_to_balance\x18\x03 \x01(\x04\x42\x02\x30\x01H\x00\x42\x0b\n\toperation\"x\n\x1a\x42lockAddressBalanceChanges\x12\x18\n\x0c\x62lock_height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12@\n\x07\x63hanges\x18\x02 \x03(\x0b\x32/.org.dash.platform.dapi.v0.AddressBalanceChange\"k\n\x1b\x41\x64\x64ressBalanceUpdateEntries\x12L\n\rblock_changes\x18\x01 \x03(\x0b\x32\x35.org.dash.platform.dapi.v0.BlockAddressBalanceChanges\"\xe1\x02\n\x16GetAddressInfoResponse\x12X\n\x02v0\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetAddressInfoResponse.GetAddressInfoResponseV0H\x00\x1a\xe1\x01\n\x18GetAddressInfoResponseV0\x12I\n\x12\x61\x64\x64ress_info_entry\x18\x01 \x01(\x0b\x32+.org.dash.platform.dapi.v0.AddressInfoEntryH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc3\x01\n\x18GetAddressesInfosRequest\x12\\\n\x02v0\x18\x01 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetAddressesInfosRequest.GetAddressesInfosRequestV0H\x00\x1a>\n\x1aGetAddressesInfosRequestV0\x12\x11\n\taddresses\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xf1\x02\n\x19GetAddressesInfosResponse\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetAddressesInfosResponse.GetAddressesInfosResponseV0H\x00\x1a\xe8\x01\n\x1bGetAddressesInfosResponseV0\x12M\n\x14\x61\x64\x64ress_info_entries\x18\x01 \x01(\x0b\x32-.org.dash.platform.dapi.v0.AddressInfoEntriesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xb5\x01\n\x1dGetAddressesTrunkStateRequest\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetAddressesTrunkStateRequest.GetAddressesTrunkStateRequestV0H\x00\x1a!\n\x1fGetAddressesTrunkStateRequestV0B\t\n\x07version\"\xaa\x02\n\x1eGetAddressesTrunkStateResponse\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetAddressesTrunkStateResponse.GetAddressesTrunkStateResponseV0H\x00\x1a\x92\x01\n GetAddressesTrunkStateResponseV0\x12/\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.Proof\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\t\n\x07version\"\xf0\x01\n\x1eGetAddressesBranchStateRequest\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetAddressesBranchStateRequest.GetAddressesBranchStateRequestV0H\x00\x1aY\n GetAddressesBranchStateRequestV0\x12\x0b\n\x03key\x18\x01 \x01(\x0c\x12\r\n\x05\x64\x65pth\x18\x02 \x01(\r\x12\x19\n\x11\x63heckpoint_height\x18\x03 \x01(\x04\x42\t\n\x07version\"\xd1\x01\n\x1fGetAddressesBranchStateResponse\x12j\n\x02v0\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetAddressesBranchStateResponse.GetAddressesBranchStateResponseV0H\x00\x1a\x37\n!GetAddressesBranchStateResponseV0\x12\x12\n\nmerk_proof\x18\x02 \x01(\x0c\x42\t\n\x07version\"\x9e\x02\n%GetRecentAddressBalanceChangesRequest\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetRecentAddressBalanceChangesRequest.GetRecentAddressBalanceChangesRequestV0H\x00\x1ar\n\'GetRecentAddressBalanceChangesRequestV0\x12\x18\n\x0cstart_height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05prove\x18\x02 \x01(\x08\x12\x1e\n\x16start_height_exclusive\x18\x03 \x01(\x08\x42\t\n\x07version\"\xb8\x03\n&GetRecentAddressBalanceChangesResponse\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetRecentAddressBalanceChangesResponse.GetRecentAddressBalanceChangesResponseV0H\x00\x1a\x88\x02\n(GetRecentAddressBalanceChangesResponseV0\x12`\n\x1e\x61\x64\x64ress_balance_update_entries\x18\x01 \x01(\x0b\x32\x36.org.dash.platform.dapi.v0.AddressBalanceUpdateEntriesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"G\n\x16\x42lockHeightCreditEntry\x12\x18\n\x0c\x62lock_height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x13\n\x07\x63redits\x18\x02 \x01(\x04\x42\x02\x30\x01\"\xb0\x01\n\x1d\x43ompactedAddressBalanceChange\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12\x19\n\x0bset_credits\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x12V\n\x19\x61\x64\x64_to_credits_operations\x18\x03 \x01(\x0b\x32\x31.org.dash.platform.dapi.v0.AddToCreditsOperationsH\x00\x42\x0b\n\toperation\"\\\n\x16\x41\x64\x64ToCreditsOperations\x12\x42\n\x07\x65ntries\x18\x01 \x03(\x0b\x32\x31.org.dash.platform.dapi.v0.BlockHeightCreditEntry\"\xae\x01\n#CompactedBlockAddressBalanceChanges\x12\x1e\n\x12start_block_height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x1c\n\x10\x65nd_block_height\x18\x02 \x01(\x04\x42\x02\x30\x01\x12I\n\x07\x63hanges\x18\x03 \x03(\x0b\x32\x38.org.dash.platform.dapi.v0.CompactedAddressBalanceChange\"\x87\x01\n$CompactedAddressBalanceUpdateEntries\x12_\n\x17\x63ompacted_block_changes\x18\x01 \x03(\x0b\x32>.org.dash.platform.dapi.v0.CompactedBlockAddressBalanceChanges\"\xa9\x02\n.GetRecentCompactedAddressBalanceChangesRequest\x12\x88\x01\n\x02v0\x18\x01 \x01(\x0b\x32z.org.dash.platform.dapi.v0.GetRecentCompactedAddressBalanceChangesRequest.GetRecentCompactedAddressBalanceChangesRequestV0H\x00\x1a\x61\n0GetRecentCompactedAddressBalanceChangesRequestV0\x12\x1e\n\x12start_block_height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xf0\x03\n/GetRecentCompactedAddressBalanceChangesResponse\x12\x8a\x01\n\x02v0\x18\x01 \x01(\x0b\x32|.org.dash.platform.dapi.v0.GetRecentCompactedAddressBalanceChangesResponse.GetRecentCompactedAddressBalanceChangesResponseV0H\x00\x1a\xa4\x02\n1GetRecentCompactedAddressBalanceChangesResponseV0\x12s\n(compacted_address_balance_update_entries\x18\x01 \x01(\x0b\x32?.org.dash.platform.dapi.v0.CompactedAddressBalanceUpdateEntriesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xf4\x01\n GetShieldedEncryptedNotesRequest\x12l\n\x02v0\x18\x01 \x01(\x0b\x32^.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesRequest.GetShieldedEncryptedNotesRequestV0H\x00\x1aW\n\"GetShieldedEncryptedNotesRequestV0\x12\x13\n\x0bstart_index\x18\x01 \x01(\x04\x12\r\n\x05\x63ount\x18\x02 \x01(\r\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xbc\x05\n!GetShieldedEncryptedNotesResponse\x12n\n\x02v0\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesResponse.GetShieldedEncryptedNotesResponseV0H\x00\x1a\x9b\x04\n#GetShieldedEncryptedNotesResponseV0\x12\x8a\x01\n\x0f\x65ncrypted_notes\x18\x01 \x01(\x0b\x32o.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesResponse.GetShieldedEncryptedNotesResponseV0.EncryptedNotesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aW\n\rEncryptedNote\x12\x11\n\tnullifier\x18\x01 \x01(\x0c\x12\x0b\n\x03\x63mx\x18\x02 \x01(\x0c\x12\x16\n\x0e\x65ncrypted_note\x18\x03 \x01(\x0c\x12\x0e\n\x06\x63v_net\x18\x04 \x01(\x0c\x1a\x91\x01\n\x0e\x45ncryptedNotes\x12\x7f\n\x07\x65ntries\x18\x01 \x03(\x0b\x32n.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesResponse.GetShieldedEncryptedNotesResponseV0.EncryptedNoteB\x08\n\x06resultB\t\n\x07version\"\xb4\x01\n\x19GetShieldedAnchorsRequest\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetShieldedAnchorsRequest.GetShieldedAnchorsRequestV0H\x00\x1a,\n\x1bGetShieldedAnchorsRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xb1\x03\n\x1aGetShieldedAnchorsResponse\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetShieldedAnchorsResponse.GetShieldedAnchorsResponseV0H\x00\x1a\xa5\x02\n\x1cGetShieldedAnchorsResponseV0\x12m\n\x07\x61nchors\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetShieldedAnchorsResponse.GetShieldedAnchorsResponseV0.AnchorsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x1a\n\x07\x41nchors\x12\x0f\n\x07\x61nchors\x18\x01 \x03(\x0c\x42\x08\n\x06resultB\t\n\x07version\"\xd8\x01\n\"GetMostRecentShieldedAnchorRequest\x12p\n\x02v0\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetMostRecentShieldedAnchorRequest.GetMostRecentShieldedAnchorRequestV0H\x00\x1a\x35\n$GetMostRecentShieldedAnchorRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xdc\x02\n#GetMostRecentShieldedAnchorResponse\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetMostRecentShieldedAnchorResponse.GetMostRecentShieldedAnchorResponseV0H\x00\x1a\xb5\x01\n%GetMostRecentShieldedAnchorResponseV0\x12\x10\n\x06\x61nchor\x18\x01 \x01(\x0cH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xbc\x01\n\x1bGetShieldedPoolStateRequest\x12\x62\n\x02v0\x18\x01 \x01(\x0b\x32T.org.dash.platform.dapi.v0.GetShieldedPoolStateRequest.GetShieldedPoolStateRequestV0H\x00\x1a.\n\x1dGetShieldedPoolStateRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xcb\x02\n\x1cGetShieldedPoolStateResponse\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetShieldedPoolStateResponse.GetShieldedPoolStateResponseV0H\x00\x1a\xb9\x01\n\x1eGetShieldedPoolStateResponseV0\x12\x1b\n\rtotal_balance\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc0\x01\n\x1cGetShieldedNotesCountRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetShieldedNotesCountRequest.GetShieldedNotesCountRequestV0H\x00\x1a/\n\x1eGetShieldedNotesCountRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xd3\x02\n\x1dGetShieldedNotesCountResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetShieldedNotesCountResponse.GetShieldedNotesCountResponseV0H\x00\x1a\xbe\x01\n\x1fGetShieldedNotesCountResponseV0\x12\x1f\n\x11total_notes_count\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xd4\x01\n\x1cGetShieldedNullifiersRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetShieldedNullifiersRequest.GetShieldedNullifiersRequestV0H\x00\x1a\x43\n\x1eGetShieldedNullifiersRequestV0\x12\x12\n\nnullifiers\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x86\x05\n\x1dGetShieldedNullifiersResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetShieldedNullifiersResponse.GetShieldedNullifiersResponseV0H\x00\x1a\xf1\x03\n\x1fGetShieldedNullifiersResponseV0\x12\x88\x01\n\x12nullifier_statuses\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetShieldedNullifiersResponse.GetShieldedNullifiersResponseV0.NullifierStatusesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x36\n\x0fNullifierStatus\x12\x11\n\tnullifier\x18\x01 \x01(\x0c\x12\x10\n\x08is_spent\x18\x02 \x01(\x08\x1a\x8e\x01\n\x11NullifierStatuses\x12y\n\x07\x65ntries\x18\x01 \x03(\x0b\x32h.org.dash.platform.dapi.v0.GetShieldedNullifiersResponse.GetShieldedNullifiersResponseV0.NullifierStatusB\x08\n\x06resultB\t\n\x07version*Z\n\nKeyPurpose\x12\x12\n\x0e\x41UTHENTICATION\x10\x00\x12\x0e\n\nENCRYPTION\x10\x01\x12\x0e\n\nDECRYPTION\x10\x02\x12\x0c\n\x08TRANSFER\x10\x03\x12\n\n\x06VOTING\x10\x05*\x92\x01\n\x16\x43ontractModerationList\x12(\n$CONTRACT_MODERATION_LIST_UNSPECIFIED\x10\x00\x12$\n CONTRACT_MODERATION_LIST_BANLIST\x10\x01\x12(\n$CONTRACT_MODERATION_LIST_SUSPENSIONS\x10\x02\x32\xd0P\n\x08Platform\x12\x93\x01\n\x18\x62roadcastStateTransition\x12:.org.dash.platform.dapi.v0.BroadcastStateTransitionRequest\x1a;.org.dash.platform.dapi.v0.BroadcastStateTransitionResponse\x12l\n\x0bgetIdentity\x12-.org.dash.platform.dapi.v0.GetIdentityRequest\x1a..org.dash.platform.dapi.v0.GetIdentityResponse\x12x\n\x0fgetIdentityKeys\x12\x31.org.dash.platform.dapi.v0.GetIdentityKeysRequest\x1a\x32.org.dash.platform.dapi.v0.GetIdentityKeysResponse\x12\x96\x01\n\x19getIdentitiesContractKeys\x12;.org.dash.platform.dapi.v0.GetIdentitiesContractKeysRequest\x1a<.org.dash.platform.dapi.v0.GetIdentitiesContractKeysResponse\x12{\n\x10getIdentityNonce\x12\x32.org.dash.platform.dapi.v0.GetIdentityNonceRequest\x1a\x33.org.dash.platform.dapi.v0.GetIdentityNonceResponse\x12\x93\x01\n\x18getIdentityContractNonce\x12:.org.dash.platform.dapi.v0.GetIdentityContractNonceRequest\x1a;.org.dash.platform.dapi.v0.GetIdentityContractNonceResponse\x12\xa8\x01\n\x1fgetIdentityKeysRemainingBudgets\x12\x41.org.dash.platform.dapi.v0.GetIdentityKeysRemainingBudgetsRequest\x1a\x42.org.dash.platform.dapi.v0.GetIdentityKeysRemainingBudgetsResponse\x12\x81\x01\n\x12getIdentityBalance\x12\x34.org.dash.platform.dapi.v0.GetIdentityBalanceRequest\x1a\x35.org.dash.platform.dapi.v0.GetIdentityBalanceResponse\x12\x8a\x01\n\x15getIdentitiesBalances\x12\x37.org.dash.platform.dapi.v0.GetIdentitiesBalancesRequest\x1a\x38.org.dash.platform.dapi.v0.GetIdentitiesBalancesResponse\x12\xa2\x01\n\x1dgetIdentityBalanceAndRevision\x12?.org.dash.platform.dapi.v0.GetIdentityBalanceAndRevisionRequest\x1a@.org.dash.platform.dapi.v0.GetIdentityBalanceAndRevisionResponse\x12\xaf\x01\n#getEvonodesProposedEpochBlocksByIds\x12\x45.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksByIdsRequest\x1a\x41.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksResponse\x12\xb3\x01\n%getEvonodesProposedEpochBlocksByRange\x12G.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksByRangeRequest\x1a\x41.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksResponse\x12x\n\x0fgetDataContract\x12\x31.org.dash.platform.dapi.v0.GetDataContractRequest\x1a\x32.org.dash.platform.dapi.v0.GetDataContractResponse\x12\x8d\x01\n\x16getDataContractHistory\x12\x38.org.dash.platform.dapi.v0.GetDataContractHistoryRequest\x1a\x39.org.dash.platform.dapi.v0.GetDataContractHistoryResponse\x12\xa5\x01\n\x1egetDataContractsLatestVersions\x12@.org.dash.platform.dapi.v0.GetDataContractsLatestVersionsRequest\x1a\x41.org.dash.platform.dapi.v0.GetDataContractsLatestVersionsResponse\x12{\n\x10getDataContracts\x12\x32.org.dash.platform.dapi.v0.GetDataContractsRequest\x1a\x33.org.dash.platform.dapi.v0.GetDataContractsResponse\x12\x89\x01\n\x17getDataContractsByRange\x12\x39.org.dash.platform.dapi.v0.GetDataContractsByRangeRequest\x1a\x33.org.dash.platform.dapi.v0.GetDataContractsResponse\x12\x87\x01\n\x14getContractGroupInfo\x12\x36.org.dash.platform.dapi.v0.GetContractGroupInfoRequest\x1a\x37.org.dash.platform.dapi.v0.GetContractGroupInfoResponse\x12\x90\x01\n\x17getContractGroupMembers\x12\x39.org.dash.platform.dapi.v0.GetContractGroupMembersRequest\x1a:.org.dash.platform.dapi.v0.GetContractGroupMembersResponse\x12\x9f\x01\n\x1cgetContractGroupsForContract\x12>.org.dash.platform.dapi.v0.GetContractGroupsForContractRequest\x1a?.org.dash.platform.dapi.v0.GetContractGroupsForContractResponse\x12\x9c\x01\n\x1bgetContractModerationStatus\x12=.org.dash.platform.dapi.v0.GetContractModerationStatusRequest\x1a>.org.dash.platform.dapi.v0.GetContractModerationStatusResponse\x12\x9f\x01\n\x1cgetContractModerationEntries\x12>.org.dash.platform.dapi.v0.GetContractModerationEntriesRequest\x1a?.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse\x12\x9c\x01\n\x1bgetContractDocumentRemovals\x12=.org.dash.platform.dapi.v0.GetContractDocumentRemovalsRequest\x1a>.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse\x12\x81\x01\n\x12getContractFeePots\x12\x34.org.dash.platform.dapi.v0.GetContractFeePotsRequest\x1a\x35.org.dash.platform.dapi.v0.GetContractFeePotsResponse\x12\x81\x01\n\x12getDocumentHistory\x12\x34.org.dash.platform.dapi.v0.GetDocumentHistoryRequest\x1a\x35.org.dash.platform.dapi.v0.GetDocumentHistoryResponse\x12o\n\x0cgetDocuments\x12..org.dash.platform.dapi.v0.GetDocumentsRequest\x1a/.org.dash.platform.dapi.v0.GetDocumentsResponse\x12\x99\x01\n\x1agetIdentityByPublicKeyHash\x12<.org.dash.platform.dapi.v0.GetIdentityByPublicKeyHashRequest\x1a=.org.dash.platform.dapi.v0.GetIdentityByPublicKeyHashResponse\x12\xb4\x01\n#getIdentityByNonUniquePublicKeyHash\x12\x45.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashRequest\x1a\x46.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashResponse\x12\x9f\x01\n\x1cwaitForStateTransitionResult\x12>.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest\x1a?.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse\x12\x81\x01\n\x12getConsensusParams\x12\x34.org.dash.platform.dapi.v0.GetConsensusParamsRequest\x1a\x35.org.dash.platform.dapi.v0.GetConsensusParamsResponse\x12\xa5\x01\n\x1egetProtocolVersionUpgradeState\x12@.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateRequest\x1a\x41.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateResponse\x12\xb4\x01\n#getProtocolVersionUpgradeVoteStatus\x12\x45.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusRequest\x1a\x46.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusResponse\x12r\n\rgetEpochsInfo\x12/.org.dash.platform.dapi.v0.GetEpochsInfoRequest\x1a\x30.org.dash.platform.dapi.v0.GetEpochsInfoResponse\x12\x8d\x01\n\x16getFinalizedEpochInfos\x12\x38.org.dash.platform.dapi.v0.GetFinalizedEpochInfosRequest\x1a\x39.org.dash.platform.dapi.v0.GetFinalizedEpochInfosResponse\x12\x8a\x01\n\x15getContestedResources\x12\x37.org.dash.platform.dapi.v0.GetContestedResourcesRequest\x1a\x38.org.dash.platform.dapi.v0.GetContestedResourcesResponse\x12\xa2\x01\n\x1dgetContestedResourceVoteState\x12?.org.dash.platform.dapi.v0.GetContestedResourceVoteStateRequest\x1a@.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse\x12\xba\x01\n%getContestedResourceVotersForIdentity\x12G.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityRequest\x1aH.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityResponse\x12\xae\x01\n!getContestedResourceIdentityVotes\x12\x43.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesRequest\x1a\x44.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse\x12\x8a\x01\n\x15getVotePollsByEndDate\x12\x37.org.dash.platform.dapi.v0.GetVotePollsByEndDateRequest\x1a\x38.org.dash.platform.dapi.v0.GetVotePollsByEndDateResponse\x12\xa5\x01\n\x1egetPrefundedSpecializedBalance\x12@.org.dash.platform.dapi.v0.GetPrefundedSpecializedBalanceRequest\x1a\x41.org.dash.platform.dapi.v0.GetPrefundedSpecializedBalanceResponse\x12\x96\x01\n\x19getTotalCreditsInPlatform\x12;.org.dash.platform.dapi.v0.GetTotalCreditsInPlatformRequest\x1a<.org.dash.platform.dapi.v0.GetTotalCreditsInPlatformResponse\x12x\n\x0fgetPathElements\x12\x31.org.dash.platform.dapi.v0.GetPathElementsRequest\x1a\x32.org.dash.platform.dapi.v0.GetPathElementsResponse\x12\x66\n\tgetStatus\x12+.org.dash.platform.dapi.v0.GetStatusRequest\x1a,.org.dash.platform.dapi.v0.GetStatusResponse\x12\x8a\x01\n\x15getCurrentQuorumsInfo\x12\x37.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoRequest\x1a\x38.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoResponse\x12\x93\x01\n\x18getIdentityTokenBalances\x12:.org.dash.platform.dapi.v0.GetIdentityTokenBalancesRequest\x1a;.org.dash.platform.dapi.v0.GetIdentityTokenBalancesResponse\x12\x99\x01\n\x1agetIdentitiesTokenBalances\x12<.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesRequest\x1a=.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesResponse\x12\x8a\x01\n\x15getIdentityTokenInfos\x12\x37.org.dash.platform.dapi.v0.GetIdentityTokenInfosRequest\x1a\x38.org.dash.platform.dapi.v0.GetIdentityTokenInfosResponse\x12\x90\x01\n\x17getIdentitiesTokenInfos\x12\x39.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosRequest\x1a:.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosResponse\x12{\n\x10getTokenStatuses\x12\x32.org.dash.platform.dapi.v0.GetTokenStatusesRequest\x1a\x33.org.dash.platform.dapi.v0.GetTokenStatusesResponse\x12\x9f\x01\n\x1cgetTokenDirectPurchasePrices\x12>.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesRequest\x1a?.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse\x12\x87\x01\n\x14getTokenContractInfo\x12\x36.org.dash.platform.dapi.v0.GetTokenContractInfoRequest\x1a\x37.org.dash.platform.dapi.v0.GetTokenContractInfoResponse\x12\xb1\x01\n\"getTokenPreProgrammedDistributions\x12\x44.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsRequest\x1a\x45.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsResponse\x12\xbd\x01\n&getTokenPerpetualDistributionLastClaim\x12H.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimRequest\x1aI.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimResponse\x12\x84\x01\n\x13getTokenTotalSupply\x12\x35.org.dash.platform.dapi.v0.GetTokenTotalSupplyRequest\x1a\x36.org.dash.platform.dapi.v0.GetTokenTotalSupplyResponse\x12o\n\x0cgetGroupInfo\x12..org.dash.platform.dapi.v0.GetGroupInfoRequest\x1a/.org.dash.platform.dapi.v0.GetGroupInfoResponse\x12r\n\rgetGroupInfos\x12/.org.dash.platform.dapi.v0.GetGroupInfosRequest\x1a\x30.org.dash.platform.dapi.v0.GetGroupInfosResponse\x12x\n\x0fgetGroupActions\x12\x31.org.dash.platform.dapi.v0.GetGroupActionsRequest\x1a\x32.org.dash.platform.dapi.v0.GetGroupActionsResponse\x12\x8a\x01\n\x15getGroupActionSigners\x12\x37.org.dash.platform.dapi.v0.GetGroupActionSignersRequest\x1a\x38.org.dash.platform.dapi.v0.GetGroupActionSignersResponse\x12u\n\x0egetAddressInfo\x12\x30.org.dash.platform.dapi.v0.GetAddressInfoRequest\x1a\x31.org.dash.platform.dapi.v0.GetAddressInfoResponse\x12~\n\x11getAddressesInfos\x12\x33.org.dash.platform.dapi.v0.GetAddressesInfosRequest\x1a\x34.org.dash.platform.dapi.v0.GetAddressesInfosResponse\x12\x8d\x01\n\x16getAddressesTrunkState\x12\x38.org.dash.platform.dapi.v0.GetAddressesTrunkStateRequest\x1a\x39.org.dash.platform.dapi.v0.GetAddressesTrunkStateResponse\x12\x90\x01\n\x17getAddressesBranchState\x12\x39.org.dash.platform.dapi.v0.GetAddressesBranchStateRequest\x1a:.org.dash.platform.dapi.v0.GetAddressesBranchStateResponse\x12\xa5\x01\n\x1egetRecentAddressBalanceChanges\x12@.org.dash.platform.dapi.v0.GetRecentAddressBalanceChangesRequest\x1a\x41.org.dash.platform.dapi.v0.GetRecentAddressBalanceChangesResponse\x12\xc0\x01\n\'getRecentCompactedAddressBalanceChanges\x12I.org.dash.platform.dapi.v0.GetRecentCompactedAddressBalanceChangesRequest\x1aJ.org.dash.platform.dapi.v0.GetRecentCompactedAddressBalanceChangesResponse\x12\x96\x01\n\x19getShieldedEncryptedNotes\x12;.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesRequest\x1a<.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesResponse\x12\x81\x01\n\x12getShieldedAnchors\x12\x34.org.dash.platform.dapi.v0.GetShieldedAnchorsRequest\x1a\x35.org.dash.platform.dapi.v0.GetShieldedAnchorsResponse\x12\x9c\x01\n\x1bgetMostRecentShieldedAnchor\x12=.org.dash.platform.dapi.v0.GetMostRecentShieldedAnchorRequest\x1a>.org.dash.platform.dapi.v0.GetMostRecentShieldedAnchorResponse\x12\x87\x01\n\x14getShieldedPoolState\x12\x36.org.dash.platform.dapi.v0.GetShieldedPoolStateRequest\x1a\x37.org.dash.platform.dapi.v0.GetShieldedPoolStateResponse\x12\x8a\x01\n\x15getShieldedNotesCount\x12\x37.org.dash.platform.dapi.v0.GetShieldedNotesCountRequest\x1a\x38.org.dash.platform.dapi.v0.GetShieldedNotesCountResponse\x12\x8a\x01\n\x15getShieldedNullifiers\x12\x37.org.dash.platform.dapi.v0.GetShieldedNullifiersRequest\x1a\x38.org.dash.platform.dapi.v0.GetShieldedNullifiersResponseb\x06proto3' + serialized_pb=b'\n\x0eplatform.proto\x12\x19org.dash.platform.dapi.v0\x1a\x1egoogle/protobuf/wrappers.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x81\x01\n\x05Proof\x12\x15\n\rgrovedb_proof\x18\x01 \x01(\x0c\x12\x13\n\x0bquorum_hash\x18\x02 \x01(\x0c\x12\x11\n\tsignature\x18\x03 \x01(\x0c\x12\r\n\x05round\x18\x04 \x01(\r\x12\x15\n\rblock_id_hash\x18\x05 \x01(\x0c\x12\x13\n\x0bquorum_type\x18\x06 \x01(\r\"\x98\x01\n\x10ResponseMetadata\x12\x12\n\x06height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12 \n\x18\x63ore_chain_locked_height\x18\x02 \x01(\r\x12\r\n\x05\x65poch\x18\x03 \x01(\r\x12\x13\n\x07time_ms\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\x18\n\x10protocol_version\x18\x05 \x01(\r\x12\x10\n\x08\x63hain_id\x18\x06 \x01(\t\"L\n\x1dStateTransitionBroadcastError\x12\x0c\n\x04\x63ode\x18\x01 \x01(\r\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\";\n\x1f\x42roadcastStateTransitionRequest\x12\x18\n\x10state_transition\x18\x01 \x01(\x0c\"\"\n BroadcastStateTransitionResponse\"\xa4\x01\n\x12GetIdentityRequest\x12P\n\x02v0\x18\x01 \x01(\x0b\x32\x42.org.dash.platform.dapi.v0.GetIdentityRequest.GetIdentityRequestV0H\x00\x1a\x31\n\x14GetIdentityRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xc1\x01\n\x17GetIdentityNonceRequest\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetIdentityNonceRequest.GetIdentityNonceRequestV0H\x00\x1a?\n\x19GetIdentityNonceRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xf6\x01\n\x1fGetIdentityContractNonceRequest\x12j\n\x02v0\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetIdentityContractNonceRequest.GetIdentityContractNonceRequestV0H\x00\x1a\\\n!GetIdentityContractNonceRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x13\n\x0b\x63ontract_id\x18\x02 \x01(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xc0\x01\n\x19GetIdentityBalanceRequest\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetIdentityBalanceRequest.GetIdentityBalanceRequestV0H\x00\x1a\x38\n\x1bGetIdentityBalanceRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xec\x01\n$GetIdentityBalanceAndRevisionRequest\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetIdentityBalanceAndRevisionRequest.GetIdentityBalanceAndRevisionRequestV0H\x00\x1a\x43\n&GetIdentityBalanceAndRevisionRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x9e\x02\n\x13GetIdentityResponse\x12R\n\x02v0\x18\x01 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetIdentityResponse.GetIdentityResponseV0H\x00\x1a\xa7\x01\n\x15GetIdentityResponseV0\x12\x12\n\x08identity\x18\x01 \x01(\x0cH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xbc\x02\n\x18GetIdentityNonceResponse\x12\\\n\x02v0\x18\x01 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetIdentityNonceResponse.GetIdentityNonceResponseV0H\x00\x1a\xb6\x01\n\x1aGetIdentityNonceResponseV0\x12\x1c\n\x0eidentity_nonce\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xe5\x02\n GetIdentityContractNonceResponse\x12l\n\x02v0\x18\x01 \x01(\x0b\x32^.org.dash.platform.dapi.v0.GetIdentityContractNonceResponse.GetIdentityContractNonceResponseV0H\x00\x1a\xc7\x01\n\"GetIdentityContractNonceResponseV0\x12%\n\x17identity_contract_nonce\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\x8e\x02\n&GetIdentityKeysRemainingBudgetsRequest\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetIdentityKeysRemainingBudgetsRequest.GetIdentityKeysRemainingBudgetsRequestV0H\x00\x1a_\n(GetIdentityKeysRemainingBudgetsRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x0f\n\x07key_ids\x18\x02 \x03(\r\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\x96\x06\n\'GetIdentityKeysRemainingBudgetsResponse\x12z\n\x02v0\x18\x01 \x01(\x0b\x32l.org.dash.platform.dapi.v0.GetIdentityKeysRemainingBudgetsResponse.GetIdentityKeysRemainingBudgetsResponseV0H\x00\x1a\xe3\x04\n)GetIdentityKeysRemainingBudgetsResponseV0\x12\xa4\x01\n\x16keys_remaining_budgets\x18\x01 \x01(\x0b\x32\x81\x01.org.dash.platform.dapi.v0.GetIdentityKeysRemainingBudgetsResponse.GetIdentityKeysRemainingBudgetsResponseV0.KeysRemainingBudgetsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x61\n\x17KeyRemainingBudgetEntry\x12\x0e\n\x06key_id\x18\x01 \x01(\r\x12!\n\x10remaining_budget\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x88\x01\x01\x42\x13\n\x11_remaining_budget\x1a\xaf\x01\n\x14KeysRemainingBudgets\x12\x96\x01\n\x07\x65ntries\x18\x01 \x03(\x0b\x32\x84\x01.org.dash.platform.dapi.v0.GetIdentityKeysRemainingBudgetsResponse.GetIdentityKeysRemainingBudgetsResponseV0.KeyRemainingBudgetEntryB\x08\n\x06resultB\t\n\x07version\"\xbd\x02\n\x1aGetIdentityBalanceResponse\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetIdentityBalanceResponse.GetIdentityBalanceResponseV0H\x00\x1a\xb1\x01\n\x1cGetIdentityBalanceResponseV0\x12\x15\n\x07\x62\x61lance\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xb1\x04\n%GetIdentityBalanceAndRevisionResponse\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetIdentityBalanceAndRevisionResponse.GetIdentityBalanceAndRevisionResponseV0H\x00\x1a\x84\x03\n\'GetIdentityBalanceAndRevisionResponseV0\x12\x9b\x01\n\x14\x62\x61lance_and_revision\x18\x01 \x01(\x0b\x32{.org.dash.platform.dapi.v0.GetIdentityBalanceAndRevisionResponse.GetIdentityBalanceAndRevisionResponseV0.BalanceAndRevisionH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a?\n\x12\x42\x61lanceAndRevision\x12\x13\n\x07\x62\x61lance\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x14\n\x08revision\x18\x02 \x01(\x04\x42\x02\x30\x01\x42\x08\n\x06resultB\t\n\x07version\"\xd1\x01\n\x0eKeyRequestType\x12\x36\n\x08\x61ll_keys\x18\x01 \x01(\x0b\x32\".org.dash.platform.dapi.v0.AllKeysH\x00\x12@\n\rspecific_keys\x18\x02 \x01(\x0b\x32\'.org.dash.platform.dapi.v0.SpecificKeysH\x00\x12:\n\nsearch_key\x18\x03 \x01(\x0b\x32$.org.dash.platform.dapi.v0.SearchKeyH\x00\x42\t\n\x07request\"\t\n\x07\x41llKeys\"\x1f\n\x0cSpecificKeys\x12\x0f\n\x07key_ids\x18\x01 \x03(\r\"\xb6\x01\n\tSearchKey\x12I\n\x0bpurpose_map\x18\x01 \x03(\x0b\x32\x34.org.dash.platform.dapi.v0.SearchKey.PurposeMapEntry\x1a^\n\x0fPurposeMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\r\x12:\n\x05value\x18\x02 \x01(\x0b\x32+.org.dash.platform.dapi.v0.SecurityLevelMap:\x02\x38\x01\"\xbf\x02\n\x10SecurityLevelMap\x12]\n\x12security_level_map\x18\x01 \x03(\x0b\x32\x41.org.dash.platform.dapi.v0.SecurityLevelMap.SecurityLevelMapEntry\x1aw\n\x15SecurityLevelMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\r\x12M\n\x05value\x18\x02 \x01(\x0e\x32>.org.dash.platform.dapi.v0.SecurityLevelMap.KeyKindRequestType:\x02\x38\x01\"S\n\x12KeyKindRequestType\x12\x1f\n\x1b\x43URRENT_KEY_OF_KIND_REQUEST\x10\x00\x12\x1c\n\x18\x41LL_KEYS_OF_KIND_REQUEST\x10\x01\"\xda\x02\n\x16GetIdentityKeysRequest\x12X\n\x02v0\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetIdentityKeysRequest.GetIdentityKeysRequestV0H\x00\x1a\xda\x01\n\x18GetIdentityKeysRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12?\n\x0crequest_type\x18\x02 \x01(\x0b\x32).org.dash.platform.dapi.v0.KeyRequestType\x12+\n\x05limit\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12,\n\x06offset\x18\x04 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\t\n\x07version\"\x99\x03\n\x17GetIdentityKeysResponse\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetIdentityKeysResponse.GetIdentityKeysResponseV0H\x00\x1a\x96\x02\n\x19GetIdentityKeysResponseV0\x12\x61\n\x04keys\x18\x01 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetIdentityKeysResponse.GetIdentityKeysResponseV0.KeysH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x1a\n\x04Keys\x12\x12\n\nkeys_bytes\x18\x01 \x03(\x0c\x42\x08\n\x06resultB\t\n\x07version\"\xef\x02\n GetIdentitiesContractKeysRequest\x12l\n\x02v0\x18\x01 \x01(\x0b\x32^.org.dash.platform.dapi.v0.GetIdentitiesContractKeysRequest.GetIdentitiesContractKeysRequestV0H\x00\x1a\xd1\x01\n\"GetIdentitiesContractKeysRequestV0\x12\x16\n\x0eidentities_ids\x18\x01 \x03(\x0c\x12\x13\n\x0b\x63ontract_id\x18\x02 \x01(\x0c\x12\x1f\n\x12\x64ocument_type_name\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x37\n\x08purposes\x18\x04 \x03(\x0e\x32%.org.dash.platform.dapi.v0.KeyPurpose\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\x15\n\x13_document_type_nameB\t\n\x07version\"\xdf\x06\n!GetIdentitiesContractKeysResponse\x12n\n\x02v0\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetIdentitiesContractKeysResponse.GetIdentitiesContractKeysResponseV0H\x00\x1a\xbe\x05\n#GetIdentitiesContractKeysResponseV0\x12\x8a\x01\n\x0fidentities_keys\x18\x01 \x01(\x0b\x32o.org.dash.platform.dapi.v0.GetIdentitiesContractKeysResponse.GetIdentitiesContractKeysResponseV0.IdentitiesKeysH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aY\n\x0bPurposeKeys\x12\x36\n\x07purpose\x18\x01 \x01(\x0e\x32%.org.dash.platform.dapi.v0.KeyPurpose\x12\x12\n\nkeys_bytes\x18\x02 \x03(\x0c\x1a\x9f\x01\n\x0cIdentityKeys\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12z\n\x04keys\x18\x02 \x03(\x0b\x32l.org.dash.platform.dapi.v0.GetIdentitiesContractKeysResponse.GetIdentitiesContractKeysResponseV0.PurposeKeys\x1a\x90\x01\n\x0eIdentitiesKeys\x12~\n\x07\x65ntries\x18\x01 \x03(\x0b\x32m.org.dash.platform.dapi.v0.GetIdentitiesContractKeysResponse.GetIdentitiesContractKeysResponseV0.IdentityKeysB\x08\n\x06resultB\t\n\x07version\"\xa4\x02\n*GetEvonodesProposedEpochBlocksByIdsRequest\x12\x80\x01\n\x02v0\x18\x01 \x01(\x0b\x32r.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksByIdsRequest.GetEvonodesProposedEpochBlocksByIdsRequestV0H\x00\x1ah\n,GetEvonodesProposedEpochBlocksByIdsRequestV0\x12\x12\n\x05\x65poch\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x0b\n\x03ids\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\x08\n\x06_epochB\t\n\x07version\"\x92\x06\n&GetEvonodesProposedEpochBlocksResponse\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksResponse.GetEvonodesProposedEpochBlocksResponseV0H\x00\x1a\xe2\x04\n(GetEvonodesProposedEpochBlocksResponseV0\x12\xb1\x01\n#evonodes_proposed_block_counts_info\x18\x01 \x01(\x0b\x32\x81\x01.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksResponse.GetEvonodesProposedEpochBlocksResponseV0.EvonodesProposedBlocksH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a?\n\x15\x45vonodeProposedBlocks\x12\x13\n\x0bpro_tx_hash\x18\x01 \x01(\x0c\x12\x11\n\x05\x63ount\x18\x02 \x01(\x04\x42\x02\x30\x01\x1a\xc4\x01\n\x16\x45vonodesProposedBlocks\x12\xa9\x01\n\x1e\x65vonodes_proposed_block_counts\x18\x01 \x03(\x0b\x32\x80\x01.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksResponse.GetEvonodesProposedEpochBlocksResponseV0.EvonodeProposedBlocksB\x08\n\x06resultB\t\n\x07version\"\xf2\x02\n,GetEvonodesProposedEpochBlocksByRangeRequest\x12\x84\x01\n\x02v0\x18\x01 \x01(\x0b\x32v.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksByRangeRequest.GetEvonodesProposedEpochBlocksByRangeRequestV0H\x00\x1a\xaf\x01\n.GetEvonodesProposedEpochBlocksByRangeRequestV0\x12\x12\n\x05\x65poch\x18\x01 \x01(\rH\x01\x88\x01\x01\x12\x12\n\x05limit\x18\x02 \x01(\rH\x02\x88\x01\x01\x12\x15\n\x0bstart_after\x18\x03 \x01(\x0cH\x00\x12\x12\n\x08start_at\x18\x04 \x01(\x0cH\x00\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\x07\n\x05startB\x08\n\x06_epochB\x08\n\x06_limitB\t\n\x07version\"\xcd\x01\n\x1cGetIdentitiesBalancesRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetIdentitiesBalancesRequest.GetIdentitiesBalancesRequestV0H\x00\x1a<\n\x1eGetIdentitiesBalancesRequestV0\x12\x0b\n\x03ids\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x9f\x05\n\x1dGetIdentitiesBalancesResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetIdentitiesBalancesResponse.GetIdentitiesBalancesResponseV0H\x00\x1a\x8a\x04\n\x1fGetIdentitiesBalancesResponseV0\x12\x8a\x01\n\x13identities_balances\x18\x01 \x01(\x0b\x32k.org.dash.platform.dapi.v0.GetIdentitiesBalancesResponse.GetIdentitiesBalancesResponseV0.IdentitiesBalancesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aL\n\x0fIdentityBalance\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x18\n\x07\x62\x61lance\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x88\x01\x01\x42\n\n\x08_balance\x1a\x8f\x01\n\x12IdentitiesBalances\x12y\n\x07\x65ntries\x18\x01 \x03(\x0b\x32h.org.dash.platform.dapi.v0.GetIdentitiesBalancesResponse.GetIdentitiesBalancesResponseV0.IdentityBalanceB\x08\n\x06resultB\t\n\x07version\"\xb4\x01\n\x16GetDataContractRequest\x12X\n\x02v0\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetDataContractRequest.GetDataContractRequestV0H\x00\x1a\x35\n\x18GetDataContractRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xb3\x02\n\x17GetDataContractResponse\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetDataContractResponse.GetDataContractResponseV0H\x00\x1a\xb0\x01\n\x19GetDataContractResponseV0\x12\x17\n\rdata_contract\x18\x01 \x01(\x0cH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\x8c\x02\n%GetDataContractsLatestVersionsRequest\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetDataContractsLatestVersionsRequest.GetDataContractsLatestVersionsRequestV0H\x00\x1a`\n\'GetDataContractsLatestVersionsRequestV0\x12\x0b\n\x03ids\x18\x01 \x03(\x0c\x12\x19\n\x11include_contracts\x18\x02 \x01(\x08\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xfa\x05\n&GetDataContractsLatestVersionsResponse\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetDataContractsLatestVersionsResponse.GetDataContractsLatestVersionsResponseV0H\x00\x1a\x84\x01\n\x1e\x44\x61taContractLatestVersionEntry\x12\x12\n\nidentifier\x18\x01 \x01(\x0c\x12\x14\n\x07version\x18\x02 \x01(\rH\x00\x88\x01\x01\x12\x1a\n\rdata_contract\x18\x03 \x01(\x0cH\x01\x88\x01\x01\x42\n\n\x08_versionB\x10\n\x0e_data_contract\x1a\x90\x01\n\x1b\x44\x61taContractsLatestVersions\x12q\n\x07\x65ntries\x18\x01 \x03(\x0b\x32`.org.dash.platform.dapi.v0.GetDataContractsLatestVersionsResponse.DataContractLatestVersionEntry\x1a\xb0\x02\n(GetDataContractsLatestVersionsResponseV0\x12\x87\x01\n\x1e\x64\x61ta_contracts_latest_versions\x18\x01 \x01(\x0b\x32].org.dash.platform.dapi.v0.GetDataContractsLatestVersionsResponse.DataContractsLatestVersionsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"R\n\x1f\x43ontractGroupDocumentTypeMember\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\"G\n\x18\x43ontractGroupTokenMember\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x16\n\x0etoken_position\x18\x02 \x01(\r\"\xd7\x01\n\x1bGetContractGroupInfoRequest\x12\x62\n\x02v0\x18\x01 \x01(\x0b\x32T.org.dash.platform.dapi.v0.GetContractGroupInfoRequest.GetContractGroupInfoRequestV0H\x00\x1aI\n\x1dGetContractGroupInfoRequestV0\x12\x19\n\x11\x63ontract_group_id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x98\x04\n\x1cGetContractGroupInfoResponse\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetContractGroupInfoResponse.GetContractGroupInfoResponseV0H\x00\x1a~\n\x11\x43ontractGroupInfo\x12\x10\n\x08owner_id\x18\x01 \x01(\x0c\x12\x11\n\tadmin_ids\x18\x02 \x03(\x0c\x12\x11\n\x04name\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x18\n\x0b\x64\x65scription\x18\x04 \x01(\tH\x01\x88\x01\x01\x42\x07\n\x05_nameB\x0e\n\x0c_description\x1a\x86\x02\n\x1eGetContractGroupInfoResponseV0\x12h\n\x13\x63ontract_group_info\x18\x01 \x01(\x0b\x32I.org.dash.platform.dapi.v0.GetContractGroupInfoResponse.ContractGroupInfoH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xf8\x06\n\x1eGetContractGroupMembersRequest\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetContractGroupMembersRequest.GetContractGroupMembersRequestV0H\x00\x1a@\n\x14\x43ontractMembersQuery\x12\x18\n\x0bstart_after\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x42\x0e\n\x0c_start_after\x1a\x80\x01\n\x18\x44ocumentTypeMembersQuery\x12T\n\x0bstart_after\x18\x01 \x01(\x0b\x32:.org.dash.platform.dapi.v0.ContractGroupDocumentTypeMemberH\x00\x88\x01\x01\x42\x0e\n\x0c_start_after\x1ar\n\x11TokenMembersQuery\x12M\n\x0bstart_after\x18\x01 \x01(\x0b\x32\x33.org.dash.platform.dapi.v0.ContractGroupTokenMemberH\x00\x88\x01\x01\x42\x0e\n\x0c_start_after\x1a\xa7\x03\n GetContractGroupMembersRequestV0\x12\x19\n\x11\x63ontract_group_id\x18\x01 \x01(\x0c\x12\x63\n\tcontracts\x18\x02 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetContractGroupMembersRequest.ContractMembersQueryH\x00\x12l\n\x0e\x64ocument_types\x18\x03 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetContractGroupMembersRequest.DocumentTypeMembersQueryH\x00\x12]\n\x06tokens\x18\x04 \x01(\x0b\x32K.org.dash.platform.dapi.v0.GetContractGroupMembersRequest.TokenMembersQueryH\x00\x12\x12\n\x05limit\x18\x05 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\x06 \x01(\x08\x42\t\n\x07membersB\x08\n\x06_limitB\t\n\x07version\"\xc9\x06\n\x1fGetContractGroupMembersResponse\x12j\n\x02v0\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetContractGroupMembersResponse.GetContractGroupMembersResponseV0H\x00\x1a\'\n\x0f\x43ontractMembers\x12\x14\n\x0c\x63ontract_ids\x18\x01 \x03(\x0c\x1ai\n\x13\x44ocumentTypeMembers\x12R\n\x0e\x64ocument_types\x18\x01 \x03(\x0b\x32:.org.dash.platform.dapi.v0.ContractGroupDocumentTypeMember\x1aS\n\x0cTokenMembers\x12\x43\n\x06tokens\x18\x01 \x03(\x0b\x32\x33.org.dash.platform.dapi.v0.ContractGroupTokenMember\x1a\xc5\x03\n!GetContractGroupMembersResponseV0\x12_\n\tcontracts\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetContractGroupMembersResponse.ContractMembersH\x00\x12h\n\x0e\x64ocument_types\x18\x02 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetContractGroupMembersResponse.DocumentTypeMembersH\x00\x12Y\n\x06tokens\x18\x03 \x01(\x0b\x32G.org.dash.platform.dapi.v0.GetContractGroupMembersResponse.TokenMembersH\x00\x12\x31\n\x05proof\x18\x04 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x05 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"M\n\x1a\x43ontractModerationDocument\x12\x1a\n\x12\x64ocument_type_name\x18\x01 \x01(\t\x12\x13\n\x0b\x64ocument_id\x18\x02 \x01(\x0c\"\x8e\x01\n\x18\x43ontractModerationReason\x12\x11\n\x04\x63ode\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x0c\n\x04text\x18\x02 \x01(\t\x12H\n\tdocuments\x18\x03 \x03(\x0b\x32\x35.org.dash.platform.dapi.v0.ContractModerationDocumentB\x07\n\x05_code\"i\n\x0f\x43ontractWarning\x12\x11\n\twarned_at\x18\x01 \x01(\x04\x12\x43\n\x06reason\x18\x02 \x01(\x0b\x32\x33.org.dash.platform.dapi.v0.ContractModerationReason\"\xc5\x02\n\"GetContractModerationStatusRequest\x12p\n\x02v0\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetContractModerationStatusRequest.GetContractModerationStatusRequestV0H\x00\x1a\xa1\x01\n$GetContractModerationStatusRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x13\n\x0bidentity_id\x18\x02 \x01(\x0c\x12@\n\x05lists\x18\x03 \x03(\x0e\x32\x31.org.dash.platform.dapi.v0.ContractModerationList\x12\r\n\x05prove\x18\x04 \x01(\x08\x42\t\n\x07version\"\xec\x06\n#GetContractModerationStatusResponse\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.GetContractModerationStatusResponseV0H\x00\x1a\xb4\x03\n\x18\x43ontractModerationStatus\x12\x13\n\x06\x62\x61nned\x18\x01 \x01(\x08H\x00\x88\x01\x01\x12\x1c\n\x0fsuspended_until\x18\x02 \x01(\x04H\x01\x88\x01\x01\x12@\n\x05lists\x18\x03 \x03(\x0e\x32\x31.org.dash.platform.dapi.v0.ContractModerationList\x12L\n\nban_reason\x18\x04 \x01(\x0b\x32\x33.org.dash.platform.dapi.v0.ContractModerationReasonH\x02\x88\x01\x01\x12S\n\x11suspension_reason\x18\x05 \x01(\x0b\x32\x33.org.dash.platform.dapi.v0.ContractModerationReasonH\x03\x88\x01\x01\x12<\n\x08warnings\x18\x06 \x03(\x0b\x32*.org.dash.platform.dapi.v0.ContractWarningB\t\n\x07_bannedB\x12\n\x10_suspended_untilB\r\n\x0b_ban_reasonB\x14\n\x12_suspension_reason\x1a\x8e\x02\n%GetContractModerationStatusResponseV0\x12i\n\x06status\x18\x01 \x01(\x0b\x32W.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractModerationStatusH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xfb\x02\n#GetContractModerationEntriesRequest\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetContractModerationEntriesRequest.GetContractModerationEntriesRequestV0H\x00\x1a\xd4\x01\n%GetContractModerationEntriesRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12?\n\x04list\x18\x02 \x01(\x0e\x32\x31.org.dash.platform.dapi.v0.ContractModerationList\x12\x18\n\x0bstart_after\x18\x03 \x01(\x0cH\x00\x88\x01\x01\x12\x12\n\x05limit\x18\x04 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\x0e\n\x0c_start_afterB\x08\n\x06_limitB\t\n\x07version\"\x96\x06\n$GetContractModerationEntriesResponse\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.GetContractModerationEntriesResponseV0H\x00\x1a\xcf\x01\n\x17\x43ontractModerationEntry\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x12\n\x05until\x18\x02 \x01(\x04H\x00\x88\x01\x01\x12\x43\n\x06reason\x18\x03 \x01(\x0b\x32\x33.org.dash.platform.dapi.v0.ContractModerationReason\x12<\n\x08warnings\x18\x04 \x03(\x0b\x32*.org.dash.platform.dapi.v0.ContractWarningB\x08\n\x06_until\x1a\x85\x01\n\x19\x43ontractModerationEntries\x12h\n\x07\x65ntries\x18\x01 \x03(\x0b\x32W.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry\x1a\x92\x02\n&GetContractModerationEntriesResponseV0\x12l\n\x07\x65ntries\x18\x01 \x01(\x0b\x32Y.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntriesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc3\x04\n\"GetContractDocumentRemovalsRequest\x12p\n\x02v0\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetContractDocumentRemovalsRequest.GetContractDocumentRemovalsRequestV0H\x00\x1a#\n\x0b\x44ocumentIds\x12\x14\n\x0c\x64ocument_ids\x18\x01 \x03(\x0c\x1aN\n\x04Page\x12\x18\n\x0bstart_after\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x12\x12\n\x05limit\x18\x02 \x01(\rH\x01\x88\x01\x01\x42\x0e\n\x0c_start_afterB\x08\n\x06_limit\x1a\xaa\x02\n$GetContractDocumentRemovalsRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\x61\n\x0c\x64ocument_ids\x18\x03 \x01(\x0b\x32I.org.dash.platform.dapi.v0.GetContractDocumentRemovalsRequest.DocumentIdsH\x00\x12R\n\x04page\x18\x04 \x01(\x0b\x32\x42.org.dash.platform.dapi.v0.GetContractDocumentRemovalsRequest.PageH\x00\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\x0b\n\tselectionB\t\n\x07version\"\xcb\x07\n#GetContractDocumentRemovalsResponse\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.GetContractDocumentRemovalsResponseV0H\x00\x1aH\n\x1b\x43ontractDocumentRestoration\x12\x14\n\x0cmoderator_id\x18\x01 \x01(\x0c\x12\x13\n\x0brestored_at\x18\x02 \x01(\x04\x1a\xc0\x02\n\x17\x43ontractDocumentRemoval\x12\x13\n\x0b\x64ocument_id\x18\x01 \x01(\x0c\x12\x19\n\x11\x64ocument_owner_id\x18\x02 \x01(\x0c\x12\x14\n\x0cmoderator_id\x18\x03 \x01(\x0c\x12\x12\n\nremoved_at\x18\x04 \x01(\x04\x12\x43\n\x06reason\x18\x05 \x01(\x0b\x32\x33.org.dash.platform.dapi.v0.ContractModerationReason\x12\x15\n\rdocument_hash\x18\x06 \x01(\x0c\x12o\n\x0brestoration\x18\x07 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration\x1a\x84\x01\n\x18\x43ontractDocumentRemovals\x12h\n\x08removals\x18\x01 \x03(\x0b\x32V.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval\x1a\x90\x02\n%GetContractDocumentRemovalsResponseV0\x12k\n\x08removals\x18\x01 \x01(\x0b\x32W.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemovalsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc9\x01\n\x19GetContractFeePotsRequest\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetContractFeePotsRequest.GetContractFeePotsRequestV0H\x00\x1a\x41\n\x1bGetContractFeePotsRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x9f\x06\n\x1aGetContractFeePotsResponse\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetContractFeePotsResponse.GetContractFeePotsResponseV0H\x00\x1aR\n\x17\x43ontractFeePotLastClaim\x12\r\n\x05\x65poch\x18\x01 \x01(\r\x12\x13\n\x07time_ms\x18\x02 \x01(\x04\x42\x02\x30\x01\x12\x13\n\x0b\x63laimant_id\x18\x03 \x01(\x0c\x1a\x88\x01\n\x0e\x43ontractFeePot\x12\x13\n\x07\x63redits\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x61\n\nlast_claim\x18\x02 \x01(\x0b\x32M.org.dash.platform.dapi.v0.GetContractFeePotsResponse.ContractFeePotLastClaim\x1a\xc0\x01\n\x0f\x43ontractFeePots\x12S\n\x05owner\x18\x01 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetContractFeePotsResponse.ContractFeePot\x12X\n\nmoderators\x18\x02 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetContractFeePotsResponse.ContractFeePot\x1a\xf1\x01\n\x1cGetContractFeePotsResponseV0\x12U\n\x04pots\x18\x01 \x01(\x0b\x32\x45.org.dash.platform.dapi.v0.GetContractFeePotsResponse.ContractFeePotsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xf1\x01\n#GetContractGroupsForContractRequest\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetContractGroupsForContractRequest.GetContractGroupsForContractRequestV0H\x00\x1aK\n%GetContractGroupsForContractRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xf5\x06\n$GetContractGroupsForContractResponse\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetContractGroupsForContractResponse.GetContractGroupsForContractResponseV0H\x00\x1aQ\n\x17\x44ocumentTypeMemberships\x12\x1a\n\x12\x64ocument_type_name\x18\x01 \x01(\t\x12\x1a\n\x12\x63ontract_group_ids\x18\x02 \x03(\x0c\x1a\x46\n\x10TokenMemberships\x12\x16\n\x0etoken_position\x18\x01 \x01(\r\x12\x1a\n\x12\x63ontract_group_ids\x18\x02 \x03(\x0c\x1a\x89\x02\n\x18\x43ontractGroupMemberships\x12\x1a\n\x12\x63ontract_group_ids\x18\x01 \x03(\x0c\x12o\n\x0e\x64ocument_types\x18\x02 \x03(\x0b\x32W.org.dash.platform.dapi.v0.GetContractGroupsForContractResponse.DocumentTypeMemberships\x12`\n\x06tokens\x18\x03 \x03(\x0b\x32P.org.dash.platform.dapi.v0.GetContractGroupsForContractResponse.TokenMemberships\x1a\xa4\x02\n&GetContractGroupsForContractResponseV0\x12~\n\x1a\x63ontract_group_memberships\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetContractGroupsForContractResponse.ContractGroupMembershipsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xb9\x01\n\x17GetDataContractsRequest\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetDataContractsRequest.GetDataContractsRequestV0H\x00\x1a\x37\n\x19GetDataContractsRequestV0\x12\x0b\n\x03ids\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xad\x02\n\x1eGetDataContractsByRangeRequest\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetDataContractsByRangeRequest.GetDataContractsByRangeRequestV0H\x00\x1a\x95\x01\n GetDataContractsByRangeRequestV0\x12\x12\n\x05limit\x18\x01 \x01(\rH\x01\x88\x01\x01\x12\x15\n\x0bstart_after\x18\x02 \x01(\x0cH\x00\x12\x12\n\x08start_at\x18\x03 \x01(\x0cH\x00\x12\x10\n\x08ids_only\x18\x04 \x01(\x08\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\x07\n\x05startB\x08\n\x06_limitB\t\n\x07version\"\xcf\x04\n\x18GetDataContractsResponse\x12\\\n\x02v0\x18\x01 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetDataContractsResponse.GetDataContractsResponseV0H\x00\x1a[\n\x11\x44\x61taContractEntry\x12\x12\n\nidentifier\x18\x01 \x01(\x0c\x12\x32\n\rdata_contract\x18\x02 \x01(\x0b\x32\x1b.google.protobuf.BytesValue\x1au\n\rDataContracts\x12\x64\n\x15\x64\x61ta_contract_entries\x18\x01 \x03(\x0b\x32\x45.org.dash.platform.dapi.v0.GetDataContractsResponse.DataContractEntry\x1a\xf5\x01\n\x1aGetDataContractsResponseV0\x12[\n\x0e\x64\x61ta_contracts\x18\x01 \x01(\x0b\x32\x41.org.dash.platform.dapi.v0.GetDataContractsResponse.DataContractsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc5\x02\n\x1dGetDataContractHistoryRequest\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetDataContractHistoryRequest.GetDataContractHistoryRequestV0H\x00\x1a\xb0\x01\n\x1fGetDataContractHistoryRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12+\n\x05limit\x18\x02 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12,\n\x06offset\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\x17\n\x0bstart_at_ms\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\t\n\x07version\"\xb2\x05\n\x1eGetDataContractHistoryResponse\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetDataContractHistoryResponse.GetDataContractHistoryResponseV0H\x00\x1a\x9a\x04\n GetDataContractHistoryResponseV0\x12\x8f\x01\n\x15\x64\x61ta_contract_history\x18\x01 \x01(\x0b\x32n.org.dash.platform.dapi.v0.GetDataContractHistoryResponse.GetDataContractHistoryResponseV0.DataContractHistoryH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a;\n\x18\x44\x61taContractHistoryEntry\x12\x10\n\x04\x64\x61te\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05value\x18\x02 \x01(\x0c\x1a\xaa\x01\n\x13\x44\x61taContractHistory\x12\x92\x01\n\x15\x64\x61ta_contract_entries\x18\x01 \x03(\x0b\x32s.org.dash.platform.dapi.v0.GetDataContractHistoryResponse.GetDataContractHistoryResponseV0.DataContractHistoryEntryB\x08\n\x06resultB\t\n\x07version\"\xe0\x1f\n\x13GetDocumentsRequest\x12R\n\x02v0\x18\x01 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV0H\x00\x12R\n\x02v1\x18\x02 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1H\x00\x1a\xfe\x02\n\x12\x44ocumentFieldValue\x12\x14\n\nbool_value\x18\x01 \x01(\x08H\x00\x12\x19\n\x0bint64_value\x18\x02 \x01(\x12\x42\x02\x30\x01H\x00\x12\x1a\n\x0cuint64_value\x18\x03 \x01(\x04\x42\x02\x30\x01H\x00\x12\x16\n\x0c\x64ouble_value\x18\x04 \x01(\x01H\x00\x12\x0e\n\x04text\x18\x05 \x01(\tH\x00\x12\x15\n\x0b\x62ytes_value\x18\x06 \x01(\x0cH\x00\x12[\n\x04list\x18\x07 \x01(\x0b\x32K.org.dash.platform.dapi.v0.GetDocumentsRequest.DocumentFieldValue.ValueListH\x00\x12\x14\n\nnull_value\x18\x08 \x01(\x08H\x00\x1a^\n\tValueList\x12Q\n\x06values\x18\x01 \x03(\x0b\x32\x41.org.dash.platform.dapi.v0.GetDocumentsRequest.DocumentFieldValueB\t\n\x07variant\x1a\xe2\x02\n\x12TimeRangeSelection\x12\\\n\x08selector\x18\x01 \x01(\x0e\x32J.org.dash.platform.dapi.v0.GetDocumentsRequest.TimeRangeSelection.Selector\x12\x19\n\x08start_ms\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x88\x01\x01\x12T\n\x04grid\x18\x03 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetDocumentsRequest.TimeRangeSelection.Grid\x1a>\n\x04Grid\x12\x11\n\x05range\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x10\n\x04step\x18\x02 \x01(\x04\x42\x02\x30\x01\x12\x11\n\x05phase\x18\x03 \x01(\x04\x42\x02\x30\x01\"0\n\x08Selector\x12\n\n\x06NEWEST\x10\x00\x12\n\n\x06OLDEST\x10\x01\x12\x0c\n\x08\x42Y_START\x10\x02\x42\x0b\n\t_start_ms\x1a\x95\x02\n\x0bWhereClause\x12\r\n\x05\x66ield\x18\x01 \x01(\t\x12N\n\x08operator\x18\x02 \x01(\x0e\x32<.org.dash.platform.dapi.v0.GetDocumentsRequest.WhereOperator\x12P\n\x05value\x18\x03 \x01(\x0b\x32\x41.org.dash.platform.dapi.v0.GetDocumentsRequest.DocumentFieldValue\x12U\n\ntime_range\x18\x04 \x01(\x0b\x32\x41.org.dash.platform.dapi.v0.GetDocumentsRequest.TimeRangeSelection\x1a\xa4\x01\n\x0fHavingAggregate\x12Y\n\x08\x66unction\x18\x01 \x01(\x0e\x32G.org.dash.platform.dapi.v0.GetDocumentsRequest.HavingAggregate.Function\x12\r\n\x05\x66ield\x18\x02 \x01(\t\"\'\n\x08\x46unction\x12\t\n\x05\x43OUNT\x10\x00\x12\x07\n\x03SUM\x10\x01\x12\x07\n\x03\x41VG\x10\x02\x1a\xf9\x03\n\x0cHavingClause\x12Q\n\taggregate\x18\x01 \x01(\x0b\x32>.org.dash.platform.dapi.v0.GetDocumentsRequest.HavingAggregate\x12V\n\x08operator\x18\x02 \x01(\x0e\x32\x44.org.dash.platform.dapi.v0.GetDocumentsRequest.HavingClause.Operator\x12R\n\x05value\x18\x03 \x01(\x0b\x32\x41.org.dash.platform.dapi.v0.GetDocumentsRequest.DocumentFieldValueH\x00\"\xe0\x01\n\x08Operator\x12\t\n\x05\x45QUAL\x10\x00\x12\r\n\tNOT_EQUAL\x10\x01\x12\x10\n\x0cGREATER_THAN\x10\x02\x12\x1a\n\x16GREATER_THAN_OR_EQUALS\x10\x03\x12\r\n\tLESS_THAN\x10\x04\x12\x17\n\x13LESS_THAN_OR_EQUALS\x10\x05\x12\x0b\n\x07\x42\x45TWEEN\x10\x06\x12\x1a\n\x16\x42\x45TWEEN_EXCLUDE_BOUNDS\x10\x07\x12\x18\n\x14\x42\x45TWEEN_EXCLUDE_LEFT\x10\x08\x12\x19\n\x15\x42\x45TWEEN_EXCLUDE_RIGHT\x10\t\x12\x06\n\x02IN\x10\nB\x07\n\x05right\x1a\x90\x01\n\x0bOrderClause\x12\x0f\n\x05\x66ield\x18\x01 \x01(\tH\x00\x12S\n\taggregate\x18\x03 \x01(\x0b\x32>.org.dash.platform.dapi.v0.GetDocumentsRequest.HavingAggregateH\x00\x12\x11\n\tascending\x18\x02 \x01(\x08\x42\x08\n\x06target\x1a\xbb\x01\n\x15GetDocumentsRequestV0\x12\x18\n\x10\x64\x61ta_contract_id\x18\x01 \x01(\x0c\x12\x15\n\rdocument_type\x18\x02 \x01(\t\x12\r\n\x05where\x18\x03 \x01(\x0c\x12\x10\n\x08order_by\x18\x04 \x01(\x0c\x12\r\n\x05limit\x18\x05 \x01(\r\x12\x15\n\x0bstart_after\x18\x06 \x01(\x0cH\x00\x12\x12\n\x08start_at\x18\x07 \x01(\x0cH\x00\x12\r\n\x05prove\x18\x08 \x01(\x08\x42\x07\n\x05start\x1a\xa6\x0c\n\x15GetDocumentsRequestV1\x12\x18\n\x10\x64\x61ta_contract_id\x18\x01 \x01(\x0c\x12\x15\n\rdocument_type\x18\x02 \x01(\t\x12Q\n\rwhere_clauses\x18\x03 \x03(\x0b\x32:.org.dash.platform.dapi.v0.GetDocumentsRequest.WhereClause\x12L\n\x08order_by\x18\x04 \x03(\x0b\x32:.org.dash.platform.dapi.v0.GetDocumentsRequest.OrderClause\x12\x12\n\x05limit\x18\x05 \x01(\rH\x01\x88\x01\x01\x12\x15\n\x0bstart_after\x18\x06 \x01(\x0cH\x00\x12\x12\n\x08start_at\x18\x07 \x01(\x0cH\x00\x12\r\n\x05prove\x18\x08 \x01(\x08\x12\\\n\x07selects\x18\t \x03(\x0b\x32K.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1.Select\x12\x10\n\x08group_by\x18\n \x03(\t\x12K\n\x06having\x18\x0b \x03(\x0b\x32;.org.dash.platform.dapi.v0.GetDocumentsRequest.HavingClause\x12\x13\n\x06offset\x18\x0c \x01(\rH\x02\x88\x01\x01\x12\x61\n\x07\x63hained\x18\r \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1.ChainedJoin\x12\x62\n\x0bsub_queries\x18\x0e \x03(\x0b\x32M.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1.SubQuery\x1a\xc9\x01\n\x06Select\x12\x66\n\x08\x66unction\x18\x01 \x01(\x0e\x32T.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1.Select.Function\x12\r\n\x05\x66ield\x18\x02 \x01(\t\"H\n\x08\x46unction\x12\r\n\tDOCUMENTS\x10\x00\x12\t\n\x05\x43OUNT\x10\x01\x12\x07\n\x03SUM\x10\x02\x12\x07\n\x03\x41VG\x10\x03\x12\x07\n\x03MIN\x10\x04\x12\x07\n\x03MAX\x10\x05\x1a\x41\n\x0b\x43hainedJoin\x12\x15\n\rjoin_property\x18\x01 \x01(\t\x12\x1b\n\x13outer_document_type\x18\x02 \x01(\t\x1a\xa6\x04\n\x08SubQuery\x12\x18\n\x10\x64\x61ta_contract_id\x18\x01 \x01(\x0c\x12\x15\n\rdocument_type\x18\x02 \x01(\t\x12Q\n\rwhere_clauses\x18\x03 \x03(\x0b\x32:.org.dash.platform.dapi.v0.GetDocumentsRequest.WhereClause\x12L\n\x08order_by\x18\x04 \x03(\x0b\x32:.org.dash.platform.dapi.v0.GetDocumentsRequest.OrderClause\x12\x12\n\x05limit\x18\x05 \x01(\rH\x00\x88\x01\x01\x12`\n\x04kind\x18\x06 \x01(\x0e\x32R.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1.SubQuery.Kind\x12\x63\n\x04\x62ind\x18\x07 \x01(\x0b\x32U.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1.SubQuery.Binding\x1a\x41\n\x07\x42inding\x12\x0e\n\x06source\x18\x01 \x01(\r\x12\x17\n\x0fsource_property\x18\x02 \x01(\t\x12\r\n\x05\x66ield\x18\x03 \x01(\t\" \n\x04Kind\x12\r\n\tDOCUMENTS\x10\x00\x12\t\n\x05\x43OUNT\x10\x01\x42\x08\n\x06_limitB\x07\n\x05startB\x08\n\x06_limitB\t\n\x07_offset\"\xfa\x01\n\rWhereOperator\x12\t\n\x05\x45QUAL\x10\x00\x12\x10\n\x0cGREATER_THAN\x10\x01\x12\x1a\n\x16GREATER_THAN_OR_EQUALS\x10\x02\x12\r\n\tLESS_THAN\x10\x03\x12\x17\n\x13LESS_THAN_OR_EQUALS\x10\x04\x12\x0b\n\x07\x42\x45TWEEN\x10\x05\x12\x1a\n\x16\x42\x45TWEEN_EXCLUDE_BOUNDS\x10\x06\x12\x18\n\x14\x42\x45TWEEN_EXCLUDE_LEFT\x10\x07\x12\x19\n\x15\x42\x45TWEEN_EXCLUDE_RIGHT\x10\x08\x12\x06\n\x02IN\x10\t\x12\x0f\n\x0bSTARTS_WITH\x10\n\x12\x11\n\rIN_TIME_RANGE\x10\x0b\x42\t\n\x07version\"\xf2\x1b\n\x14GetDocumentsResponse\x12T\n\x02v0\x18\x01 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV0H\x00\x12T\n\x02v1\x18\x02 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1H\x00\x1a\x9b\x02\n\x16GetDocumentsResponseV0\x12\x65\n\tdocuments\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV0.DocumentsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x1e\n\tDocuments\x12\x11\n\tdocuments\x18\x01 \x03(\x0c\x42\x08\n\x06result\x1a\x84\x18\n\x16GetDocumentsResponseV1\x12\x61\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.ResultDataH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x1e\n\tDocuments\x12\x11\n\tdocuments\x18\x01 \x03(\x0c\x1aL\n\nCountEntry\x12\x13\n\x06in_key\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x12\x0b\n\x03key\x18\x02 \x01(\x0c\x12\x11\n\x05\x63ount\x18\x03 \x01(\x04\x42\x02\x30\x01\x42\t\n\x07_in_key\x1ar\n\x0c\x43ountEntries\x12\x62\n\x07\x65ntries\x18\x01 \x03(\x0b\x32Q.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.CountEntry\x1a\xa0\x01\n\x0c\x43ountResults\x12\x1d\n\x0f\x61ggregate_count\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x66\n\x07\x65ntries\x18\x02 \x01(\x0b\x32S.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.CountEntriesH\x00\x42\t\n\x07variant\x1aH\n\x08SumEntry\x12\x13\n\x06in_key\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x12\x0b\n\x03key\x18\x02 \x01(\x0c\x12\x0f\n\x03sum\x18\x03 \x01(\x12\x42\x02\x30\x01\x42\t\n\x07_in_key\x1an\n\nSumEntries\x12`\n\x07\x65ntries\x18\x01 \x03(\x0b\x32O.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.SumEntry\x1a\x9a\x01\n\nSumResults\x12\x1b\n\raggregate_sum\x18\x01 \x01(\x12\x42\x02\x30\x01H\x00\x12\x64\n\x07\x65ntries\x18\x02 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.SumEntriesH\x00\x42\t\n\x07variant\x1a_\n\x0c\x41verageEntry\x12\x13\n\x06in_key\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x12\x0b\n\x03key\x18\x02 \x01(\x0c\x12\x11\n\x05\x63ount\x18\x03 \x01(\x04\x42\x02\x30\x01\x12\x0f\n\x03sum\x18\x04 \x01(\x12\x42\x02\x30\x01\x42\t\n\x07_in_key\x1av\n\x0e\x41verageEntries\x12\x64\n\x07\x65ntries\x18\x01 \x03(\x0b\x32S.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.AverageEntry\x1a\x36\n\x10\x41verageAggregate\x12\x11\n\x05\x63ount\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x0f\n\x03sum\x18\x02 \x01(\x12\x42\x02\x30\x01\x1a\xfb\x01\n\x0e\x41verageResults\x12t\n\x11\x61ggregate_average\x18\x01 \x01(\x0b\x32W.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.AverageAggregateH\x00\x12h\n\x07\x65ntries\x18\x02 \x01(\x0b\x32U.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.AverageEntriesH\x00\x42\t\n\x07variant\x1az\n\x0bRankedEntry\x12\x0b\n\x03key\x18\x01 \x01(\x0c\x12\x13\n\x05\x63ount\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x12\x11\n\x03sum\x18\x03 \x01(\x12\x42\x02\x30\x01H\x00\x12\r\n\x03\x61vg\x18\x04 \x01(\x01H\x00\x12\x13\n\x06in_key\x18\x05 \x01(\x0cH\x01\x88\x01\x01\x42\x07\n\x05valueB\t\n\x07_in_key\x1a\x9a\x01\n\rRankedEntries\x12\x63\n\x07\x65ntries\x18\x01 \x03(\x0b\x32R.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.RankedEntry\x12\x18\n\x07skipped\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x88\x01\x01\x42\n\n\x08_skipped\x1a\xf7\x05\n\nResultData\x12\x65\n\tdocuments\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.DocumentsH\x00\x12\x65\n\x06\x63ounts\x18\x02 \x01(\x0b\x32S.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.CountResultsH\x00\x12\x61\n\x04sums\x18\x03 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.SumResultsH\x00\x12i\n\x08\x61verages\x18\x04 \x01(\x0b\x32U.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.AverageResultsH\x00\x12\x66\n\x06ranked\x18\x05 \x01(\x0b\x32T.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.RankedEntriesH\x00\x12j\n\x07\x63hained\x18\x06 \x01(\x0b\x32W.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.ChainedDocumentsH\x00\x12n\n\tcomposite\x18\x07 \x01(\x0b\x32Y.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.CompositeDocumentsH\x00\x42\t\n\x07variant\x1a_\n\x10\x43hainedDocuments\x12\x17\n\x0finner_documents\x18\x01 \x03(\x0c\x12\x17\n\x0fouter_documents\x18\x02 \x03(\x0c\x12\x19\n\x11missing_outer_ids\x18\x03 \x03(\x0c\x1a\xab\x03\n\x12\x43ompositeDocuments\x12\x16\n\x0epage_documents\x18\x01 \x03(\x0c\x12}\n\x0bsub_results\x18\x02 \x03(\x0b\x32h.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.CompositeDocuments.SubQueryResult\x1a\xfd\x01\n\x0eSubQueryResult\x12\x65\n\tdocuments\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.DocumentsH\x00\x12\x65\n\x06\x63ounts\x18\x02 \x01(\x0b\x32S.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.CountEntriesH\x00\x12\x13\n\x0bmissing_ids\x18\x03 \x03(\x0c\x42\x08\n\x06resultB\x08\n\x06resultB\t\n\x07version\"\xf4\x02\n\x19GetDocumentHistoryRequest\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetDocumentHistoryRequest.GetDocumentHistoryRequestV0H\x00\x1a\xeb\x01\n\x1bGetDocumentHistoryRequestV0\x12\x18\n\x10\x64\x61ta_contract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\x13\n\x0b\x64ocument_id\x18\x03 \x01(\x0c\x12+\n\x05limit\x18\x04 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12,\n\x06offset\x18\x05 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\x17\n\x0bstart_at_ms\x18\x06 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05prove\x18\x07 \x01(\x08\x42\t\n\x07version\"\xf7\x04\n\x1aGetDocumentHistoryResponse\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetDocumentHistoryResponse.GetDocumentHistoryResponseV0H\x00\x1a\xeb\x03\n\x1cGetDocumentHistoryResponseV0\x12~\n\x10\x64ocument_history\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetDocumentHistoryResponse.GetDocumentHistoryResponseV0.DocumentHistoryH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x37\n\x14\x44ocumentHistoryEntry\x12\x10\n\x04\x64\x61te\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05value\x18\x02 \x01(\x0c\x1a\x95\x01\n\x0f\x44ocumentHistory\x12\x81\x01\n\x10\x64ocument_entries\x18\x01 \x03(\x0b\x32g.org.dash.platform.dapi.v0.GetDocumentHistoryResponse.GetDocumentHistoryResponseV0.DocumentHistoryEntryB\x08\n\x06resultB\t\n\x07version\"\xed\x01\n!GetIdentityByPublicKeyHashRequest\x12n\n\x02v0\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetIdentityByPublicKeyHashRequest.GetIdentityByPublicKeyHashRequestV0H\x00\x1aM\n#GetIdentityByPublicKeyHashRequestV0\x12\x17\n\x0fpublic_key_hash\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xda\x02\n\"GetIdentityByPublicKeyHashResponse\x12p\n\x02v0\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetIdentityByPublicKeyHashResponse.GetIdentityByPublicKeyHashResponseV0H\x00\x1a\xb6\x01\n$GetIdentityByPublicKeyHashResponseV0\x12\x12\n\x08identity\x18\x01 \x01(\x0cH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xbd\x02\n*GetIdentityByNonUniquePublicKeyHashRequest\x12\x80\x01\n\x02v0\x18\x01 \x01(\x0b\x32r.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashRequest.GetIdentityByNonUniquePublicKeyHashRequestV0H\x00\x1a\x80\x01\n,GetIdentityByNonUniquePublicKeyHashRequestV0\x12\x17\n\x0fpublic_key_hash\x18\x01 \x01(\x0c\x12\x18\n\x0bstart_after\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\x0e\n\x0c_start_afterB\t\n\x07version\"\xd6\x06\n+GetIdentityByNonUniquePublicKeyHashResponse\x12\x82\x01\n\x02v0\x18\x01 \x01(\x0b\x32t.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashResponse.GetIdentityByNonUniquePublicKeyHashResponseV0H\x00\x1a\x96\x05\n-GetIdentityByNonUniquePublicKeyHashResponseV0\x12\x9a\x01\n\x08identity\x18\x01 \x01(\x0b\x32\x85\x01.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashResponse.GetIdentityByNonUniquePublicKeyHashResponseV0.IdentityResponseH\x00\x12\x9d\x01\n\x05proof\x18\x02 \x01(\x0b\x32\x8b\x01.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashResponse.GetIdentityByNonUniquePublicKeyHashResponseV0.IdentityProvedResponseH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x36\n\x10IdentityResponse\x12\x15\n\x08identity\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x42\x0b\n\t_identity\x1a\xa6\x01\n\x16IdentityProvedResponse\x12P\n&grovedb_identity_public_key_hash_proof\x18\x01 \x01(\x0b\x32 .org.dash.platform.dapi.v0.Proof\x12!\n\x14identity_proof_bytes\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x42\x17\n\x15_identity_proof_bytesB\x08\n\x06resultB\t\n\x07version\"\xfb\x01\n#WaitForStateTransitionResultRequest\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.WaitForStateTransitionResultRequestV0H\x00\x1aU\n%WaitForStateTransitionResultRequestV0\x12\x1d\n\x15state_transition_hash\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x99\x03\n$WaitForStateTransitionResultResponse\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0H\x00\x1a\xef\x01\n&WaitForStateTransitionResultResponseV0\x12I\n\x05\x65rror\x18\x01 \x01(\x0b\x32\x38.org.dash.platform.dapi.v0.StateTransitionBroadcastErrorH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc4\x01\n\x19GetConsensusParamsRequest\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetConsensusParamsRequest.GetConsensusParamsRequestV0H\x00\x1a<\n\x1bGetConsensusParamsRequestV0\x12\x0e\n\x06height\x18\x01 \x01(\x05\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x9c\x04\n\x1aGetConsensusParamsResponse\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetConsensusParamsResponse.GetConsensusParamsResponseV0H\x00\x1aP\n\x14\x43onsensusParamsBlock\x12\x11\n\tmax_bytes\x18\x01 \x01(\t\x12\x0f\n\x07max_gas\x18\x02 \x01(\t\x12\x14\n\x0ctime_iota_ms\x18\x03 \x01(\t\x1a\x62\n\x17\x43onsensusParamsEvidence\x12\x1a\n\x12max_age_num_blocks\x18\x01 \x01(\t\x12\x18\n\x10max_age_duration\x18\x02 \x01(\t\x12\x11\n\tmax_bytes\x18\x03 \x01(\t\x1a\xda\x01\n\x1cGetConsensusParamsResponseV0\x12Y\n\x05\x62lock\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetConsensusParamsResponse.ConsensusParamsBlock\x12_\n\x08\x65vidence\x18\x02 \x01(\x0b\x32M.org.dash.platform.dapi.v0.GetConsensusParamsResponse.ConsensusParamsEvidenceB\t\n\x07version\"\xe4\x01\n%GetProtocolVersionUpgradeStateRequest\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateRequest.GetProtocolVersionUpgradeStateRequestV0H\x00\x1a\x38\n\'GetProtocolVersionUpgradeStateRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xb5\x05\n&GetProtocolVersionUpgradeStateResponse\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateResponse.GetProtocolVersionUpgradeStateResponseV0H\x00\x1a\x85\x04\n(GetProtocolVersionUpgradeStateResponseV0\x12\x87\x01\n\x08versions\x18\x01 \x01(\x0b\x32s.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateResponse.GetProtocolVersionUpgradeStateResponseV0.VersionsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x96\x01\n\x08Versions\x12\x89\x01\n\x08versions\x18\x01 \x03(\x0b\x32w.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateResponse.GetProtocolVersionUpgradeStateResponseV0.VersionEntry\x1a:\n\x0cVersionEntry\x12\x16\n\x0eversion_number\x18\x01 \x01(\r\x12\x12\n\nvote_count\x18\x02 \x01(\rB\x08\n\x06resultB\t\n\x07version\"\xa3\x02\n*GetProtocolVersionUpgradeVoteStatusRequest\x12\x80\x01\n\x02v0\x18\x01 \x01(\x0b\x32r.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusRequest.GetProtocolVersionUpgradeVoteStatusRequestV0H\x00\x1ag\n,GetProtocolVersionUpgradeVoteStatusRequestV0\x12\x19\n\x11start_pro_tx_hash\x18\x01 \x01(\x0c\x12\r\n\x05\x63ount\x18\x02 \x01(\r\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xef\x05\n+GetProtocolVersionUpgradeVoteStatusResponse\x12\x82\x01\n\x02v0\x18\x01 \x01(\x0b\x32t.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusResponse.GetProtocolVersionUpgradeVoteStatusResponseV0H\x00\x1a\xaf\x04\n-GetProtocolVersionUpgradeVoteStatusResponseV0\x12\x98\x01\n\x08versions\x18\x01 \x01(\x0b\x32\x83\x01.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusResponse.GetProtocolVersionUpgradeVoteStatusResponseV0.VersionSignalsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\xaf\x01\n\x0eVersionSignals\x12\x9c\x01\n\x0fversion_signals\x18\x01 \x03(\x0b\x32\x82\x01.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusResponse.GetProtocolVersionUpgradeVoteStatusResponseV0.VersionSignal\x1a\x35\n\rVersionSignal\x12\x13\n\x0bpro_tx_hash\x18\x01 \x01(\x0c\x12\x0f\n\x07version\x18\x02 \x01(\rB\x08\n\x06resultB\t\n\x07version\"\xf5\x01\n\x14GetEpochsInfoRequest\x12T\n\x02v0\x18\x01 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetEpochsInfoRequest.GetEpochsInfoRequestV0H\x00\x1a|\n\x16GetEpochsInfoRequestV0\x12\x31\n\x0bstart_epoch\x18\x01 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\r\n\x05\x63ount\x18\x02 \x01(\r\x12\x11\n\tascending\x18\x03 \x01(\x08\x12\r\n\x05prove\x18\x04 \x01(\x08\x42\t\n\x07version\"\x99\x05\n\x15GetEpochsInfoResponse\x12V\n\x02v0\x18\x01 \x01(\x0b\x32H.org.dash.platform.dapi.v0.GetEpochsInfoResponse.GetEpochsInfoResponseV0H\x00\x1a\x9c\x04\n\x17GetEpochsInfoResponseV0\x12\x65\n\x06\x65pochs\x18\x01 \x01(\x0b\x32S.org.dash.platform.dapi.v0.GetEpochsInfoResponse.GetEpochsInfoResponseV0.EpochInfosH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1au\n\nEpochInfos\x12g\n\x0b\x65poch_infos\x18\x01 \x03(\x0b\x32R.org.dash.platform.dapi.v0.GetEpochsInfoResponse.GetEpochsInfoResponseV0.EpochInfo\x1a\xa6\x01\n\tEpochInfo\x12\x0e\n\x06number\x18\x01 \x01(\r\x12\x1e\n\x12\x66irst_block_height\x18\x02 \x01(\x04\x42\x02\x30\x01\x12\x1f\n\x17\x66irst_core_block_height\x18\x03 \x01(\r\x12\x16\n\nstart_time\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\x16\n\x0e\x66\x65\x65_multiplier\x18\x05 \x01(\x01\x12\x18\n\x10protocol_version\x18\x06 \x01(\rB\x08\n\x06resultB\t\n\x07version\"\xbf\x02\n\x1dGetFinalizedEpochInfosRequest\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetFinalizedEpochInfosRequest.GetFinalizedEpochInfosRequestV0H\x00\x1a\xaa\x01\n\x1fGetFinalizedEpochInfosRequestV0\x12\x19\n\x11start_epoch_index\x18\x01 \x01(\r\x12\"\n\x1astart_epoch_index_included\x18\x02 \x01(\x08\x12\x17\n\x0f\x65nd_epoch_index\x18\x03 \x01(\r\x12 \n\x18\x65nd_epoch_index_included\x18\x04 \x01(\x08\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\t\n\x07version\"\xbd\t\n\x1eGetFinalizedEpochInfosResponse\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetFinalizedEpochInfosResponse.GetFinalizedEpochInfosResponseV0H\x00\x1a\xa5\x08\n GetFinalizedEpochInfosResponseV0\x12\x80\x01\n\x06\x65pochs\x18\x01 \x01(\x0b\x32n.org.dash.platform.dapi.v0.GetFinalizedEpochInfosResponse.GetFinalizedEpochInfosResponseV0.FinalizedEpochInfosH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\xa4\x01\n\x13\x46inalizedEpochInfos\x12\x8c\x01\n\x15\x66inalized_epoch_infos\x18\x01 \x03(\x0b\x32m.org.dash.platform.dapi.v0.GetFinalizedEpochInfosResponse.GetFinalizedEpochInfosResponseV0.FinalizedEpochInfo\x1a\x9f\x04\n\x12\x46inalizedEpochInfo\x12\x0e\n\x06number\x18\x01 \x01(\r\x12\x1e\n\x12\x66irst_block_height\x18\x02 \x01(\x04\x42\x02\x30\x01\x12\x1f\n\x17\x66irst_core_block_height\x18\x03 \x01(\r\x12\x1c\n\x10\x66irst_block_time\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\x16\n\x0e\x66\x65\x65_multiplier\x18\x05 \x01(\x01\x12\x18\n\x10protocol_version\x18\x06 \x01(\r\x12!\n\x15total_blocks_in_epoch\x18\x07 \x01(\x04\x42\x02\x30\x01\x12*\n\"next_epoch_start_core_block_height\x18\x08 \x01(\r\x12!\n\x15total_processing_fees\x18\t \x01(\x04\x42\x02\x30\x01\x12*\n\x1etotal_distributed_storage_fees\x18\n \x01(\x04\x42\x02\x30\x01\x12&\n\x1atotal_created_storage_fees\x18\x0b \x01(\x04\x42\x02\x30\x01\x12\x1e\n\x12\x63ore_block_rewards\x18\x0c \x01(\x04\x42\x02\x30\x01\x12\x81\x01\n\x0f\x62lock_proposers\x18\r \x03(\x0b\x32h.org.dash.platform.dapi.v0.GetFinalizedEpochInfosResponse.GetFinalizedEpochInfosResponseV0.BlockProposer\x1a\x39\n\rBlockProposer\x12\x13\n\x0bproposer_id\x18\x01 \x01(\x0c\x12\x13\n\x0b\x62lock_count\x18\x02 \x01(\rB\x08\n\x06resultB\t\n\x07version\"\xde\x04\n\x1cGetContestedResourcesRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetContestedResourcesRequest.GetContestedResourcesRequestV0H\x00\x1a\xcc\x03\n\x1eGetContestedResourcesRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\x12\n\nindex_name\x18\x03 \x01(\t\x12\x1a\n\x12start_index_values\x18\x04 \x03(\x0c\x12\x18\n\x10\x65nd_index_values\x18\x05 \x03(\x0c\x12\x89\x01\n\x13start_at_value_info\x18\x06 \x01(\x0b\x32g.org.dash.platform.dapi.v0.GetContestedResourcesRequest.GetContestedResourcesRequestV0.StartAtValueInfoH\x00\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x07 \x01(\rH\x01\x88\x01\x01\x12\x17\n\x0forder_ascending\x18\x08 \x01(\x08\x12\r\n\x05prove\x18\t \x01(\x08\x1a\x45\n\x10StartAtValueInfo\x12\x13\n\x0bstart_value\x18\x01 \x01(\x0c\x12\x1c\n\x14start_value_included\x18\x02 \x01(\x08\x42\x16\n\x14_start_at_value_infoB\x08\n\x06_countB\t\n\x07version\"\x88\x04\n\x1dGetContestedResourcesResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetContestedResourcesResponse.GetContestedResourcesResponseV0H\x00\x1a\xf3\x02\n\x1fGetContestedResourcesResponseV0\x12\x95\x01\n\x19\x63ontested_resource_values\x18\x01 \x01(\x0b\x32p.org.dash.platform.dapi.v0.GetContestedResourcesResponse.GetContestedResourcesResponseV0.ContestedResourceValuesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a<\n\x17\x43ontestedResourceValues\x12!\n\x19\x63ontested_resource_values\x18\x01 \x03(\x0c\x42\x08\n\x06resultB\t\n\x07version\"\xd2\x05\n\x1cGetVotePollsByEndDateRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetVotePollsByEndDateRequest.GetVotePollsByEndDateRequestV0H\x00\x1a\xc0\x04\n\x1eGetVotePollsByEndDateRequestV0\x12\x84\x01\n\x0fstart_time_info\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetVotePollsByEndDateRequest.GetVotePollsByEndDateRequestV0.StartAtTimeInfoH\x00\x88\x01\x01\x12\x80\x01\n\rend_time_info\x18\x02 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetVotePollsByEndDateRequest.GetVotePollsByEndDateRequestV0.EndAtTimeInfoH\x01\x88\x01\x01\x12\x12\n\x05limit\x18\x03 \x01(\rH\x02\x88\x01\x01\x12\x13\n\x06offset\x18\x04 \x01(\rH\x03\x88\x01\x01\x12\x11\n\tascending\x18\x05 \x01(\x08\x12\r\n\x05prove\x18\x06 \x01(\x08\x1aI\n\x0fStartAtTimeInfo\x12\x19\n\rstart_time_ms\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x1b\n\x13start_time_included\x18\x02 \x01(\x08\x1a\x43\n\rEndAtTimeInfo\x12\x17\n\x0b\x65nd_time_ms\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x19\n\x11\x65nd_time_included\x18\x02 \x01(\x08\x42\x12\n\x10_start_time_infoB\x10\n\x0e_end_time_infoB\x08\n\x06_limitB\t\n\x07_offsetB\t\n\x07version\"\x83\x06\n\x1dGetVotePollsByEndDateResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetVotePollsByEndDateResponse.GetVotePollsByEndDateResponseV0H\x00\x1a\xee\x04\n\x1fGetVotePollsByEndDateResponseV0\x12\x9c\x01\n\x18vote_polls_by_timestamps\x18\x01 \x01(\x0b\x32x.org.dash.platform.dapi.v0.GetVotePollsByEndDateResponse.GetVotePollsByEndDateResponseV0.SerializedVotePollsByTimestampsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aV\n\x1eSerializedVotePollsByTimestamp\x12\x15\n\ttimestamp\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x1d\n\x15serialized_vote_polls\x18\x02 \x03(\x0c\x1a\xd7\x01\n\x1fSerializedVotePollsByTimestamps\x12\x99\x01\n\x18vote_polls_by_timestamps\x18\x01 \x03(\x0b\x32w.org.dash.platform.dapi.v0.GetVotePollsByEndDateResponse.GetVotePollsByEndDateResponseV0.SerializedVotePollsByTimestamp\x12\x18\n\x10\x66inished_results\x18\x02 \x01(\x08\x42\x08\n\x06resultB\t\n\x07version\"\xff\x06\n$GetContestedResourceVoteStateRequest\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetContestedResourceVoteStateRequest.GetContestedResourceVoteStateRequestV0H\x00\x1a\xd5\x05\n&GetContestedResourceVoteStateRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\x12\n\nindex_name\x18\x03 \x01(\t\x12\x14\n\x0cindex_values\x18\x04 \x03(\x0c\x12\x86\x01\n\x0bresult_type\x18\x05 \x01(\x0e\x32q.org.dash.platform.dapi.v0.GetContestedResourceVoteStateRequest.GetContestedResourceVoteStateRequestV0.ResultType\x12\x36\n.allow_include_locked_and_abstaining_vote_tally\x18\x06 \x01(\x08\x12\xa3\x01\n\x18start_at_identifier_info\x18\x07 \x01(\x0b\x32|.org.dash.platform.dapi.v0.GetContestedResourceVoteStateRequest.GetContestedResourceVoteStateRequestV0.StartAtIdentifierInfoH\x00\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x08 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\t \x01(\x08\x1aT\n\x15StartAtIdentifierInfo\x12\x18\n\x10start_identifier\x18\x01 \x01(\x0c\x12!\n\x19start_identifier_included\x18\x02 \x01(\x08\"I\n\nResultType\x12\r\n\tDOCUMENTS\x10\x00\x12\x0e\n\nVOTE_TALLY\x10\x01\x12\x1c\n\x18\x44OCUMENTS_AND_VOTE_TALLY\x10\x02\x42\x1b\n\x19_start_at_identifier_infoB\x08\n\x06_countB\t\n\x07version\"\x94\x0c\n%GetContestedResourceVoteStateResponse\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse.GetContestedResourceVoteStateResponseV0H\x00\x1a\xe7\n\n\'GetContestedResourceVoteStateResponseV0\x12\xae\x01\n\x1d\x63ontested_resource_contenders\x18\x01 \x01(\x0b\x32\x84\x01.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse.GetContestedResourceVoteStateResponseV0.ContestedResourceContendersH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\xda\x03\n\x10\x46inishedVoteInfo\x12\xad\x01\n\x15\x66inished_vote_outcome\x18\x01 \x01(\x0e\x32\x8d\x01.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse.GetContestedResourceVoteStateResponseV0.FinishedVoteInfo.FinishedVoteOutcome\x12\x1f\n\x12won_by_identity_id\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x12$\n\x18\x66inished_at_block_height\x18\x03 \x01(\x04\x42\x02\x30\x01\x12%\n\x1d\x66inished_at_core_block_height\x18\x04 \x01(\r\x12%\n\x19\x66inished_at_block_time_ms\x18\x05 \x01(\x04\x42\x02\x30\x01\x12\x19\n\x11\x66inished_at_epoch\x18\x06 \x01(\r\"O\n\x13\x46inishedVoteOutcome\x12\x14\n\x10TOWARDS_IDENTITY\x10\x00\x12\n\n\x06LOCKED\x10\x01\x12\x16\n\x12NO_PREVIOUS_WINNER\x10\x02\x42\x15\n\x13_won_by_identity_id\x1a\xc4\x03\n\x1b\x43ontestedResourceContenders\x12\x86\x01\n\ncontenders\x18\x01 \x03(\x0b\x32r.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse.GetContestedResourceVoteStateResponseV0.Contender\x12\x1f\n\x12\x61\x62stain_vote_tally\x18\x02 \x01(\rH\x00\x88\x01\x01\x12\x1c\n\x0flock_vote_tally\x18\x03 \x01(\rH\x01\x88\x01\x01\x12\x9a\x01\n\x12\x66inished_vote_info\x18\x04 \x01(\x0b\x32y.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse.GetContestedResourceVoteStateResponseV0.FinishedVoteInfoH\x02\x88\x01\x01\x42\x15\n\x13_abstain_vote_tallyB\x12\n\x10_lock_vote_tallyB\x15\n\x13_finished_vote_info\x1ak\n\tContender\x12\x12\n\nidentifier\x18\x01 \x01(\x0c\x12\x17\n\nvote_count\x18\x02 \x01(\rH\x00\x88\x01\x01\x12\x15\n\x08\x64ocument\x18\x03 \x01(\x0cH\x01\x88\x01\x01\x42\r\n\x0b_vote_countB\x0b\n\t_documentB\x08\n\x06resultB\t\n\x07version\"\xd5\x05\n,GetContestedResourceVotersForIdentityRequest\x12\x84\x01\n\x02v0\x18\x01 \x01(\x0b\x32v.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityRequest.GetContestedResourceVotersForIdentityRequestV0H\x00\x1a\x92\x04\n.GetContestedResourceVotersForIdentityRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\x12\n\nindex_name\x18\x03 \x01(\t\x12\x14\n\x0cindex_values\x18\x04 \x03(\x0c\x12\x15\n\rcontestant_id\x18\x05 \x01(\x0c\x12\xb4\x01\n\x18start_at_identifier_info\x18\x06 \x01(\x0b\x32\x8c\x01.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityRequest.GetContestedResourceVotersForIdentityRequestV0.StartAtIdentifierInfoH\x00\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x07 \x01(\rH\x01\x88\x01\x01\x12\x17\n\x0forder_ascending\x18\x08 \x01(\x08\x12\r\n\x05prove\x18\t \x01(\x08\x1aT\n\x15StartAtIdentifierInfo\x12\x18\n\x10start_identifier\x18\x01 \x01(\x0c\x12!\n\x19start_identifier_included\x18\x02 \x01(\x08\x42\x1b\n\x19_start_at_identifier_infoB\x08\n\x06_countB\t\n\x07version\"\xf1\x04\n-GetContestedResourceVotersForIdentityResponse\x12\x86\x01\n\x02v0\x18\x01 \x01(\x0b\x32x.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityResponse.GetContestedResourceVotersForIdentityResponseV0H\x00\x1a\xab\x03\n/GetContestedResourceVotersForIdentityResponseV0\x12\xb6\x01\n\x19\x63ontested_resource_voters\x18\x01 \x01(\x0b\x32\x90\x01.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityResponse.GetContestedResourceVotersForIdentityResponseV0.ContestedResourceVotersH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x43\n\x17\x43ontestedResourceVoters\x12\x0e\n\x06voters\x18\x01 \x03(\x0c\x12\x18\n\x10\x66inished_results\x18\x02 \x01(\x08\x42\x08\n\x06resultB\t\n\x07version\"\xad\x05\n(GetContestedResourceIdentityVotesRequest\x12|\n\x02v0\x18\x01 \x01(\x0b\x32n.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesRequest.GetContestedResourceIdentityVotesRequestV0H\x00\x1a\xf7\x03\n*GetContestedResourceIdentityVotesRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12+\n\x05limit\x18\x02 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12,\n\x06offset\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\x17\n\x0forder_ascending\x18\x04 \x01(\x08\x12\xae\x01\n\x1astart_at_vote_poll_id_info\x18\x05 \x01(\x0b\x32\x84\x01.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesRequest.GetContestedResourceIdentityVotesRequestV0.StartAtVotePollIdInfoH\x00\x88\x01\x01\x12\r\n\x05prove\x18\x06 \x01(\x08\x1a\x61\n\x15StartAtVotePollIdInfo\x12 \n\x18start_at_poll_identifier\x18\x01 \x01(\x0c\x12&\n\x1estart_poll_identifier_included\x18\x02 \x01(\x08\x42\x1d\n\x1b_start_at_vote_poll_id_infoB\t\n\x07version\"\xc8\n\n)GetContestedResourceIdentityVotesResponse\x12~\n\x02v0\x18\x01 \x01(\x0b\x32p.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse.GetContestedResourceIdentityVotesResponseV0H\x00\x1a\x8f\t\n+GetContestedResourceIdentityVotesResponseV0\x12\xa1\x01\n\x05votes\x18\x01 \x01(\x0b\x32\x8f\x01.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse.GetContestedResourceIdentityVotesResponseV0.ContestedResourceIdentityVotesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\xf7\x01\n\x1e\x43ontestedResourceIdentityVotes\x12\xba\x01\n!contested_resource_identity_votes\x18\x01 \x03(\x0b\x32\x8e\x01.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse.GetContestedResourceIdentityVotesResponseV0.ContestedResourceIdentityVote\x12\x18\n\x10\x66inished_results\x18\x02 \x01(\x08\x1a\xad\x02\n\x12ResourceVoteChoice\x12\xad\x01\n\x10vote_choice_type\x18\x01 \x01(\x0e\x32\x92\x01.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse.GetContestedResourceIdentityVotesResponseV0.ResourceVoteChoice.VoteChoiceType\x12\x18\n\x0bidentity_id\x18\x02 \x01(\x0cH\x00\x88\x01\x01\"=\n\x0eVoteChoiceType\x12\x14\n\x10TOWARDS_IDENTITY\x10\x00\x12\x0b\n\x07\x41\x42STAIN\x10\x01\x12\x08\n\x04LOCK\x10\x02\x42\x0e\n\x0c_identity_id\x1a\x95\x02\n\x1d\x43ontestedResourceIdentityVote\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\'\n\x1fserialized_index_storage_values\x18\x03 \x03(\x0c\x12\x99\x01\n\x0bvote_choice\x18\x04 \x01(\x0b\x32\x83\x01.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse.GetContestedResourceIdentityVotesResponseV0.ResourceVoteChoiceB\x08\n\x06resultB\t\n\x07version\"\xf0\x01\n%GetPrefundedSpecializedBalanceRequest\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetPrefundedSpecializedBalanceRequest.GetPrefundedSpecializedBalanceRequestV0H\x00\x1a\x44\n\'GetPrefundedSpecializedBalanceRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xed\x02\n&GetPrefundedSpecializedBalanceResponse\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetPrefundedSpecializedBalanceResponse.GetPrefundedSpecializedBalanceResponseV0H\x00\x1a\xbd\x01\n(GetPrefundedSpecializedBalanceResponseV0\x12\x15\n\x07\x62\x61lance\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xd0\x01\n GetTotalCreditsInPlatformRequest\x12l\n\x02v0\x18\x01 \x01(\x0b\x32^.org.dash.platform.dapi.v0.GetTotalCreditsInPlatformRequest.GetTotalCreditsInPlatformRequestV0H\x00\x1a\x33\n\"GetTotalCreditsInPlatformRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xd9\x02\n!GetTotalCreditsInPlatformResponse\x12n\n\x02v0\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetTotalCreditsInPlatformResponse.GetTotalCreditsInPlatformResponseV0H\x00\x1a\xb8\x01\n#GetTotalCreditsInPlatformResponseV0\x12\x15\n\x07\x63redits\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc4\x01\n\x16GetPathElementsRequest\x12X\n\x02v0\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetPathElementsRequest.GetPathElementsRequestV0H\x00\x1a\x45\n\x18GetPathElementsRequestV0\x12\x0c\n\x04path\x18\x01 \x03(\x0c\x12\x0c\n\x04keys\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xa3\x03\n\x17GetPathElementsResponse\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetPathElementsResponse.GetPathElementsResponseV0H\x00\x1a\xa0\x02\n\x19GetPathElementsResponseV0\x12i\n\x08\x65lements\x18\x01 \x01(\x0b\x32U.org.dash.platform.dapi.v0.GetPathElementsResponse.GetPathElementsResponseV0.ElementsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x1c\n\x08\x45lements\x12\x10\n\x08\x65lements\x18\x01 \x03(\x0c\x42\x08\n\x06resultB\t\n\x07version\"\x81\x01\n\x10GetStatusRequest\x12L\n\x02v0\x18\x01 \x01(\x0b\x32>.org.dash.platform.dapi.v0.GetStatusRequest.GetStatusRequestV0H\x00\x1a\x14\n\x12GetStatusRequestV0B\t\n\x07version\"\xe4\x10\n\x11GetStatusResponse\x12N\n\x02v0\x18\x01 \x01(\x0b\x32@.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0H\x00\x1a\xf3\x0f\n\x13GetStatusResponseV0\x12Y\n\x07version\x18\x01 \x01(\x0b\x32H.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Version\x12S\n\x04node\x18\x02 \x01(\x0b\x32\x45.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Node\x12U\n\x05\x63hain\x18\x03 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Chain\x12Y\n\x07network\x18\x04 \x01(\x0b\x32H.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Network\x12^\n\nstate_sync\x18\x05 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.StateSync\x12S\n\x04time\x18\x06 \x01(\x0b\x32\x45.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Time\x1a\x82\x05\n\x07Version\x12\x63\n\x08software\x18\x01 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Version.Software\x12\x63\n\x08protocol\x18\x02 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Version.Protocol\x1a^\n\x08Software\x12\x0c\n\x04\x64\x61pi\x18\x01 \x01(\t\x12\x12\n\x05\x64rive\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x17\n\ntenderdash\x18\x03 \x01(\tH\x01\x88\x01\x01\x42\x08\n\x06_driveB\r\n\x0b_tenderdash\x1a\xcc\x02\n\x08Protocol\x12p\n\ntenderdash\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Version.Protocol.Tenderdash\x12\x66\n\x05\x64rive\x18\x02 \x01(\x0b\x32W.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Version.Protocol.Drive\x1a(\n\nTenderdash\x12\x0b\n\x03p2p\x18\x01 \x01(\r\x12\r\n\x05\x62lock\x18\x02 \x01(\r\x1a<\n\x05\x44rive\x12\x0e\n\x06latest\x18\x03 \x01(\r\x12\x0f\n\x07\x63urrent\x18\x04 \x01(\r\x12\x12\n\nnext_epoch\x18\x05 \x01(\r\x1a\x7f\n\x04Time\x12\x11\n\x05local\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x16\n\x05\x62lock\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x88\x01\x01\x12\x18\n\x07genesis\x18\x03 \x01(\x04\x42\x02\x30\x01H\x01\x88\x01\x01\x12\x12\n\x05\x65poch\x18\x04 \x01(\rH\x02\x88\x01\x01\x42\x08\n\x06_blockB\n\n\x08_genesisB\x08\n\x06_epoch\x1a<\n\x04Node\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\x18\n\x0bpro_tx_hash\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x42\x0e\n\x0c_pro_tx_hash\x1a\xb3\x02\n\x05\x43hain\x12\x13\n\x0b\x63\x61tching_up\x18\x01 \x01(\x08\x12\x19\n\x11latest_block_hash\x18\x02 \x01(\x0c\x12\x17\n\x0flatest_app_hash\x18\x03 \x01(\x0c\x12\x1f\n\x13latest_block_height\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\x1b\n\x13\x65\x61rliest_block_hash\x18\x05 \x01(\x0c\x12\x19\n\x11\x65\x61rliest_app_hash\x18\x06 \x01(\x0c\x12!\n\x15\x65\x61rliest_block_height\x18\x07 \x01(\x04\x42\x02\x30\x01\x12!\n\x15max_peer_block_height\x18\t \x01(\x04\x42\x02\x30\x01\x12%\n\x18\x63ore_chain_locked_height\x18\n \x01(\rH\x00\x88\x01\x01\x42\x1b\n\x19_core_chain_locked_height\x1a\x43\n\x07Network\x12\x10\n\x08\x63hain_id\x18\x01 \x01(\t\x12\x13\n\x0bpeers_count\x18\x02 \x01(\r\x12\x11\n\tlistening\x18\x03 \x01(\x08\x1a\x85\x02\n\tStateSync\x12\x1d\n\x11total_synced_time\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x1a\n\x0eremaining_time\x18\x02 \x01(\x04\x42\x02\x30\x01\x12\x17\n\x0ftotal_snapshots\x18\x03 \x01(\r\x12\"\n\x16\x63hunk_process_avg_time\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\x1b\n\x0fsnapshot_height\x18\x05 \x01(\x04\x42\x02\x30\x01\x12!\n\x15snapshot_chunks_count\x18\x06 \x01(\x04\x42\x02\x30\x01\x12\x1d\n\x11\x62\x61\x63kfilled_blocks\x18\x07 \x01(\x04\x42\x02\x30\x01\x12!\n\x15\x62\x61\x63kfill_blocks_total\x18\x08 \x01(\x04\x42\x02\x30\x01\x42\t\n\x07version\"\xb1\x01\n\x1cGetCurrentQuorumsInfoRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoRequest.GetCurrentQuorumsInfoRequestV0H\x00\x1a \n\x1eGetCurrentQuorumsInfoRequestV0B\t\n\x07version\"\xa1\x05\n\x1dGetCurrentQuorumsInfoResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoResponse.GetCurrentQuorumsInfoResponseV0H\x00\x1a\x46\n\x0bValidatorV0\x12\x13\n\x0bpro_tx_hash\x18\x01 \x01(\x0c\x12\x0f\n\x07node_ip\x18\x02 \x01(\t\x12\x11\n\tis_banned\x18\x03 \x01(\x08\x1a\xaf\x01\n\x0eValidatorSetV0\x12\x13\n\x0bquorum_hash\x18\x01 \x01(\x0c\x12\x13\n\x0b\x63ore_height\x18\x02 \x01(\r\x12U\n\x07members\x18\x03 \x03(\x0b\x32\x44.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoResponse.ValidatorV0\x12\x1c\n\x14threshold_public_key\x18\x04 \x01(\x0c\x1a\x92\x02\n\x1fGetCurrentQuorumsInfoResponseV0\x12\x15\n\rquorum_hashes\x18\x01 \x03(\x0c\x12\x1b\n\x13\x63urrent_quorum_hash\x18\x02 \x01(\x0c\x12_\n\x0evalidator_sets\x18\x03 \x03(\x0b\x32G.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoResponse.ValidatorSetV0\x12\x1b\n\x13last_block_proposer\x18\x04 \x01(\x0c\x12=\n\x08metadata\x18\x05 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\t\n\x07version\"\xf4\x01\n\x1fGetIdentityTokenBalancesRequest\x12j\n\x02v0\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetIdentityTokenBalancesRequest.GetIdentityTokenBalancesRequestV0H\x00\x1aZ\n!GetIdentityTokenBalancesRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x11\n\ttoken_ids\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xad\x05\n GetIdentityTokenBalancesResponse\x12l\n\x02v0\x18\x01 \x01(\x0b\x32^.org.dash.platform.dapi.v0.GetIdentityTokenBalancesResponse.GetIdentityTokenBalancesResponseV0H\x00\x1a\x8f\x04\n\"GetIdentityTokenBalancesResponseV0\x12\x86\x01\n\x0etoken_balances\x18\x01 \x01(\x0b\x32l.org.dash.platform.dapi.v0.GetIdentityTokenBalancesResponse.GetIdentityTokenBalancesResponseV0.TokenBalancesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aG\n\x11TokenBalanceEntry\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x14\n\x07\x62\x61lance\x18\x02 \x01(\x04H\x00\x88\x01\x01\x42\n\n\x08_balance\x1a\x9a\x01\n\rTokenBalances\x12\x88\x01\n\x0etoken_balances\x18\x01 \x03(\x0b\x32p.org.dash.platform.dapi.v0.GetIdentityTokenBalancesResponse.GetIdentityTokenBalancesResponseV0.TokenBalanceEntryB\x08\n\x06resultB\t\n\x07version\"\xfc\x01\n!GetIdentitiesTokenBalancesRequest\x12n\n\x02v0\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesRequest.GetIdentitiesTokenBalancesRequestV0H\x00\x1a\\\n#GetIdentitiesTokenBalancesRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x14\n\x0cidentity_ids\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xf2\x05\n\"GetIdentitiesTokenBalancesResponse\x12p\n\x02v0\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesResponse.GetIdentitiesTokenBalancesResponseV0H\x00\x1a\xce\x04\n$GetIdentitiesTokenBalancesResponseV0\x12\x9b\x01\n\x17identity_token_balances\x18\x01 \x01(\x0b\x32x.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesResponse.GetIdentitiesTokenBalancesResponseV0.IdentityTokenBalancesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aR\n\x19IdentityTokenBalanceEntry\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x14\n\x07\x62\x61lance\x18\x02 \x01(\x04H\x00\x88\x01\x01\x42\n\n\x08_balance\x1a\xb7\x01\n\x15IdentityTokenBalances\x12\x9d\x01\n\x17identity_token_balances\x18\x01 \x03(\x0b\x32|.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesResponse.GetIdentitiesTokenBalancesResponseV0.IdentityTokenBalanceEntryB\x08\n\x06resultB\t\n\x07version\"\xe8\x01\n\x1cGetIdentityTokenInfosRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetIdentityTokenInfosRequest.GetIdentityTokenInfosRequestV0H\x00\x1aW\n\x1eGetIdentityTokenInfosRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x11\n\ttoken_ids\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\x98\x06\n\x1dGetIdentityTokenInfosResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetIdentityTokenInfosResponse.GetIdentityTokenInfosResponseV0H\x00\x1a\x83\x05\n\x1fGetIdentityTokenInfosResponseV0\x12z\n\x0btoken_infos\x18\x01 \x01(\x0b\x32\x63.org.dash.platform.dapi.v0.GetIdentityTokenInfosResponse.GetIdentityTokenInfosResponseV0.TokenInfosH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a(\n\x16TokenIdentityInfoEntry\x12\x0e\n\x06\x66rozen\x18\x01 \x01(\x08\x1a\xb0\x01\n\x0eTokenInfoEntry\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x82\x01\n\x04info\x18\x02 \x01(\x0b\x32o.org.dash.platform.dapi.v0.GetIdentityTokenInfosResponse.GetIdentityTokenInfosResponseV0.TokenIdentityInfoEntryH\x00\x88\x01\x01\x42\x07\n\x05_info\x1a\x8a\x01\n\nTokenInfos\x12|\n\x0btoken_infos\x18\x01 \x03(\x0b\x32g.org.dash.platform.dapi.v0.GetIdentityTokenInfosResponse.GetIdentityTokenInfosResponseV0.TokenInfoEntryB\x08\n\x06resultB\t\n\x07version\"\xf0\x01\n\x1eGetIdentitiesTokenInfosRequest\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosRequest.GetIdentitiesTokenInfosRequestV0H\x00\x1aY\n GetIdentitiesTokenInfosRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x14\n\x0cidentity_ids\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xca\x06\n\x1fGetIdentitiesTokenInfosResponse\x12j\n\x02v0\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosResponse.GetIdentitiesTokenInfosResponseV0H\x00\x1a\xaf\x05\n!GetIdentitiesTokenInfosResponseV0\x12\x8f\x01\n\x14identity_token_infos\x18\x01 \x01(\x0b\x32o.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosResponse.GetIdentitiesTokenInfosResponseV0.IdentityTokenInfosH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a(\n\x16TokenIdentityInfoEntry\x12\x0e\n\x06\x66rozen\x18\x01 \x01(\x08\x1a\xb7\x01\n\x0eTokenInfoEntry\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x86\x01\n\x04info\x18\x02 \x01(\x0b\x32s.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosResponse.GetIdentitiesTokenInfosResponseV0.TokenIdentityInfoEntryH\x00\x88\x01\x01\x42\x07\n\x05_info\x1a\x97\x01\n\x12IdentityTokenInfos\x12\x80\x01\n\x0btoken_infos\x18\x01 \x03(\x0b\x32k.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosResponse.GetIdentitiesTokenInfosResponseV0.TokenInfoEntryB\x08\n\x06resultB\t\n\x07version\"\xbf\x01\n\x17GetTokenStatusesRequest\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetTokenStatusesRequest.GetTokenStatusesRequestV0H\x00\x1a=\n\x19GetTokenStatusesRequestV0\x12\x11\n\ttoken_ids\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xe7\x04\n\x18GetTokenStatusesResponse\x12\\\n\x02v0\x18\x01 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetTokenStatusesResponse.GetTokenStatusesResponseV0H\x00\x1a\xe1\x03\n\x1aGetTokenStatusesResponseV0\x12v\n\x0etoken_statuses\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetTokenStatusesResponse.GetTokenStatusesResponseV0.TokenStatusesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x44\n\x10TokenStatusEntry\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x13\n\x06paused\x18\x02 \x01(\x08H\x00\x88\x01\x01\x42\t\n\x07_paused\x1a\x88\x01\n\rTokenStatuses\x12w\n\x0etoken_statuses\x18\x01 \x03(\x0b\x32_.org.dash.platform.dapi.v0.GetTokenStatusesResponse.GetTokenStatusesResponseV0.TokenStatusEntryB\x08\n\x06resultB\t\n\x07version\"\xef\x01\n#GetTokenDirectPurchasePricesRequest\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesRequest.GetTokenDirectPurchasePricesRequestV0H\x00\x1aI\n%GetTokenDirectPurchasePricesRequestV0\x12\x11\n\ttoken_ids\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x8b\t\n$GetTokenDirectPurchasePricesResponse\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse.GetTokenDirectPurchasePricesResponseV0H\x00\x1a\xe1\x07\n&GetTokenDirectPurchasePricesResponseV0\x12\xa9\x01\n\x1ctoken_direct_purchase_prices\x18\x01 \x01(\x0b\x32\x80\x01.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse.GetTokenDirectPurchasePricesResponseV0.TokenDirectPurchasePricesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x33\n\x10PriceForQuantity\x12\x10\n\x08quantity\x18\x01 \x01(\x04\x12\r\n\x05price\x18\x02 \x01(\x04\x1a\xa7\x01\n\x0fPricingSchedule\x12\x93\x01\n\x12price_for_quantity\x18\x01 \x03(\x0b\x32w.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse.GetTokenDirectPurchasePricesResponseV0.PriceForQuantity\x1a\xe4\x01\n\x1dTokenDirectPurchasePriceEntry\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x15\n\x0b\x66ixed_price\x18\x02 \x01(\x04H\x00\x12\x90\x01\n\x0evariable_price\x18\x03 \x01(\x0b\x32v.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse.GetTokenDirectPurchasePricesResponseV0.PricingScheduleH\x00\x42\x07\n\x05price\x1a\xc8\x01\n\x19TokenDirectPurchasePrices\x12\xaa\x01\n\x1btoken_direct_purchase_price\x18\x01 \x03(\x0b\x32\x84\x01.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse.GetTokenDirectPurchasePricesResponseV0.TokenDirectPurchasePriceEntryB\x08\n\x06resultB\t\n\x07version\"\xce\x01\n\x1bGetTokenContractInfoRequest\x12\x62\n\x02v0\x18\x01 \x01(\x0b\x32T.org.dash.platform.dapi.v0.GetTokenContractInfoRequest.GetTokenContractInfoRequestV0H\x00\x1a@\n\x1dGetTokenContractInfoRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xfb\x03\n\x1cGetTokenContractInfoResponse\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetTokenContractInfoResponse.GetTokenContractInfoResponseV0H\x00\x1a\xe9\x02\n\x1eGetTokenContractInfoResponseV0\x12|\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32l.org.dash.platform.dapi.v0.GetTokenContractInfoResponse.GetTokenContractInfoResponseV0.TokenContractInfoDataH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aM\n\x15TokenContractInfoData\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1f\n\x17token_contract_position\x18\x02 \x01(\rB\x08\n\x06resultB\t\n\x07version\"\xef\x04\n)GetTokenPreProgrammedDistributionsRequest\x12~\n\x02v0\x18\x01 \x01(\x0b\x32p.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsRequest.GetTokenPreProgrammedDistributionsRequestV0H\x00\x1a\xb6\x03\n+GetTokenPreProgrammedDistributionsRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x98\x01\n\rstart_at_info\x18\x02 \x01(\x0b\x32|.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsRequest.GetTokenPreProgrammedDistributionsRequestV0.StartAtInfoH\x00\x88\x01\x01\x12\x12\n\x05limit\x18\x03 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\x04 \x01(\x08\x1a\x9a\x01\n\x0bStartAtInfo\x12\x15\n\rstart_time_ms\x18\x01 \x01(\x04\x12\x1c\n\x0fstart_recipient\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x12%\n\x18start_recipient_included\x18\x03 \x01(\x08H\x01\x88\x01\x01\x42\x12\n\x10_start_recipientB\x1b\n\x19_start_recipient_includedB\x10\n\x0e_start_at_infoB\x08\n\x06_limitB\t\n\x07version\"\xec\x07\n*GetTokenPreProgrammedDistributionsResponse\x12\x80\x01\n\x02v0\x18\x01 \x01(\x0b\x32r.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsResponse.GetTokenPreProgrammedDistributionsResponseV0H\x00\x1a\xaf\x06\n,GetTokenPreProgrammedDistributionsResponseV0\x12\xa5\x01\n\x13token_distributions\x18\x01 \x01(\x0b\x32\x85\x01.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsResponse.GetTokenPreProgrammedDistributionsResponseV0.TokenDistributionsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a>\n\x16TokenDistributionEntry\x12\x14\n\x0crecipient_id\x18\x01 \x01(\x0c\x12\x0e\n\x06\x61mount\x18\x02 \x01(\x04\x1a\xd4\x01\n\x1bTokenTimedDistributionEntry\x12\x11\n\ttimestamp\x18\x01 \x01(\x04\x12\xa1\x01\n\rdistributions\x18\x02 \x03(\x0b\x32\x89\x01.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsResponse.GetTokenPreProgrammedDistributionsResponseV0.TokenDistributionEntry\x1a\xc3\x01\n\x12TokenDistributions\x12\xac\x01\n\x13token_distributions\x18\x01 \x03(\x0b\x32\x8e\x01.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsResponse.GetTokenPreProgrammedDistributionsResponseV0.TokenTimedDistributionEntryB\x08\n\x06resultB\t\n\x07version\"\x82\x04\n-GetTokenPerpetualDistributionLastClaimRequest\x12\x86\x01\n\x02v0\x18\x01 \x01(\x0b\x32x.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimRequest.GetTokenPerpetualDistributionLastClaimRequestV0H\x00\x1aI\n\x11\x43ontractTokenInfo\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1f\n\x17token_contract_position\x18\x02 \x01(\r\x1a\xf1\x01\n/GetTokenPerpetualDistributionLastClaimRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12v\n\rcontract_info\x18\x02 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimRequest.ContractTokenInfoH\x00\x88\x01\x01\x12\x13\n\x0bidentity_id\x18\x04 \x01(\x0c\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\x10\n\x0e_contract_infoB\t\n\x07version\"\x93\x05\n.GetTokenPerpetualDistributionLastClaimResponse\x12\x88\x01\n\x02v0\x18\x01 \x01(\x0b\x32z.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimResponse.GetTokenPerpetualDistributionLastClaimResponseV0H\x00\x1a\xca\x03\n0GetTokenPerpetualDistributionLastClaimResponseV0\x12\x9f\x01\n\nlast_claim\x18\x01 \x01(\x0b\x32\x88\x01.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimResponse.GetTokenPerpetualDistributionLastClaimResponseV0.LastClaimInfoH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1ax\n\rLastClaimInfo\x12\x1a\n\x0ctimestamp_ms\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x1a\n\x0c\x62lock_height\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x12\x0f\n\x05\x65poch\x18\x03 \x01(\rH\x00\x12\x13\n\traw_bytes\x18\x04 \x01(\x0cH\x00\x42\t\n\x07paid_atB\x08\n\x06resultB\t\n\x07version\"\xca\x01\n\x1aGetTokenTotalSupplyRequest\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetTokenTotalSupplyRequest.GetTokenTotalSupplyRequestV0H\x00\x1a?\n\x1cGetTokenTotalSupplyRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xaf\x04\n\x1bGetTokenTotalSupplyResponse\x12\x62\n\x02v0\x18\x01 \x01(\x0b\x32T.org.dash.platform.dapi.v0.GetTokenTotalSupplyResponse.GetTokenTotalSupplyResponseV0H\x00\x1a\xa0\x03\n\x1dGetTokenTotalSupplyResponseV0\x12\x88\x01\n\x12token_total_supply\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetTokenTotalSupplyResponse.GetTokenTotalSupplyResponseV0.TokenTotalSupplyEntryH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1ax\n\x15TokenTotalSupplyEntry\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x30\n(total_aggregated_amount_in_user_accounts\x18\x02 \x01(\x04\x12\x1b\n\x13total_system_amount\x18\x03 \x01(\x04\x42\x08\n\x06resultB\t\n\x07version\"\xd2\x01\n\x13GetGroupInfoRequest\x12R\n\x02v0\x18\x01 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetGroupInfoRequest.GetGroupInfoRequestV0H\x00\x1a\\\n\x15GetGroupInfoRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1f\n\x17group_contract_position\x18\x02 \x01(\r\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xd4\x05\n\x14GetGroupInfoResponse\x12T\n\x02v0\x18\x01 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetGroupInfoResponse.GetGroupInfoResponseV0H\x00\x1a\xda\x04\n\x16GetGroupInfoResponseV0\x12\x66\n\ngroup_info\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetGroupInfoResponse.GetGroupInfoResponseV0.GroupInfoH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x04 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x34\n\x10GroupMemberEntry\x12\x11\n\tmember_id\x18\x01 \x01(\x0c\x12\r\n\x05power\x18\x02 \x01(\r\x1a\x98\x01\n\x0eGroupInfoEntry\x12h\n\x07members\x18\x01 \x03(\x0b\x32W.org.dash.platform.dapi.v0.GetGroupInfoResponse.GetGroupInfoResponseV0.GroupMemberEntry\x12\x1c\n\x14group_required_power\x18\x02 \x01(\r\x1a\x8a\x01\n\tGroupInfo\x12n\n\ngroup_info\x18\x01 \x01(\x0b\x32U.org.dash.platform.dapi.v0.GetGroupInfoResponse.GetGroupInfoResponseV0.GroupInfoEntryH\x00\x88\x01\x01\x42\r\n\x0b_group_infoB\x08\n\x06resultB\t\n\x07version\"\xed\x03\n\x14GetGroupInfosRequest\x12T\n\x02v0\x18\x01 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetGroupInfosRequest.GetGroupInfosRequestV0H\x00\x1au\n\x1cStartAtGroupContractPosition\x12%\n\x1dstart_group_contract_position\x18\x01 \x01(\r\x12.\n&start_group_contract_position_included\x18\x02 \x01(\x08\x1a\xfc\x01\n\x16GetGroupInfosRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12{\n start_at_group_contract_position\x18\x02 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetGroupInfosRequest.StartAtGroupContractPositionH\x00\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x03 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\x04 \x01(\x08\x42#\n!_start_at_group_contract_positionB\x08\n\x06_countB\t\n\x07version\"\xff\x05\n\x15GetGroupInfosResponse\x12V\n\x02v0\x18\x01 \x01(\x0b\x32H.org.dash.platform.dapi.v0.GetGroupInfosResponse.GetGroupInfosResponseV0H\x00\x1a\x82\x05\n\x17GetGroupInfosResponseV0\x12j\n\x0bgroup_infos\x18\x01 \x01(\x0b\x32S.org.dash.platform.dapi.v0.GetGroupInfosResponse.GetGroupInfosResponseV0.GroupInfosH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x04 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x34\n\x10GroupMemberEntry\x12\x11\n\tmember_id\x18\x01 \x01(\x0c\x12\r\n\x05power\x18\x02 \x01(\r\x1a\xc3\x01\n\x16GroupPositionInfoEntry\x12\x1f\n\x17group_contract_position\x18\x01 \x01(\r\x12j\n\x07members\x18\x02 \x03(\x0b\x32Y.org.dash.platform.dapi.v0.GetGroupInfosResponse.GetGroupInfosResponseV0.GroupMemberEntry\x12\x1c\n\x14group_required_power\x18\x03 \x01(\r\x1a\x82\x01\n\nGroupInfos\x12t\n\x0bgroup_infos\x18\x01 \x03(\x0b\x32_.org.dash.platform.dapi.v0.GetGroupInfosResponse.GetGroupInfosResponseV0.GroupPositionInfoEntryB\x08\n\x06resultB\t\n\x07version\"\xbe\x04\n\x16GetGroupActionsRequest\x12X\n\x02v0\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetGroupActionsRequest.GetGroupActionsRequestV0H\x00\x1aL\n\x0fStartAtActionId\x12\x17\n\x0fstart_action_id\x18\x01 \x01(\x0c\x12 \n\x18start_action_id_included\x18\x02 \x01(\x08\x1a\xc8\x02\n\x18GetGroupActionsRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1f\n\x17group_contract_position\x18\x02 \x01(\r\x12N\n\x06status\x18\x03 \x01(\x0e\x32>.org.dash.platform.dapi.v0.GetGroupActionsRequest.ActionStatus\x12\x62\n\x12start_at_action_id\x18\x04 \x01(\x0b\x32\x41.org.dash.platform.dapi.v0.GetGroupActionsRequest.StartAtActionIdH\x00\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x05 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\x06 \x01(\x08\x42\x15\n\x13_start_at_action_idB\x08\n\x06_count\"&\n\x0c\x41\x63tionStatus\x12\n\n\x06\x41\x43TIVE\x10\x00\x12\n\n\x06\x43LOSED\x10\x01\x42\t\n\x07version\"\xd6\x1e\n\x17GetGroupActionsResponse\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0H\x00\x1a\xd3\x1d\n\x19GetGroupActionsResponseV0\x12r\n\rgroup_actions\x18\x01 \x01(\x0b\x32Y.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.GroupActionsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a[\n\tMintEvent\x12\x0e\n\x06\x61mount\x18\x01 \x01(\x04\x12\x14\n\x0crecipient_id\x18\x02 \x01(\x0c\x12\x18\n\x0bpublic_note\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1a[\n\tBurnEvent\x12\x0e\n\x06\x61mount\x18\x01 \x01(\x04\x12\x14\n\x0c\x62urn_from_id\x18\x02 \x01(\x0c\x12\x18\n\x0bpublic_note\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1aJ\n\x0b\x46reezeEvent\x12\x11\n\tfrozen_id\x18\x01 \x01(\x0c\x12\x18\n\x0bpublic_note\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1aL\n\rUnfreezeEvent\x12\x11\n\tfrozen_id\x18\x01 \x01(\x0c\x12\x18\n\x0bpublic_note\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1a\x66\n\x17\x44\x65stroyFrozenFundsEvent\x12\x11\n\tfrozen_id\x18\x01 \x01(\x0c\x12\x0e\n\x06\x61mount\x18\x02 \x01(\x04\x12\x18\n\x0bpublic_note\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1a\x64\n\x13SharedEncryptedNote\x12\x18\n\x10sender_key_index\x18\x01 \x01(\r\x12\x1b\n\x13recipient_key_index\x18\x02 \x01(\r\x12\x16\n\x0e\x65ncrypted_data\x18\x03 \x01(\x0c\x1a{\n\x15PersonalEncryptedNote\x12!\n\x19root_encryption_key_index\x18\x01 \x01(\r\x12\'\n\x1f\x64\x65rivation_encryption_key_index\x18\x02 \x01(\r\x12\x16\n\x0e\x65ncrypted_data\x18\x03 \x01(\x0c\x1a\xe9\x01\n\x14\x45mergencyActionEvent\x12\x81\x01\n\x0b\x61\x63tion_type\x18\x01 \x01(\x0e\x32l.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.EmergencyActionEvent.ActionType\x12\x18\n\x0bpublic_note\x18\x02 \x01(\tH\x00\x88\x01\x01\"#\n\nActionType\x12\t\n\x05PAUSE\x10\x00\x12\n\n\x06RESUME\x10\x01\x42\x0e\n\x0c_public_note\x1a\x64\n\x16TokenConfigUpdateEvent\x12 \n\x18token_config_update_item\x18\x01 \x01(\x0c\x12\x18\n\x0bpublic_note\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1a\xe6\x03\n\x1eUpdateDirectPurchasePriceEvent\x12\x15\n\x0b\x66ixed_price\x18\x01 \x01(\x04H\x00\x12\x95\x01\n\x0evariable_price\x18\x02 \x01(\x0b\x32{.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.UpdateDirectPurchasePriceEvent.PricingScheduleH\x00\x12\x18\n\x0bpublic_note\x18\x03 \x01(\tH\x01\x88\x01\x01\x1a\x33\n\x10PriceForQuantity\x12\x10\n\x08quantity\x18\x01 \x01(\x04\x12\r\n\x05price\x18\x02 \x01(\x04\x1a\xac\x01\n\x0fPricingSchedule\x12\x98\x01\n\x12price_for_quantity\x18\x01 \x03(\x0b\x32|.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.UpdateDirectPurchasePriceEvent.PriceForQuantityB\x07\n\x05priceB\x0e\n\x0c_public_note\x1a\xfc\x02\n\x10GroupActionEvent\x12n\n\x0btoken_event\x18\x01 \x01(\x0b\x32W.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.TokenEventH\x00\x12t\n\x0e\x64ocument_event\x18\x02 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.DocumentEventH\x00\x12t\n\x0e\x63ontract_event\x18\x03 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.ContractEventH\x00\x42\x0c\n\nevent_type\x1a\x8b\x01\n\rDocumentEvent\x12r\n\x06\x63reate\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.DocumentCreateEventH\x00\x42\x06\n\x04type\x1a/\n\x13\x44ocumentCreateEvent\x12\x18\n\x10\x63reated_document\x18\x01 \x01(\x0c\x1a/\n\x13\x43ontractUpdateEvent\x12\x18\n\x10updated_contract\x18\x01 \x01(\x0c\x1a\x8b\x01\n\rContractEvent\x12r\n\x06update\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.ContractUpdateEventH\x00\x42\x06\n\x04type\x1a\xd1\x07\n\nTokenEvent\x12\x66\n\x04mint\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.MintEventH\x00\x12\x66\n\x04\x62urn\x18\x02 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.BurnEventH\x00\x12j\n\x06\x66reeze\x18\x03 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.FreezeEventH\x00\x12n\n\x08unfreeze\x18\x04 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.UnfreezeEventH\x00\x12\x84\x01\n\x14\x64\x65stroy_frozen_funds\x18\x05 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.DestroyFrozenFundsEventH\x00\x12}\n\x10\x65mergency_action\x18\x06 \x01(\x0b\x32\x61.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.EmergencyActionEventH\x00\x12\x82\x01\n\x13token_config_update\x18\x07 \x01(\x0b\x32\x63.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.TokenConfigUpdateEventH\x00\x12\x83\x01\n\x0cupdate_price\x18\x08 \x01(\x0b\x32k.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.UpdateDirectPurchasePriceEventH\x00\x42\x06\n\x04type\x1a\x93\x01\n\x10GroupActionEntry\x12\x11\n\taction_id\x18\x01 \x01(\x0c\x12l\n\x05\x65vent\x18\x02 \x01(\x0b\x32].org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.GroupActionEvent\x1a\x84\x01\n\x0cGroupActions\x12t\n\rgroup_actions\x18\x01 \x03(\x0b\x32].org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.GroupActionEntryB\x08\n\x06resultB\t\n\x07version\"\x88\x03\n\x1cGetGroupActionSignersRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetGroupActionSignersRequest.GetGroupActionSignersRequestV0H\x00\x1a\xce\x01\n\x1eGetGroupActionSignersRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1f\n\x17group_contract_position\x18\x02 \x01(\r\x12T\n\x06status\x18\x03 \x01(\x0e\x32\x44.org.dash.platform.dapi.v0.GetGroupActionSignersRequest.ActionStatus\x12\x11\n\taction_id\x18\x04 \x01(\x0c\x12\r\n\x05prove\x18\x05 \x01(\x08\"&\n\x0c\x41\x63tionStatus\x12\n\n\x06\x41\x43TIVE\x10\x00\x12\n\n\x06\x43LOSED\x10\x01\x42\t\n\x07version\"\x8b\x05\n\x1dGetGroupActionSignersResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetGroupActionSignersResponse.GetGroupActionSignersResponseV0H\x00\x1a\xf6\x03\n\x1fGetGroupActionSignersResponseV0\x12\x8b\x01\n\x14group_action_signers\x18\x01 \x01(\x0b\x32k.org.dash.platform.dapi.v0.GetGroupActionSignersResponse.GetGroupActionSignersResponseV0.GroupActionSignersH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x35\n\x11GroupActionSigner\x12\x11\n\tsigner_id\x18\x01 \x01(\x0c\x12\r\n\x05power\x18\x02 \x01(\r\x1a\x91\x01\n\x12GroupActionSigners\x12{\n\x07signers\x18\x01 \x03(\x0b\x32j.org.dash.platform.dapi.v0.GetGroupActionSignersResponse.GetGroupActionSignersResponseV0.GroupActionSignerB\x08\n\x06resultB\t\n\x07version\"\xb5\x01\n\x15GetAddressInfoRequest\x12V\n\x02v0\x18\x01 \x01(\x0b\x32H.org.dash.platform.dapi.v0.GetAddressInfoRequest.GetAddressInfoRequestV0H\x00\x1a\x39\n\x17GetAddressInfoRequestV0\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x85\x01\n\x10\x41\x64\x64ressInfoEntry\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12J\n\x11\x62\x61lance_and_nonce\x18\x02 \x01(\x0b\x32*.org.dash.platform.dapi.v0.BalanceAndNonceH\x00\x88\x01\x01\x42\x14\n\x12_balance_and_nonce\"1\n\x0f\x42\x61lanceAndNonce\x12\x0f\n\x07\x62\x61lance\x18\x01 \x01(\x04\x12\r\n\x05nonce\x18\x02 \x01(\r\"_\n\x12\x41\x64\x64ressInfoEntries\x12I\n\x14\x61\x64\x64ress_info_entries\x18\x01 \x03(\x0b\x32+.org.dash.platform.dapi.v0.AddressInfoEntry\"m\n\x14\x41\x64\x64ressBalanceChange\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12\x19\n\x0bset_balance\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x12\x1c\n\x0e\x61\x64\x64_to_balance\x18\x03 \x01(\x04\x42\x02\x30\x01H\x00\x42\x0b\n\toperation\"x\n\x1a\x42lockAddressBalanceChanges\x12\x18\n\x0c\x62lock_height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12@\n\x07\x63hanges\x18\x02 \x03(\x0b\x32/.org.dash.platform.dapi.v0.AddressBalanceChange\"k\n\x1b\x41\x64\x64ressBalanceUpdateEntries\x12L\n\rblock_changes\x18\x01 \x03(\x0b\x32\x35.org.dash.platform.dapi.v0.BlockAddressBalanceChanges\"\xe1\x02\n\x16GetAddressInfoResponse\x12X\n\x02v0\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetAddressInfoResponse.GetAddressInfoResponseV0H\x00\x1a\xe1\x01\n\x18GetAddressInfoResponseV0\x12I\n\x12\x61\x64\x64ress_info_entry\x18\x01 \x01(\x0b\x32+.org.dash.platform.dapi.v0.AddressInfoEntryH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc3\x01\n\x18GetAddressesInfosRequest\x12\\\n\x02v0\x18\x01 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetAddressesInfosRequest.GetAddressesInfosRequestV0H\x00\x1a>\n\x1aGetAddressesInfosRequestV0\x12\x11\n\taddresses\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xf1\x02\n\x19GetAddressesInfosResponse\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetAddressesInfosResponse.GetAddressesInfosResponseV0H\x00\x1a\xe8\x01\n\x1bGetAddressesInfosResponseV0\x12M\n\x14\x61\x64\x64ress_info_entries\x18\x01 \x01(\x0b\x32-.org.dash.platform.dapi.v0.AddressInfoEntriesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xb5\x01\n\x1dGetAddressesTrunkStateRequest\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetAddressesTrunkStateRequest.GetAddressesTrunkStateRequestV0H\x00\x1a!\n\x1fGetAddressesTrunkStateRequestV0B\t\n\x07version\"\xaa\x02\n\x1eGetAddressesTrunkStateResponse\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetAddressesTrunkStateResponse.GetAddressesTrunkStateResponseV0H\x00\x1a\x92\x01\n GetAddressesTrunkStateResponseV0\x12/\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.Proof\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\t\n\x07version\"\xf0\x01\n\x1eGetAddressesBranchStateRequest\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetAddressesBranchStateRequest.GetAddressesBranchStateRequestV0H\x00\x1aY\n GetAddressesBranchStateRequestV0\x12\x0b\n\x03key\x18\x01 \x01(\x0c\x12\r\n\x05\x64\x65pth\x18\x02 \x01(\r\x12\x19\n\x11\x63heckpoint_height\x18\x03 \x01(\x04\x42\t\n\x07version\"\xd1\x01\n\x1fGetAddressesBranchStateResponse\x12j\n\x02v0\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetAddressesBranchStateResponse.GetAddressesBranchStateResponseV0H\x00\x1a\x37\n!GetAddressesBranchStateResponseV0\x12\x12\n\nmerk_proof\x18\x02 \x01(\x0c\x42\t\n\x07version\"\x9e\x02\n%GetRecentAddressBalanceChangesRequest\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetRecentAddressBalanceChangesRequest.GetRecentAddressBalanceChangesRequestV0H\x00\x1ar\n\'GetRecentAddressBalanceChangesRequestV0\x12\x18\n\x0cstart_height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05prove\x18\x02 \x01(\x08\x12\x1e\n\x16start_height_exclusive\x18\x03 \x01(\x08\x42\t\n\x07version\"\xb8\x03\n&GetRecentAddressBalanceChangesResponse\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetRecentAddressBalanceChangesResponse.GetRecentAddressBalanceChangesResponseV0H\x00\x1a\x88\x02\n(GetRecentAddressBalanceChangesResponseV0\x12`\n\x1e\x61\x64\x64ress_balance_update_entries\x18\x01 \x01(\x0b\x32\x36.org.dash.platform.dapi.v0.AddressBalanceUpdateEntriesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"G\n\x16\x42lockHeightCreditEntry\x12\x18\n\x0c\x62lock_height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x13\n\x07\x63redits\x18\x02 \x01(\x04\x42\x02\x30\x01\"\xb0\x01\n\x1d\x43ompactedAddressBalanceChange\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12\x19\n\x0bset_credits\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x12V\n\x19\x61\x64\x64_to_credits_operations\x18\x03 \x01(\x0b\x32\x31.org.dash.platform.dapi.v0.AddToCreditsOperationsH\x00\x42\x0b\n\toperation\"\\\n\x16\x41\x64\x64ToCreditsOperations\x12\x42\n\x07\x65ntries\x18\x01 \x03(\x0b\x32\x31.org.dash.platform.dapi.v0.BlockHeightCreditEntry\"\xae\x01\n#CompactedBlockAddressBalanceChanges\x12\x1e\n\x12start_block_height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x1c\n\x10\x65nd_block_height\x18\x02 \x01(\x04\x42\x02\x30\x01\x12I\n\x07\x63hanges\x18\x03 \x03(\x0b\x32\x38.org.dash.platform.dapi.v0.CompactedAddressBalanceChange\"\x87\x01\n$CompactedAddressBalanceUpdateEntries\x12_\n\x17\x63ompacted_block_changes\x18\x01 \x03(\x0b\x32>.org.dash.platform.dapi.v0.CompactedBlockAddressBalanceChanges\"\xa9\x02\n.GetRecentCompactedAddressBalanceChangesRequest\x12\x88\x01\n\x02v0\x18\x01 \x01(\x0b\x32z.org.dash.platform.dapi.v0.GetRecentCompactedAddressBalanceChangesRequest.GetRecentCompactedAddressBalanceChangesRequestV0H\x00\x1a\x61\n0GetRecentCompactedAddressBalanceChangesRequestV0\x12\x1e\n\x12start_block_height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xf0\x03\n/GetRecentCompactedAddressBalanceChangesResponse\x12\x8a\x01\n\x02v0\x18\x01 \x01(\x0b\x32|.org.dash.platform.dapi.v0.GetRecentCompactedAddressBalanceChangesResponse.GetRecentCompactedAddressBalanceChangesResponseV0H\x00\x1a\xa4\x02\n1GetRecentCompactedAddressBalanceChangesResponseV0\x12s\n(compacted_address_balance_update_entries\x18\x01 \x01(\x0b\x32?.org.dash.platform.dapi.v0.CompactedAddressBalanceUpdateEntriesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xf4\x01\n GetShieldedEncryptedNotesRequest\x12l\n\x02v0\x18\x01 \x01(\x0b\x32^.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesRequest.GetShieldedEncryptedNotesRequestV0H\x00\x1aW\n\"GetShieldedEncryptedNotesRequestV0\x12\x13\n\x0bstart_index\x18\x01 \x01(\x04\x12\r\n\x05\x63ount\x18\x02 \x01(\r\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xbc\x05\n!GetShieldedEncryptedNotesResponse\x12n\n\x02v0\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesResponse.GetShieldedEncryptedNotesResponseV0H\x00\x1a\x9b\x04\n#GetShieldedEncryptedNotesResponseV0\x12\x8a\x01\n\x0f\x65ncrypted_notes\x18\x01 \x01(\x0b\x32o.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesResponse.GetShieldedEncryptedNotesResponseV0.EncryptedNotesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aW\n\rEncryptedNote\x12\x11\n\tnullifier\x18\x01 \x01(\x0c\x12\x0b\n\x03\x63mx\x18\x02 \x01(\x0c\x12\x16\n\x0e\x65ncrypted_note\x18\x03 \x01(\x0c\x12\x0e\n\x06\x63v_net\x18\x04 \x01(\x0c\x1a\x91\x01\n\x0e\x45ncryptedNotes\x12\x7f\n\x07\x65ntries\x18\x01 \x03(\x0b\x32n.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesResponse.GetShieldedEncryptedNotesResponseV0.EncryptedNoteB\x08\n\x06resultB\t\n\x07version\"\xb4\x01\n\x19GetShieldedAnchorsRequest\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetShieldedAnchorsRequest.GetShieldedAnchorsRequestV0H\x00\x1a,\n\x1bGetShieldedAnchorsRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xb1\x03\n\x1aGetShieldedAnchorsResponse\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetShieldedAnchorsResponse.GetShieldedAnchorsResponseV0H\x00\x1a\xa5\x02\n\x1cGetShieldedAnchorsResponseV0\x12m\n\x07\x61nchors\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetShieldedAnchorsResponse.GetShieldedAnchorsResponseV0.AnchorsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x1a\n\x07\x41nchors\x12\x0f\n\x07\x61nchors\x18\x01 \x03(\x0c\x42\x08\n\x06resultB\t\n\x07version\"\xd8\x01\n\"GetMostRecentShieldedAnchorRequest\x12p\n\x02v0\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetMostRecentShieldedAnchorRequest.GetMostRecentShieldedAnchorRequestV0H\x00\x1a\x35\n$GetMostRecentShieldedAnchorRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xdc\x02\n#GetMostRecentShieldedAnchorResponse\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetMostRecentShieldedAnchorResponse.GetMostRecentShieldedAnchorResponseV0H\x00\x1a\xb5\x01\n%GetMostRecentShieldedAnchorResponseV0\x12\x10\n\x06\x61nchor\x18\x01 \x01(\x0cH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xbc\x01\n\x1bGetShieldedPoolStateRequest\x12\x62\n\x02v0\x18\x01 \x01(\x0b\x32T.org.dash.platform.dapi.v0.GetShieldedPoolStateRequest.GetShieldedPoolStateRequestV0H\x00\x1a.\n\x1dGetShieldedPoolStateRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xcb\x02\n\x1cGetShieldedPoolStateResponse\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetShieldedPoolStateResponse.GetShieldedPoolStateResponseV0H\x00\x1a\xb9\x01\n\x1eGetShieldedPoolStateResponseV0\x12\x1b\n\rtotal_balance\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc0\x01\n\x1cGetShieldedNotesCountRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetShieldedNotesCountRequest.GetShieldedNotesCountRequestV0H\x00\x1a/\n\x1eGetShieldedNotesCountRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xd3\x02\n\x1dGetShieldedNotesCountResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetShieldedNotesCountResponse.GetShieldedNotesCountResponseV0H\x00\x1a\xbe\x01\n\x1fGetShieldedNotesCountResponseV0\x12\x1f\n\x11total_notes_count\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xd4\x01\n\x1cGetShieldedNullifiersRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetShieldedNullifiersRequest.GetShieldedNullifiersRequestV0H\x00\x1a\x43\n\x1eGetShieldedNullifiersRequestV0\x12\x12\n\nnullifiers\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x86\x05\n\x1dGetShieldedNullifiersResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetShieldedNullifiersResponse.GetShieldedNullifiersResponseV0H\x00\x1a\xf1\x03\n\x1fGetShieldedNullifiersResponseV0\x12\x88\x01\n\x12nullifier_statuses\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetShieldedNullifiersResponse.GetShieldedNullifiersResponseV0.NullifierStatusesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x36\n\x0fNullifierStatus\x12\x11\n\tnullifier\x18\x01 \x01(\x0c\x12\x10\n\x08is_spent\x18\x02 \x01(\x08\x1a\x8e\x01\n\x11NullifierStatuses\x12y\n\x07\x65ntries\x18\x01 \x03(\x0b\x32h.org.dash.platform.dapi.v0.GetShieldedNullifiersResponse.GetShieldedNullifiersResponseV0.NullifierStatusB\x08\n\x06resultB\t\n\x07version*Z\n\nKeyPurpose\x12\x12\n\x0e\x41UTHENTICATION\x10\x00\x12\x0e\n\nENCRYPTION\x10\x01\x12\x0e\n\nDECRYPTION\x10\x02\x12\x0c\n\x08TRANSFER\x10\x03\x12\n\n\x06VOTING\x10\x05*\xb9\x01\n\x16\x43ontractModerationList\x12(\n$CONTRACT_MODERATION_LIST_UNSPECIFIED\x10\x00\x12$\n CONTRACT_MODERATION_LIST_BANLIST\x10\x01\x12(\n$CONTRACT_MODERATION_LIST_SUSPENSIONS\x10\x02\x12%\n!CONTRACT_MODERATION_LIST_WARNINGS\x10\x03\x32\xd0P\n\x08Platform\x12\x93\x01\n\x18\x62roadcastStateTransition\x12:.org.dash.platform.dapi.v0.BroadcastStateTransitionRequest\x1a;.org.dash.platform.dapi.v0.BroadcastStateTransitionResponse\x12l\n\x0bgetIdentity\x12-.org.dash.platform.dapi.v0.GetIdentityRequest\x1a..org.dash.platform.dapi.v0.GetIdentityResponse\x12x\n\x0fgetIdentityKeys\x12\x31.org.dash.platform.dapi.v0.GetIdentityKeysRequest\x1a\x32.org.dash.platform.dapi.v0.GetIdentityKeysResponse\x12\x96\x01\n\x19getIdentitiesContractKeys\x12;.org.dash.platform.dapi.v0.GetIdentitiesContractKeysRequest\x1a<.org.dash.platform.dapi.v0.GetIdentitiesContractKeysResponse\x12{\n\x10getIdentityNonce\x12\x32.org.dash.platform.dapi.v0.GetIdentityNonceRequest\x1a\x33.org.dash.platform.dapi.v0.GetIdentityNonceResponse\x12\x93\x01\n\x18getIdentityContractNonce\x12:.org.dash.platform.dapi.v0.GetIdentityContractNonceRequest\x1a;.org.dash.platform.dapi.v0.GetIdentityContractNonceResponse\x12\xa8\x01\n\x1fgetIdentityKeysRemainingBudgets\x12\x41.org.dash.platform.dapi.v0.GetIdentityKeysRemainingBudgetsRequest\x1a\x42.org.dash.platform.dapi.v0.GetIdentityKeysRemainingBudgetsResponse\x12\x81\x01\n\x12getIdentityBalance\x12\x34.org.dash.platform.dapi.v0.GetIdentityBalanceRequest\x1a\x35.org.dash.platform.dapi.v0.GetIdentityBalanceResponse\x12\x8a\x01\n\x15getIdentitiesBalances\x12\x37.org.dash.platform.dapi.v0.GetIdentitiesBalancesRequest\x1a\x38.org.dash.platform.dapi.v0.GetIdentitiesBalancesResponse\x12\xa2\x01\n\x1dgetIdentityBalanceAndRevision\x12?.org.dash.platform.dapi.v0.GetIdentityBalanceAndRevisionRequest\x1a@.org.dash.platform.dapi.v0.GetIdentityBalanceAndRevisionResponse\x12\xaf\x01\n#getEvonodesProposedEpochBlocksByIds\x12\x45.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksByIdsRequest\x1a\x41.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksResponse\x12\xb3\x01\n%getEvonodesProposedEpochBlocksByRange\x12G.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksByRangeRequest\x1a\x41.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksResponse\x12x\n\x0fgetDataContract\x12\x31.org.dash.platform.dapi.v0.GetDataContractRequest\x1a\x32.org.dash.platform.dapi.v0.GetDataContractResponse\x12\x8d\x01\n\x16getDataContractHistory\x12\x38.org.dash.platform.dapi.v0.GetDataContractHistoryRequest\x1a\x39.org.dash.platform.dapi.v0.GetDataContractHistoryResponse\x12\xa5\x01\n\x1egetDataContractsLatestVersions\x12@.org.dash.platform.dapi.v0.GetDataContractsLatestVersionsRequest\x1a\x41.org.dash.platform.dapi.v0.GetDataContractsLatestVersionsResponse\x12{\n\x10getDataContracts\x12\x32.org.dash.platform.dapi.v0.GetDataContractsRequest\x1a\x33.org.dash.platform.dapi.v0.GetDataContractsResponse\x12\x89\x01\n\x17getDataContractsByRange\x12\x39.org.dash.platform.dapi.v0.GetDataContractsByRangeRequest\x1a\x33.org.dash.platform.dapi.v0.GetDataContractsResponse\x12\x87\x01\n\x14getContractGroupInfo\x12\x36.org.dash.platform.dapi.v0.GetContractGroupInfoRequest\x1a\x37.org.dash.platform.dapi.v0.GetContractGroupInfoResponse\x12\x90\x01\n\x17getContractGroupMembers\x12\x39.org.dash.platform.dapi.v0.GetContractGroupMembersRequest\x1a:.org.dash.platform.dapi.v0.GetContractGroupMembersResponse\x12\x9f\x01\n\x1cgetContractGroupsForContract\x12>.org.dash.platform.dapi.v0.GetContractGroupsForContractRequest\x1a?.org.dash.platform.dapi.v0.GetContractGroupsForContractResponse\x12\x9c\x01\n\x1bgetContractModerationStatus\x12=.org.dash.platform.dapi.v0.GetContractModerationStatusRequest\x1a>.org.dash.platform.dapi.v0.GetContractModerationStatusResponse\x12\x9f\x01\n\x1cgetContractModerationEntries\x12>.org.dash.platform.dapi.v0.GetContractModerationEntriesRequest\x1a?.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse\x12\x9c\x01\n\x1bgetContractDocumentRemovals\x12=.org.dash.platform.dapi.v0.GetContractDocumentRemovalsRequest\x1a>.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse\x12\x81\x01\n\x12getContractFeePots\x12\x34.org.dash.platform.dapi.v0.GetContractFeePotsRequest\x1a\x35.org.dash.platform.dapi.v0.GetContractFeePotsResponse\x12\x81\x01\n\x12getDocumentHistory\x12\x34.org.dash.platform.dapi.v0.GetDocumentHistoryRequest\x1a\x35.org.dash.platform.dapi.v0.GetDocumentHistoryResponse\x12o\n\x0cgetDocuments\x12..org.dash.platform.dapi.v0.GetDocumentsRequest\x1a/.org.dash.platform.dapi.v0.GetDocumentsResponse\x12\x99\x01\n\x1agetIdentityByPublicKeyHash\x12<.org.dash.platform.dapi.v0.GetIdentityByPublicKeyHashRequest\x1a=.org.dash.platform.dapi.v0.GetIdentityByPublicKeyHashResponse\x12\xb4\x01\n#getIdentityByNonUniquePublicKeyHash\x12\x45.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashRequest\x1a\x46.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashResponse\x12\x9f\x01\n\x1cwaitForStateTransitionResult\x12>.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest\x1a?.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse\x12\x81\x01\n\x12getConsensusParams\x12\x34.org.dash.platform.dapi.v0.GetConsensusParamsRequest\x1a\x35.org.dash.platform.dapi.v0.GetConsensusParamsResponse\x12\xa5\x01\n\x1egetProtocolVersionUpgradeState\x12@.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateRequest\x1a\x41.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateResponse\x12\xb4\x01\n#getProtocolVersionUpgradeVoteStatus\x12\x45.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusRequest\x1a\x46.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusResponse\x12r\n\rgetEpochsInfo\x12/.org.dash.platform.dapi.v0.GetEpochsInfoRequest\x1a\x30.org.dash.platform.dapi.v0.GetEpochsInfoResponse\x12\x8d\x01\n\x16getFinalizedEpochInfos\x12\x38.org.dash.platform.dapi.v0.GetFinalizedEpochInfosRequest\x1a\x39.org.dash.platform.dapi.v0.GetFinalizedEpochInfosResponse\x12\x8a\x01\n\x15getContestedResources\x12\x37.org.dash.platform.dapi.v0.GetContestedResourcesRequest\x1a\x38.org.dash.platform.dapi.v0.GetContestedResourcesResponse\x12\xa2\x01\n\x1dgetContestedResourceVoteState\x12?.org.dash.platform.dapi.v0.GetContestedResourceVoteStateRequest\x1a@.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse\x12\xba\x01\n%getContestedResourceVotersForIdentity\x12G.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityRequest\x1aH.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityResponse\x12\xae\x01\n!getContestedResourceIdentityVotes\x12\x43.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesRequest\x1a\x44.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse\x12\x8a\x01\n\x15getVotePollsByEndDate\x12\x37.org.dash.platform.dapi.v0.GetVotePollsByEndDateRequest\x1a\x38.org.dash.platform.dapi.v0.GetVotePollsByEndDateResponse\x12\xa5\x01\n\x1egetPrefundedSpecializedBalance\x12@.org.dash.platform.dapi.v0.GetPrefundedSpecializedBalanceRequest\x1a\x41.org.dash.platform.dapi.v0.GetPrefundedSpecializedBalanceResponse\x12\x96\x01\n\x19getTotalCreditsInPlatform\x12;.org.dash.platform.dapi.v0.GetTotalCreditsInPlatformRequest\x1a<.org.dash.platform.dapi.v0.GetTotalCreditsInPlatformResponse\x12x\n\x0fgetPathElements\x12\x31.org.dash.platform.dapi.v0.GetPathElementsRequest\x1a\x32.org.dash.platform.dapi.v0.GetPathElementsResponse\x12\x66\n\tgetStatus\x12+.org.dash.platform.dapi.v0.GetStatusRequest\x1a,.org.dash.platform.dapi.v0.GetStatusResponse\x12\x8a\x01\n\x15getCurrentQuorumsInfo\x12\x37.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoRequest\x1a\x38.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoResponse\x12\x93\x01\n\x18getIdentityTokenBalances\x12:.org.dash.platform.dapi.v0.GetIdentityTokenBalancesRequest\x1a;.org.dash.platform.dapi.v0.GetIdentityTokenBalancesResponse\x12\x99\x01\n\x1agetIdentitiesTokenBalances\x12<.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesRequest\x1a=.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesResponse\x12\x8a\x01\n\x15getIdentityTokenInfos\x12\x37.org.dash.platform.dapi.v0.GetIdentityTokenInfosRequest\x1a\x38.org.dash.platform.dapi.v0.GetIdentityTokenInfosResponse\x12\x90\x01\n\x17getIdentitiesTokenInfos\x12\x39.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosRequest\x1a:.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosResponse\x12{\n\x10getTokenStatuses\x12\x32.org.dash.platform.dapi.v0.GetTokenStatusesRequest\x1a\x33.org.dash.platform.dapi.v0.GetTokenStatusesResponse\x12\x9f\x01\n\x1cgetTokenDirectPurchasePrices\x12>.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesRequest\x1a?.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse\x12\x87\x01\n\x14getTokenContractInfo\x12\x36.org.dash.platform.dapi.v0.GetTokenContractInfoRequest\x1a\x37.org.dash.platform.dapi.v0.GetTokenContractInfoResponse\x12\xb1\x01\n\"getTokenPreProgrammedDistributions\x12\x44.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsRequest\x1a\x45.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsResponse\x12\xbd\x01\n&getTokenPerpetualDistributionLastClaim\x12H.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimRequest\x1aI.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimResponse\x12\x84\x01\n\x13getTokenTotalSupply\x12\x35.org.dash.platform.dapi.v0.GetTokenTotalSupplyRequest\x1a\x36.org.dash.platform.dapi.v0.GetTokenTotalSupplyResponse\x12o\n\x0cgetGroupInfo\x12..org.dash.platform.dapi.v0.GetGroupInfoRequest\x1a/.org.dash.platform.dapi.v0.GetGroupInfoResponse\x12r\n\rgetGroupInfos\x12/.org.dash.platform.dapi.v0.GetGroupInfosRequest\x1a\x30.org.dash.platform.dapi.v0.GetGroupInfosResponse\x12x\n\x0fgetGroupActions\x12\x31.org.dash.platform.dapi.v0.GetGroupActionsRequest\x1a\x32.org.dash.platform.dapi.v0.GetGroupActionsResponse\x12\x8a\x01\n\x15getGroupActionSigners\x12\x37.org.dash.platform.dapi.v0.GetGroupActionSignersRequest\x1a\x38.org.dash.platform.dapi.v0.GetGroupActionSignersResponse\x12u\n\x0egetAddressInfo\x12\x30.org.dash.platform.dapi.v0.GetAddressInfoRequest\x1a\x31.org.dash.platform.dapi.v0.GetAddressInfoResponse\x12~\n\x11getAddressesInfos\x12\x33.org.dash.platform.dapi.v0.GetAddressesInfosRequest\x1a\x34.org.dash.platform.dapi.v0.GetAddressesInfosResponse\x12\x8d\x01\n\x16getAddressesTrunkState\x12\x38.org.dash.platform.dapi.v0.GetAddressesTrunkStateRequest\x1a\x39.org.dash.platform.dapi.v0.GetAddressesTrunkStateResponse\x12\x90\x01\n\x17getAddressesBranchState\x12\x39.org.dash.platform.dapi.v0.GetAddressesBranchStateRequest\x1a:.org.dash.platform.dapi.v0.GetAddressesBranchStateResponse\x12\xa5\x01\n\x1egetRecentAddressBalanceChanges\x12@.org.dash.platform.dapi.v0.GetRecentAddressBalanceChangesRequest\x1a\x41.org.dash.platform.dapi.v0.GetRecentAddressBalanceChangesResponse\x12\xc0\x01\n\'getRecentCompactedAddressBalanceChanges\x12I.org.dash.platform.dapi.v0.GetRecentCompactedAddressBalanceChangesRequest\x1aJ.org.dash.platform.dapi.v0.GetRecentCompactedAddressBalanceChangesResponse\x12\x96\x01\n\x19getShieldedEncryptedNotes\x12;.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesRequest\x1a<.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesResponse\x12\x81\x01\n\x12getShieldedAnchors\x12\x34.org.dash.platform.dapi.v0.GetShieldedAnchorsRequest\x1a\x35.org.dash.platform.dapi.v0.GetShieldedAnchorsResponse\x12\x9c\x01\n\x1bgetMostRecentShieldedAnchor\x12=.org.dash.platform.dapi.v0.GetMostRecentShieldedAnchorRequest\x1a>.org.dash.platform.dapi.v0.GetMostRecentShieldedAnchorResponse\x12\x87\x01\n\x14getShieldedPoolState\x12\x36.org.dash.platform.dapi.v0.GetShieldedPoolStateRequest\x1a\x37.org.dash.platform.dapi.v0.GetShieldedPoolStateResponse\x12\x8a\x01\n\x15getShieldedNotesCount\x12\x37.org.dash.platform.dapi.v0.GetShieldedNotesCountRequest\x1a\x38.org.dash.platform.dapi.v0.GetShieldedNotesCountResponse\x12\x8a\x01\n\x15getShieldedNullifiers\x12\x37.org.dash.platform.dapi.v0.GetShieldedNullifiersRequest\x1a\x38.org.dash.platform.dapi.v0.GetShieldedNullifiersResponseb\x06proto3' , dependencies=[google_dot_protobuf_dot_wrappers__pb2.DESCRIPTOR,google_dot_protobuf_dot_struct__pb2.DESCRIPTOR,google_dot_protobuf_dot_timestamp__pb2.DESCRIPTOR,]) @@ -62,8 +62,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=78708, - serialized_end=78798, + serialized_start=79303, + serialized_end=79393, ) _sym_db.RegisterEnumDescriptor(_KEYPURPOSE) @@ -90,11 +90,16 @@ serialized_options=None, type=None, create_key=_descriptor._internal_create_key), + _descriptor.EnumValueDescriptor( + name='CONTRACT_MODERATION_LIST_WARNINGS', index=3, number=3, + serialized_options=None, + type=None, + create_key=_descriptor._internal_create_key), ], containing_type=None, serialized_options=None, - serialized_start=78801, - serialized_end=78947, + serialized_start=79396, + serialized_end=79581, ) _sym_db.RegisterEnumDescriptor(_CONTRACTMODERATIONLIST) @@ -107,6 +112,7 @@ CONTRACT_MODERATION_LIST_UNSPECIFIED = 0 CONTRACT_MODERATION_LIST_BANLIST = 1 CONTRACT_MODERATION_LIST_SUSPENSIONS = 2 +CONTRACT_MODERATION_LIST_WARNINGS = 3 _SECURITYLEVELMAP_KEYKINDREQUESTTYPE = _descriptor.EnumDescriptor( @@ -159,8 +165,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=22633, - serialized_end=22681, + serialized_start=23228, + serialized_end=23276, ) _sym_db.RegisterEnumDescriptor(_GETDOCUMENTSREQUEST_TIMERANGESELECTION_SELECTOR) @@ -189,8 +195,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=23102, - serialized_end=23141, + serialized_start=23697, + serialized_end=23736, ) _sym_db.RegisterEnumDescriptor(_GETDOCUMENTSREQUEST_HAVINGAGGREGATE_FUNCTION) @@ -259,8 +265,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=23416, - serialized_end=23640, + serialized_start=24011, + serialized_end=24235, ) _sym_db.RegisterEnumDescriptor(_GETDOCUMENTSREQUEST_HAVINGCLAUSE_OPERATOR) @@ -304,8 +310,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=24841, - serialized_end=24913, + serialized_start=25436, + serialized_end=25508, ) _sym_db.RegisterEnumDescriptor(_GETDOCUMENTSREQUEST_GETDOCUMENTSREQUESTV1_SELECT_FUNCTION) @@ -329,8 +335,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=25491, - serialized_end=25523, + serialized_start=26086, + serialized_end=26118, ) _sym_db.RegisterEnumDescriptor(_GETDOCUMENTSREQUEST_GETDOCUMENTSREQUESTV1_SUBQUERY_KIND) @@ -404,8 +410,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=25566, - serialized_end=25816, + serialized_start=26161, + serialized_end=26411, ) _sym_db.RegisterEnumDescriptor(_GETDOCUMENTSREQUEST_WHEREOPERATOR) @@ -434,8 +440,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=41418, - serialized_end=41491, + serialized_start=42013, + serialized_end=42086, ) _sym_db.RegisterEnumDescriptor(_GETCONTESTEDRESOURCEVOTESTATEREQUEST_GETCONTESTEDRESOURCEVOTESTATEREQUESTV0_RESULTTYPE) @@ -464,8 +470,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=42413, - serialized_end=42492, + serialized_start=43008, + serialized_end=43087, ) _sym_db.RegisterEnumDescriptor(_GETCONTESTEDRESOURCEVOTESTATERESPONSE_GETCONTESTEDRESOURCEVOTESTATERESPONSEV0_FINISHEDVOTEINFO_FINISHEDVOTEOUTCOME) @@ -494,8 +500,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=46121, - serialized_end=46182, + serialized_start=46716, + serialized_end=46777, ) _sym_db.RegisterEnumDescriptor(_GETCONTESTEDRESOURCEIDENTITYVOTESRESPONSE_GETCONTESTEDRESOURCEIDENTITYVOTESRESPONSEV0_RESOURCEVOTECHOICE_VOTECHOICETYPE) @@ -519,8 +525,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=64746, - serialized_end=64784, + serialized_start=65341, + serialized_end=65379, ) _sym_db.RegisterEnumDescriptor(_GETGROUPACTIONSREQUEST_ACTIONSTATUS) @@ -544,8 +550,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=66031, - serialized_end=66066, + serialized_start=66626, + serialized_end=66661, ) _sym_db.RegisterEnumDescriptor(_GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_EMERGENCYACTIONEVENT_ACTIONTYPE) @@ -569,8 +575,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=64746, - serialized_end=64784, + serialized_start=65341, + serialized_end=65379, ) _sym_db.RegisterEnumDescriptor(_GETGROUPACTIONSIGNERSREQUEST_ACTIONSTATUS) @@ -4392,6 +4398,45 @@ ) +_CONTRACTMODERATIONDOCUMENT = _descriptor.Descriptor( + name='ContractModerationDocument', + full_name='org.dash.platform.dapi.v0.ContractModerationDocument', + filename=None, + file=DESCRIPTOR, + containing_type=None, + create_key=_descriptor._internal_create_key, + fields=[ + _descriptor.FieldDescriptor( + name='document_type_name', full_name='org.dash.platform.dapi.v0.ContractModerationDocument.document_type_name', index=0, + number=1, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=b"".decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='document_id', full_name='org.dash.platform.dapi.v0.ContractModerationDocument.document_id', index=1, + number=2, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=b"", + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=13843, + serialized_end=13920, +) + + _CONTRACTMODERATIONREASON = _descriptor.Descriptor( name='ContractModerationReason', full_name='org.dash.platform.dapi.v0.ContractModerationReason', @@ -4414,6 +4459,13 @@ message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='documents', full_name='org.dash.platform.dapi.v0.ContractModerationReason.documents', index=2, + number=3, type=11, cpp_type=10, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], @@ -4431,8 +4483,47 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=13843, - serialized_end=13911, + serialized_start=13923, + serialized_end=14065, +) + + +_CONTRACTWARNING = _descriptor.Descriptor( + name='ContractWarning', + full_name='org.dash.platform.dapi.v0.ContractWarning', + filename=None, + file=DESCRIPTOR, + containing_type=None, + create_key=_descriptor._internal_create_key, + fields=[ + _descriptor.FieldDescriptor( + name='warned_at', full_name='org.dash.platform.dapi.v0.ContractWarning.warned_at', index=0, + number=1, type=4, cpp_type=4, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='reason', full_name='org.dash.platform.dapi.v0.ContractWarning.reason', index=1, + number=2, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=14067, + serialized_end=14172, ) @@ -4484,8 +4575,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=14067, - serialized_end=14228, + serialized_start=14328, + serialized_end=14489, ) _GETCONTRACTMODERATIONSTATUSREQUEST = _descriptor.Descriptor( @@ -4520,8 +4611,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=13914, - serialized_end=14239, + serialized_start=14175, + serialized_end=14500, ) @@ -4568,6 +4659,13 @@ message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='warnings', full_name='org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractModerationStatus.warnings', index=5, + number=6, type=11, cpp_type=10, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], @@ -4600,8 +4698,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=14398, - serialized_end=14772, + serialized_start=14659, + serialized_end=15095, ) _GETCONTRACTMODERATIONSTATUSRESPONSE_GETCONTRACTMODERATIONSTATUSRESPONSEV0 = _descriptor.Descriptor( @@ -4650,8 +4748,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=14775, - serialized_end=15045, + serialized_start=15098, + serialized_end=15368, ) _GETCONTRACTMODERATIONSTATUSRESPONSE = _descriptor.Descriptor( @@ -4686,8 +4784,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=14242, - serialized_end=15056, + serialized_start=14503, + serialized_end=15379, ) @@ -4756,8 +4854,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=15215, - serialized_end=15427, + serialized_start=15538, + serialized_end=15750, ) _GETCONTRACTMODERATIONENTRIESREQUEST = _descriptor.Descriptor( @@ -4792,8 +4890,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=15059, - serialized_end=15438, + serialized_start=15382, + serialized_end=15761, ) @@ -4826,6 +4924,13 @@ message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='warnings', full_name='org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry.warnings', index=3, + number=4, type=11, cpp_type=10, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], @@ -4843,8 +4948,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=15600, - serialized_end=15745, + serialized_start=15923, + serialized_end=16130, ) _GETCONTRACTMODERATIONENTRIESRESPONSE_CONTRACTMODERATIONENTRIES = _descriptor.Descriptor( @@ -4874,8 +4979,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=15748, - serialized_end=15881, + serialized_start=16133, + serialized_end=16266, ) _GETCONTRACTMODERATIONENTRIESRESPONSE_GETCONTRACTMODERATIONENTRIESRESPONSEV0 = _descriptor.Descriptor( @@ -4924,8 +5029,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=15884, - serialized_end=16158, + serialized_start=16269, + serialized_end=16543, ) _GETCONTRACTMODERATIONENTRIESRESPONSE = _descriptor.Descriptor( @@ -4960,8 +5065,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=15441, - serialized_end=16169, + serialized_start=15764, + serialized_end=16554, ) @@ -4992,8 +5097,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=16324, - serialized_end=16359, + serialized_start=16709, + serialized_end=16744, ) _GETCONTRACTDOCUMENTREMOVALSREQUEST_PAGE = _descriptor.Descriptor( @@ -5040,8 +5145,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=16361, - serialized_end=16439, + serialized_start=16746, + serialized_end=16824, ) _GETCONTRACTDOCUMENTREMOVALSREQUEST_GETCONTRACTDOCUMENTREMOVALSREQUESTV0 = _descriptor.Descriptor( @@ -5104,8 +5209,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=16442, - serialized_end=16740, + serialized_start=16827, + serialized_end=17125, ) _GETCONTRACTDOCUMENTREMOVALSREQUEST = _descriptor.Descriptor( @@ -5140,11 +5245,49 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=16172, - serialized_end=16751, + serialized_start=16557, + serialized_end=17136, ) +_GETCONTRACTDOCUMENTREMOVALSRESPONSE_CONTRACTDOCUMENTRESTORATION = _descriptor.Descriptor( + name='ContractDocumentRestoration', + full_name='org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration', + filename=None, + file=DESCRIPTOR, + containing_type=None, + create_key=_descriptor._internal_create_key, + fields=[ + _descriptor.FieldDescriptor( + name='moderator_id', full_name='org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.moderator_id', index=0, + number=1, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=b"", + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='restored_at', full_name='org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.restored_at', index=1, + number=2, type=4, cpp_type=4, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=17294, + serialized_end=17366, +) + _GETCONTRACTDOCUMENTREMOVALSRESPONSE_CONTRACTDOCUMENTREMOVAL = _descriptor.Descriptor( name='ContractDocumentRemoval', full_name='org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval', @@ -5188,6 +5331,20 @@ message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='document_hash', full_name='org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval.document_hash', index=5, + number=6, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=b"", + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='restoration', full_name='org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval.restoration', index=6, + number=7, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], @@ -5200,8 +5357,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=16910, - serialized_end=17094, + serialized_start=17369, + serialized_end=17689, ) _GETCONTRACTDOCUMENTREMOVALSRESPONSE_CONTRACTDOCUMENTREMOVALS = _descriptor.Descriptor( @@ -5231,8 +5388,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=17097, - serialized_end=17229, + serialized_start=17692, + serialized_end=17824, ) _GETCONTRACTDOCUMENTREMOVALSRESPONSE_GETCONTRACTDOCUMENTREMOVALSRESPONSEV0 = _descriptor.Descriptor( @@ -5281,8 +5438,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=17232, - serialized_end=17504, + serialized_start=17827, + serialized_end=18099, ) _GETCONTRACTDOCUMENTREMOVALSRESPONSE = _descriptor.Descriptor( @@ -5303,7 +5460,7 @@ ], extensions=[ ], - nested_types=[_GETCONTRACTDOCUMENTREMOVALSRESPONSE_CONTRACTDOCUMENTREMOVAL, _GETCONTRACTDOCUMENTREMOVALSRESPONSE_CONTRACTDOCUMENTREMOVALS, _GETCONTRACTDOCUMENTREMOVALSRESPONSE_GETCONTRACTDOCUMENTREMOVALSRESPONSEV0, ], + nested_types=[_GETCONTRACTDOCUMENTREMOVALSRESPONSE_CONTRACTDOCUMENTRESTORATION, _GETCONTRACTDOCUMENTREMOVALSRESPONSE_CONTRACTDOCUMENTREMOVAL, _GETCONTRACTDOCUMENTREMOVALSRESPONSE_CONTRACTDOCUMENTREMOVALS, _GETCONTRACTDOCUMENTREMOVALSRESPONSE_GETCONTRACTDOCUMENTREMOVALSRESPONSEV0, ], enum_types=[ ], serialized_options=None, @@ -5317,8 +5474,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=16754, - serialized_end=17515, + serialized_start=17139, + serialized_end=18110, ) @@ -5356,8 +5513,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=17643, - serialized_end=17708, + serialized_start=18238, + serialized_end=18303, ) _GETCONTRACTFEEPOTSREQUEST = _descriptor.Descriptor( @@ -5392,8 +5549,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=17518, - serialized_end=17719, + serialized_start=18113, + serialized_end=18314, ) @@ -5438,8 +5595,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=17850, - serialized_end=17932, + serialized_start=18445, + serialized_end=18527, ) _GETCONTRACTFEEPOTSRESPONSE_CONTRACTFEEPOT = _descriptor.Descriptor( @@ -5476,8 +5633,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=17935, - serialized_end=18071, + serialized_start=18530, + serialized_end=18666, ) _GETCONTRACTFEEPOTSRESPONSE_CONTRACTFEEPOTS = _descriptor.Descriptor( @@ -5514,8 +5671,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=18074, - serialized_end=18266, + serialized_start=18669, + serialized_end=18861, ) _GETCONTRACTFEEPOTSRESPONSE_GETCONTRACTFEEPOTSRESPONSEV0 = _descriptor.Descriptor( @@ -5564,8 +5721,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=18269, - serialized_end=18510, + serialized_start=18864, + serialized_end=19105, ) _GETCONTRACTFEEPOTSRESPONSE = _descriptor.Descriptor( @@ -5600,8 +5757,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=17722, - serialized_end=18521, + serialized_start=18317, + serialized_end=19116, ) @@ -5639,8 +5796,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=18679, - serialized_end=18754, + serialized_start=19274, + serialized_end=19349, ) _GETCONTRACTGROUPSFORCONTRACTREQUEST = _descriptor.Descriptor( @@ -5675,8 +5832,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=18524, - serialized_end=18765, + serialized_start=19119, + serialized_end=19360, ) @@ -5714,8 +5871,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=18926, - serialized_end=19007, + serialized_start=19521, + serialized_end=19602, ) _GETCONTRACTGROUPSFORCONTRACTRESPONSE_TOKENMEMBERSHIPS = _descriptor.Descriptor( @@ -5752,8 +5909,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=19009, - serialized_end=19079, + serialized_start=19604, + serialized_end=19674, ) _GETCONTRACTGROUPSFORCONTRACTRESPONSE_CONTRACTGROUPMEMBERSHIPS = _descriptor.Descriptor( @@ -5797,8 +5954,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=19082, - serialized_end=19347, + serialized_start=19677, + serialized_end=19942, ) _GETCONTRACTGROUPSFORCONTRACTRESPONSE_GETCONTRACTGROUPSFORCONTRACTRESPONSEV0 = _descriptor.Descriptor( @@ -5847,8 +6004,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=19350, - serialized_end=19642, + serialized_start=19945, + serialized_end=20237, ) _GETCONTRACTGROUPSFORCONTRACTRESPONSE = _descriptor.Descriptor( @@ -5883,8 +6040,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=18768, - serialized_end=19653, + serialized_start=19363, + serialized_end=20248, ) @@ -5922,8 +6079,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=19775, - serialized_end=19830, + serialized_start=20370, + serialized_end=20425, ) _GETDATACONTRACTSREQUEST = _descriptor.Descriptor( @@ -5958,8 +6115,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=19656, - serialized_end=19841, + serialized_start=20251, + serialized_end=20436, ) @@ -6028,8 +6185,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=19985, - serialized_end=20134, + serialized_start=20580, + serialized_end=20729, ) _GETDATACONTRACTSBYRANGEREQUEST = _descriptor.Descriptor( @@ -6064,8 +6221,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=19844, - serialized_end=20145, + serialized_start=20439, + serialized_end=20740, ) @@ -6103,8 +6260,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=20270, - serialized_end=20361, + serialized_start=20865, + serialized_end=20956, ) _GETDATACONTRACTSRESPONSE_DATACONTRACTS = _descriptor.Descriptor( @@ -6134,8 +6291,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=20363, - serialized_end=20480, + serialized_start=20958, + serialized_end=21075, ) _GETDATACONTRACTSRESPONSE_GETDATACONTRACTSRESPONSEV0 = _descriptor.Descriptor( @@ -6184,8 +6341,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=20483, - serialized_end=20728, + serialized_start=21078, + serialized_end=21323, ) _GETDATACONTRACTSRESPONSE = _descriptor.Descriptor( @@ -6220,8 +6377,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=20148, - serialized_end=20739, + serialized_start=20743, + serialized_end=21334, ) @@ -6280,8 +6437,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=20880, - serialized_end=21056, + serialized_start=21475, + serialized_end=21651, ) _GETDATACONTRACTHISTORYREQUEST = _descriptor.Descriptor( @@ -6316,8 +6473,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=20742, - serialized_end=21067, + serialized_start=21337, + serialized_end=21662, ) @@ -6355,8 +6512,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=21507, - serialized_end=21566, + serialized_start=22102, + serialized_end=22161, ) _GETDATACONTRACTHISTORYRESPONSE_GETDATACONTRACTHISTORYRESPONSEV0_DATACONTRACTHISTORY = _descriptor.Descriptor( @@ -6386,8 +6543,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=21569, - serialized_end=21739, + serialized_start=22164, + serialized_end=22334, ) _GETDATACONTRACTHISTORYRESPONSE_GETDATACONTRACTHISTORYRESPONSEV0 = _descriptor.Descriptor( @@ -6436,8 +6593,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=21211, - serialized_end=21749, + serialized_start=21806, + serialized_end=22344, ) _GETDATACONTRACTHISTORYRESPONSE = _descriptor.Descriptor( @@ -6472,8 +6629,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=21070, - serialized_end=21760, + serialized_start=21665, + serialized_end=22355, ) @@ -6504,8 +6661,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=22232, - serialized_end=22326, + serialized_start=22827, + serialized_end=22921, ) _GETDOCUMENTSREQUEST_DOCUMENTFIELDVALUE = _descriptor.Descriptor( @@ -6589,8 +6746,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=21955, - serialized_end=22337, + serialized_start=22550, + serialized_end=22932, ) _GETDOCUMENTSREQUEST_TIMERANGESELECTION_GRID = _descriptor.Descriptor( @@ -6634,8 +6791,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=22569, - serialized_end=22631, + serialized_start=23164, + serialized_end=23226, ) _GETDOCUMENTSREQUEST_TIMERANGESELECTION = _descriptor.Descriptor( @@ -6685,8 +6842,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=22340, - serialized_end=22694, + serialized_start=22935, + serialized_end=23289, ) _GETDOCUMENTSREQUEST_WHERECLAUSE = _descriptor.Descriptor( @@ -6737,8 +6894,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=22697, - serialized_end=22974, + serialized_start=23292, + serialized_end=23569, ) _GETDOCUMENTSREQUEST_HAVINGAGGREGATE = _descriptor.Descriptor( @@ -6776,8 +6933,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=22977, - serialized_end=23141, + serialized_start=23572, + serialized_end=23736, ) _GETDOCUMENTSREQUEST_HAVINGCLAUSE = _descriptor.Descriptor( @@ -6827,8 +6984,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=23144, - serialized_end=23649, + serialized_start=23739, + serialized_end=24244, ) _GETDOCUMENTSREQUEST_ORDERCLAUSE = _descriptor.Descriptor( @@ -6877,8 +7034,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=23652, - serialized_end=23796, + serialized_start=24247, + serialized_end=24391, ) _GETDOCUMENTSREQUEST_GETDOCUMENTSREQUESTV0 = _descriptor.Descriptor( @@ -6962,8 +7119,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=23799, - serialized_end=23986, + serialized_start=24394, + serialized_end=24581, ) _GETDOCUMENTSREQUEST_GETDOCUMENTSREQUESTV1_SELECT = _descriptor.Descriptor( @@ -7001,8 +7158,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=24712, - serialized_end=24913, + serialized_start=25307, + serialized_end=25508, ) _GETDOCUMENTSREQUEST_GETDOCUMENTSREQUESTV1_CHAINEDJOIN = _descriptor.Descriptor( @@ -7039,8 +7196,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=24915, - serialized_end=24980, + serialized_start=25510, + serialized_end=25575, ) _GETDOCUMENTSREQUEST_GETDOCUMENTSREQUESTV1_SUBQUERY_BINDING = _descriptor.Descriptor( @@ -7084,8 +7241,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=25424, - serialized_end=25489, + serialized_start=26019, + serialized_end=26084, ) _GETDOCUMENTSREQUEST_GETDOCUMENTSREQUESTV1_SUBQUERY = _descriptor.Descriptor( @@ -7163,8 +7320,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=24983, - serialized_end=25533, + serialized_start=25578, + serialized_end=26128, ) _GETDOCUMENTSREQUEST_GETDOCUMENTSREQUESTV1 = _descriptor.Descriptor( @@ -7300,8 +7457,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=23989, - serialized_end=25563, + serialized_start=24584, + serialized_end=26158, ) _GETDOCUMENTSREQUEST = _descriptor.Descriptor( @@ -7344,8 +7501,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=21763, - serialized_end=25827, + serialized_start=22358, + serialized_end=26422, ) @@ -7376,8 +7533,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=26270, - serialized_end=26300, + serialized_start=26865, + serialized_end=26895, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV0 = _descriptor.Descriptor( @@ -7426,8 +7583,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=26027, - serialized_end=26310, + serialized_start=26622, + serialized_end=26905, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_DOCUMENTS = _descriptor.Descriptor( @@ -7457,8 +7614,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=26270, - serialized_end=26300, + serialized_start=26865, + serialized_end=26895, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_COUNTENTRY = _descriptor.Descriptor( @@ -7507,8 +7664,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=26584, - serialized_end=26660, + serialized_start=27179, + serialized_end=27255, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_COUNTENTRIES = _descriptor.Descriptor( @@ -7538,8 +7695,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=26662, - serialized_end=26776, + serialized_start=27257, + serialized_end=27371, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_COUNTRESULTS = _descriptor.Descriptor( @@ -7581,8 +7738,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=26779, - serialized_end=26939, + serialized_start=27374, + serialized_end=27534, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_SUMENTRY = _descriptor.Descriptor( @@ -7631,8 +7788,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=26941, - serialized_end=27013, + serialized_start=27536, + serialized_end=27608, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_SUMENTRIES = _descriptor.Descriptor( @@ -7662,8 +7819,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=27015, - serialized_end=27125, + serialized_start=27610, + serialized_end=27720, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_SUMRESULTS = _descriptor.Descriptor( @@ -7705,8 +7862,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=27128, - serialized_end=27282, + serialized_start=27723, + serialized_end=27877, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_AVERAGEENTRY = _descriptor.Descriptor( @@ -7762,8 +7919,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=27284, - serialized_end=27379, + serialized_start=27879, + serialized_end=27974, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_AVERAGEENTRIES = _descriptor.Descriptor( @@ -7793,8 +7950,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=27381, - serialized_end=27499, + serialized_start=27976, + serialized_end=28094, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_AVERAGEAGGREGATE = _descriptor.Descriptor( @@ -7831,8 +7988,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=27501, - serialized_end=27555, + serialized_start=28096, + serialized_end=28150, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_AVERAGERESULTS = _descriptor.Descriptor( @@ -7874,8 +8031,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=27558, - serialized_end=27809, + serialized_start=28153, + serialized_end=28404, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_RANKEDENTRY = _descriptor.Descriptor( @@ -7943,8 +8100,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=27811, - serialized_end=27933, + serialized_start=28406, + serialized_end=28528, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_RANKEDENTRIES = _descriptor.Descriptor( @@ -7986,8 +8143,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=27936, - serialized_end=28090, + serialized_start=28531, + serialized_end=28685, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_RESULTDATA = _descriptor.Descriptor( @@ -8064,8 +8221,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=28093, - serialized_end=28852, + serialized_start=28688, + serialized_end=29447, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_CHAINEDDOCUMENTS = _descriptor.Descriptor( @@ -8109,8 +8266,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=28854, - serialized_end=28949, + serialized_start=29449, + serialized_end=29544, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_COMPOSITEDOCUMENTS_SUBQUERYRESULT = _descriptor.Descriptor( @@ -8159,8 +8316,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=29126, - serialized_end=29379, + serialized_start=29721, + serialized_end=29974, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_COMPOSITEDOCUMENTS = _descriptor.Descriptor( @@ -8197,8 +8354,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=28952, - serialized_end=29379, + serialized_start=29547, + serialized_end=29974, ) _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1 = _descriptor.Descriptor( @@ -8247,8 +8404,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=26313, - serialized_end=29389, + serialized_start=26908, + serialized_end=29984, ) _GETDOCUMENTSRESPONSE = _descriptor.Descriptor( @@ -8290,8 +8447,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=25830, - serialized_end=29400, + serialized_start=26425, + serialized_end=29995, ) @@ -8364,8 +8521,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=29529, - serialized_end=29764, + serialized_start=30124, + serialized_end=30359, ) _GETDOCUMENTHISTORYREQUEST = _descriptor.Descriptor( @@ -8400,8 +8557,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=29403, - serialized_end=29775, + serialized_start=29998, + serialized_end=30370, ) @@ -8439,8 +8596,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=30181, - serialized_end=30236, + serialized_start=30776, + serialized_end=30831, ) _GETDOCUMENTHISTORYRESPONSE_GETDOCUMENTHISTORYRESPONSEV0_DOCUMENTHISTORY = _descriptor.Descriptor( @@ -8470,8 +8627,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=30239, - serialized_end=30388, + serialized_start=30834, + serialized_end=30983, ) _GETDOCUMENTHISTORYRESPONSE_GETDOCUMENTHISTORYRESPONSEV0 = _descriptor.Descriptor( @@ -8520,8 +8677,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=29907, - serialized_end=30398, + serialized_start=30502, + serialized_end=30993, ) _GETDOCUMENTHISTORYRESPONSE = _descriptor.Descriptor( @@ -8556,8 +8713,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=29778, - serialized_end=30409, + serialized_start=30373, + serialized_end=31004, ) @@ -8595,8 +8752,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=30561, - serialized_end=30638, + serialized_start=31156, + serialized_end=31233, ) _GETIDENTITYBYPUBLICKEYHASHREQUEST = _descriptor.Descriptor( @@ -8631,8 +8788,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=30412, - serialized_end=30649, + serialized_start=31007, + serialized_end=31244, ) @@ -8682,8 +8839,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=30805, - serialized_end=30987, + serialized_start=31400, + serialized_end=31582, ) _GETIDENTITYBYPUBLICKEYHASHRESPONSE = _descriptor.Descriptor( @@ -8718,8 +8875,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=30652, - serialized_end=30998, + serialized_start=31247, + serialized_end=31593, ) @@ -8769,8 +8926,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=31179, - serialized_end=31307, + serialized_start=31774, + serialized_end=31902, ) _GETIDENTITYBYNONUNIQUEPUBLICKEYHASHREQUEST = _descriptor.Descriptor( @@ -8805,8 +8962,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=31001, - serialized_end=31318, + serialized_start=31596, + serialized_end=31913, ) @@ -8842,8 +8999,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=31931, - serialized_end=31985, + serialized_start=32526, + serialized_end=32580, ) _GETIDENTITYBYNONUNIQUEPUBLICKEYHASHRESPONSE_GETIDENTITYBYNONUNIQUEPUBLICKEYHASHRESPONSEV0_IDENTITYPROVEDRESPONSE = _descriptor.Descriptor( @@ -8885,8 +9042,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=31988, - serialized_end=32154, + serialized_start=32583, + serialized_end=32749, ) _GETIDENTITYBYNONUNIQUEPUBLICKEYHASHRESPONSE_GETIDENTITYBYNONUNIQUEPUBLICKEYHASHRESPONSEV0 = _descriptor.Descriptor( @@ -8935,8 +9092,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=31502, - serialized_end=32164, + serialized_start=32097, + serialized_end=32759, ) _GETIDENTITYBYNONUNIQUEPUBLICKEYHASHRESPONSE = _descriptor.Descriptor( @@ -8971,8 +9128,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=31321, - serialized_end=32175, + serialized_start=31916, + serialized_end=32770, ) @@ -9010,8 +9167,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=32333, - serialized_end=32418, + serialized_start=32928, + serialized_end=33013, ) _WAITFORSTATETRANSITIONRESULTREQUEST = _descriptor.Descriptor( @@ -9046,8 +9203,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=32178, - serialized_end=32429, + serialized_start=32773, + serialized_end=33024, ) @@ -9097,8 +9254,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=32591, - serialized_end=32830, + serialized_start=33186, + serialized_end=33425, ) _WAITFORSTATETRANSITIONRESULTRESPONSE = _descriptor.Descriptor( @@ -9133,8 +9290,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=32432, - serialized_end=32841, + serialized_start=33027, + serialized_end=33436, ) @@ -9172,8 +9329,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=32969, - serialized_end=33029, + serialized_start=33564, + serialized_end=33624, ) _GETCONSENSUSPARAMSREQUEST = _descriptor.Descriptor( @@ -9208,8 +9365,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=32844, - serialized_end=33040, + serialized_start=33439, + serialized_end=33635, ) @@ -9254,8 +9411,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=33171, - serialized_end=33251, + serialized_start=33766, + serialized_end=33846, ) _GETCONSENSUSPARAMSRESPONSE_CONSENSUSPARAMSEVIDENCE = _descriptor.Descriptor( @@ -9299,8 +9456,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=33253, - serialized_end=33351, + serialized_start=33848, + serialized_end=33946, ) _GETCONSENSUSPARAMSRESPONSE_GETCONSENSUSPARAMSRESPONSEV0 = _descriptor.Descriptor( @@ -9337,8 +9494,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=33354, - serialized_end=33572, + serialized_start=33949, + serialized_end=34167, ) _GETCONSENSUSPARAMSRESPONSE = _descriptor.Descriptor( @@ -9373,8 +9530,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=33043, - serialized_end=33583, + serialized_start=33638, + serialized_end=34178, ) @@ -9405,8 +9562,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=33747, - serialized_end=33803, + serialized_start=34342, + serialized_end=34398, ) _GETPROTOCOLVERSIONUPGRADESTATEREQUEST = _descriptor.Descriptor( @@ -9441,8 +9598,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=33586, - serialized_end=33814, + serialized_start=34181, + serialized_end=34409, ) @@ -9473,8 +9630,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=34279, - serialized_end=34429, + serialized_start=34874, + serialized_end=35024, ) _GETPROTOCOLVERSIONUPGRADESTATERESPONSE_GETPROTOCOLVERSIONUPGRADESTATERESPONSEV0_VERSIONENTRY = _descriptor.Descriptor( @@ -9511,8 +9668,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=34431, - serialized_end=34489, + serialized_start=35026, + serialized_end=35084, ) _GETPROTOCOLVERSIONUPGRADESTATERESPONSE_GETPROTOCOLVERSIONUPGRADESTATERESPONSEV0 = _descriptor.Descriptor( @@ -9561,8 +9718,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=33982, - serialized_end=34499, + serialized_start=34577, + serialized_end=35094, ) _GETPROTOCOLVERSIONUPGRADESTATERESPONSE = _descriptor.Descriptor( @@ -9597,8 +9754,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=33817, - serialized_end=34510, + serialized_start=34412, + serialized_end=35105, ) @@ -9643,8 +9800,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=34690, - serialized_end=34793, + serialized_start=35285, + serialized_end=35388, ) _GETPROTOCOLVERSIONUPGRADEVOTESTATUSREQUEST = _descriptor.Descriptor( @@ -9679,8 +9836,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=34513, - serialized_end=34804, + serialized_start=35108, + serialized_end=35399, ) @@ -9711,8 +9868,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=35307, - serialized_end=35482, + serialized_start=35902, + serialized_end=36077, ) _GETPROTOCOLVERSIONUPGRADEVOTESTATUSRESPONSE_GETPROTOCOLVERSIONUPGRADEVOTESTATUSRESPONSEV0_VERSIONSIGNAL = _descriptor.Descriptor( @@ -9749,8 +9906,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=35484, - serialized_end=35537, + serialized_start=36079, + serialized_end=36132, ) _GETPROTOCOLVERSIONUPGRADEVOTESTATUSRESPONSE_GETPROTOCOLVERSIONUPGRADEVOTESTATUSRESPONSEV0 = _descriptor.Descriptor( @@ -9799,8 +9956,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=34988, - serialized_end=35547, + serialized_start=35583, + serialized_end=36142, ) _GETPROTOCOLVERSIONUPGRADEVOTESTATUSRESPONSE = _descriptor.Descriptor( @@ -9835,8 +9992,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=34807, - serialized_end=35558, + serialized_start=35402, + serialized_end=36153, ) @@ -9888,8 +10045,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=35671, - serialized_end=35795, + serialized_start=36266, + serialized_end=36390, ) _GETEPOCHSINFOREQUEST = _descriptor.Descriptor( @@ -9924,8 +10081,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=35561, - serialized_end=35806, + serialized_start=36156, + serialized_end=36401, ) @@ -9956,8 +10113,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=36167, - serialized_end=36284, + serialized_start=36762, + serialized_end=36879, ) _GETEPOCHSINFORESPONSE_GETEPOCHSINFORESPONSEV0_EPOCHINFO = _descriptor.Descriptor( @@ -10022,8 +10179,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=36287, - serialized_end=36453, + serialized_start=36882, + serialized_end=37048, ) _GETEPOCHSINFORESPONSE_GETEPOCHSINFORESPONSEV0 = _descriptor.Descriptor( @@ -10072,8 +10229,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=35923, - serialized_end=36463, + serialized_start=36518, + serialized_end=37058, ) _GETEPOCHSINFORESPONSE = _descriptor.Descriptor( @@ -10108,8 +10265,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=35809, - serialized_end=36474, + serialized_start=36404, + serialized_end=37069, ) @@ -10168,8 +10325,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=36615, - serialized_end=36785, + serialized_start=37210, + serialized_end=37380, ) _GETFINALIZEDEPOCHINFOSREQUEST = _descriptor.Descriptor( @@ -10204,8 +10361,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=36477, - serialized_end=36796, + serialized_start=37072, + serialized_end=37391, ) @@ -10236,8 +10393,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=37222, - serialized_end=37386, + serialized_start=37817, + serialized_end=37981, ) _GETFINALIZEDEPOCHINFOSRESPONSE_GETFINALIZEDEPOCHINFOSRESPONSEV0_FINALIZEDEPOCHINFO = _descriptor.Descriptor( @@ -10351,8 +10508,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=37389, - serialized_end=37932, + serialized_start=37984, + serialized_end=38527, ) _GETFINALIZEDEPOCHINFOSRESPONSE_GETFINALIZEDEPOCHINFOSRESPONSEV0_BLOCKPROPOSER = _descriptor.Descriptor( @@ -10389,8 +10546,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=37934, - serialized_end=37991, + serialized_start=38529, + serialized_end=38586, ) _GETFINALIZEDEPOCHINFOSRESPONSE_GETFINALIZEDEPOCHINFOSRESPONSEV0 = _descriptor.Descriptor( @@ -10439,8 +10596,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=36940, - serialized_end=38001, + serialized_start=37535, + serialized_end=38596, ) _GETFINALIZEDEPOCHINFOSRESPONSE = _descriptor.Descriptor( @@ -10475,8 +10632,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=36799, - serialized_end=38012, + serialized_start=37394, + serialized_end=38607, ) @@ -10514,8 +10671,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=38507, - serialized_end=38576, + serialized_start=39102, + serialized_end=39171, ) _GETCONTESTEDRESOURCESREQUEST_GETCONTESTEDRESOURCESREQUESTV0 = _descriptor.Descriptor( @@ -10611,8 +10768,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=38150, - serialized_end=38610, + serialized_start=38745, + serialized_end=39205, ) _GETCONTESTEDRESOURCESREQUEST = _descriptor.Descriptor( @@ -10647,8 +10804,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=38015, - serialized_end=38621, + serialized_start=38610, + serialized_end=39216, ) @@ -10679,8 +10836,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=39063, - serialized_end=39123, + serialized_start=39658, + serialized_end=39718, ) _GETCONTESTEDRESOURCESRESPONSE_GETCONTESTEDRESOURCESRESPONSEV0 = _descriptor.Descriptor( @@ -10729,8 +10886,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=38762, - serialized_end=39133, + serialized_start=39357, + serialized_end=39728, ) _GETCONTESTEDRESOURCESRESPONSE = _descriptor.Descriptor( @@ -10765,8 +10922,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=38624, - serialized_end=39144, + serialized_start=39219, + serialized_end=39739, ) @@ -10804,8 +10961,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=39657, - serialized_end=39730, + serialized_start=40252, + serialized_end=40325, ) _GETVOTEPOLLSBYENDDATEREQUEST_GETVOTEPOLLSBYENDDATEREQUESTV0_ENDATTIMEINFO = _descriptor.Descriptor( @@ -10842,8 +10999,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=39732, - serialized_end=39799, + serialized_start=40327, + serialized_end=40394, ) _GETVOTEPOLLSBYENDDATEREQUEST_GETVOTEPOLLSBYENDDATEREQUESTV0 = _descriptor.Descriptor( @@ -10928,8 +11085,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=39282, - serialized_end=39858, + serialized_start=39877, + serialized_end=40453, ) _GETVOTEPOLLSBYENDDATEREQUEST = _descriptor.Descriptor( @@ -10964,8 +11121,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=39147, - serialized_end=39869, + serialized_start=39742, + serialized_end=40464, ) @@ -11003,8 +11160,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=40318, - serialized_end=40404, + serialized_start=40913, + serialized_end=40999, ) _GETVOTEPOLLSBYENDDATERESPONSE_GETVOTEPOLLSBYENDDATERESPONSEV0_SERIALIZEDVOTEPOLLSBYTIMESTAMPS = _descriptor.Descriptor( @@ -11041,8 +11198,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=40407, - serialized_end=40622, + serialized_start=41002, + serialized_end=41217, ) _GETVOTEPOLLSBYENDDATERESPONSE_GETVOTEPOLLSBYENDDATERESPONSEV0 = _descriptor.Descriptor( @@ -11091,8 +11248,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=40010, - serialized_end=40632, + serialized_start=40605, + serialized_end=41227, ) _GETVOTEPOLLSBYENDDATERESPONSE = _descriptor.Descriptor( @@ -11127,8 +11284,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=39872, - serialized_end=40643, + serialized_start=40467, + serialized_end=41238, ) @@ -11166,8 +11323,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=41332, - serialized_end=41416, + serialized_start=41927, + serialized_end=42011, ) _GETCONTESTEDRESOURCEVOTESTATEREQUEST_GETCONTESTEDRESOURCEVOTESTATEREQUESTV0 = _descriptor.Descriptor( @@ -11264,8 +11421,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=40805, - serialized_end=41530, + serialized_start=41400, + serialized_end=42125, ) _GETCONTESTEDRESOURCEVOTESTATEREQUEST = _descriptor.Descriptor( @@ -11300,8 +11457,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=40646, - serialized_end=41541, + serialized_start=41241, + serialized_end=42136, ) @@ -11373,8 +11530,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=42041, - serialized_end=42515, + serialized_start=42636, + serialized_end=43110, ) _GETCONTESTEDRESOURCEVOTESTATERESPONSE_GETCONTESTEDRESOURCEVOTESTATERESPONSEV0_CONTESTEDRESOURCECONTENDERS = _descriptor.Descriptor( @@ -11440,8 +11597,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=42518, - serialized_end=42970, + serialized_start=43113, + serialized_end=43565, ) _GETCONTESTEDRESOURCEVOTESTATERESPONSE_GETCONTESTEDRESOURCEVOTESTATERESPONSEV0_CONTENDER = _descriptor.Descriptor( @@ -11495,8 +11652,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=42972, - serialized_end=43079, + serialized_start=43567, + serialized_end=43674, ) _GETCONTESTEDRESOURCEVOTESTATERESPONSE_GETCONTESTEDRESOURCEVOTESTATERESPONSEV0 = _descriptor.Descriptor( @@ -11545,8 +11702,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=41706, - serialized_end=43089, + serialized_start=42301, + serialized_end=43684, ) _GETCONTESTEDRESOURCEVOTESTATERESPONSE = _descriptor.Descriptor( @@ -11581,8 +11738,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=41544, - serialized_end=43100, + serialized_start=42139, + serialized_end=43695, ) @@ -11620,8 +11777,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=41332, - serialized_end=41416, + serialized_start=41927, + serialized_end=42011, ) _GETCONTESTEDRESOURCEVOTERSFORIDENTITYREQUEST_GETCONTESTEDRESOURCEVOTERSFORIDENTITYREQUESTV0 = _descriptor.Descriptor( @@ -11717,8 +11874,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=43287, - serialized_end=43817, + serialized_start=43882, + serialized_end=44412, ) _GETCONTESTEDRESOURCEVOTERSFORIDENTITYREQUEST = _descriptor.Descriptor( @@ -11753,8 +11910,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=43103, - serialized_end=43828, + serialized_start=43698, + serialized_end=44423, ) @@ -11792,8 +11949,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=44368, - serialized_end=44435, + serialized_start=44963, + serialized_end=45030, ) _GETCONTESTEDRESOURCEVOTERSFORIDENTITYRESPONSE_GETCONTESTEDRESOURCEVOTERSFORIDENTITYRESPONSEV0 = _descriptor.Descriptor( @@ -11842,8 +11999,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=44018, - serialized_end=44445, + serialized_start=44613, + serialized_end=45040, ) _GETCONTESTEDRESOURCEVOTERSFORIDENTITYRESPONSE = _descriptor.Descriptor( @@ -11878,8 +12035,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=43831, - serialized_end=44456, + serialized_start=44426, + serialized_end=45051, ) @@ -11917,8 +12074,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=45005, - serialized_end=45102, + serialized_start=45600, + serialized_end=45697, ) _GETCONTESTEDRESOURCEIDENTITYVOTESREQUEST_GETCONTESTEDRESOURCEIDENTITYVOTESREQUESTV0 = _descriptor.Descriptor( @@ -11988,8 +12145,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=44630, - serialized_end=45133, + serialized_start=45225, + serialized_end=45728, ) _GETCONTESTEDRESOURCEIDENTITYVOTESREQUEST = _descriptor.Descriptor( @@ -12024,8 +12181,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=44459, - serialized_end=45144, + serialized_start=45054, + serialized_end=45739, ) @@ -12063,8 +12220,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=45647, - serialized_end=45894, + serialized_start=46242, + serialized_end=46489, ) _GETCONTESTEDRESOURCEIDENTITYVOTESRESPONSE_GETCONTESTEDRESOURCEIDENTITYVOTESRESPONSEV0_RESOURCEVOTECHOICE = _descriptor.Descriptor( @@ -12107,8 +12264,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=45897, - serialized_end=46198, + serialized_start=46492, + serialized_end=46793, ) _GETCONTESTEDRESOURCEIDENTITYVOTESRESPONSE_GETCONTESTEDRESOURCEIDENTITYVOTESRESPONSEV0_CONTESTEDRESOURCEIDENTITYVOTE = _descriptor.Descriptor( @@ -12159,8 +12316,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=46201, - serialized_end=46478, + serialized_start=46796, + serialized_end=47073, ) _GETCONTESTEDRESOURCEIDENTITYVOTESRESPONSE_GETCONTESTEDRESOURCEIDENTITYVOTESRESPONSEV0 = _descriptor.Descriptor( @@ -12209,8 +12366,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=45321, - serialized_end=46488, + serialized_start=45916, + serialized_end=47083, ) _GETCONTESTEDRESOURCEIDENTITYVOTESRESPONSE = _descriptor.Descriptor( @@ -12245,8 +12402,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=45147, - serialized_end=46499, + serialized_start=45742, + serialized_end=47094, ) @@ -12284,8 +12441,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=46663, - serialized_end=46731, + serialized_start=47258, + serialized_end=47326, ) _GETPREFUNDEDSPECIALIZEDBALANCEREQUEST = _descriptor.Descriptor( @@ -12320,8 +12477,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=46502, - serialized_end=46742, + serialized_start=47097, + serialized_end=47337, ) @@ -12371,8 +12528,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=46910, - serialized_end=47099, + serialized_start=47505, + serialized_end=47694, ) _GETPREFUNDEDSPECIALIZEDBALANCERESPONSE = _descriptor.Descriptor( @@ -12407,8 +12564,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=46745, - serialized_end=47110, + serialized_start=47340, + serialized_end=47705, ) @@ -12439,8 +12596,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=47259, - serialized_end=47310, + serialized_start=47854, + serialized_end=47905, ) _GETTOTALCREDITSINPLATFORMREQUEST = _descriptor.Descriptor( @@ -12475,8 +12632,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=47113, - serialized_end=47321, + serialized_start=47708, + serialized_end=47916, ) @@ -12526,8 +12683,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=47474, - serialized_end=47658, + serialized_start=48069, + serialized_end=48253, ) _GETTOTALCREDITSINPLATFORMRESPONSE = _descriptor.Descriptor( @@ -12562,8 +12719,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=47324, - serialized_end=47669, + serialized_start=47919, + serialized_end=48264, ) @@ -12608,8 +12765,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=47788, - serialized_end=47857, + serialized_start=48383, + serialized_end=48452, ) _GETPATHELEMENTSREQUEST = _descriptor.Descriptor( @@ -12644,8 +12801,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=47672, - serialized_end=47868, + serialized_start=48267, + serialized_end=48463, ) @@ -12676,8 +12833,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=48241, - serialized_end=48269, + serialized_start=48836, + serialized_end=48864, ) _GETPATHELEMENTSRESPONSE_GETPATHELEMENTSRESPONSEV0 = _descriptor.Descriptor( @@ -12726,8 +12883,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=47991, - serialized_end=48279, + serialized_start=48586, + serialized_end=48874, ) _GETPATHELEMENTSRESPONSE = _descriptor.Descriptor( @@ -12762,8 +12919,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=47871, - serialized_end=48290, + serialized_start=48466, + serialized_end=48885, ) @@ -12787,8 +12944,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=48391, - serialized_end=48411, + serialized_start=48986, + serialized_end=49006, ) _GETSTATUSREQUEST = _descriptor.Descriptor( @@ -12823,8 +12980,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=48293, - serialized_end=48422, + serialized_start=48888, + serialized_end=49017, ) @@ -12879,8 +13036,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=49299, - serialized_end=49393, + serialized_start=49894, + serialized_end=49988, ) _GETSTATUSRESPONSE_GETSTATUSRESPONSEV0_VERSION_PROTOCOL_TENDERDASH = _descriptor.Descriptor( @@ -12917,8 +13074,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=49626, - serialized_end=49666, + serialized_start=50221, + serialized_end=50261, ) _GETSTATUSRESPONSE_GETSTATUSRESPONSEV0_VERSION_PROTOCOL_DRIVE = _descriptor.Descriptor( @@ -12962,8 +13119,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=49668, - serialized_end=49728, + serialized_start=50263, + serialized_end=50323, ) _GETSTATUSRESPONSE_GETSTATUSRESPONSEV0_VERSION_PROTOCOL = _descriptor.Descriptor( @@ -13000,8 +13157,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=49396, - serialized_end=49728, + serialized_start=49991, + serialized_end=50323, ) _GETSTATUSRESPONSE_GETSTATUSRESPONSEV0_VERSION = _descriptor.Descriptor( @@ -13038,8 +13195,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=49086, - serialized_end=49728, + serialized_start=49681, + serialized_end=50323, ) _GETSTATUSRESPONSE_GETSTATUSRESPONSEV0_TIME = _descriptor.Descriptor( @@ -13105,8 +13262,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=49730, - serialized_end=49857, + serialized_start=50325, + serialized_end=50452, ) _GETSTATUSRESPONSE_GETSTATUSRESPONSEV0_NODE = _descriptor.Descriptor( @@ -13148,8 +13305,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=49859, - serialized_end=49919, + serialized_start=50454, + serialized_end=50514, ) _GETSTATUSRESPONSE_GETSTATUSRESPONSEV0_CHAIN = _descriptor.Descriptor( @@ -13240,8 +13397,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=49922, - serialized_end=50229, + serialized_start=50517, + serialized_end=50824, ) _GETSTATUSRESPONSE_GETSTATUSRESPONSEV0_NETWORK = _descriptor.Descriptor( @@ -13285,8 +13442,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=50231, - serialized_end=50298, + serialized_start=50826, + serialized_end=50893, ) _GETSTATUSRESPONSE_GETSTATUSRESPONSEV0_STATESYNC = _descriptor.Descriptor( @@ -13365,8 +13522,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=50301, - serialized_end=50562, + serialized_start=50896, + serialized_end=51157, ) _GETSTATUSRESPONSE_GETSTATUSRESPONSEV0 = _descriptor.Descriptor( @@ -13431,8 +13588,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=48527, - serialized_end=50562, + serialized_start=49122, + serialized_end=51157, ) _GETSTATUSRESPONSE = _descriptor.Descriptor( @@ -13467,8 +13624,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=48425, - serialized_end=50573, + serialized_start=49020, + serialized_end=51168, ) @@ -13492,8 +13649,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=50710, - serialized_end=50742, + serialized_start=51305, + serialized_end=51337, ) _GETCURRENTQUORUMSINFOREQUEST = _descriptor.Descriptor( @@ -13528,8 +13685,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=50576, - serialized_end=50753, + serialized_start=51171, + serialized_end=51348, ) @@ -13574,8 +13731,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=50893, - serialized_end=50963, + serialized_start=51488, + serialized_end=51558, ) _GETCURRENTQUORUMSINFORESPONSE_VALIDATORSETV0 = _descriptor.Descriptor( @@ -13626,8 +13783,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=50966, - serialized_end=51141, + serialized_start=51561, + serialized_end=51736, ) _GETCURRENTQUORUMSINFORESPONSE_GETCURRENTQUORUMSINFORESPONSEV0 = _descriptor.Descriptor( @@ -13685,8 +13842,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=51144, - serialized_end=51418, + serialized_start=51739, + serialized_end=52013, ) _GETCURRENTQUORUMSINFORESPONSE = _descriptor.Descriptor( @@ -13721,8 +13878,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=50756, - serialized_end=51429, + serialized_start=51351, + serialized_end=52024, ) @@ -13767,8 +13924,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=51575, - serialized_end=51665, + serialized_start=52170, + serialized_end=52260, ) _GETIDENTITYTOKENBALANCESREQUEST = _descriptor.Descriptor( @@ -13803,8 +13960,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=51432, - serialized_end=51676, + serialized_start=52027, + serialized_end=52271, ) @@ -13847,8 +14004,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=52115, - serialized_end=52186, + serialized_start=52710, + serialized_end=52781, ) _GETIDENTITYTOKENBALANCESRESPONSE_GETIDENTITYTOKENBALANCESRESPONSEV0_TOKENBALANCES = _descriptor.Descriptor( @@ -13878,8 +14035,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=52189, - serialized_end=52343, + serialized_start=52784, + serialized_end=52938, ) _GETIDENTITYTOKENBALANCESRESPONSE_GETIDENTITYTOKENBALANCESRESPONSEV0 = _descriptor.Descriptor( @@ -13928,8 +14085,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=51826, - serialized_end=52353, + serialized_start=52421, + serialized_end=52948, ) _GETIDENTITYTOKENBALANCESRESPONSE = _descriptor.Descriptor( @@ -13964,8 +14121,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=51679, - serialized_end=52364, + serialized_start=52274, + serialized_end=52959, ) @@ -14010,8 +14167,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=52516, - serialized_end=52608, + serialized_start=53111, + serialized_end=53203, ) _GETIDENTITIESTOKENBALANCESREQUEST = _descriptor.Descriptor( @@ -14046,8 +14203,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=52367, - serialized_end=52619, + serialized_start=52962, + serialized_end=53214, ) @@ -14090,8 +14247,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=53087, - serialized_end=53169, + serialized_start=53682, + serialized_end=53764, ) _GETIDENTITIESTOKENBALANCESRESPONSE_GETIDENTITIESTOKENBALANCESRESPONSEV0_IDENTITYTOKENBALANCES = _descriptor.Descriptor( @@ -14121,8 +14278,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=53172, - serialized_end=53355, + serialized_start=53767, + serialized_end=53950, ) _GETIDENTITIESTOKENBALANCESRESPONSE_GETIDENTITIESTOKENBALANCESRESPONSEV0 = _descriptor.Descriptor( @@ -14171,8 +14328,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=52775, - serialized_end=53365, + serialized_start=53370, + serialized_end=53960, ) _GETIDENTITIESTOKENBALANCESRESPONSE = _descriptor.Descriptor( @@ -14207,8 +14364,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=52622, - serialized_end=53376, + serialized_start=53217, + serialized_end=53971, ) @@ -14253,8 +14410,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=53513, - serialized_end=53600, + serialized_start=54108, + serialized_end=54195, ) _GETIDENTITYTOKENINFOSREQUEST = _descriptor.Descriptor( @@ -14289,8 +14446,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=53379, - serialized_end=53611, + serialized_start=53974, + serialized_end=54206, ) @@ -14321,8 +14478,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=54025, - serialized_end=54065, + serialized_start=54620, + serialized_end=54660, ) _GETIDENTITYTOKENINFOSRESPONSE_GETIDENTITYTOKENINFOSRESPONSEV0_TOKENINFOENTRY = _descriptor.Descriptor( @@ -14364,8 +14521,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=54068, - serialized_end=54244, + serialized_start=54663, + serialized_end=54839, ) _GETIDENTITYTOKENINFOSRESPONSE_GETIDENTITYTOKENINFOSRESPONSEV0_TOKENINFOS = _descriptor.Descriptor( @@ -14395,8 +14552,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=54247, - serialized_end=54385, + serialized_start=54842, + serialized_end=54980, ) _GETIDENTITYTOKENINFOSRESPONSE_GETIDENTITYTOKENINFOSRESPONSEV0 = _descriptor.Descriptor( @@ -14445,8 +14602,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=53752, - serialized_end=54395, + serialized_start=54347, + serialized_end=54990, ) _GETIDENTITYTOKENINFOSRESPONSE = _descriptor.Descriptor( @@ -14481,8 +14638,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=53614, - serialized_end=54406, + serialized_start=54209, + serialized_end=55001, ) @@ -14527,8 +14684,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=54549, - serialized_end=54638, + serialized_start=55144, + serialized_end=55233, ) _GETIDENTITIESTOKENINFOSREQUEST = _descriptor.Descriptor( @@ -14563,8 +14720,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=54409, - serialized_end=54649, + serialized_start=55004, + serialized_end=55244, ) @@ -14595,8 +14752,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=54025, - serialized_end=54065, + serialized_start=54620, + serialized_end=54660, ) _GETIDENTITIESTOKENINFOSRESPONSE_GETIDENTITIESTOKENINFOSRESPONSEV0_TOKENINFOENTRY = _descriptor.Descriptor( @@ -14638,8 +14795,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=55136, - serialized_end=55319, + serialized_start=55731, + serialized_end=55914, ) _GETIDENTITIESTOKENINFOSRESPONSE_GETIDENTITIESTOKENINFOSRESPONSEV0_IDENTITYTOKENINFOS = _descriptor.Descriptor( @@ -14669,8 +14826,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=55322, - serialized_end=55473, + serialized_start=55917, + serialized_end=56068, ) _GETIDENTITIESTOKENINFOSRESPONSE_GETIDENTITIESTOKENINFOSRESPONSEV0 = _descriptor.Descriptor( @@ -14719,8 +14876,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=54796, - serialized_end=55483, + serialized_start=55391, + serialized_end=56078, ) _GETIDENTITIESTOKENINFOSRESPONSE = _descriptor.Descriptor( @@ -14755,8 +14912,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=54652, - serialized_end=55494, + serialized_start=55247, + serialized_end=56089, ) @@ -14794,8 +14951,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=55616, - serialized_end=55677, + serialized_start=56211, + serialized_end=56272, ) _GETTOKENSTATUSESREQUEST = _descriptor.Descriptor( @@ -14830,8 +14987,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=55497, - serialized_end=55688, + serialized_start=56092, + serialized_end=56283, ) @@ -14874,8 +15031,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=56078, - serialized_end=56146, + serialized_start=56673, + serialized_end=56741, ) _GETTOKENSTATUSESRESPONSE_GETTOKENSTATUSESRESPONSEV0_TOKENSTATUSES = _descriptor.Descriptor( @@ -14905,8 +15062,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=56149, - serialized_end=56285, + serialized_start=56744, + serialized_end=56880, ) _GETTOKENSTATUSESRESPONSE_GETTOKENSTATUSESRESPONSEV0 = _descriptor.Descriptor( @@ -14955,8 +15112,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=55814, - serialized_end=56295, + serialized_start=56409, + serialized_end=56890, ) _GETTOKENSTATUSESRESPONSE = _descriptor.Descriptor( @@ -14991,8 +15148,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=55691, - serialized_end=56306, + serialized_start=56286, + serialized_end=56901, ) @@ -15030,8 +15187,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=56464, - serialized_end=56537, + serialized_start=57059, + serialized_end=57132, ) _GETTOKENDIRECTPURCHASEPRICESREQUEST = _descriptor.Descriptor( @@ -15066,8 +15223,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=56309, - serialized_end=56548, + serialized_start=56904, + serialized_end=57143, ) @@ -15105,8 +15262,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=57038, - serialized_end=57089, + serialized_start=57633, + serialized_end=57684, ) _GETTOKENDIRECTPURCHASEPRICESRESPONSE_GETTOKENDIRECTPURCHASEPRICESRESPONSEV0_PRICINGSCHEDULE = _descriptor.Descriptor( @@ -15136,8 +15293,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=57092, - serialized_end=57259, + serialized_start=57687, + serialized_end=57854, ) _GETTOKENDIRECTPURCHASEPRICESRESPONSE_GETTOKENDIRECTPURCHASEPRICESRESPONSEV0_TOKENDIRECTPURCHASEPRICEENTRY = _descriptor.Descriptor( @@ -15186,8 +15343,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=57262, - serialized_end=57490, + serialized_start=57857, + serialized_end=58085, ) _GETTOKENDIRECTPURCHASEPRICESRESPONSE_GETTOKENDIRECTPURCHASEPRICESRESPONSEV0_TOKENDIRECTPURCHASEPRICES = _descriptor.Descriptor( @@ -15217,8 +15374,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=57493, - serialized_end=57693, + serialized_start=58088, + serialized_end=58288, ) _GETTOKENDIRECTPURCHASEPRICESRESPONSE_GETTOKENDIRECTPURCHASEPRICESRESPONSEV0 = _descriptor.Descriptor( @@ -15267,8 +15424,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=56710, - serialized_end=57703, + serialized_start=57305, + serialized_end=58298, ) _GETTOKENDIRECTPURCHASEPRICESRESPONSE = _descriptor.Descriptor( @@ -15303,8 +15460,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=56551, - serialized_end=57714, + serialized_start=57146, + serialized_end=58309, ) @@ -15342,8 +15499,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=57848, - serialized_end=57912, + serialized_start=58443, + serialized_end=58507, ) _GETTOKENCONTRACTINFOREQUEST = _descriptor.Descriptor( @@ -15378,8 +15535,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=57717, - serialized_end=57923, + serialized_start=58312, + serialized_end=58518, ) @@ -15417,8 +15574,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=58335, - serialized_end=58412, + serialized_start=58930, + serialized_end=59007, ) _GETTOKENCONTRACTINFORESPONSE_GETTOKENCONTRACTINFORESPONSEV0 = _descriptor.Descriptor( @@ -15467,8 +15624,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=58061, - serialized_end=58422, + serialized_start=58656, + serialized_end=59017, ) _GETTOKENCONTRACTINFORESPONSE = _descriptor.Descriptor( @@ -15503,8 +15660,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=57926, - serialized_end=58433, + serialized_start=58521, + serialized_end=59028, ) @@ -15559,8 +15716,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=58866, - serialized_end=59020, + serialized_start=59461, + serialized_end=59615, ) _GETTOKENPREPROGRAMMEDDISTRIBUTIONSREQUEST_GETTOKENPREPROGRAMMEDDISTRIBUTIONSREQUESTV0 = _descriptor.Descriptor( @@ -15621,8 +15778,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=58610, - serialized_end=59048, + serialized_start=59205, + serialized_end=59643, ) _GETTOKENPREPROGRAMMEDDISTRIBUTIONSREQUEST = _descriptor.Descriptor( @@ -15657,8 +15814,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=58436, - serialized_end=59059, + serialized_start=59031, + serialized_end=59654, ) @@ -15696,8 +15853,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=59570, - serialized_end=59632, + serialized_start=60165, + serialized_end=60227, ) _GETTOKENPREPROGRAMMEDDISTRIBUTIONSRESPONSE_GETTOKENPREPROGRAMMEDDISTRIBUTIONSRESPONSEV0_TOKENTIMEDDISTRIBUTIONENTRY = _descriptor.Descriptor( @@ -15734,8 +15891,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=59635, - serialized_end=59847, + serialized_start=60230, + serialized_end=60442, ) _GETTOKENPREPROGRAMMEDDISTRIBUTIONSRESPONSE_GETTOKENPREPROGRAMMEDDISTRIBUTIONSRESPONSEV0_TOKENDISTRIBUTIONS = _descriptor.Descriptor( @@ -15765,8 +15922,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=59850, - serialized_end=60045, + serialized_start=60445, + serialized_end=60640, ) _GETTOKENPREPROGRAMMEDDISTRIBUTIONSRESPONSE_GETTOKENPREPROGRAMMEDDISTRIBUTIONSRESPONSEV0 = _descriptor.Descriptor( @@ -15815,8 +15972,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=59240, - serialized_end=60055, + serialized_start=59835, + serialized_end=60650, ) _GETTOKENPREPROGRAMMEDDISTRIBUTIONSRESPONSE = _descriptor.Descriptor( @@ -15851,8 +16008,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=59062, - serialized_end=60066, + serialized_start=59657, + serialized_end=60661, ) @@ -15890,8 +16047,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=60255, - serialized_end=60328, + serialized_start=60850, + serialized_end=60923, ) _GETTOKENPERPETUALDISTRIBUTIONLASTCLAIMREQUEST_GETTOKENPERPETUALDISTRIBUTIONLASTCLAIMREQUESTV0 = _descriptor.Descriptor( @@ -15947,8 +16104,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=60331, - serialized_end=60572, + serialized_start=60926, + serialized_end=61167, ) _GETTOKENPERPETUALDISTRIBUTIONLASTCLAIMREQUEST = _descriptor.Descriptor( @@ -15983,8 +16140,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=60069, - serialized_end=60583, + serialized_start=60664, + serialized_end=61178, ) @@ -16041,8 +16198,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=61104, - serialized_end=61224, + serialized_start=61699, + serialized_end=61819, ) _GETTOKENPERPETUALDISTRIBUTIONLASTCLAIMRESPONSE_GETTOKENPERPETUALDISTRIBUTIONLASTCLAIMRESPONSEV0 = _descriptor.Descriptor( @@ -16091,8 +16248,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=60776, - serialized_end=61234, + serialized_start=61371, + serialized_end=61829, ) _GETTOKENPERPETUALDISTRIBUTIONLASTCLAIMRESPONSE = _descriptor.Descriptor( @@ -16127,8 +16284,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=60586, - serialized_end=61245, + serialized_start=61181, + serialized_end=61840, ) @@ -16166,8 +16323,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=61376, - serialized_end=61439, + serialized_start=61971, + serialized_end=62034, ) _GETTOKENTOTALSUPPLYREQUEST = _descriptor.Descriptor( @@ -16202,8 +16359,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=61248, - serialized_end=61450, + serialized_start=61843, + serialized_end=62045, ) @@ -16248,8 +16405,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=61871, - serialized_end=61991, + serialized_start=62466, + serialized_end=62586, ) _GETTOKENTOTALSUPPLYRESPONSE_GETTOKENTOTALSUPPLYRESPONSEV0 = _descriptor.Descriptor( @@ -16298,8 +16455,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=61585, - serialized_end=62001, + serialized_start=62180, + serialized_end=62596, ) _GETTOKENTOTALSUPPLYRESPONSE = _descriptor.Descriptor( @@ -16334,8 +16491,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=61453, - serialized_end=62012, + serialized_start=62048, + serialized_end=62607, ) @@ -16380,8 +16537,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=62122, - serialized_end=62214, + serialized_start=62717, + serialized_end=62809, ) _GETGROUPINFOREQUEST = _descriptor.Descriptor( @@ -16416,8 +16573,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=62015, - serialized_end=62225, + serialized_start=62610, + serialized_end=62820, ) @@ -16455,8 +16612,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=62583, - serialized_end=62635, + serialized_start=63178, + serialized_end=63230, ) _GETGROUPINFORESPONSE_GETGROUPINFORESPONSEV0_GROUPINFOENTRY = _descriptor.Descriptor( @@ -16493,8 +16650,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=62638, - serialized_end=62790, + serialized_start=63233, + serialized_end=63385, ) _GETGROUPINFORESPONSE_GETGROUPINFORESPONSEV0_GROUPINFO = _descriptor.Descriptor( @@ -16529,8 +16686,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=62793, - serialized_end=62931, + serialized_start=63388, + serialized_end=63526, ) _GETGROUPINFORESPONSE_GETGROUPINFORESPONSEV0 = _descriptor.Descriptor( @@ -16579,8 +16736,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=62339, - serialized_end=62941, + serialized_start=62934, + serialized_end=63536, ) _GETGROUPINFORESPONSE = _descriptor.Descriptor( @@ -16615,8 +16772,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=62228, - serialized_end=62952, + serialized_start=62823, + serialized_end=63547, ) @@ -16654,8 +16811,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=63065, - serialized_end=63182, + serialized_start=63660, + serialized_end=63777, ) _GETGROUPINFOSREQUEST_GETGROUPINFOSREQUESTV0 = _descriptor.Descriptor( @@ -16716,8 +16873,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=63185, - serialized_end=63437, + serialized_start=63780, + serialized_end=64032, ) _GETGROUPINFOSREQUEST = _descriptor.Descriptor( @@ -16752,8 +16909,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=62955, - serialized_end=63448, + serialized_start=63550, + serialized_end=64043, ) @@ -16791,8 +16948,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=62583, - serialized_end=62635, + serialized_start=63178, + serialized_end=63230, ) _GETGROUPINFOSRESPONSE_GETGROUPINFOSRESPONSEV0_GROUPPOSITIONINFOENTRY = _descriptor.Descriptor( @@ -16836,8 +16993,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=63869, - serialized_end=64064, + serialized_start=64464, + serialized_end=64659, ) _GETGROUPINFOSRESPONSE_GETGROUPINFOSRESPONSEV0_GROUPINFOS = _descriptor.Descriptor( @@ -16867,8 +17024,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=64067, - serialized_end=64197, + serialized_start=64662, + serialized_end=64792, ) _GETGROUPINFOSRESPONSE_GETGROUPINFOSRESPONSEV0 = _descriptor.Descriptor( @@ -16917,8 +17074,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=63565, - serialized_end=64207, + serialized_start=64160, + serialized_end=64802, ) _GETGROUPINFOSRESPONSE = _descriptor.Descriptor( @@ -16953,8 +17110,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=63451, - serialized_end=64218, + serialized_start=64046, + serialized_end=64813, ) @@ -16992,8 +17149,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=64337, - serialized_end=64413, + serialized_start=64932, + serialized_end=65008, ) _GETGROUPACTIONSREQUEST_GETGROUPACTIONSREQUESTV0 = _descriptor.Descriptor( @@ -17068,8 +17225,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=64416, - serialized_end=64744, + serialized_start=65011, + serialized_end=65339, ) _GETGROUPACTIONSREQUEST = _descriptor.Descriptor( @@ -17105,8 +17262,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=64221, - serialized_end=64795, + serialized_start=64816, + serialized_end=65390, ) @@ -17156,8 +17313,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=65177, - serialized_end=65268, + serialized_start=65772, + serialized_end=65863, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_BURNEVENT = _descriptor.Descriptor( @@ -17206,8 +17363,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=65270, - serialized_end=65361, + serialized_start=65865, + serialized_end=65956, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_FREEZEEVENT = _descriptor.Descriptor( @@ -17249,8 +17406,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=65363, - serialized_end=65437, + serialized_start=65958, + serialized_end=66032, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_UNFREEZEEVENT = _descriptor.Descriptor( @@ -17292,8 +17449,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=65439, - serialized_end=65515, + serialized_start=66034, + serialized_end=66110, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_DESTROYFROZENFUNDSEVENT = _descriptor.Descriptor( @@ -17342,8 +17499,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=65517, - serialized_end=65619, + serialized_start=66112, + serialized_end=66214, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_SHAREDENCRYPTEDNOTE = _descriptor.Descriptor( @@ -17387,8 +17544,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=65621, - serialized_end=65721, + serialized_start=66216, + serialized_end=66316, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_PERSONALENCRYPTEDNOTE = _descriptor.Descriptor( @@ -17432,8 +17589,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=65723, - serialized_end=65846, + serialized_start=66318, + serialized_end=66441, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_EMERGENCYACTIONEVENT = _descriptor.Descriptor( @@ -17476,8 +17633,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=65849, - serialized_end=66082, + serialized_start=66444, + serialized_end=66677, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_TOKENCONFIGUPDATEEVENT = _descriptor.Descriptor( @@ -17519,8 +17676,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=66084, - serialized_end=66184, + serialized_start=66679, + serialized_end=66779, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_UPDATEDIRECTPURCHASEPRICEEVENT_PRICEFORQUANTITY = _descriptor.Descriptor( @@ -17557,8 +17714,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=57038, - serialized_end=57089, + serialized_start=57633, + serialized_end=57684, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_UPDATEDIRECTPURCHASEPRICEEVENT_PRICINGSCHEDULE = _descriptor.Descriptor( @@ -17588,8 +17745,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=66476, - serialized_end=66648, + serialized_start=67071, + serialized_end=67243, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_UPDATEDIRECTPURCHASEPRICEEVENT = _descriptor.Descriptor( @@ -17643,8 +17800,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=66187, - serialized_end=66673, + serialized_start=66782, + serialized_end=67268, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_GROUPACTIONEVENT = _descriptor.Descriptor( @@ -17693,8 +17850,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=66676, - serialized_end=67056, + serialized_start=67271, + serialized_end=67651, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_DOCUMENTEVENT = _descriptor.Descriptor( @@ -17729,8 +17886,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=67059, - serialized_end=67198, + serialized_start=67654, + serialized_end=67793, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_DOCUMENTCREATEEVENT = _descriptor.Descriptor( @@ -17760,8 +17917,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=67200, - serialized_end=67247, + serialized_start=67795, + serialized_end=67842, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_CONTRACTUPDATEEVENT = _descriptor.Descriptor( @@ -17791,8 +17948,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=67249, - serialized_end=67296, + serialized_start=67844, + serialized_end=67891, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_CONTRACTEVENT = _descriptor.Descriptor( @@ -17827,8 +17984,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=67299, - serialized_end=67438, + serialized_start=67894, + serialized_end=68033, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_TOKENEVENT = _descriptor.Descriptor( @@ -17912,8 +18069,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=67441, - serialized_end=68418, + serialized_start=68036, + serialized_end=69013, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_GROUPACTIONENTRY = _descriptor.Descriptor( @@ -17950,8 +18107,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=68421, - serialized_end=68568, + serialized_start=69016, + serialized_end=69163, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_GROUPACTIONS = _descriptor.Descriptor( @@ -17981,8 +18138,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=68571, - serialized_end=68703, + serialized_start=69166, + serialized_end=69298, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0 = _descriptor.Descriptor( @@ -18031,8 +18188,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=64918, - serialized_end=68713, + serialized_start=65513, + serialized_end=69308, ) _GETGROUPACTIONSRESPONSE = _descriptor.Descriptor( @@ -18067,8 +18224,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=64798, - serialized_end=68724, + serialized_start=65393, + serialized_end=69319, ) @@ -18127,8 +18284,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=68862, - serialized_end=69068, + serialized_start=69457, + serialized_end=69663, ) _GETGROUPACTIONSIGNERSREQUEST = _descriptor.Descriptor( @@ -18164,8 +18321,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=68727, - serialized_end=69119, + serialized_start=69322, + serialized_end=69714, ) @@ -18203,8 +18360,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=69551, - serialized_end=69604, + serialized_start=70146, + serialized_end=70199, ) _GETGROUPACTIONSIGNERSRESPONSE_GETGROUPACTIONSIGNERSRESPONSEV0_GROUPACTIONSIGNERS = _descriptor.Descriptor( @@ -18234,8 +18391,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=69607, - serialized_end=69752, + serialized_start=70202, + serialized_end=70347, ) _GETGROUPACTIONSIGNERSRESPONSE_GETGROUPACTIONSIGNERSRESPONSEV0 = _descriptor.Descriptor( @@ -18284,8 +18441,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=69260, - serialized_end=69762, + serialized_start=69855, + serialized_end=70357, ) _GETGROUPACTIONSIGNERSRESPONSE = _descriptor.Descriptor( @@ -18320,8 +18477,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=69122, - serialized_end=69773, + serialized_start=69717, + serialized_end=70368, ) @@ -18359,8 +18516,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=69889, - serialized_end=69946, + serialized_start=70484, + serialized_end=70541, ) _GETADDRESSINFOREQUEST = _descriptor.Descriptor( @@ -18395,8 +18552,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=69776, - serialized_end=69957, + serialized_start=70371, + serialized_end=70552, ) @@ -18439,8 +18596,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=69960, - serialized_end=70093, + serialized_start=70555, + serialized_end=70688, ) @@ -18478,8 +18635,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=70095, - serialized_end=70144, + serialized_start=70690, + serialized_end=70739, ) @@ -18510,8 +18667,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=70146, - serialized_end=70241, + serialized_start=70741, + serialized_end=70836, ) @@ -18561,8 +18718,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=70243, - serialized_end=70352, + serialized_start=70838, + serialized_end=70947, ) @@ -18600,8 +18757,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=70354, - serialized_end=70474, + serialized_start=70949, + serialized_end=71069, ) @@ -18632,8 +18789,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=70476, - serialized_end=70583, + serialized_start=71071, + serialized_end=71178, ) @@ -18683,8 +18840,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=70703, - serialized_end=70928, + serialized_start=71298, + serialized_end=71523, ) _GETADDRESSINFORESPONSE = _descriptor.Descriptor( @@ -18719,8 +18876,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=70586, - serialized_end=70939, + serialized_start=71181, + serialized_end=71534, ) @@ -18758,8 +18915,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=71064, - serialized_end=71126, + serialized_start=71659, + serialized_end=71721, ) _GETADDRESSESINFOSREQUEST = _descriptor.Descriptor( @@ -18794,8 +18951,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=70942, - serialized_end=71137, + serialized_start=71537, + serialized_end=71732, ) @@ -18845,8 +19002,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=71266, - serialized_end=71498, + serialized_start=71861, + serialized_end=72093, ) _GETADDRESSESINFOSRESPONSE = _descriptor.Descriptor( @@ -18881,8 +19038,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=71140, - serialized_end=71509, + serialized_start=71735, + serialized_end=72104, ) @@ -18906,8 +19063,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=71649, - serialized_end=71682, + serialized_start=72244, + serialized_end=72277, ) _GETADDRESSESTRUNKSTATEREQUEST = _descriptor.Descriptor( @@ -18942,8 +19099,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=71512, - serialized_end=71693, + serialized_start=72107, + serialized_end=72288, ) @@ -18981,8 +19138,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=71837, - serialized_end=71983, + serialized_start=72432, + serialized_end=72578, ) _GETADDRESSESTRUNKSTATERESPONSE = _descriptor.Descriptor( @@ -19017,8 +19174,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=71696, - serialized_end=71994, + serialized_start=72291, + serialized_end=72589, ) @@ -19063,8 +19220,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=72137, - serialized_end=72226, + serialized_start=72732, + serialized_end=72821, ) _GETADDRESSESBRANCHSTATEREQUEST = _descriptor.Descriptor( @@ -19099,8 +19256,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=71997, - serialized_end=72237, + serialized_start=72592, + serialized_end=72832, ) @@ -19131,8 +19288,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=72383, - serialized_end=72438, + serialized_start=72978, + serialized_end=73033, ) _GETADDRESSESBRANCHSTATERESPONSE = _descriptor.Descriptor( @@ -19167,8 +19324,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=72240, - serialized_end=72449, + serialized_start=72835, + serialized_end=73044, ) @@ -19213,8 +19370,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=72613, - serialized_end=72727, + serialized_start=73208, + serialized_end=73322, ) _GETRECENTADDRESSBALANCECHANGESREQUEST = _descriptor.Descriptor( @@ -19249,8 +19406,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=72452, - serialized_end=72738, + serialized_start=73047, + serialized_end=73333, ) @@ -19300,8 +19457,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=72906, - serialized_end=73170, + serialized_start=73501, + serialized_end=73765, ) _GETRECENTADDRESSBALANCECHANGESRESPONSE = _descriptor.Descriptor( @@ -19336,8 +19493,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=72741, - serialized_end=73181, + serialized_start=73336, + serialized_end=73776, ) @@ -19375,8 +19532,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=73183, - serialized_end=73254, + serialized_start=73778, + serialized_end=73849, ) @@ -19426,8 +19583,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=73257, - serialized_end=73433, + serialized_start=73852, + serialized_end=74028, ) @@ -19458,8 +19615,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=73435, - serialized_end=73527, + serialized_start=74030, + serialized_end=74122, ) @@ -19504,8 +19661,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=73530, - serialized_end=73704, + serialized_start=74125, + serialized_end=74299, ) @@ -19536,8 +19693,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=73707, - serialized_end=73842, + serialized_start=74302, + serialized_end=74437, ) @@ -19575,8 +19732,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=74034, - serialized_end=74131, + serialized_start=74629, + serialized_end=74726, ) _GETRECENTCOMPACTEDADDRESSBALANCECHANGESREQUEST = _descriptor.Descriptor( @@ -19611,8 +19768,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=73845, - serialized_end=74142, + serialized_start=74440, + serialized_end=74737, ) @@ -19662,8 +19819,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=74338, - serialized_end=74630, + serialized_start=74933, + serialized_end=75225, ) _GETRECENTCOMPACTEDADDRESSBALANCECHANGESRESPONSE = _descriptor.Descriptor( @@ -19698,8 +19855,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=74145, - serialized_end=74641, + serialized_start=74740, + serialized_end=75236, ) @@ -19744,8 +19901,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=74790, - serialized_end=74877, + serialized_start=75385, + serialized_end=75472, ) _GETSHIELDEDENCRYPTEDNOTESREQUEST = _descriptor.Descriptor( @@ -19780,8 +19937,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=74644, - serialized_end=74888, + serialized_start=75239, + serialized_end=75483, ) @@ -19833,8 +19990,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=75335, - serialized_end=75422, + serialized_start=75930, + serialized_end=76017, ) _GETSHIELDEDENCRYPTEDNOTESRESPONSE_GETSHIELDEDENCRYPTEDNOTESRESPONSEV0_ENCRYPTEDNOTES = _descriptor.Descriptor( @@ -19864,8 +20021,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=75425, - serialized_end=75570, + serialized_start=76020, + serialized_end=76165, ) _GETSHIELDEDENCRYPTEDNOTESRESPONSE_GETSHIELDEDENCRYPTEDNOTESRESPONSEV0 = _descriptor.Descriptor( @@ -19914,8 +20071,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=75041, - serialized_end=75580, + serialized_start=75636, + serialized_end=76175, ) _GETSHIELDEDENCRYPTEDNOTESRESPONSE = _descriptor.Descriptor( @@ -19950,8 +20107,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=74891, - serialized_end=75591, + serialized_start=75486, + serialized_end=76186, ) @@ -19982,8 +20139,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=75719, - serialized_end=75763, + serialized_start=76314, + serialized_end=76358, ) _GETSHIELDEDANCHORSREQUEST = _descriptor.Descriptor( @@ -20018,8 +20175,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=75594, - serialized_end=75774, + serialized_start=76189, + serialized_end=76369, ) @@ -20050,8 +20207,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=76163, - serialized_end=76189, + serialized_start=76758, + serialized_end=76784, ) _GETSHIELDEDANCHORSRESPONSE_GETSHIELDEDANCHORSRESPONSEV0 = _descriptor.Descriptor( @@ -20100,8 +20257,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=75906, - serialized_end=76199, + serialized_start=76501, + serialized_end=76794, ) _GETSHIELDEDANCHORSRESPONSE = _descriptor.Descriptor( @@ -20136,8 +20293,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=75777, - serialized_end=76210, + serialized_start=76372, + serialized_end=76805, ) @@ -20168,8 +20325,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=76365, - serialized_end=76418, + serialized_start=76960, + serialized_end=77013, ) _GETMOSTRECENTSHIELDEDANCHORREQUEST = _descriptor.Descriptor( @@ -20204,8 +20361,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=76213, - serialized_end=76429, + serialized_start=76808, + serialized_end=77024, ) @@ -20255,8 +20412,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=76588, - serialized_end=76769, + serialized_start=77183, + serialized_end=77364, ) _GETMOSTRECENTSHIELDEDANCHORRESPONSE = _descriptor.Descriptor( @@ -20291,8 +20448,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=76432, - serialized_end=76780, + serialized_start=77027, + serialized_end=77375, ) @@ -20323,8 +20480,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=76914, - serialized_end=76960, + serialized_start=77509, + serialized_end=77555, ) _GETSHIELDEDPOOLSTATEREQUEST = _descriptor.Descriptor( @@ -20359,8 +20516,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=76783, - serialized_end=76971, + serialized_start=77378, + serialized_end=77566, ) @@ -20410,8 +20567,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=77109, - serialized_end=77294, + serialized_start=77704, + serialized_end=77889, ) _GETSHIELDEDPOOLSTATERESPONSE = _descriptor.Descriptor( @@ -20446,8 +20603,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=76974, - serialized_end=77305, + serialized_start=77569, + serialized_end=77900, ) @@ -20478,8 +20635,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=77442, - serialized_end=77489, + serialized_start=78037, + serialized_end=78084, ) _GETSHIELDEDNOTESCOUNTREQUEST = _descriptor.Descriptor( @@ -20514,8 +20671,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=77308, - serialized_end=77500, + serialized_start=77903, + serialized_end=78095, ) @@ -20565,8 +20722,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=77641, - serialized_end=77831, + serialized_start=78236, + serialized_end=78426, ) _GETSHIELDEDNOTESCOUNTRESPONSE = _descriptor.Descriptor( @@ -20601,8 +20758,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=77503, - serialized_end=77842, + serialized_start=78098, + serialized_end=78437, ) @@ -20640,8 +20797,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=77979, - serialized_end=78046, + serialized_start=78574, + serialized_end=78641, ) _GETSHIELDEDNULLIFIERSREQUEST = _descriptor.Descriptor( @@ -20676,8 +20833,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=77845, - serialized_end=78057, + serialized_start=78440, + serialized_end=78652, ) @@ -20715,8 +20872,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=78486, - serialized_end=78540, + serialized_start=79081, + serialized_end=79135, ) _GETSHIELDEDNULLIFIERSRESPONSE_GETSHIELDEDNULLIFIERSRESPONSEV0_NULLIFIERSTATUSES = _descriptor.Descriptor( @@ -20746,8 +20903,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=78543, - serialized_end=78685, + serialized_start=79138, + serialized_end=79280, ) _GETSHIELDEDNULLIFIERSRESPONSE_GETSHIELDEDNULLIFIERSRESPONSEV0 = _descriptor.Descriptor( @@ -20796,8 +20953,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=78198, - serialized_end=78695, + serialized_start=78793, + serialized_end=79290, ) _GETSHIELDEDNULLIFIERSRESPONSE = _descriptor.Descriptor( @@ -20832,8 +20989,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=78060, - serialized_end=78706, + serialized_start=78655, + serialized_end=79301, ) _GETIDENTITYREQUEST_GETIDENTITYREQUESTV0.containing_type = _GETIDENTITYREQUEST @@ -21224,9 +21381,11 @@ _GETCONTRACTGROUPMEMBERSRESPONSE.oneofs_by_name['version'].fields.append( _GETCONTRACTGROUPMEMBERSRESPONSE.fields_by_name['v0']) _GETCONTRACTGROUPMEMBERSRESPONSE.fields_by_name['v0'].containing_oneof = _GETCONTRACTGROUPMEMBERSRESPONSE.oneofs_by_name['version'] +_CONTRACTMODERATIONREASON.fields_by_name['documents'].message_type = _CONTRACTMODERATIONDOCUMENT _CONTRACTMODERATIONREASON.oneofs_by_name['_code'].fields.append( _CONTRACTMODERATIONREASON.fields_by_name['code']) _CONTRACTMODERATIONREASON.fields_by_name['code'].containing_oneof = _CONTRACTMODERATIONREASON.oneofs_by_name['_code'] +_CONTRACTWARNING.fields_by_name['reason'].message_type = _CONTRACTMODERATIONREASON _GETCONTRACTMODERATIONSTATUSREQUEST_GETCONTRACTMODERATIONSTATUSREQUESTV0.fields_by_name['lists'].enum_type = _CONTRACTMODERATIONLIST _GETCONTRACTMODERATIONSTATUSREQUEST_GETCONTRACTMODERATIONSTATUSREQUESTV0.containing_type = _GETCONTRACTMODERATIONSTATUSREQUEST _GETCONTRACTMODERATIONSTATUSREQUEST.fields_by_name['v0'].message_type = _GETCONTRACTMODERATIONSTATUSREQUEST_GETCONTRACTMODERATIONSTATUSREQUESTV0 @@ -21236,6 +21395,7 @@ _GETCONTRACTMODERATIONSTATUSRESPONSE_CONTRACTMODERATIONSTATUS.fields_by_name['lists'].enum_type = _CONTRACTMODERATIONLIST _GETCONTRACTMODERATIONSTATUSRESPONSE_CONTRACTMODERATIONSTATUS.fields_by_name['ban_reason'].message_type = _CONTRACTMODERATIONREASON _GETCONTRACTMODERATIONSTATUSRESPONSE_CONTRACTMODERATIONSTATUS.fields_by_name['suspension_reason'].message_type = _CONTRACTMODERATIONREASON +_GETCONTRACTMODERATIONSTATUSRESPONSE_CONTRACTMODERATIONSTATUS.fields_by_name['warnings'].message_type = _CONTRACTWARNING _GETCONTRACTMODERATIONSTATUSRESPONSE_CONTRACTMODERATIONSTATUS.containing_type = _GETCONTRACTMODERATIONSTATUSRESPONSE _GETCONTRACTMODERATIONSTATUSRESPONSE_CONTRACTMODERATIONSTATUS.oneofs_by_name['_banned'].fields.append( _GETCONTRACTMODERATIONSTATUSRESPONSE_CONTRACTMODERATIONSTATUS.fields_by_name['banned']) @@ -21276,6 +21436,7 @@ _GETCONTRACTMODERATIONENTRIESREQUEST.fields_by_name['v0']) _GETCONTRACTMODERATIONENTRIESREQUEST.fields_by_name['v0'].containing_oneof = _GETCONTRACTMODERATIONENTRIESREQUEST.oneofs_by_name['version'] _GETCONTRACTMODERATIONENTRIESRESPONSE_CONTRACTMODERATIONENTRY.fields_by_name['reason'].message_type = _CONTRACTMODERATIONREASON +_GETCONTRACTMODERATIONENTRIESRESPONSE_CONTRACTMODERATIONENTRY.fields_by_name['warnings'].message_type = _CONTRACTWARNING _GETCONTRACTMODERATIONENTRIESRESPONSE_CONTRACTMODERATIONENTRY.containing_type = _GETCONTRACTMODERATIONENTRIESRESPONSE _GETCONTRACTMODERATIONENTRIESRESPONSE_CONTRACTMODERATIONENTRY.oneofs_by_name['_until'].fields.append( _GETCONTRACTMODERATIONENTRIESRESPONSE_CONTRACTMODERATIONENTRY.fields_by_name['until']) @@ -21317,7 +21478,9 @@ _GETCONTRACTDOCUMENTREMOVALSREQUEST.oneofs_by_name['version'].fields.append( _GETCONTRACTDOCUMENTREMOVALSREQUEST.fields_by_name['v0']) _GETCONTRACTDOCUMENTREMOVALSREQUEST.fields_by_name['v0'].containing_oneof = _GETCONTRACTDOCUMENTREMOVALSREQUEST.oneofs_by_name['version'] +_GETCONTRACTDOCUMENTREMOVALSRESPONSE_CONTRACTDOCUMENTRESTORATION.containing_type = _GETCONTRACTDOCUMENTREMOVALSRESPONSE _GETCONTRACTDOCUMENTREMOVALSRESPONSE_CONTRACTDOCUMENTREMOVAL.fields_by_name['reason'].message_type = _CONTRACTMODERATIONREASON +_GETCONTRACTDOCUMENTREMOVALSRESPONSE_CONTRACTDOCUMENTREMOVAL.fields_by_name['restoration'].message_type = _GETCONTRACTDOCUMENTREMOVALSRESPONSE_CONTRACTDOCUMENTRESTORATION _GETCONTRACTDOCUMENTREMOVALSRESPONSE_CONTRACTDOCUMENTREMOVAL.containing_type = _GETCONTRACTDOCUMENTREMOVALSRESPONSE _GETCONTRACTDOCUMENTREMOVALSRESPONSE_CONTRACTDOCUMENTREMOVALS.fields_by_name['removals'].message_type = _GETCONTRACTDOCUMENTREMOVALSRESPONSE_CONTRACTDOCUMENTREMOVAL _GETCONTRACTDOCUMENTREMOVALSRESPONSE_CONTRACTDOCUMENTREMOVALS.containing_type = _GETCONTRACTDOCUMENTREMOVALSRESPONSE @@ -22965,7 +23128,9 @@ DESCRIPTOR.message_types_by_name['GetContractGroupInfoResponse'] = _GETCONTRACTGROUPINFORESPONSE DESCRIPTOR.message_types_by_name['GetContractGroupMembersRequest'] = _GETCONTRACTGROUPMEMBERSREQUEST DESCRIPTOR.message_types_by_name['GetContractGroupMembersResponse'] = _GETCONTRACTGROUPMEMBERSRESPONSE +DESCRIPTOR.message_types_by_name['ContractModerationDocument'] = _CONTRACTMODERATIONDOCUMENT DESCRIPTOR.message_types_by_name['ContractModerationReason'] = _CONTRACTMODERATIONREASON +DESCRIPTOR.message_types_by_name['ContractWarning'] = _CONTRACTWARNING DESCRIPTOR.message_types_by_name['GetContractModerationStatusRequest'] = _GETCONTRACTMODERATIONSTATUSREQUEST DESCRIPTOR.message_types_by_name['GetContractModerationStatusResponse'] = _GETCONTRACTMODERATIONSTATUSRESPONSE DESCRIPTOR.message_types_by_name['GetContractModerationEntriesRequest'] = _GETCONTRACTMODERATIONENTRIESREQUEST @@ -23783,6 +23948,13 @@ _sym_db.RegisterMessage(GetContractGroupMembersResponse.TokenMembers) _sym_db.RegisterMessage(GetContractGroupMembersResponse.GetContractGroupMembersResponseV0) +ContractModerationDocument = _reflection.GeneratedProtocolMessageType('ContractModerationDocument', (_message.Message,), { + 'DESCRIPTOR' : _CONTRACTMODERATIONDOCUMENT, + '__module__' : 'platform_pb2' + # @@protoc_insertion_point(class_scope:org.dash.platform.dapi.v0.ContractModerationDocument) + }) +_sym_db.RegisterMessage(ContractModerationDocument) + ContractModerationReason = _reflection.GeneratedProtocolMessageType('ContractModerationReason', (_message.Message,), { 'DESCRIPTOR' : _CONTRACTMODERATIONREASON, '__module__' : 'platform_pb2' @@ -23790,6 +23962,13 @@ }) _sym_db.RegisterMessage(ContractModerationReason) +ContractWarning = _reflection.GeneratedProtocolMessageType('ContractWarning', (_message.Message,), { + 'DESCRIPTOR' : _CONTRACTWARNING, + '__module__' : 'platform_pb2' + # @@protoc_insertion_point(class_scope:org.dash.platform.dapi.v0.ContractWarning) + }) +_sym_db.RegisterMessage(ContractWarning) + GetContractModerationStatusRequest = _reflection.GeneratedProtocolMessageType('GetContractModerationStatusRequest', (_message.Message,), { 'GetContractModerationStatusRequestV0' : _reflection.GeneratedProtocolMessageType('GetContractModerationStatusRequestV0', (_message.Message,), { @@ -23907,6 +24086,13 @@ GetContractDocumentRemovalsResponse = _reflection.GeneratedProtocolMessageType('GetContractDocumentRemovalsResponse', (_message.Message,), { + 'ContractDocumentRestoration' : _reflection.GeneratedProtocolMessageType('ContractDocumentRestoration', (_message.Message,), { + 'DESCRIPTOR' : _GETCONTRACTDOCUMENTREMOVALSRESPONSE_CONTRACTDOCUMENTRESTORATION, + '__module__' : 'platform_pb2' + # @@protoc_insertion_point(class_scope:org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration) + }) + , + 'ContractDocumentRemoval' : _reflection.GeneratedProtocolMessageType('ContractDocumentRemoval', (_message.Message,), { 'DESCRIPTOR' : _GETCONTRACTDOCUMENTREMOVALSRESPONSE_CONTRACTDOCUMENTREMOVAL, '__module__' : 'platform_pb2' @@ -23932,6 +24118,7 @@ # @@protoc_insertion_point(class_scope:org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse) }) _sym_db.RegisterMessage(GetContractDocumentRemovalsResponse) +_sym_db.RegisterMessage(GetContractDocumentRemovalsResponse.ContractDocumentRestoration) _sym_db.RegisterMessage(GetContractDocumentRemovalsResponse.ContractDocumentRemoval) _sym_db.RegisterMessage(GetContractDocumentRemovalsResponse.ContractDocumentRemovals) _sym_db.RegisterMessage(GetContractDocumentRemovalsResponse.GetContractDocumentRemovalsResponseV0) @@ -26775,8 +26962,8 @@ index=0, serialized_options=None, create_key=_descriptor._internal_create_key, - serialized_start=78950, - serialized_end=89270, + serialized_start=79584, + serialized_end=89904, methods=[ _descriptor.MethodDescriptor( name='broadcastStateTransition', diff --git a/packages/dapi-grpc/clients/platform/v0/web/platform_pb.d.ts b/packages/dapi-grpc/clients/platform/v0/web/platform_pb.d.ts index 622498c4d27..f0dfa62cdb8 100644 --- a/packages/dapi-grpc/clients/platform/v0/web/platform_pb.d.ts +++ b/packages/dapi-grpc/clients/platform/v0/web/platform_pb.d.ts @@ -2768,6 +2768,32 @@ export namespace GetContractGroupMembersResponse { } } +export class ContractModerationDocument extends jspb.Message { + getDocumentTypeName(): string; + setDocumentTypeName(value: string): void; + + getDocumentId(): Uint8Array | string; + getDocumentId_asU8(): Uint8Array; + getDocumentId_asB64(): string; + setDocumentId(value: Uint8Array | string): void; + + serializeBinary(): Uint8Array; + toObject(includeInstance?: boolean): ContractModerationDocument.AsObject; + static toObject(includeInstance: boolean, msg: ContractModerationDocument): ContractModerationDocument.AsObject; + static extensions: {[key: number]: jspb.ExtensionFieldInfo}; + static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; + static serializeBinaryToWriter(message: ContractModerationDocument, writer: jspb.BinaryWriter): void; + static deserializeBinary(bytes: Uint8Array): ContractModerationDocument; + static deserializeBinaryFromReader(message: ContractModerationDocument, reader: jspb.BinaryReader): ContractModerationDocument; +} + +export namespace ContractModerationDocument { + export type AsObject = { + documentTypeName: string, + documentId: Uint8Array | string, + } +} + export class ContractModerationReason extends jspb.Message { hasCode(): boolean; clearCode(): void; @@ -2777,6 +2803,11 @@ export class ContractModerationReason extends jspb.Message { getText(): string; setText(value: string): void; + clearDocumentsList(): void; + getDocumentsList(): Array; + setDocumentsList(value: Array): void; + addDocuments(value?: ContractModerationDocument, index?: number): ContractModerationDocument; + serializeBinary(): Uint8Array; toObject(includeInstance?: boolean): ContractModerationReason.AsObject; static toObject(includeInstance: boolean, msg: ContractModerationReason): ContractModerationReason.AsObject; @@ -2791,6 +2822,33 @@ export namespace ContractModerationReason { export type AsObject = { code: number, text: string, + documentsList: Array, + } +} + +export class ContractWarning extends jspb.Message { + getWarnedAt(): number; + setWarnedAt(value: number): void; + + hasReason(): boolean; + clearReason(): void; + getReason(): ContractModerationReason | undefined; + setReason(value?: ContractModerationReason): void; + + serializeBinary(): Uint8Array; + toObject(includeInstance?: boolean): ContractWarning.AsObject; + static toObject(includeInstance: boolean, msg: ContractWarning): ContractWarning.AsObject; + static extensions: {[key: number]: jspb.ExtensionFieldInfo}; + static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; + static serializeBinaryToWriter(message: ContractWarning, writer: jspb.BinaryWriter): void; + static deserializeBinary(bytes: Uint8Array): ContractWarning; + static deserializeBinaryFromReader(message: ContractWarning, reader: jspb.BinaryReader): ContractWarning; +} + +export namespace ContractWarning { + export type AsObject = { + warnedAt: number, + reason?: ContractModerationReason.AsObject, } } @@ -2908,6 +2966,11 @@ export namespace GetContractModerationStatusResponse { getSuspensionReason(): ContractModerationReason | undefined; setSuspensionReason(value?: ContractModerationReason): void; + clearWarningsList(): void; + getWarningsList(): Array; + setWarningsList(value: Array): void; + addWarnings(value?: ContractWarning, index?: number): ContractWarning; + serializeBinary(): Uint8Array; toObject(includeInstance?: boolean): ContractModerationStatus.AsObject; static toObject(includeInstance: boolean, msg: ContractModerationStatus): ContractModerationStatus.AsObject; @@ -2925,6 +2988,7 @@ export namespace GetContractModerationStatusResponse { listsList: Array, banReason?: ContractModerationReason.AsObject, suspensionReason?: ContractModerationReason.AsObject, + warningsList: Array, } } @@ -3085,6 +3149,11 @@ export namespace GetContractModerationEntriesResponse { getReason(): ContractModerationReason | undefined; setReason(value?: ContractModerationReason): void; + clearWarningsList(): void; + getWarningsList(): Array; + setWarningsList(value: Array): void; + addWarnings(value?: ContractWarning, index?: number): ContractWarning; + serializeBinary(): Uint8Array; toObject(includeInstance?: boolean): ContractModerationEntry.AsObject; static toObject(includeInstance: boolean, msg: ContractModerationEntry): ContractModerationEntry.AsObject; @@ -3100,6 +3169,7 @@ export namespace GetContractModerationEntriesResponse { identityId: Uint8Array | string, until: number, reason?: ContractModerationReason.AsObject, + warningsList: Array, } } @@ -3325,6 +3395,32 @@ export namespace GetContractDocumentRemovalsResponse { v0?: GetContractDocumentRemovalsResponse.GetContractDocumentRemovalsResponseV0.AsObject, } + export class ContractDocumentRestoration extends jspb.Message { + getModeratorId(): Uint8Array | string; + getModeratorId_asU8(): Uint8Array; + getModeratorId_asB64(): string; + setModeratorId(value: Uint8Array | string): void; + + getRestoredAt(): number; + setRestoredAt(value: number): void; + + serializeBinary(): Uint8Array; + toObject(includeInstance?: boolean): ContractDocumentRestoration.AsObject; + static toObject(includeInstance: boolean, msg: ContractDocumentRestoration): ContractDocumentRestoration.AsObject; + static extensions: {[key: number]: jspb.ExtensionFieldInfo}; + static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; + static serializeBinaryToWriter(message: ContractDocumentRestoration, writer: jspb.BinaryWriter): void; + static deserializeBinary(bytes: Uint8Array): ContractDocumentRestoration; + static deserializeBinaryFromReader(message: ContractDocumentRestoration, reader: jspb.BinaryReader): ContractDocumentRestoration; + } + + export namespace ContractDocumentRestoration { + export type AsObject = { + moderatorId: Uint8Array | string, + restoredAt: number, + } + } + export class ContractDocumentRemoval extends jspb.Message { getDocumentId(): Uint8Array | string; getDocumentId_asU8(): Uint8Array; @@ -3349,6 +3445,16 @@ export namespace GetContractDocumentRemovalsResponse { getReason(): ContractModerationReason | undefined; setReason(value?: ContractModerationReason): void; + getDocumentHash(): Uint8Array | string; + getDocumentHash_asU8(): Uint8Array; + getDocumentHash_asB64(): string; + setDocumentHash(value: Uint8Array | string): void; + + hasRestoration(): boolean; + clearRestoration(): void; + getRestoration(): GetContractDocumentRemovalsResponse.ContractDocumentRestoration | undefined; + setRestoration(value?: GetContractDocumentRemovalsResponse.ContractDocumentRestoration): void; + serializeBinary(): Uint8Array; toObject(includeInstance?: boolean): ContractDocumentRemoval.AsObject; static toObject(includeInstance: boolean, msg: ContractDocumentRemoval): ContractDocumentRemoval.AsObject; @@ -3366,6 +3472,8 @@ export namespace GetContractDocumentRemovalsResponse { moderatorId: Uint8Array | string, removedAt: number, reason?: ContractModerationReason.AsObject, + documentHash: Uint8Array | string, + restoration?: GetContractDocumentRemovalsResponse.ContractDocumentRestoration.AsObject, } } @@ -14879,6 +14987,7 @@ export interface ContractModerationListMap { CONTRACT_MODERATION_LIST_UNSPECIFIED: 0; CONTRACT_MODERATION_LIST_BANLIST: 1; CONTRACT_MODERATION_LIST_SUSPENSIONS: 2; + CONTRACT_MODERATION_LIST_WARNINGS: 3; } export const ContractModerationList: ContractModerationListMap; diff --git a/packages/dapi-grpc/clients/platform/v0/web/platform_pb.js b/packages/dapi-grpc/clients/platform/v0/web/platform_pb.js index 150c073de43..8fe2b482632 100644 --- a/packages/dapi-grpc/clients/platform/v0/web/platform_pb.js +++ b/packages/dapi-grpc/clients/platform/v0/web/platform_pb.js @@ -39,8 +39,10 @@ goog.exportSymbol('proto.org.dash.platform.dapi.v0.CompactedAddressBalanceUpdate goog.exportSymbol('proto.org.dash.platform.dapi.v0.CompactedBlockAddressBalanceChanges', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.ContractGroupDocumentTypeMember', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.ContractGroupTokenMember', null, { proto }); +goog.exportSymbol('proto.org.dash.platform.dapi.v0.ContractModerationDocument', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.ContractModerationList', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.ContractModerationReason', null, { proto }); +goog.exportSymbol('proto.org.dash.platform.dapi.v0.ContractWarning', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.GetAddressInfoRequest', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.GetAddressInfoRequest.GetAddressInfoRequestV0', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.GetAddressInfoRequest.VersionCase', null, { proto }); @@ -127,6 +129,7 @@ goog.exportSymbol('proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsRe goog.exportSymbol('proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemovals', null, { proto }); +goog.exportSymbol('proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.GetContractDocumentRemovalsResponseV0', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.GetContractDocumentRemovalsResponseV0.ResultCase', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.VersionCase', null, { proto }); @@ -2628,9 +2631,30 @@ if (goog.DEBUG && !COMPILED) { * @extends {jspb.Message} * @constructor */ -proto.org.dash.platform.dapi.v0.ContractModerationReason = function(opt_data) { +proto.org.dash.platform.dapi.v0.ContractModerationDocument = function(opt_data) { jspb.Message.initialize(this, opt_data, 0, -1, null, null); }; +goog.inherits(proto.org.dash.platform.dapi.v0.ContractModerationDocument, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.org.dash.platform.dapi.v0.ContractModerationDocument.displayName = 'proto.org.dash.platform.dapi.v0.ContractModerationDocument'; +} +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.org.dash.platform.dapi.v0.ContractModerationReason = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, proto.org.dash.platform.dapi.v0.ContractModerationReason.repeatedFields_, null); +}; goog.inherits(proto.org.dash.platform.dapi.v0.ContractModerationReason, jspb.Message); if (goog.DEBUG && !COMPILED) { /** @@ -2639,6 +2663,27 @@ if (goog.DEBUG && !COMPILED) { */ proto.org.dash.platform.dapi.v0.ContractModerationReason.displayName = 'proto.org.dash.platform.dapi.v0.ContractModerationReason'; } +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.org.dash.platform.dapi.v0.ContractWarning = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, null, null); +}; +goog.inherits(proto.org.dash.platform.dapi.v0.ContractWarning, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.org.dash.platform.dapi.v0.ContractWarning.displayName = 'proto.org.dash.platform.dapi.v0.ContractWarning'; +} /** * Generated by JsPbCodeGenerator. * @param {Array=} opt_data Optional initial data array, typically from a @@ -2818,7 +2863,7 @@ if (goog.DEBUG && !COMPILED) { * @constructor */ proto.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry = function(opt_data) { - jspb.Message.initialize(this, opt_data, 0, -1, null, null); + jspb.Message.initialize(this, opt_data, 0, -1, proto.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry.repeatedFields_, null); }; goog.inherits(proto.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry, jspb.Message); if (goog.DEBUG && !COMPILED) { @@ -2975,6 +3020,27 @@ if (goog.DEBUG && !COMPILED) { */ proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.displayName = 'proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse'; } +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, null, null); +}; +goog.inherits(proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.displayName = 'proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration'; +} /** * Generated by JsPbCodeGenerator. * @param {Array=} opt_data Optional initial data array, typically from a @@ -29518,6 +29584,197 @@ proto.org.dash.platform.dapi.v0.GetContractGroupMembersResponse.prototype.hasV0 +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.org.dash.platform.dapi.v0.ContractModerationDocument.prototype.toObject = function(opt_includeInstance) { + return proto.org.dash.platform.dapi.v0.ContractModerationDocument.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.org.dash.platform.dapi.v0.ContractModerationDocument} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.org.dash.platform.dapi.v0.ContractModerationDocument.toObject = function(includeInstance, msg) { + var f, obj = { + documentTypeName: jspb.Message.getFieldWithDefault(msg, 1, ""), + documentId: msg.getDocumentId_asB64() + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.org.dash.platform.dapi.v0.ContractModerationDocument} + */ +proto.org.dash.platform.dapi.v0.ContractModerationDocument.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.org.dash.platform.dapi.v0.ContractModerationDocument; + return proto.org.dash.platform.dapi.v0.ContractModerationDocument.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.org.dash.platform.dapi.v0.ContractModerationDocument} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.org.dash.platform.dapi.v0.ContractModerationDocument} + */ +proto.org.dash.platform.dapi.v0.ContractModerationDocument.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = /** @type {string} */ (reader.readString()); + msg.setDocumentTypeName(value); + break; + case 2: + var value = /** @type {!Uint8Array} */ (reader.readBytes()); + msg.setDocumentId(value); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.org.dash.platform.dapi.v0.ContractModerationDocument.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.org.dash.platform.dapi.v0.ContractModerationDocument.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.org.dash.platform.dapi.v0.ContractModerationDocument} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.org.dash.platform.dapi.v0.ContractModerationDocument.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = message.getDocumentTypeName(); + if (f.length > 0) { + writer.writeString( + 1, + f + ); + } + f = message.getDocumentId_asU8(); + if (f.length > 0) { + writer.writeBytes( + 2, + f + ); + } +}; + + +/** + * optional string document_type_name = 1; + * @return {string} + */ +proto.org.dash.platform.dapi.v0.ContractModerationDocument.prototype.getDocumentTypeName = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 1, "")); +}; + + +/** + * @param {string} value + * @return {!proto.org.dash.platform.dapi.v0.ContractModerationDocument} returns this + */ +proto.org.dash.platform.dapi.v0.ContractModerationDocument.prototype.setDocumentTypeName = function(value) { + return jspb.Message.setProto3StringField(this, 1, value); +}; + + +/** + * optional bytes document_id = 2; + * @return {string} + */ +proto.org.dash.platform.dapi.v0.ContractModerationDocument.prototype.getDocumentId = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 2, "")); +}; + + +/** + * optional bytes document_id = 2; + * This is a type-conversion wrapper around `getDocumentId()` + * @return {string} + */ +proto.org.dash.platform.dapi.v0.ContractModerationDocument.prototype.getDocumentId_asB64 = function() { + return /** @type {string} */ (jspb.Message.bytesAsB64( + this.getDocumentId())); +}; + + +/** + * optional bytes document_id = 2; + * Note that Uint8Array is not supported on all browsers. + * @see http://caniuse.com/Uint8Array + * This is a type-conversion wrapper around `getDocumentId()` + * @return {!Uint8Array} + */ +proto.org.dash.platform.dapi.v0.ContractModerationDocument.prototype.getDocumentId_asU8 = function() { + return /** @type {!Uint8Array} */ (jspb.Message.bytesAsU8( + this.getDocumentId())); +}; + + +/** + * @param {!(string|Uint8Array)} value + * @return {!proto.org.dash.platform.dapi.v0.ContractModerationDocument} returns this + */ +proto.org.dash.platform.dapi.v0.ContractModerationDocument.prototype.setDocumentId = function(value) { + return jspb.Message.setProto3BytesField(this, 2, value); +}; + + + +/** + * List of repeated fields within this message type. + * @private {!Array} + * @const + */ +proto.org.dash.platform.dapi.v0.ContractModerationReason.repeatedFields_ = [3]; + + + if (jspb.Message.GENERATE_TO_OBJECT) { /** * Creates an object representation of this proto. @@ -29548,7 +29805,9 @@ proto.org.dash.platform.dapi.v0.ContractModerationReason.prototype.toObject = fu proto.org.dash.platform.dapi.v0.ContractModerationReason.toObject = function(includeInstance, msg) { var f, obj = { code: jspb.Message.getFieldWithDefault(msg, 1, 0), - text: jspb.Message.getFieldWithDefault(msg, 2, "") + text: jspb.Message.getFieldWithDefault(msg, 2, ""), + documentsList: jspb.Message.toObjectList(msg.getDocumentsList(), + proto.org.dash.platform.dapi.v0.ContractModerationDocument.toObject, includeInstance) }; if (includeInstance) { @@ -29593,6 +29852,11 @@ proto.org.dash.platform.dapi.v0.ContractModerationReason.deserializeBinaryFromRe var value = /** @type {string} */ (reader.readString()); msg.setText(value); break; + case 3: + var value = new proto.org.dash.platform.dapi.v0.ContractModerationDocument; + reader.readMessage(value,proto.org.dash.platform.dapi.v0.ContractModerationDocument.deserializeBinaryFromReader); + msg.addDocuments(value); + break; default: reader.skipField(); break; @@ -29636,6 +29900,14 @@ proto.org.dash.platform.dapi.v0.ContractModerationReason.serializeBinaryToWriter f ); } + f = message.getDocumentsList(); + if (f.length > 0) { + writer.writeRepeatedMessage( + 3, + f, + proto.org.dash.platform.dapi.v0.ContractModerationDocument.serializeBinaryToWriter + ); + } }; @@ -29693,6 +29965,225 @@ proto.org.dash.platform.dapi.v0.ContractModerationReason.prototype.setText = fun }; +/** + * repeated ContractModerationDocument documents = 3; + * @return {!Array} + */ +proto.org.dash.platform.dapi.v0.ContractModerationReason.prototype.getDocumentsList = function() { + return /** @type{!Array} */ ( + jspb.Message.getRepeatedWrapperField(this, proto.org.dash.platform.dapi.v0.ContractModerationDocument, 3)); +}; + + +/** + * @param {!Array} value + * @return {!proto.org.dash.platform.dapi.v0.ContractModerationReason} returns this +*/ +proto.org.dash.platform.dapi.v0.ContractModerationReason.prototype.setDocumentsList = function(value) { + return jspb.Message.setRepeatedWrapperField(this, 3, value); +}; + + +/** + * @param {!proto.org.dash.platform.dapi.v0.ContractModerationDocument=} opt_value + * @param {number=} opt_index + * @return {!proto.org.dash.platform.dapi.v0.ContractModerationDocument} + */ +proto.org.dash.platform.dapi.v0.ContractModerationReason.prototype.addDocuments = function(opt_value, opt_index) { + return jspb.Message.addToRepeatedWrapperField(this, 3, opt_value, proto.org.dash.platform.dapi.v0.ContractModerationDocument, opt_index); +}; + + +/** + * Clears the list making it empty but non-null. + * @return {!proto.org.dash.platform.dapi.v0.ContractModerationReason} returns this + */ +proto.org.dash.platform.dapi.v0.ContractModerationReason.prototype.clearDocumentsList = function() { + return this.setDocumentsList([]); +}; + + + + + +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.org.dash.platform.dapi.v0.ContractWarning.prototype.toObject = function(opt_includeInstance) { + return proto.org.dash.platform.dapi.v0.ContractWarning.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.org.dash.platform.dapi.v0.ContractWarning} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.org.dash.platform.dapi.v0.ContractWarning.toObject = function(includeInstance, msg) { + var f, obj = { + warnedAt: jspb.Message.getFieldWithDefault(msg, 1, 0), + reason: (f = msg.getReason()) && proto.org.dash.platform.dapi.v0.ContractModerationReason.toObject(includeInstance, f) + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.org.dash.platform.dapi.v0.ContractWarning} + */ +proto.org.dash.platform.dapi.v0.ContractWarning.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.org.dash.platform.dapi.v0.ContractWarning; + return proto.org.dash.platform.dapi.v0.ContractWarning.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.org.dash.platform.dapi.v0.ContractWarning} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.org.dash.platform.dapi.v0.ContractWarning} + */ +proto.org.dash.platform.dapi.v0.ContractWarning.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = /** @type {number} */ (reader.readUint64()); + msg.setWarnedAt(value); + break; + case 2: + var value = new proto.org.dash.platform.dapi.v0.ContractModerationReason; + reader.readMessage(value,proto.org.dash.platform.dapi.v0.ContractModerationReason.deserializeBinaryFromReader); + msg.setReason(value); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.org.dash.platform.dapi.v0.ContractWarning.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.org.dash.platform.dapi.v0.ContractWarning.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.org.dash.platform.dapi.v0.ContractWarning} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.org.dash.platform.dapi.v0.ContractWarning.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = message.getWarnedAt(); + if (f !== 0) { + writer.writeUint64( + 1, + f + ); + } + f = message.getReason(); + if (f != null) { + writer.writeMessage( + 2, + f, + proto.org.dash.platform.dapi.v0.ContractModerationReason.serializeBinaryToWriter + ); + } +}; + + +/** + * optional uint64 warned_at = 1; + * @return {number} + */ +proto.org.dash.platform.dapi.v0.ContractWarning.prototype.getWarnedAt = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 1, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.org.dash.platform.dapi.v0.ContractWarning} returns this + */ +proto.org.dash.platform.dapi.v0.ContractWarning.prototype.setWarnedAt = function(value) { + return jspb.Message.setProto3IntField(this, 1, value); +}; + + +/** + * optional ContractModerationReason reason = 2; + * @return {?proto.org.dash.platform.dapi.v0.ContractModerationReason} + */ +proto.org.dash.platform.dapi.v0.ContractWarning.prototype.getReason = function() { + return /** @type{?proto.org.dash.platform.dapi.v0.ContractModerationReason} */ ( + jspb.Message.getWrapperField(this, proto.org.dash.platform.dapi.v0.ContractModerationReason, 2)); +}; + + +/** + * @param {?proto.org.dash.platform.dapi.v0.ContractModerationReason|undefined} value + * @return {!proto.org.dash.platform.dapi.v0.ContractWarning} returns this +*/ +proto.org.dash.platform.dapi.v0.ContractWarning.prototype.setReason = function(value) { + return jspb.Message.setWrapperField(this, 2, value); +}; + + +/** + * Clears the message field making it undefined. + * @return {!proto.org.dash.platform.dapi.v0.ContractWarning} returns this + */ +proto.org.dash.platform.dapi.v0.ContractWarning.prototype.clearReason = function() { + return this.setReason(undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.org.dash.platform.dapi.v0.ContractWarning.prototype.hasReason = function() { + return jspb.Message.getField(this, 2) != null; +}; + + /** * Oneof group definitions for this message. Each group defines the field @@ -30310,7 +30801,7 @@ proto.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.serializeBin * @private {!Array} * @const */ -proto.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractModerationStatus.repeatedFields_ = [3]; +proto.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractModerationStatus.repeatedFields_ = [3,6]; @@ -30347,7 +30838,9 @@ proto.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractMode suspendedUntil: jspb.Message.getFieldWithDefault(msg, 2, 0), listsList: (f = jspb.Message.getRepeatedField(msg, 3)) == null ? undefined : f, banReason: (f = msg.getBanReason()) && proto.org.dash.platform.dapi.v0.ContractModerationReason.toObject(includeInstance, f), - suspensionReason: (f = msg.getSuspensionReason()) && proto.org.dash.platform.dapi.v0.ContractModerationReason.toObject(includeInstance, f) + suspensionReason: (f = msg.getSuspensionReason()) && proto.org.dash.platform.dapi.v0.ContractModerationReason.toObject(includeInstance, f), + warningsList: jspb.Message.toObjectList(msg.getWarningsList(), + proto.org.dash.platform.dapi.v0.ContractWarning.toObject, includeInstance) }; if (includeInstance) { @@ -30408,6 +30901,11 @@ proto.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractMode reader.readMessage(value,proto.org.dash.platform.dapi.v0.ContractModerationReason.deserializeBinaryFromReader); msg.setSuspensionReason(value); break; + case 6: + var value = new proto.org.dash.platform.dapi.v0.ContractWarning; + reader.readMessage(value,proto.org.dash.platform.dapi.v0.ContractWarning.deserializeBinaryFromReader); + msg.addWarnings(value); + break; default: reader.skipField(); break; @@ -30474,6 +30972,14 @@ proto.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractMode proto.org.dash.platform.dapi.v0.ContractModerationReason.serializeBinaryToWriter ); } + f = message.getWarningsList(); + if (f.length > 0) { + writer.writeRepeatedMessage( + 6, + f, + proto.org.dash.platform.dapi.v0.ContractWarning.serializeBinaryToWriter + ); + } }; @@ -30660,6 +31166,44 @@ proto.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractMode }; +/** + * repeated ContractWarning warnings = 6; + * @return {!Array} + */ +proto.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractModerationStatus.prototype.getWarningsList = function() { + return /** @type{!Array} */ ( + jspb.Message.getRepeatedWrapperField(this, proto.org.dash.platform.dapi.v0.ContractWarning, 6)); +}; + + +/** + * @param {!Array} value + * @return {!proto.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractModerationStatus} returns this +*/ +proto.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractModerationStatus.prototype.setWarningsList = function(value) { + return jspb.Message.setRepeatedWrapperField(this, 6, value); +}; + + +/** + * @param {!proto.org.dash.platform.dapi.v0.ContractWarning=} opt_value + * @param {number=} opt_index + * @return {!proto.org.dash.platform.dapi.v0.ContractWarning} + */ +proto.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractModerationStatus.prototype.addWarnings = function(opt_value, opt_index) { + return jspb.Message.addToRepeatedWrapperField(this, 6, opt_value, proto.org.dash.platform.dapi.v0.ContractWarning, opt_index); +}; + + +/** + * Clears the list making it empty but non-null. + * @return {!proto.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractModerationStatus} returns this + */ +proto.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractModerationStatus.prototype.clearWarningsList = function() { + return this.setWarningsList([]); +}; + + /** * Oneof group definitions for this message. Each group defines the field @@ -31626,6 +32170,13 @@ proto.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.serializeBi +/** + * List of repeated fields within this message type. + * @private {!Array} + * @const + */ +proto.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry.repeatedFields_ = [4]; + if (jspb.Message.GENERATE_TO_OBJECT) { @@ -31659,7 +32210,9 @@ proto.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractMod var f, obj = { identityId: msg.getIdentityId_asB64(), until: jspb.Message.getFieldWithDefault(msg, 2, 0), - reason: (f = msg.getReason()) && proto.org.dash.platform.dapi.v0.ContractModerationReason.toObject(includeInstance, f) + reason: (f = msg.getReason()) && proto.org.dash.platform.dapi.v0.ContractModerationReason.toObject(includeInstance, f), + warningsList: jspb.Message.toObjectList(msg.getWarningsList(), + proto.org.dash.platform.dapi.v0.ContractWarning.toObject, includeInstance) }; if (includeInstance) { @@ -31709,6 +32262,11 @@ proto.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractMod reader.readMessage(value,proto.org.dash.platform.dapi.v0.ContractModerationReason.deserializeBinaryFromReader); msg.setReason(value); break; + case 4: + var value = new proto.org.dash.platform.dapi.v0.ContractWarning; + reader.readMessage(value,proto.org.dash.platform.dapi.v0.ContractWarning.deserializeBinaryFromReader); + msg.addWarnings(value); + break; default: reader.skipField(); break; @@ -31760,6 +32318,14 @@ proto.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractMod proto.org.dash.platform.dapi.v0.ContractModerationReason.serializeBinaryToWriter ); } + f = message.getWarningsList(); + if (f.length > 0) { + writer.writeRepeatedMessage( + 4, + f, + proto.org.dash.platform.dapi.v0.ContractWarning.serializeBinaryToWriter + ); + } }; @@ -31878,6 +32444,44 @@ proto.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractMod }; +/** + * repeated ContractWarning warnings = 4; + * @return {!Array} + */ +proto.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry.prototype.getWarningsList = function() { + return /** @type{!Array} */ ( + jspb.Message.getRepeatedWrapperField(this, proto.org.dash.platform.dapi.v0.ContractWarning, 4)); +}; + + +/** + * @param {!Array} value + * @return {!proto.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry} returns this +*/ +proto.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry.prototype.setWarningsList = function(value) { + return jspb.Message.setRepeatedWrapperField(this, 4, value); +}; + + +/** + * @param {!proto.org.dash.platform.dapi.v0.ContractWarning=} opt_value + * @param {number=} opt_index + * @return {!proto.org.dash.platform.dapi.v0.ContractWarning} + */ +proto.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry.prototype.addWarnings = function(opt_value, opt_index) { + return jspb.Message.addToRepeatedWrapperField(this, 4, opt_value, proto.org.dash.platform.dapi.v0.ContractWarning, opt_index); +}; + + +/** + * Clears the list making it empty but non-null. + * @return {!proto.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry} returns this + */ +proto.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry.prototype.clearWarningsList = function() { + return this.setWarningsList([]); +}; + + /** * List of repeated fields within this message type. @@ -33414,6 +34018,190 @@ proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.serializeBin +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.prototype.toObject = function(opt_includeInstance) { + return proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.toObject = function(includeInstance, msg) { + var f, obj = { + moderatorId: msg.getModeratorId_asB64(), + restoredAt: jspb.Message.getFieldWithDefault(msg, 2, 0) + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration} + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration; + return proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration} + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = /** @type {!Uint8Array} */ (reader.readBytes()); + msg.setModeratorId(value); + break; + case 2: + var value = /** @type {number} */ (reader.readUint64()); + msg.setRestoredAt(value); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = message.getModeratorId_asU8(); + if (f.length > 0) { + writer.writeBytes( + 1, + f + ); + } + f = message.getRestoredAt(); + if (f !== 0) { + writer.writeUint64( + 2, + f + ); + } +}; + + +/** + * optional bytes moderator_id = 1; + * @return {string} + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.prototype.getModeratorId = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 1, "")); +}; + + +/** + * optional bytes moderator_id = 1; + * This is a type-conversion wrapper around `getModeratorId()` + * @return {string} + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.prototype.getModeratorId_asB64 = function() { + return /** @type {string} */ (jspb.Message.bytesAsB64( + this.getModeratorId())); +}; + + +/** + * optional bytes moderator_id = 1; + * Note that Uint8Array is not supported on all browsers. + * @see http://caniuse.com/Uint8Array + * This is a type-conversion wrapper around `getModeratorId()` + * @return {!Uint8Array} + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.prototype.getModeratorId_asU8 = function() { + return /** @type {!Uint8Array} */ (jspb.Message.bytesAsU8( + this.getModeratorId())); +}; + + +/** + * @param {!(string|Uint8Array)} value + * @return {!proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration} returns this + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.prototype.setModeratorId = function(value) { + return jspb.Message.setProto3BytesField(this, 1, value); +}; + + +/** + * optional uint64 restored_at = 2; + * @return {number} + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.prototype.getRestoredAt = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 2, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration} returns this + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.prototype.setRestoredAt = function(value) { + return jspb.Message.setProto3IntField(this, 2, value); +}; + + + + + if (jspb.Message.GENERATE_TO_OBJECT) { /** * Creates an object representation of this proto. @@ -33447,7 +34235,9 @@ proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocu documentOwnerId: msg.getDocumentOwnerId_asB64(), moderatorId: msg.getModeratorId_asB64(), removedAt: jspb.Message.getFieldWithDefault(msg, 4, 0), - reason: (f = msg.getReason()) && proto.org.dash.platform.dapi.v0.ContractModerationReason.toObject(includeInstance, f) + reason: (f = msg.getReason()) && proto.org.dash.platform.dapi.v0.ContractModerationReason.toObject(includeInstance, f), + documentHash: msg.getDocumentHash_asB64(), + restoration: (f = msg.getRestoration()) && proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.toObject(includeInstance, f) }; if (includeInstance) { @@ -33505,6 +34295,15 @@ proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocu reader.readMessage(value,proto.org.dash.platform.dapi.v0.ContractModerationReason.deserializeBinaryFromReader); msg.setReason(value); break; + case 6: + var value = /** @type {!Uint8Array} */ (reader.readBytes()); + msg.setDocumentHash(value); + break; + case 7: + var value = new proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration; + reader.readMessage(value,proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.deserializeBinaryFromReader); + msg.setRestoration(value); + break; default: reader.skipField(); break; @@ -33570,6 +34369,21 @@ proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocu proto.org.dash.platform.dapi.v0.ContractModerationReason.serializeBinaryToWriter ); } + f = message.getDocumentHash_asU8(); + if (f.length > 0) { + writer.writeBytes( + 6, + f + ); + } + f = message.getRestoration(); + if (f != null) { + writer.writeMessage( + 7, + f, + proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration.serializeBinaryToWriter + ); + } }; @@ -33754,6 +34568,85 @@ proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocu }; +/** + * optional bytes document_hash = 6; + * @return {string} + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval.prototype.getDocumentHash = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 6, "")); +}; + + +/** + * optional bytes document_hash = 6; + * This is a type-conversion wrapper around `getDocumentHash()` + * @return {string} + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval.prototype.getDocumentHash_asB64 = function() { + return /** @type {string} */ (jspb.Message.bytesAsB64( + this.getDocumentHash())); +}; + + +/** + * optional bytes document_hash = 6; + * Note that Uint8Array is not supported on all browsers. + * @see http://caniuse.com/Uint8Array + * This is a type-conversion wrapper around `getDocumentHash()` + * @return {!Uint8Array} + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval.prototype.getDocumentHash_asU8 = function() { + return /** @type {!Uint8Array} */ (jspb.Message.bytesAsU8( + this.getDocumentHash())); +}; + + +/** + * @param {!(string|Uint8Array)} value + * @return {!proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval} returns this + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval.prototype.setDocumentHash = function(value) { + return jspb.Message.setProto3BytesField(this, 6, value); +}; + + +/** + * optional ContractDocumentRestoration restoration = 7; + * @return {?proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration} + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval.prototype.getRestoration = function() { + return /** @type{?proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration} */ ( + jspb.Message.getWrapperField(this, proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration, 7)); +}; + + +/** + * @param {?proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration|undefined} value + * @return {!proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval} returns this +*/ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval.prototype.setRestoration = function(value) { + return jspb.Message.setWrapperField(this, 7, value); +}; + + +/** + * Clears the message field making it undefined. + * @return {!proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval} returns this + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval.prototype.clearRestoration = function() { + return this.setRestoration(undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval.prototype.hasRestoration = function() { + return jspb.Message.getField(this, 7) != null; +}; + + /** * List of repeated fields within this message type. @@ -112676,7 +113569,8 @@ proto.org.dash.platform.dapi.v0.KeyPurpose = { proto.org.dash.platform.dapi.v0.ContractModerationList = { CONTRACT_MODERATION_LIST_UNSPECIFIED: 0, CONTRACT_MODERATION_LIST_BANLIST: 1, - CONTRACT_MODERATION_LIST_SUSPENSIONS: 2 + CONTRACT_MODERATION_LIST_SUSPENSIONS: 2, + CONTRACT_MODERATION_LIST_WARNINGS: 3 }; goog.object.extend(exports, proto.org.dash.platform.dapi.v0); diff --git a/packages/dapi-grpc/protos/platform/v0/platform.proto b/packages/dapi-grpc/protos/platform/v0/platform.proto index e8902229b8e..07c3e8dfc04 100644 --- a/packages/dapi-grpc/protos/platform/v0/platform.proto +++ b/packages/dapi-grpc/protos/platform/v0/platform.proto @@ -706,20 +706,36 @@ message GetContractGroupMembersResponse { oneof version { GetContractGroupMembersResponseV0 v0 = 1; } } -// One of the two moderation lists a moderated data contract may keep (protocol version 14). +// One of the moderation lists a moderated data contract may keep (protocol version 14). enum ContractModerationList { CONTRACT_MODERATION_LIST_UNSPECIFIED = 0; // Not a list: a request that leaves the field out is refused CONTRACT_MODERATION_LIST_BANLIST = 1; // Identities barred until an unban CONTRACT_MODERATION_LIST_SUSPENSIONS = 2; // Identities barred until a block time + CONTRACT_MODERATION_LIST_WARNINGS = 3; // Identities warned, barred from nothing } -// Why a moderator banned or suspended an identity. Nothing checks what a -// moderator writes. +// A document a moderation reason is about. +message ContractModerationDocument { + string document_type_name = 1; // The document type, on the moderated contract + bytes document_id = 2; // The 32-byte id of the document +} + +// Why a moderator banned, suspended or warned an identity, or deleted a +// document. Nothing checks what a moderator writes, and the documents cited +// are not looked up. message ContractModerationReason { optional uint32 code = 1; // Reserved for the ban codes a contract may declare in a later // protocol version; a u16, expected unset today, never checked string text = 2; // Free text, at most 1024 bytes of UTF-8, may be empty + repeated ContractModerationDocument documents = + 3; // The documents the reason is about, at most 16, none twice +} + +// One warning an identity carries on a contract's warning list. +message ContractWarning { + uint64 warned_at = 1; // The time of the block that issued it, in milliseconds + ContractModerationReason reason = 2; // Why } message GetContractModerationStatusRequest { @@ -750,6 +766,9 @@ message GetContractModerationStatusResponse { 4; // Set when the identity is banned: why optional ContractModerationReason suspension_reason = 5; // Set when the identity is suspended: why + repeated ContractWarning warnings = + 6; // When the warning list was read: the identity's warnings, oldest + // first; empty when it carries none } message GetContractModerationStatusResponseV0 { @@ -778,11 +797,17 @@ message GetContractModerationEntriesRequest { message GetContractModerationEntriesResponse { message ContractModerationEntry { - bytes identity_id = 1; // The barred identity + bytes identity_id = 1; // The identity on the list optional uint64 until = 2; // For a suspension list entry: the block time, in milliseconds, at // which it lapses - ContractModerationReason reason = 3; // Why the identity is on the list + ContractModerationReason reason = + 3; // Why the identity is on the list: the ban's reason or the + // suspension's. Unset for a warning list entry, whose reasons + // are its warnings' + repeated ContractWarning warnings = + 4; // For a warning list entry: every warning the identity carries, + // oldest first; the latest is why it is on the list } message ContractModerationEntries { @@ -832,6 +857,13 @@ message GetContractDocumentRemovalsRequest { } message GetContractDocumentRemovalsResponse { + message ContractDocumentRestoration { + bytes moderator_id = + 1; // The contract owner or moderator that restored the document + uint64 restored_at = + 2; // The time of the block that restored it, in milliseconds + } + message ContractDocumentRemoval { bytes document_id = 1; // The id the removed document had bytes document_owner_id = 2; // The identity that owned it @@ -839,6 +871,13 @@ message GetContractDocumentRemovalsResponse { uint64 removed_at = 4; // The time of the block that removed it, in milliseconds ContractModerationReason reason = 5; // Why the moderator removed it + bytes document_hash = + 6; // A 32-byte double SHA-256 of the document as it was serialized + // under its document type when it was removed: what a restore must + // bring back byte for byte + ContractDocumentRestoration restoration = + 7; // Set once a moderator restored the document, which is then live + // again as it was; absent while the removal stands } message ContractDocumentRemovals { diff --git a/packages/data-contracts/Cargo.toml b/packages/data-contracts/Cargo.toml index da907fd418c..87cc92e1318 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,7 @@ 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 } + +[dev-dependencies] +base58 = "0.2.0" 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..932f139cbfb 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 @@ -138,8 +143,8 @@ impl SystemDataContract { SystemDataContract::KeywordSearch => keyword_search_contract::ID_BYTES, #[cfg(not(feature = "keyword-search"))] SystemDataContract::KeywordSearch => [ - 92, 20, 14, 101, 92, 2, 101, 187, 194, 168, 8, 113, 109, 225, 132, 121, 133, 19, - 89, 24, 173, 81, 205, 253, 11, 118, 102, 75, 169, 91, 163, 124, + 161, 147, 167, 153, 40, 225, 219, 101, 50, 156, 28, 146, 150, 52, 114, 213, 56, + 154, 106, 15, 79, 66, 18, 156, 94, 146, 216, 104, 140, 93, 170, 215, ], #[cfg(feature = "document-history")] @@ -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,73 @@ 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")), + } + } +} + +#[cfg(test)] +mod tests { + use super::SystemDataContract; + use base58::FromBase58; + + /// `id()` spells the published identifier of every system contract under both feature + /// settings: with a contract's feature on it reads that crate's constant, without it the + /// bytes copied above. A copy that drifts from the published id would give feature-less + /// builds a different contract id, which is exactly what happened to `KeywordSearch` + /// before this test existed. `FeatureFlags` has no crate and no published id; its bytes + /// are only a reserved slot. + #[test] + fn every_system_contract_id_matches_the_published_id() { + let published = |base58: &str| base58.from_base58().expect("the published id is base58"); + + for contract in SystemDataContract::ALL { + let expected = match contract { + SystemDataContract::Withdrawals => { + published("4fJLR2GYTPFdomuTVvNy3VRrvWgvkKPzqehEBpNf2nk6") + } + SystemDataContract::MasternodeRewards => { + published("rUnsWrFu3PKyRMGk2mxmZVBPbQuZx2qtHeFjURoQevX") + } + SystemDataContract::FeatureFlags => continue, + SystemDataContract::DPNS => { + published("GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec") + } + SystemDataContract::Dashpay => { + published("Bwr4WHCPz5rFVAD87RqTs3izo4zpzwsEdKPWUT1NS1C7") + } + SystemDataContract::WalletUtils => { + published("7CSFGeF4WNzgDmx94zwvHkYaG3Dx4XEe5LFsFgJswLbm") + } + SystemDataContract::TokenHistory => { + published("43gujrzZgXqcKBiScLa4T8XTDnRhenR9BLx8GWVHjPxF") + } + SystemDataContract::KeywordSearch => { + published("BsjE6tQxG47wffZCRQCovFx5rYrAYYC3rTVRWKro27LA") + } + SystemDataContract::DocumentHistory => { + published("6voHRaoiPcfmMhbqCA9dixH98xcgPQ9UEcuaXjpVu3LD") + } + SystemDataContract::AppConnect => { + published("H8F9mP1BM55TE1ShsxPZHzhyinaMdY9bMmP85mkDhcJJ") + } + }; + + assert_eq!( + contract.id().to_buffer().to_vec(), + expected, + "{contract:?} id does not match its published id" + ); } } } diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index 9d93e99aff3..d8a36204c73 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -16,6 +16,7 @@ Evo SDK provides a high-level, strongly-typed interface for interacting with [Da - [Facades](#facades) - [Ranked queries](#ranked-queries) - [Document references (`refersTo`)](#document-references-refersto) +- [Building a document create transition by hand](#building-a-document-create-transition-by-hand) - [Immutable properties (`immutable`)](#immutable-properties-immutable) - [Chained queries (provable semi-join)](#chained-queries-provable-semi-join) - [Composite queries (a page plus its sub-queries)](#composite-queries-a-page-plus-its-sub-queries) @@ -224,6 +225,22 @@ try { } ``` +## Building a document create transition by hand + +`sdk.documents.create` fetches the nonce, builds, signs, broadcasts and returns the confirmed document, whose `id` is the one Platform stored. An app that needs the signed transition itself (to broadcast later, or to cache the signed bytes) builds it from the re-exported `wasm-dpp2` classes: + +```ts +import { Document, DocumentCreateTransition, BatchTransition } from '@dashevo/evo-sdk'; + +const document = new Document({ properties, documentTypeName, dataContractId, ownerId }); +const transition = new DocumentCreateTransition({ document, identityContractNonce: nonce }); +const batch = BatchTransition.fromBatchedTransitions([transition.toDocumentTransition()], ownerId, 0); // userFeeIncrease +const stateTransition = batch.toStateTransition(); +// sign, then sdk.stateTransitions.broadcast(stateTransition) +``` + +From protocol version 14 the id of a new document commits to the identity contract nonce of its create transition. `new DocumentCreateTransition(...)` derives that id from the document's entropy and `identityContractNonce`, puts it on the transition and writes it back onto `document`, so `document.id` is final once the transition exists and equals `transition.base.id`. Before that the `Document` carries a placeholder. To know the id earlier, `document.setIdForCreation(nonce)` or `Document.generateId(type, owner, contract, entropy, nonce)`, or pass `identityContractNonce` to the `Document` constructor. Pass `platformVersion` (defaults to latest) to any of them for a network on an earlier protocol version. No app needs to reimplement the hash. + ## Immutable properties (`immutable`) From protocol version 14 a mutable document type can freeze some of its top-level properties at creation with the doctype-level `immutable` list, while the rest of the document stays replaceable. A second list, `immutableAllowSetting`, names the frozen properties a replace may still set while the stored document has no value for them; once present they are frozen too. Both are consensus-enforced on every replace, and a fetched contract can be asked what it declares: diff --git a/packages/js-evo-sdk/src/contracts/facade.ts b/packages/js-evo-sdk/src/contracts/facade.ts index 2273606471b..e350da749b5 100644 --- a/packages/js-evo-sdk/src/contracts/facade.ts +++ b/packages/js-evo-sdk/src/contracts/facade.ts @@ -142,6 +142,23 @@ export class ContractsFacade { return w.contractUnsuspendUser(options); } + /** + * Warns an identity on a moderated contract: adds a warning, stamped with the block time, + * to the warnings it carries on the contract's warning list. A warning bars nothing; the + * warnings accumulate, at most 16 at a time, until they are cleared, and anyone can read + * them. `options.reason` is required, as for a ban. + */ + async warnUser(options: wasm.ContractWarnOptions): Promise { + const w = await this.sdk.getWasmSdkConnected(); + return w.contractWarnUser(options); + } + + /** Takes an identity off a moderated contract's warning list: every warning it carries goes. */ + async clearUserWarnings(options: wasm.ContractModerationOptions): Promise { + const w = await this.sdk.getWasmSdkConnected(); + return w.contractClearUserWarnings(options); + } + /** * Deletes one document on a moderated contract as a moderator, whoever owns it, except the * contract owner and the moderators. The document type must set `canBeDeletedByModerators`; @@ -160,8 +177,25 @@ export class ContractsFacade { } /** - * One identity's status on a moderated contract: whether it is banned, and until when it is - * suspended. Every list named must be one the contract keeps. + * Restores, as a moderator, one document a moderator deleted: `options.document` is the + * document as it was (as fetched before the deletion), which must hash to what its removal + * record holds, and the restore must come within a week of the deletion (41120). Any current + * moderator or the contract owner may restore, whoever deleted. The document goes back + * through an ordinary insert, so a unique index value another document took meanwhile + * refuses it (40105). Signed like the other moderations. Resolves with the record of the + * deletion, now marked restored (`restoredBy`, `restoredAt`); the signer paid for the + * document's storage, whose refund stays its owner's. + */ + async moderatorRestoreDocument( + options: wasm.ContractRestoreDocumentOptions, + ): Promise { + const w = await this.sdk.getWasmSdkConnected(); + return w.contractRestoreDocument(options); + } + + /** + * One identity's status on a moderated contract: whether it is banned, until when it is + * suspended, and the warnings it carries. Every list named must be one the contract keeps. */ async moderationStatus(query: wasm.ContractModerationStatusQuery): Promise { const w = await this.sdk.getWasmSdkConnected(); @@ -176,8 +210,9 @@ export class ContractsFacade { } /** - * One page of a moderated contract's banlist or suspension list, in identity id order. Pass - * the page's `nextStartAfter` as the next query's `startAfter`; a page without one (it holds fewer entries than the limit) is the last. + * One page of a moderated contract's banlist, suspension list or warning list, in identity + * id order. Pass the page's `nextStartAfter` as the next query's `startAfter`; a page + * without one (it holds fewer entries than the limit) is the last. */ async moderationEntries(query: wasm.ContractModerationEntriesQuery): Promise { const w = await this.sdk.getWasmSdkConnected(); diff --git a/packages/js-evo-sdk/tests/unit/facades/contracts.spec.ts b/packages/js-evo-sdk/tests/unit/facades/contracts.spec.ts index 6726511a16e..dbff540b738 100644 --- a/packages/js-evo-sdk/tests/unit/facades/contracts.spec.ts +++ b/packages/js-evo-sdk/tests/unit/facades/contracts.spec.ts @@ -216,9 +216,10 @@ describe('ContractsFacade', () => { const identityId = 'H2pb35GtKpjLinncBYeMsXkdDYXCbsFzzVmssce6pSJ1'; // Every moderation transition resolves to the status on the lists its proof covers: both - // for a ban, the edited one otherwise. `banned` is only set when `lists` includes `banlist`. - // Each action gets the options the WASM entrypoint accepts for it: a reason for a ban and a - // suspend, `until` for a suspend alone, neither for an unban or an unsuspend. + // barring lists for a ban, the edited one otherwise. `banned` is only set when `lists` + // includes `banlist`. Each action gets the options the WASM entrypoint accepts for it: a + // reason for a ban, a suspend and a warn, `until` for a suspend alone, neither for an + // unban, an unsuspend or a clearWarnings. const transitions = [ { facade: 'banUser', @@ -248,6 +249,21 @@ describe('ContractsFacade', () => { extraOptions: {}, result: { lists: ['suspensions'] }, }, + { + facade: 'warnUser', + wasm: 'contractWarnUser', + extraOptions: { reason: { text: 'first strike' } }, + result: { + lists: ['warnings'], + warnings: [{ warnedAt: BigInt(1700000000000), reason: { text: 'first strike' } }], + }, + }, + { + facade: 'clearUserWarnings', + wasm: 'contractClearUserWarnings', + extraOptions: {}, + result: { lists: ['warnings'], warnings: [] }, + }, ] as const; transitions.forEach(({ @@ -264,7 +280,7 @@ describe('ContractsFacade', () => { }; // The per-action option types differ, so the facade method is called through one - // signature wide enough for all four. + // signature wide enough for all six. const method = client.contracts[facade].bind(client.contracts) as ( moderationOptions: typeof options, ) => Promise; @@ -279,6 +295,7 @@ describe('ContractsFacade', () => { expect(moderated.suspensionReason).to.deep.equal( 'suspensionReason' in result ? result.suspensionReason : undefined, ); + expect(moderated.warnings).to.deep.equal('warnings' in result ? result.warnings : undefined); }); }); @@ -334,6 +351,7 @@ describe('ContractsFacade', () => { moderatorId: contractId, reason: { code: 2, text: 'spam' }, removedAt: BigInt(1800000000000), + documentHash: '11'.repeat(32), }; it('should forward moderatorDeleteDocument() to contractDeleteDocument() and return the removal record', async function run() { @@ -375,6 +393,31 @@ describe('ContractsFacade', () => { expect(result.reason).to.deep.equal({ text: '' }); }); + it('should forward moderatorRestoreDocument() to contractRestoreDocument() and return the marked record', async function run() { + const record = { + contractId, + documentTypeName, + documentId, + ...removal, + restoredBy: identityId, + restoredAt: BigInt(1800000001000), + }; + const stub = this.sinon.stub(wasmSdk, 'contractRestoreDocument').resolves(record); + const options = { + identity: Object.create(wasmSDKPackage.Identity.prototype), + contractId, + documentTypeName, + document: Object.create(wasmSDKPackage.Document.prototype), + signer, + }; + + const result = await client.contracts.moderatorRestoreDocument(options); + + expect(stub).to.be.calledOnceWithExactly(options); + expect(result).to.equal(record); + expect(result.restoredBy).to.equal(identityId); + }); + it('should fetch the removal records of the documents named, which carry no cursor', async function run() { const page = { removals: [{ documentId, ...removal }] }; const stub = this.sinon.stub(wasmSdk, 'getContractDocumentRemovals').resolves(page); diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/ContractJson.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/ContractJson.kt index 5d28ec01338..5ea90087fc2 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/ContractJson.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/ContractJson.kt @@ -195,10 +195,17 @@ internal fun indexAxisDescriptors(index: JsonObject): List { } /** - * The index's member-key property on an indexOnly document type: the - * declared `terminal`, defaulting to `$ownerId` exactly as DPP - * normalizes an omitted terminal. `null` on stored (non-indexOnly) - * document types, where entries are keyed by document id. + * The index's member key on an indexOnly document type: the declared + * `terminal` (a property name, or the ordered component names of a + * composite terminal joined with ` ‖ ` for display), defaulting to + * `$ownerId` exactly as DPP normalizes an omitted terminal. `null` on + * stored (non-indexOnly) document types, where entries are keyed by + * document id. */ internal fun indexTerminal(index: JsonObject, indexOnly: Boolean): String? = - index.stringField("terminal") ?: if (indexOnly) "\$ownerId" else null + index.stringField("terminal") + ?: (index["terminal"] as? JsonArray) + ?.mapNotNull { (it as? JsonPrimitive)?.content } + ?.takeIf { it.isNotEmpty() } + ?.joinToString(" ‖ ") + ?: if (indexOnly) "\$ownerId" else null diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/IndexKeywordDescriptorsTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/IndexKeywordDescriptorsTest.kt index 75122722c35..f3d0f5687a0 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/IndexKeywordDescriptorsTest.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/IndexKeywordDescriptorsTest.kt @@ -1,6 +1,8 @@ package org.dashfoundation.example.ui.contracts import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import org.junit.Assert.assertEquals @@ -92,5 +94,10 @@ class IndexKeywordDescriptorsTest { val declared = buildJsonObject { put("terminal", "postId") } assertEquals("postId", indexTerminal(declared, indexOnly = true)) assertEquals("postId", indexTerminal(declared, indexOnly = false)) + + val composite = buildJsonObject { + put("terminal", buildJsonArray { add("postId"); add("\$ownerId") }) + } + assertEquals("postId ‖ \$ownerId", indexTerminal(composite, indexOnly = true)) } } 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/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index b20906d47f3..fefdea82fee 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 @@ -132,6 +132,45 @@ "maxLength": 64, "pattern": "^[a-zA-Z0-9-_]{1,64}$" }, + "contractRequirements": { + "description": "contract references only: what the referenced contract must declare beyond existing, checked when the referring document is written against the contract already fetched for the existence check and the block time, so a requirement costs no further read. Each key names an aspect of the referenced contract and its value the requirement: moderation \"elected\" requires the contract to declare an elected moderation team and \"electionOpen\" one whose own electionDelay, counted from the contract's creation, has passed at the block time of the write (or which declares none); minimumAgeSeconds requires the contract's recorded creation time to be at least that many seconds before the block time of the write, and minimumSecondsSinceUpdate the later of its recorded creation and last update times (a contract without a recorded creation time never meets either); owner \"self\" requires the contract to be owned by the writer of the referring document (its $ownerId), \"other\" by anyone else; readonly true requires the contract's config to be readonly (one that can never be updated again) and keepsHistory true its config to keep history, only true being declarable for either; ownerProtected requires the contract's elected moderation declaration to protect (true) or not protect (false) the contract owner from the team, a contract without elected moderation meeting neither. An unmet requirement refuses the write (ReferencedContractRequirementNotMetError, 40135)", + "type": "object", + "properties": { + "moderation": { + "enum": [ + "elected", + "electionOpen" + ] + }, + "minimumAgeSeconds": { + "type": "integer", + "minimum": 1, + "maximum": 4294967295 + }, + "minimumSecondsSinceUpdate": { + "type": "integer", + "minimum": 1, + "maximum": 4294967295 + }, + "owner": { + "enum": [ + "self", + "other" + ] + }, + "readonly": { + "const": true + }, + "keepsHistory": { + "const": true + }, + "ownerProtected": { + "type": "boolean" + } + }, + "minProperties": 1, + "additionalProperties": false + }, "keyIdProperty": { "description": "The property of the same document type whose value carries the referenced key id; the reference property's value carries the identity id", "type": "string", @@ -207,6 +246,18 @@ "keyIdProperty": false } } + }, + { + "if": { + "properties": { "type": { "const": "contract" } }, + "required": ["type"] + }, + "then": {}, + "else": { + "properties": { + "contractRequirements": false + } + } } ] }, @@ -587,9 +638,10 @@ "resolution": { "type": "integer", "enum": [ - 0 + 0, + 1 ], - "description": "Resolution. 0 - Masternode Vote" + "description": "Resolution. 0 - Masternode Vote. 1 - Masternode Vote without a Lock choice: the contest always ends with a winner, a tie goes to the earliest contender, and a contest with a single contender at the end of the join window is awarded at once" }, "description": { "type": "string", @@ -721,10 +773,25 @@ "description": "Buckets the first index property's timestamp into fixed-length, regularly-spaced (possibly overlapping) time ranges. The window parameters (`range`, `step`, `phase`) are declared in seconds, since a bucket is selected from block time and the target block interval is five seconds; the stored key is the range start as a u64 millisecond timestamp, so it stays directly comparable to the source timestamp it buckets. Enables trending/leaderboard queries within the newest/oldest active range. A system-timestamp source must be listed in the document type's required fields. Several indexes may bucket the same timestamp with different grids — each grid gets its own index subtree, keyed by the property name qualified with the grid parameters. Available from protocol version 14." }, "terminal": { - "type": "string", - "minLength": 1, - "maxLength": 256, - "description": "Only on indexOnly document types: names the property whose value is this index entry's member key — the docId-analog terminal key under the index's storage marker, stored as an Item instead of a Reference because there is no primary-storage row. Either \"$ownerId\" (the default when omitted) or an identifier property carrying a refersTo declaration (identity, contract, token, permanentDocument, or deletableDocument). Must not repeat one of the index's listed properties. Available from protocol version 14." + "oneOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "minItems": 1, + "maxItems": 10, + "uniqueItems": true + } + ], + "description": "Only on indexOnly document types: the property, or the ordered list of properties (a composite terminal), whose encoded values, concatenated, are this index entry's member key — the docId-analog terminal key under the index's storage marker, stored as an Item instead of a Reference because there is no primary-storage row. Each component is \"$ownerId\" (the default when the keyword is omitted) or any schema property a prefix position could carry (no arrays or objects; byte arrays of at most 255 bytes, strings of at most 63 characters); every component but the last must be fixed width (a byte array with minItems equal to maxItems, an identifier, an integer, a boolean or a date), and the whole key at most 255 bytes. One entry exists per (prefix values, terminal values). No other system property may be a component, and none may repeat one of the index's listed properties. An index with no properties is a flat index keyed by its terminal alone, admitting no aggregate, ranking, timeRange, skipIfAbsent or preallocated keyword. Available from protocol version 14." }, "preallocated": { "type": "boolean", @@ -736,7 +803,6 @@ } }, "required": [ - "properties", "name" ], "dependentRequired": { @@ -903,6 +969,18 @@ "type": "boolean", "description": "When true, documents of this type are never written to primary storage: the index entries are the rows, each terminating in an Item keyed by the index's `terminal` property instead of a Reference keyed by the document id. Only what is in the indexes exists and is recoverable. Requires: every property required and appearing in at least one index (except a `skipIfAbsent` index's optional first property), $ownerId in at least one index (as a property or terminal), documentsMutable: false, no transfers/trading/history/transient properties, and no doctype-level aggregate keywords (use the index-level count flags). Available from protocol version 14." }, + "entryPayload": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "minItems": 1, + "maxItems": 16, + "uniqueItems": true, + "description": "Only on indexOnly document types: the top-level properties stored in every entry's value, after the 32-byte row commitment, instead of in a key — the type's value slot. Each is length-framed in property-name order and recovered by decoding the proved element: raw bytes for byte arrays, UTF-8 for strings, and tree-key encoding for other scalars. Empty byte arrays and strings remain distinct from null, and an empty string remains distinct from a NUL string. Listed properties must be required, bounded (maxItems on byte arrays, maxLength on strings), scalar, and must not appear in any index as a property or a terminal component; their summed bounds are capped by the field value limit. With more than one index the payload rides in every entry. Fixed when the document type is created. Available from protocol version 14." + }, "actionFees": { "type": "object", "description": "A fixed fee in credits charged, on top of the gas, for actions on documents of this type, split between the contract's owner pot and its moderators pot (each paid out by a ContractFeeClaim state transition). Whoever pays the gas of the action pays its fee. A transition on a priced action must name the declared owner and moderators amounts, and for feeMultiplier pricing the fee multiplier its signer knew with the increase in percent they accept, in $actionFeeAgreement; it is refused without one (DocumentActionFeeAgreementNotSetError, code 40132), with other amounts or another pricing (code 40133), or when the epoch's multiplier rose beyond the accepted increase (code 40134). At least one action must be priced and a priced action must charge something. Fixed when the document type is published: a contract update cannot add, change or remove the fees of an existing document type. Available from protocol version 14.", diff --git a/packages/rs-dpp/src/data_contract/config/methods/validate_update/v2/mod.rs b/packages/rs-dpp/src/data_contract/config/methods/validate_update/v2/mod.rs index f1778504f65..befbeb0636b 100644 --- a/packages/rs-dpp/src/data_contract/config/methods/validate_update/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/config/methods/validate_update/v2/mod.rs @@ -11,7 +11,9 @@ impl DataContractConfig { /// can not make an unmoderated contract moderated, turn a second list on, or turn a list /// off. Whoever writes documents under a contract knows, from its first version, whether /// and how they can be barred from it, and a list that is on may hold entries. Only the - /// moderators may change. + /// moderators may change, and only between the merged kinds: an elected declaration is + /// fixed at creation in every field, its interim included, and a contract neither enters + /// nor leaves elected moderation by an update. #[inline(always)] pub(super) fn validate_update_v2( &self, @@ -27,24 +29,59 @@ impl DataContractConfig { let lists = |config: &DataContractConfig| { config .moderation() - .map(|moderation| (moderation.banlist, moderation.suspensions)) - .unwrap_or((false, false)) + .map(|moderation| { + ( + moderation.banlist, + moderation.suspensions, + moderation.warnings, + ) + }) + .unwrap_or((false, false, false)) }; - let (old_banlist, old_suspensions) = lists(self); - let (new_banlist, new_suspensions) = lists(new_config); + let (old_banlist, old_suspensions, old_warnings) = lists(self); + let (new_banlist, new_suspensions, new_warnings) = lists(new_config); let refusal = if old_banlist && !new_banlist { Some("contract can not turn off its banlist once it keeps one") } else if old_suspensions && !new_suspensions { Some("contract can not turn off its suspension list once it keeps one") + } else if old_warnings && !new_warnings { + Some("contract can not turn off its warning list once it keeps one") } else if !old_banlist && new_banlist { Some("contract can not start keeping a banlist after it is created") } else if !old_suspensions && new_suspensions { Some("contract can not start keeping a suspension list after it is created") + } else if !old_warnings && new_warnings { + Some("contract can not start keeping a warning list after it is created") } else { None }; + if let Some(reason) = refusal { + return SimpleConsensusValidationResult::new_with_error( + DataContractConfigUpdateError::new(contract_id, reason).into(), + ); + } + + let elected = |config: &DataContractConfig| { + config + .moderation() + .and_then(|moderation| moderation.moderators.elected()) + .cloned() + }; + let refusal = match (elected(self), elected(new_config)) { + (Some(old), Some(new)) if old != new => { + Some("contract can not change its elected moderation declaration") + } + (Some(_), None) => { + Some("contract can not leave elected moderation once it declares it") + } + (None, Some(_)) => { + Some("contract can not declare elected moderation after it is created") + } + _ => None, + }; + match refusal { Some(reason) => SimpleConsensusValidationResult::new_with_error( DataContractConfigUpdateError::new(contract_id, reason).into(), @@ -57,9 +94,13 @@ impl DataContractConfig { #[cfg(test)] mod tests { use super::*; - use crate::data_contract::config::moderation::{ContractModerationConfig, ContractModerators}; + use crate::data_contract::config::moderation::{ + ContractModerationConfig, ContractModerators, ElectedModerators, InterimModerators, + ModerationAbility, DEFAULT_ELECTION_WINDOW_SECONDS, + }; use crate::data_contract::config::v1::DataContractConfigV1; use crate::data_contract::config::v2::DataContractConfigV2; + use std::collections::{BTreeMap, BTreeSet}; fn moderated(banlist: bool, suspensions: bool) -> DataContractConfig { DataContractConfig::V2(DataContractConfigV2 { @@ -67,6 +108,7 @@ mod tests { banlist, suspensions, moderators: ContractModerators::ContractOwner, + warnings: false, }), ..DataContractConfigV2::default() }) @@ -114,6 +156,36 @@ mod tests { } } + #[test] + fn should_fix_the_warning_list_at_creation_like_the_others() { + let platform_version = PlatformVersion::latest(); + let contract_id = Identifier::new([1u8; 32]); + let with_warnings = |banlist: bool| { + DataContractConfig::V2(DataContractConfigV2 { + moderation: Some(ContractModerationConfig { + banlist, + suspensions: false, + warnings: true, + moderators: ContractModerators::ContractOwner, + }), + ..DataContractConfigV2::default() + }) + }; + // On: refused. Off: refused. Kept: fine. + assert!(!moderated(true, false) + .validate_update_v2(&with_warnings(true), contract_id, platform_version) + .is_valid()); + assert!(!with_warnings(true) + .validate_update_v2(&moderated(true, false), contract_id, platform_version) + .is_valid()); + let kept = with_warnings(false).validate_update_v2( + &with_warnings(false), + contract_id, + platform_version, + ); + assert!(kept.is_valid(), "{:?}", kept.errors); + } + #[test] fn should_reject_turning_a_list_off() { let platform_version = PlatformVersion::latest(); @@ -132,6 +204,102 @@ mod tests { assert!(!result.is_valid()); } + /// An elected declaration with both lists, `post` moderated, the owner in the interim + fn elected(modify: impl FnOnce(&mut ElectedModerators)) -> DataContractConfig { + let mut declaration = ElectedModerators { + join_window: DEFAULT_ELECTION_WINDOW_SECONDS, + vote_window: DEFAULT_ELECTION_WINDOW_SECONDS, + challenge_cool_down: 1_209_600, + moderated_document_types: BTreeMap::from([( + "post".to_string(), + BTreeSet::from([ModerationAbility::Ban]), + )]), + interim: InterimModerators::ContractOwner, + election_delay: None, + owner_protected: false, + }; + modify(&mut declaration); + DataContractConfig::V2(DataContractConfigV2 { + moderation: Some(ContractModerationConfig { + banlist: true, + suspensions: true, + warnings: false, + moderators: ContractModerators::Elected(Box::new(declaration)), + }), + ..DataContractConfigV2::default() + }) + } + + #[test] + fn should_freeze_every_field_of_an_elected_declaration() { + let platform_version = PlatformVersion::latest(); + let contract_id = Identifier::new([1u8; 32]); + let unchanged = elected(|_| {}); + let kept = unchanged.validate_update_v2(&unchanged, contract_id, platform_version); + assert!(kept.is_valid(), "{:?}", kept.errors); + + type Change = (&'static str, fn(&mut ElectedModerators)); + let changes: [Change; 8] = [ + ("join window", |d| d.join_window += 1), + ("vote window", |d| d.vote_window += 1), + ("challenge cool-down", |d| d.challenge_cool_down += 1), + ("election delay", |d| d.election_delay = Some(1)), + ("moderated set", |d| { + d.moderated_document_types + .insert("like".to_string(), BTreeSet::from([ModerationAbility::Ban])); + }), + ("abilities", |d| { + d.moderated_document_types + .get_mut("post") + .expect("post is moderated") + .insert(ModerationAbility::Suspend); + }), + ("interim", |d| { + d.interim = + InterimModerators::AppointedModerators([Identifier::new([5u8; 32])].into()) + }), + ("owner flag", |d| d.owner_protected = true), + ]; + for (what, change) in changes { + let result = + unchanged.validate_update_v2(&elected(change), contract_id, platform_version); + assert!( + !result.is_valid(), + "expected a changed {what} to be refused" + ); + assert!( + format!("{:?}", result.errors).contains("can not change its elected"), + "{what}: {:?}", + result.errors + ); + } + } + + #[test] + fn should_refuse_entering_or_leaving_elected_moderation() { + let platform_version = PlatformVersion::latest(); + let contract_id = Identifier::new([1u8; 32]); + let entering = moderated(true, true).validate_update_v2( + &elected(|_| {}), + contract_id, + platform_version, + ); + assert!(format!("{:?}", entering.errors).contains("after it is created")); + let leaving = elected(|_| {}).validate_update_v2( + &moderated(true, true), + contract_id, + platform_version, + ); + assert!(format!("{:?}", leaving.errors).contains("once it declares it")); + // Leaving moderation altogether is caught by the lists first; the declaration would be too. + let dropped = elected(|_| {}).validate_update_v2( + &DataContractConfig::V1(DataContractConfigV1::default()), + contract_id, + platform_version, + ); + assert!(!dropped.is_valid()); + } + #[test] fn should_allow_changing_the_moderators() { let platform_version = PlatformVersion::latest(); @@ -143,6 +311,7 @@ mod tests { moderators: ContractModerators::AppointedModerators( [Identifier::new([5u8; 32])].into_iter().collect(), ), + warnings: false, }), ..DataContractConfigV2::default() }); diff --git a/packages/rs-dpp/src/data_contract/config/mod.rs b/packages/rs-dpp/src/data_contract/config/mod.rs index 267146466c8..f44b2b20ebc 100644 --- a/packages/rs-dpp/src/data_contract/config/mod.rs +++ b/packages/rs-dpp/src/data_contract/config/mod.rs @@ -730,6 +730,7 @@ mod tests { banlist: true, suspensions: false, moderators: Default::default(), + warnings: false, }), ..DataContractConfigV2::default() }); @@ -765,6 +766,7 @@ mod tests { banlist: true, suspensions: false, moderators: Default::default(), + warnings: false, }), ..DataContractConfigV2::default() }); diff --git a/packages/rs-dpp/src/data_contract/config/moderation/document_removal.rs b/packages/rs-dpp/src/data_contract/config/moderation/document_removal.rs index 7f628d7ee89..db239248d62 100644 --- a/packages/rs-dpp/src/data_contract/config/moderation/document_removal.rs +++ b/packages/rs-dpp/src/data_contract/config/moderation/document_removal.rs @@ -7,10 +7,13 @@ use serde::{Deserialize, Serialize}; /// The record a contract keeps of a document one of its moderators deleted. /// /// The document itself is gone; this is what is left to say that it was removed, not lost: -/// whose it was, who removed it, why and when. It is stored under the contract, by document -/// type then document id, paid for by the moderator, and never deleted. It is final: a -/// document id commits to the nonce of its create transition and is produced at most once, so -/// the removed id can not be created again and no second removal can replace the record. +/// whose it was, who removed it, why and when, and a hash of what it was. It is stored under +/// the contract, by document type then document id, paid for by the moderator, and never +/// deleted. A document id commits to the nonce of its create transition and is produced at +/// most once, so the removed id can not be created again; what can bring the document back +/// is a moderator's restore, within `SystemLimits::contract_document_restore_window_ms` of +/// the removal, which marks the record restored and leaves it in place. A later deletion of +/// the restored document replaces the record with a fresh one. #[derive( Debug, Clone, PartialEq, Eq, Default, Encode, Decode, DecodeUntrusted, Serialize, Deserialize, )] @@ -25,4 +28,30 @@ pub struct ContractDocumentRemoval { pub reason: ContractModerationReason, /// The time of the block that removed it, in milliseconds. pub removed_at: TimestampMillis, + /// A double SHA-256 of the document as it was serialized under its document type at the + /// time of the removal (`Document::serialize`): what a restore must bring back, byte for + /// byte. + pub document_hash: [u8; 32], + /// Set once a moderator restored the document: it is live again, at its id, as it was. + pub restoration: Option, +} + +/// The mark a restore leaves on a removal record: who brought the document back, and when. +#[derive( + Debug, Clone, PartialEq, Eq, Default, Encode, Decode, DecodeUntrusted, Serialize, Deserialize, +)] +#[serde(rename_all = "camelCase")] +pub struct ContractDocumentRestoration { + /// The contract owner or moderator that restored the document. + pub moderator_id: Identifier, + /// The time of the block that restored it, in milliseconds. + pub restored_at: TimestampMillis, +} + +impl ContractDocumentRemoval { + /// Whether the document was restored: the record then describes a removal that was + /// undone, and the document is live again. + pub fn is_restored(&self) -> bool { + self.restoration.is_some() + } } diff --git a/packages/rs-dpp/src/data_contract/config/moderation/elected.rs b/packages/rs-dpp/src/data_contract/config/moderation/elected.rs new file mode 100644 index 00000000000..7afb9aa7a8d --- /dev/null +++ b/packages/rs-dpp/src/data_contract/config/moderation/elected.rs @@ -0,0 +1,955 @@ +//! Elected moderation: the declaration that a contract's moderators are a team chosen by +//! masternodes and evonodes instead of by the contract owner (protocol version 14). +//! +//! The declaration is fixed at the contract's creation and never changes: the election +//! parameters, the document types the team moderates with the abilities a charter may claim +//! on each, who moderates until the first team is seated, and whether the owner is protected +//! from the team. A charter does not price the moderators part of a document action: the +//! type's own `actionFees.moderators` amount is the most a team may charge, and a charter +//! charges a share of it. No election exists yet: until one does, the contract is in its +//! **interim**, +//! moderated by the interim moderators the declaration names, or by nobody: with the +//! moderated types not yet usable, or usable and unmoderated meanwhile. + +use crate::data_contract::config::moderation::{ + document_schema_lets_moderators_delete, ContractModerationConfig, +}; +use crate::data_contract::DocumentName; +use crate::prelude::TimestampMillis; +#[cfg(feature = "json-conversion")] +use crate::serialization::JsonSafeFields; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_value::{Identifier, Value}; +use platform_version::version::PlatformVersion; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +/// One week: the join window and the vote window a declaration gets when it leaves them out. +pub const DEFAULT_ELECTION_WINDOW_SECONDS: u32 = 604_800; + +/// The keys of an elected declaration on the wire, beside the `$type` of its variant. +pub mod property_names { + /// The join window, in seconds + pub const JOIN_WINDOW: &str = "joinWindow"; + /// The vote window, in seconds + pub const VOTE_WINDOW: &str = "voteWindow"; + /// The challenge cool-down, in seconds + pub const CHALLENGE_COOL_DOWN: &str = "challengeCoolDown"; + /// The election delay, in seconds after the contract's creation + pub const ELECTION_DELAY: &str = "electionDelay"; + /// The moderated document types, each with the abilities a charter may claim on it + pub const MODERATED_DOCUMENT_TYPES: &str = "moderatedDocumentTypes"; + /// The interim moderators + pub const INTERIM: &str = "interim"; + /// Whether the owner is protected from the team + pub const OWNER_PROTECTED: &str = "ownerProtected"; + /// The `$type` of the variant and of the interim + pub const TYPE: &str = "$type"; + /// The identities of an appointed interim set + pub const IDENTITIES: &str = "identities"; +} + +/// What a moderation team may do to a contract's users and content. The contract declares +/// which of these a charter may claim on each moderated document type; a seated team has +/// what its charter claims. +/// +/// Append-only: the discriminant is stored in every declaration. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Encode, + Decode, + DecodeUntrusted, + Serialize, + Deserialize, +)] +#[serde(rename_all = "camelCase")] +pub enum ModerationAbility { + /// Delete documents of the type, within its `canBeDeletedByModeratorsFor` window. Needs + /// the type flagged `canBeDeletedByModerators`. + DeleteDocuments, + /// Put identities on the banlist and take them off it, over their documents of the + /// type. Needs the banlist. + Ban, + /// Put identities on the suspension list and take them off it, over their documents of + /// the type. Needs the suspension list. + Suspend, + /// Warn identities and clear their warnings, over their documents of the type. Needs + /// the warning list. + Warn, +} + +impl ModerationAbility { + /// The wire name of the ability + pub fn as_str(&self) -> &'static str { + match self { + ModerationAbility::DeleteDocuments => "deleteDocuments", + ModerationAbility::Ban => "ban", + ModerationAbility::Suspend => "suspend", + ModerationAbility::Warn => "warn", + } + } +} + +impl fmt::Display for ModerationAbility { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Who moderates an elected contract until its first team is seated. +/// +/// The first two are the merged kinds of [`ContractModerators`](super::ContractModerators), +/// with the same authority: the owner alone, or the owner and a fixed set. The other two +/// name nobody, so nothing is moderated in the meantime and nobody claims the moderators +/// pot: under `NotYetUsable` the moderated document types can not be used until a team is +/// seated (their transitions are refused), and a contract that never attracts a team keeps +/// them unusable for good; under `NoModeration` they are used unmoderated until then. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, DecodeUntrusted)] +pub enum InterimModerators { + /// Only the contract owner moderates until a team is seated. + ContractOwner, + /// The identities the contract appoints moderate until a team is seated, beside its + /// owner, who always may. Non-empty, at most `SystemLimits::max_contract_moderators`, + /// each an identity that exists; a named owner counts toward the limit. + AppointedModerators(BTreeSet), + /// Nobody moderates until a team is seated, and the moderated document types can not be + /// used until then. + NotYetUsable, + /// Nobody moderates until a team is seated, and the moderated document types are used + /// unmoderated until then. + NoModeration, +} + +impl InterimModerators { + /// The identities the interim names, `None` unless a set is appointed. + pub fn identity_ids(&self) -> Option<&BTreeSet> { + match self { + InterimModerators::AppointedModerators(ids) => Some(ids), + InterimModerators::ContractOwner + | InterimModerators::NotYetUsable + | InterimModerators::NoModeration => None, + } + } + + /// Whether `identity_id` may moderate a contract owned by `owner_id` during the interim. + pub fn may_moderate(&self, owner_id: &Identifier, identity_id: &Identifier) -> bool { + match self { + InterimModerators::ContractOwner => owner_id == identity_id, + InterimModerators::AppointedModerators(ids) => { + owner_id == identity_id || ids.contains(identity_id) + } + InterimModerators::NotYetUsable | InterimModerators::NoModeration => false, + } + } + + /// The interim team of a contract owned by `owner_id`: who shares its moderators fee pot + /// until a team is seated. Nobody under [`InterimModerators::NotYetUsable`] or + /// [`InterimModerators::NoModeration`]: the pot accumulates for the team that gets seated. + pub fn team(&self, owner_id: &Identifier) -> BTreeSet { + match self { + InterimModerators::ContractOwner => BTreeSet::from([*owner_id]), + InterimModerators::AppointedModerators(ids) => ids.clone(), + InterimModerators::NotYetUsable | InterimModerators::NoModeration => BTreeSet::new(), + } + } + + /// Whether the moderated document types are unusable during the interim. + pub fn blocks_moderated_document_types(&self) -> bool { + matches!(self, InterimModerators::NotYetUsable) + } + + /// The wire name of the kind + fn type_name(&self) -> &'static str { + match self { + InterimModerators::ContractOwner => "contractOwner", + InterimModerators::AppointedModerators(_) => "appointedModerators", + InterimModerators::NotYetUsable => "notYetUsable", + InterimModerators::NoModeration => "noModeration", + } + } +} + +// The wire shape is the moderators' own: a flat `{"$type": "contractOwner"}`, +// `{"$type": "appointedModerators", "identities": [...]}`, `{"$type": "notYetUsable"}` or +// `{"$type": "noModeration"}` map. +impl Serialize for InterimModerators { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeMap; + let identities = self.identity_ids(); + let mut m = serializer.serialize_map(Some(1 + usize::from(identities.is_some())))?; + m.serialize_entry(property_names::TYPE, self.type_name())?; + if let Some(ids) = identities { + m.serialize_entry(property_names::IDENTITIES, ids)?; + } + m.end() + } +} + +impl<'de> Deserialize<'de> for InterimModerators { + fn deserialize>(deserializer: D) -> Result { + use serde::de::{self, MapAccess, Visitor}; + + struct V; + + impl<'de> Visitor<'de> for V { + type Value = InterimModerators; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str( + "InterimModerators as a map with a `$type` discriminator, \ + e.g. {\"$type\": \"contractOwner\"}, \ + {\"$type\": \"appointedModerators\", \"identities\": [\"\"]}, \ + {\"$type\": \"notYetUsable\"} or {\"$type\": \"noModeration\"}", + ) + } + + fn visit_map>(self, mut map: A) -> Result { + let mut variant: Option = None; + let mut identities: Option> = None; + + while let Some(key) = map.next_key::()? { + match key.as_str() { + property_names::TYPE => { + if variant.is_some() { + return Err(de::Error::duplicate_field(property_names::TYPE)); + } + variant = Some(map.next_value()?); + } + property_names::IDENTITIES => { + if identities.is_some() { + return Err(de::Error::duplicate_field(property_names::IDENTITIES)); + } + identities = Some(map.next_value()?); + } + // Refused rather than skipped: the declaration is frozen, so a + // misspelled key must not pass. + other => { + return Err(de::Error::unknown_field( + other, + &[property_names::TYPE, property_names::IDENTITIES], + )); + } + } + } + + let variant = + variant.ok_or_else(|| de::Error::missing_field(property_names::TYPE))?; + let without_identities = |kind: InterimModerators| { + if identities.is_some() { + return Err(de::Error::custom( + "`identities` is only valid for `appointedModerators`", + )); + } + Ok(kind) + }; + match variant.as_str() { + "contractOwner" => without_identities(InterimModerators::ContractOwner), + "notYetUsable" => without_identities(InterimModerators::NotYetUsable), + "noModeration" => without_identities(InterimModerators::NoModeration), + "appointedModerators" => { + let ids = identities + .ok_or_else(|| de::Error::missing_field(property_names::IDENTITIES))?; + Ok(InterimModerators::AppointedModerators(ids)) + } + other => Err(de::Error::unknown_variant( + other, + &[ + "contractOwner", + "appointedModerators", + "notYetUsable", + "noModeration", + ], + )), + } + } + } + + deserializer.deserialize_map(V) + } +} + +impl fmt::Display for InterimModerators { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + InterimModerators::ContractOwner => write!(f, "the contract owner"), + InterimModerators::AppointedModerators(ids) => { + write!( + f, + "the contract owner and {} appointed moderators", + ids.len() + ) + } + InterimModerators::NotYetUsable => { + write!(f, "nobody, its moderated document types not yet usable") + } + InterimModerators::NoModeration => { + write!( + f, + "nobody, its moderated document types unmoderated meanwhile" + ) + } + } + } +} + +/// The declaration that a contract's moderators are an elected team. Every field is fixed +/// at the contract's creation. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, DecodeUntrusted)] +pub struct ElectedModerators { + /// How long, in seconds, applicants may join an election once the first one applied. + /// `SystemLimits::min_contract_moderation_election_window_seconds` to + /// `SystemLimits::max_contract_moderation_election_window_seconds` (one day to four + /// weeks); [`DEFAULT_ELECTION_WINDOW_SECONDS`] when the declaration leaves it out. + pub join_window: u32, + /// How long, in seconds, masternodes vote once the join window closed. The same bounds + /// and default. + pub vote_window: u32, + /// How long, in seconds, a seated team is safe from a challenge after a seat change. + /// `SystemLimits::min_contract_moderation_challenge_cool_down_seconds` to + /// `SystemLimits::max_contract_moderation_challenge_cool_down_seconds` (two weeks to + /// three years), always declared. + pub challenge_cool_down: u32, + /// How long, in seconds after the contract's creation, before the first charter may be + /// filed against the contract: the notice the contract gives before its first election + /// can be called. Unbounded, and `None` when the declaration leaves it out, in which + /// case the election may be called at once. A reference declaring + /// `contractRequirements: { "moderation": "electionOpen" }` is what reads it. + pub election_delay: Option, + /// The document types the team moderates, each with the abilities a charter may claim + /// on it: non-empty, each type a document type of the contract, each ability set + /// non-empty and backed by the contract (`Ban`, `Suspend` and `Warn` by the list the + /// contract keeps, `DeleteDocuments` by the type being flagged + /// `canBeDeletedByModerators`). The lists themselves stay contract-wide: an ability on + /// a type is what a team may do over the documents of that type. The set also bounds + /// the interim block. A charter does not price the moderators part of an action: the + /// type's `actionFees.moderators` amount is the most a team may charge, and a charter + /// charges a share of it. + pub moderated_document_types: BTreeMap>, + /// Who moderates until the first team is seated. + pub interim: InterimModerators, + /// Whether the contract owner is protected from the team, as the owner and the + /// moderators of the merged kinds are: it can then be neither banned nor suspended, and + /// its documents can not be deleted. Not protected by default. During the interim the + /// owner is protected whenever it moderates, flag or not. + pub owner_protected: bool, +} + +impl ElectedModerators { + /// Whether the first election may be called at `block_time_ms` on a contract created at + /// `contract_created_at`: the declaration has no election delay, or the delay has passed + /// since the creation. An elected declaration is made at the contract's creation and + /// never changes, so the creation is the declaration's own time. A contract without a + /// recorded creation time and with a delay is of unknown age, and its election is not + /// open. + pub fn election_is_open( + &self, + contract_created_at: Option, + block_time_ms: TimestampMillis, + ) -> bool { + match self.election_delay { + None => true, + Some(delay) => contract_created_at.is_some_and(|created_at| { + block_time_ms + >= created_at.saturating_add(TimestampMillis::from(delay).saturating_mul(1000)) + }), + } + } + + /// Whether the team moderates the document type + pub fn moderates_document_type(&self, document_type_name: &str) -> bool { + self.moderated_document_types + .contains_key(document_type_name) + } + + /// Whether a charter may claim the ability on the document type + pub fn allows(&self, document_type_name: &str, ability: ModerationAbility) -> bool { + self.moderated_document_types + .get(document_type_name) + .is_some_and(|abilities| abilities.contains(&ability)) + } + + /// Whether the interim refuses every document transition of the document type: the + /// interim names nobody and the type is moderated. Once a team is seated (not yet + /// possible) this ends. + pub fn interim_blocks_document_type(&self, document_type_name: &str) -> bool { + self.interim.blocks_moderated_document_types() + && self.moderates_document_type(document_type_name) + } + + /// The pure-data rules of the declaration beyond those every moderator kind shares (a + /// named set non-empty and within the limit, checked on the interim set by the caller): + /// the windows and the cool-down within the limits, the moderated set non-empty, each + /// of its types a document type of the contract with a non-empty ability set the + /// contract backs. The first rule broken is the reason returned. + pub(super) fn validation_error( + &self, + config: &ContractModerationConfig, + document_schemas: &BTreeMap, + platform_version: &PlatformVersion, + ) -> Option { + let limits = &platform_version.system_limits; + let within = |what: &str, seconds: u32, min: u32, max: u32| { + (seconds < min || seconds > max).then(|| { + format!("the {what} of {seconds} seconds is outside {min} to {max} seconds") + }) + }; + let window_min = limits.min_contract_moderation_election_window_seconds; + let window_max = limits.max_contract_moderation_election_window_seconds; + if let Some(reason) = within("join window", self.join_window, window_min, window_max) + .or_else(|| within("vote window", self.vote_window, window_min, window_max)) + .or_else(|| { + within( + "challenge cool-down", + self.challenge_cool_down, + limits.min_contract_moderation_challenge_cool_down_seconds, + limits.max_contract_moderation_challenge_cool_down_seconds, + ) + }) + { + return Some(reason); + } + + if self.moderated_document_types.is_empty() { + return Some("the moderated document type set is empty".to_string()); + } + for (document_type_name, abilities) in &self.moderated_document_types { + let Some(schema) = document_schemas.get(document_type_name) else { + return Some(format!( + "the moderated document type \"{document_type_name}\" is not a document \ + type of the contract" + )); + }; + if abilities.is_empty() { + return Some(format!( + "the ability set of the moderated document type \"{document_type_name}\" \ + is empty" + )); + } + for ability in abilities { + let unbacked = match ability { + ModerationAbility::Ban if !config.banlist => { + Some("bans, but the contract keeps no banlist") + } + ModerationAbility::Suspend if !config.suspensions => { + Some("suspensions, but the contract keeps no suspension list") + } + ModerationAbility::Warn if !config.warnings => { + Some("warnings, but the contract keeps no warning list") + } + ModerationAbility::DeleteDocuments + if !document_schema_lets_moderators_delete(schema) => + { + Some("document deletions, but the type can not be deleted by moderators") + } + _ => None, + }; + if let Some(unbacked) = unbacked { + return Some(format!( + "the moderated document type \"{document_type_name}\" allows {unbacked}" + )); + } + } + } + + None + } +} + +impl fmt::Display for ElectedModerators { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "an elected moderation team, in its interim moderated by {}", + self.interim + )?; + if let Some(delay) = self.election_delay { + write!( + f, + ", its first election open {delay} seconds after the contract's creation" + )?; + } + Ok(()) + } +} + +#[cfg(feature = "json-conversion")] +impl JsonSafeFields for ModerationAbility {} +#[cfg(feature = "json-conversion")] +impl JsonSafeFields for InterimModerators {} +#[cfg(feature = "json-conversion")] +impl JsonSafeFields for ElectedModerators {} + +#[cfg(test)] +mod tests { + use super::*; + use crate::consensus::ConsensusError; + use crate::data_contract::config::moderation::ContractModerators; + use platform_value::platform_value; + + fn set(ids: &[u8]) -> BTreeSet { + ids.iter().map(|b| Identifier::from([*b; 32])).collect() + } + + /// `post`, which moderators may delete, and `like`, which they may not + fn schemas() -> BTreeMap { + BTreeMap::from([ + ( + "post".to_string(), + platform_value!({ "type": "object", "canBeDeletedByModerators": true }), + ), + ("like".to_string(), platform_value!({ "type": "object" })), + ]) + } + + /// A declaration within every bound: both lists, bans and suspensions allowed, `post` + /// moderated, the owner in the interim. + fn elected() -> ElectedModerators { + ElectedModerators { + join_window: DEFAULT_ELECTION_WINDOW_SECONDS, + vote_window: DEFAULT_ELECTION_WINDOW_SECONDS, + challenge_cool_down: 1_209_600, + moderated_document_types: BTreeMap::from([( + "post".to_string(), + moderated(&[ModerationAbility::Ban, ModerationAbility::Suspend]), + )]), + interim: InterimModerators::ContractOwner, + election_delay: None, + owner_protected: false, + } + } + + /// The ability set of a moderated type + fn moderated(abilities: &[ModerationAbility]) -> BTreeSet { + abilities.iter().copied().collect() + } + + /// The ability set of a moderated type, to edit + fn abilities_of<'a>( + declaration: &'a mut ElectedModerators, + name: &str, + ) -> &'a mut BTreeSet { + declaration + .moderated_document_types + .get_mut(name) + .expect("the type is moderated") + } + + fn config(elected: ElectedModerators) -> ContractModerationConfig { + ContractModerationConfig { + banlist: true, + suspensions: true, + warnings: false, + moderators: ContractModerators::Elected(Box::new(elected)), + } + } + + /// The reason the declaration is refused for, `None` when it is accepted + fn refusal(config: &ContractModerationConfig) -> Option { + let result = config + .validate(&schemas(), PlatformVersion::latest()) + .expect("validate"); + (!result.is_valid()).then(|| rendered(&result.errors)) + } + + /// The errors' messages, one per line; `Debug` would escape the quotes the messages + /// put around document type names. + fn rendered(errors: &[ConsensusError]) -> String { + errors + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n") + } + + #[test] + fn should_accept_a_declaration_within_every_bound() { + assert_eq!(refusal(&config(elected())), None); + } + + #[test] + fn should_accept_every_bound_and_refuse_one_second_outside_each() { + let limits = &PlatformVersion::latest().system_limits; + let window_min = limits.min_contract_moderation_election_window_seconds; + let window_max = limits.max_contract_moderation_election_window_seconds; + let cool_down_min = limits.min_contract_moderation_challenge_cool_down_seconds; + let cool_down_max = limits.max_contract_moderation_challenge_cool_down_seconds; + assert_eq!(window_min, 86_400); + assert_eq!(window_max, 2_419_200); + assert_eq!(cool_down_min, 1_209_600); + assert_eq!(cool_down_max, 94_608_000); + + let with = |f: fn(&mut ElectedModerators, u32), seconds: u32| { + let mut declaration = elected(); + f(&mut declaration, seconds); + config(declaration) + }; + let join = |d: &mut ElectedModerators, s: u32| d.join_window = s; + let vote = |d: &mut ElectedModerators, s: u32| d.vote_window = s; + let cool_down = |d: &mut ElectedModerators, s: u32| d.challenge_cool_down = s; + for (name, field, min, max) in [ + ( + "join window", + join as fn(&mut ElectedModerators, u32), + window_min, + window_max, + ), + ("vote window", vote, window_min, window_max), + ( + "challenge cool-down", + cool_down, + cool_down_min, + cool_down_max, + ), + ] { + assert_eq!(refusal(&with(field, min)), None, "{name} at its minimum"); + assert_eq!(refusal(&with(field, max)), None, "{name} at its maximum"); + let below = refusal(&with(field, min - 1)).expect("refused below the minimum"); + assert!(below.contains(name), "{below}"); + let above = refusal(&with(field, max + 1)).expect("refused above the maximum"); + assert!(above.contains(name), "{above}"); + } + } + + #[test] + fn should_require_a_moderated_set_of_document_types_the_contract_has() { + let mut empty = elected(); + empty.moderated_document_types.clear(); + assert!(refusal(&config(empty)) + .expect("refused") + .contains("moderated document type set is empty")); + + let mut unknown = elected(); + unknown + .moderated_document_types + .insert("comment".to_string(), moderated(&[ModerationAbility::Ban])); + assert!(refusal(&config(unknown)) + .expect("refused") + .contains("\"comment\" is not a document type")); + + // A moderated type need not be deletable by moderators. + let mut not_deletable = elected(); + not_deletable.moderated_document_types = + BTreeMap::from([("like".to_string(), moderated(&[ModerationAbility::Ban]))]); + assert_eq!(refusal(&config(not_deletable)), None); + } + + #[test] + fn should_require_a_non_empty_envelope_the_contract_can_back() { + let mut empty = elected(); + abilities_of(&mut empty, "post").clear(); + assert!(refusal(&config(empty)) + .expect("refused") + .contains("ability set of the moderated document type \"post\" is empty")); + + let mut bans_without_banlist = config(elected()); + bans_without_banlist.banlist = false; + bans_without_banlist.suspensions = true; + assert!(refusal(&bans_without_banlist) + .expect("refused") + .contains("keeps no banlist")); + + let mut suspensions_without_list = config(elected()); + suspensions_without_list.suspensions = false; + assert!(refusal(&suspensions_without_list) + .expect("refused") + .contains("keeps no suspension list")); + + let mut warnings = elected(); + abilities_of(&mut warnings, "post").insert(ModerationAbility::Warn); + let mut warnings_without_list = config(warnings); + assert!(refusal(&warnings_without_list) + .expect("refused") + .contains("keeps no warning list")); + warnings_without_list.warnings = true; + assert_eq!(refusal(&warnings_without_list), None); + + // Deletions on `post` are backed by its flag; on `like` they are not. + let mut deletions = elected(); + *abilities_of(&mut deletions, "post") = + BTreeSet::from([ModerationAbility::DeleteDocuments]); + assert_eq!(refusal(&config(deletions)), None); + let mut deletions_on_like = elected(); + deletions_on_like.moderated_document_types.insert( + "like".to_string(), + moderated(&[ModerationAbility::DeleteDocuments]), + ); + assert!(refusal(&config(deletions_on_like)) + .expect("refused") + .contains("\"like\" allows document deletions, but the type can not be deleted")); + } + + #[test] + fn should_bound_the_interim_set_like_an_appointed_set() { + let max = PlatformVersion::latest() + .system_limits + .max_contract_moderators as u8; + let with = |ids: &[u8]| { + let mut declaration = elected(); + declaration.interim = InterimModerators::AppointedModerators(set(ids)); + config(declaration) + }; + assert!(refusal(&with(&[])).expect("refused").contains("empty")); + assert_eq!(refusal(&with(&(1..=max).collect::>())), None); + assert!(refusal(&with(&(1..=max + 1).collect::>())) + .expect("refused") + .contains("at most")); + } + + #[test] + fn should_give_each_interim_kind_its_authority_team_and_block() { + let owner = Identifier::from([9; 32]); + let moderator = Identifier::from([1; 32]); + let user = Identifier::from([2; 32]); + let with = |interim: InterimModerators, owner_protected: bool| { + let mut declaration = elected(); + declaration.interim = interim; + declaration.owner_protected = owner_protected; + ContractModerators::Elected(Box::new(declaration)) + }; + + let owner_alone = with(InterimModerators::ContractOwner, false); + assert!(owner_alone.may_moderate(&owner, &owner)); + assert!(!owner_alone.may_moderate(&owner, &moderator)); + assert_eq!(owner_alone.team(&owner), BTreeSet::from([owner])); + assert_eq!(owner_alone.identity_ids(), None); + assert!(owner_alone.protects(&owner, &owner)); + assert!(!owner_alone.protects(&owner, &user)); + assert!(!owner_alone.interim_blocks_document_type("post")); + + let appointed = with(InterimModerators::AppointedModerators(set(&[1])), false); + assert!(appointed.may_moderate(&owner, &owner)); + assert!(appointed.may_moderate(&owner, &moderator)); + assert!(!appointed.may_moderate(&owner, &user)); + assert_eq!(appointed.team(&owner), set(&[1])); + assert_eq!(appointed.identity_ids(), Some(&set(&[1]))); + assert!(appointed.protects(&owner, &moderator)); + + let nobody = with(InterimModerators::NotYetUsable, false); + assert!(!nobody.may_moderate(&owner, &owner)); + assert!(nobody.team(&owner).is_empty()); + assert!(!nobody.protects(&owner, &owner)); + assert!(nobody.interim_blocks_document_type("post")); + assert!(!nobody.interim_blocks_document_type("like")); + + // The flag protects the owner where nothing else does. + let protected = with(InterimModerators::NotYetUsable, true); + assert!(!protected.may_moderate(&owner, &owner)); + assert!(protected.protects(&owner, &owner)); + assert!(!protected.protects(&owner, &user)); + } + + #[test] + fn should_round_trip_through_json_and_platform_value() { + let mut declaration = elected(); + declaration.interim = InterimModerators::AppointedModerators(set(&[1, 2])); + declaration.owner_protected = true; + declaration.election_delay = Some(86_400); + let moderators = ContractModerators::Elected(Box::new(declaration)); + + let json = serde_json::to_value(&moderators).expect("serialize"); + assert_eq!(json["$type"], "elected"); + assert_eq!(json["joinWindow"], 604_800); + assert_eq!(json["electionDelay"], 86_400); + assert_eq!( + json["moderatedDocumentTypes"], + serde_json::json!({ "post": ["ban", "suspend"] }) + ); + assert_eq!(json["interim"]["$type"], "appointedModerators"); + assert_eq!( + json["interim"]["identities"].as_array().map(|a| a.len()), + Some(2) + ); + assert_eq!(json["ownerProtected"], true); + let back: ContractModerators = serde_json::from_value(json).expect("deserialize"); + assert_eq!(back, moderators); + + let value = platform_value::to_value(&moderators).expect("to value"); + let back: ContractModerators = platform_value::from_value(value).expect("from value"); + assert_eq!(back, moderators); + + let config = ContractModerationConfig { + banlist: true, + suspensions: true, + warnings: false, + moderators: moderators.clone(), + }; + let value = platform_value::to_value(&config).expect("to value"); + let back: ContractModerationConfig = platform_value::from_value(value).expect("from value"); + assert_eq!(back, config); + } + + #[test] + fn should_default_the_windows_and_the_flag_and_refuse_a_misspelled_key() { + let minimal = serde_json::json!({ + "$type": "elected", + "challengeCoolDown": 1_209_600, + "moderatedDocumentTypes": { "post": ["ban"] }, + "interim": { "$type": "notYetUsable" }, + }); + let parsed: ContractModerators = serde_json::from_value(minimal).expect("deserialize"); + let elected = parsed.elected().expect("elected"); + assert_eq!(elected.join_window, DEFAULT_ELECTION_WINDOW_SECONDS); + assert_eq!(elected.election_delay, None); + let json = serde_json::to_value(&parsed).expect("serialize"); + assert!( + json.get("electionDelay").is_none(), + "a declaration without a delay serializes none: {json}" + ); + assert_eq!(elected.vote_window, DEFAULT_ELECTION_WINDOW_SECONDS); + assert!(!elected.owner_protected); + assert_eq!(elected.interim, InterimModerators::NotYetUsable); + let no_moderation: InterimModerators = + serde_json::from_value(serde_json::json!({ "$type": "noModeration" })) + .expect("deserialize"); + assert_eq!(no_moderation, InterimModerators::NoModeration); + assert_eq!( + serde_json::to_value(&no_moderation).expect("serialize"), + serde_json::json!({ "$type": "noModeration" }) + ); + + let refused = [ + // The cool-down has no default. + serde_json::json!({ + "$type": "elected", + "moderatedDocumentTypes": { "post": ["ban"] }, + "interim": { "$type": "contractOwner" }, + }), + // A misspelled key is refused, not dropped. + serde_json::json!({ + "$type": "elected", + "challengeCoolDown": 1_209_600, + "moderatedDocumentTypes": { "post": ["ban"] }, + "interim": { "$type": "contractOwner" }, + "ownerProtcted": true, + }), + // So is one inside the interim, and an unknown interim kind. + serde_json::json!({ + "$type": "elected", + "challengeCoolDown": 1_209_600, + "moderatedDocumentTypes": { "post": ["ban"] }, + "interim": { "$type": "contractOwner", "identity": [] }, + }), + serde_json::json!({ + "$type": "elected", + "challengeCoolDown": 1_209_600, + "moderatedDocumentTypes": { "post": ["ban"] }, + "interim": { "$type": "seatedTeam" }, + }), + // An unknown ability. + serde_json::json!({ + "$type": "elected", + "challengeCoolDown": 1_209_600, + "moderatedDocumentTypes": { "post": ["silence"] }, + "interim": { "$type": "contractOwner" }, + }), + // A charter does not price actions: fee maximums are no key of the declaration. + serde_json::json!({ + "$type": "elected", + "challengeCoolDown": 1_209_600, + "moderatedDocumentTypes": { "post": ["ban"] }, + "moderatorsActionFeeMaximums": { "post": { "create": 1 } }, + "interim": { "$type": "contractOwner" }, + }), + // The abilities live under each moderated type, not beside them. + serde_json::json!({ + "$type": "elected", + "challengeCoolDown": 1_209_600, + "moderatedDocumentTypes": ["post"], + "abilities": ["ban"], + "interim": { "$type": "contractOwner" }, + }), + // The elected keys under another kind, and `identities` under elected. + serde_json::json!({ "$type": "contractOwner", "ownerProtected": true }), + serde_json::json!({ + "$type": "elected", + "challengeCoolDown": 1_209_600, + "moderatedDocumentTypes": { "post": ["ban"] }, + "interim": { "$type": "contractOwner" }, + "identities": [], + }), + ]; + for json in refused { + assert!( + serde_json::from_value::(json.clone()).is_err(), + "{json}" + ); + } + } + + #[test] + fn should_open_the_election_after_the_delay_from_the_contract_creation() { + let created_at: TimestampMillis = 1_700_000_000_000; + let mut declaration = elected(); + + // No delay: open at once, whether or not the creation time is recorded + assert!(declaration.election_is_open(Some(created_at), created_at)); + assert!(declaration.election_is_open(None, 0)); + + declaration.election_delay = Some(3600); + assert!(!declaration.election_is_open(Some(created_at), created_at + 3_599_999)); + assert!(declaration.election_is_open(Some(created_at), created_at + 3_600_000)); + assert!(declaration.election_is_open(Some(created_at), TimestampMillis::MAX)); + // A delay on a contract of unknown age never opens + assert!(!declaration.election_is_open(None, TimestampMillis::MAX)); + // The bound saturates rather than wrapping around into the past + declaration.election_delay = Some(u32::MAX); + assert!( + !declaration.election_is_open(Some(TimestampMillis::MAX - 1), TimestampMillis::MAX - 1) + ); + } + + #[test] + fn should_describe_itself() { + let mut declaration = elected(); + assert_eq!( + ContractModerators::Elected(Box::new(declaration.clone())).to_string(), + "an elected moderation team, in its interim moderated by the contract owner" + ); + declaration.election_delay = Some(86_400); + assert_eq!( + declaration.to_string(), + "an elected moderation team, in its interim moderated by the contract owner, its \ + first election open 86400 seconds after the contract's creation" + ); + declaration.election_delay = None; + declaration.interim = InterimModerators::NotYetUsable; + assert_eq!( + declaration.to_string(), + "an elected moderation team, in its interim moderated by nobody, its moderated \ + document types not yet usable" + ); + declaration.interim = InterimModerators::NoModeration; + assert_eq!( + declaration.to_string(), + "an elected moderation team, in its interim moderated by nobody, its moderated \ + document types unmoderated meanwhile" + ); + assert_eq!( + ModerationAbility::DeleteDocuments.to_string(), + "deleteDocuments" + ); + } + + #[test] + fn should_leave_the_moderated_types_usable_and_unmoderated_under_no_moderation() { + let mut declaration = elected(); + declaration.interim = InterimModerators::NoModeration; + let owner = Identifier::from([9u8; 32]); + assert!(!declaration.interim_blocks_document_type("post")); + assert!(!declaration.interim.may_moderate(&owner, &owner)); + assert!(declaration.interim.team(&owner).is_empty()); + assert_eq!(declaration.interim.identity_ids(), None); + assert!(declaration.allows("post", ModerationAbility::Ban)); + assert!(!declaration.allows("post", ModerationAbility::Warn)); + assert!(!declaration.allows("like", ModerationAbility::Ban)); + assert_eq!(refusal(&config(declaration)), None); + } +} diff --git a/packages/rs-dpp/src/data_contract/config/moderation/mod.rs b/packages/rs-dpp/src/data_contract/config/moderation/mod.rs index 6c2539cd5ea..30262d0941e 100644 --- a/packages/rs-dpp/src/data_contract/config/moderation/mod.rs +++ b/packages/rs-dpp/src/data_contract/config/moderation/mod.rs @@ -1,38 +1,62 @@ //! Contract moderation: the declaration, inside a data contract's config, that the contract -//! keeps a banlist and/or a suspension list of identities, and who may edit them. +//! keeps a banlist, a suspension list and/or a warning list of identities, and who may edit +//! them. //! //! An identity on the banlist, or on the suspension list with a suspension that has not lapsed, //! cannot act on the contract at the document level: every document transition it signs against -//! the contract is refused. Token transitions are not affected. The lists live under the -//! contract's own subtree in Drive (keys `128` and `192` of its other tree, `[64, id, 2]`) and are edited by the +//! the contract is refused. Token transitions are not affected. A warning bars nothing: it is +//! a record, with a reason and a time, that the identity and everyone else can read, and that +//! accumulates until a moderator clears it. The lists live under the contract's own subtree in +//! Drive (keys `128`, `192` and `224` of its other tree, `[64, id, 2]`) and are edited by the //! `ContractUserModeration` state transition. //! //! The same moderators may delete the documents of the document types that say so //! (`canBeDeletedByModerators`), with the same transition. Each removal leaves a //! [`ContractDocumentRemoval`] under the contract (key `16` of its other tree). +//! +//! A contract may instead declare that its moderators are an [elected team](elected): until +//! one is seated, the interim moderators the declaration names moderate as the merged kinds +//! do, or nobody does and the moderated document types wait. use crate::consensus::basic::contract_moderation::InvalidContractModerationConfigError; +use crate::data_contract::document_type::property_names::CAN_BE_DELETED_BY_MODERATORS; +use crate::data_contract::DocumentName; use crate::identity::TimestampMillis; #[cfg(feature = "json-conversion")] use crate::serialization::JsonSafeFields; use crate::validation::SimpleConsensusValidationResult; use crate::ProtocolError; use bincode::{Decode, DecodeUntrusted, Encode}; -use platform_value::Identifier; +use platform_value::{Identifier, Value}; use platform_version::version::PlatformVersion; use serde::{Deserialize, Serialize}; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::fmt; mod document_removal; +pub mod elected; mod reason; -pub use document_removal::ContractDocumentRemoval; -pub use reason::ContractModerationReason; +pub use document_removal::{ContractDocumentRemoval, ContractDocumentRestoration}; +pub use elected::{ + ElectedModerators, InterimModerators, ModerationAbility, DEFAULT_ELECTION_WINDOW_SECONDS, +}; +pub use reason::{ContractModerationDocument, ContractModerationReason}; + +/// Whether a raw document type schema sets `canBeDeletedByModerators: true`. +pub fn document_schema_lets_moderators_delete(schema: &Value) -> bool { + schema + .get_optional_bool(CAN_BE_DELETED_BY_MODERATORS) + .ok() + .flatten() + .unwrap_or(false) +} /// Who may send a `ContractUserModeration` transition for the contract. /// -/// The contract owner always may, named or not. A moderator set is fixed in the config and -/// changed only by a contract update. +/// With the first two kinds the contract owner always may, named or not; a moderator set is +/// fixed in the config and changed only by a contract update. With the third the moderators +/// are an elected team, and until one is seated the interim moderators the declaration +/// names; the declaration is fixed at creation and never changes. #[derive(Debug, Clone, PartialEq, Eq, Default, Encode, Decode, DecodeUntrusted)] pub enum ContractModerators { /// Only the contract owner moderates. @@ -43,32 +67,77 @@ pub enum ContractModerators { /// exists. The owner may be appointed too, and then counts toward that limit; appointing /// it changes nothing about who may moderate. AppointedModerators(BTreeSet), + /// A team elected by masternodes moderates, once one is seated; until then the interim + /// moderators of the declaration do. Declarable only when the contract is created, and + /// never left or changed by an update. Boxed: the declaration is the largest kind by + /// far, and a contract's config is embedded by value wherever a contract is. + Elected(Box), } impl ContractModerators { - /// The identities the set names, the owner among them only when it is named. `None` for - /// `ContractOwner`. + /// The identities the kind names, the owner among them only when it is named: the + /// appointed set, or the appointed interim set of an elected declaration. `None` when + /// nobody is named. pub fn identity_ids(&self) -> Option<&BTreeSet> { match self { ContractModerators::ContractOwner => None, ContractModerators::AppointedModerators(ids) => Some(ids), + ContractModerators::Elected(elected) => elected.interim.identity_ids(), } } - /// Whether `identity_id` is one of the identities the set names. + /// Whether `identity_id` is one of the identities the kind names. pub fn names(&self, identity_id: &Identifier) -> bool { self.identity_ids() .is_some_and(|ids| ids.contains(identity_id)) } - /// Whether `identity_id` may moderate a contract owned by `owner_id`. + /// The elected declaration, `None` for the merged kinds. + pub fn elected(&self) -> Option<&ElectedModerators> { + match self { + ContractModerators::Elected(elected) => Some(elected.as_ref()), + ContractModerators::ContractOwner | ContractModerators::AppointedModerators(_) => None, + } + } + + /// Whether `identity_id` may moderate a contract owned by `owner_id`. Under an elected + /// declaration, whether it may during the interim: the owner alone, the owner and the + /// appointed interim set, or nobody, the moderated types unusable or unmoderated meanwhile. pub fn may_moderate(&self, owner_id: &Identifier, identity_id: &Identifier) -> bool { - owner_id == identity_id || self.names(identity_id) + match self { + ContractModerators::ContractOwner | ContractModerators::AppointedModerators(_) => { + owner_id == identity_id || self.names(identity_id) + } + ContractModerators::Elected(elected) => { + elected.interim.may_moderate(owner_id, identity_id) + } + } + } + + /// Whether `identity_id` is protected from moderation on a contract owned by `owner_id`: + /// it can be neither banned nor suspended, and its documents can not be deleted. Whoever + /// may moderate is, and so is the owner of an elected contract whose declaration says so. + pub fn protects(&self, owner_id: &Identifier, identity_id: &Identifier) -> bool { + self.may_moderate(owner_id, identity_id) + || (owner_id == identity_id + && self + .elected() + .is_some_and(|elected| elected.owner_protected)) + } + + /// Whether every document transition of the document type is refused: an elected + /// declaration in its interim with nobody moderating blocks its moderated types until a + /// team is seated. + pub fn interim_blocks_document_type(&self, document_type_name: &str) -> bool { + self.elected() + .is_some_and(|elected| elected.interim_blocks_document_type(document_type_name)) } /// The moderation team of a contract owned by `owner_id`: the identities that share its /// moderators fee pot. It is the set the contract appoints, the owner among them only - /// when appointed, and the owner alone when nobody is appointed. + /// when appointed, and the owner alone when nobody is appointed. Under an elected + /// declaration it is the interim's team: the same by kind, and nobody while the + /// moderated types are not yet usable, so that the pot accumulates for the team to come. /// /// The team is about earnings, not authority: an owner who is not on it still may /// moderate ([`Self::may_moderate`]). @@ -76,15 +145,18 @@ impl ContractModerators { match self { ContractModerators::ContractOwner => BTreeSet::from([*owner_id]), ContractModerators::AppointedModerators(ids) => ids.clone(), + ContractModerators::Elected(elected) => elected.interim.team(owner_id), } } } -// The wire shape is a flat `{"$type": "contractOwner"}` or -// `{"$type": "appointedModerators", "identities": [...]}` map, the style of -// `AuthorizedActionTakers`. Bincode is untouched. +// The wire shape is a flat `{"$type": "contractOwner"}`, +// `{"$type": "appointedModerators", "identities": [...]}` or `{"$type": "elected", ...}` map +// with the declaration's keys beside its `$type`, the style of `AuthorizedActionTakers`. +// Bincode is untouched. impl Serialize for ContractModerators { fn serialize(&self, serializer: S) -> Result { + use elected::property_names as elected_names; use serde::ser::SerializeMap; match self { ContractModerators::ContractOwner => { @@ -98,14 +170,87 @@ impl Serialize for ContractModerators { m.serialize_entry("identities", ids)?; m.end() } + ContractModerators::Elected(elected) => { + let entries = 7 + usize::from(elected.election_delay.is_some()); + let mut m = serializer.serialize_map(Some(entries))?; + m.serialize_entry("$type", "elected")?; + m.serialize_entry(elected_names::JOIN_WINDOW, &elected.join_window)?; + m.serialize_entry(elected_names::VOTE_WINDOW, &elected.vote_window)?; + m.serialize_entry( + elected_names::CHALLENGE_COOL_DOWN, + &elected.challenge_cool_down, + )?; + // Absent, not null, when the declaration has no delay: the wire form of a + // declaration that left it out is unchanged + if let Some(delay) = elected.election_delay { + m.serialize_entry(elected_names::ELECTION_DELAY, &delay)?; + } + m.serialize_entry( + elected_names::MODERATED_DOCUMENT_TYPES, + &elected.moderated_document_types, + )?; + m.serialize_entry(elected_names::INTERIM, &elected.interim)?; + m.serialize_entry(elected_names::OWNER_PROTECTED, &elected.owner_protected)?; + m.end() + } } } } impl<'de> Deserialize<'de> for ContractModerators { fn deserialize>(deserializer: D) -> Result { + use elected::property_names as elected_names; use serde::de::{self, MapAccess, Visitor}; + const KEYS: &[&str] = &[ + "$type", + "identities", + elected_names::JOIN_WINDOW, + elected_names::VOTE_WINDOW, + elected_names::CHALLENGE_COOL_DOWN, + elected_names::ELECTION_DELAY, + elected_names::MODERATED_DOCUMENT_TYPES, + elected_names::INTERIM, + elected_names::OWNER_PROTECTED, + ]; + + /// The keys of an elected declaration, each read at most once. + #[derive(Default)] + struct ElectedKeys { + join_window: Option, + vote_window: Option, + challenge_cool_down: Option, + election_delay: Option, + moderated_document_types: Option>>, + interim: Option, + owner_protected: Option, + } + + impl ElectedKeys { + fn any(&self) -> bool { + self.join_window.is_some() + || self.vote_window.is_some() + || self.challenge_cool_down.is_some() + || self.election_delay.is_some() + || self.moderated_document_types.is_some() + || self.interim.is_some() + || self.owner_protected.is_some() + } + } + + /// Reads the value of `key` into `slot`, refusing a second occurrence. + fn read_once<'de, A: MapAccess<'de>, T: Deserialize<'de>>( + map: &mut A, + key: &'static str, + slot: &mut Option, + ) -> Result<(), A::Error> { + if slot.is_some() { + return Err(de::Error::duplicate_field(key)); + } + *slot = Some(map.next_value()?); + Ok(()) + } + struct V; impl<'de> Visitor<'de> for V { @@ -114,38 +259,68 @@ impl<'de> Deserialize<'de> for ContractModerators { fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { f.write_str( "ContractModerators as a map with a `$type` discriminator, \ - e.g. {\"$type\": \"contractOwner\"} or \ - {\"$type\": \"appointedModerators\", \"identities\": [\"\"]}", + e.g. {\"$type\": \"contractOwner\"}, \ + {\"$type\": \"appointedModerators\", \"identities\": [\"\"]} or \ + {\"$type\": \"elected\", \"challengeCoolDown\": 1209600, \ + \"moderatedDocumentTypes\": [\"post\"], \"abilities\": [\"ban\"], \ + \"interim\": {\"$type\": \"contractOwner\"}}", ) } fn visit_map>(self, mut map: A) -> Result { let mut variant: Option = None; let mut identities: Option> = None; + let mut elected = ElectedKeys::default(); while let Some(key) = map.next_key::()? { match key.as_str() { - "$type" => { - if variant.is_some() { - return Err(de::Error::duplicate_field("$type")); - } - variant = Some(map.next_value()?); - } - "identities" => { - if identities.is_some() { - return Err(de::Error::duplicate_field("identities")); - } - identities = Some(map.next_value()?); + "$type" => read_once(&mut map, "$type", &mut variant)?, + "identities" => read_once(&mut map, "identities", &mut identities)?, + elected_names::JOIN_WINDOW => read_once( + &mut map, + elected_names::JOIN_WINDOW, + &mut elected.join_window, + )?, + elected_names::VOTE_WINDOW => read_once( + &mut map, + elected_names::VOTE_WINDOW, + &mut elected.vote_window, + )?, + elected_names::CHALLENGE_COOL_DOWN => read_once( + &mut map, + elected_names::CHALLENGE_COOL_DOWN, + &mut elected.challenge_cool_down, + )?, + elected_names::ELECTION_DELAY => read_once( + &mut map, + elected_names::ELECTION_DELAY, + &mut elected.election_delay, + )?, + elected_names::MODERATED_DOCUMENT_TYPES => read_once( + &mut map, + elected_names::MODERATED_DOCUMENT_TYPES, + &mut elected.moderated_document_types, + )?, + elected_names::INTERIM => { + read_once(&mut map, elected_names::INTERIM, &mut elected.interim)? } + elected_names::OWNER_PROTECTED => read_once( + &mut map, + elected_names::OWNER_PROTECTED, + &mut elected.owner_protected, + )?, // Refused rather than skipped: the declaration can hardly be changed // after the contract is created, so a misspelled key must not pass. - other => { - return Err(de::Error::unknown_field(other, &["$type", "identities"])); - } + other => return Err(de::Error::unknown_field(other, KEYS)), } } let variant = variant.ok_or_else(|| de::Error::missing_field("$type"))?; + if variant != "elected" && elected.any() { + return Err(de::Error::custom( + "the keys of an elected declaration are only valid for `elected`", + )); + } match variant.as_str() { "contractOwner" => { if identities.is_some() { @@ -160,9 +335,37 @@ impl<'de> Deserialize<'de> for ContractModerators { identities.ok_or_else(|| de::Error::missing_field("identities"))?; Ok(ContractModerators::AppointedModerators(ids)) } + "elected" => { + if identities.is_some() { + return Err(de::Error::custom( + "`identities` is only valid for `appointedModerators`; an \ + elected declaration names its interim set under `interim`", + )); + } + let required = |key: &'static str| move || de::Error::missing_field(key); + Ok(ContractModerators::Elected(Box::new(ElectedModerators { + join_window: elected + .join_window + .unwrap_or(DEFAULT_ELECTION_WINDOW_SECONDS), + vote_window: elected + .vote_window + .unwrap_or(DEFAULT_ELECTION_WINDOW_SECONDS), + challenge_cool_down: elected + .challenge_cool_down + .ok_or_else(required(elected_names::CHALLENGE_COOL_DOWN))?, + election_delay: elected.election_delay, + moderated_document_types: elected + .moderated_document_types + .ok_or_else(required(elected_names::MODERATED_DOCUMENT_TYPES))?, + interim: elected + .interim + .ok_or_else(required(elected_names::INTERIM))?, + owner_protected: elected.owner_protected.unwrap_or(false), + }))) + } other => Err(de::Error::unknown_variant( other, - &["contractOwner", "appointedModerators"], + &["contractOwner", "appointedModerators", "elected"], )), } } @@ -179,11 +382,12 @@ impl fmt::Display for ContractModerators { ContractModerators::AppointedModerators(ids) => { write!(f, "contract owner and {} appointed moderators", ids.len()) } + ContractModerators::Elected(elected) => elected.fmt(f), } } } -/// Which of the two moderation lists an action or a query refers to. +/// Which of the moderation lists an action or a query refers to. #[derive( Debug, Clone, @@ -205,6 +409,19 @@ pub enum ContractModerationList { Banlist, /// The suspension list: identities barred until a block time. Suspensions, + /// The warning list: identities warned, and why, barred from nothing. + Warnings, +} + +impl ContractModerationList { + /// Whether an entry on the list bars the identity from the contract's documents. A + /// warning does not. + pub fn bars(&self) -> bool { + match self { + ContractModerationList::Banlist | ContractModerationList::Suspensions => true, + ContractModerationList::Warnings => false, + } + } } impl fmt::Display for ContractModerationList { @@ -212,6 +429,7 @@ impl fmt::Display for ContractModerationList { match self { ContractModerationList::Banlist => write!(f, "banlist"), ContractModerationList::Suspensions => write!(f, "suspensions"), + ContractModerationList::Warnings => write!(f, "warning list"), } } } @@ -232,6 +450,12 @@ pub struct ContractModerationConfig { /// Who may edit the lists. #[serde(default)] pub moderators: ContractModerators, + /// The contract keeps a warning list (Drive key `224` of the contract's other tree). + /// Last in the bincode layout: it joined after the rest, and a declaration stored by a + /// 4.2 beta without it fails to decode at its end rather than misreading the moderators. + /// Such networks are reset. + #[serde(default)] + pub warnings: bool, } impl ContractModerationConfig { @@ -240,25 +464,48 @@ impl ContractModerationConfig { match list { ContractModerationList::Banlist => self.banlist, ContractModerationList::Suspensions => self.suspensions, + ContractModerationList::Warnings => self.warnings, } } - /// The lists the contract keeps, in tree key order. + /// The lists the contract keeps, in tree key order: the banlist, the suspension list, the + /// warning list. pub fn lists(&self) -> impl Iterator + '_ { [ ContractModerationList::Banlist, ContractModerationList::Suspensions, + ContractModerationList::Warnings, ] .into_iter() .filter(|list| self.keeps(*list)) } + /// The lists the contract keeps whose entries bar an identity from its documents: the + /// banlist and the suspension list, never the warning list. What the document gate reads, + /// and what a ban's proof covers. + pub fn barring_lists(&self) -> impl Iterator + '_ { + self.lists().filter(ContractModerationList::bars) + } + /// Whether `identity_id` may moderate a contract owned by `owner_id`. Whoever may moderate /// cannot be put on a list either, though an entry it already carries may be removed. pub fn may_moderate(&self, owner_id: &Identifier, identity_id: &Identifier) -> bool { self.moderators.may_moderate(owner_id, identity_id) } + /// Whether `identity_id` is protected from moderation on a contract owned by `owner_id`. + /// See [`ContractModerators::protects`]. + pub fn protects(&self, owner_id: &Identifier, identity_id: &Identifier) -> bool { + self.moderators.protects(owner_id, identity_id) + } + + /// Whether every document transition of the document type is refused until a moderation + /// team is seated. See [`ContractModerators::interim_blocks_document_type`]. + pub fn interim_blocks_document_type(&self, document_type_name: &str) -> bool { + self.moderators + .interim_blocks_document_type(document_type_name) + } + /// The moderation team of a contract owned by `owner_id`: the identities that share its /// moderators fee pot. See [`ContractModerators::team`]. pub fn team(&self, owner_id: &Identifier) -> BTreeSet { @@ -268,14 +515,16 @@ impl ContractModerationConfig { /// The pure-data rules of the declaration: it gives the moderators something to do, and a /// moderator set is non-empty and within `SystemLimits::max_contract_moderators` (a named /// owner counts). Something to do is a list to edit or, failing that, a document type whose - /// documents they may delete (`has_document_type_deletable_by_moderators`, which the - /// caller reads from the contract the declaration belongs to). + /// documents they may delete, read from the raw `document_schemas` of the contract the + /// declaration belongs to, which an elected declaration is also checked against: its + /// moderated types must name document types of the contract + /// ([`ElectedModerators::validation_error`] has its rules). /// Whether the named identities exist is state validation, done by the contract create /// and update transitions: a moderator that does not exist can never sign, so naming one /// is a mistake, caught where it is cheapest. pub fn validate( &self, - has_document_type_deletable_by_moderators: bool, + document_schemas: &BTreeMap, platform_version: &PlatformVersion, ) -> Result { match platform_version @@ -284,7 +533,7 @@ impl ContractModerationConfig { .methods .validate_moderation_config { - 0 => Ok(self.validate_v0(has_document_type_deletable_by_moderators, platform_version)), + 0 => Ok(self.validate_v0(document_schemas, platform_version)), version => Err(ProtocolError::UnknownVersionMismatch { method: "ContractModerationConfig::validate".to_string(), known_versions: vec![0], @@ -296,14 +545,21 @@ impl ContractModerationConfig { #[inline(always)] fn validate_v0( &self, - has_document_type_deletable_by_moderators: bool, + document_schemas: &BTreeMap, platform_version: &PlatformVersion, ) -> SimpleConsensusValidationResult { - if !self.banlist && !self.suspensions && !has_document_type_deletable_by_moderators { + let has_document_type_deletable_by_moderators = document_schemas + .values() + .any(document_schema_lets_moderators_delete); + if !self.banlist + && !self.suspensions + && !self.warnings + && !has_document_type_deletable_by_moderators + { return SimpleConsensusValidationResult::new_with_error( InvalidContractModerationConfigError::new( - "moderation declares neither a banlist nor a suspension list, and no \ - document type can be deleted by moderators" + "moderation declares neither a banlist, a suspension list nor a warning \ + list, and no document type can be deleted by moderators" .to_string(), ) .into(), @@ -330,6 +586,16 @@ impl ContractModerationConfig { ); } } + if let Some(reason) = self + .moderators + .elected() + .and_then(|elected| elected.validation_error(self, document_schemas, platform_version)) + { + return SimpleConsensusValidationResult::new_with_error( + InvalidContractModerationConfigError::new(format!("elected moderation: {reason}")) + .into(), + ); + } SimpleConsensusValidationResult::new() } } @@ -363,6 +629,18 @@ pub struct ContractSuspension { pub reason: ContractModerationReason, } +/// One warning of a warning list entry. +#[derive( + Debug, Clone, PartialEq, Eq, Default, Encode, Decode, DecodeUntrusted, Serialize, Deserialize, +)] +#[serde(rename_all = "camelCase")] +pub struct ContractWarning { + /// The time of the block that issued the warning, in milliseconds. + pub warned_at: TimestampMillis, + /// Why the moderator warned the identity. + pub reason: ContractModerationReason, +} + /// What a contract's moderation lists say about one identity. #[derive( Debug, Clone, PartialEq, Eq, Default, Encode, Decode, DecodeUntrusted, Serialize, Deserialize, @@ -374,6 +652,10 @@ pub struct ContractModerationStatus { /// The identity's suspension list entry, `None` when it is not suspended. A lapsed /// suspension (at or before the block time) still appears here until it is swept. pub suspension: Option, + /// The identity's warnings, oldest first; empty when it carries none. They stay until a + /// moderator clears them, and bar nothing. + #[serde(default)] + pub warnings: Vec, } impl ContractModerationStatus { @@ -382,6 +664,11 @@ impl ContractModerationStatus { self.ban.is_some() } + /// Whether the identity carries at least one warning. + pub fn warned(&self) -> bool { + !self.warnings.is_empty() + } + /// The block time, in milliseconds, until which the identity is suspended, lapsed or not. pub fn suspended_until(&self) -> Option { self.suspension.as_ref().map(|suspension| suspension.until) @@ -423,6 +710,12 @@ pub enum ContractModerationListStatus { /// suspension still appears here until it is swept. suspension: Option, }, + /// The warning list entry + #[serde(rename_all = "camelCase")] + Warnings { + /// The identity's warnings, oldest first; empty when it carries none. + warnings: Vec, + }, } impl ContractModerationListStatus { @@ -435,6 +728,9 @@ impl ContractModerationListStatus { ContractModerationList::Suspensions => Self::Suspensions { suspension: status.suspension.clone(), }, + ContractModerationList::Warnings => Self::Warnings { + warnings: status.warnings.clone(), + }, } } @@ -443,6 +739,7 @@ impl ContractModerationListStatus { match self { Self::Banlist { .. } => ContractModerationList::Banlist, Self::Suspensions { .. } => ContractModerationList::Suspensions, + Self::Warnings { .. } => ContractModerationList::Warnings, } } } @@ -474,7 +771,8 @@ impl ContractModerationListStatuses { pub fn ban(&self) -> Option> { self.0.iter().find_map(|status| match status { ContractModerationListStatus::Banlist { ban } => Some(ban.as_ref()), - ContractModerationListStatus::Suspensions { .. } => None, + ContractModerationListStatus::Suspensions { .. } + | ContractModerationListStatus::Warnings { .. } => None, }) } @@ -482,8 +780,19 @@ impl ContractModerationListStatuses { /// suspension list was not queried. pub fn suspension(&self) -> Option> { self.0.iter().find_map(|status| match status { - ContractModerationListStatus::Banlist { .. } => None, ContractModerationListStatus::Suspensions { suspension } => Some(suspension.as_ref()), + ContractModerationListStatus::Banlist { .. } + | ContractModerationListStatus::Warnings { .. } => None, + }) + } + + /// The identity's warnings, oldest first (`Some(&[])`: none), `None` when the warning list + /// was not queried. + pub fn warnings(&self) -> Option<&[ContractWarning]> { + self.0.iter().find_map(|status| match status { + ContractModerationListStatus::Warnings { warnings } => Some(warnings.as_slice()), + ContractModerationListStatus::Banlist { .. } + | ContractModerationListStatus::Suspensions { .. } => None, }) } @@ -513,11 +822,20 @@ impl ContractModerationListStatuses { #[cfg(test)] mod tests { use super::*; + use platform_value::platform_value; fn set(ids: &[u8]) -> BTreeSet { ids.iter().map(|b| Identifier::from([*b; 32])).collect() } + /// One document type, `post`, whose documents moderators may delete + fn schemas_with_a_deletable_type() -> BTreeMap { + BTreeMap::from([( + "post".to_string(), + platform_value!({ "type": "object", "canBeDeletedByModerators": true }), + )]) + } + #[test] fn should_round_trip_moderators_through_json() { for moderators in [ @@ -564,10 +882,11 @@ mod tests { let config = ContractModerationConfig { banlist: false, suspensions: false, + warnings: false, moderators: ContractModerators::ContractOwner, }; let result = config - .validate(false, PlatformVersion::latest()) + .validate(&BTreeMap::new(), PlatformVersion::latest()) .expect("validate"); assert!(!result.is_valid()); } @@ -577,10 +896,11 @@ mod tests { let config = ContractModerationConfig { banlist: false, suspensions: false, + warnings: false, moderators: ContractModerators::ContractOwner, }; let result = config - .validate(true, PlatformVersion::latest()) + .validate(&schemas_with_a_deletable_type(), PlatformVersion::latest()) .expect("validate"); assert!(result.is_valid(), "{:?}", result.errors); assert_eq!(config.lists().count(), 0); @@ -592,10 +912,11 @@ mod tests { let config = ContractModerationConfig { banlist: true, suspensions: false, + warnings: false, moderators: ContractModerators::AppointedModerators(set(&[9, 1])), }; let result = config - .validate(false, PlatformVersion::latest()) + .validate(&BTreeMap::new(), PlatformVersion::latest()) .expect("validate"); assert!(result.is_valid(), "{:?}", result.errors); // Naming the owner changes nothing about who may moderate or who is protected. @@ -610,16 +931,17 @@ mod tests { let config = |count: u8| ContractModerationConfig { banlist: true, suspensions: false, + warnings: false, moderators: ContractModerators::AppointedModerators(set( &(1..=count).collect::>() )), }; assert!(config(max) - .validate(false, platform_version) + .validate(&BTreeMap::new(), platform_version) .expect("validate") .is_valid()); assert!(!config(max + 1) - .validate(false, platform_version) + .validate(&BTreeMap::new(), platform_version) .expect("validate") .is_valid()); } @@ -630,10 +952,11 @@ mod tests { let empty = ContractModerationConfig { banlist: true, suspensions: true, + warnings: false, moderators: ContractModerators::AppointedModerators(BTreeSet::new()), }; assert!(!empty - .validate(false, platform_version) + .validate(&BTreeMap::new(), platform_version) .expect("validate") .is_valid()); let too_many: Vec = @@ -641,10 +964,11 @@ mod tests { let oversized = ContractModerationConfig { banlist: true, suspensions: true, + warnings: false, moderators: ContractModerators::AppointedModerators(set(&too_many)), }; assert!(!oversized - .validate(false, platform_version) + .validate(&BTreeMap::new(), platform_version) .expect("validate") .is_valid()); } @@ -655,10 +979,11 @@ mod tests { let config = ContractModerationConfig { banlist: true, suspensions: true, + warnings: false, moderators: ContractModerators::AppointedModerators(set(&[1, 2, 3])), }; assert!(config - .validate(false, PlatformVersion::latest()) + .validate(&BTreeMap::new(), PlatformVersion::latest()) .expect("validate") .is_valid()); assert!(config.may_moderate(&owner, &owner)); @@ -684,11 +1009,109 @@ mod tests { ); } + #[test] + fn should_keep_a_warning_list_alone_and_bar_nobody_with_it() { + let config = ContractModerationConfig { + banlist: false, + suspensions: false, + warnings: true, + moderators: ContractModerators::ContractOwner, + }; + let result = config + .validate(&BTreeMap::new(), PlatformVersion::latest()) + .expect("validate"); + assert!(result.is_valid(), "{:?}", result.errors); + assert_eq!( + config.lists().collect::>(), + vec![ContractModerationList::Warnings] + ); + // Warnings bar nothing: the document gate reads no list of this contract. + assert_eq!(config.barring_lists().count(), 0); + + let all = ContractModerationConfig { + banlist: true, + suspensions: true, + warnings: true, + moderators: ContractModerators::ContractOwner, + }; + assert_eq!( + all.lists().collect::>(), + vec![ + ContractModerationList::Banlist, + ContractModerationList::Suspensions, + ContractModerationList::Warnings, + ] + ); + assert_eq!( + all.barring_lists().collect::>(), + vec![ + ContractModerationList::Banlist, + ContractModerationList::Suspensions, + ] + ); + } + + #[test] + fn should_round_trip_a_config_with_warnings_through_json_and_default_them_off() { + let config = ContractModerationConfig { + banlist: true, + suspensions: false, + warnings: true, + moderators: ContractModerators::ContractOwner, + }; + let json = serde_json::to_value(&config).expect("to json"); + assert_eq!(json["warnings"], true); + let back: ContractModerationConfig = serde_json::from_value(json).expect("from json"); + assert_eq!(back, config); + + // A declaration written before warning lists existed keeps no warning list. + let older: ContractModerationConfig = serde_json::from_value(serde_json::json!({ + "banlist": true, + "moderators": { "$type": "contractOwner" }, + })) + .expect("from json"); + assert!(!older.warnings); + } + + #[test] + fn should_report_the_warnings_of_the_list_queried_only() { + let warned = ContractModerationStatus { + ban: None, + suspension: None, + warnings: vec![ContractWarning { + warned_at: 5, + reason: ContractModerationReason::from_text("first strike"), + }], + }; + assert!(warned.warned()); + assert!(!warned.is_barred_at(0)); + + let warnings_only = ContractModerationListStatuses::from_status( + &[ContractModerationList::Warnings], + &warned, + ); + assert_eq!(warnings_only.warnings().map(<[_]>::len), Some(1)); + assert_eq!(warnings_only.banned(), None); + assert_eq!(warnings_only.suspended_until(), None); + assert!(!warnings_only.is_barred_on_queried_lists_at(0)); + + let banlist_only = ContractModerationListStatuses::from_status( + &[ContractModerationList::Banlist], + &warned, + ); + assert_eq!(banlist_only.warnings(), None); + assert_eq!( + ContractModerationListStatus::Warnings { warnings: vec![] }.list(), + ContractModerationList::Warnings + ); + } + #[test] fn should_tell_barred_from_lapsed() { let banned = ContractModerationStatus { ban: Some(ContractBan::default()), suspension: None, + warnings: vec![], }; assert!(banned.banned()); assert!(banned.is_barred_at(0)); @@ -698,6 +1121,7 @@ mod tests { until: 100, reason: ContractModerationReason::from_text("flooding"), }), + warnings: vec![], }; assert!(!suspended.banned()); assert_eq!(suspended.suspended_until(), Some(100)); @@ -714,6 +1138,7 @@ mod tests { reason: ContractModerationReason::from_text("spam"), }), suspension: None, + warnings: vec![], }; let banlist_only = ContractModerationListStatuses::from_status( diff --git a/packages/rs-dpp/src/data_contract/config/moderation/reason.rs b/packages/rs-dpp/src/data_contract/config/moderation/reason.rs index 25103a74c29..58678f389d9 100644 --- a/packages/rs-dpp/src/data_contract/config/moderation/reason.rs +++ b/packages/rs-dpp/src/data_contract/config/moderation/reason.rs @@ -1,15 +1,49 @@ -use crate::consensus::basic::contract_moderation::ContractModerationReasonTooLongError; +use crate::consensus::basic::contract_moderation::{ + ContractModerationReasonTooLongError, InvalidContractModerationReasonDocumentsError, +}; #[cfg(feature = "json-conversion")] use crate::serialization::JsonSafeFields; use crate::validation::SimpleConsensusValidationResult; use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_value::Identifier; use platform_version::version::PlatformVersion; use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; -/// Why a moderator banned or suspended an identity. Every ban and every suspension carries -/// one, and it is stored with the entry, so whoever reads the list reads the reason. +/// The longest document type name a contract admits, in bytes: the bound the document type +/// parser enforces on a schema, applied here to a name a reason cites. +const MAX_DOCUMENT_TYPE_NAME_LENGTH: usize = 64; + +/// A document a moderation reason is about, named by its document type and its id. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + PartialOrd, + Ord, + Default, + Encode, + Decode, + DecodeUntrusted, + Serialize, + Deserialize, +)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ContractModerationDocument { + /// The document type of the document, on the moderated contract. + pub document_type_name: String, + /// The document's id. + pub document_id: Identifier, +} + +/// Why a moderator banned, suspended or warned an identity, or deleted a document. Every such +/// action carries one, and it is stored with the entry or the record, so whoever reads the +/// list reads the reason. /// -/// Nothing checks what a moderator writes: the text is free, and so is the code. +/// Nothing checks what a moderator writes: the text is free, so is the code, and the documents +/// a reason cites are not looked up. A cited document may have been deleted since, by its +/// author or by a moderator (whose deletion left a record), or may never have existed. #[derive( Debug, Clone, PartialEq, Eq, Default, Encode, Decode, DecodeUntrusted, Serialize, Deserialize, )] @@ -23,18 +57,32 @@ pub struct ContractModerationReason { /// Free text, at most `SystemLimits::max_contract_moderation_reason_length` bytes of /// UTF-8. May be empty. pub text: String, + /// The documents the reason is about: the posts a warning or a ban is for, at most + /// `SystemLimits::max_contract_moderation_reason_documents`, none twice. Left out of the + /// JSON when there are none. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub documents: Vec, } impl ContractModerationReason { - /// A reason without a code. + /// A reason without a code, about no document. pub fn from_text(text: impl Into) -> Self { Self { code: None, text: text.into(), + documents: vec![], } } - /// Checks the length of the text. The code is not checked. + /// The same reason, about `documents`. + pub fn with_documents(mut self, documents: Vec) -> Self { + self.documents = documents; + self + } + + /// Checks the length of the text and the documents cited: at most the limit, each with a + /// document type name a contract could admit (one to 64 bytes), and none twice. The code + /// is not checked, and neither is whether a cited document, or its type, exists. pub fn validate(&self, platform_version: &PlatformVersion) -> SimpleConsensusValidationResult { let max_length = platform_version .system_limits @@ -45,10 +93,48 @@ impl ContractModerationReason { .into(), ); } + let max_documents = platform_version + .system_limits + .max_contract_moderation_reason_documents; + if self.documents.len() > usize::from(max_documents) { + return SimpleConsensusValidationResult::new_with_error( + InvalidContractModerationReasonDocumentsError::new(format!( + "{} documents cited, at most {} allowed", + self.documents.len(), + max_documents + )) + .into(), + ); + } + let mut seen = BTreeSet::new(); + for document in &self.documents { + let name_length = document.document_type_name.len(); + if name_length == 0 || name_length > MAX_DOCUMENT_TYPE_NAME_LENGTH { + return SimpleConsensusValidationResult::new_with_error( + InvalidContractModerationReasonDocumentsError::new(format!( + "a cited document type name is {} bytes long, it must be 1 to {}", + name_length, MAX_DOCUMENT_TYPE_NAME_LENGTH + )) + .into(), + ); + } + if !seen.insert(document) { + return SimpleConsensusValidationResult::new_with_error( + InvalidContractModerationReasonDocumentsError::new(format!( + "document {} of type {} is cited twice", + document.document_id, document.document_type_name + )) + .into(), + ); + } + } SimpleConsensusValidationResult::new() } } +#[cfg(feature = "json-conversion")] +impl JsonSafeFields for ContractModerationDocument {} + #[cfg(feature = "json-conversion")] impl JsonSafeFields for ContractModerationReason {} @@ -57,12 +143,14 @@ mod tests { use super::*; use crate::consensus::basic::BasicError; use crate::consensus::ConsensusError; + use platform_value::string_encoding::Encoding; #[test] fn should_round_trip_through_json_with_and_without_a_code() { let reason = ContractModerationReason { code: Some(7), text: "spam".to_string(), + documents: vec![], }; let json = serde_json::to_value(&reason).expect("to json"); assert_eq!(json, serde_json::json!({"code": 7, "text": "spam"})); @@ -79,6 +167,79 @@ mod tests { ); } + fn document(seed: u8) -> ContractModerationDocument { + ContractModerationDocument { + document_type_name: "post".to_string(), + document_id: Identifier::from([seed; 32]), + } + } + + #[test] + fn should_round_trip_the_documents_through_json_and_leave_them_out_when_none() { + let reason = ContractModerationReason::from_text("spam").with_documents(vec![document(1)]); + let json = serde_json::to_value(&reason).expect("to json"); + assert_eq!(json["documents"][0]["documentTypeName"], "post"); + assert_eq!( + json["documents"][0]["documentId"], + Identifier::from([1; 32]).to_string(Encoding::Base58) + ); + assert_eq!( + serde_json::from_value::(json).expect("from json"), + reason + ); + // A reason about no document has no `documents` key, as before documents existed. + let json = + serde_json::to_value(ContractModerationReason::from_text("spam")).expect("to json"); + assert!(json.get("documents").is_none()); + } + + #[test] + fn should_bound_the_documents_cited_and_refuse_one_cited_twice() { + let platform_version = PlatformVersion::latest(); + let max_documents = platform_version + .system_limits + .max_contract_moderation_reason_documents as u8; + let cited = |count: u8| { + ContractModerationReason::from_text("spam") + .with_documents((1..=count).map(document).collect()) + }; + assert!(cited(max_documents).validate(platform_version).is_valid()); + let refused = |reason: ContractModerationReason| { + matches!( + reason.validate(platform_version).errors.as_slice(), + [ConsensusError::BasicError( + BasicError::InvalidContractModerationReasonDocumentsError(_) + )] + ) + }; + assert!(refused(cited(max_documents + 1))); + assert!(refused( + ContractModerationReason::from_text("spam") + .with_documents(vec![document(1), document(1)]) + )); + // The same id under another type is another document. + let other_type = ContractModerationDocument { + document_type_name: "reply".to_string(), + ..document(1) + }; + assert!(ContractModerationReason::from_text("spam") + .with_documents(vec![document(1), other_type]) + .validate(platform_version) + .is_valid()); + for name in ["", &"n".repeat(65)] { + assert!(refused( + ContractModerationReason::from_text("spam").with_documents(vec![ + ContractModerationDocument { + document_type_name: name.to_string(), + document_id: Identifier::from([1; 32]), + } + ]) + )); + } + // Nothing checks whether the document exists. + assert!(cited(1).validate(platform_version).is_valid()); + } + #[test] fn should_refuse_an_unknown_field() { serde_json::from_value::( @@ -97,6 +258,7 @@ mod tests { let reason = ContractModerationReason { code: Some(u16::MAX), text: "é".repeat(max_length / 2), + documents: vec![], }; assert_eq!(reason.text.len(), max_length); assert!(reason.validate(platform_version).is_valid()); diff --git a/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs b/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs index 0e34a7b57d2..589b295f98c 100644 --- a/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs @@ -945,6 +945,7 @@ impl DocumentTypeV1Getters for DocumentTypeMutRef<'_> { /// predate the keyword: V0 and V1 have no field to borrow from, and the /// getter hands out a reference. static NO_IMMUTABLE_FIELDS: BTreeSet = BTreeSet::new(); +static NO_ENTRY_PAYLOAD: BTreeSet = BTreeSet::new(); impl DocumentTypeV2Getters for DocumentType { fn documents_countable(&self) -> bool { @@ -987,6 +988,15 @@ impl DocumentTypeV2Getters for DocumentType { } } + /// The entry-payload property names of an indexOnly type (empty before V2). + fn entry_payload(&self) -> &BTreeSet { + match self { + DocumentType::V0(_) => &NO_ENTRY_PAYLOAD, + DocumentType::V1(_) => &NO_ENTRY_PAYLOAD, + DocumentType::V2(v2) => v2.entry_payload(), + } + } + fn documents_can_be_deleted_by_moderators(&self) -> bool { match self { DocumentType::V0(_) => false, @@ -1103,6 +1113,15 @@ impl DocumentTypeV2Getters for DocumentTypeRef<'_> { } } + /// The entry-payload property names of an indexOnly type (empty before V2). + fn entry_payload(&self) -> &BTreeSet { + match self { + DocumentTypeRef::V0(_) => &NO_ENTRY_PAYLOAD, + DocumentTypeRef::V1(_) => &NO_ENTRY_PAYLOAD, + DocumentTypeRef::V2(v2) => v2.entry_payload(), + } + } + fn documents_can_be_deleted_by_moderators(&self) -> bool { match self { DocumentTypeRef::V0(_) => false, @@ -1185,6 +1204,15 @@ impl DocumentTypeV2Getters for DocumentTypeMutRef<'_> { } } + /// The entry-payload property names of an indexOnly type (empty before V2). + fn entry_payload(&self) -> &BTreeSet { + match self { + DocumentTypeMutRef::V0(_) => &NO_ENTRY_PAYLOAD, + DocumentTypeMutRef::V1(_) => &NO_ENTRY_PAYLOAD, + DocumentTypeMutRef::V2(v2) => v2.entry_payload(), + } + } + fn documents_can_be_deleted_by_moderators(&self) -> bool { match self { DocumentTypeMutRef::V0(_) => false, diff --git a/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs index f492facd064..063ffe3fd54 100644 --- a/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs @@ -33,6 +33,10 @@ pub trait DocumentTypeV2Getters { /// each terminating in an `Item` keyed by the index's `terminal` /// property. Only what is in the indexes exists and is recoverable. fn index_only(&self) -> bool; + /// On an indexOnly type, the top-level properties stored in every entry's + /// value after the row commitment (`entryPayload`), in name order; empty + /// elsewhere. + fn entry_payload(&self) -> &BTreeSet; /// Returns whether the contract's moderators may delete documents of this /// type (the `canBeDeletedByModerators` keyword, protocol version 14). 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 caf79778b86..597adf4a2e2 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 @@ -71,8 +71,7 @@ use crate::consensus::basic::data_contract::{ }; #[cfg(feature = "validation")] use crate::consensus::basic::data_contract::{ - DuplicateIndexNameError, InvalidIndexPropertyTypeError, InvalidIndexedPropertyConstraintError, - SystemPropertyIndexAlreadyPresentError, UndefinedIndexPropertyError, + DuplicateIndexNameError, SystemPropertyIndexAlreadyPresentError, UndefinedIndexPropertyError, UniqueIndicesLimitReachedError, }; #[cfg(feature = "validation")] @@ -100,9 +99,10 @@ use jsonschema::JSONSchema; use std::collections::HashSet; #[cfg(feature = "validation")] -use super::{ - MAX_INDEXED_BYTE_ARRAY_PROPERTY_LENGTH, MAX_INDEXED_STRING_PROPERTY_LENGTH, - NOT_ALLOWED_SYSTEM_PROPERTIES, +use super::NOT_ALLOWED_SYSTEM_PROPERTIES; +use super::{MAX_INDEXED_BYTE_ARRAY_PROPERTY_LENGTH, MAX_INDEXED_STRING_PROPERTY_LENGTH}; +use crate::consensus::basic::data_contract::{ + InvalidIndexPropertyTypeError, InvalidIndexedPropertyConstraintError, }; /// RANKED: the extra index-property check a generation runs before the generic @@ -225,6 +225,10 @@ pub(super) struct ParserGeneration { /// `rangeCountable` has always implied `documentsCountable`. Forwarded to /// [`Index::try_from_value_map`] exactly like the admissions above. pub admit_range_countable_implies_countable: bool, + /// Whether a contested index may declare `"resolution": 1`, the masternode + /// vote without a Lock choice. Forwarded to [`Index::try_from_value_map`] + /// exactly like the admissions above. + pub admit_index_no_locking_resolution: bool, } /// Reject a document type whose name is not a non-empty ASCII @@ -869,6 +873,7 @@ fn parse_indices( range_countable_implies_countable: ctx .generation .admit_range_countable_implies_countable, + no_locking_resolution: ctx.generation.admit_index_no_locking_resolution, }, ) .map_err(consensus_or_protocol_data_contract_error)?; @@ -1161,7 +1166,7 @@ fn parse_indices( use crate::document::property_names::OWNER_ID; for index in indices.values_mut() { if index.terminal.is_none() { - index.terminal = Some(OWNER_ID.to_string()); + index.terminal = Some(vec![OWNER_ID.to_string()]); } } } @@ -1288,68 +1293,90 @@ fn validate_index_properties( ctx.platform_version, )?; - // Validate indexed property type - match &property_definition.property_type { - // Array and objects aren't supported for indexing yet - DocumentPropertyType::Array(_) - | DocumentPropertyType::Object(_) - | DocumentPropertyType::VariableTypeArray(_) => { - Err(ProtocolError::ConsensusError(Box::new( - InvalidIndexPropertyTypeError::new( - ctx.name.to_owned(), - index.name.to_owned(), - index_property.name.to_owned(), - property_definition.property_type.name(), - ) - .into(), - ))) - } - // Indexed byte array size must be limited - DocumentPropertyType::ByteArray(sizes) - if sizes.max_size.is_none() - || sizes.max_size.unwrap() > MAX_INDEXED_BYTE_ARRAY_PROPERTY_LENGTH => - { - Err(ProtocolError::ConsensusError(Box::new( - InvalidIndexedPropertyConstraintError::new( - ctx.name.to_owned(), - index.name.to_owned(), - index_property.name.to_owned(), - "maxItems".to_string(), - format!( - "should be less or equal {}", - MAX_INDEXED_BYTE_ARRAY_PROPERTY_LENGTH - ), - ) - .into(), - ))) - } - // Indexed string length must be limited - DocumentPropertyType::String(sizes) - if sizes.max_length.is_none() - || sizes.max_length.unwrap() > MAX_INDEXED_STRING_PROPERTY_LENGTH => - { - Err(ProtocolError::ConsensusError(Box::new( - InvalidIndexedPropertyConstraintError::new( - ctx.name.to_owned(), - index.name.to_owned(), - index_property.name.to_owned(), - "maxLength".to_string(), - format!( - "should be less or equal {}", - MAX_INDEXED_STRING_PROPERTY_LENGTH - ), - ) - .into(), - ))) - } - _ => Ok(()), - } + // The shape limits every indexed value carries as a grovedb key, + // shared with an indexOnly index's terminal. + check_indexable_property_shape( + ctx.name, + &index.name, + &index_property.name, + &property_definition.property_type, + ) } else { Ok(()) } }) } +/// The shape checks a property must pass to be indexed, shared by the +/// prefix positions of an index and an indexOnly index's terminal: the +/// encoded value becomes a grovedb key, so arrays and objects are refused +/// and byte arrays and strings must be bounded (grovedb caps keys at 255 +/// bytes; the string bound is in characters, each at most four bytes). +fn check_indexable_property_shape( + document_type_name: &str, + index_name: &str, + property_name: &str, + property_type: &DocumentPropertyType, +) -> Result<(), ProtocolError> { + match property_type { + // Array and objects aren't supported for indexing yet + DocumentPropertyType::Array(_) + | DocumentPropertyType::Object(_) + | DocumentPropertyType::VariableTypeArray(_) => { + Err(ProtocolError::ConsensusError(Box::new( + InvalidIndexPropertyTypeError::new( + document_type_name.to_owned(), + index_name.to_owned(), + property_name.to_owned(), + property_type.name(), + ) + .into(), + ))) + } + // Indexed byte array size must be limited + DocumentPropertyType::ByteArray(sizes) + if sizes + .max_size + .is_none_or(|max_size| max_size > MAX_INDEXED_BYTE_ARRAY_PROPERTY_LENGTH) => + { + Err(ProtocolError::ConsensusError(Box::new( + InvalidIndexedPropertyConstraintError::new( + document_type_name.to_owned(), + index_name.to_owned(), + property_name.to_owned(), + "maxItems".to_string(), + format!( + "should be less or equal {}", + MAX_INDEXED_BYTE_ARRAY_PROPERTY_LENGTH + ), + ) + .into(), + ))) + } + // Indexed string length must be limited + DocumentPropertyType::String(sizes) + if sizes + .max_length + .is_none_or(|max_length| max_length > MAX_INDEXED_STRING_PROPERTY_LENGTH) => + { + Err(ProtocolError::ConsensusError(Box::new( + InvalidIndexedPropertyConstraintError::new( + document_type_name.to_owned(), + index_name.to_owned(), + property_name.to_owned(), + "maxLength".to_string(), + format!( + "should be less or equal {}", + MAX_INDEXED_STRING_PROPERTY_LENGTH + ), + ) + .into(), + ))) + } + _ => Ok(()), + } +} + /// The identifier and binary paths implied by the parsed properties, plus /// the security level and the encryption/decryption key requirements the /// schema asks for. @@ -2061,6 +2088,21 @@ pub(super) fn apply_can_be_deleted_by_moderators( name, ))); } + // A moderator's restore puts the document back through the ordinary insert, unique + // indexes checked; a contested index awards its value by a vote, which no restore can go + // through, so such a type would have deletions that can not be undone. + if document_type + .indices + .values() + .any(|index| index.contested_index.is_some()) + { + return Err(structure_error(format!( + "document type \"{}\" has a contested index and must not set \ + `canBeDeletedByModerators`: a document a moderator deleted is restored by \ + an ordinary insert, and a contested index only takes a document through a vote", + name, + ))); + } document_type.documents_can_be_deleted_by_moderators = true; Ok(()) @@ -2073,8 +2115,16 @@ pub(super) fn apply_can_be_deleted_by_moderators( pub(super) fn parse_can_be_deleted_by_moderators_for_keyword( schema: &Value, ) -> Result, ProtocolError> { - schema - .get_optional_integer::(CAN_BE_DELETED_BY_MODERATORS_FOR) + // A schema that is not an object carries no keyword. Like every other + // doctype-level keyword read before the core parser, this one must not be + // the first to fail on such a schema: the core parser refuses it as an + // invalid contract structure, and a raw value error here would replace + // that refusal. + let Ok(schema_map) = schema.to_map() else { + return Ok(None); + }; + + Value::inner_optional_integer_value::(schema_map, CAN_BE_DELETED_BY_MODERATORS_FOR) .map_err(consensus_or_protocol_value_error) } @@ -2310,6 +2360,7 @@ pub(super) fn apply_index_only( document_type: &mut DocumentTypeV2, index_only: bool, name: &str, + platform_version: &PlatformVersion, ) -> Result<(), ProtocolError> { use crate::document::property_names::{CREATED_AT, OWNER_ID}; @@ -2333,6 +2384,29 @@ pub(super) fn apply_index_only( index_name, name, ))); } + // An index without properties is only meaningful as a flat indexOnly + // index (keyed by its terminal alone); on a stored type it would + // reach no level at all and index nothing. + if let Some((index_name, _)) = document_type + .indices + .iter() + .find(|(_, index)| index.properties.is_empty()) + { + return Err(structure_error(format!( + "index \"{}\" on document type \"{}\" has no properties: an index keyed by \ + a terminal alone (a flat index) is only allowed on an indexOnly document \ + type", + index_name, name, + ))); + } + if !document_type.entry_payload.is_empty() { + return Err(structure_error(format!( + "document type \"{}\" declares `entryPayload`, which is only allowed on \ + indexOnly document types (a stored document type keeps every property in \ + its primary row)", + name, + ))); + } // Same for `preallocated`: only an indexOnly index's trees are cheap // permanent structure whose member entries carry the data — on a // normal document type the trees hold references to stored rows and @@ -2444,15 +2518,109 @@ pub(super) fn apply_index_only( // structure's level info and the `Index` values below agree, and every // check here reads `Some`. + // ---- entry payload -------------------------------------------------- + // `entryPayload` names the type's value slot: top-level scalar + // properties stored in every entry's value, after the row commitment, + // instead of in a key. They are still committed (the commitment hashes + // every present property) and still required, but they sit in no + // index, so the every-property-indexed rule below exempts them. Each + // must be bounded, since fee estimation sizes the entry value by the + // sum of their bounds, and the sum is capped by the field value limit. + let mut payload_max_total: u32 = 0; + for payload_property in document_type.entry_payload.iter() { + let Some(property) = document_type.properties.get(payload_property) else { + return Err(structure_error(format!( + "entryPayload of indexOnly document type \"{}\" names \"{}\", which is not \ + a top-level property of the document type", + name, payload_property, + ))); + }; + if matches!( + property.property_type, + DocumentPropertyType::Object(_) + | DocumentPropertyType::Array(_) + | DocumentPropertyType::VariableTypeArray(_) + ) { + return Err(structure_error(format!( + "entryPayload property \"{}\" of indexOnly document type \"{}\" must be a \ + scalar (a byte array, string, integer, boolean, date or identifier): the \ + entry value is a flat concatenation of length-framed scalars", + payload_property, name, + ))); + } + let max_width = property + .property_type + .max_byte_size(platform_version)? + .unwrap_or(u16::MAX); + if max_width == u16::MAX { + return Err(structure_error(format!( + "entryPayload property \"{}\" of indexOnly document type \"{}\" must be \ + bounded (declare maxItems on a byte array or maxLength on a string): fee \ + estimation sizes every entry's value by the payload bounds", + payload_property, name, + ))); + } + // Two bytes of length frame per property. + payload_max_total += u32::from(max_width) + 2; + if !document_type.required_fields.contains(payload_property) { + return Err(structure_error(format!( + "entryPayload property \"{}\" of indexOnly document type \"{}\" must be \ + listed in `required`: the entry value has no representation for an absent \ + property", + payload_property, name, + ))); + } + if let Some((index_name, _)) = document_type.indices.iter().find(|(_, index)| { + index.terminal_contains(payload_property) + || index + .properties + .iter() + .any(|index_property| index_property.name == *payload_property) + }) { + return Err(structure_error(format!( + "entryPayload property \"{}\" of indexOnly document type \"{}\" also \ + appears in index \"{}\": a property is either a key (a prefix property or \ + a terminal component) or entry payload, never both", + payload_property, name, index_name, + ))); + } + } + if payload_max_total > platform_version.system_limits.max_field_value_size { + return Err(structure_error(format!( + "entryPayload of indexOnly document type \"{}\" may encode to {} bytes, over \ + the {}-byte cap on an entry's value", + name, payload_max_total, platform_version.system_limits.max_field_value_size, + ))); + } + // ---- per-index rules ------------------------------------------------ for (index_name, index) in document_type.indices.iter() { if index.properties.is_empty() { - return Err(structure_error(format!( - "index \"{}\" on indexOnly document type \"{}\" has no properties: an \ - indexOnly entry is `[…property values, 0, terminal value]`, so at least \ - one prefix property is required above the terminal", - index_name, name, - ))); + // FLAT index: no prefix levels, the entries live directly under + // a level keyed by the terminal's component names. There is no + // prefix level for an aggregate, a ranking, a time grid, a skip + // trigger or a preallocation to apply to, so none of those + // keywords is admitted on it. + if index.countable.is_countable() + || index.range_countable + || index.summable.is_some() + || index.range_summable + || index.ranked_countable + || !index.ranked_countable_at.is_empty() + || index.ranked_summable + || index.ranked_averageable + || index.time_range.is_some() + || index.skip_if_absent + || index.preallocated + { + return Err(structure_error(format!( + "index \"{}\" on indexOnly document type \"{}\" has no properties (a \ + flat index keyed by its terminal alone), so it admits no countable, \ + summable, ranked, timeRange, skipIfAbsent or preallocated keyword: \ + there is no prefix level for them to apply to", + index_name, name, + ))); + } } if index.unique { return Err(structure_error(format!( @@ -2550,67 +2718,143 @@ pub(super) fn apply_index_only( // (canonical property, i64-safe integer type, `required` // membership) run for every doctype, indexOnly included. - let terminal = index.terminal.as_deref().expect("normalized to Some above"); - - if index - .properties - .iter() - .any(|property| property.name == terminal) - { + let components = index.terminal_components(); + if components.is_empty() { return Err(structure_error(format!( - "index \"{}\" on indexOnly document type \"{}\" repeats its terminal \ - (\"{}\") in its properties: the terminal is the member key below the \ - listed properties, so listing it again would index the same dimension \ - twice", - index_name, name, terminal, + "index \"{}\" on indexOnly document type \"{}\" has no terminal after \ + normalization: internal parser error", + index_name, name, ))); } - // The terminal is the member key — it must be a referable entity id: - // the owner identity, or a property carrying a refersTo declaration - // whose value alone IS the referenced entity's id (identity, - // contract, token, or a document of either reference kind; a - // deletable document's entries simply outlive it, as the member key - // is an Item, not a Reference). `identityPublicKey` is deliberately - // NOT admitted: it is a - // compound reference — this property carries the identity id while a - // separate `keyIdProperty` carries the key id — so a terminal keyed - // by it would conflate references to different keys of the same - // identity. - if terminal != OWNER_ID { - use crate::data_contract::document_type::property::DocumentPropertyReferenceTarget; - match document_type.flattened_properties.get(terminal) { - Some(property) - if matches!( - property.property_type, - DocumentPropertyType::IdentifierWithReference( - DocumentPropertyReferenceTarget::Identity - | DocumentPropertyReferenceTarget::Contract - | DocumentPropertyReferenceTarget::Token - | DocumentPropertyReferenceTarget::PermanentDocument { .. } - | DocumentPropertyReferenceTarget::DeletableDocument { .. } - ) - ) => {} - Some(_) => { + for component in components { + if index + .properties + .iter() + .any(|property| property.name == *component) + { + return Err(structure_error(format!( + "index \"{}\" on indexOnly document type \"{}\" repeats its terminal \ + component (\"{}\") in its properties: the terminal is the member key \ + below the listed properties, so listing it again would index the same \ + dimension twice", + index_name, name, component, + ))); + } + } + + // The terminal is the member key: the encoded values of its + // components, concatenated in order. Any property a prefix position + // admits may serve as a component: every path derives the member + // key through the same tree-key encoding the prefix levels use (the + // walkers and probes via `get_raw_for_document_type`, queries and + // executed proofs via `serialize_value_for_key`, synthesis via + // `decode_value_for_tree_keys`), so a component needs no particular + // width or meaning — only the shape limits every indexed value + // carries. Structural uniqueness spans the whole key: one entry per + // (prefix values, terminal values). + // + // Every component but the last must be fixed width: a leading + // component is followed by more key bytes, and only a fixed-width + // encoding keeps equality on the leading components a clean key + // range (and lets synthesis split the key back). Strings are never + // fixed width (their bound counts characters, not bytes), so a + // string can only be the last component. + // + // System properties other than `$ownerId` are refused: `$createdAt` + // is the one other system value an indexOnly entry can carry, and + // the rules that reason about it (the proof-index selection, + // `required` membership, bucketing) all walk the prefix properties, + // so admitting it as a component would need each of them extended + // first. + let mut terminal_max_width: u32 = 0; + for (position, component) in components.iter().enumerate() { + let is_last = position + 1 == components.len(); + let max_width: u32 = if component == OWNER_ID { + 32 + } else { + if component.starts_with('$') { + return Err(structure_error(format!( + "terminal component \"{}\" of index \"{}\" on indexOnly document \ + type \"{}\" is a system property: only $ownerId may be a terminal \ + component (name a schema property, or list $createdAt among the \ + index's properties instead)", + component, index_name, name, + ))); + } + // A flat level is keyed by its component names, each behind + // a zero byte (`flat_level_key_for`); a name carrying one + // would alias another flat level or a property-name tree. + // The meta-schema's name pattern already excludes it for + // contracts entering the chain; this keeps the invariant + // explicit for every parse. + if component.contains('\0') { return Err(structure_error(format!( - "terminal \"{}\" of index \"{}\" on indexOnly document type \"{}\" \ - must be \"$ownerId\" or an identifier property with a refersTo \ - declaration targeting identity, contract, token, \ - permanentDocument, or deletableDocument: the terminal is the \ - entry's member key and must \ - alone be a referable entity id (an identityPublicKey reference is \ - compound — its key id lives in a separate property — and is not \ - admitted)", - terminal, index_name, name, + "terminal component \"{}\" of index \"{}\" on indexOnly document \ + type \"{}\" contains a zero byte, which the flat level key \ + reserves as its separator", + component.escape_default(), + index_name, + name, ))); } - None => { + let Some(property) = document_type.flattened_properties.get(component) else { + return Err(structure_error(format!( + "terminal component \"{}\" of index \"{}\" on indexOnly document \ + type \"{}\" does not name a property of the document type", + component, index_name, name, + ))); + }; + check_indexable_property_shape( + name, + index_name, + component, + &property.property_type, + )?; + let max_width = property + .property_type + .max_byte_size(platform_version)? + .unwrap_or(u16::MAX); + // Fixed width by the tree-key encoding itself: the same + // helper synthesis splits member keys with. + let fixed_width = property.property_type.fixed_tree_key_width().is_some(); + if !is_last && !fixed_width { return Err(structure_error(format!( - "terminal \"{}\" of index \"{}\" on indexOnly document type \"{}\" \ - does not name a property of the document type", - terminal, index_name, name, + "terminal component \"{}\" of index \"{}\" on indexOnly document \ + type \"{}\" is followed by another component but is not fixed \ + width: every component but the last must encode to a fixed number \ + of bytes (a byte array with minItems equal to maxItems, an \ + identifier, an integer, a boolean or a date); a string or a \ + variable-size byte array can only be the last component", + component, index_name, name, ))); } + u32::from(max_width) + }; + terminal_max_width += max_width; + } + if terminal_max_width > u32::from(MAX_INDEXED_BYTE_ARRAY_PROPERTY_LENGTH) { + return Err(structure_error(format!( + "the terminal of index \"{}\" on indexOnly document type \"{}\" encodes to \ + up to {} bytes, over the {}-byte member key cap: shorten or drop a \ + component", + index_name, name, terminal_max_width, MAX_INDEXED_BYTE_ARRAY_PROPERTY_LENGTH, + ))); + } + + // The flat level is itself a GroveDB key. Bounding the encoded + // values above does not bound the concatenated component names. + if let Some(flat_key) = index.flat_level_key() { + if flat_key.len() > usize::from(MAX_INDEXED_BYTE_ARRAY_PROPERTY_LENGTH) { + return Err(structure_error(format!( + "the flat level of index \"{}\" on indexOnly document type \"{}\" \ + encodes to {} bytes, over the {}-byte flat level key cap: shorten \ + or drop a terminal component name", + index_name, + name, + flat_key.len(), + MAX_INDEXED_BYTE_ARRAY_PROPERTY_LENGTH, + ))); } } @@ -2643,7 +2887,7 @@ pub(super) fn apply_index_only( // row — and remove an entry it never created; binding every entry // to its owner closes that, at the cost of the (unneeded) global- // uniqueness-without-owner shape. - if terminal != OWNER_ID + if !index.terminal_contains(OWNER_ID) && !index .properties .iter() @@ -2662,7 +2906,7 @@ pub(super) fn apply_index_only( // `created_at` only when `$createdAt` is in `required`. Without // this, an indexed `$createdAt` would silently take the missing- // value branch instead of storing block time. - if (terminal == CREATED_AT + if (index.terminal_contains(CREATED_AT) || index .properties .iter() @@ -2738,7 +2982,7 @@ pub(super) fn apply_index_only( // index qualifies as the proof index.) let has_proof_index = document_type.indices.values().any(|index| { !index.skip_if_absent - && index.terminal.as_deref() != Some(CREATED_AT) + && !index.terminal_contains(CREATED_AT) && !index .properties .iter() @@ -2779,10 +3023,15 @@ pub(super) fn apply_index_only( continue; } let is_trigger = skip_triggers.contains(property_name.as_str()); + if document_type.entry_payload.contains(property_name.as_str()) { + // Stored in every entry's value: validated above (required, + // bounded, in no index). + continue; + } let covered = document_type.indices.values().any(|index| { // A skip index only counts as coverage for its own trigger. (is_trigger || !index.skip_if_absent) - && (index.terminal.as_deref() == Some(property_name.as_str()) + && (index.terminal_contains(property_name) || index .properties .iter() @@ -2816,7 +3065,7 @@ pub(super) fn apply_index_only( // when no non-skip index (and no deeper level of any index) // reaches through that branch. for (index_name, index) in document_type.indices.iter() { - if index.terminal.as_deref() == Some(property_name.as_str()) { + if index.terminal_contains(property_name) { return Err(structure_error(format!( "optional property \"{}\" on indexOnly document type \"{}\" is the \ terminal of index \"{}\": a terminal is every entry's member key \ 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 d806fbe3552..da27d502beb 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 @@ -4,8 +4,9 @@ use crate::data_contract::document_type::v0::DocumentTypeV0; use crate::data_contract::document_type::v1::DocumentTypeV1; use crate::data_contract::document_type::{ is_referenced_system_agreement_property, is_referring_system_agreement_property, - property_names, DocumentProperty, DocumentPropertyReferenceTarget, DocumentPropertyType, - DocumentType, + property_names, ContractReferenceModeration, ContractReferenceOwner, + ContractReferenceRequirements, DocumentProperty, DocumentPropertyReferenceTarget, + DocumentPropertyType, DocumentType, }; use crate::data_contract::errors::DataContractError; use crate::data_contract::{TokenConfiguration, TokenContractPosition}; @@ -367,12 +368,25 @@ fn apply_property_reference_v0( let refers_to_map = refers_to_value.to_btree_ref_string_map()?; - let target = match refers_to_map + let reference_type = refers_to_map .get_str(property_names::TYPE) - .map_err(|e| DataContractError::ValueWrongType(e.to_string()))? + .map_err(|e| DataContractError::ValueWrongType(e.to_string()))?; + + // Requirements on the referenced contract belong to contract references alone + if reference_type != "contract" + && refers_to_map.contains_key(property_names::CONTRACT_REQUIREMENTS) { + return Err(DataContractError::InvalidContractStructure(format!( + "{} refersTo does not take contractRequirements", + reference_type + ))); + } + + let target = match reference_type { "identity" => DocumentPropertyReferenceTarget::Identity, - "contract" => DocumentPropertyReferenceTarget::Contract, + "contract" => DocumentPropertyReferenceTarget::Contract { + contract_requirements: parse_contract_reference_requirements(&refers_to_map)?, + }, "token" => DocumentPropertyReferenceTarget::Token, // The two document targets share one declaration shape; they differ // only in whether the referenced document type must forbid deletion, @@ -512,6 +526,115 @@ fn apply_property_reference_v0( Ok(DocumentPropertyType::IdentifierWithReference(target)) } +/// The `contractRequirements` of a `contract` reference: each key an aspect of the referenced +/// contract with a closed set of values (`moderation`, `owner`, the config flags), or a bound +/// on it (`minimumAgeSeconds`), at least one when the object is given at all. +fn parse_contract_reference_requirements( + refers_to_map: &BTreeMap, +) -> Result { + let Some(fields_value) = refers_to_map.get(property_names::CONTRACT_REQUIREMENTS) else { + return Ok(ContractReferenceRequirements::default()); + }; + let fields_map = fields_value.to_btree_ref_string_map()?; + if fields_map.is_empty() { + return Err(DataContractError::InvalidContractStructure( + "contract refersTo contractRequirements must declare at least one requirement" + .to_string(), + )); + } + let mut fields = ContractReferenceRequirements::default(); + for (field, value) in fields_map { + match field.as_str() { + property_names::MODERATION => { + let name = value.as_text().ok_or_else(|| { + DataContractError::InvalidContractStructure( + "contract refersTo contractRequirements moderation must be a string" + .to_string(), + ) + })?; + fields.moderation = Some(ContractReferenceModeration::from_wire_name(name).ok_or_else(|| { + DataContractError::InvalidContractStructure(format!( + "contract refersTo contractRequirements moderation {name:?} is unknown, expected one of {:?}", + ContractReferenceModeration::WIRE_NAMES + )) + })?); + } + property_names::MINIMUM_AGE_SECONDS => { + fields.minimum_age_seconds = Some(parse_contract_reference_seconds(&field, value)?); + } + property_names::MINIMUM_SECONDS_SINCE_UPDATE => { + fields.minimum_seconds_since_update = + Some(parse_contract_reference_seconds(&field, value)?); + } + property_names::OWNER => { + let name = value.as_text().ok_or_else(|| { + DataContractError::InvalidContractStructure( + "contract refersTo contractRequirements owner must be a string".to_string(), + ) + })?; + fields.owner = Some(ContractReferenceOwner::from_wire_name(name).ok_or_else(|| { + DataContractError::InvalidContractStructure(format!( + "contract refersTo contractRequirements owner {name:?} is unknown, expected \"self\" or \"other\"" + )) + })?); + } + property_names::READONLY => { + fields.readonly = Some(parse_contract_reference_true(&field, value)?); + } + property_names::KEEPS_HISTORY => { + fields.keeps_history = Some(parse_contract_reference_true(&field, value)?); + } + property_names::OWNER_PROTECTED => { + fields.owner_protected = Some(parse_contract_reference_bool(&field, value)?); + } + other => { + return Err(DataContractError::InvalidContractStructure(format!( + "contract refersTo contractRequirements {other:?} is unknown" + ))); + } + } + } + Ok(fields) +} + +/// A duration requirement of a `contract` reference (`minimumAgeSeconds`, +/// `minimumSecondsSinceUpdate`): a whole number of seconds from 1 to `u32::MAX`. +fn parse_contract_reference_seconds(field: &str, value: &Value) -> Result { + let seconds: u32 = value.to_integer().map_err(|_| { + DataContractError::InvalidContractStructure(format!( + "contract refersTo contractRequirements {field} must be an integer from 1 to 4294967295" + )) + })?; + if seconds == 0 { + return Err(DataContractError::InvalidContractStructure(format!( + "contract refersTo contractRequirements {field} must be at least 1" + ))); + } + Ok(seconds) +} + +/// A boolean requirement of a `contract` reference (`ownerProtected`): `true` or `false`. +fn parse_contract_reference_bool(field: &str, value: &Value) -> Result { + value.as_bool().ok_or_else(|| { + DataContractError::InvalidContractStructure(format!( + "contract refersTo contractRequirements {field} must be a boolean" + )) + }) +} + +/// A flag requirement of a `contract` reference (`readonly`, `keepsHistory`): only `true` +/// requires anything, so `false` is refused rather than declared as a requirement that +/// requires nothing. +fn parse_contract_reference_true(field: &str, value: &Value) -> Result { + if parse_contract_reference_bool(field, value)? { + Ok(true) + } else { + Err(DataContractError::InvalidContractStructure(format!( + "contract refersTo contractRequirements {field} must be true" + ))) + } +} + #[cfg(test)] mod tests { use super::*; @@ -986,6 +1109,360 @@ mod tests { ); } + fn contract_reference_schema(refers_to: serde_json::Value) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "targetContractId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": refers_to + } + }, + "required": [], + "additionalProperties": false + }) + } + + fn contract_reference_target(refers_to: serde_json::Value) -> DocumentPropertyType { + try_document_type_from_schema(contract_reference_schema(refers_to)) + .expect("should parse") + .as_ref() + .flattened_properties() + .get("targetContractId") + .map(|p| p.property_type.clone()) + .expect("property should be present") + } + + #[test] + fn should_parse_contract_refers_to_without_contract_requirements_as_no_requirement() { + assert_eq!( + contract_reference_target(json!({ "type": "contract" })), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements::default(), + } + ) + ); + } + + #[test] + fn should_parse_contract_refers_to_requiring_elected_moderation() { + assert_eq!( + contract_reference_target(json!({ + "type": "contract", + "contractRequirements": { "moderation": "elected" } + })), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + moderation: Some(ContractReferenceModeration::Elected), + minimum_age_seconds: None, + minimum_seconds_since_update: None, + owner: None, + readonly: None, + keeps_history: None, + owner_protected: None, + }, + } + ) + ); + } + + #[test] + fn should_parse_contract_refers_to_requiring_the_moderation_election_open() { + assert_eq!( + contract_reference_target(json!({ + "type": "contract", + "contractRequirements": { "moderation": "electionOpen" } + })), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + moderation: Some(ContractReferenceModeration::ElectionOpen), + minimum_age_seconds: None, + minimum_seconds_since_update: None, + owner: None, + readonly: None, + keeps_history: None, + owner_protected: None, + }, + } + ) + ); + } + + #[test] + fn should_parse_contract_refers_to_requiring_a_minimum_age_or_time_since_update() { + assert_eq!( + contract_reference_target(json!({ + "type": "contract", + "contractRequirements": { "minimumAgeSeconds": 604800 } + })), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + moderation: None, + minimum_age_seconds: Some(604_800), + minimum_seconds_since_update: None, + owner: None, + readonly: None, + keeps_history: None, + owner_protected: None, + }, + } + ) + ); + assert_eq!( + contract_reference_target(json!({ + "type": "contract", + "contractRequirements": { "minimumSecondsSinceUpdate": 86400 } + })), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + moderation: None, + minimum_age_seconds: None, + minimum_seconds_since_update: Some(86_400), + owner: None, + readonly: None, + keeps_history: None, + owner_protected: None, + }, + } + ) + ); + assert_eq!( + contract_reference_target(json!({ + "type": "contract", + "contractRequirements": { + "moderation": "elected", + "minimumAgeSeconds": u32::MAX, + "minimumSecondsSinceUpdate": 1 + } + })), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + moderation: Some(ContractReferenceModeration::Elected), + minimum_age_seconds: Some(u32::MAX), + minimum_seconds_since_update: Some(1), + owner: None, + readonly: None, + keeps_history: None, + owner_protected: None, + }, + } + ) + ); + } + + #[test] + fn should_parse_contract_refers_to_requiring_an_owner_relation() { + for (name, owner) in [ + ("self", ContractReferenceOwner::Writer), + ("other", ContractReferenceOwner::Other), + ] { + assert_eq!( + contract_reference_target(json!({ + "type": "contract", + "contractRequirements": { "owner": name } + })), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + owner: Some(owner), + ..Default::default() + }, + } + ) + ); + } + assert_eq!( + contract_reference_target(json!({ + "type": "contract", + "contractRequirements": { "moderation": "elected", "owner": "other" } + })), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + moderation: Some(ContractReferenceModeration::Elected), + owner: Some(ContractReferenceOwner::Other), + ..Default::default() + }, + } + ) + ); + } + + #[test] + fn should_parse_contract_refers_to_requiring_config_flags() { + for (requirements, expected) in [ + ( + json!({ "readonly": true }), + ContractReferenceRequirements { + readonly: Some(true), + ..Default::default() + }, + ), + ( + json!({ "keepsHistory": true }), + ContractReferenceRequirements { + keeps_history: Some(true), + ..Default::default() + }, + ), + ( + json!({ "ownerProtected": true }), + ContractReferenceRequirements { + owner_protected: Some(true), + ..Default::default() + }, + ), + ( + json!({ "ownerProtected": false }), + ContractReferenceRequirements { + owner_protected: Some(false), + ..Default::default() + }, + ), + ( + json!({ "moderation": "elected", "readonly": true, "keepsHistory": true, "ownerProtected": false }), + ContractReferenceRequirements { + moderation: Some(ContractReferenceModeration::Elected), + readonly: Some(true), + keeps_history: Some(true), + owner_protected: Some(false), + ..Default::default() + }, + ), + ] { + assert_eq!( + contract_reference_target(json!({ + "type": "contract", + "contractRequirements": requirements + })), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: expected, + } + ), + "{requirements}" + ); + } + } + + #[test] + fn should_reject_a_config_flag_requirement_that_is_not_a_boolean_or_requires_nothing() { + for (field, value, fragment) in [ + ("readonly", json!(false), "readonly must be true"), + ("readonly", json!("true"), "readonly must be a boolean"), + ("readonly", json!(1), "readonly must be a boolean"), + ("keepsHistory", json!(false), "keepsHistory must be true"), + ( + "keepsHistory", + json!("true"), + "keepsHistory must be a boolean", + ), + ( + "ownerProtected", + json!("true"), + "ownerProtected must be a boolean", + ), + ( + "ownerProtected", + json!(0), + "ownerProtected must be a boolean", + ), + ( + "ownerProtected", + json!(null), + "ownerProtected must be a boolean", + ), + ] { + let refers_to = json!({ + "type": "contract", + "contractRequirements": { field: value } + }); + let err = try_document_type_from_schema(contract_reference_schema(refers_to.clone())) + .expect_err("should be refused"); + assert!( + err.to_string().contains(fragment), + "{refers_to}: expected {fragment:?}, got {err}" + ); + } + } + + #[test] + fn should_reject_a_duration_requirement_that_is_zero_negative_too_large_or_not_an_integer() { + for field in ["minimumAgeSeconds", "minimumSecondsSinceUpdate"] { + for (seconds, fragment) in [ + (json!(0), "must be at least 1"), + (json!(-1), "must be an integer"), + (json!(u64::from(u32::MAX) + 1), "must be an integer"), + (json!(1.5), "must be an integer"), + (json!("3600"), "must be an integer"), + ] { + let refers_to = json!({ + "type": "contract", + "contractRequirements": { field: seconds } + }); + let err = + try_document_type_from_schema(contract_reference_schema(refers_to.clone())) + .expect_err("should be refused"); + assert!( + err.to_string().contains(fragment) && err.to_string().contains(field), + "{refers_to}: expected {fragment:?} naming {field}, got {err}" + ); + } + } + } + + #[test] + fn should_reject_contract_requirements_that_are_empty_unknown_or_on_another_type() { + for (refers_to, fragment) in [ + ( + json!({ "type": "contract", "contractRequirements": {} }), + "at least one requirement", + ), + ( + json!({ "type": "contract", "contractRequirements": { "moderation": "appointed" } }), + "is unknown", + ), + ( + json!({ "type": "contract", "contractRequirements": { "moderation": 1 } }), + "must be a string", + ), + ( + json!({ "type": "contract", "contractRequirements": { "tokens": "any" } }), + "is unknown", + ), + ( + json!({ "type": "contract", "contractRequirements": { "owner": "anyone" } }), + "owner \"anyone\" is unknown, expected \"self\" or \"other\"", + ), + ( + json!({ "type": "contract", "contractRequirements": { "owner": true } }), + "owner must be a string", + ), + ( + json!({ "type": "identity", "contractRequirements": { "moderation": "elected" } }), + "does not take contractRequirements", + ), + ] { + let err = try_document_type_from_schema(contract_reference_schema(refers_to.clone())) + .expect_err("should be refused"); + assert!( + err.to_string().contains(fragment), + "{refers_to}: expected {fragment:?}, got {err}" + ); + } + } + #[test] fn should_reject_permanent_document_refers_to_with_invalid_contract_id() { let err = try_document_type_from_schema(json!({ diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v1/mod.rs index 162bc735540..eba4f863edb 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v1/mod.rs @@ -117,6 +117,8 @@ impl DocumentTypeV1 { // the parser demands an explicit countable `countable`, as the frozen // v1 and v2 meta-schemas do. admit_range_countable_implies_countable: false, + // NO LOCKING RESOLUTION: a generation-3 value; not in this grammar. + admit_index_no_locking_resolution: false, }, platform_version, ) diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/index_only_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/index_only_tests.rs index e5cfcd0473d..f7a7e302652 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/index_only_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/index_only_tests.rs @@ -20,6 +20,8 @@ //! validation to pin the meta-schema admission. use super::*; +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; use crate::data_contract::document_type::accessors::DocumentTypeV2Getters; use crate::data_contract::errors::DataContractError; use platform_value::platform_value; @@ -169,6 +171,41 @@ fn expect_structure_error(result: Result, needle: } } +/// The terminal shares the prefix positions' shape checks, which report +/// through the typed index consensus errors rather than a structure error. +fn expect_basic_error( + result: Result, + what: &str, + check: impl Fn(&BasicError) -> bool, +) { + match result { + Err(ProtocolError::ConsensusError(err)) => match *err { + ConsensusError::BasicError(ref basic) if check(basic) => {} + other => panic!("expected {what}, got {other}"), + }, + Err(other) => panic!("expected {what}, got {other}"), + Ok(_) => panic!("expected {what}, but the schema parsed"), + } +} + +/// Add a schema property to the likes schema and list it in `required`. +fn with_required_property(schema: &mut Value, name: &str, definition: Value) { + schema + .get_mut("properties") + .expect("properties accessible") + .expect("properties present") + .set_value(name, definition) + .expect("property applies"); + let mut required = schema + .get_optional_array("required") + .expect("required readable") + .unwrap_or_default(); + required.push(Value::Text(name.to_string())); + schema + .set_value("required", Value::Array(required)) + .expect("required applies"); +} + // ── the happy path ────────────────────────────────────────────────────── #[test] @@ -195,15 +232,15 @@ fn terminal_defaults_to_owner_id() { // `byPost` omits its terminal; normalization spells it out, so the // omitted and explicit forms parse to equal indexes. assert_eq!( - document_type.indices["byPost"].terminal.as_deref(), + document_type.indices["byPost"].single_terminal(), Some("$ownerId") ); assert_eq!( - document_type.indices["byHashtagPost"].terminal.as_deref(), + document_type.indices["byHashtagPost"].single_terminal(), Some("$ownerId") ); assert_eq!( - document_type.indices["byLiker"].terminal.as_deref(), + document_type.indices["byLiker"].single_terminal(), Some("postId") ); } @@ -586,13 +623,155 @@ fn rejects_terminal_repeating_an_index_property() { } #[test] -fn rejects_terminal_without_refers_to() { - // `hashtag` is a plain string — not `$ownerId`, not a refersTo-typed - // identifier — so it cannot be a member key. +fn accepts_a_plain_scalar_terminal() { + // `hashtag` is a bounded string with no refersTo: any property a prefix + // position admits may be the member key, so byLiker becomes + // `[$ownerId] → hashtag` — one entry per (owner, hashtag). let schema = likes_schema_with_index_key(2, "terminal", platform_value!("hashtag")); + for full_validation in [false, true] { + let document_type = parse_with(schema.clone(), PlatformVersion::latest(), full_validation) + .expect("a string terminal parses"); + assert_eq!( + document_type.indices["byLiker"].single_terminal(), + Some("hashtag") + ); + } +} + +#[test] +fn accepts_byte_array_and_integer_terminals() { + // A 33-byte array (a compressed public key) and a bounded integer as + // member keys, each carried by a fourth index so every property stays + // indexed: byLiker becomes `[$ownerId] → pubKey`, byScore is + // `[$ownerId, pubKey] → score`. + let mut schema = likes_schema_with_index_key(2, "terminal", platform_value!("pubKey")); + with_required_property( + &mut schema, + "pubKey", + platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 33, + "maxItems": 33, + "position": 2 + }), + ); + with_required_property( + &mut schema, + "score", + platform_value!({ "type": "integer", "minimum": 0, "maximum": 100, "position": 3 }), + ); + schema + .get_mut("indices") + .expect("indices accessible") + .expect("indices present") + .as_array_mut() + .expect("indices is an array") + .push(platform_value!({ + "name": "byScore", + "properties": [{ "$ownerId": "asc" }, { "pubKey": "asc" }], + "terminal": "score" + })); + for full_validation in [false, true] { + let document_type = parse_with(schema.clone(), PlatformVersion::latest(), full_validation) + .expect("byte array and integer terminals parse"); + assert_eq!( + document_type.indices["byLiker"].single_terminal(), + Some("pubKey") + ); + assert_eq!( + document_type.indices["byScore"].single_terminal(), + Some("score") + ); + } +} + +#[test] +fn object_terminals_are_not_addressable_but_their_leaves_are() { + // Only leaf properties exist in the flattened property map, so an + // object cannot be named as a terminal at all — the same as at a prefix + // position — while a nested leaf can, provided its ancestor is required + // (the no-null invariant every indexOnly property carries). + let mut schema = likes_schema_with_index_key(2, "terminal", platform_value!("profile")); + with_required_property( + &mut schema, + "profile", + platform_value!({ + "type": "object", + "properties": { "nick": { "type": "string", "maxLength": 8, "position": 0 } }, + "required": ["nick"], + "position": 2 + }), + ); + expect_structure_error( + parse_with(schema.clone(), PlatformVersion::latest(), false), + "does not name a property", + ); + + schema + .get_mut("indices") + .expect("indices accessible") + .expect("indices present") + .as_array_mut() + .expect("indices is an array") + .get_mut(2) + .expect("byLiker exists") + .set_value("terminal", platform_value!("profile.nick")) + .expect("terminal applies"); + let document_type = parse_with(schema, PlatformVersion::latest(), false) + .expect("a nested leaf terminal parses"); + assert_eq!( + document_type.indices["byLiker"].single_terminal(), + Some("profile.nick") + ); +} + +#[test] +fn rejects_an_unbounded_byte_array_terminal() { + // 256 bytes exceeds the indexed byte-array bound (255, grovedb's key cap). + let mut schema = likes_schema_with_index_key(2, "terminal", platform_value!("blob")); + with_required_property( + &mut schema, + "blob", + platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 0, + "maxItems": 256, + "position": 2 + }), + ); + expect_basic_error( + parse_with(schema, PlatformVersion::latest(), false), + "InvalidIndexedPropertyConstraintError", + |error| matches!(error, BasicError::InvalidIndexedPropertyConstraintError(_)), + ); +} + +#[test] +fn rejects_an_overlong_string_terminal() { + // 64 characters exceeds the indexed string bound (63). + let mut schema = likes_schema_with_index_key(2, "terminal", platform_value!("caption")); + with_required_property( + &mut schema, + "caption", + platform_value!({ "type": "string", "maxLength": 64, "position": 2 }), + ); + expect_basic_error( + parse_with(schema, PlatformVersion::latest(), false), + "InvalidIndexedPropertyConstraintError", + |error| matches!(error, BasicError::InvalidIndexedPropertyConstraintError(_)), + ); +} + +#[test] +fn rejects_system_property_terminals_other_than_owner_id() { + // `$createdAt` may be indexed in the prefix, where the rules that reason + // about it look; it is not admitted as a terminal. + let schema = likes_schema_with_index_key(2, "terminal", platform_value!("$createdAt")); expect_structure_error( parse_with(schema, PlatformVersion::latest(), false), - "refersTo", + "only $ownerId may be a terminal", ); } @@ -751,46 +930,49 @@ fn rejects_indexed_created_at_that_is_not_required() { } #[test] -fn rejects_identity_public_key_reference_terminals() { - // identityPublicKey is a compound reference (identity id here, key id - // in a companion property) — the member key alone cannot identify the - // referenced key, so it is not a legal terminal. +fn accepts_an_identity_public_key_reference_terminal() { + // An identityPublicKey reference is a 32-byte identifier like any other + // as a member key; the companion key id is indexed in the prefix, so + // entries for different keys of one identity stay distinct. The old + // "referable entity" restriction no longer applies: a terminal is any + // indexable property. let mut schema = likes_schema_with_index_key(2, "terminal", platform_value!("keyRef")); + with_required_property( + &mut schema, + "keyRef", + platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { "type": "identityPublicKey", "keyIdProperty": "keyId" }, + "position": 2 + }), + ); + with_required_property( + &mut schema, + "keyId", + platform_value!({ "type": "integer", "minimum": 0, "maximum": 100, "position": 3 }), + ); schema - .get_mut("properties") - .expect("properties accessible") - .expect("properties present") - .set_value( - "keyRef", - platform_value!({ - "type": "array", - "byteArray": true, - "minItems": 32, - "maxItems": 32, - "contentMediaType": "application/x.dash.dpp.identifier", - "refersTo": { "type": "identityPublicKey", "keyIdProperty": "keyId" }, - "position": 2 - }), - ) - .expect("property applies"); - schema - .get_mut("properties") - .expect("properties accessible") - .expect("properties present") - .set_value( - "keyId", - platform_value!({ "type": "integer", "minimum": 0, "maximum": 100, "position": 3 }), - ) - .expect("property applies"); - schema + .get_mut("indices") + .expect("indices accessible") + .expect("indices present") + .as_array_mut() + .expect("indices is an array") + .get_mut(2) + .expect("byLiker exists") .set_value( - "required", - platform_value!(["hashtag", "postId", "keyRef", "keyId"]), + "properties", + platform_value!([{ "$ownerId": "asc" }, { "keyId": "asc" }]), ) - .expect("required applies"); - expect_structure_error( - parse_with(schema, PlatformVersion::latest(), false), - "identityPublicKey", + .expect("properties apply"); + let document_type = parse_with(schema, PlatformVersion::latest(), false) + .expect("an identityPublicKey reference terminal parses"); + assert_eq!( + document_type.indices["byLiker"].single_terminal(), + Some("keyRef") ); } @@ -865,6 +1047,458 @@ fn rejects_missing_owner_id() { ); } +// ── composite and flat terminals ──────────────────────────────────────── + +/// The likes schema with one extra index appended. +fn likes_schema_with_extra_index(index: Value) -> Value { + let mut schema = likes_schema(); + schema + .get_mut("indices") + .expect("indices accessible") + .expect("indices present") + .as_array_mut() + .expect("indices is an array") + .push(index); + schema +} + +#[test] +fn accepts_a_composite_terminal_below_a_prefix() { + // byLiker becomes `[$ownerId] → postId ‖ hashtag`: a 32-byte identifier + // followed by a string, which may only be the last component. + let mut schema = + likes_schema_with_index_key(2, "terminal", platform_value!(["postId", "hashtag"])); + // The hashtag is bounded to 40 characters here: a string bound counts + // characters of up to four bytes, and 32 + 4 × 63 would exceed the + // 255-byte member key cap. + schema + .get_mut("properties") + .expect("properties accessible") + .expect("properties present") + .set_value( + "hashtag", + platform_value!({ "type": "string", "maxLength": 40, "position": 0 }), + ) + .expect("property applies"); + for full_validation in [false, true] { + let document_type = parse_with(schema.clone(), PlatformVersion::latest(), full_validation) + .expect("a composite terminal parses"); + let index = &document_type.indices["byLiker"]; + assert_eq!( + index.terminal_components(), + &["postId".to_string(), "hashtag".to_string()] + ); + assert!(index.single_terminal().is_none()); + assert!(!index.is_flat()); + } +} + +#[test] +fn accepts_a_flat_composite_terminal() { + // An index with no properties at all: a flat index keyed by + // `postId ‖ $ownerId`, living under its own zero-byte-prefixed level. + let schema = likes_schema_with_extra_index(platform_value!({ + "name": "byPostOwner", + "terminal": ["postId", "$ownerId"] + })); + for full_validation in [false, true] { + let document_type = parse_with(schema.clone(), PlatformVersion::latest(), full_validation) + .expect("a flat composite terminal parses"); + let index = &document_type.indices["byPostOwner"]; + assert!(index.is_flat()); + assert!(index.properties.is_empty()); + assert_eq!( + index.flat_level_key().as_deref(), + Some("\0postId\0$ownerId") + ); + assert!( + document_type + .index_structure + .sub_levels() + .contains_key("\0postId\0$ownerId"), + "the flat level is a top-level entry of the index structure" + ); + assert!( + document_type.index_structure.sub_levels()["\0postId\0$ownerId"] + .has_index_with_type() + .is_some(), + "the flat level terminates its index" + ); + } +} + +#[test] +fn rejects_a_variable_width_leading_component() { + // A string is never fixed width, so it cannot be followed by another + // component. + let schema = likes_schema_with_index_key(2, "terminal", platform_value!(["hashtag", "postId"])); + expect_structure_error( + parse_with(schema, PlatformVersion::latest(), false), + "is not fixed width", + ); +} + +#[test] +fn should_bound_flat_level_names_independently_of_member_values() { + // Four booleans and an owner encode to only 36 member-key bytes, but + // the names used for their flat level can exceed GroveDB's key limit. + for last_name_length in [50, 51, 64] { + let names = [ + "a".repeat(64), + "b".repeat(64), + "c".repeat(64), + "d".repeat(last_name_length), + ]; + let mut schema = platform_value!({ + "type": "object", + "indexOnly": true, + "documentsMutable": false, + "properties": {}, + "required": [], + "additionalProperties": false + }); + for (position, name) in names.iter().enumerate() { + with_required_property( + &mut schema, + name, + platform_value!({ "type": "boolean", "position": position }), + ); + } + let mut terminal: Vec = names.into_iter().map(Value::Text).collect(); + terminal.push(Value::Text("$ownerId".to_string())); + schema + .set_value( + "indices", + platform_value!([{ + "name": "byValues", + "terminal": terminal + }]), + ) + .expect("indices apply"); + + for full_validation in [false, true] { + let result = parse_with(schema.clone(), PlatformVersion::latest(), full_validation); + if last_name_length == 50 { + let document_type = result.expect("a 255-byte flat level key is accepted"); + assert_eq!( + document_type.indices["byValues"] + .flat_level_key() + .unwrap() + .len(), + 255 + ); + } else { + expect_structure_error(result, "flat level key cap"); + } + } + } +} + +#[test] +fn rejects_a_composite_terminal_over_the_key_cap() { + // Two 200-byte arrays: 400 bytes, over grovedb's 255-byte key cap. + let mut schema = + likes_schema_with_index_key(2, "terminal", platform_value!(["blobA", "blobB"])); + for (name, position) in [("blobA", 2), ("blobB", 3)] { + with_required_property( + &mut schema, + name, + platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 200, + "maxItems": 200, + "position": position + }), + ); + } + expect_structure_error( + parse_with(schema, PlatformVersion::latest(), false), + "member key cap", + ); +} + +#[test] +fn rejects_a_terminal_component_name_with_a_zero_byte() { + let mut schema = login_response_schema(); + with_required_property( + &mut schema, + "bad\0name", + platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 4, + "maxItems": 4, + "position": 3 + }), + ); + schema + .set_value( + "indices", + platform_value!([ + { "name": "byRequest", "terminal": ["bad\0name", "$ownerId"] } + ]), + ) + .expect("indices apply"); + expect_structure_error( + parse_with(schema, PlatformVersion::latest(), false), + "zero byte", + ); +} + +#[test] +fn rejects_a_terminal_with_no_components() { + let schema = likes_schema_with_index_key(2, "terminal", platform_value!([])); + match parse_with(schema, PlatformVersion::latest(), false) { + Ok(_) => panic!("an empty terminal list must be refused"), + Err(error) => assert!( + error.to_string().contains("at least one property"), + "expected the empty-terminal rejection, got: {error}" + ), + } +} + +/// The flat level's storage key is the zero-joined component names, which +/// the value-width cap does not bound: five one-byte components named at +/// the 64-character limit fit the member key with room to spare and still +/// spell a 300-byte level key. +#[test] +fn rejects_a_flat_level_key_over_the_key_cap() { + let names: Vec = (0..5) + .map(|position| format!("{position}{}", "n".repeat(63))) + .collect(); + let mut properties = platform_value::Value::Map(Default::default()); + for (position, name) in names.iter().enumerate() { + properties + .set_value( + name, + platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 1, + "maxItems": 1, + "position": position as u64 + }), + ) + .expect("property applies"); + } + let required: Vec = names + .iter() + .map(|name| platform_value!(name.as_str())) + .collect(); + let mut terminal = required.clone(); + terminal.push(platform_value!("$ownerId")); + let schema = platform_value!({ + "type": "object", + "indexOnly": true, + "documentsMutable": false, + "canBeDeleted": true, + "indices": [{ "name": "byNames", "terminal": platform_value::Value::Array(terminal) }], + "properties": properties, + "required": platform_value::Value::Array(required), + "additionalProperties": false + }); + expect_structure_error( + parse_with(schema, PlatformVersion::latest(), false), + "flat level key cap", + ); +} + +#[test] +fn rejects_a_terminal_naming_a_component_twice() { + let schema = likes_schema_with_index_key(2, "terminal", platform_value!(["postId", "postId"])); + match parse_with(schema, PlatformVersion::latest(), false) { + Ok(_) => panic!("a duplicate terminal component must be refused"), + Err(error) => assert!( + error.to_string().contains("twice"), + "expected the duplicate-component refusal, got {error}" + ), + } +} + +#[test] +fn rejects_aggregates_on_a_flat_index() { + // No prefix level exists for an aggregate to apply to. + let schema = likes_schema_with_extra_index(platform_value!({ + "name": "byPostOwner", + "terminal": ["postId", "$ownerId"], + "countable": true + })); + expect_structure_error( + parse_with(schema, PlatformVersion::latest(), false), + "has no properties (a flat index", + ); +} + +#[test] +fn rejects_a_flat_index_on_a_stored_type() { + let schema = platform_value!({ + "type": "object", + "properties": { + "hashtag": { "type": "string", "maxLength": 63, "position": 0 } + }, + "indices": [{ "name": "flat" }], + "additionalProperties": false + }); + expect_structure_error( + parse_with(schema, PlatformVersion::latest(), false), + "only allowed on an indexOnly document type", + ); +} + +// ── entry payload ─────────────────────────────────────────────────────── + +/// The app-connect login response: a flat index keyed by the request hash +/// and the responding identity, with the wallet's ephemeral key and the +/// ciphertext in every entry's value. +fn login_response_schema() -> Value { + platform_value!({ + "type": "object", + "indexOnly": true, + "documentsMutable": false, + "canBeDeleted": true, + "indices": [ + { "name": "byRequest", "terminal": ["appEphemeralPubKeyHash", "$ownerId"] } + ], + "entryPayload": ["walletEphemeralPubKey", "encryptedPayload"], + "properties": { + "appEphemeralPubKeyHash": { + "type": "array", + "byteArray": true, + "minItems": 20, + "maxItems": 20, + "position": 0 + }, + "walletEphemeralPubKey": { + "type": "array", + "byteArray": true, + "minItems": 33, + "maxItems": 33, + "position": 1 + }, + "encryptedPayload": { + "type": "array", + "byteArray": true, + "minItems": 60, + "maxItems": 572, + "position": 2 + } + }, + "required": ["appEphemeralPubKeyHash", "walletEphemeralPubKey", "encryptedPayload"], + "additionalProperties": false + }) +} + +#[test] +fn accepts_an_entry_payload_on_a_flat_composite_index() { + for full_validation in [false, true] { + let document_type = parse_with( + login_response_schema(), + PlatformVersion::latest(), + full_validation, + ) + .expect("the login response schema parses"); + assert_eq!( + document_type + .entry_payload + .iter() + .map(String::as_str) + .collect::>(), + vec!["encryptedPayload", "walletEphemeralPubKey"], + "payload properties are kept in name order" + ); + assert!(document_type.indices["byRequest"].is_flat()); + } +} + +#[test] +fn rejects_an_entry_payload_property_that_is_also_indexed() { + let mut schema = login_response_schema(); + schema + .set_value( + "entryPayload", + platform_value!([ + "appEphemeralPubKeyHash", + "walletEphemeralPubKey", + "encryptedPayload" + ]), + ) + .expect("entryPayload applies"); + expect_structure_error( + parse_with(schema, PlatformVersion::latest(), false), + "also appears in index", + ); +} + +#[test] +fn rejects_an_unbounded_entry_payload_property() { + let mut schema = login_response_schema(); + schema + .get_mut("properties") + .expect("properties accessible") + .expect("properties present") + .set_value( + "encryptedPayload", + platform_value!({ "type": "array", "byteArray": true, "position": 2 }), + ) + .expect("property applies"); + expect_structure_error( + parse_with(schema, PlatformVersion::latest(), false), + "must be bounded", + ); +} + +#[test] +fn rejects_an_optional_entry_payload_property() { + let mut schema = login_response_schema(); + schema + .set_value( + "required", + platform_value!(["appEphemeralPubKeyHash", "walletEphemeralPubKey"]), + ) + .expect("required applies"); + expect_structure_error( + parse_with(schema, PlatformVersion::latest(), false), + "must be listed in `required`", + ); +} + +#[test] +fn rejects_an_entry_payload_naming_an_unknown_property() { + let mut schema = login_response_schema(); + schema + .set_value( + "entryPayload", + platform_value!(["nonsense", "encryptedPayload"]), + ) + .expect("entryPayload applies"); + // walletEphemeralPubKey is then in no index and no payload either, but + // the unknown name is refused first. + expect_structure_error( + parse_with(schema, PlatformVersion::latest(), false), + "is not a top-level property", + ); +} + +#[test] +fn rejects_an_entry_payload_on_a_stored_type() { + let schema = platform_value!({ + "type": "object", + "properties": { + "hashtag": { "type": "string", "maxLength": 63, "position": 0 }, + "note": { "type": "string", "maxLength": 63, "position": 1 } + }, + "required": ["hashtag", "note"], + "indices": [{ "name": "byHashtag", "properties": [{ "hashtag": "asc" }] }], + "entryPayload": ["note"], + "additionalProperties": false + }); + expect_structure_error( + parse_with(schema, PlatformVersion::latest(), false), + "declares `entryPayload`", + ); +} + // ── terminal without indexOnly ────────────────────────────────────────── #[test] @@ -1135,8 +1769,7 @@ fn accepts_a_deletable_document_reference_as_the_terminal() { .indices .get("byLiker") .unwrap() - .terminal - .as_deref(), + .single_terminal(), Some("postId") ); } diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs index 89833f017cb..79ef89772c6 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs @@ -287,6 +287,8 @@ fn try_from_schema_generation_3( // above. let aggregates = common::parse_doctype_aggregate_keywords(&schema, name)?; let index_only = common::parse_index_only_keyword(&schema)?; + let entry_payload = + common::parse_property_name_list_keyword(&schema, name, property_names::ENTRY_PAYLOAD)?; let action_fees = DocumentActionFees::try_from_document_schema(&schema, name)?; let can_be_deleted_by_moderators = common::parse_can_be_deleted_by_moderators_keyword(&schema)?; let can_be_deleted_by_moderators_for = @@ -356,17 +358,22 @@ fn try_from_schema_generation_3( 3, ) .range_countable_implies_countable, + // NO LOCKING RESOLUTION: a contested index resolved without a Lock + // choice, a generation-3 value from the same shared mapping. + admit_index_no_locking_resolution: IndexGrammarAdmissions::for_schema_generation(3) + .no_locking_resolution, }, platform_version, )?; let mut v2: DocumentTypeV2 = v1.into(); v2.action_fees = action_fees; + v2.entry_payload = entry_payload; common::apply_doctype_aggregates(&mut v2, aggregates, name)?; // After the aggregates: `apply_index_only` rejects the doctype-level // aggregate flags (they describe the primary-key tree, which an // indexOnly type does not have), so it has to see them already applied. - common::apply_index_only(&mut v2, index_only, name)?; + common::apply_index_only(&mut v2, index_only, name, platform_version)?; // After the core parse: the lints read the resolved `documentsMutable` // flag (contract default applied) and the parsed top-level properties. common::apply_immutable_fields( diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/moderators_delete_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/moderators_delete_tests.rs index 36f8ab58762..38d951e9f2a 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/moderators_delete_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/moderators_delete_tests.rs @@ -1,6 +1,8 @@ //! The `canBeDeletedByModerators` doctype keyword (protocol version 14): what it requires of //! the contract and of the document type. use super::*; +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; use crate::data_contract::config::moderation::{ContractModerationConfig, ContractModerators}; use crate::data_contract::document_type::accessors::DocumentTypeV2Getters; use platform_value::platform_value; @@ -12,6 +14,7 @@ fn moderated_config(platform_version: &PlatformVersion) -> DataContractConfig { banlist: false, suspensions: false, moderators: ContractModerators::ContractOwner, + warnings: false, })) } @@ -84,6 +87,40 @@ fn assert_refused_naming(result: Result, fragments: } } +#[test] +fn should_preserve_contract_structure_errors_for_non_object_schemas() { + let platform_version = PlatformVersion::latest(); + let config = moderated_config(platform_version); + for schema in [ + Value::Null, + Value::Bool(false), + Value::Array(vec![]), + Value::Text("invalid".to_string()), + Value::U64(1), + ] { + for full_validation in [false, true] { + let error = parse_with_config( + schema.clone(), + &config, + platform_version.protocol_version, + full_validation, + ) + .expect_err("a document schema must be an object"); + let ProtocolError::ConsensusError(error) = error else { + panic!("expected a consensus error, got {error:?}"); + }; + assert!( + matches!(error.as_ref(), + ConsensusError::BasicError(BasicError::ContractError( + DataContractError::InvalidContractStructure(message) + )) if message == "document schema must be an object: structure error: value is not a map" + ), + "schema {schema:?}, full_validation={full_validation}: {error:?}", + ); + } + } +} + #[test] fn should_parse_the_flag_on_a_moderated_contract() { let document_type = parse_moderated(post_schema(platform_value!({}))).expect("parse"); @@ -173,6 +210,40 @@ fn should_refuse_the_flag_on_an_index_only_type() { ); } +#[test] +fn should_refuse_the_flag_on_a_type_with_a_contested_index() { + // A moderator's restore puts a deleted document back through an ordinary insert; a + // contested index only takes a document through a vote, so such a deletion could never be + // undone. + let schema = platform_value!({ + "type": "object", + "documentsMutable": false, + "canBeDeletedByModerators": true, + "indices": [ + { + "name": "byLabel", + "properties": [{ "normalizedLabel": "asc" }], + "unique": true, + "contested": { + "fieldMatches": [ + { "field": "normalizedLabel", "regexPattern": "^[a-z]{3,}$" }, + ], + "resolution": 0, + }, + }, + ], + "properties": { + "normalizedLabel": { "type": "string", "maxLength": 50, "position": 0 }, + }, + "required": ["normalizedLabel"], + "additionalProperties": false, + }); + assert_refused_naming( + parse_moderated(schema), + &["contested index", "canBeDeletedByModerators"], + ); +} + #[test] fn should_allow_the_flag_on_a_transferable_tradeable_type() { let document_type = parse_moderated(post_schema(platform_value!({ @@ -320,3 +391,33 @@ fn should_refuse_a_window_that_is_not_a_positive_number_of_seconds_on_the_stored } } } + +#[test] +fn should_refuse_a_schema_that_is_not_an_object_as_an_invalid_contract_structure() { + // The window is read off the raw schema before the core parser checks that + // the schema is an object. It must not be the reader that fails first: a + // schema that is no object carries no keyword, and the core parser is the + // one that names the real problem. + let platform_version = PlatformVersion::latest(); + for full_validation in [true, false] { + for schema in [ + platform_value!(null), + platform_value!("post"), + platform_value!([]), + ] { + let error = parse_with_config( + schema.clone(), + &moderated_config(platform_version), + platform_version.protocol_version, + full_validation, + ) + .expect_err("a schema that is not an object must be refused"); + let message = format!("{error:?}"); + assert!( + message.contains("InvalidContractStructure"), + "schema {schema:?} must be refused as an invalid contract structure (full \ + validation: {full_validation}), got {message}" + ); + } + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/index/mod.rs b/packages/rs-dpp/src/data_contract/document_type/index/mod.rs index 888d1648d64..35f857b897a 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index/mod.rs @@ -71,12 +71,16 @@ pub const TIME_RANGE: &str = "timeRange"; /// `range % step == 0`), not a versioned limit: it is part of what makes a /// transform well-formed at all. pub const MAX_TIME_RANGE_PHASE_SECONDS: u64 = 31_536_000; -/// Index-level keyword naming the property whose value is the index entry's -/// **member key** on an `indexOnly` document type — the docId-analog terminal -/// key stored under the `0` storage marker, where a normal index stores the -/// document id. `"$ownerId"` (the default) or a refersTo-typed identifier -/// property (identity, contract, token, or permanent document — the permanent -/// kinds `refersTo` targets). Only allowed on indexOnly document types; the +/// Index-level keyword naming the property — or the ordered list of +/// properties, for a composite terminal — whose encoded value(s), +/// concatenated, are the index entry's **member key** on an `indexOnly` +/// document type: the docId-analog terminal key stored under the `0` storage +/// marker, where a normal index stores the document id. Each component is +/// `"$ownerId"` (the default) or any schema property a prefix position could +/// carry (identifiers with or without a `refersTo`, bounded byte arrays and +/// strings, integers, booleans, dates), every component but the last fixed +/// width. An index with no `properties` is a flat index keyed by its +/// terminal alone. Only allowed on indexOnly document types; the /// doc-type-level validation rejects it elsewhere. Meta-schema v3+ (protocol /// version 14). pub const TERMINAL: &str = "terminal"; @@ -117,7 +121,14 @@ pub const SKIP_IF_ABSENT: &str = "skipIfAbsent"; #[derive(Clone, Copy, Debug, PartialEq, Eq, Ord, PartialOrd)] #[cfg_attr(feature = "serde-conversion", derive(Serialize, Deserialize))] pub enum ContestedIndexResolution { + /// Masternodes and evonodes vote for a contender, abstain, or lock the value so nobody + /// gets it. This is the DPNS rule. MasternodeVote = 0, + /// Masternodes and evonodes vote for a contender or abstain; there is no Lock choice, + /// so the contest always ends with a winner. A contest whose join window closes with a + /// single contender is awarded at once, without the vote window. Meta-schema v3+ + /// (protocol version 14). + MasternodeVoteNoLocking = 1, } impl TryFrom for ContestedIndexResolution { @@ -126,6 +137,7 @@ impl TryFrom for ContestedIndexResolution { fn try_from(value: u8) -> Result { match value { 0 => Ok(MasternodeVote), + 1 => Ok(ContestedIndexResolution::MasternodeVoteNoLocking), value => Err(ProtocolError::UnknownStorageKeyRequirements(format!( "contested index resolution unknown: {}", value @@ -429,6 +441,70 @@ where ) } +/// Deserializer for [`Index::terminal`] accepting `null`, a bare property +/// name (the single-component form, and the only spelling the field had +/// while it was an `Option`) and an array of names (the composite +/// form). Serialization always emits the array form. +#[cfg(feature = "serde-conversion")] +fn deserialize_terminal<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum TerminalCompat { + Many(Vec), + One(String), + } + Ok(match Option::::deserialize(deserializer)? { + None => None, + Some(TerminalCompat::One(name)) => Some(vec![name]), + Some(TerminalCompat::Many(names)) => Some(names), + }) +} + +/// Whether `fields`, all terminal components, appear in the terminal's +/// declared order: one member key sorts by its components in that order, +/// so a walk over member keys can implement no other ordering. The terminal +/// route additionally requires the run to be contiguous after the +/// equality-bound components; the matcher only refuses what no index walk +/// could serve, so another index may still take the query. +fn follows_component_order(components: &[String], fields: &[&str]) -> bool { + let position_of = |field: &str| components.iter().position(|component| component == field); + fields + .windows(2) + .all(|pair| match (position_of(pair[0]), position_of(pair[1])) { + (Some(earlier), Some(later)) => earlier < later, + _ => false, + }) +} + +/// The storage key of a flat indexOnly index's level (an index with no +/// prefix `properties`): a zero byte followed by each terminal component +/// name, every name preceded by a zero byte. Property names never contain a +/// zero byte, so a flat level can never collide with a property-name tree: +/// a flat `["$ownerId"]` terminal keys `"\0$ownerId"`, beside the +/// `"$ownerId"` property-name tree a prefixed index may own in the same +/// document type. Every place that turns a flat index into a GroveDB path +/// segment — contract setup, the document walkers (via `IndexLevel`), the +/// uniqueness and delete probes, query path derivation, synthesis and proof +/// verification — derives it through this function. +pub fn flat_level_key_for(components: &[String]) -> String { + let mut key = String::with_capacity(components.iter().map(|c| c.len() + 1).sum()); + for component in components { + key.push('\0'); + key.push_str(component); + } + key +} + +/// Whether an index-structure level key names a flat index's level (see +/// [`flat_level_key_for`]) rather than a property-name or grid-qualified +/// tree. +pub fn is_flat_level_key(level_key: &str) -> bool { + level_key.starts_with('\0') +} + // Indices documentation: https://dashplatform.readme.io/docs/reference-data-contracts#document-indices #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] #[cfg_attr(feature = "serde-conversion", derive(Serialize, Deserialize))] @@ -625,21 +701,31 @@ pub struct Index { // JSON must still deserialize. #[cfg_attr(feature = "serde-conversion", serde(default))] pub time_range: Option, - /// On an `indexOnly` document type, the property whose value is this - /// index's member key: the terminal key under the `0` storage marker, - /// sitting exactly where a normal index stores the document id — except - /// the element is an `Item` instead of a `Reference`, because there is no - /// primary-storage row to reference. `"$ownerId"` or a refersTo-typed - /// identifier property; the doc-type-level validation - /// (`apply_index_only`) normalizes an omitted value to `"$ownerId"` and + /// On an `indexOnly` document type, the property — or the ordered list + /// of properties, for a composite terminal — whose encoded value(s), + /// concatenated, form this index's member key: the terminal key under + /// the `0` storage marker, sitting exactly where a normal index stores + /// the document id — except the element is an `Item` instead of a + /// `Reference`, because there is no primary-storage row to reference. + /// Each component is `"$ownerId"` or any schema property a prefix + /// position could carry, keyed by its tree-key encoding; every component + /// but the last must be fixed width so equality on the leading ones is a + /// clean key range. An index with no `properties` at all is a *flat* + /// index: its entries live directly under a level keyed by the terminal's + /// names ([`flat_level_key_for`]). The doc-type-level validation + /// (`apply_index_only`) normalizes an omitted value to `["$ownerId"]` and /// rejects the keyword entirely on non-indexOnly document types, so on a /// parsed non-indexOnly type this is always `None`. // // `serde(default)`: added after the struct's serde shape was in the wild // (see the note on `countable` above), so pre-existing JSON must still - // deserialize. - #[cfg_attr(feature = "serde-conversion", serde(default))] - pub terminal: Option, + // deserialize. The deserializer also accepts the bare-string spelling + // the field had while it was an `Option`. + #[cfg_attr( + feature = "serde-conversion", + serde(default, deserialize_with = "deserialize_terminal") + )] + pub terminal: Option>, /// On an indexOnly document type whose index path is fully determined by /// a same-contract `permanentDocument` reference (see [`PREALLOCATED`]): /// when `true`, inserting a referenced document also creates this index's @@ -696,6 +782,9 @@ pub(crate) struct IndexGrammarAdmissions { /// generations reject the omission, and their frozen meta-schemas (v1, /// v2) carry a `dependentRequired` row that says the same. pub(crate) range_countable_implies_countable: bool, + /// Whether a contested index may be resolved without a Lock choice + /// (`"resolution": 1`, [`ContestedIndexResolution::MasternodeVoteNoLocking`]). + pub(crate) no_locking_resolution: bool, } impl IndexGrammarAdmissions { @@ -711,6 +800,7 @@ impl IndexGrammarAdmissions { preallocated: generation >= 3, skip_if_absent: generation >= 3, range_countable_implies_countable: generation >= 3, + no_locking_resolution: generation >= 3, } } } @@ -801,6 +891,43 @@ impl Index { } } + /// The terminal's components: one name for a single-property terminal, + /// several for a composite one (the member key is their encoded values + /// concatenated in this order), none on a non-indexOnly index. + pub fn terminal_components(&self) -> &[String] { + self.terminal.as_deref().unwrap_or(&[]) + } + + /// Whether `name` is one of the terminal's components. + pub fn terminal_contains(&self, name: &str) -> bool { + self.terminal_components() + .iter() + .any(|component| component == name) + } + + /// The terminal's single component; `None` on a composite terminal or a + /// non-indexOnly index. + pub fn single_terminal(&self) -> Option<&str> { + match self.terminal.as_deref() { + Some([component]) => Some(component.as_str()), + _ => None, + } + } + + /// Whether this is a flat index: an indexOnly index with no prefix + /// properties, whose entries live directly under the level keyed by + /// [`Self::flat_level_key`]. + pub fn is_flat(&self) -> bool { + self.properties.is_empty() && self.terminal.is_some() + } + + /// The storage key of a flat index's level (see [`flat_level_key_for`]); + /// `None` unless [`Self::is_flat`]. + pub fn flat_level_key(&self) -> Option { + self.is_flat() + .then(|| flat_level_key_for(self.terminal_components())) + } + /// Get values pub fn extract_values(&self, data: &BTreeMap) -> Vec { self.properties @@ -905,29 +1032,36 @@ impl Index { in_field_name: Option<&str>, order_by: &[&str], ) -> Option<(u16, bool)> { - let Some(terminal) = self.terminal.as_deref() else { + let Some(components) = self.terminal.as_deref() else { return self .matches(index_names, in_field_name, order_by) .map(|difference| (difference, false)); }; + let is_component = |field: &str| components.iter().any(|component| component == field); - let terminal_used = index_names.contains(&terminal); + let terminal_used = index_names.iter().any(|field| is_component(field)); let prefix_fields: Vec<&str> = index_names .iter() .copied() - .filter(|field| *field != terminal) + .filter(|field| !is_component(field)) .collect(); - let prefix_order_by: &[&str] = match order_by.iter().position(|field| *field == terminal) { - // Ordering by the terminal is ordering the deepest level — - // admissible only as the ordering's last entry. - Some(position) if position + 1 == order_by.len() => &order_by[..position], + let prefix_order_by: &[&str] = match order_by.iter().position(|field| is_component(field)) { + // Ordering by the terminal (any of its components) is ordering + // the deepest level — admissible only as the ordering's + // trailing entries. + Some(position) + if order_by[position..].iter().all(|field| is_component(field)) + && follows_component_order(components, &order_by[position..]) => + { + &order_by[..position] + } Some(_) => return None, None => order_by, }; let prefix_in_field = match in_field_name { // An `in` on the terminal sits at the deepest position by // construction; the prefix keeps no `in` constraint. - Some(field) if field == terminal => None, + Some(field) if is_component(field) => None, other => other, }; @@ -946,6 +1080,14 @@ impl Index { in_field_name: Option<&str>, order_by: &[&str], ) -> Option { + // A FLAT indexOnly index has no prefix components at all: it + // matches exactly the queries that bind none (its terminal + // components are matched by the terminal-aware callers, which + // strip them before reaching here). + if properties.is_empty() { + return (index_names.is_empty() && in_field_name.is_none() && order_by.is_empty()) + .then_some(0); + } // Here we are trying to figure out if the Index matches the order by // To do so we take the index and go backwards as we need the order by clauses to be // continuous, but they do not need to be at the end. @@ -1053,27 +1195,34 @@ impl Index { in_field_name: Option<&str>, order_by: &[&str], ) -> Option<(u16, bool)> { - let Some(terminal) = self.terminal.as_deref() else { + let Some(components) = self.terminal.as_deref() else { return self .matches_contiguous(equality_fields, range_field, in_field_name, order_by) .map(|difference| (difference, false)); }; + let is_component = |field: &str| components.iter().any(|component| component == field); - let terminal_used = equality_fields.contains(&terminal) - || range_field == Some(terminal) - || in_field_name == Some(terminal) - || order_by.contains(&terminal); + let terminal_used = equality_fields.iter().any(|field| is_component(field)) + || range_field.is_some_and(is_component) + || in_field_name.is_some_and(is_component) + || order_by.iter().any(|field| is_component(field)); let prefix_equality_fields: Vec<&str> = equality_fields .iter() .copied() - .filter(|field| *field != terminal) + .filter(|field| !is_component(field)) .collect(); - let prefix_range_field = range_field.filter(|field| *field != terminal); - let prefix_in_field = in_field_name.filter(|field| *field != terminal); - let prefix_order_by: &[&str] = match order_by.iter().position(|field| *field == terminal) { - // Ordering by the terminal is ordering the deepest level — - // admissible only as the ordering's last entry. - Some(position) if position + 1 == order_by.len() => &order_by[..position], + let prefix_range_field = range_field.filter(|field| !is_component(field)); + let prefix_in_field = in_field_name.filter(|field| !is_component(field)); + let prefix_order_by: &[&str] = match order_by.iter().position(|field| is_component(field)) { + // Ordering by the terminal (any of its components) is ordering + // the deepest level — admissible only as the ordering's + // trailing entries. + Some(position) + if order_by[position..].iter().all(|field| is_component(field)) + && follows_component_order(components, &order_by[position..]) => + { + &order_by[..position] + } Some(_) => return None, None => order_by, }; @@ -1192,6 +1341,7 @@ impl TryFrom<&[(Value, Value)]> for Index { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) } @@ -1232,6 +1382,7 @@ impl Index { preallocated: preallocated_allowed, skip_if_absent: skip_if_absent_allowed, range_countable_implies_countable, + no_locking_resolution: no_locking_resolution_allowed, } = admissions; // Decouple the map // It contains properties and a unique key @@ -1294,7 +1445,7 @@ impl Index { let mut ranked_summable = false; let mut ranked_averageable = false; let mut time_range: Option = None; - let mut terminal: Option = None; + let mut terminal: Option> = None; let mut preallocated = false; let mut skip_if_absent = false; @@ -1401,6 +1552,14 @@ impl Index { resolution_int.try_into().map_err(|e: ProtocolError| { DataContractError::ValueWrongType(e.to_string()) })?; + if contested_index_information.resolution + == ContestedIndexResolution::MasternodeVoteNoLocking + && !no_locking_resolution_allowed + { + return Err(DataContractError::InvalidContractStructure( + "contested index resolution 1 (masternode vote without locking) requires document type schema generation 3 (protocol version 14)".to_string(), + )); + } } "description" => {} key => { @@ -1731,19 +1890,51 @@ impl Index { // fact this parser cannot see; `apply_index_only` in // `try_from_schema::common` enforces it. TERMINAL if terminal_allowed => { - let terminal_name = - value_value - .as_text() - .ok_or(DataContractError::ValueWrongType( - "terminal value must be a string naming a property".to_string(), - ))?; - if terminal_name.is_empty() { + // A bare name is a single-component terminal; an array + // is a composite one, keyed by the concatenation of its + // components' encoded values in the listed order. + let components: Vec = match value_value { + Value::Text(terminal_name) => vec![terminal_name.clone()], + Value::Array(entries) => entries + .iter() + .map(|entry| { + entry.as_text().map(str::to_owned).ok_or( + DataContractError::ValueWrongType( + "every terminal component must be a string naming a \ + property" + .to_string(), + ), + ) + }) + .collect::>()?, + _ => { + return Err(DataContractError::ValueWrongType( + "terminal value must be a property name or an array of \ + property names" + .to_string(), + )) + } + }; + if components.is_empty() { + return Err(DataContractError::InvalidContractStructure( + "terminal must name at least one property".to_string(), + )); + } + if components.iter().any(|component| component.is_empty()) { return Err(DataContractError::InvalidContractStructure( "terminal must name a property; an empty string names nothing" .to_string(), )); } - terminal = Some(terminal_name.to_owned()); + for (position, component) in components.iter().enumerate() { + if components[..position].contains(component) { + return Err(DataContractError::InvalidContractStructure(format!( + "terminal lists property \"{}\" twice", + component + ))); + } + } + terminal = Some(components); } // `preallocated` is guarded the same way as `terminal` above: // it joined the grammar at meta-schema v3, so below that the @@ -2551,6 +2742,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .expect("should parse"); @@ -2569,6 +2761,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .unwrap_err(); @@ -2587,6 +2780,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .unwrap_err(); @@ -2609,6 +2803,7 @@ mod tests { preallocated: true, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }; let mut map = index_value_map("postId", None); @@ -2640,6 +2835,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .unwrap_err(); @@ -2672,6 +2868,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .unwrap_err(); @@ -2696,6 +2893,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .expect("should parse"); @@ -2720,6 +2918,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .unwrap_err(); @@ -2745,6 +2944,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .unwrap_err(); @@ -2771,6 +2971,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .expect("should parse"); @@ -2793,6 +2994,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .expect("should parse"); @@ -2820,6 +3022,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .expect("should parse"); @@ -2847,6 +3050,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .unwrap_err(); @@ -2872,6 +3076,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .unwrap_err(); @@ -2902,6 +3107,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .expect("the parser applies structural rules only"); @@ -2927,6 +3133,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .unwrap_err(); @@ -2948,6 +3155,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .unwrap_err(); @@ -2973,6 +3181,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .unwrap_err(); @@ -3029,6 +3238,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ) .unwrap_err(); @@ -3067,6 +3277,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .expect("should parse"); @@ -3098,6 +3309,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .unwrap_err(); @@ -3131,6 +3343,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .unwrap_err(); @@ -3158,6 +3371,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .expect("a non-unique $updatedAt bucketing stays legal"); @@ -3180,6 +3394,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ) .unwrap_err(); @@ -3196,9 +3411,15 @@ mod tests { assert_eq!(res, ContestedIndexResolution::MasternodeVote); } + #[test] + fn test_contested_index_resolution_try_from_no_locking() { + let res = ContestedIndexResolution::try_from(1u8).unwrap(); + assert_eq!(res, ContestedIndexResolution::MasternodeVoteNoLocking); + } + #[test] fn test_contested_index_resolution_try_from_invalid() { - let res = ContestedIndexResolution::try_from(1u8); + let res = ContestedIndexResolution::try_from(2u8); assert!(res.is_err()); } @@ -3740,10 +3961,54 @@ mod tests { ); } + /// A composite terminal's components share one member key, so an + /// ordering over them is admissible only in declared order. + #[test] + fn test_matches_including_terminal_contiguous_composite_order() { + let mut index = make_index("idx", vec![("hashtag", true)], false); + index.terminal = Some(vec!["post".to_string(), "owner".to_string()]); + + assert_eq!( + index.matches_including_terminal_contiguous( + &["hashtag"], + None, + None, + &["post", "owner"] + ), + Some((0, true)), + "declared order is admissible" + ); + assert_eq!( + index.matches_including_terminal_contiguous( + &["hashtag", "post"], + None, + None, + &["owner"] + ), + Some((0, true)), + "a later component alone is admissible" + ); + assert_eq!( + index.matches_including_terminal_contiguous( + &["hashtag"], + None, + None, + &["owner", "post"] + ), + None, + "a reversed run cannot be served by any member-key walk" + ); + assert_eq!( + index.matches_including_terminal(&["hashtag"], None, &["owner", "post"]), + None, + "the non-contiguous matcher refuses the same run" + ); + } + #[test] fn test_matches_including_terminal_contiguous() { let mut index = make_index("idx", vec![("hashtag", true), ("post", true)], false); - index.terminal = Some("owner".to_string()); + index.terminal = Some(vec!["owner".to_string()]); // Fully determined prefix + terminal equality. assert_eq!( @@ -4370,6 +4635,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ) .expect("all three ranked keywords must parse when the grammar allows them"); @@ -4398,6 +4664,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ) .expect("index without ranked keywords must parse"); @@ -4442,6 +4709,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ) .expect("ranked flags on a compound index must be accepted"); @@ -4474,6 +4742,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ); assert!( @@ -4507,6 +4776,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ); assert!( @@ -4538,6 +4808,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ); assert!( @@ -4566,6 +4837,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ); assert!( @@ -4596,6 +4868,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ); assert!( @@ -4626,6 +4899,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ); assert!( @@ -4658,6 +4932,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ) .expect("rankedAverageable on the averageable sugar form must parse"); @@ -4696,6 +4971,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ) .expect("rankedAverageable on the explicit longhand form must parse"); @@ -4724,6 +5000,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ); assert!(result.is_err(), "{key} must reject a non-boolean value"); @@ -4758,6 +5035,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ); assert!( @@ -4831,6 +5109,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: true, } } @@ -5292,6 +5571,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ); let msg = format!( @@ -5356,6 +5636,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ); assert!( @@ -5386,6 +5667,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ) .unwrap_or_else(|e| panic!("{axis} with no nullSearchable key must parse: {e:?}")); @@ -5412,6 +5694,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ) .unwrap_or_else(|e| { @@ -5436,6 +5719,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ) .expect("nullSearchable: false on a plain index must still parse"); @@ -5455,6 +5739,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ) .expect("nullSearchable: false on a range-averageable index must still parse"); @@ -5481,6 +5766,47 @@ mod tests { assert!(result.is_err()); // contest supported only for unique indexes } + fn contested_unique_index_map(resolution: u64) -> Vec<(Value, Value)> { + vec![ + (Value::Text("unique".to_string()), Value::Bool(true)), + ( + Value::Text("properties".to_string()), + Value::Array(vec![Value::Map(vec![( + Value::Text("fieldA".to_string()), + Value::Text("asc".to_string()), + )])]), + ), + ( + Value::Text("contested".to_string()), + Value::Map(vec![( + Value::Text("resolution".to_string()), + Value::U64(resolution), + )]), + ), + ] + } + + /// `"resolution": 1` is a generation-3 value: the grammar without the admission + /// refuses it, generation 3 parses it as the masternode vote without locking. + #[test] + fn test_index_contested_resolution_no_locking_needs_the_admission() { + let index_map = contested_unique_index_map(1); + assert!(Index::try_from(index_map.as_slice()).is_err()); + let index = Index::try_from_value_map(index_map.as_slice(), v3_admissions()) + .expect("generation 3 admits the no-locking resolution"); + assert_eq!( + index.contested_index.expect("contested").resolution, + ContestedIndexResolution::MasternodeVoteNoLocking + ); + let index = + Index::try_from_value_map(contested_unique_index_map(0).as_slice(), v3_admissions()) + .expect("the masternode vote resolution parses in every generation"); + assert_eq!( + index.contested_index.expect("contested").resolution, + ContestedIndexResolution::MasternodeVote + ); + } + #[test] fn test_index_try_from_contested_with_unique() { let index_map: Vec<(Value, Value)> = vec![ @@ -5752,6 +6078,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, } } diff --git a/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs b/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs index d97ca664e64..dccb6e19d8b 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs @@ -234,7 +234,7 @@ mod tests { ranked_summable: false, ranked_averageable: false, time_range: None, - terminal: Some("$ownerId".to_string()), + terminal: Some(vec!["$ownerId".to_string()]), preallocated: true, skip_if_absent: false, } diff --git a/packages/rs-dpp/src/data_contract/document_type/index_level/mod.rs b/packages/rs-dpp/src/data_contract/document_type/index_level/mod.rs index cac5dca3df3..b24540e0047 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index_level/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index_level/mod.rs @@ -102,18 +102,19 @@ pub struct IndexLevelTypeInfo { /// other two. The set of axes declared here is what the rs-drive write path /// turns into the indexed tree's axis list. pub ranked_averageable: bool, - /// On an indexOnly document type, the property whose value is this - /// index's member key: the terminal key under the `0` storage marker, - /// where a normal index stores the document id — stored as an `Item` - /// instead of a `Reference` because there is no primary-storage row. - /// Always `Some` here when the declaring type is indexOnly (the parser - /// normalizes an omitted terminal to `$ownerId`), always `None` - /// otherwise. Carried on the level info because index levels merge + /// On an indexOnly document type, the property — or ordered list of + /// properties, for a composite terminal — whose encoded value(s) form + /// this index's member key: the terminal key under the `0` storage + /// marker, where a normal index stores the document id — stored as an + /// `Item` instead of a `Reference` because there is no primary-storage + /// row. Always `Some` here when the declaring type is indexOnly (the + /// parser normalizes an omitted terminal to `["$ownerId"]`), always + /// `None` otherwise. Carried on the level info because index levels merge /// across indexes sharing prefixes, and the write path only sees the /// level at the terminal — but two indexes can never share a full /// property list (duplicates are rejected), so each terminating level /// belongs to exactly one index and the field is unambiguous. - pub terminal: Option, + pub terminal: Option>, /// Whether the terminating index is `preallocated` (see /// [`crate::data_contract::document_type::index::PREALLOCATED`]): its /// dynamic trees are created when a refersTo-referenced document is, and @@ -124,6 +125,15 @@ pub struct IndexLevelTypeInfo { /// `false` on every pre-PV14 contract (the grammar rejects the keyword /// below meta-schema v3). pub preallocated: bool, + /// Whether the terminating index is FLAT (an indexOnly index with no + /// prefix properties, see [`Index::is_flat`]): its entries sit directly + /// under the `0` bucket of its own level, which is registration-time + /// structure like a property-name tree, so the delete walker stops its + /// upward prune at that bucket exactly as on a preallocated index. + /// Carried here so the walkers read the layout off the level info that + /// defines it instead of inferring it from a path height. `false` on + /// every pre-PV14 contract and on every prefixed index. + pub flat: bool, } impl IndexType { @@ -356,6 +366,43 @@ impl IndexLevel { .filter_map(|at| index.properties.iter().position(|p| &p.name == at)) .collect(); let min_ranked_at_position = ranked_at_positions.iter().copied().min(); + // A FLAT indexOnly index has no prefix properties: its entries + // live directly under one level keyed by the terminal's + // component names (`flat_level_key_for`, whose zero-byte + // prefix keeps it disjoint from every property-name tree). A + // property-less index with no terminal cannot exist on a + // parsed type — the parser refuses it — so a hand-built one + // stamps nothing here rather than a level nothing could key. + if index.properties.is_empty() { + let Some(flat_key) = index.flat_level_key() else { + continue; + }; + let flat_level = + index_level + .sub_index_levels + .entry(flat_key) + .or_insert_with(|| { + counter += 1; + IndexLevel { + level_identifier: counter, + sub_index_levels: Default::default(), + has_index_with_type: None, + time_range: None, + ranked_count_grouping: false, + count_propagating: false, + count_exempt_branch: false, + } + }); + if flat_level.has_index_with_type.is_some() { + return Err(ConsensusError::BasicError(BasicError::DuplicateIndexError( + DuplicateIndexError::new(document_type_name.to_owned(), index.name.clone()), + )) + .into()); + } + flat_level.has_index_with_type = Some(Self::terminator_info(index)); + continue; + } + let mut current_level = &mut index_level; let mut properties_iter = index.properties.iter().enumerate().peekable(); @@ -432,41 +479,7 @@ impl IndexLevel { .into()); } - let index_type = if index.unique { - UniqueIndex - } else { - NonUniqueIndex - }; - - // if things are null searchable that means we should insert with all null - - current_level.has_index_with_type = Some(IndexLevelTypeInfo { - should_insert_with_all_null: index.null_searchable, - index_type, - countable: index.countable, - range_countable: index.range_countable, - summable: index.summable.clone(), - range_summable: index.range_summable, - // The ranking axes live on the same terminating level - // as the range axes they extend: this is the level - // named after the index's LAST property, whose children - // are that property's value trees (one per group). The - // rs-drive write path reads them off the very same - // `IndexLevelTypeInfo` it already consults for - // `range_countable` / `range_summable` when it picks - // the property-name tree variant. - ranked_countable: index.ranked_countable, - ranked_summable: index.ranked_summable, - ranked_averageable: index.ranked_averageable, - // indexOnly member key. Only ever `Some` on PV14+ - // contracts (the grammar rejects the keyword below - // generation 3), so stamping it here changes nothing - // for any historical index level. - terminal: index.terminal.clone(), - // Same PV14+ gating as `terminal` — `false` on - // every historical index level. - preallocated: index.preallocated, - }); + current_level.has_index_with_type = Some(Self::terminator_info(index)); } } } @@ -483,6 +496,48 @@ impl IndexLevel { Ok(index_level) } + /// The terminator stamp an index leaves on the level its last property + /// reaches (or, for a flat index, on its flat level): the index type and + /// every per-index axis the write path reads off the level. + fn terminator_info(index: &Index) -> IndexLevelTypeInfo { + let index_type = if index.unique { + UniqueIndex + } else { + NonUniqueIndex + }; + // if things are null searchable that means we should insert with all null + IndexLevelTypeInfo { + should_insert_with_all_null: index.null_searchable, + index_type, + countable: index.countable, + range_countable: index.range_countable, + summable: index.summable.clone(), + range_summable: index.range_summable, + // The ranking axes live on the same terminating level as the + // range axes they extend: this is the level named after the + // index's LAST property, whose children are that property's + // value trees (one per group). The rs-drive write path reads + // them off the very same `IndexLevelTypeInfo` it already + // consults for `range_countable` / `range_summable` when it + // picks the property-name tree variant. + ranked_countable: index.ranked_countable, + ranked_summable: index.ranked_summable, + ranked_averageable: index.ranked_averageable, + // indexOnly member key. Only ever `Some` on PV14+ contracts + // (the grammar rejects the keyword below generation 3), so + // stamping it here changes nothing for any historical index + // level. + terminal: index.terminal.clone(), + // Same PV14+ gating as `terminal` — `false` on every + // historical index level. + preallocated: index.preallocated, + // A flat index terminates on its own level, directly under the + // document type: the one layout whose prune boundary is the + // level's `0` bucket rather than the document type. + flat: index.is_flat(), + } + } + /// Recursively marks, under every prefix-ranking chain level (grouping /// or count-propagating), the child levels that do NOT continue the /// chain as [`Self::count_exempt_branch`]. The chain child is the one diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs index 1985bc056ae..8bbcf432ae1 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs @@ -390,6 +390,22 @@ impl DocumentTypeRef<'_> { ); } + // The entry payload is part of every stored entry's value layout: + // adding, dropping or renaming a payload property would leave the + // existing entries undecodable (and their commitments unrecomputable). + if new_document_type.entry_payload() != self.entry_payload() { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + "document type can not change its entryPayload: the listed properties are \ + the value layout of every stored entry" + .to_string(), + ) + .into(), + ); + } + SimpleConsensusValidationResult::new() } @@ -1360,6 +1376,118 @@ mod tests { ); } + #[test] + fn should_return_invalid_result_when_entry_payload_is_changed() { + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let document_type_name = "note"; + + // Both types are valid indexOnly types over the same properties: + // one keeps `body` in every entry's value slot, the other keys + // by it as the terminal's last component. Every other config + // flag is equal, so `validate_config` reaches the payload check. + let payload_schema = platform_value!({ + "type": "object", + "indexOnly": true, + "documentsMutable": false, + "properties": { + "postId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + }, + "body": { + "type": "array", + "byteArray": true, + "maxItems": 16, + "position": 1, + } + }, + "required": ["postId", "body"], + "indices": [ + { + "name": "byPost", + "properties": [{ "postId": "asc" }], + } + ], + "entryPayload": ["body"], + "additionalProperties": false, + }); + let keyed_schema = platform_value!({ + "type": "object", + "indexOnly": true, + "documentsMutable": false, + "properties": { + "postId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + }, + "body": { + "type": "array", + "byteArray": true, + "maxItems": 16, + "position": 1, + } + }, + "required": ["postId", "body"], + "indices": [ + { + "name": "byPost", + "properties": [{ "postId": "asc" }], + "terminal": ["$ownerId", "body"], + } + ], + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let make_document_type = |schema: platform_value::Value| { + DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("document type should parse") + }; + + let old_document_type = make_document_type(payload_schema); + let new_document_type = make_document_type(keyed_schema); + + let result = old_document_type + .as_ref() + .validate_config(new_document_type.as_ref()); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::DocumentTypeUpdateError(e) + )] if e.additional_message().starts_with("document type can not change its entryPayload") + ); + + // The unchanged pair passes the same check. + let result = old_document_type + .as_ref() + .validate_config(old_document_type.as_ref()); + assert!(result.errors.is_empty(), "{:?}", result.errors); + } + #[test] fn should_return_invalid_result_when_range_countable_is_changed() { // documents_countable must remain equal across old/new so that @@ -1868,6 +1996,70 @@ mod tests { ); } } + #[test] + fn should_return_invalid_result_when_a_contract_reference_requirement_changes() { + let platform_version = PlatformVersion::latest(); + + for (old_fields, new_fields, changed_path) in [ + ( + platform_value!({ "type": "contract" }), + platform_value!({ "type": "contract", "contractRequirements": { "moderation": "elected" } }), + "/properties/toUserId/refersTo/contractRequirements", + ), + ( + platform_value!({ "type": "contract", "contractRequirements": { "moderation": "elected" } }), + platform_value!({ "type": "contract" }), + "/properties/toUserId/refersTo/contractRequirements", + ), + ( + platform_value!({ "type": "contract", "contractRequirements": { "minimumAgeSeconds": 3600 } }), + platform_value!({ "type": "contract", "contractRequirements": { "minimumAgeSeconds": 7200 } }), + "/properties/toUserId/refersTo/contractRequirements/minimumAgeSeconds", + ), + ( + platform_value!({ "type": "contract", "contractRequirements": { "minimumSecondsSinceUpdate": 60 } }), + platform_value!({ "type": "contract", "contractRequirements": { "minimumSecondsSinceUpdate": 61 } }), + "/properties/toUserId/refersTo/contractRequirements/minimumSecondsSinceUpdate", + ), + ( + platform_value!({ "type": "contract", "contractRequirements": { "owner": "self" } }), + platform_value!({ "type": "contract", "contractRequirements": { "owner": "other" } }), + "/properties/toUserId/refersTo/contractRequirements/owner", + ), + ( + platform_value!({ "type": "contract", "contractRequirements": { "ownerProtected": true } }), + platform_value!({ "type": "contract", "contractRequirements": { "ownerProtected": false } }), + "/properties/toUserId/refersTo/contractRequirements/ownerProtected", + ), + ( + platform_value!({ "type": "contract", "contractRequirements": { "owner": "self" } }), + platform_value!({ "type": "contract", "contractRequirements": { "owner": "self", "readonly": true } }), + "/properties/toUserId/refersTo/contractRequirements/readonly", + ), + ( + platform_value!({ "type": "contract", "contractRequirements": { "owner": "self", "keepsHistory": true } }), + platform_value!({ "type": "contract", "contractRequirements": { "owner": "self" } }), + "/properties/toUserId/refersTo/contractRequirements/keepsHistory", + ), + ] { + let old_document_type = + identifier_document_type(Some(old_fields), platform_version); + let new_document_type = + identifier_document_type(Some(new_fields), platform_version); + + let result = old_document_type + .as_ref() + .validate_schema(new_document_type.as_ref(), platform_version) + .expect("failed to validate schema compatibility"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::IncompatibleDocumentTypeSchemaError(e) + )] if e.property_path() == changed_path + ); + } + } } mod validate_byte_array_encoding { diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs index 29a8640eeab..99c0c95ee2d 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs @@ -506,6 +506,7 @@ mod tests { banlist: true, suspensions: false, moderators: ContractModerators::ContractOwner, + warnings: false, })); let make_document_type = |schema: platform_value::Value| { @@ -565,6 +566,7 @@ mod tests { banlist: true, suspensions: false, moderators: ContractModerators::ContractOwner, + warnings: false, })); let make_document_type = |window: Option| { let mut schema = platform_value!({ diff --git a/packages/rs-dpp/src/data_contract/document_type/mod.rs b/packages/rs-dpp/src/data_contract/document_type/mod.rs index 5ebdfc67648..3e39ec42e9c 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -107,6 +107,14 @@ pub(crate) mod property_names { pub const DOCUMENT_TYPE: &str = "documentType"; pub const KEY_ID_PROPERTY: &str = "keyIdProperty"; pub const PROPERTY_AGREEMENT: &str = "propertyAgreement"; + pub const CONTRACT_REQUIREMENTS: &str = "contractRequirements"; + pub const MODERATION: &str = "moderation"; + pub const MINIMUM_AGE_SECONDS: &str = "minimumAgeSeconds"; + pub const MINIMUM_SECONDS_SINCE_UPDATE: &str = "minimumSecondsSinceUpdate"; + pub const OWNER: &str = "owner"; + pub const READONLY: &str = "readonly"; + pub const KEEPS_HISTORY: &str = "keepsHistory"; + pub const OWNER_PROTECTED: &str = "ownerProtected"; pub const DOCUMENTS_COUNTABLE: &str = "documentsCountable"; pub const RANGE_COUNTABLE: &str = "rangeCountable"; /// Doctype-level flag naming the property whose values are summed into @@ -143,6 +151,14 @@ pub(crate) mod property_names { /// 14). See `apply_index_only` in `try_from_schema::common` for the /// structural constraints the flag imposes. pub const INDEX_ONLY: &str = "indexOnly"; + /// Doctype-level list, on an `indexOnly` type, of the top-level properties + /// stored in every entry's value (after the row commitment) instead of in + /// a key: the type's value slot. Listed properties must be required, must + /// not appear in any index (as a property or a terminal component) and + /// must be bounded; they are recovered by decoding the proved element. + /// Meta-schema v3+ (protocol version 14). See `apply_index_only` in + /// `try_from_schema::common`. + pub const ENTRY_PAYLOAD: &str = "entryPayload"; /// Doctype-level flag letting the contract's moderators (its owner and the /// identities its moderation config appoints) delete documents of this type /// with a `ContractUserModeration` transition, whatever `canBeDeleted` says 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 b50a8562e1a..d71648c21e2 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 @@ -10,9 +10,14 @@ use platform_serialization_derive::{ }; use crate::consensus::basic::decode::DecodingError; +use crate::data_contract::accessors::v0::DataContractV0Getters; +use crate::data_contract::accessors::v1::DataContractV1Getters; +use crate::data_contract::config::v0::DataContractConfigGettersV0; use crate::data_contract::config::v1::DataContractConfigGettersV1; +use crate::data_contract::config::v2::DataContractConfigGettersV2; use crate::data_contract::config::DataContractConfig; use crate::data_contract::document_type::property_names; +use crate::data_contract::DataContract; use crate::document::property_names::{CREATOR_ID, OWNER_ID}; use crate::prelude::TimestampMillis; use crate::ProtocolError; @@ -27,7 +32,7 @@ use platform_version::version::PlatformVersion; use rand::distributions::{Alphanumeric, Standard}; use rand::rngs::StdRng; use rand::Rng; -use serde::Serialize; +use serde::{Deserialize, Serialize}; pub mod array; @@ -84,6 +89,337 @@ pub struct ByteArrayPropertySizes { pub max_size: Option, } +/// What a `contract` reference requires of the contract it points at, beyond its existence. +/// +/// Declared as `refersTo: { "type": "contract", "contractRequirements": { ... } }`: each key names an +/// aspect of the referenced contract and its value the requirement on it. Consensus checks the +/// requirements when the referring document is written, against the contract it has already +/// fetched for the existence check and the write itself (its owner and block time), so a +/// requirement costs no further read. An unmet one refuses the write with +/// `ReferencedContractRequirementNotMetError` (40135). +#[derive( + Debug, PartialEq, Eq, Clone, Default, Serialize, Deserialize, Encode, Decode, DecodeUntrusted, +)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ContractReferenceRequirements { + /// The moderation the referenced contract must declare. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub moderation: Option, + /// How long, in seconds, the referenced contract must have existed when the referring + /// document is written: its recorded creation time plus this many seconds must not be + /// after the block time. A contract that never recorded a creation time does not meet it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub minimum_age_seconds: Option, + /// How long, in seconds, the referenced contract must have been unchanged when the + /// referring document is written: the later of its recorded creation and last update + /// times plus this many seconds must not be after the block time. A contract that never + /// recorded a creation time does not meet it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub minimum_seconds_since_update: Option, + /// Who must own the referenced contract, relative to the writer of the referring document. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner: Option, + /// Whether the referenced contract must be read-only (its config's `readonly`), a + /// contract that can never be updated again. Only `true` is declarable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub readonly: Option, + /// Whether the referenced contract must keep its history (its config's `keepsHistory`). + /// Only `true` is declarable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub keeps_history: Option, + /// Whether the elected moderation declaration of the referenced contract must protect + /// (`true`), or must not protect (`false`), the contract owner from the team, its + /// `ownerProtected`. Either value implies elected moderation: a contract with no + /// moderation, or with moderation of another kind, meets neither. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner_protected: Option, +} + +/// The write of a referring document, what a contract reference's requirements are checked +/// against beside the referenced contract itself. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub struct ReferringWrite { + /// The `$ownerId` of the referring document: the owner of the transition writing it. + pub owner_id: Identifier, + /// The time of the block writing it. + pub block_time_ms: TimestampMillis, +} + +/// Who a `contract` reference may require to own the referenced contract, relative to the +/// writer of the referring document. +#[derive( + Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize, Encode, Decode, DecodeUntrusted, +)] +pub enum ContractReferenceOwner { + /// The writer itself: the contract's owner is the `$ownerId` of the referring document, + /// a write gate like the `$ownerId` property agreement of a document reference. + #[serde(rename = "self")] + Writer, + /// Anyone but the writer. + #[serde(rename = "other")] + Other, +} + +impl ContractReferenceOwner { + /// The wire name, the value of `contractRequirements.owner`. + pub fn as_str(&self) -> &'static str { + match self { + ContractReferenceOwner::Writer => "self", + ContractReferenceOwner::Other => "other", + } + } + + /// The owner relation a wire name names, `None` for any other name. + pub fn from_wire_name(name: &str) -> Option { + match name { + "self" => Some(ContractReferenceOwner::Writer), + "other" => Some(ContractReferenceOwner::Other), + _ => None, + } + } + + /// Whether `contract`, owned as it is, meets this for a referring document owned by + /// `writer_id`. + pub fn is_met_by(&self, contract: &DataContract, writer_id: &Identifier) -> bool { + match self { + ContractReferenceOwner::Writer => contract.owner_id() == *writer_id, + ContractReferenceOwner::Other => contract.owner_id() != *writer_id, + } + } +} + +/// The moderation a `contract` reference may require of the referenced contract. +#[derive( + Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize, Encode, Decode, DecodeUntrusted, +)] +#[serde(rename_all = "camelCase")] +pub enum ContractReferenceModeration { + /// The contract declares an elected moderation team (`ContractModerators::Elected`), + /// whatever its interim, whether a team is seated yet and whether its election delay + /// has passed. A charter proposal declares this, so teams can form during the notice + /// the contract gives before its first election. + Elected, + /// The contract declares an elected moderation team whose own `electionDelay`, counted + /// from the contract's creation, has passed at the block time of the write, or which + /// declares no delay. The delay is the contract's, not the reference's: the charter + /// that opens the contest declares this and carries no number. + ElectionOpen, +} + +impl ContractReferenceModeration { + /// The wire name, the value of `contractRequirements.moderation`. + pub fn as_str(&self) -> &'static str { + match self { + ContractReferenceModeration::Elected => "elected", + ContractReferenceModeration::ElectionOpen => "electionOpen", + } + } + + /// The wire names, for the message that refuses another. + pub const WIRE_NAMES: &'static [&'static str] = &["elected", "electionOpen"]; + + /// The moderation a wire name names, `None` for any other name. + pub fn from_wire_name(name: &str) -> Option { + match name { + "elected" => Some(ContractReferenceModeration::Elected), + "electionOpen" => Some(ContractReferenceModeration::ElectionOpen), + _ => None, + } + } + + /// How the requirement reads after "a contract with". + pub fn describe(&self) -> &'static str { + match self { + ContractReferenceModeration::Elected => "elected moderation", + ContractReferenceModeration::ElectionOpen => "its moderation election open", + } + } + + /// Whether `contract` declares what this requires at `block_time_ms`, the time of the + /// block writing the referring document. + pub fn is_met_by(&self, contract: &DataContract, block_time_ms: TimestampMillis) -> bool { + let elected = contract + .config() + .moderation() + .and_then(|moderation| moderation.moderators.elected()); + match self { + ContractReferenceModeration::Elected => elected.is_some(), + ContractReferenceModeration::ElectionOpen => elected.is_some_and(|elected| { + elected.election_is_open(contract.created_at(), block_time_ms) + }), + } + } +} + +/// One requirement of a [`ContractReferenceRequirements`] declaration, named the way the +/// declaration spells it, for the error that reports it unmet. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum ContractReferenceRequirement { + Moderation(ContractReferenceModeration), + MinimumAgeSeconds(u32), + MinimumSecondsSinceUpdate(u32), + Owner(ContractReferenceOwner), + Readonly(bool), + KeepsHistory(bool), + OwnerProtected(bool), +} + +impl ContractReferenceRequirement { + /// The `contractRequirements` key the requirement was declared under. + pub fn field(&self) -> &'static str { + match self { + ContractReferenceRequirement::Moderation(_) => property_names::MODERATION, + ContractReferenceRequirement::MinimumAgeSeconds(_) => { + property_names::MINIMUM_AGE_SECONDS + } + ContractReferenceRequirement::MinimumSecondsSinceUpdate(_) => { + property_names::MINIMUM_SECONDS_SINCE_UPDATE + } + ContractReferenceRequirement::Owner(_) => property_names::OWNER, + ContractReferenceRequirement::Readonly(_) => property_names::READONLY, + ContractReferenceRequirement::KeepsHistory(_) => property_names::KEEPS_HISTORY, + ContractReferenceRequirement::OwnerProtected(_) => property_names::OWNER_PROTECTED, + } + } + + /// The value the declaration requires, as spelled in the schema. + pub fn required(&self) -> String { + match self { + ContractReferenceRequirement::Moderation(moderation) => moderation.as_str().to_string(), + ContractReferenceRequirement::MinimumAgeSeconds(seconds) + | ContractReferenceRequirement::MinimumSecondsSinceUpdate(seconds) => { + seconds.to_string() + } + ContractReferenceRequirement::Owner(owner) => owner.as_str().to_string(), + ContractReferenceRequirement::Readonly(flag) + | ContractReferenceRequirement::KeepsHistory(flag) + | ContractReferenceRequirement::OwnerProtected(flag) => flag.to_string(), + } + } + + /// Whether `contract` meets this requirement for `write`, the write of the referring + /// document. + pub fn is_met_by(&self, contract: &DataContract, write: ReferringWrite) -> bool { + match self { + ContractReferenceRequirement::Moderation(moderation) => { + moderation.is_met_by(contract, write.block_time_ms) + } + ContractReferenceRequirement::MinimumAgeSeconds(seconds) => { + Self::minimum_age_is_met(contract.created_at(), *seconds, write.block_time_ms) + } + ContractReferenceRequirement::MinimumSecondsSinceUpdate(seconds) => { + Self::minimum_age_is_met( + Self::last_change_time(contract), + *seconds, + write.block_time_ms, + ) + } + ContractReferenceRequirement::Owner(owner) => { + owner.is_met_by(contract, &write.owner_id) + } + ContractReferenceRequirement::Readonly(required) => { + contract.config().readonly() == *required + } + ContractReferenceRequirement::KeepsHistory(required) => { + contract.config().keeps_history() == *required + } + // Either value needs an elected declaration to read the flag from: a contract + // without one meets neither + ContractReferenceRequirement::OwnerProtected(required) => contract + .config() + .moderation() + .and_then(|moderation| moderation.moderators.elected()) + .is_some_and(|elected| elected.owner_protected == *required), + } + } + + /// Whether something that happened at `since` is at least `minimum_seconds` in the past + /// at `block_time_ms`. A contract without the recorded time (one created before contracts + /// recorded it) is of unknown age and does not meet any minimum. + pub fn minimum_age_is_met( + since: Option, + minimum_seconds: u32, + block_time_ms: TimestampMillis, + ) -> bool { + let Some(since) = since else { + return false; + }; + let old_enough_at = + since.saturating_add(TimestampMillis::from(minimum_seconds).saturating_mul(1000)); + block_time_ms >= old_enough_at + } + + /// When `contract` last changed: its last update, or its creation for a contract never + /// updated. `None` when it recorded neither. + pub fn last_change_time(contract: &DataContract) -> Option { + match (contract.created_at(), contract.updated_at()) { + (Some(created_at), Some(updated_at)) => Some(created_at.max(updated_at)), + (created_at, updated_at) => updated_at.or(created_at), + } + } +} + +impl ContractReferenceRequirements { + /// Whether the declaration requires nothing beyond the contract's existence. + pub fn is_empty(&self) -> bool { + self.moderation.is_none() + && self.minimum_age_seconds.is_none() + && self.minimum_seconds_since_update.is_none() + && self.owner.is_none() + && self.readonly.is_none() + && self.keeps_history.is_none() + && self.owner_protected.is_none() + } + + /// The requirements, in declaration order. + pub fn requirements(&self) -> impl Iterator + '_ { + self.moderation + .into_iter() + .map(ContractReferenceRequirement::Moderation) + .chain( + self.minimum_age_seconds + .into_iter() + .map(ContractReferenceRequirement::MinimumAgeSeconds), + ) + .chain( + self.minimum_seconds_since_update + .into_iter() + .map(ContractReferenceRequirement::MinimumSecondsSinceUpdate), + ) + .chain( + self.owner + .into_iter() + .map(ContractReferenceRequirement::Owner), + ) + .chain( + self.readonly + .into_iter() + .map(ContractReferenceRequirement::Readonly), + ) + .chain( + self.keeps_history + .into_iter() + .map(ContractReferenceRequirement::KeepsHistory), + ) + .chain( + self.owner_protected + .into_iter() + .map(ContractReferenceRequirement::OwnerProtected), + ) + } + + /// The first requirement `contract` does not meet for `write`, the write of the referring + /// document, `None` when it meets them all. + pub fn first_unmet_by( + &self, + contract: &DataContract, + write: ReferringWrite, + ) -> Option { + self.requirements() + .find(|requirement| !requirement.is_met_by(contract, write)) + } +} + // This enum is embedded in consensus errors, so it is consensus-serialized. // @append_only #[derive( @@ -102,7 +438,15 @@ pub struct ByteArrayPropertySizes { #[serde(rename_all = "lowercase")] pub enum DocumentPropertyReferenceTarget { Identity, - Contract, + /// A data contract, which must exist when the referring document is written and meet the + /// declared [`ContractReferenceRequirements`], if any. + Contract { + #[serde( + default, + skip_serializing_if = "ContractReferenceRequirements::is_empty" + )] + contract_requirements: ContractReferenceRequirements, + }, Token, /// A document of a document type whose documents can never be deleted /// (`canBeDeleted: false`). Only such document types may be referenced: @@ -221,7 +565,7 @@ impl DocumentPropertyReferenceTarget { permanent: false, }), DocumentPropertyReferenceTarget::Identity - | DocumentPropertyReferenceTarget::Contract + | DocumentPropertyReferenceTarget::Contract { .. } | DocumentPropertyReferenceTarget::Token | DocumentPropertyReferenceTarget::IdentityPublicKey { .. } => None, } @@ -267,7 +611,37 @@ impl std::fmt::Display for DocumentPropertyReferenceTarget { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { DocumentPropertyReferenceTarget::Identity => write!(f, "identity"), - DocumentPropertyReferenceTarget::Contract => write!(f, "contract"), + DocumentPropertyReferenceTarget::Contract { + contract_requirements, + } => { + write!(f, "contract")?; + if let Some(moderation) = contract_requirements.moderation { + write!(f, " with {}", moderation.describe())?; + } + if let Some(seconds) = contract_requirements.minimum_age_seconds { + write!(f, " at least {seconds} seconds old")?; + } + if let Some(seconds) = contract_requirements.minimum_seconds_since_update { + write!(f, " unchanged for at least {seconds} seconds")?; + } + match contract_requirements.owner { + Some(ContractReferenceOwner::Writer) => write!(f, " owned by the writer")?, + Some(ContractReferenceOwner::Other) => write!(f, " not owned by the writer")?, + None => {} + } + if contract_requirements.readonly == Some(true) { + write!(f, " read-only")?; + } + if contract_requirements.keeps_history == Some(true) { + write!(f, " keeping history")?; + } + match contract_requirements.owner_protected { + Some(true) => write!(f, " with the owner protected")?, + Some(false) => write!(f, " with the owner unprotected")?, + None => {} + } + Ok(()) + } DocumentPropertyReferenceTarget::Token => write!(f, "token"), DocumentPropertyReferenceTarget::PermanentDocument { contract_id: Some(contract_id), @@ -561,6 +935,39 @@ impl DocumentPropertyType { } } + /// The width every value of this type encodes to as a tree key + /// ([`Self::encode_value_for_tree_keys`]), when that width is fixed: + /// the integer, float, boolean, date and identifier encodings, and a + /// byte array whose bounds pin one size. `None` for strings (their + /// bound counts characters), unbounded or variable-size byte arrays, + /// objects and arrays. The one width the composite indexOnly terminal + /// rules, the walkers and synthesis all split member keys by. + pub fn fixed_tree_key_width(&self) -> Option { + match self { + DocumentPropertyType::U128 | DocumentPropertyType::I128 => Some(16), + DocumentPropertyType::U64 + | DocumentPropertyType::I64 + | DocumentPropertyType::F64 + | DocumentPropertyType::Date => Some(8), + DocumentPropertyType::U32 | DocumentPropertyType::I32 => Some(4), + DocumentPropertyType::U16 | DocumentPropertyType::I16 => Some(2), + DocumentPropertyType::U8 | DocumentPropertyType::I8 | DocumentPropertyType::Boolean => { + Some(1) + } + DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { + Some(32) + } + DocumentPropertyType::ByteArray(sizes) => match (sizes.min_size, sizes.max_size) { + (Some(min), Some(max)) if min == max && min > 0 => Some(min), + _ => None, + }, + DocumentPropertyType::String(_) + | DocumentPropertyType::Object(_) + | DocumentPropertyType::Array(_) + | DocumentPropertyType::VariableTypeArray(_) => None, + } + } + /// The middle size rounded down halfway between min and max size pub fn middle_size(&self, platform_version: &PlatformVersion) -> Option { let min_size = self.min_size()?; @@ -7477,6 +7884,372 @@ mod tests { ); } + #[test] + fn should_meet_a_minimum_age_from_the_recorded_creation_time_at_the_block_time() { + let created_at: TimestampMillis = 1_700_000_000_000; + let one_hour_ms: TimestampMillis = 3_600_000; + // One millisecond short of the minimum is not old enough; the exact minimum is + assert!(!ContractReferenceRequirement::minimum_age_is_met( + Some(created_at), + 3600, + created_at + one_hour_ms - 1 + )); + assert!(ContractReferenceRequirement::minimum_age_is_met( + Some(created_at), + 3600, + created_at + one_hour_ms + )); + assert!(ContractReferenceRequirement::minimum_age_is_met( + Some(created_at), + 3600, + TimestampMillis::MAX + )); + // A contract that never recorded its creation time is of unknown age + assert!(!ContractReferenceRequirement::minimum_age_is_met( + None, + 1, + TimestampMillis::MAX + )); + // The bound saturates rather than wrapping around into the past + assert!(!ContractReferenceRequirement::minimum_age_is_met( + Some(TimestampMillis::MAX - 1), + u32::MAX, + TimestampMillis::MAX - 1 + )); + + let requirements = ContractReferenceRequirements { + moderation: None, + minimum_age_seconds: Some(3600), + minimum_seconds_since_update: Some(60), + owner: None, + readonly: None, + keeps_history: None, + owner_protected: None, + }; + assert_eq!( + requirements.requirements().collect::>(), + vec![ + ContractReferenceRequirement::MinimumAgeSeconds(3600), + ContractReferenceRequirement::MinimumSecondsSinceUpdate(60) + ] + ); + assert_eq!( + ContractReferenceRequirement::MinimumAgeSeconds(3600).field(), + "minimumAgeSeconds" + ); + assert_eq!( + ContractReferenceRequirement::MinimumAgeSeconds(3600).required(), + "3600" + ); + assert_eq!( + ContractReferenceRequirement::MinimumSecondsSinceUpdate(60).field(), + "minimumSecondsSinceUpdate" + ); + assert_eq!( + ContractReferenceRequirement::MinimumSecondsSinceUpdate(60).required(), + "60" + ); + } + + #[test] + fn should_meet_an_owner_requirement_from_the_referenced_contract_owner_and_the_writer() { + use crate::tests::fixtures::get_dashpay_contract_fixture; + + let platform_version = PlatformVersion::latest(); + let contract = get_dashpay_contract_fixture(None, 0, platform_version.protocol_version) + .data_contract_owned(); + let owner_id = contract.owner_id(); + let someone_else = Identifier::from([0x42; 32]); + assert_ne!(owner_id, someone_else); + + assert!(ContractReferenceOwner::Writer.is_met_by(&contract, &owner_id)); + assert!(!ContractReferenceOwner::Writer.is_met_by(&contract, &someone_else)); + assert!(!ContractReferenceOwner::Other.is_met_by(&contract, &owner_id)); + assert!(ContractReferenceOwner::Other.is_met_by(&contract, &someone_else)); + + assert_eq!(ContractReferenceOwner::Writer.as_str(), "self"); + assert_eq!(ContractReferenceOwner::Other.as_str(), "other"); + assert_eq!( + ContractReferenceOwner::from_wire_name("self"), + Some(ContractReferenceOwner::Writer) + ); + assert_eq!( + ContractReferenceOwner::from_wire_name("other"), + Some(ContractReferenceOwner::Other) + ); + assert_eq!(ContractReferenceOwner::from_wire_name("owner"), None); + + let requirement = ContractReferenceRequirement::Owner(ContractReferenceOwner::Writer); + assert_eq!(requirement.field(), "owner"); + assert_eq!(requirement.required(), "self"); + assert_eq!( + ContractReferenceRequirement::Owner(ContractReferenceOwner::Other).required(), + "other" + ); + + // The first unmet requirement is reported in declaration order: an owner requirement + // is checked after the moderation and duration ones + let requirements = ContractReferenceRequirements { + owner: Some(ContractReferenceOwner::Other), + ..Default::default() + }; + assert_eq!( + requirements.requirements().collect::>(), + vec![ContractReferenceRequirement::Owner( + ContractReferenceOwner::Other + )] + ); + let by_owner = ReferringWrite { + owner_id, + block_time_ms: 0, + }; + let by_someone_else = ReferringWrite { + owner_id: someone_else, + block_time_ms: 0, + }; + assert_eq!( + requirements.first_unmet_by(&contract, by_owner), + Some(ContractReferenceRequirement::Owner( + ContractReferenceOwner::Other + )) + ); + assert_eq!( + requirements.first_unmet_by(&contract, by_someone_else), + None + ); + let both = ContractReferenceRequirements { + minimum_age_seconds: Some(1), + owner: Some(ContractReferenceOwner::Other), + ..Default::default() + }; + assert_eq!( + both.first_unmet_by(&contract, by_owner), + Some(ContractReferenceRequirement::MinimumAgeSeconds(1)) + ); + } + + #[test] + fn should_meet_a_config_flag_requirement_from_the_referenced_contract_config() { + use crate::data_contract::accessors::v0::DataContractV0Setters; + use crate::data_contract::config::moderation::{ + ContractModerationConfig, ContractModerators, ElectedModerators, InterimModerators, + ModerationAbility, DEFAULT_ELECTION_WINDOW_SECONDS, + }; + use crate::data_contract::config::v0::DataContractConfigSettersV0; + use crate::tests::fixtures::get_dashpay_contract_fixture; + use std::collections::BTreeSet; + + let platform_version = PlatformVersion::latest(); + let mut contract = get_dashpay_contract_fixture(None, 0, platform_version.protocol_version) + .data_contract_owned(); + let write = ReferringWrite { + owner_id: Identifier::from([0x42; 32]), + block_time_ms: 0, + }; + let readonly = ContractReferenceRequirement::Readonly(true); + let keeps_history = ContractReferenceRequirement::KeepsHistory(true); + let protected = ContractReferenceRequirement::OwnerProtected(true); + let unprotected = ContractReferenceRequirement::OwnerProtected(false); + + // The fixture is neither read-only nor keeping history, and declares no moderation: + // it meets none of the flags, whichever value the owner protection requires + assert!(!readonly.is_met_by(&contract, write)); + assert!(!keeps_history.is_met_by(&contract, write)); + assert!(!protected.is_met_by(&contract, write)); + assert!(!unprotected.is_met_by(&contract, write)); + + let mut config = contract.config().clone(); + config.set_readonly(true); + config.set_keeps_history(true); + contract.set_config(config); + assert!(readonly.is_met_by(&contract, write)); + assert!(keeps_history.is_met_by(&contract, write)); + + // Appointed moderation still has no owner protection to read + contract.set_config(contract.config().clone().with_moderation(Some( + ContractModerationConfig { + banlist: true, + suspensions: false, + warnings: false, + moderators: ContractModerators::AppointedModerators(BTreeSet::from([ + Identifier::from([0x77; 32]), + ])), + }, + ))); + assert!(!protected.is_met_by(&contract, write)); + assert!(!unprotected.is_met_by(&contract, write)); + + for owner_protected in [true, false] { + contract.set_config(contract.config().clone().with_moderation(Some( + ContractModerationConfig { + banlist: true, + suspensions: false, + warnings: false, + moderators: ContractModerators::Elected(Box::new(ElectedModerators { + join_window: DEFAULT_ELECTION_WINDOW_SECONDS, + vote_window: DEFAULT_ELECTION_WINDOW_SECONDS, + challenge_cool_down: 1_209_600, + election_delay: None, + moderated_document_types: BTreeMap::from([( + "profile".to_string(), + BTreeSet::from([ModerationAbility::Ban]), + )]), + interim: InterimModerators::ContractOwner, + owner_protected, + })), + }, + ))); + assert_eq!(protected.is_met_by(&contract, write), owner_protected); + assert_eq!(unprotected.is_met_by(&contract, write), !owner_protected); + } + + assert_eq!(readonly.field(), "readonly"); + assert_eq!(readonly.required(), "true"); + assert_eq!(keeps_history.field(), "keepsHistory"); + assert_eq!(keeps_history.required(), "true"); + assert_eq!(protected.field(), "ownerProtected"); + assert_eq!(protected.required(), "true"); + assert_eq!(unprotected.required(), "false"); + + // The contract is left read-only, keeping history and with its owner unprotected: + // requiring the protection is the one requirement it does not meet + let requirements = ContractReferenceRequirements { + readonly: Some(true), + keeps_history: Some(true), + owner_protected: Some(true), + ..Default::default() + }; + assert!(!requirements.is_empty()); + assert_eq!( + requirements.requirements().collect::>(), + vec![readonly, keeps_history, protected] + ); + assert_eq!( + requirements.first_unmet_by(&contract, write), + Some(protected) + ); + let met = ContractReferenceRequirements { + owner_protected: Some(false), + ..requirements + }; + assert_eq!(met.first_unmet_by(&contract, write), None); + } + + #[test] + fn should_meet_election_open_when_the_contract_declares_no_delay_or_the_delay_passed() { + use crate::data_contract::accessors::v0::DataContractV0Setters; + use crate::data_contract::accessors::v1::DataContractV1Setters; + use crate::data_contract::config::moderation::{ + ContractModerationConfig, ContractModerators, ElectedModerators, InterimModerators, + ModerationAbility, DEFAULT_ELECTION_WINDOW_SECONDS, + }; + use crate::tests::fixtures::get_dashpay_contract_fixture; + use std::collections::BTreeSet; + + let platform_version = PlatformVersion::latest(); + let mut contract = get_dashpay_contract_fixture(None, 0, platform_version.protocol_version) + .data_contract_owned(); + let created_at: TimestampMillis = 1_700_000_000_000; + contract.set_created_at(Some(created_at)); + + let elected = ContractReferenceModeration::Elected; + let open = ContractReferenceModeration::ElectionOpen; + + // No moderation at all: neither is met + assert!(!elected.is_met_by(&contract, created_at)); + assert!(!open.is_met_by(&contract, created_at)); + + let declare = |contract: &mut DataContract, election_delay: Option| { + let config = + contract + .config() + .clone() + .with_moderation(Some(ContractModerationConfig { + banlist: true, + suspensions: false, + warnings: false, + moderators: ContractModerators::Elected(Box::new(ElectedModerators { + join_window: DEFAULT_ELECTION_WINDOW_SECONDS, + vote_window: DEFAULT_ELECTION_WINDOW_SECONDS, + challenge_cool_down: 1_209_600, + election_delay, + moderated_document_types: BTreeMap::from([( + "profile".to_string(), + BTreeSet::from([ModerationAbility::Ban]), + )]), + interim: InterimModerators::ContractOwner, + owner_protected: false, + })), + })); + contract.set_config(config); + }; + + // Elected without a delay: open at once + declare(&mut contract, None); + assert!(elected.is_met_by(&contract, created_at)); + assert!(open.is_met_by(&contract, created_at)); + + // Elected with a delay: elected at once, open once the delay passed + declare(&mut contract, Some(3600)); + assert!(elected.is_met_by(&contract, created_at)); + assert!(!open.is_met_by(&contract, created_at + 3_599_999)); + assert!(open.is_met_by(&contract, created_at + 3_600_000)); + + // A delay on a contract of unknown age never opens + contract.set_created_at(None); + assert!(!open.is_met_by(&contract, TimestampMillis::MAX)); + + assert_eq!( + ContractReferenceModeration::from_wire_name("electionOpen"), + Some(ContractReferenceModeration::ElectionOpen) + ); + assert_eq!( + ContractReferenceModeration::ElectionOpen.as_str(), + "electionOpen" + ); + assert_eq!( + ContractReferenceRequirement::Moderation(ContractReferenceModeration::ElectionOpen) + .required(), + "electionOpen" + ); + } + + #[test] + fn should_take_the_last_change_time_from_the_later_of_creation_and_update() { + use crate::data_contract::accessors::v1::DataContractV1Setters; + use crate::tests::fixtures::get_dashpay_contract_fixture; + + let platform_version = PlatformVersion::latest(); + let mut contract = get_dashpay_contract_fixture(None, 0, platform_version.protocol_version) + .data_contract_owned(); + + contract.set_created_at(None); + contract.set_updated_at(None); + assert_eq!( + ContractReferenceRequirement::last_change_time(&contract), + None + ); + + contract.set_created_at(Some(1_000)); + assert_eq!( + ContractReferenceRequirement::last_change_time(&contract), + Some(1_000) + ); + + contract.set_updated_at(Some(5_000)); + assert_eq!( + ContractReferenceRequirement::last_change_time(&contract), + Some(5_000) + ); + + // A recorded update alone counts as the last change + contract.set_created_at(None); + assert_eq!( + ContractReferenceRequirement::last_change_time(&contract), + Some(5_000) + ); + } + #[test] fn should_display_reference_targets() { let contract_id = Identifier::from([7u8; 32]); @@ -7486,9 +8259,116 @@ mod tests { "identity" ); assert_eq!( - DocumentPropertyReferenceTarget::Contract.to_string(), + DocumentPropertyReferenceTarget::Contract { + contract_requirements: Default::default() + } + .to_string(), "contract" ); + assert_eq!( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + moderation: Some(ContractReferenceModeration::Elected), + minimum_age_seconds: None, + minimum_seconds_since_update: None, + owner: None, + readonly: None, + keeps_history: None, + owner_protected: None, + }, + } + .to_string(), + "contract with elected moderation" + ); + assert_eq!( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + moderation: Some(ContractReferenceModeration::Elected), + minimum_age_seconds: Some(604_800), + minimum_seconds_since_update: Some(86_400), + owner: None, + readonly: None, + keeps_history: None, + owner_protected: None, + }, + } + .to_string(), + "contract with elected moderation at least 604800 seconds old unchanged for at least 86400 seconds" + ); + assert_eq!( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + moderation: None, + minimum_age_seconds: Some(1), + minimum_seconds_since_update: None, + owner: None, + readonly: None, + keeps_history: None, + owner_protected: None, + }, + } + .to_string(), + "contract at least 1 seconds old" + ); + assert_eq!( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + owner: Some(ContractReferenceOwner::Writer), + ..Default::default() + }, + } + .to_string(), + "contract owned by the writer" + ); + assert_eq!( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + moderation: Some(ContractReferenceModeration::Elected), + owner: Some(ContractReferenceOwner::Other), + ..Default::default() + }, + } + .to_string(), + "contract with elected moderation not owned by the writer" + ); + assert_eq!( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + readonly: Some(true), + keeps_history: Some(true), + owner_protected: Some(true), + ..Default::default() + }, + } + .to_string(), + "contract read-only keeping history with the owner protected" + ); + assert_eq!( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + owner: Some(ContractReferenceOwner::Other), + owner_protected: Some(false), + ..Default::default() + }, + } + .to_string(), + "contract not owned by the writer with the owner unprotected" + ); + assert_eq!( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + moderation: Some(ContractReferenceModeration::ElectionOpen), + minimum_age_seconds: None, + minimum_seconds_since_update: None, + owner: None, + readonly: None, + keeps_history: None, + owner_protected: None, + }, + } + .to_string(), + "contract with its moderation election open" + ); assert_eq!(DocumentPropertyReferenceTarget::Token.to_string(), "token"); assert_eq!( DocumentPropertyReferenceTarget::PermanentDocument { @@ -7570,7 +8450,9 @@ mod tests { fn reference_targets_are_exhaustively_mirrored() { let targets = [ DocumentPropertyReferenceTarget::Identity, - DocumentPropertyReferenceTarget::Contract, + DocumentPropertyReferenceTarget::Contract { + contract_requirements: Default::default(), + }, DocumentPropertyReferenceTarget::Token, DocumentPropertyReferenceTarget::PermanentDocument { contract_id: None, @@ -7591,7 +8473,7 @@ mod tests { // No `_ =>` arm: a new variant is a compile error. let json_tag = match target { DocumentPropertyReferenceTarget::Identity => "identity", - DocumentPropertyReferenceTarget::Contract => "contract", + DocumentPropertyReferenceTarget::Contract { .. } => "contract", DocumentPropertyReferenceTarget::Token => "token", DocumentPropertyReferenceTarget::PermanentDocument { .. } => "permanentDocument", DocumentPropertyReferenceTarget::IdentityPublicKey { .. } => "identityPublicKey", diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs b/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs index 2c9432fdfdb..800e9ee59ea 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs @@ -232,6 +232,11 @@ impl DocumentTypeV2Getters for DocumentTypeV2 { self.index_only } + /// The entry-payload property names of an indexOnly type. + fn entry_payload(&self) -> &BTreeSet { + &self.entry_payload + } + fn documents_can_be_deleted_by_moderators(&self) -> bool { self.documents_can_be_deleted_by_moderators } diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs index 9bfefc4259e..6c5213eaf44 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs @@ -55,6 +55,10 @@ pub struct DocumentTypeV2 { /// keyword, protocol version 14). Once present they are frozen like the /// rest of the list. Every entry is also in `immutable_fields`. pub(in crate::data_contract) immutable_fields_allow_setting: BTreeSet, + /// On an indexOnly type, the top-level properties stored in every entry's + /// value after the row commitment (the `entryPayload` keyword), in name + /// order. Empty on every other type and on every pre-PV14 contract. + pub(in crate::data_contract) entry_payload: BTreeSet, /// Should documents keep history? pub(in crate::data_contract) documents_keep_history: bool, /// Should transfers of documents of this type be recorded in the document @@ -187,6 +191,7 @@ impl From for DocumentTypeV2 { transient_fields: value.transient_fields, immutable_fields: BTreeSet::new(), immutable_fields_allow_setting: BTreeSet::new(), + entry_payload: BTreeSet::new(), documents_keep_history: value.documents_keep_history, documents_keep_transfer_history: value.documents_keep_transfer_history, documents_keep_purchase_history: value.documents_keep_purchase_history, @@ -232,6 +237,7 @@ impl From for DocumentTypeV2 { transient_fields: value.transient_fields, immutable_fields: BTreeSet::new(), immutable_fields_allow_setting: BTreeSet::new(), + entry_payload: BTreeSet::new(), documents_keep_history: value.documents_keep_history, documents_keep_transfer_history: value.documents_keep_transfer_history, documents_keep_purchase_history: value.documents_keep_purchase_history, diff --git a/packages/rs-dpp/src/data_contract/serialized_version/mod.rs b/packages/rs-dpp/src/data_contract/serialized_version/mod.rs index 8f74c5261fb..be673b9a914 100644 --- a/packages/rs-dpp/src/data_contract/serialized_version/mod.rs +++ b/packages/rs-dpp/src/data_contract/serialized_version/mod.rs @@ -1,8 +1,8 @@ use super::EMPTY_KEYWORDS; use crate::data_contract::accessors::v0::DataContractV0Getters; use crate::data_contract::associated_token::token_configuration::TokenConfiguration; +use crate::data_contract::config::moderation::document_schema_lets_moderators_delete; use crate::data_contract::config::DataContractConfig; -use crate::data_contract::document_type::property_names::CAN_BE_DELETED_BY_MODERATORS; use crate::data_contract::group::Group; use crate::data_contract::serialized_version::v0::DataContractInSerializationFormatV0; use crate::data_contract::serialized_version::v1::DataContractInSerializationFormatV1; @@ -147,13 +147,9 @@ impl DataContractInSerializationFormat { /// moderation declaration is validated before the contract is parsed, since a /// declaration that keeps no list is only valid with such a document type. pub fn has_document_type_deletable_by_moderators(&self) -> bool { - self.document_schemas().values().any(|schema| { - schema - .get_optional_bool(CAN_BE_DELETED_BY_MODERATORS) - .ok() - .flatten() - .unwrap_or(false) - }) + self.document_schemas() + .values() + .any(document_schema_lets_moderators_delete) } pub fn document_schemas_mut(&mut self) -> &mut BTreeMap { @@ -547,6 +543,7 @@ mod tests { banlist: true, suspensions: false, moderators: Default::default(), + warnings: false, })); contract.set_config(config); contract diff --git a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs index ff38bb0995c..3eb229a6ebf 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -14,6 +14,7 @@ use crate::consensus::basic::contract_group::{ use crate::consensus::basic::contract_moderation::{ ContractModerationReasonTooLongError, ContractModerationSelfTargetError, DocumentActionFeesWithoutModerationError, InvalidContractModerationConfigError, + InvalidContractModerationReasonDocumentsError, }; use crate::consensus::basic::data_contract::data_contract_max_depth_exceed_error::DataContractMaxDepthExceedError; use crate::consensus::basic::data_contract::{ @@ -800,6 +801,10 @@ pub enum BasicError { // Document action fees (protocol version 14). #[error(transparent)] DocumentActionFeesWithoutModerationError(DocumentActionFeesWithoutModerationError), + + // Documents cited by a contract moderation reason (protocol version 14). + #[error(transparent)] + InvalidContractModerationReasonDocumentsError(InvalidContractModerationReasonDocumentsError), } impl From for ConsensusError { @@ -870,12 +875,20 @@ mod tests { )), 191 ); - // Document action fees (protocol version 14): the tail of the enum. + // Document action fees (protocol version 14). assert_eq!( discriminant_of(BasicError::DocumentActionFeesWithoutModerationError( DocumentActionFeesWithoutModerationError::new("post".to_string()) )), 192 ); + // Documents cited by a contract moderation reason (protocol version 14): the tail of + // the enum. + assert_eq!( + discriminant_of(BasicError::InvalidContractModerationReasonDocumentsError( + InvalidContractModerationReasonDocumentsError::new("x".to_string()) + )), + 193 + ); } } diff --git a/packages/rs-dpp/src/errors/consensus/basic/contract_moderation/invalid_contract_moderation_reason_documents_error.rs b/packages/rs-dpp/src/errors/consensus/basic/contract_moderation/invalid_contract_moderation_reason_documents_error.rs new file mode 100644 index 00000000000..bdcb0c3574f --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/contract_moderation/invalid_contract_moderation_reason_documents_error.rs @@ -0,0 +1,53 @@ +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use thiserror::Error; + +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "The documents a contract moderation reason cites are invalid: {}", + message +)] +#[platform_serialize(unversioned)] +pub struct InvalidContractModerationReasonDocumentsError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + message: String, +} + +impl InvalidContractModerationReasonDocumentsError { + pub fn new(message: String) -> Self { + Self { message } + } + + pub fn message(&self) -> &str { + &self.message + } +} + +impl From for ConsensusError { + fn from(err: InvalidContractModerationReasonDocumentsError) -> Self { + Self::BasicError(BasicError::InvalidContractModerationReasonDocumentsError( + err, + )) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/basic/contract_moderation/mod.rs b/packages/rs-dpp/src/errors/consensus/basic/contract_moderation/mod.rs index 68274deaa41..e3b1a190ee7 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/contract_moderation/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/contract_moderation/mod.rs @@ -2,8 +2,10 @@ mod contract_moderation_reason_too_long_error; mod contract_moderation_self_target_error; mod document_action_fees_without_moderation_error; mod invalid_contract_moderation_config_error; +mod invalid_contract_moderation_reason_documents_error; pub use contract_moderation_reason_too_long_error::*; pub use contract_moderation_self_target_error::*; pub use document_action_fees_without_moderation_error::*; pub use invalid_contract_moderation_config_error::*; +pub use invalid_contract_moderation_reason_documents_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index 53a39b681f2..8048f541b18 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -269,6 +269,7 @@ impl ErrorWithCode for BasicError { Self::ContractModerationSelfTargetError(_) => 10901, Self::DocumentActionFeesWithoutModerationError(_) => 10902, Self::ContractModerationReasonTooLongError(_) => 10903, + Self::InvalidContractModerationReasonDocumentsError(_) => 10904, } } } @@ -357,6 +358,7 @@ impl ErrorWithCode for StateError { Self::DocumentActionFeeAgreementNotSetError(_) => 40132, Self::DocumentActionFeeAgreementMismatchError(_) => 40133, Self::DocumentActionFeeMultiplierNotToleratedError(_) => 40134, + Self::ReferencedContractRequirementNotMetError(_) => 40135, // Identity Errors: 40200-40299 Self::IdentityAlreadyExistsError(_) => 40200, @@ -390,6 +392,7 @@ impl ErrorWithCode for StateError { Self::MasternodeVoteAlreadyPresentError(_) => 40304, Self::MasternodeIncorrectVotingAddressError(_) => 40305, Self::MasternodeIncorrectVoterIdentityIdError(_) => 40306, + Self::VoteChoiceNotAllowedForVotePollError(_) => 40307, // Prefunded specialized balances Errors: 40400-40499 Self::PrefundedSpecializedBalanceInsufficientError(_) => 40400, @@ -467,6 +470,15 @@ impl ErrorWithCode for StateError { Self::ContractModerationCounterpartyBarredError(_) => 41114, Self::DocumentTypeNotDeletableByModeratorsError(_) => 41115, Self::DocumentModerationWindowElapsedError(_) => 41116, + Self::ContractUserNotWarnedError(_) => 41117, + Self::ContractUserWarningLimitReachedError(_) => 41118, + Self::ContractDocumentRemovalNotFoundError(_) => 41119, + Self::DocumentRestoreWindowElapsedError(_) => 41120, + Self::DocumentRestoreHashMismatchError(_) => 41121, + Self::ContractDocumentAlreadyRestoredError(_) => 41122, + + // Contract moderation team errors: 41200-41299 + Self::ContractModeratedDocumentTypeNotYetUsableError(_) => 41200, } } } diff --git a/packages/rs-dpp/src/errors/consensus/state/contract_moderation/contract_document_already_restored_error.rs b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/contract_document_already_restored_error.rs new file mode 100644 index 00000000000..7afcbaa84d1 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/contract_document_already_restored_error.rs @@ -0,0 +1,83 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use crate::identity::TimestampMillis; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "Document {} on contract {} was already restored by {} at {}: it is live", + document_id, + contract_id, + restored_by, + restored_at +)] +#[platform_serialize(unversioned)] +pub struct ContractDocumentAlreadyRestoredError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + contract_id: Identifier, + document_id: Identifier, + restored_by: Identifier, + restored_at: TimestampMillis, +} + +impl ContractDocumentAlreadyRestoredError { + pub fn new( + contract_id: Identifier, + document_id: Identifier, + restored_by: Identifier, + restored_at: TimestampMillis, + ) -> Self { + Self { + contract_id, + document_id, + restored_by, + restored_at, + } + } + + pub fn contract_id(&self) -> Identifier { + self.contract_id + } + + pub fn document_id(&self) -> Identifier { + self.document_id + } + + /// The contract owner or moderator that restored the document + pub fn restored_by(&self) -> Identifier { + self.restored_by + } + + /// The time of the block that restored it, in milliseconds + pub fn restored_at(&self) -> TimestampMillis { + self.restored_at + } +} + +impl From for ConsensusError { + fn from(err: ContractDocumentAlreadyRestoredError) -> Self { + Self::StateError(StateError::ContractDocumentAlreadyRestoredError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/contract_moderation/contract_document_removal_not_found_error.rs b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/contract_document_removal_not_found_error.rs new file mode 100644 index 00000000000..a9fab02e706 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/contract_document_removal_not_found_error.rs @@ -0,0 +1,72 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "Contract {} keeps no record of a moderator's deletion of {} document {}: there is nothing to restore", + contract_id, + document_type_name, + document_id +)] +#[platform_serialize(unversioned)] +pub struct ContractDocumentRemovalNotFoundError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + contract_id: Identifier, + document_type_name: String, + document_id: Identifier, +} + +impl ContractDocumentRemovalNotFoundError { + pub fn new( + contract_id: Identifier, + document_type_name: String, + document_id: Identifier, + ) -> Self { + Self { + contract_id, + document_type_name, + document_id, + } + } + + pub fn contract_id(&self) -> Identifier { + self.contract_id + } + + pub fn document_type_name(&self) -> &str { + &self.document_type_name + } + + pub fn document_id(&self) -> Identifier { + self.document_id + } +} + +impl From for ConsensusError { + fn from(err: ContractDocumentRemovalNotFoundError) -> Self { + Self::StateError(StateError::ContractDocumentRemovalNotFoundError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/contract_moderation/contract_moderated_document_type_not_yet_usable_error.rs b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/contract_moderated_document_type_not_yet_usable_error.rs new file mode 100644 index 00000000000..0452d271c2c --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/contract_moderated_document_type_not_yet_usable_error.rs @@ -0,0 +1,66 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +/// A document transition on a document type an elected contract moderates, while nobody +/// moderates the contract: its declaration names no interim moderators, and no team is +/// seated yet, so the type can not be used. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "Documents of type {} on contract {} can not be used until a moderation team is seated", + document_type_name, + contract_id +)] +#[platform_serialize(unversioned)] +pub struct ContractModeratedDocumentTypeNotYetUsableError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + contract_id: Identifier, + document_type_name: String, +} + +impl ContractModeratedDocumentTypeNotYetUsableError { + pub fn new(contract_id: Identifier, document_type_name: String) -> Self { + Self { + contract_id, + document_type_name, + } + } + + pub fn contract_id(&self) -> Identifier { + self.contract_id + } + + pub fn document_type_name(&self) -> &str { + &self.document_type_name + } +} + +impl From for ConsensusError { + fn from(err: ContractModeratedDocumentTypeNotYetUsableError) -> Self { + Self::StateError(StateError::ContractModeratedDocumentTypeNotYetUsableError( + err, + )) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/contract_moderation/contract_user_not_warned_error.rs b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/contract_user_not_warned_error.rs new file mode 100644 index 00000000000..4400111ee83 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/contract_user_not_warned_error.rs @@ -0,0 +1,61 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "Identity {} carries no warning on contract {}", + identity_id, + contract_id +)] +#[platform_serialize(unversioned)] +pub struct ContractUserNotWarnedError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + contract_id: Identifier, + identity_id: Identifier, +} + +impl ContractUserNotWarnedError { + pub fn new(contract_id: Identifier, identity_id: Identifier) -> Self { + Self { + contract_id, + identity_id, + } + } + + pub fn contract_id(&self) -> Identifier { + self.contract_id + } + + pub fn identity_id(&self) -> Identifier { + self.identity_id + } +} + +impl From for ConsensusError { + fn from(err: ContractUserNotWarnedError) -> Self { + Self::StateError(StateError::ContractUserNotWarnedError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/contract_moderation/contract_user_warning_limit_reached_error.rs b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/contract_user_warning_limit_reached_error.rs new file mode 100644 index 00000000000..317bd6b6f6c --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/contract_user_warning_limit_reached_error.rs @@ -0,0 +1,68 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "Identity {} already carries {} warnings on contract {}, the most it may at a time; clear them before warning it again", + identity_id, + max_warnings, + contract_id +)] +#[platform_serialize(unversioned)] +pub struct ContractUserWarningLimitReachedError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + contract_id: Identifier, + identity_id: Identifier, + max_warnings: u16, +} + +impl ContractUserWarningLimitReachedError { + pub fn new(contract_id: Identifier, identity_id: Identifier, max_warnings: u16) -> Self { + Self { + contract_id, + identity_id, + max_warnings, + } + } + + pub fn contract_id(&self) -> Identifier { + self.contract_id + } + + pub fn identity_id(&self) -> Identifier { + self.identity_id + } + + pub fn max_warnings(&self) -> u16 { + self.max_warnings + } +} + +impl From for ConsensusError { + fn from(err: ContractUserWarningLimitReachedError) -> Self { + Self::StateError(StateError::ContractUserWarningLimitReachedError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/contract_moderation/document_restore_hash_mismatch_error.rs b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/document_restore_hash_mismatch_error.rs new file mode 100644 index 00000000000..2ed91cba40e --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/document_restore_hash_mismatch_error.rs @@ -0,0 +1,83 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "The document brought back for {} on contract {} hashes to {}, not to the {} its removal record holds", + document_id, + contract_id, + hex::encode(actual_hash), + hex::encode(expected_hash) +)] +#[platform_serialize(unversioned)] +pub struct DocumentRestoreHashMismatchError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + contract_id: Identifier, + document_id: Identifier, + expected_hash: [u8; 32], + actual_hash: [u8; 32], +} + +impl DocumentRestoreHashMismatchError { + pub fn new( + contract_id: Identifier, + document_id: Identifier, + expected_hash: [u8; 32], + actual_hash: [u8; 32], + ) -> Self { + Self { + contract_id, + document_id, + expected_hash, + actual_hash, + } + } + + pub fn contract_id(&self) -> Identifier { + self.contract_id + } + + pub fn document_id(&self) -> Identifier { + self.document_id + } + + /// The hash the removal record holds: of the document as it was serialized when it was + /// removed + pub fn expected_hash(&self) -> [u8; 32] { + self.expected_hash + } + + /// The hash of the document the transition brought back + pub fn actual_hash(&self) -> [u8; 32] { + self.actual_hash + } +} + +impl From for ConsensusError { + fn from(err: DocumentRestoreHashMismatchError) -> Self { + Self::StateError(StateError::DocumentRestoreHashMismatchError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/contract_moderation/document_restore_window_elapsed_error.rs b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/document_restore_window_elapsed_error.rs new file mode 100644 index 00000000000..2fea5e87339 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/document_restore_window_elapsed_error.rs @@ -0,0 +1,92 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use crate::identity::TimestampMillis; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "Document {} on contract {} was removed at {} and could be restored by moderators for {} milliseconds after that, which block time {} is past", + document_id, + contract_id, + removed_at, + window_ms, + block_time +)] +#[platform_serialize(unversioned)] +pub struct DocumentRestoreWindowElapsedError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + contract_id: Identifier, + document_id: Identifier, + removed_at: TimestampMillis, + window_ms: u64, + block_time: TimestampMillis, +} + +impl DocumentRestoreWindowElapsedError { + pub fn new( + contract_id: Identifier, + document_id: Identifier, + removed_at: TimestampMillis, + window_ms: u64, + block_time: TimestampMillis, + ) -> Self { + Self { + contract_id, + document_id, + removed_at, + window_ms, + block_time, + } + } + + pub fn contract_id(&self) -> Identifier { + self.contract_id + } + + pub fn document_id(&self) -> Identifier { + self.document_id + } + + /// The time of the block that removed the document, in milliseconds + pub fn removed_at(&self) -> TimestampMillis { + self.removed_at + } + + /// For how long after the removal, in milliseconds, moderators could restore the document + pub fn window_ms(&self) -> u64 { + self.window_ms + } + + /// The block time the restore was judged at, in milliseconds + pub fn block_time(&self) -> TimestampMillis { + self.block_time + } +} + +impl From for ConsensusError { + fn from(err: DocumentRestoreWindowElapsedError) -> Self { + Self::StateError(StateError::DocumentRestoreWindowElapsedError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/contract_moderation/mod.rs b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/mod.rs index 6300e904ff4..ed29fe122b4 100644 --- a/packages/rs-dpp/src/errors/consensus/state/contract_moderation/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/mod.rs @@ -1,6 +1,9 @@ +mod contract_document_already_restored_error; +mod contract_document_removal_not_found_error; mod contract_fee_claim_not_allowed_error; mod contract_fees_already_claimed_this_epoch_error; mod contract_fees_nothing_to_claim_error; +mod contract_moderated_document_type_not_yet_usable_error; mod contract_moderation_counterparty_barred_error; mod contract_moderation_not_enabled_error; mod contract_moderation_target_not_allowed_error; @@ -11,14 +14,21 @@ mod contract_user_already_banned_error; mod contract_user_banned_error; mod contract_user_not_banned_error; mod contract_user_not_suspended_error; +mod contract_user_not_warned_error; mod contract_user_suspended_error; +mod contract_user_warning_limit_reached_error; mod document_moderation_window_elapsed_error; +mod document_restore_hash_mismatch_error; +mod document_restore_window_elapsed_error; mod document_type_not_deletable_by_moderators_error; mod identity_not_contract_moderator_error; +pub use contract_document_already_restored_error::*; +pub use contract_document_removal_not_found_error::*; pub use contract_fee_claim_not_allowed_error::*; pub use contract_fees_already_claimed_this_epoch_error::*; pub use contract_fees_nothing_to_claim_error::*; +pub use contract_moderated_document_type_not_yet_usable_error::*; pub use contract_moderation_counterparty_barred_error::*; pub use contract_moderation_not_enabled_error::*; pub use contract_moderation_target_not_allowed_error::*; @@ -29,7 +39,11 @@ pub use contract_user_already_banned_error::*; pub use contract_user_banned_error::*; pub use contract_user_not_banned_error::*; pub use contract_user_not_suspended_error::*; +pub use contract_user_not_warned_error::*; pub use contract_user_suspended_error::*; +pub use contract_user_warning_limit_reached_error::*; pub use document_moderation_window_elapsed_error::*; +pub use document_restore_hash_mismatch_error::*; +pub use document_restore_window_elapsed_error::*; pub use document_type_not_deletable_by_moderators_error::*; pub use identity_not_contract_moderator_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/state/document/mod.rs b/packages/rs-dpp/src/errors/consensus/state/document/mod.rs index b889f6bc30c..b22abf538ff 100644 --- a/packages/rs-dpp/src/errors/consensus/state/document/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/state/document/mod.rs @@ -19,6 +19,7 @@ pub mod document_timestamps_are_equal_error; pub mod document_timestamps_mismatch_error; pub mod duplicate_unique_index_error; pub mod invalid_document_revision_error; +pub mod referenced_contract_requirement_not_met_error; pub mod referenced_document_property_agreement_invalid_error; pub mod referenced_document_property_mismatch_error; pub mod referenced_document_type_deletable_error; diff --git a/packages/rs-dpp/src/errors/consensus/state/document/referenced_contract_requirement_not_met_error.rs b/packages/rs-dpp/src/errors/consensus/state/document/referenced_contract_requirement_not_met_error.rs new file mode 100644 index 00000000000..ca08a93caaf --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/document/referenced_contract_requirement_not_met_error.rs @@ -0,0 +1,78 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "referenced contract {contract_id} for path {path} does not meet the reference's requirement {field} {required}" +)] +#[platform_serialize(unversioned)] +pub struct ReferencedContractRequirementNotMetError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + contract_id: Identifier, + field: String, + required: String, + path: String, +} + +impl ReferencedContractRequirementNotMetError { + pub fn new(contract_id: Identifier, field: String, required: String, path: String) -> Self { + Self { + contract_id, + field, + required, + path, + } + } + + /// The referenced contract, which exists but does not meet the requirement + pub fn contract_id(&self) -> &Identifier { + &self.contract_id + } + + /// The `contractRequirements` key of the requirement: `moderation`, `minimumAgeSeconds`, + /// `minimumSecondsSinceUpdate`, `owner`, `readonly`, `keepsHistory` or `ownerProtected` + pub fn field(&self) -> &str { + &self.field + } + + /// The value the reference requires as the schema spells it, `elected` for a moderation + /// requirement, the number of seconds for a duration, `self` or `other` for an owner, + /// `true` or `false` for a config flag + pub fn required(&self) -> &str { + &self.required + } + + /// The referring property + pub fn path(&self) -> &str { + &self.path + } +} + +impl From for ConsensusError { + fn from(err: ReferencedContractRequirementNotMetError) -> Self { + Self::StateError(StateError::ReferencedContractRequirementNotMetError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/state_error.rs b/packages/rs-dpp/src/errors/consensus/state/state_error.rs index a4b2502c0ef..a2793685e97 100644 --- a/packages/rs-dpp/src/errors/consensus/state/state_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/state_error.rs @@ -12,14 +12,18 @@ use crate::consensus::state::shielded::invalid_anchor_error::InvalidAnchorError; use crate::consensus::state::shielded::invalid_shielded_proof_error::InvalidShieldedProofError; use crate::consensus::state::shielded::nullifier_already_spent_error::NullifierAlreadySpentError; use crate::consensus::state::contract_moderation::{ + ContractModeratedDocumentTypeNotYetUsableError, ContractModerationNotEnabledError, ContractModerationTargetNotAllowedError, ContractFeeClaimNotAllowedError, ContractFeesAlreadyClaimedThisEpochError, ContractFeesNothingToClaimError, ContractModerationCounterpartyBarredError, ContractModerationTargetNotFoundError, ContractModeratorIdentityNotFoundError, ContractSuspensionNotInFutureError, ContractUserAlreadyBannedError, ContractUserBannedError, - ContractUserNotBannedError, ContractUserNotSuspendedError, ContractUserSuspendedError, - DocumentModerationWindowElapsedError, DocumentTypeNotDeletableByModeratorsError, + ContractUserNotBannedError, ContractUserNotSuspendedError, ContractUserNotWarnedError, + ContractUserSuspendedError, ContractUserWarningLimitReachedError, + ContractDocumentAlreadyRestoredError, ContractDocumentRemovalNotFoundError, + DocumentModerationWindowElapsedError, DocumentRestoreHashMismatchError, + DocumentRestoreWindowElapsedError, DocumentTypeNotDeletableByModeratorsError, IdentityNotContractModeratorError, }; use crate::consensus::state::contract_group::{ @@ -64,6 +68,7 @@ use crate::consensus::state::document::document_contest_not_required_error::Docu use crate::consensus::state::document::referenced_document_type_deletable_error::ReferencedDocumentTypeDeletableError; use crate::consensus::state::document::referenced_document_type_not_deletable_error::ReferencedDocumentTypeNotDeletableError; use crate::consensus::state::document::referenced_document_type_not_found_error::ReferencedDocumentTypeNotFoundError; +use crate::consensus::state::document::referenced_contract_requirement_not_met_error::ReferencedContractRequirementNotMetError; use crate::consensus::state::document::referenced_entity_not_found_error::ReferencedEntityNotFoundError; use crate::consensus::state::document::referenced_identity_key_disabled_error::ReferencedIdentityKeyDisabledError; use crate::consensus::state::document::referenced_identity_key_not_found_error::ReferencedIdentityKeyNotFoundError; @@ -95,6 +100,7 @@ use crate::consensus::state::voting::masternode_not_found_error::MasternodeNotFo use crate::consensus::state::voting::masternode_vote_already_present_error::MasternodeVoteAlreadyPresentError; use crate::consensus::state::voting::masternode_voted_too_many_times::MasternodeVotedTooManyTimesError; use crate::consensus::state::voting::vote_poll_not_available_for_voting_error::VotePollNotAvailableForVotingError; +use crate::consensus::state::voting::vote_choice_not_allowed_for_vote_poll_error::VoteChoiceNotAllowedForVotePollError; use crate::consensus::state::voting::vote_poll_not_found_error::VotePollNotFoundError; use super::document::document_timestamps_are_equal_error::DocumentTimestampsAreEqualError; @@ -550,6 +556,37 @@ pub enum StateError { // The moderators' deletion window (protocol version 14). #[error(transparent)] DocumentModerationWindowElapsedError(DocumentModerationWindowElapsedError), + + // The warning list (protocol version 14). + #[error(transparent)] + ContractUserNotWarnedError(ContractUserNotWarnedError), + + #[error(transparent)] + ContractUserWarningLimitReachedError(ContractUserWarningLimitReachedError), + // The moderators' restore of a deleted document (protocol version 14). + #[error(transparent)] + ContractDocumentRemovalNotFoundError(ContractDocumentRemovalNotFoundError), + + #[error(transparent)] + DocumentRestoreWindowElapsedError(DocumentRestoreWindowElapsedError), + + #[error(transparent)] + DocumentRestoreHashMismatchError(DocumentRestoreHashMismatchError), + + #[error(transparent)] + ContractDocumentAlreadyRestoredError(ContractDocumentAlreadyRestoredError), + + // Elected moderation teams (protocol version 14). + #[error(transparent)] + ContractModeratedDocumentTypeNotYetUsableError(ContractModeratedDocumentTypeNotYetUsableError), + + // Contested indexes resolved without a Lock choice (protocol version 14). + #[error(transparent)] + VoteChoiceNotAllowedForVotePollError(VoteChoiceNotAllowedForVotePollError), + + // Requirements on a referenced contract (protocol version 14). + #[error(transparent)] + ReferencedContractRequirementNotMetError(ReferencedContractRequirementNotMetError), } impl From for ConsensusError { @@ -571,6 +608,9 @@ mod tests { ActionFeePricing, ContractFeePot, DocumentActionFee, }; use crate::tokens::gas_fees_paid_by::GasFeesPaidBy; + use crate::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; + use crate::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; + use crate::voting::vote_polls::VotePoll; use platform_value::Identifier; /// `StateError` is encoded by variant position, so inserting a variant @@ -973,12 +1013,90 @@ mod tests { )), 133 ); - // The moderators' deletion window (protocol version 14): the tail of the enum. + // The moderators' deletion window (protocol version 14). assert_eq!( discriminant_of(StateError::DocumentModerationWindowElapsedError( DocumentModerationWindowElapsedError::new(group_id, identity_id, 1, 2, 3) )), 134 ); + // The warning list (protocol version 14). + assert_eq!( + discriminant_of(StateError::ContractUserNotWarnedError( + ContractUserNotWarnedError::new(group_id, identity_id) + )), + 135 + ); + assert_eq!( + discriminant_of(StateError::ContractUserWarningLimitReachedError( + ContractUserWarningLimitReachedError::new(group_id, identity_id, 16) + )), + 136 + ); + // The moderators' restore of a deleted document (protocol version 14). + assert_eq!( + discriminant_of(StateError::ContractDocumentRemovalNotFoundError( + ContractDocumentRemovalNotFoundError::new( + group_id, + "post".to_string(), + identity_id + ) + )), + 137 + ); + assert_eq!( + discriminant_of(StateError::DocumentRestoreWindowElapsedError( + DocumentRestoreWindowElapsedError::new(group_id, identity_id, 1, 2, 3) + )), + 138 + ); + assert_eq!( + discriminant_of(StateError::DocumentRestoreHashMismatchError( + DocumentRestoreHashMismatchError::new(group_id, identity_id, [1; 32], [2; 32]) + )), + 139 + ); + assert_eq!( + discriminant_of(StateError::ContractDocumentAlreadyRestoredError( + ContractDocumentAlreadyRestoredError::new(group_id, identity_id, identity_id, 4) + )), + 140 + ); + // Elected moderation teams (protocol version 14): the tail of the enum. + assert_eq!( + discriminant_of(StateError::ContractModeratedDocumentTypeNotYetUsableError( + ContractModeratedDocumentTypeNotYetUsableError::new(group_id, "post".to_string()) + )), + 141 + ); + // Contested indexes without a Lock choice (protocol version 14). + assert_eq!( + discriminant_of(StateError::VoteChoiceNotAllowedForVotePollError( + VoteChoiceNotAllowedForVotePollError::new( + VotePoll::ContestedDocumentResourceVotePoll( + ContestedDocumentResourceVotePoll { + contract_id: Identifier::new([7; 32]), + document_type_name: "domain".to_string(), + index_name: "parentNameAndLabel".to_string(), + index_values: vec![], + } + ), + ResourceVoteChoice::Lock, + ) + )), + 142 + ); + // Requirements on a referenced contract (protocol version 14): the tail of the enum. + assert_eq!( + discriminant_of(StateError::ReferencedContractRequirementNotMetError( + ReferencedContractRequirementNotMetError::new( + group_id, + "moderation".to_string(), + "elected".to_string(), + "targetContractId".to_string(), + ) + )), + 143 + ); } } diff --git a/packages/rs-dpp/src/errors/consensus/state/voting/mod.rs b/packages/rs-dpp/src/errors/consensus/state/voting/mod.rs index fcf092f07aa..0414bd7656a 100644 --- a/packages/rs-dpp/src/errors/consensus/state/voting/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/state/voting/mod.rs @@ -3,5 +3,6 @@ pub mod masternode_incorrect_voting_address_error; pub mod masternode_not_found_error; pub mod masternode_vote_already_present_error; pub mod masternode_voted_too_many_times; +pub mod vote_choice_not_allowed_for_vote_poll_error; pub mod vote_poll_not_available_for_voting_error; pub mod vote_poll_not_found_error; diff --git a/packages/rs-dpp/src/errors/consensus/state/voting/vote_choice_not_allowed_for_vote_poll_error.rs b/packages/rs-dpp/src/errors/consensus/state/voting/vote_choice_not_allowed_for_vote_poll_error.rs new file mode 100644 index 00000000000..d3a0a44896c --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/voting/vote_choice_not_allowed_for_vote_poll_error.rs @@ -0,0 +1,59 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use crate::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use crate::voting::vote_polls::VotePoll; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use thiserror::Error; + +/// A vote choice the vote poll does not offer: a contested index resolved by +/// `MasternodeVoteNoLocking` has no Lock choice. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("VotePoll {vote_poll} does not allow the vote choice {vote_choice}")] +#[platform_serialize(unversioned)] +pub struct VoteChoiceNotAllowedForVotePollError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + vote_poll: VotePoll, + vote_choice: ResourceVoteChoice, +} + +impl VoteChoiceNotAllowedForVotePollError { + pub fn new(vote_poll: VotePoll, vote_choice: ResourceVoteChoice) -> Self { + Self { + vote_poll, + vote_choice, + } + } + + pub fn vote_poll(&self) -> &VotePoll { + &self.vote_poll + } + + pub fn vote_choice(&self) -> ResourceVoteChoice { + self.vote_choice + } +} + +impl From for ConsensusError { + fn from(err: VoteChoiceNotAllowedForVotePollError) -> Self { + Self::StateError(StateError::VoteChoiceNotAllowedForVotePollError(err)) + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_user_moderation_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_user_moderation_transition/mod.rs index f9bebd2113a..691212b65df 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_user_moderation_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_user_moderation_transition/mod.rs @@ -159,6 +159,7 @@ mod test { reason: ContractModerationReason { code: Some(u16::MAX), text: "spam".to_string(), + documents: vec![], }, }, ContractUserModerationAction::Unban { @@ -289,6 +290,7 @@ pub(crate) mod json_convertible_tests { reason: ContractModerationReason { code: Some(12), text: "flooding".to_string(), + documents: vec![], }, }, user_fee_increase: 4, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_user_moderation_transition/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_user_moderation_transition/v0/mod.rs index 6190168426c..09542572a15 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_user_moderation_transition/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_user_moderation_transition/v0/mod.rs @@ -54,6 +54,20 @@ pub enum ContractUserModerationAction { #[cfg_attr(feature = "serde-conversion", serde(rename = "identityId"))] identity_id: Identifier, }, + /// Adds a warning, with the block time and `reason`, to the identity's entry on the + /// warning list, which bars it from nothing. Refused once the entry holds + /// `SystemLimits::max_contract_warnings_per_identity` warnings. + Warn { + #[cfg_attr(feature = "serde-conversion", serde(rename = "identityId"))] + identity_id: Identifier, + /// Why, stored with the warning. + reason: ContractModerationReason, + }, + /// Takes the identity off the warning list: every warning it carries goes. + ClearWarnings { + #[cfg_attr(feature = "serde-conversion", serde(rename = "identityId"))] + identity_id: Identifier, + }, /// Deletes a document of a document type that sets `canBeDeletedByModerators`, whoever /// owns it, except the contract owner and the moderators. The document's owner gets no /// storage refund, and a `ContractDocumentRemoval` stays under the contract. @@ -65,6 +79,17 @@ pub enum ContractUserModerationAction { /// Why, stored with the removal record. reason: ContractModerationReason, }, + /// Brings back a document a moderator deleted, as it was: the document serialized under + /// its document type (`Document::serialize`), which must hash to what its removal record + /// holds, within `SystemLimits::contract_document_restore_window_ms` of the removal. The + /// document's id, owner and content are all inside the bytes. The record stays, marked + /// restored; the signer pays for the document's storage, whose refund stays the owner's. + RestoreDocument { + #[cfg_attr(feature = "serde-conversion", serde(rename = "documentTypeName"))] + document_type_name: String, + /// The document as it was serialized when it was removed. + document: BinaryData, + }, } impl Default for ContractUserModerationAction { @@ -84,13 +109,17 @@ impl ContractUserModerationAction { ContractUserModerationAction::Ban { identity_id, .. } | ContractUserModerationAction::Unban { identity_id } | ContractUserModerationAction::Suspend { identity_id, .. } - | ContractUserModerationAction::Unsuspend { identity_id } => Some(*identity_id), - ContractUserModerationAction::DeleteDocument { .. } => None, + | ContractUserModerationAction::Unsuspend { identity_id } + | ContractUserModerationAction::Warn { identity_id, .. } + | ContractUserModerationAction::ClearWarnings { identity_id } => Some(*identity_id), + ContractUserModerationAction::DeleteDocument { .. } + | ContractUserModerationAction::RestoreDocument { .. } => None, } } /// The document a deletion targets, as its document type name and its id. `None` for an - /// action on an identity. + /// action on an identity, and for a restore, which carries the document itself: its id is + /// only known once the bytes are decoded under the document type. pub fn document(&self) -> Option<(&str, Identifier)> { match self { ContractUserModerationAction::DeleteDocument { @@ -102,6 +131,32 @@ impl ContractUserModerationAction { } } + /// The document a restore brings back, as its document type name and its serialized + /// bytes. `None` for every other action. + pub fn restored_document(&self) -> Option<(&str, &[u8])> { + match self { + ContractUserModerationAction::RestoreDocument { + document_type_name, + document, + } => Some((document_type_name.as_str(), document.as_slice())), + _ => None, + } + } + + /// The document type name a deletion or a restore names, `None` for an action on an + /// identity. + pub fn document_type_name(&self) -> Option<&str> { + match self { + ContractUserModerationAction::DeleteDocument { + document_type_name, .. + } + | ContractUserModerationAction::RestoreDocument { + document_type_name, .. + } => Some(document_type_name.as_str()), + _ => None, + } + } + /// The suspension end for a suspend, `None` otherwise. pub fn until(&self) -> Option { match self { @@ -110,15 +165,18 @@ impl ContractUserModerationAction { } } - /// The reason a ban, a suspend or a document deletion carries, `None` for an action that - /// takes an identity off a list. + /// The reason a ban, a suspend, a warn or a document deletion carries, `None` for an + /// action that takes an identity off a list. pub fn reason(&self) -> Option<&ContractModerationReason> { match self { ContractUserModerationAction::Ban { reason, .. } | ContractUserModerationAction::Suspend { reason, .. } + | ContractUserModerationAction::Warn { reason, .. } | ContractUserModerationAction::DeleteDocument { reason, .. } => Some(reason), ContractUserModerationAction::Unban { .. } - | ContractUserModerationAction::Unsuspend { .. } => None, + | ContractUserModerationAction::Unsuspend { .. } + | ContractUserModerationAction::ClearWarnings { .. } + | ContractUserModerationAction::RestoreDocument { .. } => None, } } @@ -129,7 +187,10 @@ impl ContractUserModerationAction { ContractUserModerationAction::Unban { .. } => "unban", ContractUserModerationAction::Suspend { .. } => "suspend", ContractUserModerationAction::Unsuspend { .. } => "unsuspend", + ContractUserModerationAction::Warn { .. } => "warn", + ContractUserModerationAction::ClearWarnings { .. } => "clearWarnings", ContractUserModerationAction::DeleteDocument { .. } => "deleteDocument", + ContractUserModerationAction::RestoreDocument { .. } => "restoreDocument", } } } @@ -149,9 +210,22 @@ impl fmt::Display for ContractUserModerationAction { } => { write!(f, "delete {} document {}", document_type_name, document_id) } + ContractUserModerationAction::RestoreDocument { + document_type_name, + document, + } => { + write!( + f, + "restore {} document of {} bytes", + document_type_name, + document.len() + ) + } ContractUserModerationAction::Ban { identity_id, .. } | ContractUserModerationAction::Unban { identity_id } - | ContractUserModerationAction::Unsuspend { identity_id } => { + | ContractUserModerationAction::Unsuspend { identity_id } + | ContractUserModerationAction::Warn { identity_id, .. } + | ContractUserModerationAction::ClearWarnings { identity_id } => { write!(f, "{} {}", self.name(), identity_id) } } @@ -164,8 +238,9 @@ impl fmt::Display for ContractUserModerationAction { #[cfg(feature = "json-conversion")] impl JsonSafeFields for ContractUserModerationAction {} -/// Edits the banlist or the suspension list of a moderated data contract, or deletes a -/// document of one of its document types that moderators may delete. Signed by the +/// Edits the banlist, the suspension list or the warning list of a moderated data contract, +/// or deletes or restores a document of one of its document types that moderators may +/// delete. Signed by the /// contract owner or a moderator named in the contract's config, with a CRITICAL /// authentication key, under the signer's contract-scoped nonce. #[cfg_attr(feature = "json-conversion", json_safe_fields)] @@ -267,6 +342,7 @@ mod test { let reason = ContractModerationReason { code: Some(4), text: "flooding".to_string(), + documents: vec![], }; let action = ContractUserModerationAction::Suspend { identity_id: target, @@ -285,12 +361,54 @@ mod test { assert_eq!(unban.reason(), None); } + #[test] + fn should_name_the_target_of_a_warning_and_of_its_clearing() { + let target = Identifier::random(); + let reason = ContractModerationReason::from_text("first strike"); + let warn = ContractUserModerationAction::Warn { + identity_id: target, + reason: reason.clone(), + }; + assert_eq!(warn.identity_id(), Some(target)); + assert_eq!(warn.document(), None); + assert_eq!(warn.until(), None); + assert_eq!(warn.reason(), Some(&reason)); + assert_eq!(warn.name(), "warn"); + assert_eq!(warn.to_string(), format!("warn {}", target)); + let clear = ContractUserModerationAction::ClearWarnings { + identity_id: target, + }; + assert_eq!(clear.identity_id(), Some(target)); + assert_eq!(clear.reason(), None); + assert_eq!(clear.name(), "clearWarnings"); + } + + #[cfg(feature = "json-conversion")] + #[test] + fn should_tag_a_warning_on_the_wire() { + let action = ContractUserModerationAction::Warn { + identity_id: Identifier::from([7; 32]), + reason: ContractModerationReason::from_text("spam"), + }; + let json = serde_json::to_value(&action).expect("to json"); + assert_eq!(json["$type"], "warn"); + let back: ContractUserModerationAction = serde_json::from_value(json).expect("from json"); + assert_eq!(back, action); + let clear = ContractUserModerationAction::ClearWarnings { + identity_id: Identifier::from([7; 32]), + }; + let json = serde_json::to_value(&clear).expect("to json"); + assert_eq!(json["$type"], "clearWarnings"); + assert!(json.get("reason").is_none()); + } + #[test] fn should_name_the_document_of_a_deletion() { let document_id = Identifier::random(); let reason = ContractModerationReason { code: Some(2), text: "spam".to_string(), + documents: vec![], }; let action = ContractUserModerationAction::DeleteDocument { document_type_name: "post".to_string(), @@ -309,6 +427,38 @@ mod test { ); } + #[test] + fn should_name_the_document_of_a_restore_by_its_bytes() { + let action = ContractUserModerationAction::RestoreDocument { + document_type_name: "post".to_string(), + document: BinaryData::new(vec![7; 70]), + }; + // A restore carries the document: whose it is, and which id, is inside the bytes. + assert_eq!(action.identity_id(), None); + assert_eq!(action.document(), None); + assert_eq!(action.restored_document(), Some(("post", &[7u8; 70][..]))); + assert_eq!(action.document_type_name(), Some("post")); + assert_eq!(action.until(), None); + assert_eq!(action.reason(), None); + assert_eq!(action.name(), "restoreDocument"); + assert_eq!(action.to_string(), "restore post document of 70 bytes"); + } + + #[cfg(feature = "json-conversion")] + #[test] + fn should_tag_a_restore_on_the_wire() { + let action = ContractUserModerationAction::RestoreDocument { + document_type_name: "post".to_string(), + document: BinaryData::new(vec![7; 70]), + }; + let json = serde_json::to_value(&action).expect("to json"); + assert_eq!(json["$type"], "restoreDocument"); + assert_eq!(json["documentTypeName"], "post"); + assert!(json["document"].is_string(), "the bytes travel as a string"); + let back: ContractUserModerationAction = serde_json::from_value(json).expect("from json"); + assert_eq!(back, action); + } + #[cfg(feature = "json-conversion")] #[test] fn should_tag_a_deletion_on_the_wire() { diff --git a/packages/rs-dpp/src/system_data_contracts.rs b/packages/rs-dpp/src/system_data_contracts.rs index e5504da4d21..66b865d0012 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) + } } } } @@ -202,3 +207,92 @@ mod tests { assert_eq!(v9_bytes.len(), 1758); // this will use a config v1 in serialization with sized_integer_types } } + +#[cfg(all(test, feature = "app-connect-contract", feature = "validation"))] +mod app_connect_tests { + use super::*; + use crate::data_contract::accessors::v0::DataContractV0Getters; + use crate::data_contract::document_type::random_document::CreateRandomDocument; + use crate::data_contract::validate_document::DataContractDocumentValidationMethodsV0; + use crate::document::{Document, DocumentV0Getters, DocumentV0Setters}; + use platform_value::Value; + + fn response(contract: &DataContract) -> Document { + let mut document = contract + .document_type_for_name("loginKeyResponse") + .expect("response type") + .random_document(Some(42), PlatformVersion::latest()) + .expect("response document"); + document.set_properties(BTreeMap::from([ + ( + "appEphemeralPubKeyHash".into(), + Value::Bytes(vec![0x11; 20]), + ), + ("walletEphemeralPubKey".into(), Value::Bytes(vec![0x22; 33])), + ("encryptedPayload".into(), Value::Bytes(vec![0x33; 60])), + ])); + document + } + + #[test] + fn should_validate_app_connect_response_lengths() { + let platform_version = PlatformVersion::latest(); + let contract = load_system_data_contract(SystemDataContract::AppConnect, platform_version) + .expect("system contract"); + for (property, length, expected) in [ + ("appEphemeralPubKeyHash", 19, false), + ("appEphemeralPubKeyHash", 20, true), + ("appEphemeralPubKeyHash", 21, false), + ("walletEphemeralPubKey", 32, false), + ("walletEphemeralPubKey", 33, true), + ("walletEphemeralPubKey", 34, false), + ("encryptedPayload", 59, false), + ("encryptedPayload", 60, true), + ("encryptedPayload", 572, true), + ("encryptedPayload", 573, false), + ] { + let mut document = response(&contract); + document.set(property, Value::Bytes(vec![0x44; length])); + let result = contract + .validate_document("loginKeyResponse", &document, platform_version) + .expect("validation executes"); + assert_eq!( + result.is_valid(), + expected, + "{property}: {length} bytes: {result:?}" + ); + } + } + + #[test] + fn should_require_only_the_three_app_connect_response_properties() { + let platform_version = PlatformVersion::latest(); + let contract = load_system_data_contract(SystemDataContract::AppConnect, platform_version) + .expect("system contract"); + assert_eq!(contract.document_types().len(), 1); + for property in [ + "appEphemeralPubKeyHash", + "walletEphemeralPubKey", + "encryptedPayload", + ] { + let mut document = response(&contract); + document.properties_mut().remove(property); + assert!( + !contract + .validate_document("loginKeyResponse", &document, platform_version) + .expect("validation executes") + .is_valid(), + "{property} is required" + ); + } + let mut document = response(&contract); + document.set("contractId", Value::Bytes(vec![0x55; 32])); + assert!( + !contract + .validate_document("loginKeyResponse", &document, platform_version) + .expect("validation executes") + .is_valid(), + "the former contractId field is not admitted" + ); + } +} diff --git a/packages/rs-dpp/src/validation/meta_validators/mod.rs b/packages/rs-dpp/src/validation/meta_validators/mod.rs index f02d0cc5add..097c3b3fe4c 100644 --- a/packages/rs-dpp/src/validation/meta_validators/mod.rs +++ b/packages/rs-dpp/src/validation/meta_validators/mod.rs @@ -339,6 +339,71 @@ mod tests { } } + #[test] + fn should_accept_contract_requirements_on_a_contract_refers_to_in_v3_document_schema() { + for requirements in [ + json!({ "moderation": "elected" }), + json!({ "moderation": "electionOpen" }), + json!({ "minimumAgeSeconds": 1 }), + json!({ "minimumAgeSeconds": 4294967295u64 }), + json!({ "minimumSecondsSinceUpdate": 86400 }), + json!({ "moderation": "elected", "minimumAgeSeconds": 604800, "minimumSecondsSinceUpdate": 86400 }), + json!({ "owner": "self" }), + json!({ "owner": "other" }), + json!({ "moderation": "elected", "owner": "other" }), + json!({ "readonly": true }), + json!({ "keepsHistory": true }), + json!({ "ownerProtected": true }), + json!({ "ownerProtected": false }), + json!({ "moderation": "elected", "owner": "other", "readonly": true, "keepsHistory": true, "ownerProtected": false }), + ] { + let schema = document_schema_with_refers_to(json!({ + "type": "contract", + "contractRequirements": requirements + })); + + assert!( + DOCUMENT_META_SCHEMA_V3.validate(&schema).is_ok(), + "expected contractRequirements {requirements} to be valid" + ); + } + } + + #[test] + fn should_reject_malformed_contract_requirements_in_v3_document_schema() { + for refers_to in [ + json!({ "type": "contract", "contractRequirements": {} }), + json!({ "type": "contract", "contractRequirements": { "moderation": "appointed" } }), + json!({ "type": "contract", "contractRequirements": { "minimumAgeSeconds": 0 } }), + json!({ "type": "contract", "contractRequirements": { "minimumAgeSeconds": 4294967296u64 } }), + json!({ "type": "contract", "contractRequirements": { "minimumAgeSeconds": "3600" } }), + json!({ "type": "contract", "contractRequirements": { "minimumAgeSeconds": 1.5 } }), + json!({ "type": "contract", "contractRequirements": { "minimumSecondsSinceUpdate": 0 } }), + json!({ "type": "contract", "contractRequirements": { "minimumSecondsSinceUpdate": "60" } }), + json!({ "type": "contract", "contractRequirements": { "tokens": "any" } }), + json!({ "type": "contract", "contractRequirements": { "owner": "anyone" } }), + json!({ "type": "contract", "contractRequirements": { "owner": true } }), + json!({ "type": "identity", "contractRequirements": { "minimumAgeSeconds": 3600 } }), + json!({ "type": "identity", "contractRequirements": { "owner": "self" } }), + json!({ "type": "contract", "contractRequirements": { "readonly": false } }), + json!({ "type": "contract", "contractRequirements": { "readonly": "true" } }), + json!({ "type": "contract", "contractRequirements": { "readonly": 1 } }), + json!({ "type": "contract", "contractRequirements": { "keepsHistory": false } }), + json!({ "type": "contract", "contractRequirements": { "keepsHistory": "true" } }), + json!({ "type": "contract", "contractRequirements": { "ownerProtected": "true" } }), + json!({ "type": "contract", "contractRequirements": { "ownerProtected": 1 } }), + json!({ "type": "contract", "contractRequirements": { "ownerProtected": null } }), + json!({ "type": "identity", "contractRequirements": { "readonly": true } }), + ] { + let schema = document_schema_with_refers_to(refers_to.clone()); + + assert!( + DOCUMENT_META_SCHEMA_V3.validate(&schema).is_err(), + "expected refersTo {refers_to} 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/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..2af05278cba 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,18 @@ impl Platform { ); } + // The app-connect contract activates with protocol version 14, the same + // way: a chain born at 14 registers it at genesis, an older chain gets it + // from `transition_to_version_14`. Mainnet and testnet replay v0, and every + // chain born at 9 to 13 is an ephemeral network, so the branch changes no + // genesis a live node reproduces. + 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, @@ -116,6 +128,8 @@ mod tests { mod create_genesis_state { use crate::config::PlatformConfig; use crate::test::helpers::setup::TestPlatformBuilder; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::data_contracts::SystemDataContract; use drive::config::DriveConfig; use platform_version::version::{PlatformVersion, INITIAL_PROTOCOL_VERSION}; @@ -147,5 +161,53 @@ mod tests { "dc5b0d4be407428adda2315db7d782e64015cbe2d2b7df963f05622390dc3c9f" ) } + + /// 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 (platform_version, expected) in [ + (PlatformVersion::get(13).expect("protocol 13"), false), + (PlatformVersion::latest(), true), + ] { + let initial_protocol_version = platform_version.protocol_version; + let platform = TestPlatformBuilder::new() + .with_initial_protocol_version(initial_protocol_version) + .build_with_mock_rpc() + .set_genesis_state(); + + let stored = platform + .drive + .fetch_contract( + 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_eq!(stored.contract.document_types().len(), 1); + } + } + } } } diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/mod.rs index 20fa1e72758..7bdecf92795 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/mod.rs @@ -88,6 +88,7 @@ mod tests { use crate::test::helpers::setup::TestPlatformBuilder; use dpp::block::block_info::BlockInfo; use dpp::block::epoch::Epoch; + use dpp::data_contracts::SystemDataContract; #[test] fn test_perform_events_when_version_method_is_none() { @@ -171,4 +172,117 @@ mod tests { _ => panic!("expected UnknownVersionMismatch error"), } } + + #[test] + fn should_rollback_and_retry_app_connect_registration_through_the_upgrade_dispatcher() { + let previous_version = PlatformVersion::get(13).expect("protocol 13"); + let platform_version = PlatformVersion::latest(); + let platform = TestPlatformBuilder::new() + .with_initial_protocol_version(13) + .build_with_mock_rpc() + .set_genesis_state(); + let platform_state = platform.state.load(); + let block_info = BlockInfo { + time_ms: 1_000_000, + height: 100, + core_height: 100, + epoch: Epoch::new(1).expect("epoch"), + }; + let id = SystemDataContract::AppConnect.id(); + let original_root = platform + .drive + .grove + .root_hash(None, &previous_version.drive.grove_version) + .unwrap() + .expect("original root"); + let mut first_candidate_root = None; + + for commit in [false, true] { + // The block proposal path clears this cache before retrying a candidate. + platform.drive.cache.data_contracts.clear_block_cache(); + let transaction = platform.drive.grove.start_transaction(); + platform + .perform_events_on_first_block_of_protocol_change( + &platform_state, + &block_info, + &transaction, + 13, + platform_version, + ) + .expect("protocol upgrade through the dispatcher"); + assert_eq!( + platform + .drive + .fetch_contract_version(id.to_buffer(), Some(&transaction), platform_version) + .expect("registered version"), + Some(1) + ); + let candidate_root = platform + .drive + .grove + .root_hash(Some(&transaction), &platform_version.drive.grove_version) + .unwrap() + .expect("candidate root"); + assert_ne!(candidate_root, original_root); + // Compiling the new schema must not expose it to old-version cache readers. + assert!(platform + .drive + .cache + .system_data_contracts + .find_by_id(id, platform_version) + .expect("new cache lookup") + .is_some()); + assert!(platform + .drive + .cache + .system_data_contracts + .find_by_id(id, previous_version) + .expect("old cache lookup") + .is_none()); + if commit { + assert_eq!( + Some(candidate_root), + first_candidate_root, + "retry after rollback must produce the same state" + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("commit retry"); + platform + .drive + .cache + .data_contracts + .merge_and_clear_block_cache(); + } else { + first_candidate_root = Some(candidate_root); + drop(transaction); + platform.drive.cache.data_contracts.clear_block_cache(); + assert_eq!( + platform + .drive + .grove + .root_hash(None, &previous_version.drive.grove_version) + .unwrap() + .expect("committed root after rollback"), + original_root + ); + assert!(platform + .drive + .fetch_contract(id.to_buffer(), None, None, None, previous_version) + .value + .expect("old committed state") + .is_none()); + } + } + assert_eq!( + platform + .drive + .fetch_contract_version(id.to_buffer(), None, platform_version) + .expect("committed contract version"), + Some(1) + ); + } } 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 d95899c4124..5820f88c0c3 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 response gets one system + // contract id on every network from this version. Fresh + // chains register it at genesis (`create_genesis_state` v2). + 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,102 @@ mod tests { assert!(profile.iter().any(|p| p == "shieldedAddress")); } + #[test] + fn should_insert_app_connect_on_transition_to_version_14() { + 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::latest(); + 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_eq!(stored.contract.document_types().len(), 1); + + // 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-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/mod.rs index 71c8cf00da6..7b69dfc9b2e 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/mod.rs @@ -8,6 +8,7 @@ use dpp::version::PlatformVersion; use drive::grovedb::TransactionArg; mod v0; +mod v1; impl Platform where @@ -35,9 +36,16 @@ where transaction, platform_version, ), + 1 => self.check_for_ended_vote_polls_v1( + last_committed_platform_state, + block_platform_state, + block_info, + transaction, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "check_for_ended_vote_polls".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/v1/mod.rs new file mode 100644 index 00000000000..39f9378b96b --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/v1/mod.rs @@ -0,0 +1,195 @@ +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::platform_types::platform_state::PlatformState; +use crate::rpc::core::CoreRPCLike; +use dpp::block::block_info::BlockInfo; +use dpp::document::DocumentV0Getters; +use dpp::prelude::TimestampMillis; +use dpp::version::PlatformVersion; +use dpp::voting::contender_structs::FinalizedContender; +use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice::TowardsIdentity; +use dpp::voting::vote_info_storage::contested_document_vote_poll_winner_info::ContestedDocumentVotePollWinnerInfo; +use drive::drive::votes::resolved::vote_polls::resolve::VotePollResolver; +use drive::drive::votes::resolved::vote_polls::{ResolvedVotePoll, ResolvedVotePollWithVotes}; +use drive::grovedb::TransactionArg; +use drive::query::VotePollsByEndDateDriveQuery; +use itertools::Itertools; +use std::collections::BTreeMap; + +impl Platform +where + C: CoreRPCLike, +{ + /// Checks for ended vote polls, awarding a tie to the earliest contender + #[inline(always)] + pub(super) fn check_for_ended_vote_polls_v1( + &self, + _last_committed_platform_state: &PlatformState, + block_platform_state: &PlatformState, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + // let's start by getting the vote polls that have finished. Version 0 carried two + // testnet clean-ups of protocol versions 1 and 2; this version only runs from + // protocol version 14, so it fetches the polls due by the block time and nothing else + let distribute_after_time = block_info.time_ms; + let maximum_vote_polls_to_process = platform_version + .drive_abci + .validation_and_processing + .event_constants + .maximum_vote_polls_to_process; + let vote_polls_by_timestamp = + VotePollsByEndDateDriveQuery::execute_no_proof_for_specialized_end_time_query( + distribute_after_time, + maximum_vote_polls_to_process, + &self.drive, + transaction, + &mut vec![], + platform_version, + )?; + + // Check if awarding is disabled + let vote_polls_with_info = vote_polls_by_timestamp.into_iter().map(|(end_date, vote_polls)| { + let vote_polls_with_votes = vote_polls.into_iter().map(|vote_poll| { + let resolved_vote_poll = + vote_poll.resolve(&self.drive, transaction, platform_version)?; + match resolved_vote_poll { + ResolvedVotePoll::ContestedDocumentResourceVotePollWithContractInfo( + resolved_contested_document_resource_vote_poll, + ) => { + let document_type = + resolved_contested_document_resource_vote_poll.document_type()?; + // let's see who actually won + let result = self.tally_votes_for_contested_document_resource_vote_poll( + (&resolved_contested_document_resource_vote_poll).into(), + transaction, + platform_version, + )?; + let contenders = result.contenders; + // For each contender if there vote_tally is 1 or more we need to get their votes + // We don't do this for contenders with 0 votes, as there is no point. + + let sorted_contenders: Vec<_> = contenders + .into_iter() + .sorted_by(|a, b| Ord::cmp(&b.final_vote_tally, &a.final_vote_tally)) + .collect(); + + let (contenders_with_votes, contenders_with_no_votes): (Vec<_>, Vec<_>) = sorted_contenders.iter().partition(|a| a.final_vote_tally > 0); + + let fetch_contenders = contenders_with_votes + .iter() + .map(|contender| contender.identity_id) + .collect::>(); + let mut other_contenders = if contenders_with_no_votes.is_empty() + { + BTreeMap::new() + } else { + // Other contenders are only those with no votes + contenders_with_no_votes.into_iter().map(|contender| (TowardsIdentity(contender.identity_id), vec![])).collect() + }; + + // We need to get the votes of the sorted contenders + let mut identifiers_voting_for_contenders = + self.drive.fetch_identities_voting_for_contenders( + &resolved_contested_document_resource_vote_poll, + fetch_contenders, + true, + transaction, + platform_version, + )?; + + identifiers_voting_for_contenders.append(&mut other_contenders); + + let highest_vote_tally = sorted_contenders + .first() + .map(|max_voted_contender| max_voted_contender.final_vote_tally) + .unwrap_or_default(); + // These are all the people who got top votes + let top_contenders: Vec = sorted_contenders + .into_iter() + .filter(|c| c.final_vote_tally == highest_vote_tally) + .take(100) // Limit to the first 100 before the expensive operation + .map(|contender| { + FinalizedContender::try_from_contender_with_serialized_document( + contender, + document_type, + platform_version, + ) + .map_err(Error::Protocol) + }) + .collect::, Error>>()?; + // A tie among the top contenders goes to the earliest document: + // creation time, then block height, then core height, then id. + // Version 0 awarded the latest. + let maybe_top_contender = top_contenders.into_iter().min_by(|a, b| { + a.document + .created_at() + .cmp(&b.document.created_at()) + .then_with(|| { + a.document + .created_at_block_height() + .cmp(&b.document.created_at_block_height()) + }) + .then_with(|| { + a.document + .created_at_core_block_height() + .cmp(&b.document.created_at_core_block_height()) + }) + .then_with(|| a.document.id().cmp(&b.document.id())) + }); + // We award the document to the top contender + let winner_info = if let Some(top_contender) = maybe_top_contender { + // let's check to make sure the lock votes didn't win it + // if the lock is tied with the top contender the top contender gets it + if result.locked_vote_tally > top_contender.final_vote_tally { + // the record will show it's locked + ContestedDocumentVotePollWinnerInfo::Locked + } else { + let contender_id = top_contender.identity_id; + // We award the document to the winner of the vote poll + self.award_document_to_winner( + block_info, + top_contender, + &resolved_contested_document_resource_vote_poll, + transaction, + platform_version, + )?; + ContestedDocumentVotePollWinnerInfo::WonByIdentity(contender_id) + } + } else { + ContestedDocumentVotePollWinnerInfo::NoWinner + }; + // We want to keep a record of how everyone voted + self.keep_record_of_finished_contested_resource_vote_poll( + block_platform_state, + block_info, + &resolved_contested_document_resource_vote_poll, + &identifiers_voting_for_contenders, + winner_info, + transaction, + platform_version, + )?; + Ok(ResolvedVotePollWithVotes::ContestedDocumentResourceVotePollWithContractInfoAndVotes(resolved_contested_document_resource_vote_poll, identifiers_voting_for_contenders)) + } + } + }).collect::, Error>>()?; + Ok((end_date, vote_polls_with_votes)) + }).collect::>, Error>>() +?; + + // We need to clean up the vote polls + // This means removing it and also removing all current votes + if !vote_polls_with_info.is_empty() { + self.clean_up_after_vote_polls_end( + block_info, + &vote_polls_with_info, + false, + transaction, + platform_version, + )?; + } + + Ok(()) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/check_tx_verification/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/check_tx_verification/v0/mod.rs index cee314e883d..fa35112e4d7 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/check_tx_verification/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/check_tx_verification/v0/mod.rs @@ -584,6 +584,21 @@ pub(super) fn state_transition_to_execution_event_for_check_tx_v0<'a, C: CoreRPC action }; + // Advanced structure validation judged the action fee agreements of a batch on + // the first check only. The amounts its contracts declare and the epoch's fee + // multiplier can move while it waits, and a block would refuse it; the + // transformer read both again, so the agreements are judged again off the + // action, and a batch that no longer covers them leaves the mempool. + if let StateTransitionAction::BatchAction(batch_action) = &action { + if let Err(error) = batch_action.validate_action_fee_agreements()? { + return Ok( + ConsensusValidationResult::>::new_with_errors( + vec![error], + ), + ); + } + } + let execution_event = ExecutionEvent::create_from_state_transition_action( action, maybe_identity, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v0/mod.rs index 2fcad7be280..13b1e85e620 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v0/mod.rs @@ -204,20 +204,25 @@ pub(super) fn process_state_transition_v0<'a, C: CoreRPCLike>( } } - // The prefunded_balances are currently not used as we would only use them for a masternode vote - // however the masternode vote acts as a free operation, as it is paid for - let _prefunded_balances = if state_transition.uses_prefunded_specialized_balance_for_payment() { - Some( - state_transition.validate_minimum_prefunded_specialized_balance_pre_check( - platform.drive, - transaction, - &mut state_transition_execution_context, - platform_version, - )?, - ) - } else { - None - }; + // A masternode vote is paid by its vote poll's prefunded specialized balance, never by the + // voter. When that fund does not exist or cannot cover the vote, nobody can be charged for + // the vote, so it is refused unpaid: proposers strip it from their block and other + // validators reject a block that carries it, exactly like a vote that fails its nonce check. + // Until 4.2 the pre-check ran here but its result was never read, and such a vote failed + // inside execution, when its cost was deducted, as an internal error; both outcomes keep the + // vote out of every block and no chain ever held one, so acting on it is not versioned. + if state_transition.uses_prefunded_specialized_balance_for_payment() { + let result = state_transition.validate_minimum_prefunded_specialized_balance_pre_check( + platform.drive, + transaction, + &mut state_transition_execution_context, + platform_version, + )?; + + if !result.is_valid() { + return Ok(ConsensusValidationResult::::new_with_errors(result.errors)); + } + } // Validate minimum fee for shielded spending transitions (stateless, uses public value_balance). // This is cheaper than proof verification so we check it first. @@ -354,3 +359,328 @@ pub(super) fn process_state_transition_v0<'a, C: CoreRPCLike>( ) }) } + +#[cfg(test)] +mod tests { + use crate::execution::validation::state_transition::state_transitions::tests::{ + create_dpns_identity_name_contest, dpns_name_vote_poll, serialized_dpns_name_vote, + setup_masternode_voting_identity, + }; + use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; + use assert_matches::assert_matches; + use dpp::block::block_info::BlockInfo; + use dpp::consensus::state::state_error::StateError; + use dpp::consensus::ConsensusError; + use dpp::identifier::Identifier; + use dpp::identity::accessors::IdentityGettersV0; + use dpp::identity::{Identity, IdentityPublicKey}; + use dpp::prelude::DataContract; + use dpp::version::PlatformVersion; + use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; + use simple_signer::signer::SimpleSigner; + use std::sync::Arc; + + const NAME: &str = "quantum"; + + /// A DPNS name contest whose vote poll's fund the test then tampers with, and one + /// masternode ready to vote on it. + struct Contest { + platform: TempPlatform, + dpns_contract: Arc, + contender: Identity, + fund_id: Identifier, + voter: Voter, + } + + struct Voter { + pro_tx_hash: Identifier, + identity: Identity, + signer: SimpleSigner, + voting_key: IdentityPublicKey, + } + + async fn contest_at(platform_version: &PlatformVersion) -> Contest { + let mut platform = TestPlatformBuilder::new() + .with_initial_protocol_version(platform_version.protocol_version) + .build_with_mock_rpc() + .set_genesis_state(); + let platform_state = platform.state.load(); + let (contender, _, dpns_contract) = create_dpns_identity_name_contest( + &mut platform, + &platform_state, + 7, + NAME, + platform_version, + ) + .await; + let fund_id = dpns_name_vote_poll(&dpns_contract, NAME) + .specialized_balance_id() + .expect("expected the poll's prefunded balance id"); + let voter = Voter::new(&mut platform, 29, platform_version); + Contest { + platform, + dpns_contract, + contender, + fund_id, + voter, + } + } + + impl Voter { + fn new( + platform: &mut TempPlatform, + seed: u64, + platform_version: &PlatformVersion, + ) -> Self { + let (pro_tx_hash, identity, signer, voting_key) = + setup_masternode_voting_identity(platform, seed, platform_version); + Voter { + pro_tx_hash, + identity, + signer, + voting_key, + } + } + } + + impl Contest { + fn fund(&self, platform_version: &PlatformVersion) -> Option { + self.platform + .drive + .fetch_prefunded_specialized_balance( + self.fund_id.to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch the poll's fund") + } + + /// Deletes the poll's fund, as settling the poll does + fn remove_fund(&self, platform_version: &PlatformVersion) { + self.platform + .drive + .empty_prefunded_specialized_balance(self.fund_id, true, None, platform_version) + .expect("expected to remove the poll's fund"); + } + + /// Leaves the poll's fund at `credits` + fn set_fund(&self, credits: u64, platform_version: &PlatformVersion) { + self.remove_fund(platform_version); + self.platform + .drive + .add_prefunded_specialized_balance(self.fund_id, credits, None, platform_version) + .expect("expected to fund the poll"); + assert_eq!(self.fund(platform_version), Some(credits)); + } + + /// Casts the voter's vote for the contender on the poll of `name` through block + /// processing, as a proposer or a validator would, and returns how the block treated it + async fn vote_on( + &mut self, + name: &str, + platform_version: &PlatformVersion, + ) -> StateTransitionExecutionResult { + let serialized_transition = self + .serialized_vote_by(&self.voter, name, platform_version) + .await; + self.process_block(vec![serialized_transition], platform_version) + .remove(0) + } + + /// Processes the transitions as one block and returns how the block treated each + fn process_block( + &mut self, + serialized_transitions: Vec>, + platform_version: &PlatformVersion, + ) -> Vec { + let platform_state = self.platform.state.load(); + let transaction = self.platform.drive.grove.start_transaction(); + let processing_result = self + .platform + .platform + .process_raw_state_transitions( + &serialized_transitions, + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process the votes"); + self.platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the transaction"); + processing_result.into_execution_results() + } + + /// `voter`'s signed vote for the contender on the poll of `name`, as broadcast + async fn serialized_vote_by( + &self, + voter: &Voter, + name: &str, + platform_version: &PlatformVersion, + ) -> Vec { + serialized_dpns_name_vote( + &self.dpns_contract, + ResourceVoteChoice::TowardsIdentity(self.contender.id()), + name, + &voter.signer, + voter.pro_tx_hash, + &voter.voting_key, + 1, + platform_version, + ) + .await + } + + /// `voter`'s identity nonce: 0 until an executed vote bumps it + fn nonce_of(&self, voter: &Voter, platform_version: &PlatformVersion) -> Option { + self.platform + .drive + .fetch_identity_nonce( + voter.identity.id().to_buffer(), + true, + None, + platform_version, + ) + .expect("expected to fetch the voter's nonce") + } + } + + #[tokio::test] + async fn should_refuse_a_vote_unpaid_when_the_poll_has_no_fund() { + let platform_version = PlatformVersion::latest(); + let mut contest = contest_at(platform_version).await; + contest.remove_fund(platform_version); + + let result = contest.vote_on(NAME, platform_version).await; + + assert_matches!( + result, + StateTransitionExecutionResult::UnpaidConsensusError(ConsensusError::StateError( + StateError::PrefundedSpecializedBalanceNotFoundError(error) + )) if *error.balance_id() == contest.fund_id + ); + // Nothing of the vote reached the state + assert_eq!(contest.nonce_of(&contest.voter, platform_version), Some(0)); + assert_eq!(contest.fund(platform_version), None); + } + + fn single_vote_cost(platform_version: &PlatformVersion) -> u64 { + platform_version + .fee_version + .vote_resolution_fund_fees + .contested_document_single_vote_cost + } + + /// The fund is one credit short of the single vote cost that executing the vote deducts. + /// It still covers the vote's minimum fee, the smaller amount the v0 pre-check required, + /// so under v0 the vote passed the pre-check and failed inside execution. + #[tokio::test] + async fn should_refuse_a_vote_unpaid_when_the_poll_fund_is_below_the_single_vote_cost() { + let platform_version = PlatformVersion::latest(); + let mut contest = contest_at(platform_version).await; + let single_vote_cost = single_vote_cost(platform_version); + assert!( + single_vote_cost + > platform_version + .fee_version + .state_transition_min_fees + .masternode_vote, + "the fund must satisfy the v0 threshold for this test to pin the v1 one" + ); + contest.set_fund(single_vote_cost - 1, platform_version); + + let result = contest.vote_on(NAME, platform_version).await; + + assert_matches!( + result, + StateTransitionExecutionResult::UnpaidConsensusError(ConsensusError::StateError( + StateError::PrefundedSpecializedBalanceInsufficientError(error) + )) if *error.balance_id() == contest.fund_id + && error.balance() == single_vote_cost - 1 + && error.required_balance() == single_vote_cost + ); + assert_eq!(contest.nonce_of(&contest.voter, platform_version), Some(0)); + assert_eq!(contest.fund(platform_version), Some(single_vote_cost - 1)); + } + + #[tokio::test] + async fn should_accept_a_vote_when_the_poll_fund_covers_exactly_the_single_vote_cost() { + let platform_version = PlatformVersion::latest(); + let mut contest = contest_at(platform_version).await; + let single_vote_cost = single_vote_cost(platform_version); + contest.set_fund(single_vote_cost, platform_version); + + let result = contest.vote_on(NAME, platform_version).await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(contest.nonce_of(&contest.voter, platform_version), Some(1)); + assert_eq!(contest.fund(platform_version), Some(0)); + } + + /// The fund is read through the block's transaction: a fund that covers exactly one vote + /// lets the first vote of a block in, and the second, whose pre-check sees that deduction, + /// is refused for the credits the first one took. + #[tokio::test] + async fn should_refuse_the_second_vote_of_a_block_once_the_first_took_the_fund() { + let platform_version = PlatformVersion::latest(); + let mut contest = contest_at(platform_version).await; + let single_vote_cost = single_vote_cost(platform_version); + contest.set_fund(single_vote_cost, platform_version); + let second_voter = Voter::new(&mut contest.platform, 31, platform_version); + let first_vote = contest + .serialized_vote_by(&contest.voter, NAME, platform_version) + .await; + let second_vote = contest + .serialized_vote_by(&second_voter, NAME, platform_version) + .await; + + let results = contest.process_block(vec![first_vote, second_vote], platform_version); + + assert_matches!( + results[0], + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_matches!( + &results[1], + StateTransitionExecutionResult::UnpaidConsensusError(ConsensusError::StateError( + StateError::PrefundedSpecializedBalanceInsufficientError(error) + )) if *error.balance_id() == contest.fund_id + && error.balance() == 0 + && error.required_balance() == single_vote_cost + ); + assert_eq!(contest.nonce_of(&contest.voter, platform_version), Some(1)); + assert_eq!(contest.nonce_of(&second_voter, platform_version), Some(0)); + assert_eq!(contest.fund(platform_version), Some(0)); + } + + /// The refusal is not versioned: a chain still on protocol version 13 refuses the vote the + /// same way, and it never entered a block there either. + #[tokio::test] + async fn should_refuse_a_vote_the_same_way_at_protocol_version_13() { + let platform_version = PlatformVersion::get(13).expect("protocol version 13"); + let mut contest = contest_at(platform_version).await; + contest.remove_fund(platform_version); + + let result = contest.vote_on(NAME, platform_version).await; + + assert_matches!( + result, + StateTransitionExecutionResult::UnpaidConsensusError(ConsensusError::StateError( + StateError::PrefundedSpecializedBalanceNotFoundError(error) + )) if *error.balance_id() == contest.fund_id + ); + assert_eq!(contest.nonce_of(&contest.voter, platform_version), Some(0)); + assert_eq!(contest.fund(platform_version), None); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v1/mod.rs index a7b53e53a74..2943b10fa16 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v1/mod.rs @@ -135,7 +135,7 @@ impl DocumentCreateTransitionActionStateValidationV1 for DocumentCreateTransitio .properties .iter() .map(|property| property.name.clone()) - .chain(index.terminal.clone()) + .chain(index.terminal_components().iter().cloned()) .collect(), ), )), 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 f1136808a96..6487aa68c26 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 @@ -12,7 +12,7 @@ use dpp::data_contract::document_type::accessors::{ use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; use dpp::data_contract::document_type::{ is_referring_system_agreement_property, DocumentPropertyReferenceTarget, - DocumentPropertyType, DocumentTypeRef, + DocumentPropertyType, DocumentTypeRef, ReferringWrite, }; use dpp::data_contract::DataContract; use dpp::document::property_names::{CREATOR_ID, OWNER_ID}; @@ -21,6 +21,7 @@ use dpp::errors::consensus::state::document::referenced_document_property_mismat use dpp::errors::consensus::state::document::referenced_document_type_deletable_error::ReferencedDocumentTypeDeletableError; use dpp::errors::consensus::state::document::referenced_document_type_not_deletable_error::ReferencedDocumentTypeNotDeletableError; use dpp::errors::consensus::state::document::referenced_document_type_not_found_error::ReferencedDocumentTypeNotFoundError; +use dpp::errors::consensus::state::document::referenced_contract_requirement_not_met_error::ReferencedContractRequirementNotMetError; 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; @@ -249,7 +250,7 @@ fn validate_document_type_references_v0( is_changed_field(changed, key_id_property) } DocumentPropertyReferenceTarget::Identity - | DocumentPropertyReferenceTarget::Contract + | DocumentPropertyReferenceTarget::Contract { .. } | DocumentPropertyReferenceTarget::Token => false, }; if !is_changed_field(changed, path) && !bound_property_changed { @@ -280,7 +281,9 @@ fn validate_document_type_references_v0( .fetch_identity_revision(referenced_id, true, transaction, platform_version)? .is_some() } - DocumentPropertyReferenceTarget::Contract => { + DocumentPropertyReferenceTarget::Contract { + contract_requirements, + } => { let (fee, referenced_contract) = platform.drive.get_contract_with_fetch_info_and_fee( referenced_id, @@ -297,7 +300,32 @@ fn validate_document_type_references_v0( // The cost is added even if the referenced contract does not exist or was cached execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee)); - referenced_contract.is_some() + match referenced_contract { + None => false, + Some(fetch_info) => { + // The declaration's requirements are checked against the contract + // just fetched and the write itself (its owner and block time), so + // they cost no further read; the first unmet one refuses the write + let write = ReferringWrite { + owner_id, + block_time_ms: block_info.time_ms, + }; + if let Some(requirement) = + contract_requirements.first_unmet_by(&fetch_info.contract, write) + { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedContractRequirementNotMetError::new( + Identifier::from(referenced_id), + requirement.field().to_string(), + requirement.required(), + path.to_string(), + ) + .into(), + )); + } + true + } + } } DocumentPropertyReferenceTarget::Token => { // Token contract info is written for every token when its contract is diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/index_only_batch_entries.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/index_only_batch_entries.rs index 74944ddbde5..f34b8266a8e 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/index_only_batch_entries.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/index_only_batch_entries.rs @@ -116,7 +116,7 @@ impl IndexOnlyBatchEntries { .properties .iter() .map(|property| property.name.clone()) - .chain(index.terminal.clone()) + .chain(index.terminal_components().iter().cloned()) .collect(), ) .into(), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/action_fees.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/action_fees.rs index 590e28c8e0d..8bba9ddf8e8 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/action_fees.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/action_fees.rs @@ -12,6 +12,7 @@ use super::*; mod action_fee_tests { use super::*; + use crate::execution::check_tx::CheckTxLevel::Recheck; use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult::{ PaidConsensusError, SuccessfulExecution, UnpaidConsensusError, }; @@ -69,6 +70,7 @@ mod action_fee_tests { banlist: true, suspensions: false, moderators: ContractModerators::ContractOwner, + warnings: false, }, ))); } @@ -687,6 +689,158 @@ mod action_fee_tests { ); } + #[tokio::test] + async fn should_drop_a_transition_from_the_mempool_once_the_fee_multiplier_outran_its_tolerance( + ) { + // Admitted knowing a multiplier of 1000 and accepting 20% more; the epoch's multiplier + // then moves while the transition waits. A block refuses the transition as a paid + // nonce bump (`should_refuse_a_fee_multiplier_that_rose_beyond_the_tolerance`), so the + // recheck judges the agreement again, off the multiplier read now, and drops it first. + let setup = game( + GasFeesPaidBy::DocumentOwner, + "feeMultiplier", + dash_to_credits!(0.1), + dash_to_credits!(0.1), + None, + ); + let transition = setup + .card_creation_agreeing( + GasFeesPaidBy::DocumentOwner, + setup.card_action_fee_agreement( + DocumentTransitionActionType::Create, + AgreedFeeMultiplier { + known_permille: 1_000, + increase_tolerance_percent: 20, + }, + ), + ) + .await; + assert_eq!(setup.check_tx(&transition), Vec::::new()); + assert_eq!( + setup.check_tx_at(&transition, Recheck), + Vec::::new(), + "nothing moved" + ); + + set_fee_multiplier(&setup, 1_200); + assert_eq!( + setup.check_tx_at(&transition, Recheck), + Vec::::new(), + "a rise within the tolerance keeps the transition" + ); + + set_fee_multiplier(&setup, 1_201); + assert_eq!( + setup.check_tx_at(&transition, Recheck), + vec![DOCUMENT_ACTION_FEE_MULTIPLIER_NOT_TOLERATED], + "a rise beyond the tolerance drops it" + ); + // What the block would have done with it, had the recheck let it through. + let tx = setup.platform.drive.grove.start_transaction(); + assert_eq!( + paid_codes(&setup.process(&transition, &tx)), + vec![DOCUMENT_ACTION_FEE_MULTIPLIER_NOT_TOLERATED] + ); + drop(tx); + + set_fee_multiplier(&setup, 900); + assert_eq!( + setup.check_tx_at(&transition, Recheck), + Vec::::new(), + "a multiplier that fell back is always accepted" + ); + } + + #[tokio::test] + async fn should_drop_a_transition_from_the_mempool_once_the_declared_amounts_changed() { + // A contract update may not change the amounts of a document type yet + // (`validate_action_fees_unchanged`), so the contract is rewritten in Drive as one + // will be once a moderation charter sets the moderators part. The recheck reads the + // contract again through the transformer and judges the agreement against what it + // declares now. + let setup = game( + GasFeesPaidBy::DocumentOwner, + "fixed", + dash_to_credits!(0.1), + dash_to_credits!(0.1), + None, + ); + let transition = setup.card_creation(GasFeesPaidBy::DocumentOwner).await; + assert_eq!(setup.check_tx(&transition), Vec::::new()); + assert_eq!(setup.check_tx_at(&transition, Recheck), Vec::::new()); + + let mut raised = setup.contract.clone(); + declare_action_fees( + &mut raised, + platform_value!({ + "pricing": "fixed", + "create": {"owner": OWNER_PART, "moderators": MODERATORS_PART + 1}, + }), + true, + ); + raised.increment_version(); + setup + .platform + .drive + .update_contract( + &raised, + BlockInfo::default(), + true, + None, + setup.platform_version, + None, + ) + .expect("expected to rewrite the contract with the raised moderators part"); + + assert_eq!( + setup.check_tx_at(&transition, Recheck), + vec![DOCUMENT_ACTION_FEE_AGREEMENT_MISMATCH] + ); + let tx = setup.platform.drive.grove.start_transaction(); + assert_eq!( + paid_codes(&setup.process(&transition, &tx)), + vec![DOCUMENT_ACTION_FEE_AGREEMENT_MISMATCH] + ); + } + + #[tokio::test] + async fn should_drop_a_sponsored_transition_from_the_mempool_once_the_fee_multiplier_outran_its_tolerance( + ) { + // A signer under the fee minimum who relies on the contract owner's sponsorship is + // rechecked against the state in full, on another route through check tx; the + // agreement is judged again on that route too. + let setup = game( + GasFeesPaidBy::ContractOwner, + "feeMultiplier", + dash_to_credits!(0.1), + 0, + None, + ); + let transition = setup + .card_creation_agreeing( + GasFeesPaidBy::ContractOwner, + setup.card_action_fee_agreement( + DocumentTransitionActionType::Create, + AgreedFeeMultiplier { + known_permille: 1_000, + increase_tolerance_percent: 20, + }, + ), + ) + .await; + assert_eq!(setup.check_tx(&transition), Vec::::new()); + assert_eq!(setup.check_tx_at(&transition, Recheck), Vec::::new()); + + set_fee_multiplier(&setup, 1_200); + assert_eq!(setup.check_tx_at(&transition, Recheck), Vec::::new()); + + set_fee_multiplier(&setup, 1_201); + assert_eq!( + setup.check_tx_at(&transition, Recheck), + vec![DOCUMENT_ACTION_FEE_MULTIPLIER_NOT_TOLERATED] + ); + } + #[tokio::test] async fn should_accept_a_fee_multiplier_that_fell() { let setup = game( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs index 0f464ee7ed6..30a8ba16ded 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs @@ -16,6 +16,8 @@ mod creation_tests { use dpp::fee::fee_result::refunds::FeeRefunds; use dpp::fee::fee_result::FeeResult; use dpp::data_contract::accessors::v0::DataContractV0Setters; + use dpp::data_contract::config::v0::DataContractConfigSettersV0; + use dpp::data_contract::config::DataContractConfig; use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; use dpp::data_contract::document_type::restricted_creation::CreationRestrictionMode; use dpp::document::Document; @@ -28,6 +30,8 @@ mod creation_tests { use drive::query::vote_poll_vote_state_query::ContestedDocumentVotePollDriveQueryResultType::DocumentsAndVoteTally; use drive::query::vote_poll_vote_state_query::ResolvedContestedDocumentVotePollDriveQuery; use drive::util::test_helpers::setup_contract; + use crate::test::helpers::setup::TempPlatform; + use crate::rpc::core::MockCoreRPCLike; use crate::execution::validation::state_transition::state_transitions::tests::{add_contender_to_dpns_name_contest, create_dpns_identity_name_contest, create_dpns_name_contest_give_key_info, perform_votes_multi}; use crate::platform_types::platform_state::PlatformStateV0Methods; use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult::PaidConsensusError; @@ -5295,6 +5299,62 @@ mod creation_tests { /// references it since it is the one contract known to exist in state. const REFERENCE_VALIDATION_CONTRACT_REF_CONTRACT_ID: &str = "4Bqs6itzfoDXzmgQibYZQABbqYsXmawVf7SKe3mKDQVd"; + const REFERENCE_VALIDATION_ELECTED_CONTRACT_REF_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-elected-contract-ref.json"; + /// The `id` of the elected-contract-reference fixture: the one contract in state in its + /// tests, and one that declares no moderation, so a reference to it is unmet. + const REFERENCE_VALIDATION_ELECTED_CONTRACT_REF_CONTRACT_ID: &str = + "9k3RE6kHNTsDmyXFwEPpiFQ3ipXfp5FuXGXpQ1rDHDJb"; + const REFERENCE_VALIDATION_ELECTION_OPEN_CONTRACT_REF_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-election-open-contract-ref.json"; + /// The `id` of the election-open-contract-reference fixture: in state in its tests, and + /// one that declares no moderation, so a reference to it is unmet. + const REFERENCE_VALIDATION_ELECTION_OPEN_CONTRACT_REF_CONTRACT_ID: &str = + "FutvNUuQYthkyfNtaEsdJEFsnCQshBohD9GP7NrSUwR"; + const REFERENCE_VALIDATION_AGED_CONTRACT_REF_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-aged-contract-ref.json"; + /// The `id` of the aged-contract-reference fixture (`minimumAgeSeconds: 3600`): written to + /// state without a creation time, so a reference to it is unmet. + const REFERENCE_VALIDATION_AGED_CONTRACT_REF_CONTRACT_ID: &str = + "GbYWKJSr6P7fJqkAdSCNM5kuN2eBewAgYfrMJ22rfhrD"; + const REFERENCE_VALIDATION_UPDATED_CONTRACT_REF_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-updated-contract-ref.json"; + /// The block time the aged- and updated-contract-reference tests write the referring + /// document at + const AGED_REFERENCE_BLOCK_TIME_MS: u64 = 1_700_000_000_000; + /// The minimum age, and minimum time since the last update, the aged- and + /// updated-contract-reference fixtures declare, in milliseconds + const AGED_REFERENCE_MINIMUM_AGE_MS: u64 = 3_600_000; + const REFERENCE_VALIDATION_OWNER_SELF_CONTRACT_REF_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-self-contract-ref.json"; + /// The `id` of the self-owned-contract-reference fixture (`owner: "self"`): owned by an + /// identity that is not the writer, so a reference to it is unmet. + const REFERENCE_VALIDATION_OWNER_SELF_CONTRACT_REF_CONTRACT_ID: &str = + "Gb9ZqQQTAoV7JCUrZhsS4ssZozbE33Suunzhr7RVy2pD"; + const REFERENCE_VALIDATION_OWNER_OTHER_CONTRACT_REF_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-other-contract-ref.json"; + /// The `id` of the other-owned-contract-reference fixture (`owner: "other"`): owned by an + /// identity that is not the writer, so a reference to it is met. + const REFERENCE_VALIDATION_OWNER_OTHER_CONTRACT_REF_CONTRACT_ID: &str = + "EPHoATa8ifeWoviLaxTQRWfsJUHAQUCro6dEkwgjwog7"; + const REFERENCE_VALIDATION_READONLY_CONTRACT_REF_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-readonly-contract-ref.json"; + /// The `id` of the read-only-contract-reference fixture (`readonly: true`): not read-only + /// itself, so a reference to it is unmet. + const REFERENCE_VALIDATION_READONLY_CONTRACT_REF_CONTRACT_ID: &str = + "2FkduiNwoBct7PdxmCRYgLxckj4s8dhx7tXLUUpq74Qk"; + const REFERENCE_VALIDATION_KEEPS_HISTORY_CONTRACT_REF_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-keeps-history-contract-ref.json"; + /// The `id` of the history-keeping-contract-reference fixture (`keepsHistory: true`): not + /// keeping history itself, so a reference to it is unmet. + const REFERENCE_VALIDATION_KEEPS_HISTORY_CONTRACT_REF_CONTRACT_ID: &str = + "7gQQ8BdF9gem9YxBNLd3QW7AmiGYNTTuVLerkiJZ3JCx"; + const REFERENCE_VALIDATION_OWNER_PROTECTED_CONTRACT_REF_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-protected-contract-ref.json"; + /// The `id` of the owner-protected-contract-reference fixture (`ownerProtected: true`): + /// declaring no moderation at all, so a reference to it is unmet. + const REFERENCE_VALIDATION_OWNER_PROTECTED_CONTRACT_REF_CONTRACT_ID: &str = + "6BL67iVAJ5eiVne4qXJGNRGpVqB2b2TKiF1odQ7y2qjn"; const REFERENCE_VALIDATION_TOKEN_REF_CONTRACT_PATH: &str = "tests/supporting_files/contract/reference-validation/reference-validation-contract-token-ref.json"; const REFERENCE_VALIDATION_OPTIONAL_CONTRACT_PATH: &str = @@ -5315,6 +5375,33 @@ mod creation_tests { ) -> StateTransitionExecutionResult where F: FnOnce(&mut Document, &ReferenceTargets), + { + run_reference_validation_creation_with_setup_and_mutator( + contract_path, + BlockInfo::default(), + |_, _, _| Identifier::default(), + |document, targets, _| mutator(document, targets), + ) + .await + } + + /// Like `run_reference_validation_creation_with_mutator`, with a `setup` step that writes + /// whatever else the test needs into state before the contract, given the targets already + /// in state (the writer's identity among them), and hands the mutator an id it produced + /// (a contract's, say). The document is written in a block of `block_info`. + async fn run_reference_validation_creation_with_setup_and_mutator( + contract_path: &str, + block_info: BlockInfo, + setup: S, + mutator: F, + ) -> StateTransitionExecutionResult + where + S: FnOnce( + &mut TempPlatform, + &ReferenceTargets, + &PlatformVersion, + ) -> Identifier, + F: FnOnce(&mut Document, &ReferenceTargets, Identifier), { let platform_version = PlatformVersion::latest(); let mut platform = TestPlatformBuilder::new() @@ -5345,6 +5432,8 @@ mod creation_tests { token_id, }; + let setup_id = setup(&mut platform, &targets, platform_version); + let contract = setup_contract( &platform.drive, contract_path, @@ -5375,7 +5464,7 @@ mod creation_tests { .set_id_for_creation(message, &entropy.0, 2, platform_version) .expect("expected to set the document id"); - mutator(&mut document, &targets); + mutator(&mut document, &targets, setup_id); let documents_batch_create_transition = BatchTransition::new_document_creation_transition_from_document( @@ -5404,7 +5493,7 @@ mod creation_tests { .process_raw_state_transitions( &[documents_batch_create_serialized_transition], &platform_state, - &BlockInfo::default(), + &block_info, &transaction, platform_version, false, @@ -5572,6 +5661,765 @@ mod creation_tests { ); } + /// A contract with an elected moderation team, protecting its owner from the team or not, + /// for a reference that requires one. It is written to state directly, the way the + /// fixtures are, so the moderated type needs no list behind it. + fn insert_elected_contract( + owner_protected: bool, + ) -> impl FnOnce(&mut TempPlatform, &ReferenceTargets, &PlatformVersion) -> Identifier + { + insert_elected_contract_with(None, None, owner_protected) + } + + /// An elected contract with the given election delay, recorded creation time and owner + /// protection, written to state directly, the way the fixtures are. + fn insert_elected_contract_with( + election_delay: Option, + created_at: Option, + owner_protected: bool, + ) -> impl FnOnce(&mut TempPlatform, &ReferenceTargets, &PlatformVersion) -> Identifier + { + move |platform, _targets, _platform_version| { + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::data_contract::accessors::v1::DataContractV1Setters; + use dpp::data_contract::config::moderation::{ + ContractModerationConfig, ContractModerators, ElectedModerators, InterimModerators, + ModerationAbility, DEFAULT_ELECTION_WINDOW_SECONDS, + }; + use std::collections::{BTreeMap, BTreeSet}; + + let contract = setup_contract( + &platform.drive, + REFERENCE_VALIDATION_CONTRACT_REF_CONTRACT_PATH, + Some([0xE1; 32]), + None, + Some(|contract: &mut DataContract| { + contract.set_created_at(created_at); + contract.set_config(contract.config().clone().with_moderation(Some( + ContractModerationConfig { + banlist: true, + suspensions: true, + warnings: false, + moderators: ContractModerators::Elected(Box::new(ElectedModerators { + join_window: DEFAULT_ELECTION_WINDOW_SECONDS, + vote_window: DEFAULT_ELECTION_WINDOW_SECONDS, + challenge_cool_down: 1_209_600, + election_delay, + moderated_document_types: BTreeMap::from([( + "message".to_string(), + BTreeSet::from([ModerationAbility::Ban]), + )]), + interim: InterimModerators::ContractOwner, + owner_protected, + })), + }, + ))); + }), + None, + None, + ); + contract.id() + } + } + + /// A contract with the id `contract_id` whose config `configure` adjusts, for a reference + /// that requires something of the config. Written to state directly, the way the fixtures + /// are. + fn insert_contract_configured( + contract_id: [u8; 32], + configure: impl FnOnce(&mut DataContractConfig), + ) -> impl FnOnce(&mut TempPlatform, &ReferenceTargets, &PlatformVersion) -> Identifier + { + move |platform, _targets, _platform_version| { + use dpp::data_contract::accessors::v0::DataContractV0Getters; + + let contract = setup_contract( + &platform.drive, + REFERENCE_VALIDATION_CONTRACT_REF_CONTRACT_PATH, + Some(contract_id), + None, + Some(|contract: &mut DataContract| { + let mut config = contract.config().clone(); + configure(&mut config); + contract.set_config(config); + }), + None, + None, + ); + contract.id() + } + } + #[tokio::test] + async fn should_document_creation_fail_when_election_open_contract_is_not_elected() { + // The fixture contract itself exists in state and declares no moderation at all + let existing_contract_id = Identifier::from_string( + REFERENCE_VALIDATION_ELECTION_OPEN_CONTRACT_REF_CONTRACT_ID, + Encoding::Base58, + ) + .expect("expected a valid contract id"); + + let result = run_reference_validation_creation_with_mutator( + REFERENCE_VALIDATION_ELECTION_OPEN_CONTRACT_REF_CONTRACT_PATH, + |document, _| { + document.set("refContractId", existing_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedContractRequirementNotMetError(ref e)), + .. + } if e.contract_id() == &existing_contract_id + && e.field() == "moderation" + && e.required() == "electionOpen" + && e.path() == "refContractId" + ); + } + + #[tokio::test] + async fn should_document_creation_succeed_when_elected_contract_declares_no_election_delay() { + // No delay: the election is open from the contract's creation, whether or not the + // creation time is recorded + let result = run_reference_validation_creation_with_setup_and_mutator( + REFERENCE_VALIDATION_ELECTION_OPEN_CONTRACT_REF_CONTRACT_PATH, + BlockInfo::default(), + insert_elected_contract_with(None, None, false), + |document, _, elected_contract_id| { + document.set("refContractId", elected_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_the_election_delay_has_not_passed() { + // Created one millisecond less than its own delay before the block + let result = run_reference_validation_creation_with_setup_and_mutator( + REFERENCE_VALIDATION_ELECTION_OPEN_CONTRACT_REF_CONTRACT_PATH, + aged_reference_block_info(), + insert_elected_contract_with( + Some(3600), + Some(AGED_REFERENCE_BLOCK_TIME_MS - AGED_REFERENCE_MINIMUM_AGE_MS + 1), + false, + ), + |document, _, elected_contract_id| { + document.set("refContractId", elected_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedContractRequirementNotMetError(ref e)), + .. + } if e.contract_id() == &Identifier::from([0xE1; 32]) + && e.field() == "moderation" + && e.required() == "electionOpen" + && e.path() == "refContractId" + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_a_delayed_contract_has_no_creation_time() { + // A delay on a contract of unknown age never opens + let result = run_reference_validation_creation_with_setup_and_mutator( + REFERENCE_VALIDATION_ELECTION_OPEN_CONTRACT_REF_CONTRACT_PATH, + aged_reference_block_info(), + insert_elected_contract_with(Some(1), None, false), + |document, _, elected_contract_id| { + document.set("refContractId", elected_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedContractRequirementNotMetError(ref e)), + .. + } if e.field() == "moderation" && e.required() == "electionOpen" + ); + } + + #[tokio::test] + async fn should_document_creation_succeed_when_the_election_delay_has_passed() { + // Created exactly its own delay before the block + let result = run_reference_validation_creation_with_setup_and_mutator( + REFERENCE_VALIDATION_ELECTION_OPEN_CONTRACT_REF_CONTRACT_PATH, + aged_reference_block_info(), + insert_elected_contract_with( + Some(3600), + Some(AGED_REFERENCE_BLOCK_TIME_MS - AGED_REFERENCE_MINIMUM_AGE_MS), + false, + ), + |document, _, elected_contract_id| { + document.set("refContractId", elected_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_referenced_contract_is_not_elected_moderated() { + // The fixture contract itself exists in state and declares no moderation at all + let existing_contract_id = Identifier::from_string( + REFERENCE_VALIDATION_ELECTED_CONTRACT_REF_CONTRACT_ID, + Encoding::Base58, + ) + .expect("expected a valid contract id"); + + let result = run_reference_validation_creation_with_mutator( + REFERENCE_VALIDATION_ELECTED_CONTRACT_REF_CONTRACT_PATH, + |document, _| { + document.set("refContractId", existing_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedContractRequirementNotMetError(ref e)), + .. + } if e.contract_id() == &existing_contract_id + && e.field() == "moderation" + && e.required() == "elected" + && e.path() == "refContractId" + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_required_elected_contract_missing() { + // A missing contract is still reported as missing, not as unmet + let result = run_reference_validation_creation_with_mutator( + REFERENCE_VALIDATION_ELECTED_CONTRACT_REF_CONTRACT_PATH, + |document, _| { + document.set("refContractId", Identifier::random().into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedEntityNotFoundError(_)), + .. + } + ); + } + + #[tokio::test] + async fn should_document_creation_succeed_when_referenced_contract_is_elected_moderated() { + let result = run_reference_validation_creation_with_setup_and_mutator( + REFERENCE_VALIDATION_ELECTED_CONTRACT_REF_CONTRACT_PATH, + BlockInfo::default(), + insert_elected_contract(false), + |document, _, elected_contract_id| { + document.set("refContractId", elected_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + /// A contract created at `created_at`, for a reference that requires a minimum age. It is + /// written to state directly, the way the fixtures are, with the creation time a contract + /// create transition would have recorded. + fn insert_contract_created_at( + created_at: Option, + ) -> impl FnOnce(&mut TempPlatform, &ReferenceTargets, &PlatformVersion) -> Identifier + { + insert_contract_with_times(created_at, None) + } + + /// A contract created at `created_at` and last updated at `updated_at`, the times the + /// create and update transitions would have recorded, written to state directly. + fn insert_contract_with_times( + created_at: Option, + updated_at: Option, + ) -> impl FnOnce(&mut TempPlatform, &ReferenceTargets, &PlatformVersion) -> Identifier + { + move |platform, _targets, _platform_version| { + use dpp::data_contract::accessors::v1::DataContractV1Setters; + + let contract = setup_contract( + &platform.drive, + REFERENCE_VALIDATION_CONTRACT_REF_CONTRACT_PATH, + Some([0xA6; 32]), + None, + Some(|contract: &mut DataContract| { + contract.set_created_at(created_at); + contract.set_updated_at(updated_at); + }), + None, + None, + ); + contract.id() + } + } + + fn aged_reference_block_info() -> BlockInfo { + BlockInfo { + time_ms: AGED_REFERENCE_BLOCK_TIME_MS, + ..Default::default() + } + } + + #[tokio::test] + async fn should_document_creation_fail_when_referenced_contract_is_too_young() { + // Created one millisecond less than the minimum age before the block + let result = run_reference_validation_creation_with_setup_and_mutator( + REFERENCE_VALIDATION_AGED_CONTRACT_REF_CONTRACT_PATH, + aged_reference_block_info(), + insert_contract_created_at(Some( + AGED_REFERENCE_BLOCK_TIME_MS - AGED_REFERENCE_MINIMUM_AGE_MS + 1, + )), + |document, _, young_contract_id| { + document.set("refContractId", young_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedContractRequirementNotMetError(ref e)), + .. + } if e.contract_id() == &Identifier::from([0xA6; 32]) + && e.field() == "minimumAgeSeconds" + && e.required() == "3600" + && e.path() == "refContractId" + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_referenced_contract_has_no_creation_time() { + // The fixture contract itself exists in state, written without a creation time, as a + // contract created before contracts recorded one would be: its age is unknown + let existing_contract_id = Identifier::from_string( + REFERENCE_VALIDATION_AGED_CONTRACT_REF_CONTRACT_ID, + Encoding::Base58, + ) + .expect("expected a valid contract id"); + + let result = run_reference_validation_creation_with_setup_and_mutator( + REFERENCE_VALIDATION_AGED_CONTRACT_REF_CONTRACT_PATH, + aged_reference_block_info(), + |_, _, _| Identifier::default(), + |document, _, _| { + document.set("refContractId", existing_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedContractRequirementNotMetError(ref e)), + .. + } if e.contract_id() == &existing_contract_id + && e.field() == "minimumAgeSeconds" + && e.required() == "3600" + && e.path() == "refContractId" + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_referenced_contract_was_updated_too_recently() { + // Created long before the block, but updated one millisecond less than the minimum + // before it: the update restarts the clock + let result = run_reference_validation_creation_with_setup_and_mutator( + REFERENCE_VALIDATION_UPDATED_CONTRACT_REF_CONTRACT_PATH, + aged_reference_block_info(), + insert_contract_with_times( + Some(AGED_REFERENCE_BLOCK_TIME_MS - 100 * AGED_REFERENCE_MINIMUM_AGE_MS), + Some(AGED_REFERENCE_BLOCK_TIME_MS - AGED_REFERENCE_MINIMUM_AGE_MS + 1), + ), + |document, _, updated_contract_id| { + document.set("refContractId", updated_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedContractRequirementNotMetError(ref e)), + .. + } if e.contract_id() == &Identifier::from([0xA6; 32]) + && e.field() == "minimumSecondsSinceUpdate" + && e.required() == "3600" + && e.path() == "refContractId" + ); + } + + #[tokio::test] + async fn should_document_creation_succeed_when_referenced_contract_was_updated_long_enough_ago() + { + // Updated exactly the minimum before the block + let result = run_reference_validation_creation_with_setup_and_mutator( + REFERENCE_VALIDATION_UPDATED_CONTRACT_REF_CONTRACT_PATH, + aged_reference_block_info(), + insert_contract_with_times( + Some(AGED_REFERENCE_BLOCK_TIME_MS - 100 * AGED_REFERENCE_MINIMUM_AGE_MS), + Some(AGED_REFERENCE_BLOCK_TIME_MS - AGED_REFERENCE_MINIMUM_AGE_MS), + ), + |document, _, updated_contract_id| { + document.set("refContractId", updated_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_document_creation_succeed_when_never_updated_referenced_contract_is_old_enough() + { + // Never updated: the creation time is the last change + let result = run_reference_validation_creation_with_setup_and_mutator( + REFERENCE_VALIDATION_UPDATED_CONTRACT_REF_CONTRACT_PATH, + aged_reference_block_info(), + insert_contract_created_at(Some( + AGED_REFERENCE_BLOCK_TIME_MS - AGED_REFERENCE_MINIMUM_AGE_MS, + )), + |document, _, old_contract_id| { + document.set("refContractId", old_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_document_creation_succeed_when_referenced_contract_is_old_enough() { + // Created exactly the minimum age before the block + let result = run_reference_validation_creation_with_setup_and_mutator( + REFERENCE_VALIDATION_AGED_CONTRACT_REF_CONTRACT_PATH, + aged_reference_block_info(), + insert_contract_created_at(Some( + AGED_REFERENCE_BLOCK_TIME_MS - AGED_REFERENCE_MINIMUM_AGE_MS, + )), + |document, _, old_contract_id| { + document.set("refContractId", old_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + /// A contract owned by the writer of the referring document, for a reference that requires + /// the referenced contract to be, or not to be, the writer's own. Written to state directly, + /// the way the fixtures are. + fn insert_contract_owned_by_writer( + platform: &mut TempPlatform, + targets: &ReferenceTargets, + _platform_version: &PlatformVersion, + ) -> Identifier { + let contract = setup_contract( + &platform.drive, + REFERENCE_VALIDATION_CONTRACT_REF_CONTRACT_PATH, + Some([0xB1; 32]), + Some(targets.identity_id.to_buffer()), + None::, + None, + None, + ); + contract.id() + } + + #[tokio::test] + async fn should_document_creation_succeed_when_required_self_owned_contract_is_the_writers() { + let result = run_reference_validation_creation_with_setup_and_mutator( + REFERENCE_VALIDATION_OWNER_SELF_CONTRACT_REF_CONTRACT_PATH, + BlockInfo::default(), + insert_contract_owned_by_writer, + |document, _, own_contract_id| { + document.set("refContractId", own_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_required_self_owned_contract_is_someone_elses() { + // The fixture contract itself exists in state, owned by an identity that is not the + // writer + let existing_contract_id = Identifier::from_string( + REFERENCE_VALIDATION_OWNER_SELF_CONTRACT_REF_CONTRACT_ID, + Encoding::Base58, + ) + .expect("expected a valid contract id"); + + let result = run_reference_validation_creation_with_mutator( + REFERENCE_VALIDATION_OWNER_SELF_CONTRACT_REF_CONTRACT_PATH, + |document, _| { + document.set("refContractId", existing_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedContractRequirementNotMetError(ref e)), + .. + } if e.contract_id() == &existing_contract_id + && e.field() == "owner" + && e.required() == "self" + && e.path() == "refContractId" + ); + } + + #[tokio::test] + async fn should_document_creation_succeed_when_required_other_owned_contract_is_someone_elses() + { + let existing_contract_id = Identifier::from_string( + REFERENCE_VALIDATION_OWNER_OTHER_CONTRACT_REF_CONTRACT_ID, + Encoding::Base58, + ) + .expect("expected a valid contract id"); + + let result = run_reference_validation_creation_with_mutator( + REFERENCE_VALIDATION_OWNER_OTHER_CONTRACT_REF_CONTRACT_PATH, + |document, _| { + document.set("refContractId", existing_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_required_other_owned_contract_is_the_writers() { + let result = run_reference_validation_creation_with_setup_and_mutator( + REFERENCE_VALIDATION_OWNER_OTHER_CONTRACT_REF_CONTRACT_PATH, + BlockInfo::default(), + insert_contract_owned_by_writer, + |document, _, own_contract_id| { + document.set("refContractId", own_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedContractRequirementNotMetError(ref e)), + .. + } if e.contract_id() == &Identifier::from([0xB1; 32]) + && e.field() == "owner" + && e.required() == "other" + && e.path() == "refContractId" + ); + } + + #[tokio::test] + async fn should_document_creation_succeed_when_required_readonly_contract_is_readonly() { + let result = run_reference_validation_creation_with_setup_and_mutator( + REFERENCE_VALIDATION_READONLY_CONTRACT_REF_CONTRACT_PATH, + BlockInfo::default(), + insert_contract_configured([0xC1; 32], |config| config.set_readonly(true)), + |document, _, readonly_contract_id| { + document.set("refContractId", readonly_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_required_readonly_contract_is_updatable() { + // The fixture contract itself exists in state and is not read-only + let existing_contract_id = Identifier::from_string( + REFERENCE_VALIDATION_READONLY_CONTRACT_REF_CONTRACT_ID, + Encoding::Base58, + ) + .expect("expected a valid contract id"); + + let result = run_reference_validation_creation_with_mutator( + REFERENCE_VALIDATION_READONLY_CONTRACT_REF_CONTRACT_PATH, + |document, _| { + document.set("refContractId", existing_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedContractRequirementNotMetError(ref e)), + .. + } if e.contract_id() == &existing_contract_id + && e.field() == "readonly" + && e.required() == "true" + && e.path() == "refContractId" + ); + } + + #[tokio::test] + async fn should_document_creation_succeed_when_required_history_keeping_contract_keeps_history() + { + let result = run_reference_validation_creation_with_setup_and_mutator( + REFERENCE_VALIDATION_KEEPS_HISTORY_CONTRACT_REF_CONTRACT_PATH, + BlockInfo::default(), + insert_contract_configured([0xC2; 32], |config| config.set_keeps_history(true)), + |document, _, history_contract_id| { + document.set("refContractId", history_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_required_history_keeping_contract_keeps_none() { + // The fixture contract itself exists in state and keeps no history + let existing_contract_id = Identifier::from_string( + REFERENCE_VALIDATION_KEEPS_HISTORY_CONTRACT_REF_CONTRACT_ID, + Encoding::Base58, + ) + .expect("expected a valid contract id"); + + let result = run_reference_validation_creation_with_mutator( + REFERENCE_VALIDATION_KEEPS_HISTORY_CONTRACT_REF_CONTRACT_PATH, + |document, _| { + document.set("refContractId", existing_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedContractRequirementNotMetError(ref e)), + .. + } if e.contract_id() == &existing_contract_id + && e.field() == "keepsHistory" + && e.required() == "true" + && e.path() == "refContractId" + ); + } + + #[tokio::test] + async fn should_document_creation_succeed_when_required_owner_protection_is_declared() { + let result = run_reference_validation_creation_with_setup_and_mutator( + REFERENCE_VALIDATION_OWNER_PROTECTED_CONTRACT_REF_CONTRACT_PATH, + BlockInfo::default(), + insert_elected_contract(true), + |document, _, elected_contract_id| { + document.set("refContractId", elected_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_required_owner_protection_is_not_declared() { + // Elected moderation whose declaration leaves the owner unprotected + let result = run_reference_validation_creation_with_setup_and_mutator( + REFERENCE_VALIDATION_OWNER_PROTECTED_CONTRACT_REF_CONTRACT_PATH, + BlockInfo::default(), + insert_elected_contract(false), + |document, _, elected_contract_id| { + document.set("refContractId", elected_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedContractRequirementNotMetError(ref e)), + .. + } if e.contract_id() == &Identifier::from([0xE1; 32]) + && e.field() == "ownerProtected" + && e.required() == "true" + && e.path() == "refContractId" + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_required_owner_protection_has_no_elected_moderation( + ) { + // The fixture contract itself exists in state and declares no moderation at all, so + // there is no owner protection flag to meet either value + let existing_contract_id = Identifier::from_string( + REFERENCE_VALIDATION_OWNER_PROTECTED_CONTRACT_REF_CONTRACT_ID, + Encoding::Base58, + ) + .expect("expected a valid contract id"); + + let result = run_reference_validation_creation_with_mutator( + REFERENCE_VALIDATION_OWNER_PROTECTED_CONTRACT_REF_CONTRACT_PATH, + |document, _| { + document.set("refContractId", existing_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedContractRequirementNotMetError(ref e)), + .. + } if e.contract_id() == &existing_contract_id + && e.field() == "ownerProtected" + && e.required() == "true" + && e.path() == "refContractId" + ); + } + #[tokio::test] async fn should_document_creation_succeed_with_nested_and_multiple_references() { let result = run_reference_validation_creation_with_mutator( 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 2ebdedb7734..1ba8d5de0ed 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 @@ -15,7 +15,9 @@ mod deletion_tests { // 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, + // The app-connect contract adds one sibling to the genesis contracts tree, + // increasing the bytes billed when reading that tree (protocol 14 only). + 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 a72055cfeda..94bdf6fe6b2 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 @@ -33,7 +33,9 @@ mod dpns_tests { // inserts touch a cheaper set of nodes (v13 moves by the same amount // when the seed reorders its ids). +10_000 per create: the nonce // derived id is billed both SHA-256 passes, 4 blocks instead of 2. - 5_895_280, + // The app-connect contract adds one sibling to the genesis contracts tree, + // increasing the bytes billed when reading that tree (protocol 14 only). + 6_049_360, ) .await; } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/gas_sponsorship.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/gas_sponsorship.rs index 699daa92eb5..7277c833886 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/gas_sponsorship.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/gas_sponsorship.rs @@ -517,7 +517,11 @@ pub(crate) mod gas_sponsorship_tests { self.check_tx_at(transition, FirstTimeCheck) } - fn check_tx_at(&self, transition: &StateTransition, level: CheckTxLevel) -> Vec { + pub(crate) fn check_tx_at( + &self, + transition: &StateTransition, + level: CheckTxLevel, + ) -> Vec { let platform_version = self.platform_version; let state = self.platform.state.load(); let platform_ref = PlatformRef { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/index_only.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/index_only.rs index c90252cf451..5410b384280 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/index_only.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/index_only.rs @@ -1437,6 +1437,7 @@ mod index_only_executed_proof_tests { use dpp::prelude::DataContract; use dpp::state_transition::proof_result::StateTransitionProofResult; use dpp::state_transition::StateTransition; + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; use drive::drive::Drive; use simple_signer::signer::SimpleSigner; use std::sync::Arc; @@ -2464,4 +2465,386 @@ mod index_only_executed_proof_tests { let (_, absent) = documents.into_iter().next().expect("one entry"); assert!(absent.is_none(), "the deleted tip must be proven absent"); } + + /// The scalar-terminal fixture shared with rs-drive's + /// `index_only_scalar_terminal_e2e_tests`: an `answer` keys its entry + /// by a 33-byte `payload` under `[requestId, $ownerId]`. + const SCALAR_TERMINAL_CONTRACT: &str = "../rs-drive/tests/supporting_files/contract/index-only-scalar-terminal/index-only-scalar-terminal-contract.json"; + + /// A scalar terminal through the full pipeline. The create keys its + /// entry by the 33-byte payload; the executed-create proof locates that + /// entry from the transition's values through the same + /// `serialize_value_for_key` encoding the walker keyed it with, and the + /// verified document carries the payload decoded off the member key; + /// the executed delete proves the entry absent. + #[tokio::test] + async fn test_executed_scalar_terminal_create_and_delete_proofs() { + use dpp::data_contract::accessors::v0::DataContractV0Setters; + 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(2718); + + let (alice, alice_signer, alice_key) = + setup_identity(&mut platform, 958, dash_to_credits!(1.0)); + let mut contract = + json_document_to_contract(SCALAR_TERMINAL_CONTRACT, true, platform_version) + .expect("expected to parse the scalar-terminal contract"); + contract.set_owner_id(alice.id()); + platform + .drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply the scalar-terminal contract"); + let answer_type = contract + .document_type_for_name("answer") + .expect("answer doctype exists"); + let contract_arc = Arc::new(contract.clone()); + + let request_id = vec![0x5A; 20]; + let payload = vec![0x7E; 33]; + let entropy = Bytes32::random_with_rng(&mut rng); + let mut answer = answer_type + .random_document_with_identifier_and_entropy( + &mut rng, + alice.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random answer"); + answer.set( + "requestId", + dpp::platform_value::Value::Bytes(request_id.clone()), + ); + answer.set( + "payload", + dpp::platform_value::Value::Bytes(payload.clone()), + ); + answer + .set_id_for_creation(answer_type, &entropy.0, 2, platform_version) + .expect("expected to set the document id"); + + let create = BatchTransition::new_document_creation_transition_from_document( + answer.clone(), + answer_type, + entropy.0, + &alice_key, + 2, + 0, + None, + &alice_signer, + platform_version, + None, + ) + .await + .expect("expected the create transition"); + let result = process_and_commit(&platform, &platform_state, &create, platform_version); + assert_eq!( + result.valid_count(), + 1, + "the answer must be created: {:?}", + result.execution_results() + ); + + // ── prove + verify the executed create ───────────────────────── + let proof = platform + .drive + .prove_state_transition(&create, None, platform_version) + .expect("expected to prove the executed create") + .into_data() + .expect("expected proof bytes"); + let lookup = |_id: &dpp::identifier::Identifier| Ok(Some(Arc::clone(&contract_arc))); + let (root_hash, outcome) = Drive::verify_state_transition_was_executed_with_proof( + &create, + &BlockInfo::default(), + proof.as_slice(), + &lookup, + platform_version, + ) + .expect("expected the executed-create proof to verify"); + assert_ne!(root_hash, [0u8; 32]); + assert_matches!( + &outcome, + dpp::state_transition::proof_result::StateTransitionProofOutcome::AffectedState(_) + ); + let StateTransitionProofResult::VerifiedDocuments(documents) = outcome.into_result() else { + panic!("expected verified documents"); + }; + let (_, verified) = documents.into_iter().next().expect("one document"); + let verified = verified.expect("the created answer is present"); + assert_eq!(verified.owner_id(), alice.id()); + assert_eq!( + verified + .properties() + .get("requestId") + .expect("requestId present") + .to_binary_bytes() + .expect("bytes"), + request_id + ); + assert_eq!( + verified + .properties() + .get("payload") + .expect("payload decoded off the member key") + .to_binary_bytes() + .expect("bytes"), + payload + ); + + // ── prove + verify the executed delete ───────────────────────── + let delete = BatchTransition::new_document_deletion_transition_from_document( + answer, + answer_type, + &alice_key, + 3, + 0, + None, + &alice_signer, + platform_version, + None, + ) + .await + .expect("expected the delete transition"); + let result = process_and_commit(&platform, &platform_state, &delete, platform_version); + assert_eq!( + result.valid_count(), + 1, + "the answer must be deleted: {:?}", + result.execution_results() + ); + let proof = platform + .drive + .prove_state_transition(&delete, None, platform_version) + .expect("expected to prove the executed delete") + .into_data() + .expect("expected proof bytes"); + let (root_hash, outcome) = Drive::verify_state_transition_was_executed_with_proof( + &delete, + &BlockInfo::default(), + proof.as_slice(), + &lookup, + platform_version, + ) + .expect("expected the executed-delete proof to verify"); + assert_ne!(root_hash, [0u8; 32]); + assert_matches!( + &outcome, + dpp::state_transition::proof_result::StateTransitionProofOutcome::AffectedState(_) + ); + let StateTransitionProofResult::VerifiedDocuments(documents) = outcome.into_result() else { + panic!("expected verified documents"); + }; + let (_, absent) = documents.into_iter().next().expect("one entry"); + assert!(absent.is_none(), "the deleted answer must be proven absent"); + } + + /// A FLAT composite terminal with an entry payload through the full + /// pipeline: the create's entry sits under the flat level keyed by + /// `hash ‖ owner`, the executed-create proof locates it from the + /// transition's values (the same concatenated encoding the walker + /// keyed it with) and checks the item's leading 32 bytes against the + /// recomputed commitment, and the executed delete proves it absent. + #[tokio::test] + async fn test_executed_flat_composite_create_and_delete_proofs() { + assert_flat_composite_create_and_delete_proofs(false).await; + } + + #[tokio::test] + async fn should_create_and_delete_app_connect_response_with_proofs() { + assert_flat_composite_create_and_delete_proofs(true).await; + } + + async fn assert_flat_composite_create_and_delete_proofs(system_contract: bool) { + use dpp::data_contract::accessors::v0::DataContractV0Setters; + 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(3141); + + let (alice, alice_signer, alice_key) = + setup_identity(&mut platform, 958, dash_to_credits!(1.0)); + let contract = if system_contract { + // Genesis registers the real system schema; ordinary identities write to it. + load_system_data_contract(SystemDataContract::AppConnect, platform_version) + .expect("expected the app-connect system contract") + } else { + let mut contract = + json_document_to_contract(SCALAR_TERMINAL_CONTRACT, true, platform_version) + .expect("expected to parse the scalar-terminal contract"); + contract.set_owner_id(alice.id()); + platform + .drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply the scalar-terminal contract"); + contract + }; + let response_type = contract + .document_type_for_name("loginKeyResponse") + .expect("loginKeyResponse doctype exists"); + let contract_arc = Arc::new(contract.clone()); + + let request_id = vec![0x5A; 20]; + let wallet_key = vec![0x7E; 33]; + let ciphertext = vec![0xC7; 92]; + let entropy = Bytes32::random_with_rng(&mut rng); + let mut answer = response_type + .random_document_with_identifier_and_entropy( + &mut rng, + alice.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random response"); + answer.set( + "appEphemeralPubKeyHash", + dpp::platform_value::Value::Bytes(request_id.clone()), + ); + answer.set( + "walletEphemeralPubKey", + dpp::platform_value::Value::Bytes(wallet_key.clone()), + ); + answer.set( + "encryptedPayload", + dpp::platform_value::Value::Bytes(ciphertext.clone()), + ); + answer + .set_id_for_creation(response_type, &entropy.0, 2, platform_version) + .expect("expected to set the document id"); + + let create = BatchTransition::new_document_creation_transition_from_document( + answer.clone(), + response_type, + entropy.0, + &alice_key, + 2, + 0, + None, + &alice_signer, + platform_version, + None, + ) + .await + .expect("expected the create transition"); + let result = process_and_commit(&platform, &platform_state, &create, platform_version); + assert_eq!( + result.valid_count(), + 1, + "the answer must be created: {:?}", + result.execution_results() + ); + + // ── prove + verify the executed create ───────────────────────── + let proof = platform + .drive + .prove_state_transition(&create, None, platform_version) + .expect("expected to prove the executed create") + .into_data() + .expect("expected proof bytes"); + let lookup = |_id: &dpp::identifier::Identifier| Ok(Some(Arc::clone(&contract_arc))); + let (root_hash, outcome) = Drive::verify_state_transition_was_executed_with_proof( + &create, + &BlockInfo::default(), + proof.as_slice(), + &lookup, + platform_version, + ) + .expect("expected the executed-create proof to verify"); + assert_ne!(root_hash, [0u8; 32]); + assert_matches!( + &outcome, + dpp::state_transition::proof_result::StateTransitionProofOutcome::AffectedState(_) + ); + let StateTransitionProofResult::VerifiedDocuments(documents) = outcome.into_result() else { + panic!("expected verified documents"); + }; + let (_, verified) = documents.into_iter().next().expect("one document"); + let verified = verified.expect("the created answer is present"); + assert_eq!(verified.owner_id(), alice.id()); + assert_eq!( + verified + .properties() + .get("appEphemeralPubKeyHash") + .expect("request hash present") + .to_binary_bytes() + .expect("bytes"), + request_id + ); + assert_eq!( + verified + .properties() + .get("encryptedPayload") + .expect("ciphertext present") + .to_binary_bytes() + .expect("bytes"), + ciphertext + ); + + // ── prove + verify the executed delete ───────────────────────── + let delete = BatchTransition::new_document_deletion_transition_from_document( + answer, + response_type, + &alice_key, + 3, + 0, + None, + &alice_signer, + platform_version, + None, + ) + .await + .expect("expected the delete transition"); + let result = process_and_commit(&platform, &platform_state, &delete, platform_version); + assert_eq!( + result.valid_count(), + 1, + "the answer must be deleted: {:?}", + result.execution_results() + ); + let proof = platform + .drive + .prove_state_transition(&delete, None, platform_version) + .expect("expected to prove the executed delete") + .into_data() + .expect("expected proof bytes"); + let (root_hash, outcome) = Drive::verify_state_transition_was_executed_with_proof( + &delete, + &BlockInfo::default(), + proof.as_slice(), + &lookup, + platform_version, + ) + .expect("expected the executed-delete proof to verify"); + assert_ne!(root_hash, [0u8; 32]); + assert_matches!( + &outcome, + dpp::state_transition::proof_result::StateTransitionProofOutcome::AffectedState(_) + ); + let StateTransitionProofResult::VerifiedDocuments(documents) = outcome.into_result() else { + panic!("expected verified documents"); + }; + let (_, absent) = documents.into_iter().next().expect("one entry"); + assert!(absent.is_none(), "the deleted answer must be proven absent"); + } } 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 920da412f13..0bbf619b864 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 @@ -517,7 +517,9 @@ mod replacement_tests { // 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, + // The app-connect contract adds one sibling to the genesis contracts tree, + // increasing the bytes billed when reading that tree (protocol 14 only). + 1498860, ) .await; } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/contract_moderation_gate/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/contract_moderation_gate/mod.rs index ede95b16836..7a93976d9cc 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/contract_moderation_gate/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/contract_moderation_gate/mod.rs @@ -18,22 +18,26 @@ use drive::state_transition_action::batch::batched_transition::BatchedTransition use std::collections::{BTreeMap, BTreeSet}; use v0::BatchTransitionContractModerationGateV0; -/// What the gate hands back for a signer the contract bars. +/// What the gate hands back when it refuses part or all of a signer's transitions. pub(super) struct ContractModerationRefusal<'a> { - /// The refusal of every transition the bar covers, each with its nonce bump. + /// The refusal of every transition the gate covers, each with its nonce bump. pub refused: ConsensusValidationResult>, - /// The signer's deletions, by document type, which the bar does not cover: a barred - /// identity may still take its own documents down. They carry on through the transformer. - pub deletions: BTreeMap<&'a String, Vec<&'a DocumentTransition>>, + /// The signer's transitions, by document type, the gate lets through: under a bar its + /// deletions, since a barred identity may still take its own documents down, and next to + /// an interim block everything on the types the block does not cover. They carry on + /// through the transformer. + pub passed: BTreeMap<&'a String, Vec<&'a DocumentTransition>>, } /// The contract moderation gate of the batch transformer (protocol version 14). pub(super) trait BatchTransitionContractModerationGate { - /// Gates the document transitions of `owner_id` against one contract on the contract's - /// moderation lists. Returns the refusal when the signer is banned or under a live - /// suspension and asks for anything but deletions, `None` to carry on with every - /// transition. A suspension found lapsed is recorded in `lapsed_suspensions` for the batch - /// to sweep. + /// Gates the document transitions of `owner_id` against one contract: on the interim + /// block of an elected declaration that names no interim moderators, which refuses every + /// transition of a moderated document type until a team is seated, and on the contract's + /// moderation lists. Returns the refusal when the block covers a transition, or when the + /// signer is banned or under a live suspension and asks for anything but deletions; + /// `None` to carry on with every transition. A suspension found lapsed is recorded in + /// `lapsed_suspensions` for the batch to sweep. /// /// Before protocol version 14 the gate does not exist (`None` in the version table) and /// nothing is read. @@ -165,6 +169,7 @@ mod tests { banlist: true, suspensions: true, moderators: ContractModerators::ContractOwner, + warnings: false, }, ))); contract @@ -382,7 +387,7 @@ mod tests { .collect(); assert_eq!(bumped_nonces, BTreeSet::from([1, 3])); let kept: Vec = refusal - .deletions + .passed .values() .flatten() .map(|transition| transition.base().identity_contract_nonce()) @@ -391,4 +396,176 @@ mod tests { // The status read was billed once for the whole batch. assert_eq!(execution_context.operations_slice().len(), 1); } + + /// An elected contract whose interim names nobody: a create on the moderated type is + /// refused with its nonce bump, a create on another type passes, and the lists are read + /// once for the batch, for the transition that passes. A batch on the moderated type + /// alone reads nothing. + #[tokio::test] + async fn should_block_the_moderated_types_of_an_elected_contract_in_its_interim() { + use crate::execution::validation::state_transition::tests::setup_identity; + use dpp::consensus::codes::ErrorWithCode; + use dpp::dash_to_credits; + use dpp::data_contract::config::moderation::{ + ElectedModerators, InterimModerators, ModerationAbility, + }; + use dpp::data_contract::document_type::random_document::{ + CreateRandomDocument, DocumentFieldFillSize, DocumentFieldFillType, + }; + use dpp::identity::accessors::IdentityGettersV0; + use dpp::platform_value::Bytes32; + use dpp::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; + use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransitionV0Methods; + use dpp::state_transition::batch_transition::batched_transition::BatchedTransitionRef; + use dpp::state_transition::batch_transition::document_base_transition::v0::v0_methods::DocumentBaseTransitionV0Methods; + use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; + use rand::rngs::StdRng; + use rand::SeedableRng; + + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + let (identity, signer, key) = setup_identity(&mut platform, 32, dash_to_credits!(1)); + let mut contract = get_data_contract_fixture(None, 0, platform_version.protocol_version) + .data_contract_owned(); + contract.set_config(contract.config().clone().with_moderation(Some( + ContractModerationConfig { + banlist: true, + suspensions: false, + warnings: false, + moderators: ContractModerators::Elected(Box::new(ElectedModerators { + join_window: 604_800, + vote_window: 604_800, + challenge_cool_down: 1_209_600, + moderated_document_types: BTreeMap::from([( + "niceDocument".to_string(), + BTreeSet::from([ModerationAbility::Ban]), + )]), + interim: InterimModerators::NotYetUsable, + election_delay: None, + owner_protected: false, + })), + }, + ))); + platform + .drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + None, + None, + platform_version, + ) + .expect("expected to apply the contract"); + + let mut rng = StdRng::seed_from_u64(4); + let mut batches = vec![]; + for (nonce, document_type_name) in [(1u64, "niceDocument"), (2, "prettyDocument")] { + let document_type = contract + .document_type_for_name(document_type_name) + .expect("expected the document type"); + let entropy = Bytes32::random_with_rng(&mut rng); + let mut document = document_type + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::MinDocumentFillSize, + platform_version, + ) + .expect("expected a random document"); + document + .set_id_for_creation(document_type, &entropy.0, nonce, platform_version) + .expect("expected to set the document id"); + let batch = BatchTransition::new_document_creation_transition_from_document( + document, + document_type, + entropy.0, + &key, + nonce, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expected a batch"); + batches.push(batch); + } + let transitions: Vec<&DocumentTransition> = batches + .iter() + .flat_map(|batch| match batch { + StateTransition::Batch(batch) => batch.transitions_iter().collect::>(), + _ => vec![], + }) + .filter_map(|transition| match transition { + BatchedTransitionRef::Document(document_transition) => Some(document_transition), + BatchedTransitionRef::Token(_) => None, + }) + .collect(); + fn by_type<'a>( + transitions: &[&'a DocumentTransition], + ) -> BTreeMap<&'a String, Vec<&'a DocumentTransition>> { + transitions + .iter() + .map(|transition| (transition.base().document_type_name(), vec![*transition])) + .collect() + } + + // Both types: the moderated one refused, the other passed, one read. + let document_transitions = by_type(&transitions); + let mut execution_context = + StateTransitionExecutionContext::default_for_platform_version(platform_version) + .expect("execution context"); + let refusal = BatchTransition::contract_moderation_gate( + &platform.drive, + &BlockInfo::default(), + &contract, + identity.id(), + &document_transitions, + &mut BTreeSet::new(), + &mut execution_context, + None, + platform_version, + ) + .expect("expected the gate to run") + .expect("expected the moderated type to be refused"); + assert_eq!(refusal.refused.data.as_deref().map(|a| a.len()), Some(1)); + assert_eq!(refusal.refused.errors.len(), 1); + assert_eq!(refusal.refused.errors[0].code(), 41200); + let passed: Vec = refusal + .passed + .values() + .flatten() + .map(|transition| transition.base().identity_contract_nonce()) + .collect(); + assert_eq!(passed, vec![2]); + assert_eq!(execution_context.operations_slice().len(), 1); + + // The moderated type alone: refused without a read. + let document_transitions = by_type(&transitions[..1]); + let mut execution_context = + StateTransitionExecutionContext::default_for_platform_version(platform_version) + .expect("execution context"); + let refusal = BatchTransition::contract_moderation_gate( + &platform.drive, + &BlockInfo::default(), + &contract, + identity.id(), + &document_transitions, + &mut BTreeSet::new(), + &mut execution_context, + None, + platform_version, + ) + .expect("expected the gate to run") + .expect("expected the moderated type to be refused"); + assert!(refusal.passed.is_empty()); + assert_eq!(refusal.refused.errors.len(), 1); + assert!(execution_context.operations_slice().is_empty()); + } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/contract_moderation_gate/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/contract_moderation_gate/v0/mod.rs index bdd218d7323..a7c74b4536c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/contract_moderation_gate/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/contract_moderation_gate/v0/mod.rs @@ -7,8 +7,8 @@ use crate::execution::validation::state_transition::state_transitions::batch::tr use crate::execution::validation::state_transition::state_transitions::batch::transformer::v0::BatchTransitionInternalTransformerV0; use dpp::block::block_info::BlockInfo; use dpp::consensus::state::contract_moderation::{ - ContractModerationCounterpartyBarredError, ContractModerationCounterpartyRole, - ContractUserBannedError, ContractUserSuspendedError, + ContractModeratedDocumentTypeNotYetUsableError, ContractModerationCounterpartyBarredError, + ContractModerationCounterpartyRole, ContractUserBannedError, ContractUserSuspendedError, }; use dpp::consensus::ConsensusError; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -60,6 +60,12 @@ impl BatchTransitionContractModerationGateV0 for BatchTransition { /// document transition after a suspension lapsed sweeps the stale entry. The gate runs in the transformer, so the mempool refuses a /// barred identity as a block does. A contract that declares no moderation costs nothing: /// no read is made for it. + /// + /// An elected contract whose declaration names no interim moderators refuses, first, + /// every transition of a document type it moderates until a team is seated + /// (`ContractModeratedDocumentTypeNotYetUsableError`), deletions included: nothing of + /// those types was ever written. The lists are then read only for the transitions on the + /// other types, and not at all when nothing is left. fn contract_moderation_gate_v0<'a>( drive: &Drive, block_info: &BlockInfo, @@ -76,7 +82,50 @@ impl BatchTransitionContractModerationGateV0 for BatchTransition { }; let data_contract_id = contract.id(); - let lists: Vec = moderation.lists().collect(); + // Every transition the gate refuses is refused on its own, as a per-transition + // failure is anywhere else in the transformer, so each one's contract nonce is bumped + // and none stays replayable. + let mut actions = vec![]; + let mut errors = vec![]; + let failed = |transition: &DocumentTransition, error: ConsensusError| { + Self::failed_per_transition_action( + transition.base(), + owner_id, + vec![error], + platform_version, + ) + }; + + // The interim block: the types an elected contract moderates wait for a team. + let mut unblocked: BTreeMap<&'a String, Vec<&'a DocumentTransition>> = BTreeMap::new(); + for (document_type_name, transitions) in document_transitions { + if moderation.interim_blocks_document_type(document_type_name) { + for transition in transitions { + let refusal = failed( + transition, + ContractModeratedDocumentTypeNotYetUsableError::new( + data_contract_id, + (*document_type_name).clone(), + ) + .into(), + )?; + actions.extend(refusal.data); + errors.extend(refusal.errors); + } + } else { + unblocked.insert(*document_type_name, transitions.clone()); + } + } + let blocked_any = !(actions.is_empty() && errors.is_empty()); + if unblocked.is_empty() { + // Everything was blocked (the batch is never empty), so nothing needs the lists. + return Ok(Some(ContractModerationRefusal { + refused: ConsensusValidationResult::new_with_data_and_errors(actions, errors), + passed: BTreeMap::new(), + })); + } + + let lists: Vec = moderation.barring_lists().collect(); let (fee, status) = drive.fetch_contract_moderation_status_with_fee( data_contract_id, owner_id, @@ -102,39 +151,37 @@ impl BatchTransitionContractModerationGateV0 for BatchTransition { if status.has_lapsed_suspension_at(block_info.time_ms) { lapsed_suspensions.insert(data_contract_id); } - return Ok(None); + if !blocked_any { + return Ok(None); + } + return Ok(Some(ContractModerationRefusal { + refused: ConsensusValidationResult::new_with_data_and_errors(actions, errors), + passed: unblocked, + })); }; - // Paid: the signer is authenticated and the read happened. Every transition the bar - // covers is refused on its own, as a per-transition failure is anywhere else in the - // transformer, so each one's contract nonce is bumped and none stays replayable. - let mut actions = vec![]; - let mut errors = vec![]; - let mut deletions: BTreeMap<&'a String, Vec<&'a DocumentTransition>> = BTreeMap::new(); - for (document_type_name, transitions) in document_transitions { + // Paid: the signer is authenticated and the read happened. + let mut passed: BTreeMap<&'a String, Vec<&'a DocumentTransition>> = BTreeMap::new(); + for (document_type_name, transitions) in unblocked { for transition in transitions { if matches!( transition, DocumentTransition::Delete(_) | DocumentTransition::IndexOnlyDelete(_) ) { - deletions - .entry(*document_type_name) + passed + .entry(document_type_name) .or_default() - .push(*transition); + .push(transition); continue; } - let failed = Self::failed_per_transition_action( - transition.base(), - owner_id, - vec![error.clone()], - platform_version, - )?; - actions.extend(failed.data); - errors.extend(failed.errors); + let refusal = failed(transition, error.clone())?; + actions.extend(refusal.data); + errors.extend(refusal.errors); } } - // Nothing but deletions: the bar does not apply, the batch carries on whole. + // Nothing but deletions and nothing blocked: the bar does not apply, the batch carries + // on whole. if actions.is_empty() && errors.is_empty() { return Ok(None); } @@ -144,7 +191,7 @@ impl BatchTransitionContractModerationGateV0 for BatchTransition { } else { ConsensusValidationResult::new_with_data_and_errors(actions, errors) }; - Ok(Some(ContractModerationRefusal { refused, deletions })) + Ok(Some(ContractModerationRefusal { refused, passed })) } /// A barred identity is kept out of the contract's documents as a counterparty too: it can @@ -167,7 +214,7 @@ impl BatchTransitionContractModerationGateV0 for BatchTransition { }; let data_contract_id = contract.id(); - let lists: Vec = moderation.lists().collect(); + let lists: Vec = moderation.barring_lists().collect(); let (fee, status) = drive.fetch_contract_moderation_status_with_fee( data_contract_id, counterparty_id, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs index cf34337f089..cf5458e9034 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs @@ -462,13 +462,14 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { transaction, platform_version, )?; - // A barred signer keeps its deletions: only they carry on, next to the refusal. + // What the gate lets through carries on next to the refusal: a barred signer's + // deletions, and everything on the types an elected contract's interim does not block. let (refused, document_transitions) = match refusal { - Some(ContractModerationRefusal { refused, deletions }) => { - if deletions.is_empty() { + Some(ContractModerationRefusal { refused, passed }) => { + if passed.is_empty() { return Ok(refused); } - (Some(refused), Cow::Owned(deletions)) + (Some(refused), Cow::Owned(passed)) } None => (None, Cow::Borrowed(document_transitions)), }; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/tests.rs index 56834163d8b..0598ee07e1a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/tests.rs @@ -126,6 +126,7 @@ impl Setup { banlist: true, suspensions: false, moderators, + warnings: false, }, ))); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/basic_structure/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/basic_structure/v0/mod.rs index 4ea03d822de..50be0ca32a6 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/basic_structure/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/basic_structure/v0/mod.rs @@ -20,7 +20,7 @@ impl ContractUserModerationStateTransitionStructureValidationV0 for ContractUserModerationTransition { /// An identity can not moderate itself, a suspension ends within the JSON-safe range, and - /// the text of the reason a ban, a suspension or a document deletion carries fits + /// the text of the reason a ban, a suspension, a warning or a document deletion carries fits /// `SystemLimits::max_contract_moderation_reason_length`; the reason's code is not checked. /// A document deletion names a document, not an identity: whose it is, and so whether the /// moderator may delete it, is only known once the state is read. diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/state/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/state/v0/mod.rs index ca208f27fb3..10de400402a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/state/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/state/v0/mod.rs @@ -1,6 +1,6 @@ use crate::error::execution::ExecutionError; use crate::error::Error; -use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::execution_operation::{ValidationOperation, SHA256_BLOCK_SIZE}; use crate::execution::types::state_transition_execution_context::{ StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, }; @@ -9,12 +9,17 @@ use crate::execution::validation::state_transition::state_transitions::batch::fe use crate::platform_types::platform::PlatformRef; use crate::rpc::core::CoreRPCLike; use dpp::block::block_info::BlockInfo; +use dpp::consensus::basic::decode::DecodingError; use dpp::consensus::basic::document::{DataContractNotPresentError, InvalidDocumentTypeError}; +use dpp::consensus::basic::BasicError; use dpp::consensus::state::contract_moderation::{ + ContractDocumentAlreadyRestoredError, ContractDocumentRemovalNotFoundError, ContractModerationNotEnabledError, ContractModerationTargetNotAllowedError, ContractModerationTargetNotFoundError, ContractSuspensionNotInFutureError, ContractUserAlreadyBannedError, ContractUserBannedError, ContractUserNotBannedError, - ContractUserNotSuspendedError, DocumentModerationWindowElapsedError, + ContractUserNotSuspendedError, ContractUserNotWarnedError, + ContractUserWarningLimitReachedError, DocumentModerationWindowElapsedError, + DocumentRestoreHashMismatchError, DocumentRestoreWindowElapsedError, DocumentTypeNotDeletableByModeratorsError, IdentityNotContractModeratorError, }; use dpp::consensus::state::document::document_not_found_error::DocumentNotFoundError; @@ -22,21 +27,27 @@ use dpp::consensus::state::state_error::StateError; use dpp::consensus::ConsensusError; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::config::moderation::{ - ContractModerationConfig, ContractModerationList, ContractModerationStatus, + ContractDocumentRemoval, ContractDocumentRestoration, ContractModerationConfig, + ContractModerationList, ContractModerationStatus, }; use dpp::data_contract::config::v2::DataContractConfigGettersV2; -use dpp::data_contract::document_type::accessors::DocumentTypeV2Getters; -use dpp::document::DocumentV0Getters; +use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; +use dpp::data_contract::errors::DataContractError; +use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; +use dpp::document::{Document, DocumentV0Getters}; use dpp::prelude::{ConsensusValidationResult, Identifier}; use dpp::state_transition::contract_user_moderation_transition::accessors::ContractUserModerationTransitionAccessorsV0; use dpp::state_transition::contract_user_moderation_transition::{ ContractUserModerationAction, ContractUserModerationTransition, }; use dpp::state_transition::StateTransitionOwned; +use dpp::util::hash::hash_double; use dpp::version::PlatformVersion; use drive::drive::contract::DataContractFetchInfo; use drive::grovedb::TransactionArg; -use drive::state_transition_action::contract::contract_user_moderation::v0::ContractDocumentDeletionContext; +use drive::state_transition_action::contract::contract_user_moderation::v0::{ + ContractDocumentDeletionContext, ContractDocumentRestorationContext, +}; use drive::state_transition_action::contract::contract_user_moderation::ContractUserModerationTransitionAction; use drive::state_transition_action::system::bump_identity_data_contract_nonce_action::BumpIdentityDataContractNonceAction; use drive::state_transition_action::StateTransitionAction; @@ -59,16 +70,19 @@ pub(in crate::execution::validation::state_transition::state_transitions::contra } impl ContractUserModerationStateTransitionStateValidationV0 for ContractUserModerationTransition { - /// A document deletion is checked by `transform_document_deletion_v0`. For the rest: + /// A document deletion is checked by `transform_document_deletion_v0` and a document + /// restore by `transform_document_restore_v0`. For the rest: /// reads the contract and the target's status and checks the moderation: the contract /// keeps the list the action edits, the signer is its owner or one of its moderators, the - /// target is neither and exists, and the action fits the target's status. Every refusal, - /// a contract that does not exist included, is paid for by bumping the signer's contract - /// nonce. + /// target is neither and exists, and the action fits the target's status (a warn fits + /// while the target carries fewer than `SystemLimits::max_contract_warnings_per_identity` + /// warnings). Every refusal, a contract that does not exist included, is paid for by + /// bumping the signer's contract nonce. /// - /// The action carries what Drive needs of the target's status as read here, so Drive edits - /// the lists without reading them again, and the mempool, which transforms without a state validation stage, - /// refuses with the same consensus codes as a block. + /// The action carries what Drive needs of the target's status as read here (and for a + /// warn the block time the warning is stamped with), so Drive edits the lists without + /// reading them again, and the mempool, which transforms without a state validation + /// stage, refuses with the same consensus codes as a block. fn transform_into_action_v0( &self, platform: &PlatformRef, @@ -141,10 +155,28 @@ impl ContractUserModerationStateTransitionStateValidationV0 for ContractUserMode platform_version, ); } + ContractUserModerationAction::RestoreDocument { + document_type_name, + document, + } => { + return transform_document_restore_v0( + self, + platform, + block_info, + &contract_fetch_info, + document_type_name, + document.as_slice(), + execution_context, + tx, + platform_version, + ); + } ContractUserModerationAction::Ban { identity_id, .. } | ContractUserModerationAction::Unban { identity_id } | ContractUserModerationAction::Suspend { identity_id, .. } - | ContractUserModerationAction::Unsuspend { identity_id } => *identity_id, + | ContractUserModerationAction::Unsuspend { identity_id } + | ContractUserModerationAction::Warn { identity_id, .. } + | ContractUserModerationAction::ClearWarnings { identity_id } => *identity_id, }; let Some(list) = list_of(action) else { return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( @@ -165,15 +197,18 @@ impl ContractUserModerationStateTransitionStateValidationV0 for ContractUserMode IdentityNotContractModeratorError::new(contract_id, moderator_id).into(), ); } - // Whoever may moderate (the owner and the moderators) cannot be put on a list. They can + // Whoever the contract protects (the owner and the moderators, and the owner of an + // elected contract whose declaration says so) cannot be put on a list. They can // be taken off one: a contract update may name as moderator an identity that already // carries an entry, and without the removal that entry could only be lifted by demoting // the moderator first. let adds_an_entry = matches!( action, - ContractUserModerationAction::Ban { .. } | ContractUserModerationAction::Suspend { .. } + ContractUserModerationAction::Ban { .. } + | ContractUserModerationAction::Suspend { .. } + | ContractUserModerationAction::Warn { .. } ); - if adds_an_entry && moderation.may_moderate(&owner_id, &target_id) { + if adds_an_entry && moderation.protects(&owner_id, &target_id) { return refuse( ContractModerationTargetNotAllowedError::new(contract_id, target_id).into(), ); @@ -202,14 +237,22 @@ impl ContractUserModerationStateTransitionStateValidationV0 for ContractUserMode )?; execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee)); - if let Some(error) = refusal_for_status(action, target_id, &status, contract_id, block_info) - { + if let Some(error) = refusal_for_status( + action, + target_id, + &status, + contract_id, + block_info, + platform_version, + ) { return refuse(error); } Ok(ConsensusValidationResult::new_with_data( ContractUserModerationTransitionAction::from_borrowed_transition_with_status( - self, &status, + self, + &status, + block_info.time_ms, ) .into(), )) @@ -298,7 +341,7 @@ fn transform_document_deletion_v0( // What protects the owner and the moderators from a ban protects their documents: // the owner demotes a moderator by a contract update before deleting what it wrote. let document_owner_id = document.owner_id(); - if moderation.may_moderate(&owner_id, &document_owner_id) { + if moderation.protects(&owner_id, &document_owner_id) { return refuse( ContractModerationTargetNotAllowedError::new(contract_id, document_owner_id).into(), ); @@ -333,9 +376,40 @@ fn transform_document_deletion_v0( } } - // The record this deletion leaves is always new: a document id is produced at most once - // (it commits to the nonce of its create transition), so no earlier removal can have - // recorded this id and nothing has to be read to write it. + // What the record commits to: the document as serialized under its type, which a restore + // must bring back byte for byte. It is serialized here, from the document as read, rather + // than hashed as stored: a document stored under an earlier version of its type or of the + // serialization would never re-serialize to its stored bytes, and a client keeping the + // document (not the bytes) could never match them. + let serialized = document.serialize(document_type, contract, platform_version)?; + execution_context.add_operation(ValidationOperation::DoubleSha256( + serialized.len() as u16 / SHA256_BLOCK_SIZE, + )); + let document_hash = hash_double(serialized); + + // A document id is produced at most once (it commits to the nonce of its create + // transition), so the only record this id can already have is of a deletion a moderator + // restored: the document is live again and this deletion writes a fresh record in its + // place. An unrestored record beside a live document is a state no transition produces. + let (removal_fee, existing_removal) = platform.drive.fetch_contract_document_removal_with_fee( + contract_id, + document_type_name, + document_id, + &block_info.epoch, + tx, + platform_version, + )?; + execution_context.add_operation(ValidationOperation::PrecalculatedOperation(removal_fee)); + let replaces_restored_record = match existing_removal { + None => false, + Some(removal) if removal.is_restored() => true, + Some(_) => { + return Err(Error::Execution(ExecutionError::DriveIncoherence( + "a document with an unrestored moderation removal record exists", + ))) + } + }; + Ok(ConsensusValidationResult::new_with_data( ContractUserModerationTransitionAction::from_borrowed_transition_with_document_deletion( transition, @@ -343,6 +417,205 @@ fn transform_document_deletion_v0( data_contract_fetch_info: Arc::clone(contract_fetch_info), document_owner_id, removed_at: block_info.time_ms, + document_hash, + replaces_restored_record, + }, + ) + .into(), + )) +} + +/// A document restore: the document type exists and says moderators may delete its +/// documents, the signer is the contract's owner or one of its moderators, the bytes decode +/// under the type, the document has a removal record that is not yet restored, block time is +/// within the restore window after the removal, the bytes hash to what the record holds, and +/// no other document holds a value of one of the type's unique indexes. Every refusal is paid +/// for by bumping the signer's contract nonce. +/// +/// The action carries the contract, the decoded document and the record marked restored, so +/// Drive puts the document back and marks the record without reading again. Nothing the +/// document type prices is charged, neither its creation token cost nor its `actionFees` +/// creation fee, and no fee agreement is asked: a moderator undoes a moderation, it does not +/// create content. The document comes back as it was, `$updatedAt` included, so a type's +/// deletion window (`canBeDeletedByModeratorsFor`) may have run out on it by then. +#[allow(clippy::too_many_arguments)] +fn transform_document_restore_v0( + transition: &ContractUserModerationTransition, + platform: &PlatformRef, + block_info: &BlockInfo, + contract_fetch_info: &Arc, + document_type_name: &str, + document_bytes: &[u8], + execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + platform_version: &PlatformVersion, +) -> Result, Error> { + let contract = &contract_fetch_info.contract; + let contract_id = contract.id(); + let moderator_id = transition.owner_id(); + let bump_action = || { + StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_contract_user_moderation_transition( + transition, + ), + ) + }; + let refuse = |error: ConsensusError| { + Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action(), + vec![error], + )) + }; + + let Some(document_type) = contract.document_type_optional_for_name(document_type_name) else { + return refuse( + InvalidDocumentTypeError::new(document_type_name.to_string(), contract_id).into(), + ); + }; + // Only a type moderators delete from keeps records, so only such a type has anything to + // restore. + let moderation = contract + .config() + .moderation() + .filter(|_| document_type.documents_can_be_deleted_by_moderators()); + let Some(moderation) = moderation else { + return refuse( + DocumentTypeNotDeletableByModeratorsError::new( + contract_id, + document_type_name.to_string(), + ) + .into(), + ); + }; + + let owner_id = contract.owner_id(); + if !moderation.may_moderate(&owner_id, &moderator_id) { + return refuse(IdentityNotContractModeratorError::new(contract_id, moderator_id).into()); + } + + // The bytes are the moderator's: whatever they fail to decode as is a refusal, never an + // execution error, which would fail the block. + let decoded = + match Document::from_bytes_in_consensus(document_bytes, document_type, platform_version) { + Ok(decoded) => decoded, + Err(error) => ConsensusValidationResult::new_with_error(ConsensusError::BasicError( + BasicError::ContractError(DataContractError::DecodingDocumentError( + DecodingError::new(format!( + "the document to restore does not decode under document type {}: {}", + document_type_name, error + )), + )), + )), + }; + if !decoded.is_valid() { + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action(), + decoded.errors, + )); + } + let document = decoded.into_data()?; + let document_id = document.id(); + + let (removal_fee, removal) = platform.drive.fetch_contract_document_removal_with_fee( + contract_id, + document_type_name, + document_id, + &block_info.epoch, + tx, + platform_version, + )?; + execution_context.add_operation(ValidationOperation::PrecalculatedOperation(removal_fee)); + let Some(removal) = removal else { + return refuse( + ContractDocumentRemovalNotFoundError::new( + contract_id, + document_type_name.to_string(), + document_id, + ) + .into(), + ); + }; + // A restored record means the document is live: there is nothing to bring back. + if let Some(restoration) = &removal.restoration { + return refuse( + ContractDocumentAlreadyRestoredError::new( + contract_id, + document_id, + restoration.moderator_id, + restoration.restored_at, + ) + .into(), + ); + } + + // The window bounds how far back a moderation can be undone: past it the removal stands. + // At exactly the removal time plus the window the restore still passes. + let window_ms = platform_version + .system_limits + .contract_document_restore_window_ms; + if block_info.time_ms > removal.removed_at.saturating_add(window_ms) { + return refuse( + DocumentRestoreWindowElapsedError::new( + contract_id, + document_id, + removal.removed_at, + window_ms, + block_info.time_ms, + ) + .into(), + ); + } + + // The record pins the content: only the document as it was comes back, not an edit of it. + // The hash is billed, as the one the record was written with was. + execution_context.add_operation(ValidationOperation::DoubleSha256( + document_bytes.len() as u16 / SHA256_BLOCK_SIZE, + )); + let document_hash = hash_double(document_bytes); + if document_hash != removal.document_hash { + return refuse( + DocumentRestoreHashMismatchError::new( + contract_id, + document_id, + removal.document_hash, + document_hash, + ) + .into(), + ); + } + + // What the hash does not pin: another document may have taken a value of one of the + // type's unique indexes while the document was gone, and would clash with it. + if document_type.indexes().values().any(|index| index.unique) { + let uniqueness = platform.drive.validate_restored_document_uniqueness( + contract, + document_type, + &document, + tx, + platform_version, + )?; + if !uniqueness.is_valid() { + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action(), + uniqueness.errors, + )); + } + } + + let removal = ContractDocumentRemoval { + restoration: Some(ContractDocumentRestoration { + moderator_id, + restored_at: block_info.time_ms, + }), + ..removal + }; + Ok(ConsensusValidationResult::new_with_data( + ContractUserModerationTransitionAction::from_borrowed_transition_with_document_restoration( + transition, + ContractDocumentRestorationContext { + data_contract_fetch_info: Arc::clone(contract_fetch_info), + document, + removal, }, ) .into(), @@ -359,13 +632,19 @@ fn list_of(action: &ContractUserModerationAction) -> Option { Some(ContractModerationList::Suspensions) } - ContractUserModerationAction::DeleteDocument { .. } => None, + ContractUserModerationAction::Warn { .. } + | ContractUserModerationAction::ClearWarnings { .. } => { + Some(ContractModerationList::Warnings) + } + ContractUserModerationAction::DeleteDocument { .. } + | ContractUserModerationAction::RestoreDocument { .. } => None, } } /// The lists the action needs to know about: its own, and for a ban or a suspend the other -/// one as well, because a ban removes a suspension and a suspend is refused for a banned -/// identity. Only lists the contract keeps are read. +/// barring one as well, because a ban removes a suspension and a suspend is refused for a +/// banned identity. Warnings bar nothing and are left alone by a ban, so the warning list is +/// read by a warn and a clearing alone. Only lists the contract keeps are read. fn lists_to_read( moderation: &ContractModerationConfig, action: &ContractUserModerationAction, @@ -373,11 +652,14 @@ fn lists_to_read( ) -> Vec { match action { ContractUserModerationAction::Ban { .. } | ContractUserModerationAction::Suspend { .. } => { - moderation.lists().collect() + moderation.barring_lists().collect() } ContractUserModerationAction::Unban { .. } | ContractUserModerationAction::Unsuspend { .. } - | ContractUserModerationAction::DeleteDocument { .. } => vec![list], + | ContractUserModerationAction::Warn { .. } + | ContractUserModerationAction::ClearWarnings { .. } + | ContractUserModerationAction::DeleteDocument { .. } + | ContractUserModerationAction::RestoreDocument { .. } => vec![list], } } @@ -388,6 +670,7 @@ fn refusal_for_status( status: &ContractModerationStatus, contract_id: Identifier, block_info: &BlockInfo, + platform_version: &PlatformVersion, ) -> Option { match action { ContractUserModerationAction::Ban { .. } => status @@ -414,7 +697,21 @@ fn refusal_for_status( .suspension .is_none() .then(|| ContractUserNotSuspendedError::new(contract_id, target_id).into()), + // A warning is refused only once the entry is full: a banned or suspended identity + // may be warned, since the warning outlives the ban and says why it came to that. + ContractUserModerationAction::Warn { .. } => { + let max_warnings = platform_version + .system_limits + .max_contract_warnings_per_identity; + (status.warnings.len() >= usize::from(max_warnings)).then(|| { + ContractUserWarningLimitReachedError::new(contract_id, target_id, max_warnings) + .into() + }) + } + ContractUserModerationAction::ClearWarnings { .. } => (!status.warned()) + .then(|| ContractUserNotWarnedError::new(contract_id, target_id).into()), // A document deletion reads no list. - ContractUserModerationAction::DeleteDocument { .. } => None, + ContractUserModerationAction::DeleteDocument { .. } + | ContractUserModerationAction::RestoreDocument { .. } => None, } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs index 179f093386a..516572d3816 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs @@ -1,5 +1,6 @@ //! Contract moderation through the whole processing pipeline: the transition that edits a -//! contract's banlist and suspension list, and the document gate that enforces them. +//! contract's banlist, suspension list and warning list, and the document gate that enforces +//! the first two. use crate::execution::check_tx::CheckTxLevel::FirstTimeCheck; use crate::execution::validation::state_transition::tests::setup_identity; @@ -18,9 +19,11 @@ use dpp::data_contract::accessors::v1::DataContractV1Setters; use dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; use dpp::data_contract::associated_token::token_configuration::TokenConfiguration; use dpp::data_contract::config::moderation::{ - ContractBan, ContractDocumentRemoval, ContractModerationConfig, ContractModerationList, - ContractModerationListStatus, ContractModerationListStatuses, ContractModerationReason, - ContractModerationStatus, ContractModerators, ContractSuspension, + ContractBan, ContractDocumentRemoval, ContractDocumentRestoration, ContractModerationConfig, + ContractModerationDocument, ContractModerationList, ContractModerationListStatus, + ContractModerationListStatuses, ContractModerationReason, ContractModerationStatus, + ContractModerators, ContractSuspension, ContractWarning, ElectedModerators, InterimModerators, + ModerationAbility, DEFAULT_ELECTION_WINDOW_SECONDS, }; use dpp::data_contract::config::v2::DataContractConfigGettersV2; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; @@ -30,12 +33,13 @@ use dpp::data_contract::document_type::random_document::{ use dpp::data_contract::schema::DataContractSchemaMethodsV0; use dpp::data_contract::DataContract; use dpp::document::document_methods::DocumentMethodsV0; +use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; use dpp::document::Document; use dpp::document::{DocumentV0Getters, DocumentV0Setters}; use dpp::fee::Credits; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::{Identity, IdentityPublicKey, KeyID, TimestampMillis}; -use dpp::platform_value::{platform_value, Bytes32, Identifier, Value}; +use dpp::platform_value::{platform_value, BinaryData, Bytes32, Identifier, Value}; use dpp::prelude::IdentityNonce; use dpp::serialization::PlatformSerializable; use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; @@ -54,23 +58,27 @@ use dpp::state_transition::proof_result::{ use dpp::state_transition::StateTransition; use dpp::tests::fixtures::get_data_contract_fixture; use dpp::tests::json_document::json_document_to_contract; +use dpp::util::hash::hash_double; use dpp::version::PlatformVersion; use dpp::ProtocolError; use drive::drive::contract::moderation::types::{ ContractDocumentRemovalsQuery, ContractDocumentRemovalsSelection, }; +use drive::drive::document::query::QueryDocumentsOutcomeV0Methods; use drive::drive::Drive; use drive::grovedb::Transaction; +use drive::query::DriveDocumentQuery; use drive::util::storage_flags::StorageFlags; use rand::rngs::StdRng; use rand::SeedableRng; use simple_signer::signer::SimpleSigner; use std::cell::{Cell, RefCell}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; const DATA_CONTRACT_NOT_PRESENT: u32 = 10400; const CONTRACT_MODERATION_SELF_TARGET: u32 = 10901; const CONTRACT_MODERATION_REASON_TOO_LONG: u32 = 10903; +const INVALID_CONTRACT_MODERATION_REASON_DOCUMENTS: u32 = 10904; const OVERFLOW: u32 = 10700; const CONTRACT_MODERATION_NOT_ENABLED: u32 = 41100; const IDENTITY_NOT_CONTRACT_MODERATOR: u32 = 41101; @@ -86,6 +94,15 @@ const CONTRACT_MODERATOR_IDENTITY_NOT_FOUND: u32 = 41110; const CONTRACT_MODERATION_COUNTERPARTY_BARRED: u32 = 41114; const DOCUMENT_TYPE_NOT_DELETABLE_BY_MODERATORS: u32 = 41115; const DOCUMENT_MODERATION_WINDOW_ELAPSED: u32 = 41116; +const CONTRACT_USER_NOT_WARNED: u32 = 41117; +const CONTRACT_USER_WARNING_LIMIT_REACHED: u32 = 41118; +const CONTRACT_MODERATED_DOCUMENT_TYPE_NOT_YET_USABLE: u32 = 41200; +const CONTRACT_DOCUMENT_REMOVAL_NOT_FOUND: u32 = 41119; +const DOCUMENT_RESTORE_WINDOW_ELAPSED: u32 = 41120; +const DOCUMENT_RESTORE_HASH_MISMATCH: u32 = 41121; +const CONTRACT_DOCUMENT_ALREADY_RESTORED: u32 = 41122; +const DECODING_DOCUMENT: u32 = 10223; +const DUPLICATE_UNIQUE_INDEX: u32 = 40105; const INVALID_DOCUMENT_TYPE: u32 = 10406; const INVALID_CONTRACT_STRUCTURE: u32 = 10231; const INVALID_CONTRACT_MODERATION_CONFIG: u32 = 10900; @@ -161,9 +178,19 @@ const THE_MODERATOR: Identifier = Identifier::new([7; 32]); const THE_OWNER: Identifier = Identifier::new([8; 32]); fn moderation(banlist: bool, suspensions: bool, moderator: Identifier) -> ContractModerationConfig { + moderation_with_warnings(banlist, suspensions, false, moderator) +} + +fn moderation_with_warnings( + banlist: bool, + suspensions: bool, + warnings: bool, + moderator: Identifier, +) -> ContractModerationConfig { ContractModerationConfig { banlist, suspensions, + warnings, moderators: ContractModerators::AppointedModerators([moderator].into_iter().collect()), } } @@ -211,7 +238,17 @@ impl Setup { ) .data_contract_owned(); let moderation = moderation.map(|mut moderation| { - if let ContractModerators::AppointedModerators(ids) = &mut moderation.moderators { + let named = match &mut moderation.moderators { + ContractModerators::AppointedModerators(ids) => Some(ids), + ContractModerators::Elected(elected) => match &mut elected.interim { + InterimModerators::AppointedModerators(ids) => Some(ids), + InterimModerators::ContractOwner + | InterimModerators::NotYetUsable + | InterimModerators::NoModeration => None, + }, + ContractModerators::ContractOwner => None, + }; + if let Some(ids) = named { if ids.remove(&THE_MODERATOR) { ids.insert(moderator.id()); } @@ -393,6 +430,42 @@ impl Setup { .map(|entry| entry.removal) } + /// The document of `document_type_name` stored at `document_id`, if there is one + fn stored_document( + &self, + document_type_name: &str, + document_id: Identifier, + transaction: Option<&Transaction>, + ) -> Option { + let document_type = self + .contract + .document_type_for_name(document_type_name) + .expect("expected the document type"); + let query = DriveDocumentQuery::new_primary_key_single_item_query( + &self.contract, + document_type, + document_id, + ); + self.platform + .drive + .query_documents(query, None, false, transaction, None) + .expect("expected to query the document") + .documents_owned() + .pop() + } + + /// `document` serialized under its type: what a restore carries, and what a removal + /// record hashes + fn document_bytes(&self, document_type_name: &str, document: &Document) -> Vec { + let document_type = self + .contract + .document_type_for_name(document_type_name) + .expect("expected the document type"); + document + .serialize(document_type, &self.contract, PlatformVersion::latest()) + .expect("expected to serialize the document") + } + fn balance(&self, identity_id: Identifier, transaction: Option<&Transaction>) -> Credits { self.platform .drive @@ -405,8 +478,10 @@ impl Setup { .expect("expected the identity to have a balance") } - /// Proves the committed state for a document deletion and checks the proof shows its - /// record. + /// Proves the committed state for a document deletion or restore and checks the proof + /// shows its record. A restore's verifier reads the document's id out of the bytes under + /// the contract's document type, so it is given the contract, as a client holding it is; + /// a deletion's needs none. fn assert_removal_proved(&self, transition: &StateTransition) -> ContractDocumentRemoval { let platform_version = PlatformVersion::latest(); let proof = self @@ -416,11 +491,13 @@ impl Setup { .expect("expected to prove the state transition") .into_data() .expect("expected proof bytes"); + let known_contracts: BTreeMap = + BTreeMap::from([(self.contract.id(), self.contract.clone())]); let (_, outcome) = Drive::verify_state_transition_was_executed_with_proof( transition, &BlockInfo::default(), &proof, - &|_| Ok(None), + &|id| Ok(known_contracts.get(id).cloned().map(std::sync::Arc::new)), platform_version, ) .expect("expected the proof to verify"); @@ -530,13 +607,22 @@ impl Setup { &self, identity_id: Identifier, transaction: Option<&Transaction>, + ) -> ContractModerationStatus { + self.status_on(identity_id, &BOTH, transaction) + } + + fn status_on( + &self, + identity_id: Identifier, + lists: &[ContractModerationList], + transaction: Option<&Transaction>, ) -> ContractModerationStatus { self.platform .drive .fetch_contract_moderation_status( self.contract.id(), identity_id, - &BOTH, + lists, transaction, PlatformVersion::latest(), ) @@ -632,6 +718,7 @@ fn suspension_reason() -> ContractModerationReason { ContractModerationReason { code: Some(7), text: "flooding".to_string(), + documents: vec![], } } @@ -673,6 +760,34 @@ fn unsuspend_action(identity_id: Identifier) -> ContractUserModerationAction { ContractUserModerationAction::Unsuspend { identity_id } } +fn warn_action(identity_id: Identifier, text: &str) -> ContractUserModerationAction { + ContractUserModerationAction::Warn { + identity_id, + reason: ContractModerationReason::from_text(text), + } +} + +fn clear_warnings_action(identity_id: Identifier) -> ContractUserModerationAction { + ContractUserModerationAction::ClearWarnings { identity_id } +} + +/// The warning a `warn_action` for `text` leaves in a block at `time_ms`. +fn warning(time_ms: TimestampMillis, text: &str) -> ContractWarning { + ContractWarning { + warned_at: time_ms, + reason: ContractModerationReason::from_text(text), + } +} + +/// The status of an identity that carries `warnings` and nothing else. +fn warned_with(warnings: Vec) -> ContractModerationStatus { + ContractModerationStatus { + ban: None, + suspension: None, + warnings, + } +} + #[tokio::test] async fn should_ban_a_user_refuse_its_documents_and_let_them_through_again_after_an_unban() { let setup = Setup::new(Some(moderation(true, true, THE_MODERATOR))).await; @@ -765,6 +880,388 @@ async fn should_suspend_until_a_block_time_and_sweep_the_suspension_once_it_laps ); } +#[tokio::test] +async fn should_warn_a_user_without_barring_it_accumulate_the_warnings_and_clear_them() { + let setup = Setup::new(Some(moderation_with_warnings( + true, + false, + true, + THE_MODERATOR, + ))) + .await; + let user_id = setup.user.id(); + let warnings = [ContractModerationList::Warnings]; + + let transaction = setup.platform.drive.grove.start_transaction(); + let first = setup + .moderate(&setup.owner, warn_action(user_id, "first strike")) + .await; + assert_success(&setup.process(&first, &transaction)); + // A warned user carries on: its documents pass, in the mempool and in a block. + let document = setup.create_document(&setup.user).await; + assert!(setup.check_tx(&document).is_empty()); + assert_success(&setup.process(&document, &transaction)); + + // A second warning, by the named moderator, in a later block: the entry grows. + let second = setup + .moderate(&setup.moderator, warn_action(user_id, "second strike")) + .await; + assert_success(&setup.process_at(&second, BLOCK_TIME_MS + 5_000, &transaction)); + setup.commit(transaction); + assert_eq!( + setup.status_on(user_id, &warnings, None), + warned_with(vec![ + warning(BLOCK_TIME_MS, "first strike"), + warning(BLOCK_TIME_MS + 5_000, "second strike"), + ]) + ); + // The banlist says nothing: warnings bar nothing. + assert_eq!( + setup.status(user_id, None), + ContractModerationStatus::default() + ); + // The execution proof shows the warning list alone, with the latest warning last. + assert_eq!( + setup.assert_execution_proved(&second), + ContractModerationListStatuses(vec![ContractModerationListStatus::Warnings { + warnings: vec![ + warning(BLOCK_TIME_MS, "first strike"), + warning(BLOCK_TIME_MS + 5_000, "second strike"), + ], + }]) + ); + + let transaction = setup.platform.drive.grove.start_transaction(); + let clear = setup + .moderate(&setup.owner, clear_warnings_action(user_id)) + .await; + assert_success(&setup.process(&clear, &transaction)); + assert_eq!( + setup.status_on(user_id, &warnings, Some(&transaction)), + ContractModerationStatus::default() + ); + // Nothing is left to clear. + let clear_again = setup + .moderate(&setup.owner, clear_warnings_action(user_id)) + .await; + assert_paid_with_code( + &setup.process(&clear_again, &transaction), + CONTRACT_USER_NOT_WARNED, + ); + setup.commit(transaction); + assert_eq!( + setup.assert_execution_proved(&clear), + ContractModerationListStatuses(vec![ContractModerationListStatus::Warnings { + warnings: vec![], + }]) + ); +} + +#[tokio::test] +async fn should_refuse_a_warning_past_the_limit_until_the_warnings_are_cleared() { + let setup = Setup::new(Some(moderation_with_warnings( + false, + false, + true, + THE_MODERATOR, + ))) + .await; + let user_id = setup.user.id(); + let max_warnings = PlatformVersion::latest() + .system_limits + .max_contract_warnings_per_identity; + + let transaction = setup.platform.drive.grove.start_transaction(); + for index in 0..max_warnings { + let warn = setup + .moderate( + &setup.owner, + warn_action(user_id, &format!("strike {index}")), + ) + .await; + assert_success(&setup.process(&warn, &transaction)); + } + setup.commit(transaction); + assert_eq!( + setup + .status_on(user_id, &[ContractModerationList::Warnings], None) + .warnings + .len(), + usize::from(max_warnings) + ); + // The mempool refuses the one too many before a block does. + let one_too_many = setup + .moderate(&setup.owner, warn_action(user_id, "one too many")) + .await; + let mempool_errors = setup.check_tx(&one_too_many); + assert_eq!(mempool_errors.len(), 1, "{mempool_errors:?}"); + assert_eq!( + mempool_errors[0].code(), + CONTRACT_USER_WARNING_LIMIT_REACHED + ); + let transaction = setup.platform.drive.grove.start_transaction(); + assert_paid_with_code( + &setup.process(&one_too_many, &transaction), + CONTRACT_USER_WARNING_LIMIT_REACHED, + ); + + let clear = setup + .moderate(&setup.owner, clear_warnings_action(user_id)) + .await; + assert_success(&setup.process(&clear, &transaction)); + let again = setup + .moderate(&setup.owner, warn_action(user_id, "a fresh start")) + .await; + assert_success(&setup.process(&again, &transaction)); +} + +#[tokio::test] +async fn should_keep_the_warning_list_out_of_bans_and_the_gate() { + let setup = Setup::new(Some(moderation_with_warnings( + true, + true, + true, + THE_MODERATOR, + ))) + .await; + let user_id = setup.user.id(); + let every_list = [ + ContractModerationList::Banlist, + ContractModerationList::Suspensions, + ContractModerationList::Warnings, + ]; + + let transaction = setup.platform.drive.grove.start_transaction(); + let warn = setup + .moderate(&setup.owner, warn_action(user_id, "first strike")) + .await; + assert_success(&setup.process(&warn, &transaction)); + // A ban of a warned user leaves the warnings, which say how it came to that. + let ban = setup.moderate(&setup.owner, ban_action(user_id)).await; + assert_success(&setup.process(&ban, &transaction)); + assert_eq!( + setup.status_on(user_id, &every_list, Some(&transaction)), + ContractModerationStatus { + ban: banned(), + suspension: None, + warnings: vec![warning(BLOCK_TIME_MS, "first strike")], + } + ); + // A banned user may still be warned: the warning outlives the ban. + let warn_banned = setup + .moderate(&setup.owner, warn_action(user_id, "and again")) + .await; + assert_success(&setup.process(&warn_banned, &transaction)); + setup.commit(transaction); + + // The ban's proof covers the barring lists and leaves the warning list unknown. + let proved = setup.assert_execution_proved(&ban); + assert_eq!(proved.banned(), Some(true)); + assert_eq!(proved.suspended_until(), Some(None)); + assert_eq!(proved.warnings(), None); + + // Unbanned, the user carries on with its two warnings on record. + let transaction = setup.platform.drive.grove.start_transaction(); + let unban = setup.moderate(&setup.owner, unban_action(user_id)).await; + assert_success(&setup.process(&unban, &transaction)); + let document = setup.create_document(&setup.user).await; + assert_success(&setup.process(&document, &transaction)); + assert_eq!( + setup + .status_on( + user_id, + &[ContractModerationList::Warnings], + Some(&transaction) + ) + .warnings + .len(), + 2 + ); +} + +#[tokio::test] +async fn should_store_the_documents_a_reason_cites_without_looking_them_up() { + let setup = Setup::new_with_posts(Some(moderation_with_warnings( + true, + false, + true, + THE_MODERATOR, + ))) + .await; + let user_id = setup.user.id(); + let max_documents = PlatformVersion::latest() + .system_limits + .max_contract_moderation_reason_documents; + let cite = |seeds: &[u8]| { + seeds + .iter() + .map(|seed| ContractModerationDocument { + document_type_name: POST.to_string(), + document_id: Identifier::from([*seed; 32]), + }) + .collect::>() + }; + + let transaction = setup.platform.drive.grove.start_transaction(); + // The user's real post, and one that never existed: neither is looked up. + let (post, create) = setup.create_document_of_type(&setup.user, POST).await; + assert_success(&setup.process(&create, &transaction)); + let mut documents = cite(&[0xEE]); + documents.push(ContractModerationDocument { + document_type_name: POST.to_string(), + document_id: post.id(), + }); + let reason = ContractModerationReason::from_text("these posts").with_documents(documents); + let warn = setup + .moderate( + &setup.owner, + ContractUserModerationAction::Warn { + identity_id: user_id, + reason: reason.clone(), + }, + ) + .await; + assert_success(&setup.process(&warn, &transaction)); + assert_eq!( + setup + .status_on( + user_id, + &[ContractModerationList::Warnings], + Some(&transaction) + ) + .warnings[0] + .reason, + reason + ); + // A ban cites documents the same way, and its proof carries them. + let ban = setup + .moderate( + &setup.owner, + ContractUserModerationAction::Ban { + identity_id: user_id, + reason: reason.clone(), + }, + ) + .await; + assert_success(&setup.process(&ban, &transaction)); + setup.commit(transaction); + assert_eq!( + setup + .assert_execution_proved(&ban) + .ban() + .flatten() + .map(|ban| &ban.reason), + Some(&reason) + ); + + // Too many, or one twice: refused unpaid, in the mempool as in a block. + let transaction = setup.platform.drive.grove.start_transaction(); + for documents in [ + cite(&(1..=max_documents as u8 + 1).collect::>()), + cite(&[1, 1]), + ] { + let refused = setup + .moderate( + &setup.owner, + ContractUserModerationAction::Warn { + identity_id: setup.stranger.id(), + reason: ContractModerationReason::from_text("spam").with_documents(documents), + }, + ) + .await; + assert_eq!( + setup.check_tx(&refused)[0].code(), + INVALID_CONTRACT_MODERATION_REASON_DOCUMENTS + ); + assert_unpaid_with_code( + &setup.process(&refused, &transaction), + INVALID_CONTRACT_MODERATION_REASON_DOCUMENTS, + ); + } +} + +#[tokio::test] +async fn should_refuse_a_warning_that_breaks_a_rule() { + let setup = Setup::new(Some(moderation_with_warnings( + true, + false, + true, + THE_MODERATOR, + ))) + .await; + let user_id = setup.user.id(); + let max_length = PlatformVersion::latest() + .system_limits + .max_contract_moderation_reason_length as usize; + let transaction = setup.platform.drive.grove.start_transaction(); + + // Only a moderator warns, and neither the owner nor a moderator can be warned. + let by_stranger = setup + .moderate(&setup.stranger, warn_action(user_id, "spam")) + .await; + assert_paid_with_code( + &setup.process(&by_stranger, &transaction), + IDENTITY_NOT_CONTRACT_MODERATOR, + ); + let owner_warns_moderator = setup + .moderate(&setup.owner, warn_action(setup.moderator.id(), "spam")) + .await; + assert_paid_with_code( + &setup.process(&owner_warns_moderator, &transaction), + CONTRACT_MODERATION_TARGET_NOT_ALLOWED, + ); + let self_target = setup + .moderate(&setup.owner, warn_action(setup.owner.id(), "spam")) + .await; + assert_unpaid_with_code( + &setup.process(&self_target, &transaction), + CONTRACT_MODERATION_SELF_TARGET, + ); + let unknown = setup + .moderate( + &setup.owner, + warn_action(Identifier::from([0xEE; 32]), "spam"), + ) + .await; + assert_paid_with_code( + &setup.process(&unknown, &transaction), + CONTRACT_MODERATION_TARGET_NOT_FOUND, + ); + // The reason of a warning is bounded like a ban's, and refused unpaid. + let too_long = setup + .moderate( + &setup.owner, + warn_action(user_id, &"x".repeat(max_length + 1)), + ) + .await; + assert_unpaid_with_code( + &setup.process(&too_long, &transaction), + CONTRACT_MODERATION_REASON_TOO_LONG, + ); + assert_eq!( + setup.check_tx(&too_long)[0].code(), + CONTRACT_MODERATION_REASON_TOO_LONG + ); + + // A contract without a warning list refuses a warning and a clearing alike. + let without = Setup::new(Some(moderation(true, true, THE_MODERATOR))).await; + let transaction = without.platform.drive.grove.start_transaction(); + let warn = without + .moderate(&without.owner, warn_action(without.user.id(), "spam")) + .await; + assert_paid_with_code( + &without.process(&warn, &transaction), + CONTRACT_MODERATION_NOT_ENABLED, + ); + let clear = without + .moderate(&without.owner, clear_warnings_action(without.user.id())) + .await; + assert_paid_with_code( + &without.process(&clear, &transaction), + CONTRACT_MODERATION_NOT_ENABLED, + ); +} + #[tokio::test] async fn should_let_a_named_moderator_moderate_and_refuse_everyone_else() { let mut setup = Setup::new(None).await; @@ -880,6 +1377,7 @@ async fn should_refuse_actions_that_do_not_fit_the_targets_status() { ContractModerationStatus { ban: banned(), suspension: None, + warnings: vec![], } ); let ban_again = setup.moderate(&setup.owner, ban_action(user_id)).await; @@ -958,6 +1456,17 @@ async fn should_fix_the_lists_a_contract_keeps_when_it_is_created() { let update = setup.contract_update(changed).await; config_update_refused(setup.process(&update, &transaction), what); } + // The warning list is fixed at creation like the others. + let mut with_warnings = setup.contract.clone(); + with_warnings.set_version(2); + with_warnings.set_config(with_warnings.config().clone().with_moderation(Some( + moderation_with_warnings(true, false, true, setup.moderator.id()), + ))); + let update = setup.contract_update(with_warnings).await; + config_update_refused( + setup.process(&update, &transaction), + "a warning list turned on", + ); } #[tokio::test] @@ -1150,6 +1659,7 @@ async fn should_refuse_an_update_adding_a_moderator_that_does_not_exist() { moderators: ContractModerators::AppointedModerators( [setup.moderator.id(), unknown].into_iter().collect(), ), + warnings: false, })), ); let update = setup.contract_update(widened).await; @@ -1179,6 +1689,7 @@ async fn should_accept_an_update_that_keeps_the_existing_moderators() { .into_iter() .collect(), ), + warnings: false, })), ); let update = setup.contract_update(widened).await; @@ -1199,6 +1710,7 @@ async fn should_accept_an_update_that_keeps_the_existing_moderators() { .into_iter() .collect(), ), + warnings: false, })), ); let update = setup.contract_update(unchanged).await; @@ -1213,6 +1725,7 @@ async fn should_accept_the_owner_named_among_the_moderators() { moderators: ContractModerators::AppointedModerators( [THE_OWNER, THE_MODERATOR].into_iter().collect(), ), + warnings: false, })) .await; let transaction = setup.platform.drive.grove.start_transaction(); @@ -1260,6 +1773,7 @@ async fn should_store_the_reason_of_a_ban_and_of_a_suspension_with_any_code() { let suspension_reason = ContractModerationReason { code: Some(u16::MAX), text: "flooding the feed".to_string(), + documents: vec![], }; let transaction = setup.platform.drive.grove.start_transaction(); let suspend = setup @@ -1281,6 +1795,7 @@ async fn should_store_the_reason_of_a_ban_and_of_a_suspension_with_any_code() { until, reason: suspension_reason, }), + warnings: vec![], } ); @@ -1300,6 +1815,7 @@ async fn should_store_the_reason_of_a_ban_and_of_a_suspension_with_any_code() { ContractModerationStatus { ban: Some(ContractBan::default()), suspension: None, + warnings: vec![], } ); } @@ -1690,10 +2206,18 @@ fn delete_action( } } +fn restore_action(document_type_name: &str, document: Vec) -> ContractUserModerationAction { + ContractUserModerationAction::RestoreDocument { + document_type_name: document_type_name.to_string(), + document: BinaryData::new(document), + } +} + fn deletion_reason() -> ContractModerationReason { ContractModerationReason { code: Some(3), text: "spam".to_string(), + documents: vec![], } } @@ -1712,6 +2236,12 @@ async fn should_let_a_moderator_delete_a_post_leave_its_record_and_refund_nobody assert_success(&setup.process(&create, &transaction)); setup.commit(transaction); let balance_before = setup.balance(user_id, None); + // What the record will commit to: the post as stored, timestamps the block gave it + // included, serialized under its type. + let stored = setup + .stored_document(POST, post.id(), None) + .expect("expected the post to be stored"); + let document_hash = hash_double(setup.document_bytes(POST, &stored)); let delete = setup .moderate(&setup.moderator, delete_action(POST, post.id())) @@ -1749,12 +2279,14 @@ async fn should_let_a_moderator_delete_a_post_leave_its_record_and_refund_nobody ); drop(transaction); - // Its record says whose it was, who removed it, why and when. + // Its record says whose it was, who removed it, why and when, and what it was. let expected = ContractDocumentRemoval { document_owner_id: user_id, moderator_id: setup.moderator.id(), reason: deletion_reason(), removed_at: BLOCK_TIME_MS, + document_hash, + restoration: None, }; assert_eq!(setup.post_removal(post.id(), None), Some(expected.clone())); assert_eq!(setup.assert_removal_proved(&delete), expected); @@ -1967,6 +2499,7 @@ async fn should_tie_the_document_type_keyword_to_the_moderation_declaration() { banlist: false, suspensions: false, moderators: ContractModerators::ContractOwner, + warnings: false, })), ); let create = DataContractCreateTransition::new_from_data_contract( @@ -2502,3 +3035,702 @@ async fn should_fix_the_window_of_a_document_type() { assert_paid_with_code(&setup.process(&update, &transaction), DOCUMENT_TYPE_UPDATE); } } + +/// An elected declaration keeping both lists, allowing bans and suspensions, moderating +/// `moderated`, with `interim` until a team is seated +fn elected(interim: InterimModerators, moderated: &[&str]) -> ContractModerationConfig { + ContractModerationConfig { + banlist: true, + suspensions: true, + warnings: false, + moderators: ContractModerators::Elected(Box::new(ElectedModerators { + join_window: DEFAULT_ELECTION_WINDOW_SECONDS, + vote_window: DEFAULT_ELECTION_WINDOW_SECONDS, + challenge_cool_down: 1_209_600, + moderated_document_types: moderated + .iter() + .map(|name| { + ( + name.to_string(), + BTreeSet::from([ModerationAbility::Ban, ModerationAbility::Suspend]), + ) + }) + .collect(), + interim, + election_delay: None, + owner_protected: false, + })), + } +} + +fn assert_config_update_refused(execution: &StateTransitionExecutionResult, what: &str) { + assert!( + matches!( + execution, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::DataContractConfigUpdateError(_)), + .. + } + ), + "expected {what} to be refused, got {execution:?}" + ); +} + +/// The owner moderates an elected contract until a team is seated: it bans, nobody else may, +/// the moderated type is usable, and the declaration is frozen against every update that +/// touches it while one that leaves it alone goes through. +#[tokio::test] +async fn should_let_the_owner_moderate_an_elected_contract_in_its_interim() { + let setup = Setup::new(Some(elected( + InterimModerators::ContractOwner, + &[DOCUMENT_TYPE], + ))) + .await; + let transaction = setup.platform.drive.grove.start_transaction(); + + // The moderated type is usable: somebody moderates. + let post = setup.create_document(&setup.user).await; + assert_success(&setup.process(&post, &transaction)); + + let ban = setup + .moderate(&setup.owner, ban_action(setup.user.id())) + .await; + assert_success(&setup.process(&ban, &transaction)); + assert_eq!( + setup.status(setup.user.id(), Some(&transaction)).ban, + banned() + ); + let refused = setup.create_document(&setup.user).await; + assert_paid_with_code(&setup.process(&refused, &transaction), CONTRACT_USER_BANNED); + + // The interim names nobody else: the moderator of the other tests is a stranger here. + for actor in [&setup.moderator, &setup.stranger] { + let ban = setup.moderate(actor, ban_action(setup.user.id())).await; + assert_paid_with_code( + &setup.process(&ban, &transaction), + IDENTITY_NOT_CONTRACT_MODERATOR, + ); + } + + // Frozen: a changed field, a changed interim, and leaving elected moderation. + let with_config = |moderation: ContractModerationConfig| { + let mut changed = setup.contract.clone(); + changed.set_version(2); + changed.set_config(changed.config().clone().with_moderation(Some(moderation))); + changed + }; + let mut longer_cool_down = elected(InterimModerators::ContractOwner, &[DOCUMENT_TYPE]); + if let ContractModerators::Elected(declaration) = &mut longer_cool_down.moderators { + declaration.challenge_cool_down += 1; + } + let mut protected_owner = elected(InterimModerators::ContractOwner, &[DOCUMENT_TYPE]); + if let ContractModerators::Elected(declaration) = &mut protected_owner.moderators { + declaration.owner_protected = true; + } + for (moderation, what) in [ + (longer_cool_down, "a longer cool-down"), + (protected_owner, "the owner flag turned on"), + ( + elected( + InterimModerators::AppointedModerators([setup.moderator.id()].into()), + &[DOCUMENT_TYPE], + ), + "an appointed interim set", + ), + ( + moderation(true, true, setup.moderator.id()), + "leaving elected moderation", + ), + ] { + let update = setup.contract_update(with_config(moderation)).await; + assert_config_update_refused(&setup.process(&update, &transaction), what); + } + // A wider moderated set is frozen too, even when the same update adds the type it names + // (without the type, the declaration's own validation refuses first). + let mut wider = with_config(elected( + InterimModerators::ContractOwner, + &[DOCUMENT_TYPE, POST], + )); + add_document_type(&mut wider, POST, post_schema(false)); + let update = setup.contract_update(wider).await; + assert_config_update_refused( + &setup.process(&update, &transaction), + "a wider moderated set", + ); + + // An update that leaves the declaration alone goes through, and may add a type. + let mut widened = setup.contract.clone(); + widened.set_version(2); + add_document_type(&mut widened, POST, post_schema(false)); + let update = setup.contract_update(widened).await; + assert_success(&setup.process(&update, &transaction)); +} + +/// An appointed interim set moderates as an appointed set does: the moderator and the owner +/// ban, a stranger may not, and both are protected from each other's bans. +#[tokio::test] +async fn should_let_an_appointed_interim_set_moderate_an_elected_contract() { + let setup = Setup::new(Some(elected( + InterimModerators::AppointedModerators([THE_MODERATOR].into()), + &[DOCUMENT_TYPE], + ))) + .await; + let transaction = setup.platform.drive.grove.start_transaction(); + + let ban = setup + .moderate(&setup.moderator, ban_action(setup.user.id())) + .await; + assert_success(&setup.process(&ban, &transaction)); + let ban = setup + .moderate(&setup.owner, ban_action(setup.stranger.id())) + .await; + assert_success(&setup.process(&ban, &transaction)); + let ban = setup + .moderate(&setup.stranger, ban_action(setup.user.id())) + .await; + assert_paid_with_code( + &setup.process(&ban, &transaction), + IDENTITY_NOT_CONTRACT_MODERATOR, + ); + let ban = setup + .moderate(&setup.owner, ban_action(setup.moderator.id())) + .await; + assert_paid_with_code( + &setup.process(&ban, &transaction), + CONTRACT_MODERATION_TARGET_NOT_ALLOWED, + ); + let ban = setup + .moderate(&setup.moderator, ban_action(setup.owner.id())) + .await; + assert_paid_with_code( + &setup.process(&ban, &transaction), + CONTRACT_MODERATION_TARGET_NOT_ALLOWED, + ); + + // The interim set shares the moderators pot, as an appointed set does. + use dpp::data_contract::document_type::action_fees::ContractFeePot; + assert_eq!( + ContractFeePot::Moderators.recipients(&setup.contract), + [setup.moderator.id()].into() + ); +} + +/// With nobody named in the interim, the moderated type waits for a team: its creates are +/// refused, paid, in the mempool and in a block, another type works, nobody moderates, and +/// nobody claims the moderators pot. +#[tokio::test] +async fn should_block_the_moderated_types_of_an_elected_contract_until_a_team_is_seated() { + let setup = Setup::new(Some(elected( + InterimModerators::NotYetUsable, + &[DOCUMENT_TYPE], + ))) + .await; + let transaction = setup.platform.drive.grove.start_transaction(); + + let blocked = setup.create_document(&setup.user).await; + let mempool_errors = setup.check_tx(&blocked); + assert_eq!(mempool_errors.len(), 1, "{mempool_errors:?}"); + assert_eq!( + mempool_errors[0].code(), + CONTRACT_MODERATED_DOCUMENT_TYPE_NOT_YET_USABLE + ); + assert_paid_with_code( + &setup.process(&blocked, &transaction), + CONTRACT_MODERATED_DOCUMENT_TYPE_NOT_YET_USABLE, + ); + + let (_, allowed) = setup + .create_document_of_type(&setup.user, "prettyDocument") + .await; + assert_success(&setup.process(&allowed, &transaction)); + + // Nobody moderates: the owner included. + for actor in [&setup.owner, &setup.moderator] { + let ban = setup.moderate(actor, ban_action(setup.user.id())).await; + assert_paid_with_code( + &setup.process(&ban, &transaction), + IDENTITY_NOT_CONTRACT_MODERATOR, + ); + } + use dpp::data_contract::document_type::action_fees::ContractFeePot; + assert!(ContractFeePot::Moderators + .recipients(&setup.contract) + .is_empty()); +} + +/// Under a `noModeration` interim the moderated types are used, unmoderated: nobody may +/// moderate, the owner included, and nobody claims the pot. +#[tokio::test] +async fn should_leave_the_moderated_types_usable_and_unmoderated_under_no_moderation() { + let setup = Setup::new(Some(elected( + InterimModerators::NoModeration, + &[DOCUMENT_TYPE], + ))) + .await; + let transaction = setup.platform.drive.grove.start_transaction(); + + let post = setup.create_document(&setup.user).await; + assert!(setup.check_tx(&post).is_empty()); + assert_success(&setup.process(&post, &transaction)); + + for actor in [&setup.owner, &setup.moderator] { + let ban = setup.moderate(actor, ban_action(setup.user.id())).await; + assert_paid_with_code( + &setup.process(&ban, &transaction), + IDENTITY_NOT_CONTRACT_MODERATOR, + ); + } + use dpp::data_contract::document_type::action_fees::ContractFeePot; + assert!(ContractFeePot::Moderators + .recipients(&setup.contract) + .is_empty()); +} + +/// A declaration outside a bound or naming a type the contract does not have is refused, +/// unpaid, at the create; a contract that was not born elected can not become so. +#[tokio::test] +async fn should_refuse_an_elected_declaration_the_contract_can_not_back() { + let mut setup = Setup::new(None).await; + let transaction = setup.platform.drive.grove.start_transaction(); + let contract = setup.contract.clone(); + let with = |modify: fn(&mut ElectedModerators)| { + let mut moderation = elected(InterimModerators::ContractOwner, &[DOCUMENT_TYPE]); + if let ContractModerators::Elected(declaration) = &mut moderation.moderators { + modify(declaration); + } + moderation + }; + for (moderation, what) in [ + ( + with(|d| d.join_window = 86_399), + "a join window under a day", + ), + ( + with(|d| d.vote_window = 86_399), + "a vote window under a day", + ), + ( + with(|d| d.challenge_cool_down = 94_608_001), + "a cool-down over three years", + ), + ( + with(|d| { + d.moderated_document_types = BTreeMap::from([( + "comment".to_string(), + BTreeSet::from([ModerationAbility::Ban]), + )]); + }), + "an unknown moderated type", + ), + ( + with(|d| { + d.moderated_document_types + .insert(DOCUMENT_TYPE.to_string(), BTreeSet::new()); + }), + "an empty ability set", + ), + ( + with(|d| { + d.moderated_document_types + .get_mut(DOCUMENT_TYPE) + .expect("moderated") + .insert(ModerationAbility::DeleteDocuments); + }), + "deletions on a type moderators can not delete from", + ), + ] { + setup + .contract + .set_config(contract.config().clone().with_moderation(Some(moderation))); + let create = setup + .contract_create(setup.owner.identity_nonce(), PlatformVersion::latest()) + .await; + let execution = setup.process(&create, &transaction); + assert_unpaid_with_code(&execution, INVALID_CONTRACT_MODERATION_CONFIG); + assert!( + matches!(&execution, StateTransitionExecutionResult::UnpaidConsensusError(error) if error.to_string().contains("elected moderation")), + "expected {what} to name the declaration, got {execution:?}" + ); + } + // The bounds hold: the same declaration at its minimums is accepted. + setup + .contract + .set_config(contract.config().clone().with_moderation(Some(with(|d| { + d.join_window = 86_400; + d.vote_window = 86_400; + })))); + let create = setup + .contract_create(setup.owner.identity_nonce(), PlatformVersion::latest()) + .await; + assert_success(&setup.process(&create, &transaction)); + drop(transaction); + + // Entering elected moderation by an update is refused. + let setup = Setup::new(Some(moderation(true, true, THE_MODERATOR))).await; + let transaction = setup.platform.drive.grove.start_transaction(); + let mut entering = setup.contract.clone(); + entering.set_version(2); + entering.set_config(entering.config().clone().with_moderation(Some(elected( + InterimModerators::ContractOwner, + &[DOCUMENT_TYPE], + )))); + let update = setup.contract_update(entering).await; + assert_config_update_refused( + &setup.process(&update, &transaction), + "entering elected moderation", + ); +} +/// How long after a moderator's deletion a document can be restored: the protocol's week. +fn restore_window_ms() -> TimestampMillis { + PlatformVersion::latest() + .system_limits + .contract_document_restore_window_ms +} + +#[tokio::test] +async fn should_let_any_moderator_restore_a_deleted_post_within_a_week_and_delete_it_again() { + let setup = Setup::new_with_posts(Some(moderators_without_lists())).await; + let user_id = setup.user.id(); + + let transaction = setup.platform.drive.grove.start_transaction(); + let (post, create) = setup.create_document_of_type(&setup.user, POST).await; + assert_success(&setup.process(&create, &transaction)); + setup.commit(transaction); + // The post as stored is what comes back: a client keeps it, or its bytes, before deleting. + let stored = setup + .stored_document(POST, post.id(), None) + .expect("expected the post to be stored"); + let bytes = setup.document_bytes(POST, &stored); + + let delete = setup + .moderate(&setup.moderator, delete_action(POST, post.id())) + .await; + let transaction = setup.platform.drive.grove.start_transaction(); + assert_success(&setup.process(&delete, &transaction)); + setup.commit(transaction); + let removal = setup + .post_removal(post.id(), None) + .expect("expected the record"); + assert_eq!(removal.document_hash, hash_double(&bytes)); + assert_eq!(removal.restoration, None); + assert_eq!(setup.stored_document(POST, post.id(), None), None); + + // Restored by the contract owner, not the moderator that deleted it, to the millisecond + // the window ends on. The mempool takes it, as it does a ban. + let restored_at = BLOCK_TIME_MS + restore_window_ms(); + let author_before = setup.balance(user_id, None); + let owner_before = setup.balance(setup.owner.id(), None); + let restore = setup + .moderate(&setup.owner, restore_action(POST, bytes.clone())) + .await; + assert!(setup.check_tx(&restore).is_empty()); + let transaction = setup.platform.drive.grove.start_transaction(); + assert_success(&setup.process_at(&restore, restored_at, &transaction)); + setup.commit(transaction); + + // The post is back as it was, and its record says who brought it back and when. + assert_eq!(setup.stored_document(POST, post.id(), None), Some(stored)); + let expected = ContractDocumentRemoval { + restoration: Some(ContractDocumentRestoration { + moderator_id: setup.owner.id(), + restored_at, + }), + ..removal.clone() + }; + assert_eq!(setup.post_removal(post.id(), None), Some(expected.clone())); + assert_eq!(setup.assert_removal_proved(&restore), expected); + // The owner paid for the post's storage; its author paid nothing and got nothing. + assert!(setup.balance(setup.owner.id(), None) < owner_before); + assert_eq!(setup.balance(user_id, None), author_before); + + // Deleted again: a fresh record in place of the restored one, the author refunded nothing. + let removed_again_at = restored_at + 1; + let delete_again = setup + .moderate(&setup.moderator, delete_action(POST, post.id())) + .await; + let transaction = setup.platform.drive.grove.start_transaction(); + assert_success(&setup.process_at(&delete_again, removed_again_at, &transaction)); + setup.commit(transaction); + assert_eq!( + setup.post_removal(post.id(), None), + Some(ContractDocumentRemoval { + removed_at: removed_again_at, + restoration: None, + ..removal + }) + ); + assert_eq!(setup.stored_document(POST, post.id(), None), None); + assert_eq!(setup.balance(user_id, None), author_before); +} + +#[tokio::test] +async fn should_refund_the_author_who_deletes_a_restored_post() { + // The restored post's storage flags name its author, as they did before: the refund of the + // author's own deletion is the author's, though a moderator paid to put the post back. + let setup = Setup::new_with_posts(Some(moderators_without_lists())).await; + let user_id = setup.user.id(); + + let transaction = setup.platform.drive.grove.start_transaction(); + let (post, create) = setup.create_document_of_type(&setup.user, POST).await; + assert_success(&setup.process(&create, &transaction)); + setup.commit(transaction); + let stored = setup + .stored_document(POST, post.id(), None) + .expect("expected the post to be stored"); + let bytes = setup.document_bytes(POST, &stored); + + let transaction = setup.platform.drive.grove.start_transaction(); + let delete = setup + .moderate(&setup.moderator, delete_action(POST, post.id())) + .await; + assert_success(&setup.process(&delete, &transaction)); + let restore = setup + .moderate(&setup.moderator, restore_action(POST, bytes)) + .await; + assert_success(&setup.process_at(&restore, BLOCK_TIME_MS + 1, &transaction)); + setup.commit(transaction); + + let author_before = setup.balance(user_id, None); + let moderator_before = setup.balance(setup.moderator.id(), None); + let transaction = setup.platform.drive.grove.start_transaction(); + let own_delete = own_post_deletion(&setup, &setup.user, stored).await; + assert_success(&setup.process(&own_delete, &transaction)); + setup.commit(transaction); + assert!( + setup.balance(user_id, None) > author_before, + "the storage refund outweighs the deletion's processing fee" + ); + assert_eq!(setup.balance(setup.moderator.id(), None), moderator_before); + // The author's deletion is no moderation: the record stays as the restore left it. + assert_eq!( + setup + .post_removal(post.id(), None) + .and_then(|removal| removal.restoration) + .map(|restoration| restoration.moderator_id), + Some(setup.moderator.id()) + ); +} + +#[tokio::test] +async fn should_refuse_a_document_restore_that_breaks_a_rule() { + let setup = Setup::new_with_posts(Some(moderators_without_lists())).await; + + let transaction = setup.platform.drive.grove.start_transaction(); + let (post, create) = setup.create_document_of_type(&setup.user, POST).await; + assert_success(&setup.process(&create, &transaction)); + let (nice_document, create) = setup.create_document_keeping_it(&setup.user).await; + assert_success(&setup.process(&create, &transaction)); + let stored = setup + .stored_document(POST, post.id(), Some(&transaction)) + .expect("expected the post to be stored"); + let bytes = setup.document_bytes(POST, &stored); + + // Nothing to restore: the post is live and has no record. + let live = setup + .moderate(&setup.moderator, restore_action(POST, bytes.clone())) + .await; + assert_paid_with_code( + &setup.process(&live, &transaction), + CONTRACT_DOCUMENT_REMOVAL_NOT_FOUND, + ); + + let delete = setup + .moderate(&setup.moderator, delete_action(POST, post.id())) + .await; + assert_success(&setup.process(&delete, &transaction)); + + // Nobody but the owner and the moderators, the post's author included. + for actor in [&setup.stranger, &setup.user] { + let by_other = setup + .moderate(actor, restore_action(POST, bytes.clone())) + .await; + assert_paid_with_code( + &setup.process(&by_other, &transaction), + IDENTITY_NOT_CONTRACT_MODERATOR, + ); + } + + // Only a document type that says so, and one that exists. + let nice_stored = setup + .stored_document(DOCUMENT_TYPE, nice_document.id(), Some(&transaction)) + .expect("expected the document to be stored"); + let of_another_type = setup + .moderate( + &setup.moderator, + restore_action( + DOCUMENT_TYPE, + setup.document_bytes(DOCUMENT_TYPE, &nice_stored), + ), + ) + .await; + assert_paid_with_code( + &setup.process(&of_another_type, &transaction), + DOCUMENT_TYPE_NOT_DELETABLE_BY_MODERATORS, + ); + let of_no_type = setup + .moderate(&setup.moderator, restore_action("comment", bytes.clone())) + .await; + assert_paid_with_code( + &setup.process(&of_no_type, &transaction), + INVALID_DOCUMENT_TYPE, + ); + + // Bytes that do not decode under the type: a paid refusal, never an execution error. + let garbage = setup + .moderate(&setup.moderator, restore_action(POST, vec![])) + .await; + assert_paid_with_code(&setup.process(&garbage, &transaction), DECODING_DOCUMENT); + + // The post as it was, not an edit of it: the same post with other text decodes, and is + // refused by its hash. + let mut edited = stored.clone(); + edited.set("text", Value::Text("something else".to_string())); + let of_an_edit = setup + .moderate( + &setup.moderator, + restore_action(POST, setup.document_bytes(POST, &edited)), + ) + .await; + assert_paid_with_code( + &setup.process(&of_an_edit, &transaction), + DOCUMENT_RESTORE_HASH_MISMATCH, + ); + + // Not past the window, whoever asks. + let past_the_window = BLOCK_TIME_MS + restore_window_ms() + 1; + for actor in [&setup.moderator, &setup.owner] { + let too_late = setup + .moderate(actor, restore_action(POST, bytes.clone())) + .await; + assert_paid_with_code( + &setup.process_at(&too_late, past_the_window, &transaction), + DOCUMENT_RESTORE_WINDOW_ELAPSED, + ); + } + + // None of it brought the post back or touched its record. + assert_eq!( + setup.stored_document(POST, post.id(), Some(&transaction)), + None + ); + assert_eq!( + setup + .post_removal(post.id(), Some(&transaction)) + .map(|removal| removal.restoration), + Some(None) + ); + + // Once restored, restored: the record says so. + let restore = setup + .moderate(&setup.owner, restore_action(POST, bytes.clone())) + .await; + assert_success(&setup.process_at(&restore, BLOCK_TIME_MS + restore_window_ms(), &transaction)); + let twice = setup + .moderate(&setup.moderator, restore_action(POST, bytes)) + .await; + assert_paid_with_code( + &setup.process(&twice, &transaction), + CONTRACT_DOCUMENT_ALREADY_RESTORED, + ); +} + +#[tokio::test] +async fn should_refuse_to_restore_a_post_whose_unique_value_another_post_took_meanwhile() { + let setup = Setup::new_at_with( + Some(moderators_without_lists()), + PlatformVersion::latest(), + |contract| { + add_document_type( + contract, + POST, + post_schema_with(platform_value!({ + "indices": [ + { "name": "byText", "properties": [{ "text": "asc" }], "unique": true }, + ], + })), + ) + }, + ) + .await; + let text = Value::Text("first!".to_string()); + + let transaction = setup.platform.drive.grove.start_transaction(); + let (post, create) = setup + .create_document_of_type_with(&setup.user, POST, |document| { + document.set("text", text.clone()) + }) + .await; + assert_success(&setup.process(&create, &transaction)); + let stored = setup + .stored_document(POST, post.id(), Some(&transaction)) + .expect("expected the post to be stored"); + let bytes = setup.document_bytes(POST, &stored); + + let delete = setup + .moderate(&setup.moderator, delete_action(POST, post.id())) + .await; + assert_success(&setup.process(&delete, &transaction)); + + // The value is free while the post is gone, and a stranger takes it. + let (_, create_other) = setup + .create_document_of_type_with(&setup.stranger, POST, |document| { + document.set("text", text.clone()) + }) + .await; + assert_success(&setup.process(&create_other, &transaction)); + + // The post can not come back beside it, and the refusal is paid. + let restore = setup + .moderate(&setup.moderator, restore_action(POST, bytes)) + .await; + assert_paid_with_code( + &setup.process_at(&restore, BLOCK_TIME_MS + 1, &transaction), + DUPLICATE_UNIQUE_INDEX, + ); + assert_eq!( + setup.stored_document(POST, post.id(), Some(&transaction)), + None + ); +} + +#[tokio::test] +async fn should_bring_a_post_back_settled_when_its_deletion_window_ran_out_meanwhile() { + // A restored post is the post as it was, `$updatedAt` included: the type's deletion + // window, measured from that, may have run out on it while it was gone. The author can + // still delete it; the moderators can not, until the author edits it. + let setup = setup_with_a_moderation_window().await; + + let transaction = setup.platform.drive.grove.start_transaction(); + let (post, create) = setup.create_document_of_type(&setup.user, POST).await; + assert_success(&setup.process(&create, &transaction)); + let stored = setup + .stored_document(POST, post.id(), Some(&transaction)) + .expect("expected the post to be stored"); + let bytes = setup.document_bytes(POST, &stored); + + let delete = setup + .moderate(&setup.moderator, delete_action(POST, post.id())) + .await; + assert_success(&setup.process(&delete, &transaction)); + + // Restored after the deletion window, within the restore window. + let restored_at = BLOCK_TIME_MS + MODERATION_WINDOW_MS + 1; + let restore = setup + .moderate(&setup.moderator, restore_action(POST, bytes)) + .await; + assert_success(&setup.process_at(&restore, restored_at, &transaction)); + assert_eq!( + setup.stored_document(POST, post.id(), Some(&transaction)), + Some(stored.clone()) + ); + + let delete_again = setup + .moderate(&setup.moderator, delete_action(POST, post.id())) + .await; + assert_paid_with_code( + &setup.process_at(&delete_again, restored_at + 1, &transaction), + DOCUMENT_MODERATION_WINDOW_ELAPSED, + ); + let own_delete = own_post_deletion(&setup, &setup.user, stored).await; + assert_success(&setup.process_at(&own_delete, restored_at + 1, &transaction)); +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/basic_structure/v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/basic_structure/v2/mod.rs index dee46deaf18..e4f819a011c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/basic_structure/v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/basic_structure/v2/mod.rs @@ -128,14 +128,12 @@ impl DataContractCreateStateTransitionBasicStructureValidationV2 for DataContrac } // Contract moderation: a config that declares it must be well formed (a list or a - // document type moderators can delete, a non-empty moderator set within the limit). That the named moderators exist - // is checked against the state. + // document type moderators can delete, a non-empty moderator set within the limit, and + // an elected declaration within its bounds and naming document types of the contract). + // That the named moderators exist is checked against the state. if let Some(moderation) = self.data_contract().config().moderation() { - let result = moderation.validate( - self.data_contract() - .has_document_type_deletable_by_moderators(), - platform_version, - )?; + let result = + moderation.validate(self.data_contract().document_schemas(), platform_version)?; if !result.is_valid() { return Ok(result); } @@ -456,6 +454,7 @@ mod tests { banlist: true, suspensions: false, moderators: ContractModerators::ContractOwner, + warnings: false, }, ))); } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/basic_structure/v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/basic_structure/v2/mod.rs index 3eb81cd5cbb..a7bcd1bae61 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/basic_structure/v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/basic_structure/v2/mod.rs @@ -36,11 +36,8 @@ impl DataContractUpdateStateTransitionBasicStructureValidationV2 for DataContrac } if let Some(moderation) = self.data_contract().config().moderation() { - let result = moderation.validate( - self.data_contract() - .has_document_type_deletable_by_moderators(), - platform_version, - )?; + let result = + moderation.validate(self.data_contract().document_schemas(), platform_version)?; if !result.is_valid() { return Ok(result); } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/v0/mod.rs index a3098272012..b3e93ea69ce 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/v0/mod.rs @@ -7,7 +7,6 @@ use dpp::prefunded_specialized_balance::PrefundedSpecializedBalanceIdentifier; use dpp::prelude::ConsensusValidationResult; use dpp::state_transition::masternode_vote_transition::accessors::MasternodeVoteTransitionAccessorsV0; use dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition; -use dpp::state_transition::StateTransitionEstimatedFeeValidation; use crate::error::execution::ExecutionError; use crate::execution::types::execution_operation::ValidationOperation; @@ -64,14 +63,20 @@ impl MasternodeVoteTransitionBalanceValidationV0 for MasternodeVoteTransition { )); }; - let required_fee = self.calculate_min_required_fee(platform_version)?; + // What executing the vote deducts from the fund. Until 4.2 the vote's minimum fee was + // required here instead, a smaller amount, so a fund between the two passed this check + // and the vote failed inside execution. + let single_vote_cost = platform_version + .fee_version + .vote_resolution_fund_fees + .contested_document_single_vote_cost; - if balance < required_fee { + if balance < single_vote_cost { return Ok(ConsensusValidationResult::new_with_error( PrefundedSpecializedBalanceInsufficientError::new( balance_id, balance, - required_fee, + single_vote_cost, ) .into(), )); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/mod.rs index 4991e63e9b9..5e0edc8c823 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/mod.rs @@ -22,6 +22,7 @@ use crate::platform_types::platform::PlatformRef; use crate::rpc::core::CoreRPCLike; use crate::execution::validation::state_transition::masternode_vote::state::v0::MasternodeVoteStateTransitionStateValidationV0; +use crate::execution::validation::state_transition::masternode_vote::state::v1::MasternodeVoteStateTransitionStateValidationV1; use crate::execution::validation::state_transition::masternode_vote::transform_into_action::v0::MasternodeVoteStateTransitionTransformIntoActionValidationV0; use crate::execution::validation::state_transition::processor::state::StateTransitionStateValidation; use crate::execution::validation::state_transition::transformer::StateTransitionActionTransformer; @@ -79,9 +80,10 @@ impl StateTransitionStateValidation for MasternodeVoteTransition { .state { 0 => self.validate_state_v0(action, platform, tx, platform_version), + 1 => self.validate_state_v1(action, platform, tx, platform_version), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "masternode votes state transition: validate_state".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } @@ -92,6 +94,9 @@ impl StateTransitionStateValidation for MasternodeVoteTransition { } } +#[cfg(test)] +mod no_locking_contest_tests; + #[cfg(test)] mod tests { use crate::test::helpers::setup::TestPlatformBuilder; @@ -7750,7 +7755,7 @@ mod tests { .unwrap() .expect("expected to commit transaction"); - // At this point the document should have been awarded to contender 1. + // At this point the document should have been awarded to contender 2, the earliest document (a tie, protocol version 14). { let (contenders, abstaining, locking, finished_vote_info) = get_vote_states( @@ -7770,7 +7775,7 @@ mod tests { Some(FinishedVoteInfo { finished_vote_outcome: finished_vote_info::FinishedVoteOutcome::TowardsIdentity as i32, - won_by_identity_id: Some(contender_1.id().to_vec()), + won_by_identity_id: Some(contender_2.id().to_vec()), finished_at_block_height: 10000, finished_at_core_block_height: 42, finished_at_block_time_ms: 1209900000, @@ -7818,7 +7823,7 @@ mod tests { assert_eq!( finished_vote_info, Some(( - ContestedDocumentVotePollWinnerInfo::WonByIdentity(contender_1.id()), + ContestedDocumentVotePollWinnerInfo::WonByIdentity(contender_2.id()), block_info )) ); @@ -8165,7 +8170,7 @@ mod tests { .unwrap() .expect("expected to commit transaction"); - // At this point the document should have been awarded to contender 1. + // At this point the document should have been awarded to contender 2, the earliest document (a tie, protocol version 14). { let (contenders, abstaining, locking, finished_vote_info) = get_vote_states( @@ -8185,7 +8190,7 @@ mod tests { Some(FinishedVoteInfo { finished_vote_outcome: finished_vote_info::FinishedVoteOutcome::TowardsIdentity as i32, - won_by_identity_id: Some(contender_1.id().to_vec()), + won_by_identity_id: Some(contender_2.id().to_vec()), finished_at_block_height: 10000, finished_at_core_block_height: 42, finished_at_block_time_ms: 1209900000, @@ -8233,7 +8238,7 @@ mod tests { assert_eq!( finished_vote_info, Some(( - ContestedDocumentVotePollWinnerInfo::WonByIdentity(contender_1.id()), + ContestedDocumentVotePollWinnerInfo::WonByIdentity(contender_2.id()), block_info )) ); @@ -8616,7 +8621,10 @@ mod tests { pro_tx_hash, &voting_key, 2, - Some("VotePoll ContestedDocumentResourceVotePoll(ContestedDocumentResourceVotePoll { contract_id: GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec, document_type_name: domain, index_name: parentNameAndLabel, index_values: [string dash, string quantum] }) not available for voting: Awarded(BjNejy4r9QAvLHpQ9Yq6yRMgNymeGZ46d48fJxJbMrfW)"), + // Settling the poll deleted its pot, and a block checks the pot before the + // poll's status; check_tx, which validates the poll's status first, is what + // tells a late voter the poll is over. + Some("Did not find a specialized balance with id: 8cLBdc35uovu4yHKuMKy4JWYhRFVUPSnnm8KTJJaaJw4"), platform_version, ) .await; @@ -8811,7 +8819,10 @@ mod tests { pro_tx_hash, &voting_key, 2, - Some("VotePoll ContestedDocumentResourceVotePoll(ContestedDocumentResourceVotePoll { contract_id: GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec, document_type_name: domain, index_name: parentNameAndLabel, index_values: [string dash, string quantum] }) not available for voting: Locked"), + // Settling the poll deleted its pot, and a block checks the pot before the + // poll's status; check_tx, which validates the poll's status first, is what + // tells a late voter the poll is over. + Some("Did not find a specialized balance with id: 8cLBdc35uovu4yHKuMKy4JWYhRFVUPSnnm8KTJJaaJw4"), platform_version, ) .await; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/no_locking_contest_tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/no_locking_contest_tests.rs new file mode 100644 index 00000000000..26d876ad0b8 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/no_locking_contest_tests.rs @@ -0,0 +1,761 @@ +//! Contests on a unique index resolved without locking +//! (`ContestedIndexResolution::MasternodeVoteNoLocking`, protocol version 14): no Lock choice, +//! a single contender is awarded when the join window closes, a second contender opens the +//! vote window, and a tie goes to the earliest contender. + +use crate::execution::validation::state_transition::state_transitions::tests::{ + create_dpns_identity_name_contest, get_vote_states, perform_vote, perform_votes_multi, + setup_identity, setup_masternode_voting_identity, +}; +use crate::platform_types::platform_state::PlatformStateV0Methods; +use crate::rpc::core::MockCoreRPCLike; +use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; +use dapi_grpc::platform::v0::get_contested_resource_vote_state_request::get_contested_resource_vote_state_request_v0::ResultType; +use dapi_grpc::platform::v0::get_contested_resource_vote_state_response::get_contested_resource_vote_state_response_v0::{ + finished_vote_info, FinishedVoteInfo, +}; +use dpp::block::block_info::BlockInfo; +use dpp::block::extended_block_info::v0::ExtendedBlockInfoV0; +use dpp::consensus::state::voting::vote_choice_not_allowed_for_vote_poll_error::VoteChoiceNotAllowedForVotePollError; +use dpp::dash_to_credits; +use dpp::dashcore::Network; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::random_document::{ + CreateRandomDocument, DocumentFieldFillSize, DocumentFieldFillType, +}; +use dpp::data_contract::DataContract; +use dpp::document::{DocumentV0Getters, DocumentV0Setters}; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::{Identity, IdentityPublicKey, TimestampMillis}; +use dpp::platform_value::{Bytes32, Value}; +use dpp::prelude::Identifier; +use dpp::serialization::PlatformSerializable; +use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; +use dpp::state_transition::batch_transition::BatchTransition; +use dpp::util::hash::hash_double; +use dpp::util::strings::convert_to_homograph_safe_chars; +use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use dpp::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; +use dpp::voting::vote_polls::VotePoll; +use drive::query::VotePollsByEndDateDriveQuery; +use drive::util::test_helpers::setup_contract; +use platform_version::version::PlatformVersion; +use rand::prelude::StdRng; +use rand::{Rng, SeedableRng}; +use simple_signer::signer::SimpleSigner; +use std::sync::Arc; + +/// The DPNS-shaped fixture whose `parentNameAndLabel` index is resolved without locking. +const NO_LOCKING_CONTRACT: &str = + "tests/supporting_files/contract/dpns/dpns-contract-contested-unique-index-no-locking.json"; +const NAME: &str = "quantum"; + +type IdentityInfo = (Identity, SimpleSigner, IdentityPublicKey); + +fn block(time_ms: TimestampMillis, height: u64) -> BlockInfo { + BlockInfo { + time_ms, + height, + core_height: 42, + epoch: Default::default(), + } +} + +fn setup() -> ( + TempPlatform, + &'static PlatformVersion, + DataContract, + StdRng, +) { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let mut rng = StdRng::seed_from_u64(0x9010_C41A); + let owner = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let contract = setup_contract( + &platform.drive, + NO_LOCKING_CONTRACT, + None, + Some(owner.0.id().to_buffer()), + None::, + None, + Some(platform_version), + ); + (platform, platform_version, contract, rng) +} + +/// The join window and the poll duration the platform under test applies. +fn windows( + platform: &TempPlatform, + platform_version: &PlatformVersion, +) -> (TimestampMillis, TimestampMillis) { + match platform.config.network { + Network::Mainnet => ( + platform_version + .dpp + .validation + .voting + .allow_other_contenders_time_mainnet_ms, + platform_version + .dpp + .voting_versions + .default_vote_poll_time_duration_mainnet_ms, + ), + _ => ( + platform_version + .dpp + .validation + .voting + .allow_other_contenders_time_testing_ms, + platform_version + .dpp + .voting_versions + .default_vote_poll_time_duration_test_network_ms, + ), + } +} + +fn vote_poll(contract: &DataContract) -> VotePoll { + VotePoll::ContestedDocumentResourceVotePoll(ContestedDocumentResourceVotePoll { + contract_id: contract.id(), + document_type_name: "domain".to_string(), + index_name: "parentNameAndLabel".to_string(), + index_values: vec![ + Value::Text("dash".to_string()), + Value::Text(convert_to_homograph_safe_chars(NAME)), + ], + }) +} + +/// The serialized preorder and domain creations of one contender for `NAME`. +async fn contest_transitions( + contract: &DataContract, + (identity, signer, key): &IdentityInfo, + salt_discriminator: u8, + rng: &mut StdRng, + platform_version: &PlatformVersion, +) -> (Vec, Vec) { + let preorder = contract + .document_type_for_name("preorder") + .expect("expected preorder document type"); + let domain = contract + .document_type_for_name("domain") + .expect("expected domain document type"); + let entropy = Bytes32::random_with_rng(rng); + let mut preorder_document = preorder + .random_document_with_identifier_and_entropy( + rng, + identity.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random preorder document"); + preorder_document + .set_id_for_creation(preorder, &entropy.0, 2, platform_version) + .expect("expected to set the document id"); + let mut domain_document = domain + .random_document_with_identifier_and_entropy( + rng, + identity.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random domain document"); + domain_document + .set_id_for_creation(domain, &entropy.0, 3, platform_version) + .expect("expected to set the document id"); + domain_document.set("parentDomainName", "dash".into()); + domain_document.set("normalizedParentDomainName", "dash".into()); + domain_document.set("label", NAME.into()); + domain_document.set( + "normalizedLabel", + convert_to_homograph_safe_chars(NAME).into(), + ); + domain_document.set("records.identity", domain_document.owner_id().into()); + domain_document.set("subdomainRules.allowSubdomains", false.into()); + let mut salt: [u8; 32] = [0u8; 32]; + salt[31] = salt_discriminator; + let mut salted_domain_buffer: Vec = vec![]; + salted_domain_buffer.extend(salt); + salted_domain_buffer.extend((convert_to_homograph_safe_chars(NAME) + ".dash").as_bytes()); + preorder_document.set("saltedDomainHash", hash_double(salted_domain_buffer).into()); + domain_document.set("preorderSalt", salt.into()); + let preorder_transition = BatchTransition::new_document_creation_transition_from_document( + preorder_document, + preorder, + entropy.0, + key, + 2, + 0, + None, + signer, + platform_version, + None, + ) + .await + .expect("expect to create preorder batch transition"); + let domain_transition = BatchTransition::new_document_creation_transition_from_document( + domain_document, + domain, + entropy.0, + key, + 3, + 0, + None, + signer, + platform_version, + None, + ) + .await + .expect("expect to create domain batch transition"); + ( + preorder_transition + .serialize_to_bytes() + .expect("serialize preorder transition"), + domain_transition + .serialize_to_bytes() + .expect("serialize domain transition"), + ) +} + +/// Processes transitions in a block at `time_ms` and expects every one of them to pass. +fn process_valid( + platform: &TempPlatform, + transitions: &[Vec], + time_ms: TimestampMillis, + platform_version: &PlatformVersion, +) { + let platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + transitions, + &platform_state, + &block(time_ms, 1), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transitions"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + assert_eq!( + processing_result.valid_count(), + transitions.len(), + "every transition should pass: {:?}", + processing_result.execution_results() + ); +} + +/// A contender joins the contest for `NAME`: the preorder at `time_ms`, the domain one second +/// later. Returns the domain's block time, which is when the contest starts or is joined. +async fn join( + platform: &TempPlatform, + contract: &DataContract, + identity: &IdentityInfo, + salt_discriminator: u8, + time_ms: TimestampMillis, + rng: &mut StdRng, + platform_version: &PlatformVersion, +) -> TimestampMillis { + let (preorder, domain) = contest_transitions( + contract, + identity, + salt_discriminator, + rng, + platform_version, + ) + .await; + process_valid(platform, &[preorder], time_ms, platform_version); + process_valid(platform, &[domain], time_ms + 1000, platform_version); + time_ms + 1000 +} + +fn end_dates( + platform: &TempPlatform, + platform_version: &PlatformVersion, +) -> Vec<(TimestampMillis, VotePoll)> { + VotePollsByEndDateDriveQuery { + start_time: None, + end_time: None, + limit: None, + offset: None, + order_ascending: true, + } + .execute_no_proof(&platform.drive, None, &mut vec![], platform_version) + .expect("expected the end date entries") + .into_iter() + .flat_map(|(time, polls)| polls.into_iter().map(move |poll| (time, poll))) + .collect() +} + +/// Ends every poll due at `time_ms`, as the block at that time would. +fn end_polls_at( + platform: &TempPlatform, + time_ms: TimestampMillis, + height: u64, + platform_version: &PlatformVersion, +) { + let mut platform_state = (**platform.state.load()).clone(); + let block_info = block(time_ms, height); + platform_state.set_last_committed_block_info(Some( + ExtendedBlockInfoV0 { + basic_info: block_info, + app_hash: platform + .drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .unwrap(), + quorum_hash: [0u8; 32], + block_id_hash: [0u8; 32], + proposer_pro_tx_hash: [0u8; 32], + signature: [0u8; 96], + round: 0, + } + .into(), + )); + platform.state.store(Arc::new(platform_state)); + let platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + platform + .check_for_ended_vote_polls( + &platform_state, + &platform_state, + &block_info, + Some(&transaction), + platform_version, + ) + .expect("expected to check for ended vote polls"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); +} + +/// The identity the finished contest was awarded to, if it finished. +fn winner( + platform: &TempPlatform, + contract: &DataContract, + platform_version: &PlatformVersion, +) -> Option { + let platform_state = platform.state.load(); + let (_, _, _, finished_vote_info) = get_vote_states( + platform, + &platform_state, + contract, + NAME, + None, + true, + None, + ResultType::DocumentsAndVoteTally, + platform_version, + ); + finished_vote_info.map( + |FinishedVoteInfo { + finished_vote_outcome, + won_by_identity_id, + .. + }| { + assert_eq!( + finished_vote_outcome, + finished_vote_info::FinishedVoteOutcome::TowardsIdentity as i32, + "a contest without locking always has a winner" + ); + Identifier::from_vec(won_by_identity_id.expect("expected a winner")) + .expect("expected an identifier") + }, + ) +} + +#[tokio::test] +async fn should_refuse_a_lock_vote_and_accept_the_other_choices() { + let (mut platform, platform_version, contract, mut rng) = setup(); + let alice = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let bob = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + join( + &platform, + &contract, + &alice, + 1, + 10_000, + &mut rng, + platform_version, + ) + .await; + join( + &platform, + &contract, + &bob, + 2, + 20_000, + &mut rng, + platform_version, + ) + .await; + + let (pro_tx_hash, _, signer, voting_key) = + setup_masternode_voting_identity(&mut platform, 0x10c, platform_version); + let platform_state = platform.state.load(); + let refused = + VoteChoiceNotAllowedForVotePollError::new(vote_poll(&contract), ResourceVoteChoice::Lock) + .to_string(); + perform_vote( + &mut platform, + &platform_state, + &contract, + ResourceVoteChoice::Lock, + NAME, + &signer, + pro_tx_hash, + &voting_key, + 1, + Some(&refused), + platform_version, + ) + .await; + perform_vote( + &mut platform, + &platform_state, + &contract, + ResourceVoteChoice::Abstain, + NAME, + &signer, + pro_tx_hash, + &voting_key, + 1, + None, + platform_version, + ) + .await; + let (abstaining, locking, tallies) = tallies_of(&platform, &contract, platform_version); + assert_eq!(abstaining, Some(1)); + assert_eq!(locking, Some(0)); + assert!(tallies.contains(&(alice.0.id(), Some(0)))); + assert!(tallies.contains(&(bob.0.id(), Some(0)))); + + // The same masternode changes its vote: a changed vote replaces the previous one + perform_vote( + &mut platform, + &platform_state, + &contract, + ResourceVoteChoice::TowardsIdentity(alice.0.id()), + NAME, + &signer, + pro_tx_hash, + &voting_key, + 2, + None, + platform_version, + ) + .await; + + // The masternode changed its vote, so its abstain vote is gone and alice has it + let (abstaining, locking, tallies) = tallies_of(&platform, &contract, platform_version); + assert_eq!(abstaining, Some(0)); + assert_eq!(locking, Some(0)); + assert!(tallies.contains(&(alice.0.id(), Some(1)))); + assert!(tallies.contains(&(bob.0.id(), Some(0)))); +} + +/// The abstain and lock tallies and every contender's tally of the still-running contest. +fn tallies_of( + platform: &TempPlatform, + contract: &DataContract, + platform_version: &PlatformVersion, +) -> (Option, Option, Vec<(Identifier, Option)>) { + let platform_state = platform.state.load(); + let (contenders, abstaining, locking, finished) = get_vote_states( + platform, + &platform_state, + contract, + NAME, + None, + true, + None, + ResultType::DocumentsAndVoteTally, + platform_version, + ); + assert!(finished.is_none()); + let tallies = contenders + .iter() + .map(|contender| (contender.identity_id(), contender.vote_tally())) + .collect(); + (abstaining, locking, tallies) +} + +#[tokio::test] +async fn should_award_a_single_contender_when_the_join_window_closes() { + let (mut platform, platform_version, contract, mut rng) = setup(); + let (join_window, poll_duration) = windows(&platform, platform_version); + let alice = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let start = join( + &platform, + &contract, + &alice, + 1, + 10_000, + &mut rng, + platform_version, + ) + .await; + + assert_eq!( + end_dates(&platform, platform_version), + vec![(start + join_window, vote_poll(&contract))], + "the contest ends with its join window while it has one contender" + ); + assert!(start + join_window < start + poll_duration); + + end_polls_at(&platform, start + join_window - 1, 10, platform_version); + assert_eq!(winner(&platform, &contract, platform_version), None); + + end_polls_at(&platform, start + join_window, 11, platform_version); + assert_eq!( + winner(&platform, &contract, platform_version), + Some(alice.0.id()) + ); + assert!(end_dates(&platform, platform_version).is_empty()); +} + +#[tokio::test] +async fn should_open_the_vote_window_when_a_second_contender_joins() { + let (mut platform, platform_version, contract, mut rng) = setup(); + let (join_window, poll_duration) = windows(&platform, platform_version); + let alice = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let bob = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let carol = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let start = join( + &platform, + &contract, + &alice, + 1, + 10_000, + &mut rng, + platform_version, + ) + .await; + join( + &platform, + &contract, + &bob, + 2, + start + 60_000, + &mut rng, + platform_version, + ) + .await; + + assert_eq!( + end_dates(&platform, platform_version), + vec![(start + poll_duration, vote_poll(&contract))], + "the second contender moves the end to the full poll duration" + ); + + // A third contender finds the end date there already + join( + &platform, + &contract, + &carol, + 3, + start + 120_000, + &mut rng, + platform_version, + ) + .await; + assert_eq!( + end_dates(&platform, platform_version), + vec![(start + poll_duration, vote_poll(&contract))] + ); + + perform_votes_multi( + &mut platform, + &contract, + vec![ + (ResourceVoteChoice::TowardsIdentity(bob.0.id()), 3), + (ResourceVoteChoice::TowardsIdentity(alice.0.id()), 2), + (ResourceVoteChoice::Abstain, 1), + ], + NAME, + 100, + None, + platform_version, + ) + .await; + + end_polls_at(&platform, start + join_window, 10, platform_version); + assert_eq!( + winner(&platform, &contract, platform_version), + None, + "the join window closing no longer ends the contest" + ); + + end_polls_at(&platform, start + poll_duration, 11, platform_version); + assert_eq!( + winner(&platform, &contract, platform_version), + Some(bob.0.id()), + "plurality" + ); + assert!(end_dates(&platform, platform_version).is_empty()); +} + +#[tokio::test] +async fn should_award_a_tie_to_the_earliest_contender() { + let (mut platform, platform_version, contract, mut rng) = setup(); + let (_, poll_duration) = windows(&platform, platform_version); + let alice = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let bob = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let start = join( + &platform, + &contract, + &alice, + 1, + 10_000, + &mut rng, + platform_version, + ) + .await; + join( + &platform, + &contract, + &bob, + 2, + start + 60_000, + &mut rng, + platform_version, + ) + .await; + + perform_votes_multi( + &mut platform, + &contract, + vec![ + (ResourceVoteChoice::TowardsIdentity(bob.0.id()), 2), + (ResourceVoteChoice::TowardsIdentity(alice.0.id()), 2), + ], + NAME, + 100, + None, + platform_version, + ) + .await; + + end_polls_at(&platform, start + poll_duration, 11, platform_version); + assert_eq!( + winner(&platform, &contract, platform_version), + Some(alice.0.id()), + "alice joined first" + ); +} + +#[tokio::test] +async fn should_award_the_earliest_contender_when_nobody_votes() { + let (mut platform, platform_version, contract, mut rng) = setup(); + let (_, poll_duration) = windows(&platform, platform_version); + let alice = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let bob = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let start = join( + &platform, + &contract, + &alice, + 1, + 10_000, + &mut rng, + platform_version, + ) + .await; + join( + &platform, + &contract, + &bob, + 2, + start + 60_000, + &mut rng, + platform_version, + ) + .await; + + end_polls_at(&platform, start + poll_duration, 11, platform_version); + assert_eq!( + winner(&platform, &contract, platform_version), + Some(alice.0.id()) + ); +} + +/// The DPNS rule keeps its Lock choice, and from protocol version 14 its ties go to the +/// earliest contender too: two documents created in the same block tie on time and +/// heights, so the smaller document id wins. +#[tokio::test] +async fn should_award_a_dpns_tie_to_the_earliest_contender_from_version_14() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let platform_state = platform.state.load(); + let (contender_1, contender_2, dpns_contract) = create_dpns_identity_name_contest( + &mut platform, + &platform_state, + 7, + NAME, + platform_version, + ) + .await; + perform_votes_multi( + &mut platform, + dpns_contract.as_ref(), + vec![ + (ResourceVoteChoice::TowardsIdentity(contender_1.id()), 4), + (ResourceVoteChoice::TowardsIdentity(contender_2.id()), 4), + ], + NAME, + 10, + None, + platform_version, + ) + .await; + let (contenders, _, _, _) = get_vote_states( + &platform, + &platform_state, + dpns_contract.as_ref(), + NAME, + None, + true, + None, + ResultType::DocumentsAndVoteTally, + platform_version, + ); + let earliest = contenders + .iter() + .map(|contender| { + let document = contender + .document() + .as_ref() + .expect("expected the contender's document"); + (document.id(), contender.identity_id()) + }) + .min() + .expect("expected contenders") + .1; + + let (_, poll_duration) = windows(&platform, platform_version); + end_polls_at(&platform, poll_duration + 300_000, 11, platform_version); + assert_eq!( + winner(&platform, dpns_contract.as_ref(), platform_version), + Some(earliest) + ); +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/state/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/state/mod.rs index 9a1925de7fc..008be12cc67 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/state/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/state/mod.rs @@ -1 +1,2 @@ pub(crate) mod v0; +pub(crate) mod v1; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/state/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/state/v1/mod.rs new file mode 100644 index 00000000000..4ea6d6b31b4 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/state/v1/mod.rs @@ -0,0 +1,120 @@ +use crate::error::Error; +use crate::platform_types::platform::PlatformRef; +use dpp::consensus::state::state_error::StateError; +use dpp::consensus::state::voting::vote_choice_not_allowed_for_vote_poll_error::VoteChoiceNotAllowedForVotePollError; +use dpp::consensus::state::voting::vote_poll_not_available_for_voting_error::VotePollNotAvailableForVotingError; +use dpp::consensus::state::voting::vote_poll_not_found_error::VotePollNotFoundError; +use dpp::consensus::ConsensusError; + +use dpp::prelude::ConsensusValidationResult; +use dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition; + +use crate::error::execution::ExecutionError; +use dpp::data_contract::document_type::ContestedIndexResolution; +use dpp::version::PlatformVersion; +use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use dpp::voting::vote_info_storage::contested_document_vote_poll_stored_info::{ + ContestedDocumentVotePollStatus, ContestedDocumentVotePollStoredInfoV0Getters, +}; +use drive::drive::votes::resolved::vote_polls::ResolvedVotePoll; +use drive::drive::votes::resolved::votes::resolved_resource_vote::accessors::v0::ResolvedResourceVoteGettersV0; +use drive::drive::votes::resolved::votes::ResolvedVote; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::StateTransitionAction; + +pub(in crate::execution::validation::state_transition::state_transitions::masternode_vote) trait MasternodeVoteStateTransitionStateValidationV1 +{ + fn validate_state_v1( + &self, + action: Option, + platform: &PlatformRef, + tx: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl MasternodeVoteStateTransitionStateValidationV1 for MasternodeVoteTransition { + fn validate_state_v1( + &self, + action: Option, + platform: &PlatformRef, + tx: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let Some(StateTransitionAction::MasternodeVoteAction(masternode_vote_action)) = action + else { + return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "action should be known in validate state for masternode vote transition", + ))); + }; + + // We need to make sure that the vote poll exists and is in started state + match masternode_vote_action.vote_ref() { + ResolvedVote::ResolvedResourceVote(resource_vote) => { + let vote_poll = resource_vote.vote_poll(); + match vote_poll { + ResolvedVotePoll::ContestedDocumentResourceVotePollWithContractInfo( + contested_document_resource_vote_poll, + ) => { + let Some(stored_info) = platform + .drive + .fetch_contested_document_vote_poll_stored_info( + contested_document_resource_vote_poll, + None, + tx, + platform_version, + )? + .1 + else { + return Ok(ConsensusValidationResult::new_with_error( + ConsensusError::StateError(StateError::VotePollNotFoundError( + VotePollNotFoundError::new(vote_poll.into()), + )), + )); + }; + // A contested index resolved without locking offers no Lock choice + if resource_vote.resource_vote_choice() == ResourceVoteChoice::Lock + && contested_document_resource_vote_poll + .index() + .map_err(Error::Drive)? + .contested_index + .as_ref() + .map(|contested| contested.resolution) + == Some(ContestedIndexResolution::MasternodeVoteNoLocking) + { + return Ok(ConsensusValidationResult::new_with_error( + VoteChoiceNotAllowedForVotePollError::new( + vote_poll.into(), + ResourceVoteChoice::Lock, + ) + .into(), + )); + } + let vote_poll_status = stored_info.vote_poll_status(); + match &vote_poll_status { + ContestedDocumentVotePollStatus::NotStarted + | ContestedDocumentVotePollStatus::Awarded(_) + | ContestedDocumentVotePollStatus::Locked => { + Ok(ConsensusValidationResult::new_with_error( + ConsensusError::StateError( + StateError::VotePollNotAvailableForVotingError( + VotePollNotAvailableForVotingError::new( + vote_poll.into(), + vote_poll_status, + ), + ), + ), + )) + } + ContestedDocumentVotePollStatus::Started(_) => { + Ok(ConsensusValidationResult::new_with_data( + masternode_vote_action.into(), + )) + } + } + } + } + } + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs index 0747a162852..f9fea613216 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs @@ -2188,10 +2188,25 @@ pub(in crate::execution) mod tests { assert_eq!(second_contender.vote_tally(), Some(0)); } + /// The vote poll of the DPNS name contest on `name` + pub(in crate::execution) fn dpns_name_vote_poll( + dpns_contract: &DataContract, + name: &str, + ) -> ContestedDocumentResourceVotePoll { + ContestedDocumentResourceVotePoll { + contract_id: dpns_contract.id(), + document_type_name: "domain".to_string(), + index_name: "parentNameAndLabel".to_string(), + index_values: vec![ + Value::Text("dash".to_string()), + Value::Text(convert_to_homograph_safe_chars(name)), + ], + } + } + + /// A masternode's signed vote on the DPNS name contest on `name`, serialized as broadcast #[allow(clippy::too_many_arguments)] - pub(in crate::execution) async fn perform_vote( - platform: &mut TempPlatform, - platform_state: &Guard>, + pub(in crate::execution) async fn serialized_dpns_name_vote( dpns_contract: &DataContract, resource_vote_choice: ResourceVoteChoice, name: &str, @@ -2199,27 +2214,17 @@ pub(in crate::execution) mod tests { pro_tx_hash: Identifier, voting_key: &IdentityPublicKey, nonce: IdentityNonce, - expect_error: Option<&str>, platform_version: &PlatformVersion, - ) { - // Let's vote for contender 1 - + ) -> Vec { let vote = Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { - vote_poll: VotePoll::ContestedDocumentResourceVotePoll( - ContestedDocumentResourceVotePoll { - contract_id: dpns_contract.id(), - document_type_name: "domain".to_string(), - index_name: "parentNameAndLabel".to_string(), - index_values: vec![ - Value::Text("dash".to_string()), - Value::Text(convert_to_homograph_safe_chars(name)), - ], - }, - ), + vote_poll: VotePoll::ContestedDocumentResourceVotePoll(dpns_name_vote_poll( + dpns_contract, + name, + )), resource_vote_choice, })); - let masternode_vote_transition = MasternodeVoteTransition::try_from_vote_with_signer( + MasternodeVoteTransition::try_from_vote_with_signer( vote, signer, pro_tx_hash, @@ -2229,11 +2234,36 @@ pub(in crate::execution) mod tests { None, ) .await - .expect("expected to make transition vote"); + .expect("expected to make transition vote") + .serialize_to_bytes() + .expect("expected to serialize the masternode vote") + } - let masternode_vote_serialized_transition = masternode_vote_transition - .serialize_to_bytes() - .expect("expected documents batch serialized state transition"); + #[allow(clippy::too_many_arguments)] + pub(in crate::execution) async fn perform_vote( + platform: &mut TempPlatform, + platform_state: &Guard>, + dpns_contract: &DataContract, + resource_vote_choice: ResourceVoteChoice, + name: &str, + signer: &SimpleSigner, + pro_tx_hash: Identifier, + voting_key: &IdentityPublicKey, + nonce: IdentityNonce, + expect_error: Option<&str>, + platform_version: &PlatformVersion, + ) { + let masternode_vote_serialized_transition = serialized_dpns_name_vote( + dpns_contract, + resource_vote_choice, + name, + signer, + pro_tx_hash, + voting_key, + nonce, + platform_version, + ) + .await; // CheckTx root-invariance guard (devnet paloma h788): `check_tx` asserts under // cfg(test) that it never mutates committed grovedb state, so every valid vote diff --git a/packages/rs-drive-abci/src/query/contract_moderation_queries/contract_document_removals/v0/mod.rs b/packages/rs-drive-abci/src/query/contract_moderation_queries/contract_document_removals/v0/mod.rs index 62f8970c95f..bf67b4bc459 100644 --- a/packages/rs-drive-abci/src/query/contract_moderation_queries/contract_document_removals/v0/mod.rs +++ b/packages/rs-drive-abci/src/query/contract_moderation_queries/contract_document_removals/v0/mod.rs @@ -12,6 +12,7 @@ use dapi_grpc::platform::v0::get_contract_document_removals_request::{ use dapi_grpc::platform::v0::get_contract_document_removals_response::{ get_contract_document_removals_response_v0, ContractDocumentRemoval as ContractDocumentRemovalProto, ContractDocumentRemovals, + ContractDocumentRestoration as ContractDocumentRestorationProto, GetContractDocumentRemovalsResponseV0, }; use dpp::check_validation_result_with_data; @@ -142,6 +143,13 @@ impl Platform { moderator_id: entry.removal.moderator_id.to_vec(), removed_at: entry.removal.removed_at, reason: Some(reason_to_response(entry.removal.reason)), + document_hash: entry.removal.document_hash.to_vec(), + restoration: entry.removal.restoration.map(|restoration| { + ContractDocumentRestorationProto { + moderator_id: restoration.moderator_id.to_vec(), + restored_at: restoration.restored_at, + } + }), }) .collect(), }, @@ -164,8 +172,8 @@ mod tests { use dpp::dashcore::Network; use dpp::data_contract::accessors::v0::DataContractV0Setters; use dpp::data_contract::config::moderation::{ - ContractDocumentRemoval, ContractModerationConfig, ContractModerationReason, - ContractModerators, + ContractDocumentRemoval, ContractDocumentRestoration, ContractModerationConfig, + ContractModerationReason, ContractModerators, }; use dpp::data_contract::schema::DataContractSchemaMethodsV0; use dpp::data_contract::DataContract; @@ -185,6 +193,7 @@ mod tests { banlist: false, suspensions: false, moderators: ContractModerators::ContractOwner, + warnings: false, }, ))); contract @@ -213,8 +222,15 @@ mod tests { reason: ContractModerationReason { code: Some(seed as u16), text: "spam".to_string(), + documents: vec![], }, removed_at: 1_000 + seed as u64, + document_hash: [seed + 0x20; 32], + // Every other record was restored: the response carries the restoration too. + restoration: seed.is_multiple_of(2).then(|| ContractDocumentRestoration { + moderator_id: Identifier::from([0x78; 32]), + restored_at: 2_000 + seed as u64, + }), } } @@ -227,6 +243,8 @@ mod tests { document_type_name: POST.to_string(), document_id: Identifier::from([seed; 32]), removal: removal(seed), + replaces_existing: false, + moderator_id: Identifier::from([0x77; 32]), }, )], true, @@ -275,7 +293,15 @@ mod tests { reason: Some(ContractModerationReasonProto { code: Some(seed as u32), text: "spam".to_string(), + documents: vec![], }), + document_hash: removal.document_hash.to_vec(), + restoration: removal + .restoration + .map(|restoration| ContractDocumentRestorationProto { + moderator_id: restoration.moderator_id.to_vec(), + restored_at: restoration.restored_at, + }), } } diff --git a/packages/rs-drive-abci/src/query/contract_moderation_queries/contract_moderation_entries/v0/mod.rs b/packages/rs-drive-abci/src/query/contract_moderation_queries/contract_moderation_entries/v0/mod.rs index 9a2d135b36c..77811ff7106 100644 --- a/packages/rs-drive-abci/src/query/contract_moderation_queries/contract_moderation_entries/v0/mod.rs +++ b/packages/rs-drive-abci/src/query/contract_moderation_queries/contract_moderation_entries/v0/mod.rs @@ -3,7 +3,7 @@ use crate::error::Error; use crate::platform_types::platform::Platform; use crate::platform_types::platform_state::PlatformState; use crate::query::contract_moderation_queries::{ - identifier_from_request, list_from_request, reason_to_response, + identifier_from_request, list_from_request, reason_to_response, warnings_to_response, }; use crate::query::response_metadata::CheckpointUsed; use crate::query::QueryValidationResult; @@ -20,8 +20,8 @@ use drive::drive::contract::moderation::types::ContractModerationEntriesQuery; use drive::util::grove_operations::GroveDBToUse; impl Platform { - /// Returns one page of a moderated contract's banlist or suspension list, in identity id - /// order. The list must be one the contract keeps. + /// Returns one page of a moderated contract's banlist, suspension list or warning list, in + /// identity id order. The list must be one the contract keeps. pub(super) fn query_contract_moderation_entries_v0( &self, GetContractModerationEntriesRequestV0 { @@ -103,7 +103,13 @@ impl Platform { .map(|entry| ContractModerationEntryProto { identity_id: entry.identity_id.to_vec(), until: entry.until, - reason: Some(reason_to_response(entry.reason)), + // A warning entry's reason is its latest warning's, which + // travels with the warnings: sent once. + reason: entry + .warnings + .is_empty() + .then(|| reason_to_response(entry.reason)), + warnings: warnings_to_response(entry.warnings), }) .collect(), }, @@ -121,8 +127,8 @@ impl Platform { mod tests { use super::*; use crate::query::contract_moderation_queries::tests::{ - ban, store_contract, suspend, BANLIST, BAN_REASON, SUSPENSIONS, SUSPENSION_REASON, - SUSPENSION_REASON_CODE, + ban, store_contract, store_contract_keeping, suspend, warn, BANLIST, BAN_REASON, + SUSPENSIONS, SUSPENSION_REASON, SUSPENSION_REASON_CODE, WARNINGS, WARNING_REASON, }; use crate::query::tests::setup_platform; use dapi_grpc::platform::v0::ContractModerationReason as ContractModerationReasonProto; @@ -240,6 +246,86 @@ mod tests { ); } + #[test] + fn should_return_and_prove_the_warning_list_with_every_warning() { + let (platform, state, version) = setup_platform(None, Network::Testnet, None); + let contract = store_contract_keeping(&platform, false, false, true, version); + let once = Identifier::from([0x21; 32]); + let twice = Identifier::from([0x22; 32]); + warn(&platform, &contract, once, 1_000, version); + warn(&platform, &contract, twice, 1_000, version); + warn(&platform, &contract, twice, 2_000, version); + + let result = platform + .query_contract_moderation_entries_v0( + request(contract.id().to_vec(), WARNINGS, None, None, false), + &state, + version, + ) + .expect("expected query to succeed"); + assert!(result.errors.is_empty(), "{:?}", result.errors); + let Some(get_contract_moderation_entries_response_v0::Result::Entries(page)) = + result.data.expect("expected data").result + else { + panic!("expected entries"); + }; + let reason = || { + Some(ContractModerationReasonProto { + code: None, + text: WARNING_REASON.to_string(), + documents: vec![], + }) + }; + assert_eq!(page.entries.len(), 2); + assert_eq!(page.entries[0].identity_id, once.to_vec()); + assert_eq!(page.entries[0].until, None); + // The entry's reason is the latest warning's, which travels with the warnings alone. + assert_eq!(page.entries[0].reason, None); + assert_eq!(page.entries[0].warnings.len(), 1); + assert_eq!(page.entries[0].warnings[0].reason, reason()); + assert_eq!( + page.entries[1] + .warnings + .iter() + .map(|warning| warning.warned_at) + .collect::>(), + vec![1_000, 2_000] + ); + assert_eq!(page.entries[1].warnings[1].reason, reason()); + + let result = platform + .query_contract_moderation_entries_v0( + request(contract.id().to_vec(), WARNINGS, None, None, true), + &state, + version, + ) + .expect("expected query to succeed"); + let Some(get_contract_moderation_entries_response_v0::Result::Proof(proof)) = + result.data.expect("expected data").result + else { + panic!("expected a proof"); + }; + let query = ContractModerationEntriesQuery { + list: ContractModerationList::Warnings, + start_after: None, + limit: version.drive_abci.query.max_returned_elements, + }; + let (_, proved) = Drive::verify_contract_moderation_entries( + &proof.grovedb_proof, + contract.id(), + &query, + version, + ) + .expect("expected the proof to verify"); + assert_eq!( + proved + .iter() + .map(|entry: &ContractModerationEntry| (entry.identity_id, entry.warnings.len())) + .collect::>(), + vec![(once, 1), (twice, 2)] + ); + } + #[test] fn should_page_and_prove_the_entries() { let (platform, state, version) = setup_platform(None, Network::Testnet, None); @@ -283,6 +369,7 @@ mod tests { Some(ContractModerationReasonProto { code: None, text: BAN_REASON.to_string(), + documents: vec![], }) }; @@ -314,6 +401,7 @@ mod tests { Some(ContractModerationReasonProto { code: Some(SUSPENSION_REASON_CODE as u32), text: SUSPENSION_REASON.to_string(), + documents: vec![], }) )] ); @@ -355,11 +443,13 @@ mod tests { identity_id: second, until: None, reason: ContractModerationReason::from_text(BAN_REASON), + warnings: vec![], }, ContractModerationEntry { identity_id: third, until: None, reason: ContractModerationReason::from_text(BAN_REASON), + warnings: vec![], } ] ); diff --git a/packages/rs-drive-abci/src/query/contract_moderation_queries/contract_moderation_status/v0/mod.rs b/packages/rs-drive-abci/src/query/contract_moderation_queries/contract_moderation_status/v0/mod.rs index 37714150fbb..ff8d95390c3 100644 --- a/packages/rs-drive-abci/src/query/contract_moderation_queries/contract_moderation_status/v0/mod.rs +++ b/packages/rs-drive-abci/src/query/contract_moderation_queries/contract_moderation_status/v0/mod.rs @@ -4,6 +4,7 @@ use crate::platform_types::platform::Platform; use crate::platform_types::platform_state::PlatformState; use crate::query::contract_moderation_queries::{ identifier_from_request, list_from_request, list_to_request, reason_to_response, + warnings_to_response, }; use crate::query::response_metadata::CheckpointUsed; use crate::query::QueryValidationResult; @@ -109,6 +110,9 @@ impl Platform { suspension_reason: status .suspension .map(|suspension| reason_to_response(suspension.reason)), + // Empty when the warning list was not read as much as when the identity + // carries none: `lists` tells the two apart. + warnings: warnings_to_response(status.warnings), lists: requested .iter() .map(|list| list_to_request(*list)) @@ -127,15 +131,15 @@ impl Platform { mod tests { use super::*; use crate::query::contract_moderation_queries::tests::{ - ban, store_contract, suspend, BANLIST, BAN_REASON, SUSPENSIONS, SUSPENSION_REASON, - SUSPENSION_REASON_CODE, + ban, store_contract, store_contract_keeping, suspend, warn, BANLIST, BAN_REASON, + SUSPENSIONS, SUSPENSION_REASON, SUSPENSION_REASON_CODE, WARNINGS, WARNING_REASON, }; use crate::query::tests::setup_platform; use dpp::dashcore::Network; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::config::moderation::{ ContractBan, ContractModerationListStatuses, ContractModerationReason, - ContractModerationStatus, ContractSuspension, + ContractModerationStatus, ContractSuspension, ContractWarning, }; use dpp::identifier::Identifier; use drive::drive::Drive; @@ -274,6 +278,7 @@ mod tests { reason: ContractModerationReason::from_text(BAN_REASON), }), suspension: None, + warnings: vec![], }, ), ( @@ -285,8 +290,10 @@ mod tests { reason: ContractModerationReason { code: Some(SUSPENSION_REASON_CODE), text: SUSPENSION_REASON.to_string(), + documents: vec![], }, }), + warnings: vec![], }, ), (clean, ContractModerationStatus::default()), @@ -367,6 +374,93 @@ mod tests { ); } } + #[test] + fn should_return_and_prove_the_warnings() { + let (platform, state, version) = setup_platform(None, Network::Testnet, None); + let contract = store_contract_keeping(&platform, true, false, true, version); + let warned = Identifier::from([0x41; 32]); + warn(&platform, &contract, warned, 1_000, version); + warn(&platform, &contract, warned, 2_000, version); + + let result = platform + .query_contract_moderation_status_v0( + request( + contract.id().to_vec(), + warned.to_vec(), + vec![BANLIST, WARNINGS], + false, + ), + &state, + version, + ) + .expect("expected query to succeed"); + assert!(result.errors.is_empty(), "{:?}", result.errors); + let Some(get_contract_moderation_status_response_v0::Result::Status(status)) = + result.data.expect("expected data").result + else { + panic!("expected a status"); + }; + assert_eq!(status.banned, Some(false)); + assert_eq!(status.lists, vec![BANLIST, WARNINGS]); + assert_eq!( + status + .warnings + .iter() + .map(|warning| warning.warned_at) + .collect::>(), + vec![1_000, 2_000] + ); + assert_eq!( + status.warnings[1].reason, + Some(reason_to_response(ContractModerationReason::from_text( + WARNING_REASON + ))) + ); + + let result = platform + .query_contract_moderation_status_v0( + request( + contract.id().to_vec(), + warned.to_vec(), + vec![WARNINGS], + true, + ), + &state, + version, + ) + .expect("expected query to succeed"); + let Some(get_contract_moderation_status_response_v0::Result::Proof(proof)) = + result.data.expect("expected data").result + else { + panic!("expected a proof"); + }; + let lists = [ContractModerationList::Warnings]; + let (_, proved) = Drive::verify_contract_moderation_status( + &proof.grovedb_proof, + contract.id(), + warned, + &lists, + version, + ) + .expect("expected the proof to verify"); + let expected = ContractModerationStatus { + ban: None, + suspension: None, + warnings: [1_000, 2_000] + .into_iter() + .map(|warned_at| ContractWarning { + warned_at, + reason: ContractModerationReason::from_text(WARNING_REASON), + }) + .collect(), + }; + assert_eq!( + proved, + ContractModerationListStatuses::from_status(&lists, &expected) + ); + assert_eq!(proved.banned(), None); + } + #[test] fn should_leave_a_list_that_was_not_read_unset_on_the_wire() { let (platform, state, version) = setup_platform(None, Network::Testnet, None); diff --git a/packages/rs-drive-abci/src/query/contract_moderation_queries/mod.rs b/packages/rs-drive-abci/src/query/contract_moderation_queries/mod.rs index 9a9d9cb7403..ec0c45a8e26 100644 --- a/packages/rs-drive-abci/src/query/contract_moderation_queries/mod.rs +++ b/packages/rs-drive-abci/src/query/contract_moderation_queries/mod.rs @@ -1,6 +1,6 @@ //! Contract moderation queries: one identity's status on a moderated contract, one page of a -//! contract's banlist or suspension list, and the fee pots a contract's document action fees -//! collect in. +//! contract's banlist, suspension list or warning list, and the fee pots a contract's +//! document action fees collect in. mod contract_document_removals; mod contract_fee_pots; @@ -10,10 +10,14 @@ mod contract_moderation_status; use crate::error::query::QueryError; use crate::error::Error; use crate::platform_types::platform::Platform; +use dapi_grpc::platform::v0::ContractModerationDocument as ContractModerationDocumentProto; use dapi_grpc::platform::v0::ContractModerationList as ContractModerationListProto; use dapi_grpc::platform::v0::ContractModerationReason as ContractModerationReasonProto; +use dapi_grpc::platform::v0::ContractWarning as ContractWarningProto; use dpp::data_contract::accessors::v0::DataContractV0Getters; -use dpp::data_contract::config::moderation::{ContractModerationList, ContractModerationReason}; +use dpp::data_contract::config::moderation::{ + ContractModerationList, ContractModerationReason, ContractWarning, +}; use dpp::data_contract::config::v2::DataContractConfigGettersV2; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; @@ -38,6 +42,7 @@ pub(super) fn list_from_request( match ContractModerationListProto::try_from(list) { Ok(ContractModerationListProto::Banlist) => Ok(ContractModerationList::Banlist), Ok(ContractModerationListProto::Suspensions) => Ok(ContractModerationList::Suspensions), + Ok(ContractModerationListProto::Warnings) => Ok(ContractModerationList::Warnings), // Zero is what a proto3 client sends when it leaves the field out: not a list. Ok(ContractModerationListProto::Unspecified) | Err(_) => Err(QueryError::InvalidArgument( format!("{field} {list} is not a moderation list"), @@ -50,6 +55,7 @@ pub(super) fn list_to_request(list: ContractModerationList) -> i32 { match list { ContractModerationList::Banlist => ContractModerationListProto::Banlist as i32, ContractModerationList::Suspensions => ContractModerationListProto::Suspensions as i32, + ContractModerationList::Warnings => ContractModerationListProto::Warnings as i32, } } @@ -60,9 +66,28 @@ pub(super) fn reason_to_response( ContractModerationReasonProto { code: reason.code.map(u32::from), text: reason.text, + documents: reason + .documents + .into_iter() + .map(|document| ContractModerationDocumentProto { + document_type_name: document.document_type_name, + document_id: document.document_id.to_vec(), + }) + .collect(), } } +/// Warnings as the wire carries them, oldest first as stored. +pub(super) fn warnings_to_response(warnings: Vec) -> Vec { + warnings + .into_iter() + .map(|warning| ContractWarningProto { + warned_at: warning.warned_at, + reason: Some(reason_to_response(warning.reason)), + }) + .collect() +} + impl Platform { /// The moderation lists the contract keeps, or a query error when the contract does not /// exist or keeps no list. A list the contract does not keep has no tree, so a query over @@ -102,7 +127,8 @@ pub(super) mod tests { use dpp::block::block_info::BlockInfo; use dpp::data_contract::accessors::v0::{DataContractV0Getters, DataContractV0Setters}; use dpp::data_contract::config::moderation::{ - ContractModerationConfig, ContractModerationReason, ContractModerators, + ContractModerationConfig, ContractModerationList, ContractModerationReason, + ContractModerators, ContractWarning, }; use dpp::data_contract::DataContract; use dpp::identifier::Identifier; @@ -111,11 +137,14 @@ pub(super) mod tests { pub const BANLIST: i32 = 1; pub const SUSPENSIONS: i32 = 2; + pub const WARNINGS: i32 = 3; /// The reason [`ban`] gives. pub const BAN_REASON: &str = "spam"; /// The reason [`suspend`] gives, with a code. pub const SUSPENSION_REASON: &str = "flooding"; pub const SUSPENSION_REASON_CODE: u16 = 7; + /// The reason [`warn`] gives. + pub const WARNING_REASON: &str = "first strike"; /// Stores a contract that keeps the lists asked for (none: an unmoderated contract). pub fn store_contract( @@ -123,12 +152,25 @@ pub(super) mod tests { banlist: bool, suspensions: bool, platform_version: &PlatformVersion, + ) -> DataContract { + store_contract_keeping(platform, banlist, suspensions, false, platform_version) + } + + /// Stores a contract that keeps the lists asked for, the warning list included (none: an + /// unmoderated contract). + pub fn store_contract_keeping( + platform: &TempPlatform, + banlist: bool, + suspensions: bool, + warnings: bool, + platform_version: &PlatformVersion, ) -> DataContract { let mut contract = get_data_contract_fixture(None, 0, platform_version.protocol_version) .data_contract_owned(); - let moderation = (banlist || suspensions).then_some(ContractModerationConfig { + let moderation = (banlist || suspensions || warnings).then_some(ContractModerationConfig { banlist, suspensions, + warnings, moderators: ContractModerators::ContractOwner, }); contract.set_config(contract.config().clone().with_moderation(moderation)); @@ -136,6 +178,46 @@ pub(super) mod tests { contract } + /// Warns `target` at block time `warned_at`, on top of the warnings it carries. + pub fn warn( + platform: &TempPlatform, + contract: &DataContract, + target: Identifier, + warned_at: u64, + platform_version: &PlatformVersion, + ) { + let mut warnings = platform + .drive + .fetch_contract_moderation_status( + contract.id(), + target, + &[ContractModerationList::Warnings], + None, + platform_version, + ) + .expect("expected to read the warnings") + .warnings; + let replaces_existing = !warnings.is_empty(); + warnings.push(ContractWarning { + warned_at, + reason: ContractModerationReason::from_text(WARNING_REASON), + }); + platform + .drive + .add_contract_warning( + contract.id(), + target, + &warnings, + replaces_existing, + contract.owner_id(), + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to warn"); + } + pub fn ban( platform: &TempPlatform, contract: &DataContract, @@ -173,6 +255,7 @@ pub(super) mod tests { &ContractModerationReason { code: Some(SUSPENSION_REASON_CODE), text: SUSPENSION_REASON.to_string(), + documents: vec![], }, false, contract.owner_id(), @@ -195,6 +278,10 @@ pub(super) mod tests { list_from_request(SUSPENSIONS, "list").unwrap(), ContractModerationList::Suspensions ); + assert_eq!( + list_from_request(WARNINGS, "list").unwrap(), + ContractModerationList::Warnings + ); assert!(list_from_request(7, "list").is_err()); // The proto3 default, an omitted field, is refused rather than read as the banlist. assert!(list_from_request(0, "list").is_err()); diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/upgrade_fork_tests.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/upgrade_fork_tests.rs index f1a5c8a8093..3a91a25015f 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/test_cases/upgrade_fork_tests.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/upgrade_fork_tests.rs @@ -916,6 +916,281 @@ mod tests { .is_some()); } + /// The app-connect contract only comes into existence through the upgrade to protocol + /// version 14 (or a version-14 genesis). Driven through real blocks: absent while the + /// chain still runs 13, written by the activation block, served by the system contract + /// cache from 14 only, and still there after Drive is closed and reopened. + #[stack_size(4 * 1024 * 1024)] + #[test] + async fn run_chain_v13_to_v14_registers_the_app_connect_contract() { + let strategy = NetworkStrategy { + strategy: Strategy { + start_contracts: vec![], + operations: vec![], + start_identities: StartIdentities::default(), + start_addresses: StartAddresses::default(), + identity_inserts: IdentityInsertInfo::default(), + identity_contract_nonce_gaps: None, + signer: None, + }, + total_hpmns: 50, + extra_normal_mns: 0, + validator_quorum_count: 24, + chain_lock_quorum_count: 24, + upgrading_info: Some(UpgradingInfo { + current_protocol_version: 13, + proposed_protocol_versions_with_weight: vec![(14, 1)], + upgrade_three_quarters_life: 0.0, + }), + proposer_strategy: Default::default(), + rotate_quorums: false, + failure_testing: None, + query_testing: None, + verify_state_transition_results: false, + ..Default::default() + }; + let config = PlatformConfig { + validator_set: ValidatorSetConfig { + quorum_size: 30, + ..Default::default() + }, + chain_lock: ChainLockConfig::default_100_67(), + instant_lock: InstantLockConfig::default_100_67(), + execution: ExecutionConfig { + verify_sum_trees: true, + epoch_time_length_s: 60, + ..Default::default() + }, + block_spacing_ms: 1_000, + testing_configs: PlatformTestConfig { + store_platform_state: true, + ..PlatformTestConfig::default_minimal_verifications() + }, + ..Default::default() + }; + let mut platform = TestPlatformBuilder::new() + .with_config(config.clone()) + .with_initial_protocol_version(13) + .build_with_mock_rpc(); + + let platform_version_13 = PlatformVersion::get(13).expect("platform version 13"); + let platform_version_14 = PlatformVersion::get(14).expect("platform version 14"); + let app_connect_id = SystemDataContract::AppConnect.id(); + + let ChainExecutionOutcome { + abci_app, + proposers, + validator_quorums, + current_validator_quorum_hash, + current_proposer_versions, + end_time_ms, + identity_nonce_counter, + identity_contract_nonce_counter, + instant_lock_quorums, + .. + } = run_chain_for_strategy( + &mut platform, + 60, + strategy.clone(), + config.clone(), + 13, + &mut None, + &mut None, + ) + .await; + + let state = abci_app.platform.state.load(); + assert_eq!(state.current_protocol_version_in_consensus(), 13); + assert_eq!(state.next_epoch_protocol_version(), 13); + let block_start = state + .last_committed_block_info() + .as_ref() + .expect("expected committed block info") + .basic_info() + .height + + 1; + drop(state); + + let ChainExecutionOutcome { + abci_app, + proposers, + validator_quorums, + current_validator_quorum_hash, + end_time_ms, + identity_nonce_counter, + identity_contract_nonce_counter, + instant_lock_quorums, + .. + } = continue_chain_for_strategy( + abci_app, + ChainExecutionParameters { + block_start, + core_height_start: 1, + block_count: 1, + proposers, + validator_quorums, + current_validator_quorum_hash, + current_proposer_versions: Some(current_proposer_versions.clone()), + current_identity_nonce_counter: identity_nonce_counter, + current_identity_contract_nonce_counter: identity_contract_nonce_counter, + current_votes: BTreeMap::default(), + start_time_ms: 1681094380000, + current_time_ms: end_time_ms, + instant_lock_quorums, + current_identities: Vec::new(), + current_addresses_with_balance: AddressesWithBalance::default(), + }, + strategy.clone(), + config.clone(), + StrategyRandomness::SeedEntropy(7), + ) + .await; + + let state = abci_app.platform.state.load(); + assert_eq!(state.last_committed_block_epoch().index, 1); + assert_eq!(state.current_protocol_version_in_consensus(), 13); + assert_eq!(state.next_epoch_protocol_version(), 14); + let block_start = state + .last_committed_block_info() + .as_ref() + .expect("expected committed block info") + .basic_info() + .height + + 1; + drop(state); + + // Locked in but not yet active: nothing has written the contract. + assert!(abci_app + .platform + .drive + .fetch_contract( + app_connect_id.to_buffer(), + None, + None, + None, + platform_version_13, + ) + .value + .expect("query the app-connect contract before activation") + .is_none()); + assert!(abci_app + .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()); + + drop(abci_app); + let TempPlatform { + platform: mut platform_before_activation_restart, + tempdir, + } = platform; + let core_rpc = std::mem::take(&mut platform_before_activation_restart.core_rpc); + drop(platform_before_activation_restart); + platform = TempPlatform::open_with_tempdir(tempdir, config.clone()); + platform.platform.core_rpc = core_rpc; + let abci_app = FullAbciApplication::new(&platform.platform); + + let ChainExecutionOutcome { abci_app, .. } = continue_chain_for_strategy( + abci_app, + ChainExecutionParameters { + block_start, + core_height_start: 1, + block_count: 60, + proposers, + validator_quorums, + current_validator_quorum_hash, + current_proposer_versions: Some(current_proposer_versions), + current_identity_nonce_counter: identity_nonce_counter, + current_identity_contract_nonce_counter: identity_contract_nonce_counter, + current_votes: BTreeMap::default(), + start_time_ms: 1681094380000, + current_time_ms: end_time_ms, + instant_lock_quorums, + current_identities: Vec::new(), + current_addresses_with_balance: AddressesWithBalance::default(), + }, + strategy, + config.clone(), + StrategyRandomness::SeedEntropy(18), + ) + .await; + + let state = abci_app.platform.state.load(); + assert_eq!(state.last_committed_block_epoch().index, 2); + assert_eq!(state.current_protocol_version_in_consensus(), 14); + assert_eq!(state.next_epoch_protocol_version(), 14); + drop(state); + + let stored = abci_app + .platform + .drive + .fetch_contract( + app_connect_id.to_buffer(), + None, + None, + None, + platform_version_14, + ) + .value + .expect("fetch the app-connect contract after activation") + .expect("the activation block must write the app-connect contract"); + assert_eq!(stored.contract.id(), app_connect_id); + assert!(stored + .contract + .document_type_for_name("loginKeyResponse") + .is_ok()); + assert!(abci_app + .platform + .drive + .cache + .system_data_contracts + .find_by_id(app_connect_id, platform_version_14) + .expect("expected the post-activation lookup to succeed") + .is_some()); + assert!(abci_app + .platform + .drive + .cache + .system_data_contracts + .find_by_id(app_connect_id, platform_version_13) + .expect("an old-version lookup must still succeed") + .is_none()); + drop(abci_app); + + let TempPlatform { + platform: platform_before_restart, + tempdir, + } = platform; + drop(platform_before_restart); + + let reopened_platform = TempPlatform::open_with_tempdir(tempdir, config); + let state = reopened_platform.state.load(); + assert_eq!(state.current_protocol_version_in_consensus(), 14); + drop(state); + assert!(reopened_platform + .drive + .fetch_contract( + app_connect_id.to_buffer(), + None, + None, + None, + platform_version_14, + ) + .value + .expect("fetch the app-connect contract after restart") + .is_some()); + assert!(reopened_platform + .drive + .cache + .system_data_contracts + .find_by_id(app_connect_id, platform_version_14) + .expect("expected the lookup after restart to succeed") + .is_some()); + } + /// What a node has committed about the protocol upgrade, captured to compare a node that /// never restarted with one whose Drive was closed and reopened. #[derive(Debug, PartialEq)] diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/dpns/dpns-contract-contested-unique-index-no-locking.json b/packages/rs-drive-abci/tests/supporting_files/contract/dpns/dpns-contract-contested-unique-index-no-locking.json new file mode 100644 index 00000000000..db473f41d27 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/dpns/dpns-contract-contested-unique-index-no-locking.json @@ -0,0 +1,169 @@ +{ + "$formatVersion": "0", + "id": "DWBXe9EXFPHxvbArQgT45uQR5gMmi8dfMpLhR5KSbwnZ", + "ownerId": "2QjL594djCH2NyDsn45vd6yQjEDHupMKo7CEGVTHtQxU", + "version": 1, + "documentSchemas": { + "domain": { + "documentsMutable": false, + "canBeDeleted": true, + "transferable": 1, + "tradeMode": 1, + "type": "object", + "indices": [ + { + "name": "parentNameAndLabel", + "properties": [ + { + "normalizedParentDomainName": "asc" + }, + { + "normalizedLabel": "asc" + } + ], + "unique": true, + "contested": { + "fieldMatches": [ + { + "field": "normalizedLabel", + "regexPattern": "^[a-zA-Z01]{3,19}$" + } + ], + "resolution": 1, + "description": "If the normalized label part of this index is less than 20 characters (all alphabet a-z and 0 and 1) then this index is non unique while contest resolution takes place." + } + }, + { + "name": "identityId", + "nullSearchable": false, + "properties": [ + { + "records.identity": "asc" + } + ] + } + ], + "properties": { + "label": { + "type": "string", + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]$", + "minLength": 3, + "maxLength": 63, + "position": 0, + "description": "Domain label. e.g. 'Bob'." + }, + "normalizedLabel": { + "type": "string", + "pattern": "^[a-hj-km-np-z0-9][a-hj-km-np-z0-9-]{0,61}[a-hj-km-np-z0-9]$", + "maxLength": 63, + "position": 1, + "description": "Domain label converted to lowercase for case-insensitive uniqueness validation. \"o\", \"i\" and \"l\" replaced with \"0\" and \"1\" to mitigate homograph attack. e.g. 'b0b'", + "$comment": "Must be equal to the label in lowercase. \"o\", \"i\" and \"l\" must be replaced with \"0\" and \"1\"." + }, + "parentDomainName": { + "type": "string", + "pattern": "^$|^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]$", + "minLength": 0, + "maxLength": 63, + "position": 2, + "description": "A full parent domain name. e.g. 'dash'." + }, + "normalizedParentDomainName": { + "type": "string", + "pattern": "^$|^[a-hj-km-np-z0-9][a-hj-km-np-z0-9-\\.]{0,61}[a-hj-km-np-z0-9]$", + "minLength": 0, + "maxLength": 63, + "position": 3, + "description": "A parent domain name in lowercase for case-insensitive uniqueness validation. \"o\", \"i\" and \"l\" replaced with \"0\" and \"1\" to mitigate homograph attack. e.g. 'dash'", + "$comment": "Must either be equal to an existing domain or empty to create a top level domain. \"o\", \"i\" and \"l\" must be replaced with \"0\" and \"1\". Only the data contract owner can create top level domains." + }, + "preorderSalt": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "position": 4, + "description": "Salt used in the preorder document" + }, + "records": { + "type": "object", + "properties": { + "identity": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "position": 1, + "contentMediaType": "application/x.dash.dpp.identifier", + "description": "Identifier name record that refers to an Identity" + } + }, + "minProperties": 1, + "position": 5, + "additionalProperties": false + }, + "subdomainRules": { + "type": "object", + "properties": { + "allowSubdomains": { + "type": "boolean", + "description": "This option defines who can create subdomains: true - anyone; false - only the domain owner", + "$comment": "Only the domain owner is allowed to create subdomains for non top-level domains", + "position": 0 + } + }, + "position": 6, + "description": "Subdomain rules allow domain owners to define rules for subdomains", + "additionalProperties": false, + "required": [ + "allowSubdomains" + ] + } + }, + "required": [ + "$createdAt", + "$updatedAt", + "$transferredAt", + "label", + "normalizedLabel", + "normalizedParentDomainName", + "preorderSalt", + "records", + "subdomainRules" + ], + "additionalProperties": false, + "$comment": "In order to register a domain you need to create a preorder. The preorder step is needed to prevent man-in-the-middle attacks. normalizedLabel + '.' + normalizedParentDomain must not be longer than 253 chars length as defined by RFC 1035. Domain documents are immutable: modification and deletion are restricted" + }, + "preorder": { + "documentsMutable": false, + "canBeDeleted": true, + "type": "object", + "indices": [ + { + "name": "saltedHash", + "properties": [ + { + "saltedDomainHash": "asc" + } + ], + "unique": true + } + ], + "properties": { + "saltedDomainHash": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "position": 0, + "description": "Double sha-256 of the concatenation of a 32 byte random salt and a normalized domain name" + } + }, + "required": [ + "saltedDomainHash" + ], + "additionalProperties": false, + "$comment": "Preorder documents are immutable: modification and deletion are restricted" + } + } +} \ No newline at end of file diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-aged-contract-ref.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-aged-contract-ref.json new file mode 100644 index 00000000000..6e7e380ca8d --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-aged-contract-ref.json @@ -0,0 +1,38 @@ +{ + "$formatVersion": "1", + "id": "GbYWKJSr6P7fJqkAdSCNM5kuN2eBewAgYfrMJ22rfhrD", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "refContractId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "contract", + "contractRequirements": { + "minimumAgeSeconds": 3600 + } + } + }, + "note": { + "type": "string", + "position": 1, + "maxLength": 64 + } + }, + "required": [ + "refContractId" + ], + "indices": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-elected-contract-ref.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-elected-contract-ref.json new file mode 100644 index 00000000000..c7ff58554d1 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-elected-contract-ref.json @@ -0,0 +1,38 @@ +{ + "$formatVersion": "1", + "id": "9k3RE6kHNTsDmyXFwEPpiFQ3ipXfp5FuXGXpQ1rDHDJb", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "refContractId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "contract", + "contractRequirements": { + "moderation": "elected" + } + } + }, + "note": { + "type": "string", + "position": 1, + "maxLength": 64 + } + }, + "required": [ + "refContractId" + ], + "indices": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-election-open-contract-ref.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-election-open-contract-ref.json new file mode 100644 index 00000000000..fd24dc7d0f7 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-election-open-contract-ref.json @@ -0,0 +1,38 @@ +{ + "$formatVersion": "1", + "id": "FutvNUuQYthkyfNtaEsdJEFsnCQshBohD9GP7NrSUwR", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "refContractId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "contract", + "contractRequirements": { + "moderation": "electionOpen" + } + } + }, + "note": { + "type": "string", + "position": 1, + "maxLength": 64 + } + }, + "required": [ + "refContractId" + ], + "indices": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-keeps-history-contract-ref.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-keeps-history-contract-ref.json new file mode 100644 index 00000000000..bad217dc0a4 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-keeps-history-contract-ref.json @@ -0,0 +1,38 @@ +{ + "$formatVersion": "1", + "id": "7gQQ8BdF9gem9YxBNLd3QW7AmiGYNTTuVLerkiJZ3JCx", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "refContractId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "contract", + "contractRequirements": { + "keepsHistory": true + } + } + }, + "note": { + "type": "string", + "position": 1, + "maxLength": 64 + } + }, + "required": [ + "refContractId" + ], + "indices": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-other-contract-ref.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-other-contract-ref.json new file mode 100644 index 00000000000..dc6f2f31520 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-other-contract-ref.json @@ -0,0 +1,38 @@ +{ + "$formatVersion": "1", + "id": "EPHoATa8ifeWoviLaxTQRWfsJUHAQUCro6dEkwgjwog7", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "refContractId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "contract", + "contractRequirements": { + "owner": "other" + } + } + }, + "note": { + "type": "string", + "position": 1, + "maxLength": 64 + } + }, + "required": [ + "refContractId" + ], + "indices": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-protected-contract-ref.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-protected-contract-ref.json new file mode 100644 index 00000000000..84429f76050 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-protected-contract-ref.json @@ -0,0 +1,38 @@ +{ + "$formatVersion": "1", + "id": "6BL67iVAJ5eiVne4qXJGNRGpVqB2b2TKiF1odQ7y2qjn", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "refContractId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "contract", + "contractRequirements": { + "ownerProtected": true + } + } + }, + "note": { + "type": "string", + "position": 1, + "maxLength": 64 + } + }, + "required": [ + "refContractId" + ], + "indices": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-self-contract-ref.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-self-contract-ref.json new file mode 100644 index 00000000000..752519fde02 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-self-contract-ref.json @@ -0,0 +1,38 @@ +{ + "$formatVersion": "1", + "id": "Gb9ZqQQTAoV7JCUrZhsS4ssZozbE33Suunzhr7RVy2pD", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "refContractId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "contract", + "contractRequirements": { + "owner": "self" + } + } + }, + "note": { + "type": "string", + "position": 1, + "maxLength": 64 + } + }, + "required": [ + "refContractId" + ], + "indices": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-readonly-contract-ref.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-readonly-contract-ref.json new file mode 100644 index 00000000000..ca7a468e017 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-readonly-contract-ref.json @@ -0,0 +1,38 @@ +{ + "$formatVersion": "1", + "id": "2FkduiNwoBct7PdxmCRYgLxckj4s8dhx7tXLUUpq74Qk", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "refContractId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "contract", + "contractRequirements": { + "readonly": true + } + } + }, + "note": { + "type": "string", + "position": 1, + "maxLength": 64 + } + }, + "required": [ + "refContractId" + ], + "indices": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-updated-contract-ref.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-updated-contract-ref.json new file mode 100644 index 00000000000..5c7d7490200 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-updated-contract-ref.json @@ -0,0 +1,38 @@ +{ + "$formatVersion": "1", + "id": "6wUrQd8gVLTK7sHXLBXT5brjTxfMGjnSxw2Fddwr791g", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "refContractId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "contract", + "contractRequirements": { + "minimumSecondsSinceUpdate": 3600 + } + } + }, + "note": { + "type": "string", + "position": 1, + "maxLength": 64 + } + }, + "required": [ + "refContractId" + ], + "indices": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-proof-verifier/src/types/contract_moderation.rs b/packages/rs-drive-proof-verifier/src/types/contract_moderation.rs index 7c519741189..d24437be8cb 100644 --- a/packages/rs-drive-proof-verifier/src/types/contract_moderation.rs +++ b/packages/rs-drive-proof-verifier/src/types/contract_moderation.rs @@ -1,7 +1,7 @@ //! Contract moderation query results and the wire conversions the proved and unproved paths //! share: one identity's status on the lists queried ([`ContractModerationListStatuses`]) and one -//! page of a contract's banlist or suspension list ([`ContractModerationEntries`], read with a -//! [`ContractModerationEntriesQuery`]). The records of the documents its moderators deleted +//! page of a contract's banlist, suspension list or warning list ([`ContractModerationEntries`], +//! read with a [`ContractModerationEntriesQuery`]). The records of the documents its moderators deleted //! ([`ContractDocumentRemovals`], read with a [`ContractDocumentRemovalsQuery`]) and the fee //! pots of a contract ([`ContractFeePots`]) are read here too: the pots are what its document //! action fees pay its owner and its moderators. @@ -16,12 +16,16 @@ use dapi_grpc::platform::v0::get_contract_fee_pots_response::{ ContractFeePot as ContractFeePotProto, ContractFeePots as ContractFeePotsProto, }; use dapi_grpc::platform::v0::get_contract_moderation_entries_response::ContractModerationEntry as ContractModerationEntryProto; +#[cfg(test)] +use dapi_grpc::platform::v0::ContractModerationDocument as ContractModerationDocumentProto; use dapi_grpc::platform::v0::ContractModerationList as ContractModerationListProto; use dapi_grpc::platform::v0::ContractModerationReason as ContractModerationReasonProto; +use dapi_grpc::platform::v0::ContractWarning as ContractWarningProto; pub use dpp::data_contract::config::moderation::{ - ContractBan, ContractDocumentRemoval, ContractModerationList, ContractModerationListStatus, - ContractModerationListStatuses, ContractModerationReason, ContractModerationStatus, - ContractSuspension, + ContractBan, ContractDocumentRemoval, ContractDocumentRestoration, ContractModerationDocument, + ContractModerationList, + ContractModerationListStatus, ContractModerationListStatuses, ContractModerationReason, + ContractModerationStatus, ContractSuspension, ContractWarning, }; pub use dpp::data_contract::document_type::action_fees::{ContractFeePot, ContractFeePotLastClaim}; use dpp::identifier::Identifier; @@ -39,8 +43,8 @@ pub fn default_contract_moderation_entries_limit(platform_version: &PlatformVers platform_version.drive_abci.query.max_returned_elements } -/// One page of a moderated contract's banlist or suspension list, in identity id order. A page -/// shorter than the limit is the last one. +/// One page of a moderated contract's banlist, suspension list or warning list, in identity id +/// order. A page shorter than the limit is the last one. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ContractModerationEntries(pub Vec); @@ -140,6 +144,7 @@ pub fn list_from_request(list: i32, what: &str) -> Result Ok(ContractModerationList::Banlist), Ok(ContractModerationListProto::Suspensions) => Ok(ContractModerationList::Suspensions), + Ok(ContractModerationListProto::Warnings) => Ok(ContractModerationList::Warnings), // Zero is what a proto3 client sends when it leaves the field out: not a list. Ok(ContractModerationListProto::Unspecified) | Err(_) => Err(Error::RequestError { error: format!("{what} {list} is not a moderation list"), @@ -152,6 +157,7 @@ pub fn list_to_request(list: ContractModerationList) -> i32 { match list { ContractModerationList::Banlist => ContractModerationListProto::Banlist as i32, ContractModerationList::Suspensions => ContractModerationListProto::Suspensions as i32, + ContractModerationList::Warnings => ContractModerationListProto::Warnings as i32, } } @@ -207,17 +213,21 @@ pub fn entries_query_from_request( } /// The reason of an unproved response. Every ban and every suspension carries one, so a -/// response without it is refused, and so is a code that is not a u16, which no entry of any -/// version can hold. The length of the text is not checked: its limit belongs to a protocol -/// version and may be raised by a later one, the proved path reads whatever the proof holds, -/// and the two must agree on which stored reasons a client can read. +/// response without it is refused, and so is a code that is not a u16 or a cited document +/// whose id is not 32 bytes, which no entry of any version can hold. The length of the text +/// and the number of documents are not checked: their limits belong to a protocol version and +/// may be raised by a later one, the proved path reads whatever the proof holds, and the two +/// must agree on which stored reasons a client can read. pub fn reason_from_response( reason: Option, ) -> Result { - let ContractModerationReasonProto { code, text } = - reason.ok_or(Error::ResponseDecodeError { - error: "contract moderation entry holds no reason".to_string(), - })?; + let ContractModerationReasonProto { + code, + text, + documents, + } = reason.ok_or(Error::ResponseDecodeError { + error: "contract moderation entry holds no reason".to_string(), + })?; let code = code .map(|code| { u16::try_from(code).map_err(|_| Error::ResponseDecodeError { @@ -225,16 +235,67 @@ pub fn reason_from_response( }) }) .transpose()?; - Ok(ContractModerationReason { code, text }) + let documents = documents + .into_iter() + .map(|document| { + Ok(ContractModerationDocument { + document_type_name: document.document_type_name, + document_id: Identifier::from_bytes(&document.document_id).map_err(|_| { + Error::ResponseDecodeError { + error: format!( + "a document a contract moderation reason cites has an id of {} \ + bytes, not 32", + document.document_id.len() + ), + } + })?, + }) + }) + .collect::, Error>>()?; + Ok(ContractModerationReason { + code, + text, + documents, + }) +} + +/// The warnings of an unproved response, oldest first as the node answers. Every warning +/// carries a reason, as [`reason_from_response`] requires. +pub fn warnings_from_response( + warnings: Vec, +) -> Result, Error> { + warnings + .into_iter() + .map(|warning| { + Ok(ContractWarning { + warned_at: warning.warned_at, + reason: reason_from_response(warning.reason)?, + }) + }) + .collect() } -/// The entries of an unproved response. +/// The entries of an unproved response. A warning list entry carries its warnings and no +/// `reason` of its own: its reason is the latest warning's, read from there, and a response +/// that sends one beside the warnings is refused rather than left with two sources for it. pub fn entries_from_response( entries: Vec, ) -> Result { entries .into_iter() .map(|entry| { + let warnings = warnings_from_response(entry.warnings)?; + let reason = match warnings.last() { + None => reason_from_response(entry.reason)?, + Some(latest) if entry.reason.is_none() => latest.reason.clone(), + Some(_) => { + return Err(Error::ResponseDecodeError { + error: "contract moderation entry carries warnings and a reason of \ + its own" + .to_string(), + }) + } + }; Ok(ContractModerationEntry { identity_id: Identifier::from_bytes(&entry.identity_id).map_err(|_| { Error::ProtocolError { @@ -245,7 +306,8 @@ pub fn entries_from_response( } })?, until: entry.until, - reason: reason_from_response(entry.reason)?, + reason, + warnings, }) }) .collect::, Error>>() @@ -299,9 +361,10 @@ pub fn removals_query_from_request( Ok(query) } -/// The records of an unproved response. Every record names three identities and carries a -/// reason, so a response missing any of them is refused, and so is one that answers with more -/// records than the query could hold or with the record of a document it did not name. +/// The records of an unproved response. Every record names three identities, carries a +/// reason and a 32 byte document hash, and a restored one names a fourth identity, so a +/// response missing any of them is refused, and so is one that answers with more records than +/// the query could hold or with the record of a document it did not name. pub fn removals_from_response( removals: Vec, query: &ContractDocumentRemovalsQuery, @@ -331,6 +394,26 @@ pub fn removals_from_response( )?, reason: reason_from_response(removal.reason)?, removed_at: removal.removed_at, + document_hash: removal.document_hash.as_slice().try_into().map_err(|_| { + Error::ResponseDecodeError { + error: format!( + "removal document hash holds {} bytes, expected 32", + removal.document_hash.len() + ), + } + })?, + restoration: removal + .restoration + .map(|restoration| { + Ok::<_, Error>(ContractDocumentRestoration { + moderator_id: identifier_from_response( + &restoration.moderator_id, + "restoration moderator id", + )?, + restored_at: restoration.restored_at, + }) + }) + .transpose()?, }, }) }) @@ -407,6 +490,7 @@ pub fn fee_pots_from_response(pots: ContractFeePotsProto) -> Result Identifier { Identifier::from([seed; 32]) @@ -421,6 +505,7 @@ mod tests { reason: ContractModerationReason::from_text("spam"), }), suspension: None, + warnings: vec![], }; let suspensions_only = ContractModerationListStatuses::from_status( &[ContractModerationList::Suspensions], @@ -448,6 +533,7 @@ mod tests { until: 10, reason: ContractModerationReason::from_text("flooding"), }), + warnings: vec![], }, ); assert!(suspended.is_barred_on_queried_lists_at(9)); @@ -459,6 +545,7 @@ mod tests { for list in [ ContractModerationList::Banlist, ContractModerationList::Suspensions, + ContractModerationList::Warnings, ] { assert_eq!( list_from_request(list_to_request(list), "list").expect("expected a list"), @@ -475,10 +562,11 @@ mod tests { #[test] fn should_parse_the_lists_of_a_status_request() { assert_eq!( - lists_from_request(&[2, 1]).expect("expected lists"), + lists_from_request(&[2, 1, 3]).expect("expected lists"), vec![ ContractModerationList::Suspensions, - ContractModerationList::Banlist + ContractModerationList::Banlist, + ContractModerationList::Warnings, ] ); for (lists, needle) in [ @@ -541,7 +629,9 @@ mod tests { reason: Some(ContractModerationReasonProto { code: None, text: "spam".to_string(), + documents: vec![], }), + warnings: vec![], }, ContractModerationEntryProto { identity_id: id(2).to_vec(), @@ -549,7 +639,32 @@ mod tests { reason: Some(ContractModerationReasonProto { code: Some(3), text: String::new(), + documents: vec![], }), + warnings: vec![], + }, + ContractModerationEntryProto { + identity_id: id(3).to_vec(), + until: None, + reason: None, + warnings: vec![ + ContractWarningProto { + warned_at: 5, + reason: Some(ContractModerationReasonProto { + code: None, + text: "first strike".to_string(), + documents: vec![], + }), + }, + ContractWarningProto { + warned_at: 6, + reason: Some(ContractModerationReasonProto { + code: None, + text: "second strike".to_string(), + documents: vec![], + }), + }, + ], }, ]) .expect("expected entries"); @@ -560,6 +675,7 @@ mod tests { identity_id: id(1), until: None, reason: ContractModerationReason::from_text("spam"), + warnings: vec![], }, ContractModerationEntry { identity_id: id(2), @@ -567,15 +683,65 @@ mod tests { reason: ContractModerationReason { code: Some(3), text: String::new(), + documents: vec![], }, - } + warnings: vec![], + }, + ContractModerationEntry { + identity_id: id(3), + until: None, + reason: ContractModerationReason::from_text("second strike"), + warnings: vec![ + ContractWarning { + warned_at: 5, + reason: ContractModerationReason::from_text("first strike"), + }, + ContractWarning { + warned_at: 6, + reason: ContractModerationReason::from_text("second strike"), + }, + ], + }, ] ); + // A warning entry's reason is its latest warning's: one beside the warnings is refused. + let err = entries_from_response(vec![ContractModerationEntryProto { + identity_id: id(1).to_vec(), + until: None, + reason: Some(ContractModerationReasonProto::default()), + warnings: vec![ContractWarningProto { + warned_at: 5, + reason: Some(ContractModerationReasonProto::default()), + }], + }]) + .unwrap_err(); + assert!( + matches!(err, Error::ResponseDecodeError { .. }), + "got: {err:?}" + ); + + // Every warning carries a reason + let err = entries_from_response(vec![ContractModerationEntryProto { + identity_id: id(1).to_vec(), + until: None, + reason: Some(ContractModerationReasonProto::default()), + warnings: vec![ContractWarningProto { + warned_at: 5, + reason: None, + }], + }]) + .unwrap_err(); + assert!( + matches!(err, Error::ResponseDecodeError { .. }), + "got: {err:?}" + ); + let err = entries_from_response(vec![ContractModerationEntryProto { identity_id: vec![1; 5], until: None, reason: Some(ContractModerationReasonProto::default()), + warnings: vec![], }]) .unwrap_err(); assert!(matches!(err, Error::ProtocolError { .. }), "got: {err:?}"); @@ -585,6 +751,44 @@ mod tests { identity_id: id(1).to_vec(), until: None, reason: None, + warnings: vec![], + }]) + .unwrap_err(); + assert!( + matches!(err, Error::ResponseDecodeError { .. }), + "got: {err:?}" + ); + + // A reason cites documents by type and 32-byte id; another id length is refused. + let cited_reason = |document_id: Vec| { + Some(ContractModerationReasonProto { + code: None, + text: "spam".to_string(), + documents: vec![ContractModerationDocumentProto { + document_type_name: "post".to_string(), + document_id, + }], + }) + }; + let cited = entries_from_response(vec![ContractModerationEntryProto { + identity_id: id(1).to_vec(), + until: None, + reason: cited_reason(id(9).to_vec()), + warnings: vec![], + }]) + .expect("expected a cited document to decode"); + assert_eq!( + cited.entries()[0].reason.documents, + vec![ContractModerationDocument { + document_type_name: "post".to_string(), + document_id: id(9), + }] + ); + let err = entries_from_response(vec![ContractModerationEntryProto { + identity_id: id(1).to_vec(), + until: None, + reason: cited_reason(vec![9; 5]), + warnings: vec![], }]) .unwrap_err(); assert!( @@ -600,7 +804,9 @@ mod tests { reason: Some(ContractModerationReasonProto { code: None, text: "x".repeat(4096), + documents: vec![], }), + warnings: vec![], }]) .expect("expected a long reason to decode"); assert_eq!(long.entries()[0].reason.text.len(), 4096); @@ -618,11 +824,13 @@ mod tests { identity_id: id(1), until: Some(5), reason: ContractModerationReason::default(), + warnings: vec![], }, ContractModerationEntry { identity_id: id(2), until: Some(6), reason: ContractModerationReason::default(), + warnings: vec![], }, ]); assert_eq!( @@ -642,6 +850,7 @@ mod tests { identity_id: id(1), until: Some(5), reason: ContractModerationReason::default(), + warnings: vec![], }]); assert_eq!(short.next_query(&query), None); } @@ -667,6 +876,12 @@ mod tests { moderator_id: id(0x77), reason: ContractModerationReason::from_text("spam"), removed_at: 1_000 + u64::from(seed), + document_hash: [seed + 0x20; 32], + // Every other record was restored. + restoration: seed.is_multiple_of(2).then(|| ContractDocumentRestoration { + moderator_id: id(0x78), + restored_at: 2_000 + u64::from(seed), + }), } } @@ -680,7 +895,15 @@ mod tests { reason: Some(ContractModerationReasonProto { code: None, text: "spam".to_string(), + documents: vec![], }), + document_hash: removal.document_hash.to_vec(), + restoration: removal + .restoration + .map(|restoration| ContractDocumentRestorationProto { + moderator_id: restoration.moderator_id.to_vec(), + restored_at: restoration.restored_at, + }), } } @@ -806,11 +1029,17 @@ mod tests { ] ); - // Every identifier of a record is 32 bytes. + // Every identifier of a record is 32 bytes, the restoring moderator's included. for spoil in [ |proto: &mut ContractDocumentRemovalProto| proto.document_id = vec![1; 5], |proto: &mut ContractDocumentRemovalProto| proto.document_owner_id = vec![1; 5], |proto: &mut ContractDocumentRemovalProto| proto.moderator_id = vec![1; 5], + |proto: &mut ContractDocumentRemovalProto| { + proto.restoration = Some(ContractDocumentRestorationProto { + moderator_id: vec![1; 5], + restored_at: 5, + }) + }, ] { let mut proto = removal_proto(1); spoil(&mut proto); @@ -818,12 +1047,22 @@ mod tests { assert!(matches!(err, Error::ProtocolError { .. }), "got: {err:?}"); } + // And its document hash 32 bytes. + let mut proto = removal_proto(1); + proto.document_hash = vec![1; 31]; + let err = removals_from_response(vec![proto], &ids_query(&[1])).unwrap_err(); + assert!( + matches!(&err, Error::ResponseDecodeError { error } if error.contains("expected 32")), + "got: {err:?}" + ); + // Every record carries a reason, with a code that fits a u16. for reason in [ None, Some(ContractModerationReasonProto { code: Some(u32::from(u16::MAX) + 1), text: String::new(), + documents: vec![], }), ] { let proto = ContractDocumentRemovalProto { diff --git a/packages/rs-drive-proof-verifier/src/unproved.rs b/packages/rs-drive-proof-verifier/src/unproved.rs index f53713a67e2..9d07f9bb7d6 100644 --- a/packages/rs-drive-proof-verifier/src/unproved.rs +++ b/packages/rs-drive-proof-verifier/src/unproved.rs @@ -5,9 +5,10 @@ use crate::types::contract_groups::{ }; use crate::types::contract_moderation::{ entries_from_response, fee_pots_from_response, list_from_request, lists_from_request, - reason_from_response, removals_from_response, removals_query_from_request, ContractBan, - ContractDocumentRemovals, ContractFeePots, ContractModerationEntries, ContractModerationList, - ContractModerationListStatuses, ContractModerationStatus, ContractSuspension, + reason_from_response, removals_from_response, removals_query_from_request, + warnings_from_response, ContractBan, ContractDocumentRemovals, ContractFeePots, + ContractModerationEntries, ContractModerationList, ContractModerationListStatuses, + ContractModerationStatus, ContractSuspension, }; use crate::types::data_contracts_latest_versions::{ DataContractLatestVersion, DataContractsLatestVersions, @@ -950,9 +951,20 @@ impl FromUnproved for ContractMode .map(|reason| ContractSuspension { until, reason }) }) .transpose()?; + // The warnings are read only when the warning list was asked for: an empty + // list on the wire says "none" for a list read and nothing for one not read. + let warnings = if lists.contains(&ContractModerationList::Warnings) { + warnings_from_response(status.warnings)? + } else { + vec![] + }; Some(ContractModerationListStatuses::from_status( &lists, - &ContractModerationStatus { ban, suspension }, + &ContractModerationStatus { + ban, + suspension, + warnings, + }, )) } Some(V0Result::Proof(_)) => { @@ -1510,12 +1522,14 @@ mod contract_moderation_tests { GetContractModerationStatusResponseV0, Version as StatusResponseVersion, }; use dapi_grpc::platform::v0::ContractModerationReason as ContractModerationReasonProto; + use dapi_grpc::platform::v0::ContractWarning as ContractWarningProto; use dapi_grpc::platform::v0::ResponseMetadata; use dpp::dashcore::Network; use dpp::version::PlatformVersion; const BANLIST: i32 = 1; const SUSPENSIONS: i32 = 2; + const WARNINGS: i32 = 3; fn status( requested: Vec, @@ -1559,11 +1573,14 @@ mod contract_moderation_tests { ban_reason: Some(ContractModerationReasonProto { code: None, text: "spam".to_string(), + documents: vec![], }), suspension_reason: Some(ContractModerationReasonProto { code: Some(9), text: "flooding".to_string(), + documents: vec![], }), + warnings: vec![], }, ) .expect("expected the status to convert") @@ -1571,6 +1588,8 @@ mod contract_moderation_tests { assert_eq!(statuses.banned(), Some(true)); assert_eq!(statuses.suspended_until(), Some(Some(7))); + // The warning list was not asked for, so nothing is said about it. + assert_eq!(statuses.warnings(), None); assert_eq!( statuses.ban().flatten().map(|ban| &ban.reason), Some(&ContractModerationReason::from_text("spam")) @@ -1580,6 +1599,7 @@ mod contract_moderation_tests { Some(&ContractModerationReason { code: Some(9), text: "flooding".to_string(), + documents: vec![], }) ); } @@ -1604,6 +1624,7 @@ mod contract_moderation_tests { suspension_reason: Some(ContractModerationReasonProto { code: Some(u16::MAX as u32 + 1), text: String::new(), + documents: vec![], }), ..Default::default() }, @@ -1625,6 +1646,59 @@ mod contract_moderation_tests { assert_eq!(clean.suspension(), Some(None)); } + #[test] + fn should_read_the_warnings_when_the_warning_list_was_asked_for() { + let warning = |warned_at: u64, text: &str| ContractWarningProto { + warned_at, + reason: Some(ContractModerationReasonProto { + code: None, + text: text.to_string(), + documents: vec![], + }), + }; + let warned = status( + vec![WARNINGS], + ContractModerationStatusProto { + lists: vec![WARNINGS], + warnings: vec![warning(5, "first strike"), warning(6, "second strike")], + ..Default::default() + }, + ) + .expect("expected the status to convert") + .expect("expected a status"); + let warnings = warned.warnings().expect("expected the warning list"); + assert_eq!(warnings.len(), 2); + assert_eq!(warnings[1].warned_at, 6); + assert_eq!(warnings[1].reason.text, "second strike"); + assert_eq!(warned.banned(), None); + + // No warnings on a list that was read: none, not unknown. + let clean = status( + vec![WARNINGS], + ContractModerationStatusProto { + lists: vec![WARNINGS], + ..Default::default() + }, + ) + .expect("expected the status to convert") + .expect("expected a status"); + assert_eq!(clean.warnings(), Some(&[][..])); + + // A warning without a reason is refused like an entry without one. + let no_reason = status( + vec![WARNINGS], + ContractModerationStatusProto { + lists: vec![WARNINGS], + warnings: vec![ContractWarningProto { + warned_at: 5, + reason: None, + }], + ..Default::default() + }, + ); + assert!(matches!(no_reason, Err(Error::ResponseDecodeError { .. }))); + } + #[test] fn should_refuse_a_status_that_does_not_cover_a_list_asked_for() { // The banlist was asked for and the response does not say it was read: reporting "not @@ -1688,9 +1762,12 @@ mod contract_moderation_tests { document_owner_id: vec![seed + 0x10; 32], moderator_id: vec![0x77; 32], removed_at: 1_000 + u64::from(seed), + document_hash: vec![seed + 0x20; 32], + restoration: None, reason: Some(ContractModerationReasonProto { code: None, text: "spam".to_string(), + documents: vec![], }), } } diff --git a/packages/rs-drive/grovedb-structure.json b/packages/rs-drive/grovedb-structure.json index 5851d8912b7..8a074d22cd9 100644 --- a/packages/rs-drive/grovedb-structure.json +++ b/packages/rs-drive/grovedb-structure.json @@ -3175,6 +3175,50 @@ "children": [] } ] + }, + { + "id": "contracts.contract.other.warnings", + "key": { + "type": "fixed", + "hex": "e0", + "label": "Warnings", + "constant": "CONTRACT_WARNINGS_KEY" + }, + "kinds": [ + "Tree" + ], + "since": 14, + "presence": "lazy", + "source": "packages/rs-drive/src/drive/contract/paths.rs", + "description": "The identities warned on the contract, and why. Created with the contract, and only when its config declares a warning list. A warning bars nothing, so no document transition reads it and its depth does not matter: above 192, it leaves the banlist on top in the likely combinations of lists.", + "children": [ + { + "id": "contracts.contract.other.warnings.identity", + "key": { + "type": "dynamic", + "name": "identity_id", + "matcher": { + "type": "len", + "len": 32 + }, + "encoding": "identifier32", + "description": "The warned identity's id" + }, + "kinds": [ + "Item" + ], + "flags": [ + "EpochOwned" + ], + "flags_note": "The owner is the moderator who added the entry. They pay for it, and are refunded when it is removed. A suspension replaced with a longer reason passes to the moderator who replaced it, who pays for the added bytes; replaced with a shorter or an equally long one it stays the first moderator's, who is refunded the removed bytes.", + "value": "one or more warnings, oldest first, each: the block time in milliseconds of the warning, u64 big endian, the length of its reason, u16 big endian, then the moderator's reason as in a banlist entry", + "since": 14, + "presence": "always", + "source": "packages/rs-drive/src/drive/contract/paths.rs", + "description": "The warnings one identity carries. Each warn rewrites the item one warning longer, so it belongs to the moderator that warned last; a clearing deletes it.", + "children": [] + } + ] } ] } diff --git a/packages/rs-drive/src/cache/system_contracts.rs b/packages/rs-drive/src/cache/system_contracts.rs index 2d2655be964..8c562a5bce3 100644 --- a/packages/rs-drive/src/cache/system_contracts.rs +++ b/packages/rs-drive/src/cache/system_contracts.rs @@ -3,6 +3,7 @@ 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; use platform_version::version::{PlatformVersion, ProtocolVersion}; use std::collections::BTreeMap; use std::sync::Arc; @@ -159,6 +160,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 +206,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 => 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), @@ -399,6 +410,27 @@ mod tests { .is_some()); } + #[test] + fn should_serve_app_connect_only_from_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(), + PlatformVersion::latest() + ) + .expect("expected the v14 lookup to succeed") + .is_some()); + assert!(contracts + .find_by_id(SystemDataContract::AppConnect.id(), platform_version(13)) + .expect("an old-version lookup after materialization must succeed") + .is_none()); + } + fn memoized_protocol_versions(contracts: &SystemDataContracts) -> Vec { let materialized = contracts.materialized.load(); let mut protocol_versions: Vec = materialized diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/index_only_scalar_terminal_e2e_tests.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/index_only_scalar_terminal_e2e_tests.rs new file mode 100644 index 00000000000..7745a166684 --- /dev/null +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/index_only_scalar_terminal_e2e_tests.rs @@ -0,0 +1,1914 @@ +//! End-to-end coverage for indexOnly **scalar terminals**: an index's +//! `terminal` may name any property a prefix position admits, not only +//! `$ownerId` or a refersTo identifier. The member key is the terminal +//! value in its tree-key encoding, so a 33-byte byte array, a string and +//! an integer key the `0` member bucket exactly as a prefix level would +//! key by them. +//! +//! Runs against the `index-only-scalar-terminal` fixture at +//! `tests/supporting_files/contract/index-only-scalar-terminal/`: +//! +//! | doctype | index | properties | terminal | terminal type | +//! |----------|-------------|-------------------------|------------|----------------| +//! | `answer` | `byRequest` | `[requestId, $ownerId]` | `payload` | 33-byte array | +//! | `vote` | `byPoll` | `[pollId, $ownerId]` | `choice` | string ≤ 16 | +//! | `vote` | `byChoice` | `[pollId, choice]` | `$ownerId` | ranked count | +//! | `rating` | `byPost` | `[postId, $ownerId]` | `stars` | integer 1 to 5 | +//! | `reaction` | `byPostKind` | `[postId]` | `kind ‖ $ownerId` | composite, prefixed | +//! | `loginKeyResponse` | `byRequest` | (none: flat) | `appEphemeralPubKeyHash ‖ $ownerId` | composite, flat; `entryPayload` | +//! | `note` | `byPostBody` | `[postId]` | `$ownerId ‖ body` | composite; variable-width last | +//! +//! The last two exercise **composite terminals** (the member key is the +//! concatenation of several components' encodings) — one below a prefix +//! level, one FLAT (no prefix at all, entries directly under a level keyed +//! by the terminal's names) — and the flat one also carries an +//! **entry payload**: the wallet key and the ciphertext ride in every +//! entry's item after the row commitment, the type's value slot. +//! +//! Pinned: the entry layout (member key = encoded terminal value, element +//! = row-commitment `Item`), structural uniqueness spanning the terminal +//! value, query synthesis and proof parity with the terminal decoded off +//! the member key, terminal-equality lookups, delete-by-values symmetry, +//! and the fee invariant: the dry run's member-key width follows the +//! terminal property, so the estimate keeps upper-bounding the applied +//! fee for keys narrower and wider than 32 bytes. + +use super::index_only_e2e_tests::{ + assert_grovedb_is_consistent, count_top_k, platform_version, read_grove_element, +}; +use crate::drive::document::query::QueryDocumentsOutcomeV0Methods; +use crate::drive::document::INDEX_ONLY_ROW_COMMITMENT_SIZE; +use crate::drive::Drive; +use crate::error::Error; +use crate::query::{DriveDocumentQuery, InternalClauses, OrderClause, WhereClause, WhereOperator}; +use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; +use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; +use crate::util::storage_flags::StorageFlags; +use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; +use dpp::data_contract::document_type::random_document::CreateRandomDocument; +use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; +use dpp::document::{Document, DocumentV0Getters, DocumentV0Setters}; +use dpp::fee::fee_result::FeeResult; +use dpp::platform_value::{Identifier, Value}; +use dpp::prelude::DataContract; +use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; +use dpp::tests::json_document::json_document_to_contract; +use grovedb::Element; +use std::collections::BTreeMap; + +const FIXTURE: &str = "tests/supporting_files/contract/index-only-scalar-terminal/index-only-scalar-terminal-contract.json"; + +const REQUEST: [u8; 20] = [0x5A; 20]; +const PAYLOAD_1: [u8; 33] = [0x01; 33]; +const PAYLOAD_2: [u8; 33] = [0x02; 33]; +const POLL: [u8; 32] = [0xC3; 32]; +const POST: [u8; 32] = [0xD4; 32]; +const OWNER_1: [u8; 32] = [0x11; 32]; +const OWNER_2: [u8; 32] = [0x22; 32]; +const OWNER_3: [u8; 32] = [0x33; 32]; + +/// A fresh Drive with the scalar-terminal fixture contract applied. +fn setup() -> (Drive, DataContract) { + let drive = setup_drive_with_initial_state_structure(None); + let pv = platform_version(); + let contract = + json_document_to_contract(FIXTURE, false, pv).expect("expected to parse the fixture"); + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + pv, + ) + .expect("expected to apply the fixture contract"); + (drive, contract) +} + +/// `[DataContractDocuments, contract_id, 1, ]`. +fn doctype_path(contract: &DataContract, doctype: &str) -> Vec> { + vec![ + vec![crate::drive::RootTree::DataContractDocuments as u8], + contract.id().as_bytes().to_vec(), + vec![1], + doctype.as_bytes().to_vec(), + ] +} + +/// A random document of `doctype` with the given properties and owner set. +fn build( + contract: &DataContract, + doctype: &str, + properties: Vec<(&str, Value)>, + owner: [u8; 32], + seed: u64, +) -> Document { + let document_type = contract + .document_type_for_name(doctype) + .expect("doctype exists"); + let mut document = document_type + .random_document(Some(seed), platform_version()) + .expect("random document"); + document.set_properties( + properties + .into_iter() + .map(|(name, value)| (name.to_string(), value)) + .collect::>(), + ); + document.set_owner_id(Identifier::from(owner)); + document +} + +/// An `answer` document: a request hash with its response payload. +fn build_answer( + contract: &DataContract, + request: [u8; 20], + payload: [u8; 33], + owner: [u8; 32], + seed: u64, +) -> Document { + build( + contract, + "answer", + vec![ + ("requestId", Value::Bytes(request.to_vec())), + ("payload", Value::Bytes(payload.to_vec())), + ], + owner, + seed, + ) +} + +/// A `vote` document for `choice` by `owner`. +fn build_vote(contract: &DataContract, choice: &str, owner: [u8; 32], seed: u64) -> Document { + build( + contract, + "vote", + vec![ + ("pollId", Value::Identifier(POLL)), + ("choice", Value::Text(choice.to_string())), + ], + owner, + seed, + ) +} + +/// A `rating` document of `stars` by `owner`. +fn build_rating(contract: &DataContract, stars: u64, owner: [u8; 32], seed: u64) -> Document { + build( + contract, + "rating", + vec![ + ("postId", Value::Identifier(POST)), + ("stars", Value::U64(stars)), + ], + owner, + seed, + ) +} + +/// Inserts (or dry-runs, when `apply` is false) the document and returns its fee. +fn insert( + drive: &Drive, + contract: &DataContract, + doctype: &str, + document: &Document, + apply: bool, +) -> Result { + let document_type = contract + .document_type_for_name(doctype) + .expect("doctype exists"); + drive.add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo((document, None)), + owner_id: None, + }, + contract, + document_type, + }, + false, + BlockInfo::default(), + apply, + None, + platform_version(), + None, + ) +} + +/// Deletes (or dry-runs, when `apply` is false) the document and returns its fee. +fn delete( + drive: &Drive, + contract: &DataContract, + doctype: &str, + document: Document, + apply: bool, +) -> Result { + let document_type = contract + .document_type_for_name(doctype) + .expect("doctype exists"); + drive.delete_index_only_document_for_contract( + document, + contract, + document_type, + BlockInfo::default(), + apply, + None, + platform_version(), + None, + ) +} + +/// A query on `doctype` with the given clauses and no ordering. +fn query<'a>( + contract: &'a DataContract, + doctype: &str, + clauses: Vec, + limit: Option, +) -> DriveDocumentQuery<'a> { + let document_type = contract + .document_type_for_name(doctype) + .expect("doctype exists"); + DriveDocumentQuery { + contract, + document_type, + internal_clauses: InternalClauses::extract_from_clauses(clauses, platform_version()) + .expect("clauses extract"), + offset: None, + limit, + order_by: Default::default(), + start_at: None, + start_at_included: false, + block_time_ms: None, + resolved_time_ranges: vec![], + sub_queries: vec![], + } +} + +/// An equality clause on `field`. +fn equal(field: &str, value: Value) -> WhereClause { + WhereClause { + field: field.to_string(), + operator: WhereOperator::Equal, + value, + } +} + +/// The entry under `[…prefix values, 0]` keyed by `member_key`. +fn entry( + drive: &Drive, + contract: &DataContract, + doctype: &str, + prefix: &[(&str, &[u8])], + member_key: &[u8], +) -> Option { + let mut path = doctype_path(contract, doctype); + for (property, value) in prefix { + path.push(property.as_bytes().to_vec()); + path.push(value.to_vec()); + } + path.push(vec![0]); + read_grove_element(drive, &path, member_key) +} + +/// Asserts the element is a bare row-commitment Item (no entry payload). +fn assert_commitment_item(element: Option, what: &str) { + match element { + Some(Element::Item(payload, _)) => assert_eq!( + payload.len(), + INDEX_ONLY_ROW_COMMITMENT_SIZE as usize, + "{what}: the element is the row-commitment Item" + ), + other => panic!("{what}: expected a commitment Item, got {other:?}"), + } +} + +// --------------------------------------------------------------------------- +// Entry layout +// --------------------------------------------------------------------------- + +/// Each doctype registers without a primary-key tree, and an insert keys +/// its member entry by the terminal's tree-key encoding: the raw 33 bytes +/// for the byte array, the UTF-8 bytes for the string, and the property's +/// integer key encoding — none of them 32 bytes wide. +#[test] +fn scalar_terminal_entries_are_keyed_by_the_encoded_terminal_value() { + let (drive, contract) = setup(); + + for doctype in ["answer", "vote", "rating"] { + assert!( + read_grove_element(&drive, &doctype_path(&contract, doctype), &[0]).is_none(), + "{doctype}: an indexOnly doctype has no primary-key tree" + ); + } + + let answer = build_answer(&contract, REQUEST, PAYLOAD_1, OWNER_1, 1); + insert(&drive, &contract, "answer", &answer, true).expect("insert answer"); + assert_commitment_item( + entry( + &drive, + &contract, + "answer", + &[("requestId", &REQUEST), ("$ownerId", &OWNER_1)], + &PAYLOAD_1, + ), + "answer keyed by its 33-byte payload", + ); + + let vote = build_vote(&contract, "yes", OWNER_1, 2); + insert(&drive, &contract, "vote", &vote, true).expect("insert vote"); + assert_commitment_item( + entry( + &drive, + &contract, + "vote", + &[("pollId", &POLL), ("$ownerId", &OWNER_1)], + b"yes", + ), + "vote keyed by its choice's UTF-8 bytes", + ); + // The sibling index keys the same row the ordinary way round: the + // choice in the prefix, the owner as the member key. + assert_commitment_item( + entry( + &drive, + &contract, + "vote", + &[("pollId", &POLL), ("choice", b"yes")], + &OWNER_1, + ), + "vote under byChoice", + ); + + let rating = build_rating(&contract, 4, OWNER_1, 3); + insert(&drive, &contract, "rating", &rating, true).expect("insert rating"); + let rating_type = contract + .document_type_for_name("rating") + .expect("rating doctype exists"); + let stars_key = rating_type + .serialize_value_for_key("stars", &Value::U64(4), platform_version()) + .expect("the query-side encoding of the integer"); + assert_ne!( + stars_key.len(), + 32, + "an integer member key is not identifier-wide" + ); + assert_commitment_item( + entry( + &drive, + &contract, + "rating", + &[("postId", &POST), ("$ownerId", &OWNER_1)], + &stars_key, + ), + "rating keyed by the query-side integer encoding", + ); + + assert_grovedb_is_consistent(&drive); +} + +/// Structural uniqueness spans the terminal value: the same (request, +/// owner, payload) twice is a duplicate, while a second payload by the +/// same owner under the same request is a second entry. +#[test] +fn structural_uniqueness_spans_the_terminal_value() { + let (drive, contract) = setup(); + let first = build_answer(&contract, REQUEST, PAYLOAD_1, OWNER_1, 1); + insert(&drive, &contract, "answer", &first, true).expect("first insert"); + assert!( + insert(&drive, &contract, "answer", &first, true).is_err(), + "the same values twice is a duplicate entry" + ); + let second = build_answer(&contract, REQUEST, PAYLOAD_2, OWNER_1, 2); + insert(&drive, &contract, "answer", &second, true) + .expect("a different terminal value by the same owner is a new entry"); + for payload in [PAYLOAD_1, PAYLOAD_2] { + assert_commitment_item( + entry( + &drive, + &contract, + "answer", + &[("requestId", &REQUEST), ("$ownerId", &OWNER_1)], + &payload, + ), + "both payloads sit under the same prefix", + ); + } + assert_grovedb_is_consistent(&drive); +} + +// --------------------------------------------------------------------------- +// Read surface +// --------------------------------------------------------------------------- + +/// A prefix query synthesizes the terminal off the member key — the +/// 33-byte payload comes back as bytes, the choice as text — with proof +/// parity, and a terminal-equality clause answers "did this owner send +/// this payload" with an existence or absence proof. +#[test] +fn scalar_terminal_queries_synthesize_and_prove() { + let (drive, contract) = setup(); + insert( + &drive, + &contract, + "answer", + &build_answer(&contract, REQUEST, PAYLOAD_1, OWNER_1, 1), + true, + ) + .expect("insert answer 1"); + insert( + &drive, + &contract, + "answer", + &build_answer(&contract, REQUEST, PAYLOAD_2, OWNER_2, 2), + true, + ) + .expect("insert answer 2"); + + // ── every answer to the request ── + let by_request = query( + &contract, + "answer", + vec![equal("requestId", Value::Bytes(REQUEST.to_vec()))], + Some(10), + ); + let outcome = drive + .query_documents(by_request.clone(), None, false, None, None) + .expect("prefix query executes"); + let documents = outcome.documents(); + assert_eq!(documents.len(), 2, "two answers to the request"); + let mut seen: Vec<([u8; 32], Vec)> = documents + .iter() + .map(|document| { + assert_eq!( + document + .properties() + .get("requestId") + .expect("requestId recovered from the path") + .to_binary_bytes() + .expect("bytes"), + REQUEST.to_vec() + ); + let payload = document + .properties() + .get("payload") + .expect("payload recovered from the member key") + .to_binary_bytes() + .expect("bytes"); + (document.owner_id().to_buffer(), payload) + }) + .collect(); + seen.sort(); + assert_eq!( + seen, + vec![(OWNER_1, PAYLOAD_1.to_vec()), (OWNER_2, PAYLOAD_2.to_vec())], + "synthesis must recover every (owner, payload) pair" + ); + let (proof, _) = by_request + .clone() + .execute_with_proof(&drive, None, None, platform_version()) + .expect("proof generation"); + let (_root, verified) = by_request + .verify_proof(proof.as_slice(), platform_version()) + .expect("proof verification synthesizes"); + let mut verified_ids: Vec<_> = verified.iter().map(|d| d.id()).collect(); + let mut queried_ids: Vec<_> = documents.iter().map(|d| d.id()).collect(); + verified_ids.sort(); + queried_ids.sort(); + assert_eq!( + verified_ids, queried_ids, + "proved and unproved synthesis agree" + ); + let verified_payloads: Vec> = verified + .iter() + .map(|d| { + d.properties() + .get("payload") + .expect("payload verified") + .to_binary_bytes() + .expect("bytes") + }) + .collect(); + assert!(verified_payloads.contains(&PAYLOAD_1.to_vec())); + assert!(verified_payloads.contains(&PAYLOAD_2.to_vec())); + + // ── terminal equality: did OWNER_1 send PAYLOAD_1 / PAYLOAD_2 ── + let sent = query( + &contract, + "answer", + vec![ + equal("requestId", Value::Bytes(REQUEST.to_vec())), + equal("$ownerId", Value::Identifier(OWNER_1)), + equal("payload", Value::Bytes(PAYLOAD_1.to_vec())), + ], + Some(1), + ); + let outcome = drive + .query_documents(sent.clone(), None, false, None, None) + .expect("terminal-equality query executes"); + assert_eq!(outcome.documents().len(), 1); + assert_eq!(outcome.documents()[0].owner_id().to_buffer(), OWNER_1); + let (proof, _) = sent + .clone() + .execute_with_proof(&drive, None, None, platform_version()) + .expect("existence proof generation"); + let (_root, verified) = sent + .verify_proof(proof.as_slice(), platform_version()) + .expect("existence proof verification"); + assert_eq!(verified.len(), 1); + + let not_sent = query( + &contract, + "answer", + vec![ + equal("requestId", Value::Bytes(REQUEST.to_vec())), + equal("$ownerId", Value::Identifier(OWNER_1)), + equal("payload", Value::Bytes(PAYLOAD_2.to_vec())), + ], + Some(1), + ); + let outcome = drive + .query_documents(not_sent.clone(), None, false, None, None) + .expect("negative terminal-equality query executes"); + assert!( + outcome.documents().is_empty(), + "OWNER_1 never sent PAYLOAD_2" + ); + let (proof, _) = not_sent + .clone() + .execute_with_proof(&drive, None, None, platform_version()) + .expect("absence proof generation"); + let (_root, verified) = not_sent + .verify_proof(proof.as_slice(), platform_version()) + .expect("absence proof verification"); + assert!(verified.is_empty(), "absence must verify as absence"); + + assert_grovedb_is_consistent(&drive); +} + +/// A string terminal: the poll's votes synthesize with their choices, a +/// terminal-equality clause on the string works, and the sibling ranked +/// index — which keys the same rows by owner — ranks the choices. +#[test] +fn string_terminal_votes_synthesize_and_rank() { + let (drive, contract) = setup(); + for (choice, owner, seed) in [ + ("yes", OWNER_1, 1u64), + ("yes", OWNER_2, 2), + ("no", OWNER_3, 3), + ] { + insert( + &drive, + &contract, + "vote", + &build_vote(&contract, choice, owner, seed), + true, + ) + .expect("insert vote"); + } + + let poll_votes = query( + &contract, + "vote", + vec![equal("pollId", Value::Identifier(POLL))], + Some(10), + ); + let outcome = drive + .query_documents(poll_votes.clone(), None, false, None, None) + .expect("poll query executes"); + let mut seen: Vec<([u8; 32], String)> = outcome + .documents() + .iter() + .map(|document| { + let choice = document + .properties() + .get("choice") + .expect("choice recovered") + .as_text() + .expect("text") + .to_string(); + (document.owner_id().to_buffer(), choice) + }) + .collect(); + seen.sort(); + assert_eq!( + seen, + vec![ + (OWNER_1, "yes".to_string()), + (OWNER_2, "yes".to_string()), + (OWNER_3, "no".to_string()), + ] + ); + let (proof, _) = poll_votes + .clone() + .execute_with_proof(&drive, None, None, platform_version()) + .expect("proof generation"); + let (_root, verified) = poll_votes + .verify_proof(proof.as_slice(), platform_version()) + .expect("proof verification"); + assert_eq!(verified.len(), 3); + + let owner_3_voted_no = query( + &contract, + "vote", + vec![ + equal("pollId", Value::Identifier(POLL)), + equal("$ownerId", Value::Identifier(OWNER_3)), + equal("choice", Value::Text("no".to_string())), + ], + Some(1), + ); + let outcome = drive + .query_documents(owner_3_voted_no, None, false, None, None) + .expect("string terminal equality executes"); + assert_eq!(outcome.documents().len(), 1); + + // byChoice ranks the choices by count under the poll. + let mut choice_level = doctype_path(&contract, "vote"); + choice_level.extend([b"pollId".to_vec(), POLL.to_vec(), b"choice".to_vec()]); + assert_eq!( + count_top_k(&drive, &choice_level, 10, true), + vec![(2, b"yes".to_vec()), (1, b"no".to_vec())], + "the sibling ranked index composes with a string-terminal index on the same rows" + ); + + assert_grovedb_is_consistent(&drive); +} + +// --------------------------------------------------------------------------- +// Delete symmetry and fees +// --------------------------------------------------------------------------- + +/// Delete-by-values addresses the entry through its terminal value: a +/// delete carrying a different payload finds no entry and is refused, the +/// right one removes the entry, and a repeat is refused. +#[test] +fn scalar_terminal_delete_removes_the_entry_and_refuses_a_wrong_value() { + let (drive, contract) = setup(); + let stored = build_answer(&contract, REQUEST, PAYLOAD_1, OWNER_1, 1); + insert(&drive, &contract, "answer", &stored, true).expect("insert answer"); + let prefix: [(&str, &[u8]); 2] = [("requestId", &REQUEST), ("$ownerId", &OWNER_1)]; + + let wrong = build_answer(&contract, REQUEST, PAYLOAD_2, OWNER_1, 2); + assert!( + delete(&drive, &contract, "answer", wrong, true).is_err(), + "a delete naming a payload never written finds no entry" + ); + assert_commitment_item( + entry(&drive, &contract, "answer", &prefix, &PAYLOAD_1), + "the stored entry survives the refused delete", + ); + + delete(&drive, &contract, "answer", stored.clone(), true).expect("delete by values"); + assert!( + entry(&drive, &contract, "answer", &prefix, &PAYLOAD_1).is_none(), + "the entry is gone" + ); + assert!( + delete(&drive, &contract, "answer", stored, true).is_err(), + "deleting it again finds nothing" + ); + assert_grovedb_is_consistent(&drive); +} + +/// The dry run sizes the member key by the terminal property, so the +/// estimate upper-bounds the applied fee for a 33-byte key, a string key +/// and an integer key alike, on insert and on delete. +#[test] +fn scalar_terminal_estimated_fees_upper_bound_actual_fees() { + let (drive, contract) = setup(); + let cases: Vec<(&str, Document)> = vec![ + ( + "answer", + build_answer(&contract, REQUEST, PAYLOAD_1, OWNER_1, 1), + ), + ("vote", build_vote(&contract, "yes", OWNER_1, 2)), + ("rating", build_rating(&contract, 4, OWNER_1, 3)), + ]; + for (doctype, document) in cases { + let estimated_insert = + insert(&drive, &contract, doctype, &document, false).expect("estimated insert"); + let actual_insert = + insert(&drive, &contract, doctype, &document, true).expect("actual insert"); + assert!( + estimated_insert.storage_fee >= actual_insert.storage_fee, + "{doctype}: estimated insert storage fee {} must upper-bound actual {}", + estimated_insert.storage_fee, + actual_insert.storage_fee + ); + + let estimated_delete = + delete(&drive, &contract, doctype, document.clone(), false).expect("estimated delete"); + let actual_delete = + delete(&drive, &contract, doctype, document, true).expect("actual delete"); + assert!(actual_delete.processing_fee > 0); + assert!( + estimated_delete.processing_fee >= actual_delete.processing_fee, + "{doctype}: estimated delete processing fee {} must upper-bound actual {}", + estimated_delete.processing_fee, + actual_delete.processing_fee + ); + } + assert_grovedb_is_consistent(&drive); +} + +// --------------------------------------------------------------------------- +// Composite terminals and the entry payload +// --------------------------------------------------------------------------- + +const REQUEST_HASH: [u8; 20] = [0x7A; 20]; +const OTHER_REQUEST_HASH: [u8; 20] = [0x7B; 20]; +const WALLET_KEY_1: [u8; 33] = [0xA1; 33]; +const WALLET_KEY_2: [u8; 33] = [0xA2; 33]; +const WALLET_KEY_3: [u8; 33] = [0xA3; 33]; +/// The flat level of `loginKeyResponse.byRequest`: a zero byte, then each +/// terminal component name preceded by a zero byte. +const LOGIN_FLAT_LEVEL: &[u8] = b"\0appEphemeralPubKeyHash\0$ownerId"; + +/// A stand-in ciphertext of `len` copies of `byte`. +fn cipher(byte: u8, len: usize) -> Vec { + vec![byte; len] +} + +/// A `loginKeyResponse` document for `request` with the wallet key and ciphertext payload. +fn build_login_response( + contract: &DataContract, + request: [u8; 20], + wallet_key: [u8; 33], + ciphertext: Vec, + owner: [u8; 32], + seed: u64, +) -> Document { + build( + contract, + "loginKeyResponse", + vec![ + ("appEphemeralPubKeyHash", Value::Bytes(request.to_vec())), + ("walletEphemeralPubKey", Value::Bytes(wallet_key.to_vec())), + ("encryptedPayload", Value::Bytes(ciphertext)), + ], + owner, + seed, + ) +} + +/// A `reaction` document of `kind` on the fixture post by `owner`. +fn build_reaction(contract: &DataContract, kind: u64, owner: [u8; 32], seed: u64) -> Document { + build( + contract, + "reaction", + vec![ + ("postId", Value::Identifier(POST)), + ("kind", Value::U64(kind)), + ], + owner, + seed, + ) +} + +/// A query on `doctype` with the given clauses and `order_by` (field, ascending). +fn query_ordered<'a>( + contract: &'a DataContract, + doctype: &str, + clauses: Vec, + order_by: Vec<(&str, bool)>, + limit: Option, +) -> DriveDocumentQuery<'a> { + let mut query = query(contract, doctype, clauses, limit); + query.order_by = order_by + .into_iter() + .map(|(field, ascending)| { + ( + field.to_string(), + OrderClause { + field: field.to_string(), + ascending, + }, + ) + }) + .collect(); + query +} + +/// The flat member key of a login response: request hash then owner id. +fn login_member_key(request: [u8; 20], owner: [u8; 32]) -> Vec { + let mut key = request.to_vec(); + key.extend(owner); + key +} + +/// Reads the login response entry stored under the flat level for `request` and `owner`. +fn login_entry( + drive: &Drive, + contract: &DataContract, + request: [u8; 20], + owner: [u8; 32], +) -> Option { + let mut path = doctype_path(contract, "loginKeyResponse"); + path.push(LOGIN_FLAT_LEVEL.to_vec()); + path.push(vec![0]); + read_grove_element(drive, &path, &login_member_key(request, owner)) +} + +/// The binary bytes of the document property `name`. +fn payload_bytes(document: &Document, name: &str) -> Vec { + document + .properties() + .get(name) + .unwrap_or_else(|| panic!("{name} present")) + .to_binary_bytes() + .expect("bytes") +} + +/// A flat composite index writes its entry directly under its own level: +/// `[…, "\0appEphemeralPubKeyHash\0$ownerId", 0, hash ‖ owner]`, no +/// property-name tree and no value tree for either component. The item is +/// the row commitment followed by the entry payload — each payload property +/// in name order, length-framed: the ciphertext first, then the wallet key. +#[test] +fn flat_composite_entries_carry_the_payload_in_the_item() { + let (drive, contract) = setup(); + let ciphertext = cipher(0xC1, 60); + let response = build_login_response( + &contract, + REQUEST_HASH, + WALLET_KEY_1, + ciphertext.clone(), + OWNER_1, + 1, + ); + insert(&drive, &contract, "loginKeyResponse", &response, true).expect("insert response"); + + match login_entry(&drive, &contract, REQUEST_HASH, OWNER_1) { + Some(Element::Item(bytes, _)) => { + let commitment_size = INDEX_ONLY_ROW_COMMITMENT_SIZE as usize; + assert_eq!(bytes.len(), commitment_size + 2 + 60 + 2 + 33); + let mut cursor = commitment_size; + assert_eq!(&bytes[cursor..cursor + 2], &60u16.to_be_bytes()); + cursor += 2; + assert_eq!(&bytes[cursor..cursor + 60], ciphertext.as_slice()); + cursor += 60; + assert_eq!(&bytes[cursor..cursor + 2], &33u16.to_be_bytes()); + cursor += 2; + assert_eq!(&bytes[cursor..], &WALLET_KEY_1); + } + other => panic!("expected the payload-bearing Item, got {other:?}"), + } + let base = doctype_path(&contract, "loginKeyResponse"); + assert!( + read_grove_element(&drive, &base, b"appEphemeralPubKeyHash").is_none() + && read_grove_element(&drive, &base, b"$ownerId").is_none(), + "a flat index owns no property-name trees" + ); + assert!( + read_grove_element(&drive, &base, LOGIN_FLAT_LEVEL).is_some(), + "the flat level tree sits directly under the doctype" + ); + + // The same values by the same owner are a duplicate; another owner is + // a second entry under the same request hash. + assert!(insert(&drive, &contract, "loginKeyResponse", &response, true).is_err()); + insert( + &drive, + &contract, + "loginKeyResponse", + &build_login_response( + &contract, + REQUEST_HASH, + WALLET_KEY_2, + cipher(0xC2, 92), + OWNER_2, + 2, + ), + true, + ) + .expect("a second responder"); + assert!(login_entry(&drive, &contract, REQUEST_HASH, OWNER_2).is_some()); + + assert_grovedb_is_consistent(&drive); +} + +/// The app's read: equality on the request hash (the leading component) +/// returns every responder's owner id with the wallet key and ciphertext +/// decoded off the item, as one proof; a point lookup on hash and owner +/// answers with existence or absence; a clause-free query scans the flat +/// level. +#[test] +fn flat_composite_lookup_by_request_hash_synthesizes_the_payload_and_proves() { + let (drive, contract) = setup(); + let cipher_1 = cipher(0xC1, 60); + let cipher_2 = cipher(0xC2, 572); + for (request, key, ciphertext, owner, seed) in [ + (REQUEST_HASH, WALLET_KEY_1, cipher_1.clone(), OWNER_1, 1u64), + (REQUEST_HASH, WALLET_KEY_2, cipher_2.clone(), OWNER_2, 2), + ( + OTHER_REQUEST_HASH, + WALLET_KEY_3, + cipher(0xC3, 100), + OWNER_3, + 3, + ), + ] { + insert( + &drive, + &contract, + "loginKeyResponse", + &build_login_response(&contract, request, key, ciphertext, owner, seed), + true, + ) + .expect("insert response"); + } + + // ── every answer to the request ── + let by_request = query( + &contract, + "loginKeyResponse", + vec![equal( + "appEphemeralPubKeyHash", + Value::Bytes(REQUEST_HASH.to_vec()), + )], + Some(10), + ); + let outcome = drive + .query_documents(by_request.clone(), None, false, None, None) + .expect("lookup by request hash executes"); + let documents = outcome.documents(); + assert_eq!(documents.len(), 2, "two responders to the request"); + let mut seen: Vec<([u8; 32], Vec, Vec)> = documents + .iter() + .map(|document| { + assert_eq!( + payload_bytes(document, "appEphemeralPubKeyHash"), + REQUEST_HASH.to_vec() + ); + ( + document.owner_id().to_buffer(), + payload_bytes(document, "walletEphemeralPubKey"), + payload_bytes(document, "encryptedPayload"), + ) + }) + .collect(); + seen.sort(); + assert_eq!( + seen, + vec![ + (OWNER_1, WALLET_KEY_1.to_vec(), cipher_1.clone()), + (OWNER_2, WALLET_KEY_2.to_vec(), cipher_2.clone()), + ], + "the owner comes off the member key, the wallet key and ciphertext off the item" + ); + let (proof, _) = by_request + .clone() + .execute_with_proof(&drive, None, None, platform_version()) + .expect("proof generation"); + let (_root, verified) = by_request + .verify_proof(proof.as_slice(), platform_version()) + .expect("proof verification synthesizes"); + let mut verified_ids: Vec<_> = verified.iter().map(|d| d.id()).collect(); + let mut queried_ids: Vec<_> = documents.iter().map(|d| d.id()).collect(); + verified_ids.sort(); + queried_ids.sort(); + assert_eq!( + verified_ids, queried_ids, + "proved and unproved synthesis agree" + ); + let verified_ciphers: Vec> = verified + .iter() + .map(|d| payload_bytes(d, "encryptedPayload")) + .collect(); + assert!(verified_ciphers.contains(&cipher_1) && verified_ciphers.contains(&cipher_2)); + + // ── point lookup: did OWNER_1 / OWNER_3 answer this request ── + let answered = query( + &contract, + "loginKeyResponse", + vec![ + equal( + "appEphemeralPubKeyHash", + Value::Bytes(REQUEST_HASH.to_vec()), + ), + equal("$ownerId", Value::Identifier(OWNER_1)), + ], + Some(1), + ); + let outcome = drive + .query_documents(answered.clone(), None, false, None, None) + .expect("point lookup executes"); + assert_eq!(outcome.documents().len(), 1); + assert_eq!( + payload_bytes(&outcome.documents()[0], "walletEphemeralPubKey"), + WALLET_KEY_1.to_vec() + ); + let (proof, _) = answered + .clone() + .execute_with_proof(&drive, None, None, platform_version()) + .expect("existence proof generation"); + let (_root, verified) = answered + .verify_proof(proof.as_slice(), platform_version()) + .expect("existence proof verification"); + assert_eq!(verified.len(), 1); + + let not_answered = query( + &contract, + "loginKeyResponse", + vec![ + equal( + "appEphemeralPubKeyHash", + Value::Bytes(REQUEST_HASH.to_vec()), + ), + equal("$ownerId", Value::Identifier(OWNER_3)), + ], + Some(1), + ); + assert!(drive + .query_documents(not_answered.clone(), None, false, None, None) + .expect("negative point lookup executes") + .documents() + .is_empty()); + let (proof, _) = not_answered + .clone() + .execute_with_proof(&drive, None, None, platform_version()) + .expect("absence proof generation"); + let (_root, verified) = not_answered + .verify_proof(proof.as_slice(), platform_version()) + .expect("absence proof verification"); + assert!(verified.is_empty(), "absence must verify as absence"); + + // ── everything: a clause-free query scans the flat level ── + let everything = query(&contract, "loginKeyResponse", vec![], Some(10)); + let outcome = drive + .query_documents(everything.clone(), None, false, None, None) + .expect("flat scan executes"); + assert_eq!(outcome.documents().len(), 3); + let (proof, _) = everything + .clone() + .execute_with_proof(&drive, None, None, platform_version()) + .expect("flat scan proof generation"); + let (_root, verified) = everything + .verify_proof(proof.as_slice(), platform_version()) + .expect("flat scan proof verification"); + assert_eq!(verified.len(), 3); + + assert_grovedb_is_consistent(&drive); +} + +/// Keyset pagination over the second component: with the request hash +/// bound, `$ownerId > ` ordered by `$ownerId` walks the +/// responders page by page, each page agreeing with its proof. +#[test] +fn flat_composite_keyset_pagination_over_the_second_component() { + let (drive, contract) = setup(); + for (key, owner, seed) in [ + (WALLET_KEY_1, OWNER_1, 1u64), + (WALLET_KEY_2, OWNER_2, 2), + (WALLET_KEY_3, OWNER_3, 3), + ] { + insert( + &drive, + &contract, + "loginKeyResponse", + &build_login_response( + &contract, + REQUEST_HASH, + key, + cipher(seed as u8, 60), + owner, + seed, + ), + true, + ) + .expect("insert response"); + } + + let page_1 = query_ordered( + &contract, + "loginKeyResponse", + vec![equal( + "appEphemeralPubKeyHash", + Value::Bytes(REQUEST_HASH.to_vec()), + )], + vec![("$ownerId", true)], + Some(2), + ); + let outcome = drive + .query_documents(page_1.clone(), None, false, None, None) + .expect("page 1 executes"); + let owners: Vec<[u8; 32]> = outcome + .documents() + .iter() + .map(|d| d.owner_id().to_buffer()) + .collect(); + assert_eq!(owners, vec![OWNER_1, OWNER_2]); + let (proof, _) = page_1 + .clone() + .execute_with_proof(&drive, None, None, platform_version()) + .expect("page 1 proof"); + let (_root, verified) = page_1 + .verify_proof(proof.as_slice(), platform_version()) + .expect("page 1 verifies"); + assert_eq!(verified.len(), 2); + + let page_2 = query_ordered( + &contract, + "loginKeyResponse", + vec![ + equal( + "appEphemeralPubKeyHash", + Value::Bytes(REQUEST_HASH.to_vec()), + ), + WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::GreaterThan, + value: Value::Identifier(OWNER_2), + }, + ], + vec![("$ownerId", true)], + Some(2), + ); + let outcome = drive + .query_documents(page_2.clone(), None, false, None, None) + .expect("page 2 executes"); + let owners: Vec<[u8; 32]> = outcome + .documents() + .iter() + .map(|d| d.owner_id().to_buffer()) + .collect(); + assert_eq!(owners, vec![OWNER_3]); + assert_eq!( + payload_bytes(&outcome.documents()[0], "walletEphemeralPubKey"), + WALLET_KEY_3.to_vec() + ); + let (proof, _) = page_2 + .clone() + .execute_with_proof(&drive, None, None, platform_version()) + .expect("page 2 proof"); + let (_root, verified) = page_2 + .verify_proof(proof.as_slice(), platform_version()) + .expect("page 2 verifies"); + assert_eq!(verified.len(), 1); + + assert_grovedb_is_consistent(&drive); +} + +/// Delete-by-values on a flat composite entry with a payload: a delete +/// carrying a different ciphertext recomputes a different commitment and +/// is refused, the right one removes the entry, and the dry run +/// upper-bounds the applied fee on insert and delete (the item is sized by +/// the payload bound, the key by the components' widths). +#[test] +fn flat_composite_delete_and_fees() { + let (drive, contract) = setup(); + let stored = build_login_response( + &contract, + REQUEST_HASH, + WALLET_KEY_1, + cipher(0xC1, 572), + OWNER_1, + 1, + ); + let estimated_insert = + insert(&drive, &contract, "loginKeyResponse", &stored, false).expect("estimated insert"); + let actual_insert = + insert(&drive, &contract, "loginKeyResponse", &stored, true).expect("actual insert"); + assert!( + estimated_insert.storage_fee >= actual_insert.storage_fee, + "estimated insert storage fee {} must upper-bound actual {}", + estimated_insert.storage_fee, + actual_insert.storage_fee + ); + + let wrong = build_login_response( + &contract, + REQUEST_HASH, + WALLET_KEY_1, + cipher(0xC9, 572), + OWNER_1, + 2, + ); + assert!( + delete(&drive, &contract, "loginKeyResponse", wrong, true).is_err(), + "a delete whose payload disagrees with the stored entry fails the commitment probe" + ); + assert!(login_entry(&drive, &contract, REQUEST_HASH, OWNER_1).is_some()); + + let estimated_delete = delete(&drive, &contract, "loginKeyResponse", stored.clone(), false) + .expect("estimated delete"); + let actual_delete = + delete(&drive, &contract, "loginKeyResponse", stored, true).expect("actual delete"); + assert!(actual_delete.processing_fee > 0); + assert!( + estimated_delete.processing_fee >= actual_delete.processing_fee, + "estimated delete processing fee {} must upper-bound actual {}", + estimated_delete.processing_fee, + actual_delete.processing_fee + ); + assert!(login_entry(&drive, &contract, REQUEST_HASH, OWNER_1).is_none()); + // The flat level survives the last entry's removal: it is registration + // structure, and the next insert lands under it again. + assert!(read_grove_element( + &drive, + &doctype_path(&contract, "loginKeyResponse"), + LOGIN_FLAT_LEVEL + ) + .is_some()); + insert( + &drive, + &contract, + "loginKeyResponse", + &build_login_response( + &contract, + REQUEST_HASH, + WALLET_KEY_2, + cipher(0xC2, 60), + OWNER_2, + 3, + ), + true, + ) + .expect("insert after the level was drained"); + + assert_grovedb_is_consistent(&drive); +} + +/// A composite terminal BELOW a prefix level: `[postId] → kind ‖ $ownerId`. +/// The entry sits in the post's `0` bucket keyed by the integer's tree-key +/// encoding followed by the owner; equality on `kind` (the first +/// component) with the post bound lowers onto a key range and returns the +/// reacting owners; the hierarchical machinery above is untouched. +#[test] +fn prefixed_composite_terminal_ranges_over_the_leading_component() { + let (drive, contract) = setup(); + for (kind, owner, seed) in [(3u64, OWNER_1, 1u64), (3, OWNER_2, 2), (7, OWNER_3, 3)] { + insert( + &drive, + &contract, + "reaction", + &build_reaction(&contract, kind, owner, seed), + true, + ) + .expect("insert reaction"); + } + let reaction_type = contract + .document_type_for_name("reaction") + .expect("reaction doctype exists"); + let mut expected_key = reaction_type + .serialize_value_for_key("kind", &Value::U64(3), platform_version()) + .expect("kind encodes"); + expected_key.extend(OWNER_1); + assert_commitment_item( + entry( + &drive, + &contract, + "reaction", + &[("postId", &POST)], + &expected_key, + ), + "reaction keyed by kind ‖ owner under the post", + ); + + let thumbs = query( + &contract, + "reaction", + vec![ + equal("postId", Value::Identifier(POST)), + equal("kind", Value::U64(3)), + ], + Some(10), + ); + let outcome = drive + .query_documents(thumbs.clone(), None, false, None, None) + .expect("leading-component equality executes"); + let mut owners: Vec<[u8; 32]> = outcome + .documents() + .iter() + .map(|d| d.owner_id().to_buffer()) + .collect(); + owners.sort(); + assert_eq!(owners, vec![OWNER_1, OWNER_2]); + for document in outcome.documents() { + assert_eq!( + document + .properties() + .get("kind") + .and_then(|v| v.to_integer::().ok()), + Some(3), + "the leading component is decoded off the member key" + ); + } + let (proof, _) = thumbs + .clone() + .execute_with_proof(&drive, None, None, platform_version()) + .expect("proof generation"); + let (_root, verified) = thumbs + .verify_proof(proof.as_slice(), platform_version()) + .expect("proof verification"); + assert_eq!(verified.len(), 2); + + let all_reactions = query( + &contract, + "reaction", + vec![equal("postId", Value::Identifier(POST))], + Some(10), + ); + let outcome = drive + .query_documents(all_reactions, None, false, None, None) + .expect("prefix query executes"); + assert_eq!(outcome.documents().len(), 3); + + assert_grovedb_is_consistent(&drive); +} + +#[test] +fn should_reject_unrepresentable_composite_terminal_ordering() { + let (drive, contract) = setup(); + for (kind, owner, seed) in [(3, OWNER_1, 1), (3, OWNER_3, 2), (7, OWNER_2, 3)] { + insert( + &drive, + &contract, + "reaction", + &build_reaction(&contract, kind, owner, seed), + true, + ) + .expect("insert reaction"); + } + let (valid_proof, _) = query_ordered( + &contract, + "reaction", + vec![equal("postId", Value::Identifier(POST))], + vec![("kind", true), ("$ownerId", true)], + Some(10), + ) + .execute_with_proof(&drive, None, None, platform_version()) + .expect("prove the actual member-key order"); + for order_by in [ + vec![("$ownerId", true)], + vec![("$ownerId", true), ("kind", true)], + vec![("kind", true), ("$ownerId", false)], + ] { + let query = query_ordered( + &contract, + "reaction", + vec![equal("postId", Value::Identifier(POST))], + order_by, + Some(10), + ); + // A run of components out of declared order is refused by the + // matcher itself (no index can serve it: "valid indexes are"); + // the other shapes reach the terminal route and fail its orderBy + // rule. Either way the executor, the prover and the verifier + // refuse identically. + let unrepresentable = |error: &Error| { + error.to_string().contains("orderBy") || error.to_string().contains("valid indexes are") + }; + let error = drive + .query_documents(query.clone(), None, false, None, None) + .expect_err("one member-key walk cannot implement this ordering"); + assert!(unrepresentable(&error), "{error}"); + let error = query + .clone() + .execute_with_proof(&drive, None, None, platform_version()) + .expect_err("the prover must reject the same ordering"); + assert!(unrepresentable(&error), "{error}"); + let error = query + .verify_proof(&valid_proof, platform_version()) + .expect_err("a proof of member-key order cannot prove a different sort order"); + assert!(unrepresentable(&error), "{error}"); + } +} + +#[test] +fn should_order_composite_terminal_components_in_both_directions() { + let (drive, contract) = setup(); + for (kind, owner, seed) in [(3, OWNER_1, 1), (3, OWNER_3, 2), (7, OWNER_2, 3)] { + insert( + &drive, + &contract, + "reaction", + &build_reaction(&contract, kind, owner, seed), + true, + ) + .expect("insert reaction"); + } + for (order_by, kind, expected) in [ + ( + vec![("kind", true), ("$ownerId", true)], + None, + vec![OWNER_1, OWNER_3, OWNER_2], + ), + ( + vec![("kind", false), ("$ownerId", false)], + None, + vec![OWNER_2, OWNER_3, OWNER_1], + ), + // Equality-bound components do not affect ordering, so their + // direction need not agree with the remaining member-key order. + ( + vec![("kind", true), ("$ownerId", false)], + Some(3), + vec![OWNER_3, OWNER_1], + ), + (vec![("$ownerId", true)], Some(3), vec![OWNER_1, OWNER_3]), + ] { + let mut clauses = vec![equal("postId", Value::Identifier(POST))]; + if let Some(kind) = kind { + clauses.push(equal("kind", Value::U64(kind))); + } + let query = query_ordered(&contract, "reaction", clauses, order_by, Some(10)); + let outcome = drive + .query_documents(query.clone(), None, false, None, None) + .expect("representable ordering executes"); + let owners: Vec<_> = outcome + .documents() + .iter() + .map(|d| d.owner_id().to_buffer()) + .collect(); + assert_eq!(owners, expected); + let (proof, _) = query + .clone() + .execute_with_proof(&drive, None, None, platform_version()) + .expect("prove representable ordering"); + let (_, verified) = query + .verify_proof(&proof, platform_version()) + .expect("verify ordering"); + let owners: Vec<_> = verified.iter().map(|d| d.owner_id().to_buffer()).collect(); + assert_eq!(owners, expected); + } +} + +#[test] +fn should_roundtrip_empty_and_nul_entry_payloads_and_bind_deletes() { + let (drive, contract) = setup(); + // Full contract validation admits both zero-length payloads and a NUL + // string. Neither needs the sentinels used for property tree keys. + json_document_to_contract(FIXTURE, true, platform_version()).expect("fixture validates"); + for (text, bytes) in [("", vec![]), ("\0", vec![]), ("héllo", vec![0, 1])] { + let document = build( + &contract, + "payloadValues", + vec![ + ("bytes", Value::Bytes(bytes)), + ("text", Value::Text(text.to_string())), + ], + OWNER_1, + 1, + ); + insert(&drive, &contract, "payloadValues", &document, true).expect("insert payload"); + let query = query(&contract, "payloadValues", vec![], Some(10)); + let (serialized, _, _) = query + .execute_raw_results_no_proof(&drive, None, None, platform_version()) + .expect("a covering flat scan serializes its payload"); + assert_eq!(serialized.len(), 1); + let decoded = Document::from_bytes(&serialized[0], query.document_type, platform_version()) + .expect("deserialize response"); + assert_eq!(decoded.properties(), document.properties()); + let (proof, _) = query + .clone() + .execute_with_proof(&drive, None, None, platform_version()) + .expect("prove payload"); + let (_, verified) = query + .verify_proof(&proof, platform_version()) + .expect("verify payload"); + assert_eq!(verified.len(), 1); + assert_eq!(verified[0].properties(), document.properties()); + + let mut wrong = document.clone(); + let mut properties = wrong.properties().clone(); + properties.insert( + "text".to_string(), + Value::Text(if text.is_empty() { "\0" } else { "" }.to_string()), + ); + wrong.set_properties(properties); + assert!( + delete(&drive, &contract, "payloadValues", wrong, true).is_err(), + "empty and NUL strings must have different commitments" + ); + delete(&drive, &contract, "payloadValues", document, true).expect("delete exact payload"); + } + assert_grovedb_is_consistent(&drive); +} + +#[test] +fn should_refuse_serializing_an_incomplete_flat_scan() { + let (drive, contract) = setup(); + let document = build( + &contract, + "tagged", + vec![("tag", Value::Text("hello".to_string()))], + OWNER_1, + 1, + ); + insert(&drive, &contract, "tagged", &document, true).expect("insert tagged document"); + let query = query(&contract, "tagged", vec![], Some(10)); + let error = query + .execute_raw_results_no_proof(&drive, None, None, platform_version()) + .expect_err("the flat projection cannot assert that the stored tag is absent"); + assert!( + error.to_string().contains("does not cover every property"), + "{error}" + ); + + // A proof still exposes an explicit projection, while querying the + // covering tag index may return a complete serialized document. + let (proof, _) = query + .clone() + .execute_with_proof(&drive, None, None, platform_version()) + .expect("prove partial flat projection"); + let (_, projected) = query + .verify_proof(&proof, platform_version()) + .expect("verify projection"); + assert_eq!(projected.len(), 1); + assert_eq!(projected[0].owner_id(), document.owner_id()); + assert!(!projected[0].properties().contains_key("tag")); + + let covering = self::query( + &contract, + "tagged", + vec![equal("tag", Value::Text("hello".to_string()))], + Some(10), + ); + let (serialized, _, _) = covering + .execute_raw_results_no_proof(&drive, None, None, platform_version()) + .expect("the covering index can serialize the tag"); + let decoded = Document::from_bytes(&serialized[0], covering.document_type, platform_version()) + .expect("deserialize covering response"); + assert_eq!(decoded.properties(), document.properties()); +} + +/// A `note` document of `body` on the fixture post by `owner`. +fn build_note(contract: &DataContract, body: &[u8], owner: [u8; 32], seed: u64) -> Document { + build( + contract, + "note", + vec![ + ("postId", Value::Identifier(POST)), + ("body", Value::Bytes(body.to_vec())), + ], + owner, + seed, + ) +} + +/// A variable-width LAST component (a byte array with no `minItems`): an +/// empty value contributes no key bytes, so the entry is keyed by the +/// leading component alone and must still synthesize as an empty byte +/// array rather than the tree-key null sentinel. Ranges on the last +/// component are lowered against the key itself (no padding), in both +/// directions of the bound, with proof parity. +#[test] +fn variable_width_last_component_ranges_and_empty_values() { + let (drive, contract) = setup(); + let bodies: [&[u8]; 4] = [b"", b"apple", b"banana", b"cherry"]; + for (seed, body) in bodies.iter().enumerate() { + insert( + &drive, + &contract, + "note", + &build_note(&contract, body, OWNER_1, seed as u64 + 1), + true, + ) + .expect("insert note"); + } + insert( + &drive, + &contract, + "note", + &build_note(&contract, b"zebra", OWNER_2, 9), + true, + ) + .expect("insert another owner's note"); + assert_commitment_item( + entry(&drive, &contract, "note", &[("postId", &POST)], &OWNER_1), + "an empty body keys the entry by the owner alone", + ); + + let run = |clauses: Vec, what: &str| -> Vec> { + let mut clauses = clauses; + clauses.insert(0, equal("postId", Value::Identifier(POST))); + clauses.insert(1, equal("$ownerId", Value::Identifier(OWNER_1))); + let query = query_ordered(&contract, "note", clauses, vec![("body", true)], Some(10)); + let outcome = drive + .query_documents(query.clone(), None, false, None, None) + .unwrap_or_else(|e| panic!("{what}: query executes: {e}")); + let bodies: Vec> = outcome + .documents() + .iter() + .map(|document| payload_bytes(document, "body")) + .collect(); + let (proof, _) = query + .clone() + .execute_with_proof(&drive, None, None, platform_version()) + .unwrap_or_else(|e| panic!("{what}: proof generation: {e}")); + let (_root, verified) = query + .verify_proof(proof.as_slice(), platform_version()) + .unwrap_or_else(|e| panic!("{what}: proof verification: {e}")); + let verified_bodies: Vec> = verified + .iter() + .map(|document| payload_bytes(document, "body")) + .collect(); + assert_eq!( + verified_bodies, bodies, + "{what}: proved and unproved synthesis agree" + ); + bodies + }; + let between = |low: &[u8], high: &[u8], operator: WhereOperator| WhereClause { + field: "body".to_string(), + operator, + value: Value::Array(vec![ + Value::Bytes(low.to_vec()), + Value::Bytes(high.to_vec()), + ]), + }; + let bound = |operator: WhereOperator, value: &[u8]| WhereClause { + field: "body".to_string(), + operator, + value: Value::Bytes(value.to_vec()), + }; + + assert_eq!( + run(vec![], "every body of the owner"), + bodies.iter().map(|body| body.to_vec()).collect::>(), + "the empty body is the owner's first key and comes back as an empty array" + ); + assert_eq!( + run(vec![bound(WhereOperator::LessThan, b"b")], "bodies below b"), + vec![Vec::new(), b"apple".to_vec()] + ); + assert_eq!( + run( + vec![between(b"b", b"cz", WhereOperator::Between)], + "bodies between b and cz" + ), + vec![b"banana".to_vec(), b"cherry".to_vec()] + ); + assert_eq!( + run( + vec![between( + b"apple", + b"cherry", + WhereOperator::BetweenExcludeBounds + )], + "bodies strictly between apple and cherry" + ), + vec![b"banana".to_vec()] + ); + assert_eq!( + run( + vec![bound(WhereOperator::GreaterThanOrEquals, b"cherry")], + "bodies from cherry" + ), + vec![b"cherry".to_vec()], + "another owner's later body sits under a different leading component" + ); + + assert_grovedb_is_consistent(&drive); +} + +/// An `in` clause on a composite tail: on the LAST component it addresses +/// each key directly, on a leading component it covers every key under +/// each value; both synthesize the components off the member key with +/// proof parity. +#[test] +fn composite_tail_in_clauses_on_last_and_leading_components() { + let (drive, contract) = setup(); + for (seed, body) in [b"apple".as_slice(), b"banana", b"cherry"] + .iter() + .enumerate() + { + insert( + &drive, + &contract, + "note", + &build_note(&contract, body, OWNER_1, seed as u64 + 1), + true, + ) + .expect("insert note"); + } + for (kind, owner, seed) in [(3u64, OWNER_1, 11u64), (5, OWNER_2, 12), (7, OWNER_3, 13)] { + insert( + &drive, + &contract, + "reaction", + &build_reaction(&contract, kind, owner, seed), + true, + ) + .expect("insert reaction"); + } + + let notes = query_ordered( + &contract, + "note", + vec![ + equal("postId", Value::Identifier(POST)), + equal("$ownerId", Value::Identifier(OWNER_1)), + WhereClause { + field: "body".to_string(), + operator: WhereOperator::In, + value: Value::Array(vec![ + Value::Bytes(b"cherry".to_vec()), + Value::Bytes(b"apple".to_vec()), + ]), + }, + ], + vec![("body", true)], + Some(10), + ); + let outcome = drive + .query_documents(notes.clone(), None, false, None, None) + .expect("in on the last component executes"); + let bodies: Vec> = outcome + .documents() + .iter() + .map(|document| payload_bytes(document, "body")) + .collect(); + assert_eq!(bodies, vec![b"apple".to_vec(), b"cherry".to_vec()]); + let (proof, _) = notes + .clone() + .execute_with_proof(&drive, None, None, platform_version()) + .expect("proof generation"); + let (_root, verified) = notes + .verify_proof(proof.as_slice(), platform_version()) + .expect("proof verification"); + let verified_bodies: Vec> = verified + .iter() + .map(|document| payload_bytes(document, "body")) + .collect(); + assert_eq!(verified_bodies, bodies); + + let reactions = query_ordered( + &contract, + "reaction", + vec![ + equal("postId", Value::Identifier(POST)), + WhereClause { + field: "kind".to_string(), + operator: WhereOperator::In, + value: Value::Array(vec![Value::U64(7), Value::U64(3)]), + }, + ], + vec![("kind", true)], + Some(10), + ); + let outcome = drive + .query_documents(reactions.clone(), None, false, None, None) + .expect("in on a leading component executes"); + let kinds_and_owners: Vec<(u64, [u8; 32])> = outcome + .documents() + .iter() + .map(|document| { + ( + document + .properties() + .get("kind") + .and_then(|value| value.to_integer::().ok()) + .expect("kind decoded"), + document.owner_id().to_buffer(), + ) + }) + .collect(); + assert_eq!(kinds_and_owners, vec![(3, OWNER_1), (7, OWNER_3)]); + let (proof, _) = reactions + .clone() + .execute_with_proof(&drive, None, None, platform_version()) + .expect("proof generation"); + let (_root, verified) = reactions + .verify_proof(proof.as_slice(), platform_version()) + .expect("proof verification"); + assert_eq!(verified.len(), 2); + + assert_grovedb_is_consistent(&drive); +} + +/// Exercise the shipped system schema, not just the generic feature fixture. +#[test] +fn should_query_and_relogin_with_the_app_connect_system_contract() { + let drive = setup_drive_with_initial_state_structure(None); + let pv = platform_version(); + let contract = load_system_data_contract(SystemDataContract::AppConnect, pv) + .expect("app-connect system contract"); + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + pv, + ) + .expect("apply system contract"); + + let original = build_login_response( + &contract, + REQUEST_HASH, + WALLET_KEY_1, + cipher(0xC1, 60), + OWNER_1, + 1, + ); + let other_owner = build_login_response( + &contract, + REQUEST_HASH, + WALLET_KEY_2, + cipher(0xC2, 572), + OWNER_2, + 2, + ); + for document in [&other_owner, &original] { + insert(&drive, &contract, "loginKeyResponse", document, true) + .expect("an earlier response by another identity cannot squat the request"); + } + let duplicate = build_login_response( + &contract, + REQUEST_HASH, + WALLET_KEY_3, + cipher(0xC3, 92), + OWNER_1, + 3, + ); + assert!( + insert(&drive, &contract, "loginKeyResponse", &duplicate, true).is_err(), + "each owner may answer a request only once" + ); + + let by_request = query( + &contract, + "loginKeyResponse", + vec![equal( + "appEphemeralPubKeyHash", + Value::Bytes(REQUEST_HASH.to_vec()), + )], + Some(10), + ); + let (proof, _) = by_request + .clone() + .execute_with_proof(&drive, None, None, pv) + .expect("request proof"); + let (_, documents) = by_request + .clone() + .verify_proof(&proof, pv) + .expect("verify request proof"); + assert_eq!(documents.len(), 2); + for document in documents { + let expected = if document.owner_id().to_buffer() == OWNER_1 { + &original + } else { + &other_owner + }; + assert_eq!(document.properties(), expected.properties()); + } + + assert!( + delete(&drive, &contract, "loginKeyResponse", duplicate, true).is_err(), + "deletion must supply the original ciphertext and wallet key" + ); + delete(&drive, &contract, "loginKeyResponse", original, true).expect("delete old response"); + let renewed = build_login_response( + &contract, + OTHER_REQUEST_HASH, + WALLET_KEY_3, + cipher(0xC3, 572), + OWNER_1, + 4, + ); + insert(&drive, &contract, "loginKeyResponse", &renewed, true).expect("publish re-login"); + + assert!(login_entry(&drive, &contract, REQUEST_HASH, OWNER_1).is_none()); + assert!(login_entry(&drive, &contract, REQUEST_HASH, OWNER_2).is_some()); + assert!(login_entry(&drive, &contract, OTHER_REQUEST_HASH, OWNER_1).is_some()); + let (proof, _) = by_request + .clone() + .execute_with_proof(&drive, None, None, pv) + .expect("old request proof"); + let (_, documents) = by_request + .verify_proof(&proof, pv) + .expect("verify old request"); + assert_eq!(documents.len(), 1); + assert_eq!(documents[0].owner_id().to_buffer(), OWNER_2); + + let new_request = query( + &contract, + "loginKeyResponse", + vec![ + equal( + "appEphemeralPubKeyHash", + Value::Bytes(OTHER_REQUEST_HASH.to_vec()), + ), + equal("$ownerId", Value::Identifier(OWNER_1)), + ], + Some(1), + ); + let (proof, _) = new_request + .clone() + .execute_with_proof(&drive, None, None, pv) + .expect("new request proof"); + let (_, documents) = new_request + .verify_proof(&proof, pv) + .expect("verify new request"); + assert_eq!(documents.len(), 1); + assert_eq!(documents[0].properties(), renewed.properties()); + assert_grovedb_is_consistent(&drive); +} diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/mod.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/mod.rs index a28ac2c257e..f3e0c989771 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/mod.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/mod.rs @@ -30,6 +30,7 @@ mod chained_query_e2e_tests; mod composite_query_e2e_tests; mod countable_e2e_tests; mod index_only_e2e_tests; +mod index_only_scalar_terminal_e2e_tests; mod noncounted_sibling_e2e_tests; mod preallocated_index_e2e_tests; mod prefix_ranked_index_e2e_tests; diff --git a/packages/rs-drive/src/drive/contract/moderation/add_contract_document_removal/mod.rs b/packages/rs-drive/src/drive/contract/moderation/add_contract_document_removal/mod.rs index 5b44c96bf44..d2a6add7f7f 100644 --- a/packages/rs-drive/src/drive/contract/moderation/add_contract_document_removal/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/add_contract_document_removal/mod.rs @@ -13,9 +13,11 @@ use grovedb::{EstimatedLayerInformation, TransactionArg}; use std::collections::HashMap; impl Drive { - /// The operations recording that a moderator deleted a document: the record under the - /// document's type, keyed by the document's id. A document id is produced at most once - /// (it commits to the nonce of its create transition), so the record is always new. + /// The operations writing the record of a moderator's deletion of a document: the record + /// under the document's type, keyed by the document's id. A fresh record, or with + /// `replaces_existing` the replacement of the one the document has: a restore marks the + /// record restored, and the deletion of a restored document writes a fresh record in its + /// place. `moderator_id` pays for the record, or for the bytes a replacement adds. #[allow(clippy::too_many_arguments)] pub fn add_contract_document_removal_operations( &self, @@ -23,6 +25,8 @@ impl Drive { document_type_name: &str, document_id: Identifier, removal: &ContractDocumentRemoval, + replaces_existing: bool, + moderator_id: Identifier, block_info: &BlockInfo, estimated_costs_only_with_layer_info: &mut Option< HashMap, @@ -42,6 +46,8 @@ impl Drive { document_type_name, document_id, removal, + replaces_existing, + moderator_id, block_info, estimated_costs_only_with_layer_info, transaction, diff --git a/packages/rs-drive/src/drive/contract/moderation/add_contract_document_removal/v0/mod.rs b/packages/rs-drive/src/drive/contract/moderation/add_contract_document_removal/v0/mod.rs index f112dd479de..6896dbea3f1 100644 --- a/packages/rs-drive/src/drive/contract/moderation/add_contract_document_removal/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/add_contract_document_removal/v0/mod.rs @@ -15,10 +15,18 @@ use grovedb::{EstimatedLayerInformation, TransactionArg}; use std::collections::HashMap; impl Drive { - /// A record is the document owner's id, the moderator's id, the removal time and the - /// reason, under the document's id, flagged with the moderator's identity: the moderator - /// pays for it. Nothing ever deletes it, and nothing replaces it: a document id is produced - /// at most once. + /// A record is the document owner's id, the moderator's id, the removal time, the hash of + /// the document, its restoration if any, and the reason, under the document's id, flagged + /// with `moderator_id`: the moderator that writes it pays for it. Nothing ever deletes it. + /// It is replaced in place, as a suspension is, when a restore marks it restored and when + /// a restored document is deleted again; two operations on one key would fail the batch. + /// A replacement may change size, and its flags follow GroveDB's flag merge: a longer + /// record passes, with the refund of its removal, to the moderator that replaced it, who + /// pays for the added bytes; a shorter or an equally long one stays the first moderator's. + /// + /// An estimate prices a replacement as a fresh insert of the whole record: GroveDB's + /// average-case replace assumes an item keeps its size and would price no storage for the + /// restoration a restore adds, which the moderator's balance is then not checked against. #[inline(always)] #[allow(clippy::too_many_arguments)] pub(super) fn add_contract_document_removal_operations_v0( @@ -27,6 +35,8 @@ impl Drive { document_type_name: &str, document_id: Identifier, removal: &ContractDocumentRemoval, + replaces_existing: bool, + moderator_id: Identifier, block_info: &BlockInfo, estimated_costs_only_with_layer_info: &mut Option< HashMap, @@ -34,6 +44,7 @@ impl Drive { _transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { + let estimating = estimated_costs_only_with_layer_info.is_some(); if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { Drive::add_estimation_costs_for_contract_document_removal( contract_id.to_buffer(), @@ -43,10 +54,8 @@ impl Drive { )?; } - let storage_flags = StorageFlags::new_single_epoch( - block_info.epoch.index, - Some(removal.moderator_id.to_buffer()), - ); + let storage_flags = + StorageFlags::new_single_epoch(block_info.epoch.index, Some(moderator_id.to_buffer())); let path_key_element = PathFixedSizeKeyRefElement(( contract_document_type_removals_path(contract_id.as_slice(), document_type_name), @@ -58,11 +67,19 @@ impl Drive { )); let mut batch_operations: Vec = vec![]; - self.batch_insert( - path_key_element, - &mut batch_operations, - &platform_version.drive, - )?; + if replaces_existing && !estimating { + self.batch_replace( + path_key_element, + &mut batch_operations, + &platform_version.drive, + )?; + } else { + self.batch_insert( + path_key_element, + &mut batch_operations, + &platform_version.drive, + )?; + } Ok(batch_operations) } diff --git a/packages/rs-drive/src/drive/contract/moderation/add_contract_warning/mod.rs b/packages/rs-drive/src/drive/contract/moderation/add_contract_warning/mod.rs new file mode 100644 index 00000000000..510e247286e --- /dev/null +++ b/packages/rs-drive/src/drive/contract/moderation/add_contract_warning/mod.rs @@ -0,0 +1,141 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::config::moderation::ContractWarning; +use dpp::fee::fee_result::FeeResult; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + /// Writes the warning list entry of `identity_id` on `contract_id` as `warnings`: the + /// warnings it carried, with the new one last. Paid by `moderator_id`, whose identity the + /// entry's storage flags name for the refund on removal. + /// + /// The caller must have checked that the contract keeps a warning list, and that + /// `warnings` holds at most `SystemLimits::max_contract_warnings_per_identity`; + /// `replaces_existing` says whether the identity already carries an entry, which is then + /// replaced. Applies the operations when `apply` is true, otherwise only estimates. + /// + /// # Parameters + /// + /// * `contract_id`: The moderated contract. + /// * `identity_id`: The identity to warn. + /// * `warnings`: The identity's warnings after this one, oldest first; at least one. The + /// length of each reason is the caller's to check. + /// * `replaces_existing`: Whether an entry for the identity exists and is replaced. + /// * `moderator_id`: The identity that pays for the entry. + /// * `block_info`: The current block. + /// * `apply`: Whether to apply or only estimate. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(FeeResult)` with the fee of the write. + /// * `Err(Error)` when the version is unknown or GroveDB refuses an operation. + #[allow(clippy::too_many_arguments)] + pub fn add_contract_warning( + &self, + contract_id: Identifier, + identity_id: Identifier, + warnings: &[ContractWarning], + replaces_existing: bool, + moderator_id: Identifier, + block_info: &BlockInfo, + apply: bool, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive + .methods + .contract + .moderation + .add_contract_warning + { + 0 => self.add_contract_warning_v0( + contract_id, + identity_id, + warnings, + replaces_existing, + moderator_id, + block_info, + apply, + transaction, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "add_contract_warning".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + /// The low level operations of [`Drive::add_contract_warning`]. With layer information the + /// operations are built for estimation only. + /// + /// # Parameters + /// + /// * `contract_id`: The moderated contract. + /// * `identity_id`: The identity to warn. + /// * `warnings`: The identity's warnings after this one, oldest first; at least one. + /// * `replaces_existing`: Whether an entry for the identity exists and is replaced. + /// * `moderator_id`: The identity that pays for the entry. + /// * `block_info`: The current block. + /// * `estimated_costs_only_with_layer_info`: The estimation map for a dry run. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(Vec)` with the operations. + /// * `Err(Error)` when the version is unknown or GroveDB refuses an operation. + #[allow(clippy::too_many_arguments)] + pub fn add_contract_warning_operations( + &self, + contract_id: Identifier, + identity_id: Identifier, + warnings: &[ContractWarning], + replaces_existing: bool, + moderator_id: Identifier, + block_info: &BlockInfo, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .contract + .moderation + .add_contract_warning + { + 0 => self.add_contract_warning_operations_v0( + contract_id, + identity_id, + warnings, + replaces_existing, + moderator_id, + block_info, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "add_contract_warning_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/contract/moderation/add_contract_warning/v0/mod.rs b/packages/rs-drive/src/drive/contract/moderation/add_contract_warning/v0/mod.rs new file mode 100644 index 00000000000..cfc9789df68 --- /dev/null +++ b/packages/rs-drive/src/drive/contract/moderation/add_contract_warning/v0/mod.rs @@ -0,0 +1,144 @@ +use crate::drive::contract::moderation::types::encode_warnings; +use crate::drive::contract::paths::contract_moderation_list_path; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::object_size_info::PathKeyElementInfo::PathFixedSizeKeyRefElement; +use crate::util::storage_flags::StorageFlags; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::config::moderation::{ContractModerationList, ContractWarning}; +use dpp::fee::fee_result::FeeResult; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::Element; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + #[inline(always)] + #[allow(clippy::too_many_arguments)] + pub(super) fn add_contract_warning_v0( + &self, + contract_id: Identifier, + identity_id: Identifier, + warnings: &[ContractWarning], + replaces_existing: bool, + moderator_id: Identifier, + block_info: &BlockInfo, + apply: bool, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let mut estimated_costs_only_with_layer_info = if apply { + None::> + } else { + Some(HashMap::new()) + }; + + let batch_operations = self.add_contract_warning_operations_v0( + contract_id, + identity_id, + warnings, + replaces_existing, + moderator_id, + block_info, + &mut estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + + let mut drive_operations: Vec = vec![]; + self.apply_batch_low_level_drive_operations( + estimated_costs_only_with_layer_info, + transaction, + batch_operations, + &mut drive_operations, + &platform_version.drive, + )?; + + Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + self.config.epochs_per_era, + platform_version, + None, + ) + } + + /// A warning list entry is every warning the identity carries, oldest first, each its + /// block time and its reason (see [`encode_warnings`]), under the identity's id, flagged + /// with the moderator's identity so the storage refund on removal goes back to whoever + /// paid. A warn on an identity that already carries warnings replaces the entry in place + /// with one warning more; two operations on one key would fail the batch. The entry is + /// then longer, so its flags follow GroveDB's flag merge for a grown item: it passes, with + /// the refund of its removal, to the moderator that warned last, who pays for the bytes + /// the warning added. + /// + /// An estimate prices the replacement as a fresh insert. GroveDB's average-case replace + /// assumes an item keeps its size and would price no storage for the added warning, which + /// the moderator's balance is then not checked against; the whole entry is an upper bound. + #[inline(always)] + #[allow(clippy::too_many_arguments)] + pub(super) fn add_contract_warning_operations_v0( + &self, + contract_id: Identifier, + identity_id: Identifier, + warnings: &[ContractWarning], + replaces_existing: bool, + moderator_id: Identifier, + block_info: &BlockInfo, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + _transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let estimating = estimated_costs_only_with_layer_info.is_some(); + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { + Drive::add_estimation_costs_for_contract_moderation_entry( + contract_id.to_buffer(), + ContractModerationList::Warnings, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + } + + let storage_flags = + StorageFlags::new_single_epoch(block_info.epoch.index, Some(moderator_id.to_buffer())); + + let path_key_element = PathFixedSizeKeyRefElement(( + contract_moderation_list_path(contract_id.as_slice(), ContractModerationList::Warnings), + identity_id.as_slice(), + Element::new_item_with_flags( + // Every reason was bounded by basic structure validation, so an encoding + // refusal is a code path that lost that guarantee, not a moderator's mistake. + encode_warnings(warnings).map_err(|_| { + Error::Drive(DriveError::CorruptedCodeExecution( + "a warning's reason is longer than a warning list entry can hold", + )) + })?, + storage_flags.to_some_element_flags(), + ), + )); + + let mut batch_operations: Vec = vec![]; + if replaces_existing && !estimating { + self.batch_replace( + path_key_element, + &mut batch_operations, + &platform_version.drive, + )?; + } else { + self.batch_insert( + path_key_element, + &mut batch_operations, + &platform_version.drive, + )?; + } + + Ok(batch_operations) + } +} diff --git a/packages/rs-drive/src/drive/contract/moderation/document_removal_tests.rs b/packages/rs-drive/src/drive/contract/moderation/document_removal_tests.rs index 0bdd77d4fd0..2a07895f759 100644 --- a/packages/rs-drive/src/drive/contract/moderation/document_removal_tests.rs +++ b/packages/rs-drive/src/drive/contract/moderation/document_removal_tests.rs @@ -16,7 +16,7 @@ use crate::util::batch::{ DriveOperation, }; use crate::util::grove_operations::DirectQueryType; -use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; +use crate::util::object_size_info::DocumentInfo::{DocumentOwnedInfo, DocumentRefInfo}; use crate::util::object_size_info::{ DataContractInfo, DocumentAndContractInfo, DocumentTypeInfo, OwnedDocumentInfo, }; @@ -26,7 +26,8 @@ use dpp::block::block_info::BlockInfo; use dpp::block::epoch::Epoch; use dpp::data_contract::accessors::v0::{DataContractV0Getters, DataContractV0Setters}; use dpp::data_contract::config::moderation::{ - ContractDocumentRemoval, ContractModerationConfig, ContractModerationReason, ContractModerators, + ContractDocumentRemoval, ContractDocumentRestoration, ContractModerationConfig, + ContractModerationReason, ContractModerators, }; use dpp::data_contract::document_type::action_fees::{ContractFeePot, ContractFeePotLastClaim}; use dpp::data_contract::document_type::random_document::CreateRandomDocument; @@ -76,6 +77,7 @@ fn contract_with(moderated: bool, banlist: bool, deletable_types: &[&str]) -> Da banlist, suspensions: false, moderators: ContractModerators::ContractOwner, + warnings: false, }); contract.set_config(contract.config().clone().with_moderation(moderation)); for name in deletable_types { @@ -141,8 +143,11 @@ fn removal(owner: u8, moderator: u8, text: &str, removed_at: u64) -> ContractDoc reason: ContractModerationReason { code: Some(7), text: text.to_string(), + documents: vec![], }, removed_at, + document_hash: [removed_at as u8; 32], + restoration: None, } } @@ -151,11 +156,31 @@ fn record<'a>( document_id: Identifier, removal: ContractDocumentRemoval, ) -> DriveOperation<'a> { + let moderator_id = removal.moderator_id; ContractModerationOperation(ContractModerationOperationType::AddDocumentRemoval { contract_id, document_type_name: POST.to_string(), document_id, removal, + replaces_existing: false, + moderator_id, + }) +} + +/// The replacement of `document_id`'s record by `removal`, paid for by `moderator_id` +fn replace_record<'a>( + contract_id: Identifier, + document_id: Identifier, + removal: ContractDocumentRemoval, + moderator_id: Identifier, +) -> DriveOperation<'a> { + ContractModerationOperation(ContractModerationOperationType::AddDocumentRemoval { + contract_id, + document_type_name: POST.to_string(), + document_id, + removal, + replaces_existing: true, + moderator_id, }) } @@ -364,6 +389,7 @@ fn should_keep_the_banlist_on_top_of_the_other_tree_when_every_tree_is_created_a banlist, suspensions, moderators: ContractModerators::ContractOwner, + warnings: false, }, ))); add_deletable_type(&mut contract, POST); @@ -408,6 +434,7 @@ fn should_lose_the_top_of_the_other_tree_to_the_version_item_once_both_fee_pots_ banlist: true, suspensions: true, moderators: ContractModerators::ContractOwner, + warnings: false, }, ))); add_deletable_type(&mut contract, POST); @@ -699,6 +726,8 @@ fn should_refund_nobody_for_a_document_a_moderator_deletes() { moderator_id: moderator, reason: ContractModerationReason::from_text("spam"), removed_at: 10, + document_hash: [0x43; 32], + restoration: None, }, ), forfeit(), @@ -742,11 +771,204 @@ fn should_refund_nobody_for_a_document_a_moderator_deletes() { moderator_id: moderator, reason: ContractModerationReason::from_text("spam"), removed_at: 10, + document_hash: [0x43; 32], + restoration: None, }, }], ); } +/// The insert that brings `post` back: the document as it was, its storage flags naming its +/// owner, as a create's do +fn restore_post<'a>(contract: &'a DataContract, post: &Document) -> DriveOperation<'a> { + DocumentOperation(DocumentOperationType::AddDocument { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentOwnedInfo(( + post.clone(), + Some(Cow::Owned(StorageFlags::SingleEpochOwned( + 3, + post.owner_id().to_buffer(), + ))), + )), + owner_id: Some(post.owner_id().to_buffer()), + }, + contract_info: DataContractInfo::BorrowedDataContract(contract), + document_type_info: DocumentTypeInfo::DocumentTypeName(POST.to_string()), + override_document: false, + }) +} + +fn post_is_stored(drive: &Drive, contract: &DataContract, document_id: Identifier) -> bool { + drive + .grove_has_raw( + (&contract_documents_primary_key_path(contract.id_ref().as_bytes(), POST)).into(), + document_id.as_slice(), + DirectQueryType::StatefulDirectQuery, + None, + &mut vec![], + &PlatformVersion::latest().drive, + ) + .expect("expected to query the posts") +} + +#[test] +fn should_restore_a_document_mark_its_record_and_replace_the_record_on_a_second_deletion() { + let platform_version = PlatformVersion::latest(); + let author = identity(0x41); + let moderator = identity(0x42); + let restorer = identity(0x43); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = contract_with(true, false, &[POST]); + insert(&drive, &contract); + let post = add_post(&drive, &contract, author); + let removal = ContractDocumentRemoval { + document_owner_id: author, + moderator_id: moderator, + reason: ContractModerationReason::from_text("spam"), + removed_at: 10, + document_hash: [0x44; 32], + restoration: None, + }; + apply( + &drive, + vec![ + delete_post_by_moderator(&contract, post.id()), + record(contract.id(), post.id(), removal.clone()), + forfeit(), + ], + true, + ); + assert!(!post_is_stored(&drive, &contract, post.id())); + + // The restore: the document back, the record marked in place. The restorer pays for the + // document and for the bytes the mark adds; nobody is refunded anything. + let restored = ContractDocumentRemoval { + restoration: Some(ContractDocumentRestoration { + moderator_id: restorer, + restored_at: 20, + }), + ..removal.clone() + }; + let operations = || { + vec![ + restore_post(&contract, &post), + replace_record(contract.id(), post.id(), restored.clone(), restorer), + ] + }; + let estimated = apply(&drive, operations(), false); + let applied = apply(&drive, operations(), true); + assert!( + applied.storage_fee > 0, + "the restorer pays for the document" + ); + assert!( + estimated.storage_fee >= applied.storage_fee, + "estimated {} < applied {}", + estimated.storage_fee, + applied.storage_fee + ); + assert!(applied.fee_refunds.0.is_empty()); + assert!(post_is_stored(&drive, &contract, post.id())); + assert_removals( + &drive, + contract.id(), + &by_ids(&[post.id()]), + vec![ContractDocumentRemovalEntry { + document_id: post.id(), + removal: restored.clone(), + }], + ); + + // Deleted again by a moderator: a fresh record in place of the restored one, the author + // refunded nothing again. + let again = ContractDocumentRemoval { + moderator_id: restorer, + removed_at: 30, + restoration: None, + ..removal + }; + let applied = apply( + &drive, + vec![ + delete_post_by_moderator(&contract, post.id()), + replace_record(contract.id(), post.id(), again.clone(), restorer), + forfeit(), + ], + true, + ); + assert!(applied.fee_refunds.0.is_empty()); + assert!(applied.removed_bytes_from_system > 0); + assert!(!post_is_stored(&drive, &contract, post.id())); + assert_removals( + &drive, + contract.id(), + &by_ids(&[post.id()]), + vec![ContractDocumentRemovalEntry { + document_id: post.id(), + removal: again, + }], + ); +} + +#[test] +fn should_refund_the_author_who_deletes_a_restored_document() { + // The restored document's flags name its author, as they did before the deletion: the + // refund of the author's own deletion is the author's, though a moderator paid to put + // the document back. + let platform_version = PlatformVersion::latest(); + let author = identity(0x41); + let moderator = identity(0x42); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = contract_with(true, false, &[POST]); + insert(&drive, &contract); + let post = add_post(&drive, &contract, author); + let removal = removal(0x41, 0x42, "spam", 10); + apply( + &drive, + vec![ + delete_post_by_moderator(&contract, post.id()), + record(contract.id(), post.id(), removal.clone()), + forfeit(), + ], + true, + ); + let restored = ContractDocumentRemoval { + restoration: Some(ContractDocumentRestoration { + moderator_id: moderator, + restored_at: 20, + }), + ..removal + }; + apply( + &drive, + vec![ + restore_post(&contract, &post), + replace_record(contract.id(), post.id(), restored.clone(), moderator), + ], + true, + ); + + let own = apply(&drive, vec![delete_post(&contract, post.id())], true); + let refunded: u64 = own + .fee_refunds + .get(author.as_bytes()) + .expect("expected the author to be refunded") + .values() + .sum(); + assert!(refunded > 0); + assert!(own.fee_refunds.get(moderator.as_bytes()).is_none()); + // The record stays as it was: the author's deletion is not a moderation. + assert_removals( + &drive, + contract.id(), + &by_ids(&[post.id()]), + vec![ContractDocumentRemovalEntry { + document_id: post.id(), + removal: restored, + }], + ); +} + #[test] fn should_delete_for_a_moderator_a_document_its_owner_can_not_delete() { // `canBeDeleted` rules what a document's own owner may do. A post nobody can retract, but diff --git a/packages/rs-drive/src/drive/contract/moderation/estimated_costs/v0/mod.rs b/packages/rs-drive/src/drive/contract/moderation/estimated_costs/v0/mod.rs index 35b05161fa0..db23a92a7bb 100644 --- a/packages/rs-drive/src/drive/contract/moderation/estimated_costs/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/estimated_costs/v0/mod.rs @@ -32,7 +32,7 @@ impl Drive { drive_version, )?; - // The contract's other tree (`[64, id, 2]`): the version item and up to two list trees. + // The contract's other tree (`[64, id, 2]`): the version item and up to three list trees. Drive::add_estimation_costs_for_contract_other_tree( contract_id, estimated_costs_only_with_layer_info, diff --git a/packages/rs-drive/src/drive/contract/moderation/fetch_contract_document_removals/mod.rs b/packages/rs-drive/src/drive/contract/moderation/fetch_contract_document_removals/mod.rs index 2440462a6be..63b49e6e674 100644 --- a/packages/rs-drive/src/drive/contract/moderation/fetch_contract_document_removals/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/fetch_contract_document_removals/mod.rs @@ -6,6 +6,10 @@ use crate::drive::contract::moderation::types::{ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::block::epoch::Epoch; +use dpp::data_contract::config::moderation::ContractDocumentRemoval; +use dpp::fee::fee_result::FeeResult; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; use grovedb::TransactionArg; @@ -41,4 +45,52 @@ impl Drive { })), } } + + /// The record of one document a moderator deleted, with the fee of the read, so that + /// consensus validation can bill it. The document type must be one whose documents + /// moderators may delete: its records tree exists since the type was created, so a + /// missing record reads as `None` and nothing else does. + pub fn fetch_contract_document_removal_with_fee( + &self, + contract_id: Identifier, + document_type_name: &str, + document_id: Identifier, + epoch: &Epoch, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(FeeResult, Option), Error> { + match platform_version + .drive + .methods + .contract + .moderation + .fetch_contract_document_removals + { + 0 => { + let mut drive_operations: Vec = vec![]; + let removal = self.fetch_contract_document_removal_add_to_operations_v0( + contract_id, + document_type_name, + document_id, + transaction, + &mut drive_operations, + platform_version, + )?; + let fee = Drive::calculate_fee( + None, + Some(drive_operations), + epoch, + self.config.epochs_per_era, + platform_version, + None, + )?; + Ok((fee, removal)) + } + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_contract_document_removal_with_fee".to_string(), + known_versions: vec![0], + received: version, + })), + } + } } diff --git a/packages/rs-drive/src/drive/contract/moderation/fetch_contract_document_removals/v0/mod.rs b/packages/rs-drive/src/drive/contract/moderation/fetch_contract_document_removals/v0/mod.rs index 39ab4529ae9..c6a9f46ceef 100644 --- a/packages/rs-drive/src/drive/contract/moderation/fetch_contract_document_removals/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/fetch_contract_document_removals/v0/mod.rs @@ -1,11 +1,15 @@ use crate::drive::contract::moderation::types::{ - ContractDocumentRemovalEntry, ContractDocumentRemovalsQuery, + decode_document_removal, ContractDocumentRemovalEntry, ContractDocumentRemovalsQuery, +}; +use crate::drive::contract::paths::{ + contract_document_removals_path, contract_document_type_removals_path, }; -use crate::drive::contract::paths::contract_document_removals_path; use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; use crate::util::grove_operations::DirectQueryType; +use dpp::data_contract::config::moderation::ContractDocumentRemoval; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; use grovedb::query_result_type::QueryResultType; @@ -73,4 +77,36 @@ impl Drive { }) .collect() } + + /// One record, read the way the transform of a moderation reads state: the operations of + /// the read are added to `drive_operations` for billing. + #[inline(always)] + pub(super) fn fetch_contract_document_removal_add_to_operations_v0( + &self, + contract_id: Identifier, + document_type_name: &str, + document_id: Identifier, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let path = contract_document_type_removals_path(contract_id.as_slice(), document_type_name); + self.grove_get_raw_optional_item( + (&path).into(), + document_id.as_slice(), + DirectQueryType::StatefulDirectQuery, + transaction, + drive_operations, + &platform_version.drive, + )? + .map(|value| { + decode_document_removal(&value).map_err(|description| { + Error::Drive(DriveError::CorruptedDriveState(format!( + "contract {} {} document {} removal is malformed: {}", + contract_id, document_type_name, document_id, description + ))) + }) + }) + .transpose() + } } diff --git a/packages/rs-drive/src/drive/contract/moderation/fetch_contract_moderation_status/mod.rs b/packages/rs-drive/src/drive/contract/moderation/fetch_contract_moderation_status/mod.rs index eceb22a510d..ea471c66931 100644 --- a/packages/rs-drive/src/drive/contract/moderation/fetch_contract_moderation_status/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/fetch_contract_moderation_status/mod.rs @@ -12,8 +12,8 @@ use dpp::version::PlatformVersion; use grovedb::TransactionArg; impl Drive { - /// Reads one identity's status on a moderated contract: whether it is on the banlist, and - /// until when it is on the suspension list. Only the lists in `lists` are read; those are + /// Reads one identity's status on a moderated contract: whether it is on the banlist, + /// until when it is on the suspension list, and the warnings it carries. Only the lists in `lists` are read; those are /// the lists the contract's config declares, and an undeclared list has no tree. /// /// # Parameters diff --git a/packages/rs-drive/src/drive/contract/moderation/fetch_contract_moderation_status/v0/mod.rs b/packages/rs-drive/src/drive/contract/moderation/fetch_contract_moderation_status/v0/mod.rs index 10f830c6f41..08c03b6cb33 100644 --- a/packages/rs-drive/src/drive/contract/moderation/fetch_contract_moderation_status/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/fetch_contract_moderation_status/v0/mod.rs @@ -1,4 +1,4 @@ -use crate::drive::contract::moderation::types::{decode_ban, decode_suspension}; +use crate::drive::contract::moderation::types::{decode_ban, decode_suspension, decode_warnings}; use crate::drive::contract::paths::contract_moderation_list_path; use crate::drive::Drive; use crate::error::drive::DriveError; @@ -46,6 +46,9 @@ impl Drive { (ContractModerationList::Suspensions, Some(value)) => { status.suspension = Some(decode_suspension(&value).map_err(malformed)?); } + (ContractModerationList::Warnings, Some(value)) => { + status.warnings = decode_warnings(&value).map_err(malformed)?; + } } } Ok(status) diff --git a/packages/rs-drive/src/drive/contract/moderation/insert_contract_moderation_trees/mod.rs b/packages/rs-drive/src/drive/contract/moderation/insert_contract_moderation_trees/mod.rs index 70ebbe4cbd0..d0f97958a25 100644 --- a/packages/rs-drive/src/drive/contract/moderation/insert_contract_moderation_trees/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/insert_contract_moderation_trees/mod.rs @@ -14,7 +14,8 @@ use std::collections::HashMap; impl Drive { /// Adds the operations that create the moderation list trees a contract's config declares /// and that do not exist yet: the banlist under key `128`, the suspension list under key - /// `192`, both in the contract's other tree (`[64, id, 2]`). Called by the contract insertion + /// `192`, the warning list under key `224`, all in the contract's other tree + /// (`[64, id, 2]`). Called by the contract insertion /// only: the lists a contract keeps are fixed when it is /// created, so a contract update never adds one. /// diff --git a/packages/rs-drive/src/drive/contract/moderation/mod.rs b/packages/rs-drive/src/drive/contract/moderation/mod.rs index b9dd67737b7..6b90422dbf2 100644 --- a/packages/rs-drive/src/drive/contract/moderation/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/mod.rs @@ -1,5 +1,5 @@ -//! Contract moderation: the banlist and the suspension list a moderated data contract keeps -//! under its own subtree (protocol version 14). +//! Contract moderation: the banlist, the suspension list and the warning list a moderated +//! data contract keeps under its own subtree (protocol version 14). //! //! ```text //! [64] DataContractDocuments @@ -9,7 +9,8 @@ //! └── [2] other //! ├── [64] contract version item //! ├── [128] banlist -> -> Item(reason) (when declared) -//! └── [192] suspensions -> -> Item(until ‖ reason) (when declared) +//! ├── [192] suspensions -> -> Item(until ‖ reason) (when declared) +//! └── [224] warnings -> -> Item((warned at ‖ len ‖ reason)+) (when declared) //! ``` //! //! and the records of the documents the contract's moderators deleted, under the same other @@ -27,7 +28,10 @@ //! //! `until` is a u64 of block time in milliseconds, big-endian. A reason is a tag byte (`0`: no //! code, `1`: a code), the code as a big-endian u16 when tagged, and the text as UTF-8 up to -//! the end of the value: see [`types::encode_ban`] and [`types::encode_suspension`]. +//! the end of the value: see [`types::encode_ban`] and [`types::encode_suspension`]. A warning +//! list entry holds every warning the identity carries, oldest first, each its block time as a +//! u64 big-endian, the length of its reason as a u16 big-endian and the reason: see +//! [`types::encode_warnings`]. A warning bars nothing. //! //! An entry's storage flags name the moderator that wrote it, so the storage refund of its //! deletion goes to that moderator whichever transition deletes it: an explicit unban or @@ -41,6 +45,8 @@ mod add_contract_document_removal; #[cfg(feature = "server")] mod add_contract_suspension; #[cfg(feature = "server")] +mod add_contract_warning; +#[cfg(feature = "server")] mod estimated_costs; #[cfg(feature = "server")] mod fetch_contract_document_removals; @@ -63,6 +69,8 @@ mod queries; mod remove_contract_ban; #[cfg(feature = "server")] mod remove_contract_suspension; +#[cfg(feature = "server")] +mod remove_contract_warnings; /// Query and result types shared by the fetch and verify sides. pub mod types; diff --git a/packages/rs-drive/src/drive/contract/moderation/remove_contract_warnings/mod.rs b/packages/rs-drive/src/drive/contract/moderation/remove_contract_warnings/mod.rs new file mode 100644 index 00000000000..6886bf14d62 --- /dev/null +++ b/packages/rs-drive/src/drive/contract/moderation/remove_contract_warnings/mod.rs @@ -0,0 +1,118 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::block::block_info::BlockInfo; +use dpp::fee::fee_result::FeeResult; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + /// Takes `identity_id` off the warning list of `contract_id`: every warning it carries + /// goes. The storage refund goes to the identity the entry's storage flags name. + /// + /// The caller must have checked that the entry exists. Applies the operations when + /// `apply` is true, otherwise only estimates. + /// + /// # Parameters + /// + /// * `contract_id`: The moderated contract. + /// * `identity_id`: The identity whose warnings are cleared. + /// * `block_info`: The current block. + /// * `apply`: Whether to apply or only estimate. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(FeeResult)` with the fee of the deletion. + /// * `Err(Error)` when the version is unknown or GroveDB refuses an operation. + #[allow(clippy::too_many_arguments)] + pub fn remove_contract_warnings( + &self, + contract_id: Identifier, + identity_id: Identifier, + block_info: &BlockInfo, + apply: bool, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive + .methods + .contract + .moderation + .remove_contract_warnings + { + 0 => self.remove_contract_warnings_v0( + contract_id, + identity_id, + block_info, + apply, + transaction, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "remove_contract_warnings".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + /// The low level operations of [`Drive::remove_contract_warnings`]. With layer information the + /// operations are built for estimation only. + /// + /// # Parameters + /// + /// * `contract_id`: The moderated contract. + /// * `identity_id`: The identity whose warnings are cleared. + /// * `block_info`: The current block. + /// * `estimated_costs_only_with_layer_info`: The estimation map for a dry run. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(Vec)` with the operations. + /// * `Err(Error)` when the version is unknown or GroveDB refuses an operation. + #[allow(clippy::too_many_arguments)] + pub fn remove_contract_warnings_operations( + &self, + contract_id: Identifier, + identity_id: Identifier, + block_info: &BlockInfo, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .contract + .moderation + .remove_contract_warnings + { + 0 => self.remove_contract_warnings_operations_v0( + contract_id, + identity_id, + block_info, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "remove_contract_warnings_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/contract/moderation/remove_contract_warnings/v0/mod.rs b/packages/rs-drive/src/drive/contract/moderation/remove_contract_warnings/v0/mod.rs new file mode 100644 index 00000000000..b399cafa834 --- /dev/null +++ b/packages/rs-drive/src/drive/contract/moderation/remove_contract_warnings/v0/mod.rs @@ -0,0 +1,115 @@ +use crate::drive::contract::moderation::types::estimated_entry_value_size; +use crate::drive::contract::paths::contract_moderation_list_path; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::BatchDeleteApplyType; +use crate::util::storage_flags::StorageFlags; +use crate::util::type_constants::DEFAULT_HASH_SIZE_U32; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::config::moderation::ContractModerationList; +use dpp::fee::fee_result::FeeResult; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use grovedb::{MaybeTree, TreeType}; +use std::collections::HashMap; + +impl Drive { + #[inline(always)] + #[allow(clippy::too_many_arguments)] + pub(super) fn remove_contract_warnings_v0( + &self, + contract_id: Identifier, + identity_id: Identifier, + block_info: &BlockInfo, + apply: bool, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let mut estimated_costs_only_with_layer_info = if apply { + None::> + } else { + Some(HashMap::new()) + }; + + let batch_operations = self.remove_contract_warnings_operations_v0( + contract_id, + identity_id, + block_info, + &mut estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + + let mut drive_operations: Vec = vec![]; + self.apply_batch_low_level_drive_operations( + estimated_costs_only_with_layer_info, + transaction, + batch_operations, + &mut drive_operations, + &platform_version.drive, + )?; + + Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + self.config.epochs_per_era, + platform_version, + None, + ) + } + + /// Deletes the entry. The storage refund follows the entry's own flags, which name the + /// moderator that wrote it. + #[inline(always)] + #[allow(clippy::too_many_arguments)] + pub(super) fn remove_contract_warnings_operations_v0( + &self, + contract_id: Identifier, + identity_id: Identifier, + _block_info: &BlockInfo, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let list = ContractModerationList::Warnings; + + let apply_type = if let Some(estimated_costs_only_with_layer_info) = + estimated_costs_only_with_layer_info + { + Drive::add_estimation_costs_for_contract_moderation_entry( + contract_id.to_buffer(), + list, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + BatchDeleteApplyType::StatelessBatchDelete { + in_tree_type: TreeType::NormalTree, + estimated_key_size: DEFAULT_HASH_SIZE_U32, + estimated_value_size: estimated_entry_value_size(list) + + StorageFlags::approximate_size(true, None), + } + } else { + BatchDeleteApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + } + }; + + let mut batch_operations: Vec = vec![]; + self.batch_delete( + (&contract_moderation_list_path(contract_id.as_slice(), list)).into(), + identity_id.as_slice(), + apply_type, + transaction, + &mut batch_operations, + &platform_version.drive, + )?; + + Ok(batch_operations) + } +} diff --git a/packages/rs-drive/src/drive/contract/moderation/tests.rs b/packages/rs-drive/src/drive/contract/moderation/tests.rs index 1f40de04fd9..ee76fe23dba 100644 --- a/packages/rs-drive/src/drive/contract/moderation/tests.rs +++ b/packages/rs-drive/src/drive/contract/moderation/tests.rs @@ -3,7 +3,7 @@ use crate::drive::contract::moderation::types::{ }; use crate::drive::contract::paths::{ contract_other_path, CONTRACT_BANLIST_KEY, CONTRACT_OTHER_KEY, CONTRACT_SUSPENSIONS_KEY, - CONTRACT_VERSION_KEY, + CONTRACT_VERSION_KEY, CONTRACT_WARNINGS_KEY, }; use crate::drive::{Drive, RootTree}; use crate::util::grove_operations::DirectQueryType; @@ -14,6 +14,7 @@ use dpp::data_contract::accessors::v0::{DataContractV0Getters, DataContractV0Set use dpp::data_contract::config::moderation::{ ContractBan, ContractModerationConfig, ContractModerationList, ContractModerationListStatuses, ContractModerationReason, ContractModerationStatus, ContractModerators, ContractSuspension, + ContractWarning, }; use dpp::data_contract::DataContract; use dpp::identifier::Identifier; @@ -31,6 +32,7 @@ fn banned_for(text: &str) -> ContractModerationStatus { reason: reason(text), }), suspension: None, + warnings: vec![], } } @@ -41,6 +43,7 @@ fn suspended_until(until: u64, text: &str) -> ContractModerationStatus { until, reason: reason(text), }), + warnings: vec![], } } @@ -49,18 +52,38 @@ fn identity(seed: u8) -> Identifier { } fn moderated_contract(banlist: bool, suspensions: bool) -> DataContract { + moderated_contract_keeping(banlist, suspensions, false) +} + +fn moderated_contract_keeping(banlist: bool, suspensions: bool, warnings: bool) -> DataContract { let platform_version = PlatformVersion::latest(); let mut contract = get_data_contract_fixture(None, 0, platform_version.protocol_version).data_contract_owned(); - let moderation = (banlist || suspensions).then_some(ContractModerationConfig { + let moderation = (banlist || suspensions || warnings).then_some(ContractModerationConfig { banlist, suspensions, + warnings, moderators: ContractModerators::ContractOwner, }); contract.set_config(contract.config().clone().with_moderation(moderation)); contract } +fn warning(warned_at: u64, text: &str) -> ContractWarning { + ContractWarning { + warned_at, + reason: reason(text), + } +} + +fn warned_with(warnings: Vec) -> ContractModerationStatus { + ContractModerationStatus { + ban: None, + suspension: None, + warnings, + } +} + fn insert(drive: &Drive, contract: &DataContract, platform_version: &PlatformVersion) { drive .insert_contract(contract, BlockInfo::default(), true, None, platform_version) @@ -224,6 +247,7 @@ fn should_keep_the_list_trees_and_their_entries_across_a_contract_update() { moderators: ContractModerators::AppointedModerators( [identity(0x42)].into_iter().collect(), ), + warnings: false, })), ); drive @@ -297,6 +321,7 @@ fn should_ban_and_unban_and_prove_the_status_and_the_entries() { identity_id: target, until: None, reason: reason("spam"), + warnings: vec![], }], ); @@ -403,6 +428,7 @@ fn should_suspend_replace_and_unsuspend() { identity_id: target, until: Some(20), reason: reason("flooding again, after a warning"), + warnings: vec![], }], ); @@ -574,6 +600,7 @@ fn should_page_entries_with_a_cursor_and_bound_the_limit() { identity_id, until: None, reason: reason("spam"), + warnings: vec![], }; let first_page = ContractModerationEntriesQuery { @@ -744,6 +771,7 @@ fn should_bill_the_replacing_moderator_for_a_longer_reason() { until: 20, reason: longer, }), + warnings: vec![], }, ); @@ -924,6 +952,7 @@ fn should_charge_a_ban_by_the_length_of_its_reason() { let longest = ContractModerationReason { code: Some(u16::MAX), text: "x".repeat(max_length), + documents: vec![], }; let estimated = ban(0x62, &longest, false); let full = ban(0x62, &longest, true); @@ -953,6 +982,7 @@ fn should_charge_a_ban_by_the_length_of_its_reason() { ContractModerationStatus { ban: Some(ContractBan { reason: longest }), suspension: None, + warnings: vec![], }, ); } @@ -997,6 +1027,224 @@ fn should_say_nothing_about_a_list_the_status_proof_does_not_cover() { assert_eq!(proved.0.len(), 1); } +#[test] +fn should_create_the_warning_list_tree_only_when_the_config_declares_it() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + + let without = moderated_contract(true, true); + insert(&drive, &without, platform_version); + assert!(!has_list_tree(&drive, without.id(), CONTRACT_WARNINGS_KEY)); + + let mut with = moderated_contract_keeping(false, false, true); + with.set_id(identity(0x13)); + insert(&drive, &with, platform_version); + assert!(has_list_tree(&drive, with.id(), CONTRACT_WARNINGS_KEY)); + assert!(!has_list_tree(&drive, with.id(), CONTRACT_BANLIST_KEY)); + assert!(!has_list_tree(&drive, with.id(), CONTRACT_SUSPENSIONS_KEY)); +} + +#[test] +fn should_warn_accumulate_clear_and_prove_the_status_and_the_entries() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = moderated_contract_keeping(true, false, true); + insert(&drive, &contract, platform_version); + let contract_id = contract.id(); + let first_moderator = contract.owner_id(); + let second_moderator = identity(0x62); + let target = identity(0x61); + let lists = [ContractModerationList::Warnings]; + let first = warning(1_000, "first strike"); + let second = warning(2_000, "second strike, a longer one"); + + let first_fee = drive + .add_contract_warning( + contract_id, + target, + std::slice::from_ref(&first), + false, + first_moderator, + &BlockInfo::default_with_epoch(Epoch::new(0).expect("epoch 0")), + true, + None, + platform_version, + ) + .expect("expected to warn"); + assert!(first_fee.storage_fee > 0, "a warning stores an entry"); + assert_status( + &drive, + contract_id, + target, + &lists, + warned_with(vec![first.clone()]), + ); + // The banlist says nothing about it. + assert_status( + &drive, + contract_id, + target, + &BOTH[..1], + ContractModerationStatus::default(), + ); + + // A second warning, by another moderator in a later epoch: the entry is rewritten one + // warning longer, so it passes to the moderator that warned last, who pays the added + // bytes. + let later = BlockInfo::default_with_epoch(Epoch::new(3).expect("epoch 3")); + let second_fee = drive + .add_contract_warning( + contract_id, + target, + &[first.clone(), second.clone()], + true, + second_moderator, + &later, + true, + None, + platform_version, + ) + .expect("expected to warn again"); + assert!(second_fee.storage_fee > 0, "the added warning is stored"); + assert_status( + &drive, + contract_id, + target, + &lists, + warned_with(vec![first.clone(), second.clone()]), + ); + assert_entries( + &drive, + contract_id, + &ContractModerationEntriesQuery { + list: ContractModerationList::Warnings, + start_after: None, + limit: 10, + }, + &[ContractModerationEntry { + identity_id: target, + until: None, + // The entry's reason is the latest warning's; every warning comes along. + reason: second.reason.clone(), + warnings: vec![first, second], + }], + ); + + let fee = drive + .remove_contract_warnings(contract_id, target, &later, true, None, platform_version) + .expect("expected to clear the warnings"); + assert!( + fee.fee_refunds + .calculate_refunds_amount_for_identity(second_moderator) + .is_some(), + "the moderator that warned last owns the entry" + ); + assert!( + fee.fee_refunds + .calculate_refunds_amount_for_identity(first_moderator) + .is_none(), + "the first moderator's bytes passed on with the entry" + ); + assert_status( + &drive, + contract_id, + target, + &lists, + ContractModerationStatus::default(), + ); + assert_entries( + &drive, + contract_id, + &ContractModerationEntriesQuery { + list: ContractModerationList::Warnings, + start_after: None, + limit: 10, + }, + &[], + ); +} + +#[test] +fn should_not_estimate_a_warning_below_what_it_costs() { + let platform_version = PlatformVersion::latest(); + let max_length = platform_version + .system_limits + .max_contract_moderation_reason_length as usize; + let max_warnings = platform_version + .system_limits + .max_contract_warnings_per_identity as usize; + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = moderated_contract_keeping(false, false, true); + insert(&drive, &contract, platform_version); + let contract_id = contract.id(); + let moderator = contract.owner_id(); + let target = identity(0x54); + let warn = |warnings: &[ContractWarning], replaces_existing: bool, apply: bool| { + drive + .add_contract_warning( + contract_id, + target, + warnings, + replaces_existing, + moderator, + &BlockInfo::default(), + apply, + None, + platform_version, + ) + .expect("expected to warn") + }; + + // The first warning is an insert, estimated as one. + let first = vec![warning(1, "spam")]; + let estimated = warn(&first, false, false); + let applied = warn(&first, false, true); + assert_eq!(estimated.storage_fee, applied.storage_fee, "first warning"); + + // Every later one replaces the entry with a longer one. GroveDB's average-case replace + // assumes an item keeps its size, so a replacement is estimated as an insert of the whole + // entry, an upper bound: up to the fullest entry the protocol admits. + let mut warnings = first; + for index in 1..max_warnings { + warnings.push(warning(index as u64 + 1, &"x".repeat(max_length))); + let estimated = warn(&warnings, true, false); + let applied = warn(&warnings, true, true); + assert!( + estimated.storage_fee >= applied.storage_fee, + "warning {}: estimated storage {} below applied {}", + index + 1, + estimated.storage_fee, + applied.storage_fee + ); + assert!( + estimated.total_base_fee() >= applied.total_base_fee(), + "warning {}: estimated {} below applied {}", + index + 1, + estimated.total_base_fee(), + applied.total_base_fee() + ); + } + // The fullest entry reads back whole. + assert_status( + &drive, + contract_id, + target, + &[ContractModerationList::Warnings], + warned_with(warnings), + ); + let estimated = drive + .remove_contract_warnings( + contract_id, + target, + &BlockInfo::default(), + false, + None, + platform_version, + ) + .expect("expected to estimate a clearing"); + assert!(estimated.processing_fee > 0); +} + /// The root key of the Merk at `path`/`key`, read from the tree element that points at it. fn merk_root_key(drive: &Drive, path: &[&[u8]], key: &[u8]) -> Option> { let platform_version = PlatformVersion::latest(); @@ -1020,15 +1268,22 @@ fn merk_root_key(drive: &Drive, path: &[&[u8]], key: &[u8]) -> Option> { fn should_keep_the_documents_on_top_of_the_contract_subtree_and_the_banlist_on_top_of_the_other_tree( ) { let platform_version = PlatformVersion::latest(); - // (banlist, suspensions) -> the key on top of the contract's other tree - for (banlist, suspensions, top_of_other) in [ - (false, false, CONTRACT_VERSION_KEY), - (true, false, CONTRACT_BANLIST_KEY), - (false, true, CONTRACT_SUSPENSIONS_KEY), - (true, true, CONTRACT_BANLIST_KEY), + // (banlist, suspensions, warnings) -> the key on top of the contract's other tree + for (banlist, suspensions, warnings, top_of_other) in [ + (false, false, false, CONTRACT_VERSION_KEY), + (true, false, false, CONTRACT_BANLIST_KEY), + (false, true, false, CONTRACT_SUSPENSIONS_KEY), + (true, true, false, CONTRACT_BANLIST_KEY), + (false, false, true, CONTRACT_WARNINGS_KEY), + (true, false, true, CONTRACT_BANLIST_KEY), + (false, true, true, CONTRACT_SUSPENSIONS_KEY), + // Four keys created at once root at the upper middle: the one combination where the + // banlist sits a level down. With the removal records tree (key 16) beside them it is + // on top again. + (true, true, true, CONTRACT_SUSPENSIONS_KEY), ] { let drive = setup_drive_with_initial_state_structure(Some(platform_version)); - let contract = moderated_contract(banlist, suspensions); + let contract = moderated_contract_keeping(banlist, suspensions, warnings); insert(&drive, &contract, platform_version); let contract_id = contract.id(); let contracts_root: &[u8] = Into::<&[u8; 1]>::into(RootTree::DataContractDocuments); @@ -1039,7 +1294,7 @@ fn should_keep_the_documents_on_top_of_the_contract_subtree_and_the_banlist_on_t assert_eq!( merk_root_key(&drive, &[contracts_root], contract_id.as_slice()), Some(vec![1]), - "banlist {banlist}, suspensions {suspensions}" + "banlist {banlist}, suspensions {suspensions}, warnings {warnings}" ); assert_eq!( merk_root_key( @@ -1048,7 +1303,7 @@ fn should_keep_the_documents_on_top_of_the_contract_subtree_and_the_banlist_on_t &[CONTRACT_OTHER_KEY] ), Some(vec![top_of_other]), - "banlist {banlist}, suspensions {suspensions}" + "banlist {banlist}, suspensions {suspensions}, warnings {warnings}" ); } } diff --git a/packages/rs-drive/src/drive/contract/moderation/types.rs b/packages/rs-drive/src/drive/contract/moderation/types.rs index a70578bfb8d..46eb858c511 100644 --- a/packages/rs-drive/src/drive/contract/moderation/types.rs +++ b/packages/rs-drive/src/drive/contract/moderation/types.rs @@ -1,6 +1,6 @@ use dpp::data_contract::config::moderation::{ - ContractBan, ContractDocumentRemoval, ContractModerationList, ContractModerationReason, - ContractSuspension, + ContractBan, ContractDocumentRemoval, ContractDocumentRestoration, ContractModerationDocument, + ContractModerationList, ContractModerationReason, ContractSuspension, ContractWarning, }; use dpp::identity::TimestampMillis; use dpp::platform_value::Identifier; @@ -21,16 +21,22 @@ pub struct ContractModerationEntriesQuery { /// One entry of a moderation list. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ContractModerationEntry { - /// The barred identity. + /// The identity on the list. pub identity_id: Identifier, - /// The end of the suspension for a suspension list entry, `None` for a banlist entry. + /// The end of the suspension for a suspension list entry, `None` for the other lists. pub until: Option, - /// Why the moderator banned or suspended the identity. + /// Why the identity is on the list: the ban's reason, the suspension's, or for a warning + /// list entry the reason of the latest warning (which is kept once, with the warnings, on + /// the wire). pub reason: ContractModerationReason, + /// For a warning list entry, every warning the identity carries, oldest first; empty for + /// the other lists. + pub warnings: Vec, } impl ContractModerationEntry { - /// Decodes one stored entry: see [`encode_ban`] and [`encode_suspension`]. + /// Decodes one stored entry: see [`encode_ban`], [`encode_suspension`] and + /// [`encode_warnings`]. pub fn from_key_element( list: ContractModerationList, key: &[u8], @@ -48,6 +54,7 @@ impl ContractModerationEntry { identity_id, until: None, reason, + warnings: vec![], }) } ContractModerationList::Suspensions => { @@ -56,6 +63,21 @@ impl ContractModerationEntry { identity_id, until: Some(until), reason, + warnings: vec![], + }) + } + ContractModerationList::Warnings => { + let warnings = decode_warnings(value)?; + // A stored entry holds at least one warning: `decode_warnings` refuses none. + let reason = warnings + .last() + .map(|warning| warning.reason.clone()) + .unwrap_or_default(); + Ok(Self { + identity_id, + until: None, + reason, + warnings, }) } } @@ -65,6 +87,15 @@ impl ContractModerationEntry { /// The stored size of the `until` a suspension entry starts with: a u64. pub const CONTRACT_SUSPENSION_UNTIL_SIZE: usize = 8; +/// The stored size of what each warning of a warning list entry starts with: its block time +/// as a u64 and the length of its reason as a u16. +pub const CONTRACT_WARNING_FIXED_SIZE: usize = 8 + 2; + +/// The number of warnings a warning list entry is estimated to hold when it is not known: the +/// entries a write walks past, and the entry a clearing removes. An entry being written is +/// priced by its own size. +pub const ESTIMATED_CONTRACT_WARNINGS_PER_ENTRY: u32 = 2; + /// The most bytes a reason's code takes in an entry: the tag and the u16. pub const CONTRACT_MODERATION_REASON_CODE_MAX_SIZE: u32 = 3; @@ -77,13 +108,16 @@ pub const ESTIMATED_CONTRACT_MODERATION_REASON_TEXT_SIZE: u32 = 128; /// write walks past, and the entry a delete removes. An entry being written is priced by its /// own size. pub fn estimated_entry_value_size(list: ContractModerationList) -> u32 { - let until_size = match list { - ContractModerationList::Banlist => 0, - ContractModerationList::Suspensions => CONTRACT_SUSPENSION_UNTIL_SIZE as u32, - }; - until_size - + CONTRACT_MODERATION_REASON_CODE_MAX_SIZE - + ESTIMATED_CONTRACT_MODERATION_REASON_TEXT_SIZE + let reason_size = + CONTRACT_MODERATION_REASON_CODE_MAX_SIZE + ESTIMATED_CONTRACT_MODERATION_REASON_TEXT_SIZE; + match list { + ContractModerationList::Banlist => reason_size, + ContractModerationList::Suspensions => CONTRACT_SUSPENSION_UNTIL_SIZE as u32 + reason_size, + ContractModerationList::Warnings => { + ESTIMATED_CONTRACT_WARNINGS_PER_ENTRY + * (CONTRACT_WARNING_FIXED_SIZE as u32 + reason_size) + } + } } /// Which removal records of one document type to read. @@ -148,27 +182,55 @@ impl ContractDocumentRemovalEntry { } /// The stored size of what a document removal starts with: the document owner's id, the -/// moderator's id and the removal time as a u64. -pub const CONTRACT_DOCUMENT_REMOVAL_FIXED_SIZE: usize = 32 + 32 + 8; +/// moderator's id, the removal time as a u64, the hash of the removed document and the tag +/// byte that says whether a restoration follows. +pub const CONTRACT_DOCUMENT_REMOVAL_FIXED_SIZE: usize = 32 + 32 + 8 + 32 + 1; + +/// The stored size of the restoration a restored record carries after its tag: the restoring +/// moderator's id and the restoration time as a u64. +pub const CONTRACT_DOCUMENT_RESTORATION_SIZE: usize = 32 + 8; + +const NOT_RESTORED: u8 = 0; +const RESTORED: u8 = 1; /// The size a document removal record is estimated at when its value is not known: the -/// records a write walks past, sized like a list entry's typical reason. A record being -/// written is priced by its own size. +/// records a write walks past, sized like a list entry's typical reason and as if restored, +/// the larger of the two shapes. A record being written is priced by its own size. pub fn estimated_document_removal_value_size() -> u32 { - CONTRACT_DOCUMENT_REMOVAL_FIXED_SIZE as u32 + (CONTRACT_DOCUMENT_REMOVAL_FIXED_SIZE + CONTRACT_DOCUMENT_RESTORATION_SIZE) as u32 + CONTRACT_MODERATION_REASON_CODE_MAX_SIZE + ESTIMATED_CONTRACT_MODERATION_REASON_TEXT_SIZE } +/// The stored size of a document removal record. +pub fn document_removal_encoded_size(removal: &ContractDocumentRemoval) -> usize { + CONTRACT_DOCUMENT_REMOVAL_FIXED_SIZE + + if removal.restoration.is_some() { + CONTRACT_DOCUMENT_RESTORATION_SIZE + } else { + 0 + } + + reason_encoded_size(&removal.reason) +} + /// Encodes a document removal record: the document owner's id, the moderator's id, the removal -/// time as eight big-endian bytes, then the reason as in [`encode_ban`]. +/// time as eight big-endian bytes, the hash of the removed document, a tag byte (`0`: not +/// restored, `1`: restored) followed when restored by the restoring moderator's id and the +/// restoration time as eight big-endian bytes, then the reason as in [`encode_ban`]. pub fn encode_document_removal(removal: &ContractDocumentRemoval) -> Vec { - let mut value = Vec::with_capacity( - CONTRACT_DOCUMENT_REMOVAL_FIXED_SIZE + reason_encoded_size(&removal.reason), - ); + let mut value = Vec::with_capacity(document_removal_encoded_size(removal)); value.extend_from_slice(removal.document_owner_id.as_slice()); value.extend_from_slice(removal.moderator_id.as_slice()); value.extend_from_slice(&removal.removed_at.to_be_bytes()); + value.extend_from_slice(&removal.document_hash); + match &removal.restoration { + None => value.push(NOT_RESTORED), + Some(restoration) => { + value.push(RESTORED); + value.extend_from_slice(restoration.moderator_id.as_slice()); + value.extend_from_slice(&restoration.restored_at.to_be_bytes()); + } + } encode_reason_into(&removal.reason, &mut value); value } @@ -184,7 +246,33 @@ pub fn decode_document_removal(value: &[u8]) -> Result().ok_or_else(cut_short)?; let (moderator_id, rest) = rest.split_first_chunk::<32>().ok_or_else(cut_short)?; - let (removed_at, reason) = rest.split_first_chunk::<8>().ok_or_else(cut_short)?; + let (removed_at, rest) = rest.split_first_chunk::<8>().ok_or_else(cut_short)?; + let (document_hash, rest) = rest.split_first_chunk::<32>().ok_or_else(cut_short)?; + let (tag, rest) = rest.split_first().ok_or_else(cut_short)?; + let (restoration, reason) = match *tag { + NOT_RESTORED => (None, rest), + RESTORED => { + let (restored_by, rest) = rest.split_first_chunk::<32>().ok_or_else(|| { + "document removal is cut short inside its restoration".to_string() + })?; + let (restored_at, reason) = rest.split_first_chunk::<8>().ok_or_else(|| { + "document removal is cut short inside its restoration".to_string() + })?; + ( + Some(ContractDocumentRestoration { + moderator_id: Identifier::from(*restored_by), + restored_at: TimestampMillis::from_be_bytes(*restored_at), + }), + reason, + ) + } + tag => { + return Err(format!( + "document removal has unknown restoration tag {}", + tag + )) + } + }; // A list entry with no reason bytes is one written before entries carried a reason. No // record was ever written without one, so here the same bytes are a record cut short. if reason.is_empty() { @@ -195,18 +283,24 @@ pub fn decode_document_removal(value: &[u8]) -> Result Vec { let mut value = Vec::with_capacity(reason_encoded_size(reason)); encode_reason_into(reason, &mut value); @@ -245,40 +339,171 @@ pub fn decode_suspension(value: &[u8]) -> Result { }) } +/// Encodes a warning list entry: for each warning, oldest first, its block time as eight +/// big-endian bytes, the length of its encoded reason as two big-endian bytes, then the reason +/// as in [`encode_ban`]. The length prefix is what lets one value hold several reasons, each +/// of which would otherwise run to the end of the value. +/// +/// A reason is at most a tag, a code and `max_contract_moderation_reason_length` bytes of +/// text, well within a u16, and a longer one never gets past basic structure validation. One +/// that does not fit the prefix is refused rather than written short: an entry whose prefix +/// undercounts its reason could never be read back. +pub fn encode_warnings(warnings: &[ContractWarning]) -> Result, String> { + let mut value = Vec::with_capacity( + warnings + .iter() + .map(|warning| CONTRACT_WARNING_FIXED_SIZE + reason_encoded_size(&warning.reason)) + .sum(), + ); + for warning in warnings { + value.extend_from_slice(&warning.warned_at.to_be_bytes()); + let reason_size = u16::try_from(reason_encoded_size(&warning.reason)).map_err(|_| { + format!( + "a warning's reason of {} bytes exceeds the {} the entry can hold", + reason_encoded_size(&warning.reason), + u16::MAX + ) + })?; + value.extend_from_slice(&reason_size.to_be_bytes()); + encode_reason_into(&warning.reason, &mut value); + } + Ok(value) +} + +/// Decodes a warning list entry. An entry holds at least one warning: one that holds none was +/// never written, since clearing the last warning deletes the entry. +pub fn decode_warnings(value: &[u8]) -> Result, String> { + let mut warnings = Vec::new(); + let mut rest = value; + while !rest.is_empty() { + let cut_short = || { + format!( + "warning list entry is cut short inside warning {}", + warnings.len() + 1 + ) + }; + let (warned_at, after_time) = rest.split_first_chunk::<8>().ok_or_else(cut_short)?; + let (reason_size, after_size) = + after_time.split_first_chunk::<2>().ok_or_else(cut_short)?; + let reason_size = usize::from(u16::from_be_bytes(*reason_size)); + if after_size.len() < reason_size { + return Err(cut_short()); + } + let (reason, after_reason) = after_size.split_at(reason_size); + // A reason is always written with its tag: no warning was ever written before reasons + // existed, so an empty one is a malformed entry rather than the empty reason. + if reason.is_empty() { + return Err(format!( + "warning {} of the warning list entry holds no reason", + warnings.len() + 1 + )); + } + warnings.push(ContractWarning { + warned_at: TimestampMillis::from_be_bytes(*warned_at), + reason: decode_reason(reason)?, + }); + rest = after_reason; + } + if warnings.is_empty() { + return Err("warning list entry holds no warning".to_string()); + } + Ok(warnings) +} + fn reason_encoded_size(reason: &ContractModerationReason) -> usize { - 1 + if reason.code.is_some() { 2 } else { 0 } + reason.text.len() + let documents_size = if reason.documents.is_empty() { + 0 + } else { + 1 + reason + .documents + .iter() + .map(|document| 1 + document.document_type_name.len() + 32) + .sum::() + }; + 1 + if reason.code.is_some() { 2 } else { 0 } + documents_size + reason.text.len() } fn encode_reason_into(reason: &ContractModerationReason, value: &mut Vec) { - match reason.code { - None => value.push(REASON_WITHOUT_CODE), - Some(code) => { - value.push(REASON_WITH_CODE); - value.extend_from_slice(&code.to_be_bytes()); + let mut tag = REASON_WITHOUT_CODE; + if reason.code.is_some() { + tag |= REASON_WITH_CODE; + } + if !reason.documents.is_empty() { + tag |= REASON_WITH_DOCUMENTS; + } + value.push(tag); + if let Some(code) = reason.code { + value.extend_from_slice(&code.to_be_bytes()); + } + if !reason.documents.is_empty() { + // The count and each name fit a byte: the reason's validation bounds both, so a + // longer one never reaches a writer. + value.push(reason.documents.len().min(u8::MAX as usize) as u8); + for document in &reason.documents { + let name = document.document_type_name.as_bytes(); + value.push(name.len().min(u8::MAX as usize) as u8); + value.extend_from_slice(name); + value.extend_from_slice(document.document_id.as_slice()); } } value.extend_from_slice(reason.text.as_bytes()); } fn decode_reason(value: &[u8]) -> Result { - let (code, text) = match value.split_first() { - Some((&REASON_WITHOUT_CODE, text)) => (None, text), - Some((&REASON_WITH_CODE, rest)) => { - let Some((code, text)) = rest.split_first_chunk::<2>() else { - return Err("moderation reason is cut short inside its code".to_string()); + // An entry written before entries carried a reason: an empty banlist item, a bare + // `until`. It reads as the empty reason rather than as corrupted state, which would turn + // every document transition of the identity, and its unban, into an internal error. + let Some((&tag, mut rest)) = value.split_first() else { + return Ok(ContractModerationReason::default()); + }; + if tag & !(REASON_WITH_CODE | REASON_WITH_DOCUMENTS) != 0 { + return Err(format!("moderation reason has unknown tag {}", tag)); + } + let code = if tag & REASON_WITH_CODE != 0 { + let Some((code, after)) = rest.split_first_chunk::<2>() else { + return Err("moderation reason is cut short inside its code".to_string()); + }; + rest = after; + Some(u16::from_be_bytes(*code)) + } else { + None + }; + let mut documents = Vec::new(); + if tag & REASON_WITH_DOCUMENTS != 0 { + let Some((&count, after)) = rest.split_first() else { + return Err("moderation reason is cut short before its documents".to_string()); + }; + rest = after; + for index in 0..count { + let cut_short = || { + format!( + "moderation reason is cut short inside document {}", + index + 1 + ) }; - (Some(u16::from_be_bytes(*code)), text) + let (&name_length, after_length) = rest.split_first().ok_or_else(cut_short)?; + if after_length.len() < usize::from(name_length) + 32 { + return Err(cut_short()); + } + let (name, after_name) = after_length.split_at(usize::from(name_length)); + let (id, after_id) = after_name.split_first_chunk::<32>().ok_or_else(cut_short)?; + documents.push(ContractModerationDocument { + document_type_name: std::str::from_utf8(name) + .map_err(|_| "moderation reason document type name is not UTF-8".to_string())? + .to_string(), + document_id: Identifier::from(*id), + }); + rest = after_id; } - Some((tag, _)) => return Err(format!("moderation reason has unknown code tag {}", tag)), - // An entry written before entries carried a reason: an empty banlist item, a bare - // `until`. It reads as the empty reason rather than as corrupted state, which would - // turn every document transition of the identity, and its unban, into an internal error. - None => return Ok(ContractModerationReason::default()), - }; - let text = std::str::from_utf8(text) + } + let text = std::str::from_utf8(rest) .map_err(|_| "moderation reason text is not UTF-8".to_string())? .to_string(); - Ok(ContractModerationReason { code, text }) + Ok(ContractModerationReason { + code, + text, + documents, + }) } #[cfg(test)] @@ -297,6 +522,7 @@ mod tests { let coded = ContractModerationReason { code: Some(0x0102), text: "spam".to_string(), + documents: vec![], }; assert_eq!(encode_ban(&coded), [&[1u8, 1, 2][..], b"spam"].concat()); assert_eq!( @@ -309,11 +535,81 @@ mod tests { assert_eq!(decode_ban(&[0]).expect("decode").reason, empty); } + #[test] + fn should_round_trip_the_documents_a_reason_cites() { + let cited = ContractModerationReason { + code: Some(0x0102), + text: "spam".to_string(), + documents: vec![ + ContractModerationDocument { + document_type_name: "post".to_string(), + document_id: Identifier::from([7; 32]), + }, + ContractModerationDocument { + document_type_name: "reply".to_string(), + document_id: Identifier::from([8; 32]), + }, + ], + }; + let value = encode_ban(&cited); + // tag with both bits, the code, the count, then each name (length, bytes) and id + assert_eq!(value[0], 3); + assert_eq!(&value[1..3], &[1, 2]); + assert_eq!(value[3], 2); + assert_eq!(&value[4..9], [&[4u8][..], b"post"].concat()); + assert_eq!(&value[9..41], &[7; 32]); + assert_eq!(&value[41..47], [&[5u8][..], b"reply"].concat()); + assert_eq!(&value[47..79], &[8; 32]); + assert_eq!(&value[79..], b"spam"); + assert_eq!(decode_ban(&value).expect("decode").reason, cited); + assert_eq!(reason_encoded_size(&cited), value.len()); + + // Without a code the tag carries the documents bit alone. + let uncoded = ContractModerationReason { + code: None, + ..cited.clone() + }; + let value = encode_ban(&uncoded); + assert_eq!(value[0], 2); + assert_eq!(decode_ban(&value).expect("decode").reason, uncoded); + // Inside a suspension and a warning the reason decodes the same. + let suspension = encode_suspension(5, &cited); + assert_eq!( + decode_suspension(&suspension).expect("decode").reason, + cited + ); + let warnings = encode_warnings(&[ContractWarning { + warned_at: 1, + reason: cited.clone(), + }]) + .expect("encode"); + assert_eq!(decode_warnings(&warnings).expect("decode")[0].reason, cited); + } + + #[test] + fn should_refuse_a_reason_cut_short_inside_its_documents() { + decode_ban(&[2]).expect_err("no count"); + decode_ban(&[2, 1]).expect_err("no name length"); + decode_ban(&[2, 1, 4, b'p', b'o']).expect_err("name cut short"); + let mut short_id = vec![2, 1, 4]; + short_id.extend_from_slice(b"post"); + short_id.extend_from_slice(&[7; 31]); + decode_ban(&short_id).expect_err("id cut short"); + decode_ban(&[4, b'x']).expect_err("unknown tag bit"); + // A count of zero under the documents bit is a reason about no document. + assert!(decode_ban(&[2, 0, b'x']) + .expect("decode") + .reason + .documents + .is_empty()); + } + #[test] fn should_round_trip_a_suspension() { let reason = ContractModerationReason { code: Some(9), text: "flooding, second time".to_string(), + documents: vec![], }; let value = encode_suspension(77, &reason); assert_eq!(&value[..8], &77u64.to_be_bytes()); @@ -323,6 +619,74 @@ mod tests { ); } + #[test] + fn should_round_trip_warnings_oldest_first() { + let first = ContractWarning { + warned_at: 1_000, + reason: ContractModerationReason::from_text("first strike"), + }; + let second = ContractWarning { + warned_at: 2_000, + reason: ContractModerationReason { + code: Some(3), + text: String::new(), + documents: vec![], + }, + }; + let value = encode_warnings(&[first.clone(), second.clone()]).expect("encode"); + // block time, length, tag + text; block time, length, tag + code + no text + assert_eq!(&value[..8], &1_000u64.to_be_bytes()); + assert_eq!(&value[8..10], &13u16.to_be_bytes()); + assert_eq!(&value[10..23], [&[0u8][..], b"first strike"].concat()); + assert_eq!(&value[23..31], &2_000u64.to_be_bytes()); + assert_eq!(&value[31..33], &3u16.to_be_bytes()); + assert_eq!(&value[33..], &[1u8, 0, 3]); + assert_eq!( + decode_warnings(&value).expect("decode"), + vec![first.clone(), second] + ); + + let id = Identifier::from([5; 32]); + let entry = ContractModerationEntry::from_key_element( + ContractModerationList::Warnings, + id.as_slice(), + &Element::new_item(value), + ) + .expect("decode"); + assert_eq!(entry.identity_id, id); + assert_eq!(entry.until, None); + // The entry's reason is the latest warning's. + assert_eq!(entry.reason.code, Some(3)); + assert_eq!(entry.warnings.len(), 2); + assert_eq!(entry.warnings[0], first); + } + + #[test] + fn should_refuse_a_malformed_warning_list_entry() { + decode_warnings(&[]).expect_err("no warning"); + decode_warnings(&[0; 9]).expect_err("cut short inside the time"); + let mut no_reason = 1_000u64.to_be_bytes().to_vec(); + no_reason.extend_from_slice(&0u16.to_be_bytes()); + decode_warnings(&no_reason).expect_err("a warning holds a reason"); + let mut short_reason = 1_000u64.to_be_bytes().to_vec(); + short_reason.extend_from_slice(&5u16.to_be_bytes()); + short_reason.push(0); + decode_warnings(&short_reason).expect_err("reason cut short"); + let mut trailing = encode_warnings(&[ContractWarning { + warned_at: 1, + reason: ContractModerationReason::default(), + }]) + .expect("encode"); + trailing.push(0); + decode_warnings(&trailing).expect_err("trailing byte"); + // A reason the length prefix can not measure is refused, not written short. + encode_warnings(&[ContractWarning { + warned_at: 1, + reason: ContractModerationReason::from_text("x".repeat(usize::from(u16::MAX))), + }]) + .expect_err("reason past a u16"); + } + #[test] fn should_round_trip_a_document_removal() { let removal = ContractDocumentRemoval { @@ -331,16 +695,45 @@ mod tests { reason: ContractModerationReason { code: Some(3), text: "spam".to_string(), + documents: vec![], }, removed_at: 1_700_000_000_123, + document_hash: [4; 32], + restoration: None, }; let value = encode_document_removal(&removal); assert_eq!(&value[..32], &[1; 32]); assert_eq!(&value[32..64], &[2; 32]); assert_eq!(&value[64..72], &1_700_000_000_123u64.to_be_bytes()); - assert_eq!(&value[72..], [&[1u8, 0, 3][..], b"spam"].concat()); + assert_eq!(&value[72..104], &[4; 32]); + assert_eq!(value[104], 0, "not restored"); + assert_eq!(&value[105..], [&[1u8, 0, 3][..], b"spam"].concat()); + assert_eq!(value.len(), document_removal_encoded_size(&removal)); assert_eq!(decode_document_removal(&value).expect("decode"), removal); + // Restored: the restoring moderator and the time follow the tag, before the reason. + let restored = ContractDocumentRemoval { + restoration: Some(ContractDocumentRestoration { + moderator_id: Identifier::from([5; 32]), + restored_at: 1_700_000_000_999, + }), + ..removal.clone() + }; + let value = encode_document_removal(&restored); + assert_eq!(value[104], 1, "restored"); + assert_eq!(&value[105..137], &[5; 32]); + assert_eq!(&value[137..145], &1_700_000_000_999u64.to_be_bytes()); + assert_eq!(&value[145..], [&[1u8, 0, 3][..], b"spam"].concat()); + assert_eq!(value.len(), document_removal_encoded_size(&restored)); + assert_eq!(decode_document_removal(&value).expect("decode"), restored); + assert!( + decode_document_removal(&value[..140]).is_err(), + "a record cut short inside its restoration is refused" + ); + let mut unknown_tag = value.clone(); + unknown_tag[104] = 2; + assert!(decode_document_removal(&unknown_tag).is_err()); + // No code and no text: what a moderator that gives no reason leaves. let bare = ContractDocumentRemoval { reason: ContractModerationReason::default(), @@ -379,7 +772,7 @@ mod tests { #[test] fn should_refuse_a_malformed_entry() { - decode_ban(&[2, b'x']).expect_err("unknown tag"); + decode_ban(&[4, b'x']).expect_err("unknown tag"); decode_ban(&[1, 0]).expect_err("code cut short"); decode_ban(&[0, 0xff, 0xfe]).expect_err("not utf-8"); decode_suspension(&[0; 7]).expect_err("until cut short"); @@ -418,6 +811,7 @@ mod tests { identity_id: id, until: None, reason: reason.clone(), + warnings: vec![], } ); let suspension = Element::new_item(encode_suspension(12, &reason)); @@ -432,6 +826,7 @@ mod tests { identity_id: id, until: Some(12), reason, + warnings: vec![], } ); } diff --git a/packages/rs-drive/src/drive/contract/other_tree.rs b/packages/rs-drive/src/drive/contract/other_tree.rs index 4bfe7712be1..a2561d5963e 100644 --- a/packages/rs-drive/src/drive/contract/other_tree.rs +++ b/packages/rs-drive/src/drive/contract/other_tree.rs @@ -16,7 +16,7 @@ use std::collections::HashMap; impl Drive { /// Adds the layer information of a contract's other tree (`[64, id, 2]`: the version item - /// and up to two moderation list trees), and of the contract's root subtree above it when + /// and up to three moderation list trees), and of the contract's root subtree above it when /// nothing in the batch described that one yet. The root subtree's entry is left alone /// when it is there: a document or contract operation of the same batch describes it /// better than this does. @@ -42,7 +42,7 @@ impl Drive { tree_type: TreeType::NormalTree, estimated_layer_count: EstimatedLevel(1, false), estimated_layer_sizes: Mix { - subtrees_size: Some((1, NoSumTrees, flags_size, 2)), + subtrees_size: Some((1, NoSumTrees, flags_size, 3)), items_size: Some((1, CONTRACT_VERSION_ITEM_SIZE as u32, flags_size, 1)), items_with_sum_item_size: None, references_size: None, diff --git a/packages/rs-drive/src/drive/contract/paths.rs b/packages/rs-drive/src/drive/contract/paths.rs index c44c599f77f..206d80ebf68 100644 --- a/packages/rs-drive/src/drive/contract/paths.rs +++ b/packages/rs-drive/src/drive/contract/paths.rs @@ -175,9 +175,11 @@ pub fn contract_keeping_history_storage_time_reference_path( /// /// Inside, the keys are spread like the root tree's, so that the tree is balanced as it fills /// and the most read entry sits on top: `128` the banlist, `64` the version item, `192` the -/// suspension list. A Merk built from one sorted batch roots at the middle key, so `128` is on -/// top whenever it is there; a key added later should be below `128` to keep it there when -/// four keys are created at once. +/// suspension list, `224` the warning list. A Merk built from one sorted batch roots at the +/// middle key (the upper middle of an even count), so `128` is on top whenever it is the +/// median of the keys created together; a key added later goes where it keeps `128` the +/// median in the most likely combinations, and what a document transition never reads may +/// sit a level down in the others. pub const CONTRACT_OTHER_KEY: u8 = 2; /// The key under a contract's other tree (`[64, id, 2]`) that holds the contract's version @@ -197,6 +199,17 @@ pub const CONTRACT_BANLIST_KEY: u8 = 128; /// suspension list. pub const CONTRACT_SUSPENSIONS_KEY: u8 = 192; +/// The key under a contract's other tree (`[64, id, 2]`) of the warning list a moderated +/// contract keeps (protocol version 14): `identity id -> Item(one or more of: warned at as a +/// u64 big-endian milliseconds, the reason's length as a u16 big-endian, the reason)`, oldest +/// warning first. Present only when the contract's config declares a warning list. A warning +/// bars nothing, so no document transition reads it, and its depth does not matter: above +/// `192`, it leaves the banlist on top of the other tree for a contract that keeps the +/// banlist and a warning list, with or without removal records and with both when it keeps +/// a suspension list too; only a contract keeping all three lists and no removal records has +/// the suspension list on top and the banlist one level down. +pub const CONTRACT_WARNINGS_KEY: u8 = 224; + /// The key under a contract's other tree (`[64, id, 2]`) of the records of the documents the /// contract's moderators deleted (protocol version 14): `document type name -> document id -> /// Item(document owner id, moderator id, removed at, reason)`. Present when the contract has a @@ -229,10 +242,12 @@ pub fn contract_moderation_list_key(list: ContractModerationList) -> &'static [u match list { ContractModerationList::Banlist => &[CONTRACT_BANLIST_KEY], ContractModerationList::Suspensions => &[CONTRACT_SUSPENSIONS_KEY], + ContractModerationList::Warnings => &[CONTRACT_WARNINGS_KEY], } } -/// `[64, contract id, 2, 128]` or `[64, contract id, 2, 192]`: the tree of one moderation list. +/// `[64, contract id, 2, 128]`, `[64, contract id, 2, 192]` or `[64, contract id, 2, 224]`: +/// the tree of one moderation list. pub fn contract_moderation_list_path( contract_id: &[u8], list: ContractModerationList, @@ -245,7 +260,8 @@ pub fn contract_moderation_list_path( ] } -/// `[64, contract id, 2, 128]` or `[64, contract id, 2, 192]`: the tree of one moderation list. +/// `[64, contract id, 2, 128]`, `[64, contract id, 2, 192]` or `[64, contract id, 2, 224]`: +/// the tree of one moderation list. pub fn contract_moderation_list_path_vec( contract_id: &[u8], list: ContractModerationList, diff --git a/packages/rs-drive/src/drive/contract/structure.rs b/packages/rs-drive/src/drive/contract/structure.rs index e1ea8bf3831..c44b368aba9 100644 --- a/packages/rs-drive/src/drive/contract/structure.rs +++ b/packages/rs-drive/src/drive/contract/structure.rs @@ -1,7 +1,7 @@ use crate::drive::contract::paths::{ CONTRACT_BANLIST_KEY, CONTRACT_DOCUMENT_REMOVALS_KEY, CONTRACT_LAST_MODERATORS_FEE_CLAIM_KEY, CONTRACT_LAST_OWNER_FEE_CLAIM_KEY, CONTRACT_OTHER_KEY, CONTRACT_SUSPENSIONS_KEY, - CONTRACT_VERSION_KEY, + CONTRACT_VERSION_KEY, CONTRACT_WARNINGS_KEY, }; use crate::drive::document::structure::document_type; use crate::drive::RootTree; @@ -260,6 +260,42 @@ pub(crate) fn structure() -> StructureNode { identity's next document transition sweeps it.", ), ), + StructureNode::fixed( + "warnings", + &[CONTRACT_WARNINGS_KEY], + "Warnings", + "CONTRACT_WARNINGS_KEY", + ) + .kind(ElementKind::Tree) + .lazy() + .describe( + "The identities warned on the contract, and why. \ + Created with the contract, and only when its config \ + declares a warning list. A warning bars nothing, so no \ + document transition reads it and its depth does not \ + matter: above 192, it leaves the banlist on top in \ + the likely combinations of lists.", + ) + .child( + StructureNode::identifier( + "identity", + "identity_id", + "The warned identity's id", + ) + .kind(ElementKind::Item) + .flags(&[FlagsKind::EpochOwned], MODERATOR_FLAGS) + .value( + "one or more warnings, oldest first, each: the block \ + time in milliseconds of the warning, u64 big endian, \ + the length of its reason, u16 big endian, then the \ + moderator's reason as in a banlist entry", + ) + .describe( + "The warnings one identity carries. Each warn rewrites \ + the item one warning longer, so it belongs to the \ + moderator that warned last; a clearing deletes it.", + ), + ), ]), ]), ) diff --git a/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v2/mod.rs b/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v2/mod.rs index 0f2f177135c..41c28cfdec3 100644 --- a/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v2/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v2/mod.rs @@ -27,6 +27,7 @@ use crate::fees::op::LowLevelDriveOperation; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::config::v0::DataContractConfigGettersV0; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; +use dpp::data_contract::document_type::is_flat_level_key; use crate::drive::document::paths::contract_document_type_path_vec; use dpp::version::PlatformVersion; @@ -91,14 +92,26 @@ impl Drive { let sub_level_index_count = index_level.sub_levels().len() as u32; if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { - // On this level we will have a 0 and all the top index paths + // On this level we will have a 0 and all the top index paths. + // Property-name keys keep the historical 32-byte estimate; a + // FLAT indexOnly level is keyed by its zero-joined component + // names, which can be wider, and every entry write rewrites + // that node at its real key length. + let sub_level_key_max_size = index_level + .sub_levels() + .keys() + .filter(|name| is_flat_level_key(name)) + .map(|name| u8::try_from(name.len()).unwrap_or(u8::MAX)) + .max() + .unwrap_or(DEFAULT_HASH_SIZE_U8) + .max(DEFAULT_HASH_SIZE_U8); estimated_costs_only_with_layer_info.insert( KeyInfoPath::from_known_owned_path(contract_document_type_path.clone()), EstimatedLayerInformation { tree_type: TreeType::NormalTree, estimated_layer_count: ApproximateElements(sub_level_index_count + 1), estimated_layer_sizes: AllSubtrees( - DEFAULT_HASH_SIZE_U8, + sub_level_key_max_size, NoSumTrees, storage_flags.map(|s| s.serialized_size()), ), @@ -108,6 +121,60 @@ impl Drive { // next we need to store a reference to the document for each index for (name, sub_level) in index_level.sub_levels() { + // A FLAT indexOnly index: no property-name tree and no value + // level. Its level tree (created at registration, like a + // property-name tree) holds the `0` member bucket directly, so + // the entry is handled by the terminal branch straight below + // the level: `[…doctype, , 0, ]`. + if is_flat_level_key(name) { + let Some(index_type) = sub_level.has_index_with_type() else { + continue; + }; + let mut flat_path: Vec> = contract_document_type_path.clone(); + flat_path.push(Vec::from(name.as_bytes())); + if let Some(estimated_costs_only_with_layer_info) = + estimated_costs_only_with_layer_info + { + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_owned_path(flat_path.clone()), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: ApproximateElements(1), + estimated_layer_sizes: AllSubtrees( + 1, + NoSumTrees, + storage_flags.map(|s| s.serialized_size()), + ), + }, + ); + } + let flat_path_info = if document_and_contract_info + .owned_document_info + .document_info + .is_document_size() + { + PathInfo::PathWithSizes(KeyInfoPath::from_known_owned_path(flat_path)) + } else { + PathInfo::PathAsVec::<0>(flat_path) + }; + self.remove_reference_for_index_level_for_contract_operations( + document_and_contract_info, + flat_path_info, + index_type, + false, + false, + &storage_flags, + previous_batch_operations, + estimated_costs_only_with_layer_info, + false, + event_id, + transaction, + batch_operations, + platform_version, + )?; + continue; + } + // The delete walker writes nothing itself, but its // estimation layers must describe the tree the insert path // actually laid down — including the meta-schema-v3 ranked diff --git a/packages/rs-drive/src/drive/document/delete/remove_reference_for_index_level_for_contract_operations/v0/mod.rs b/packages/rs-drive/src/drive/document/delete/remove_reference_for_index_level_for_contract_operations/v0/mod.rs index b057e4bb4c5..825a826c659 100644 --- a/packages/rs-drive/src/drive/document/delete/remove_reference_for_index_level_for_contract_operations/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/remove_reference_for_index_level_for_contract_operations/v0/mod.rs @@ -13,9 +13,9 @@ use std::collections::HashMap; use crate::drive::constants::CONTRACT_DOCUMENTS_PATH_HEIGHT; use crate::drive::document::document_reference_size; use crate::drive::document::index_level_tree_types::terminal_member_tree_type; -use crate::error::drive::DriveError; +use crate::drive::document::index_only::{index_only_member_key, index_only_terminal_max_key_size}; +use crate::drive::document::index_only_entry_payload_max_size; use crate::util::storage_flags::StorageFlags; -use dpp::document::document_methods::DocumentMethodsV0; use crate::drive::Drive; use crate::util::object_size_info::{DocumentAndContractInfo, DocumentInfoV0Methods, PathInfo}; @@ -65,10 +65,17 @@ impl Drive { // `add_reference_for_index_level_for_contract_operations_v0`. // `terminal` can only be `Some` on a PV14+ indexOnly contract, so // this branch is unreachable for every historical document. - if let Some(terminal_property) = index_type.terminal.as_deref() { + if let Some(terminal) = index_type.terminal.as_deref() { key_info_path.push(KnownKey(vec![0])); let member_tree_type = terminal_member_tree_type(index_type); + // The member key's estimated width follows the terminal property, + // mirroring the insert side. + let member_key_max_size = + index_only_terminal_max_key_size(document_type, terminal, platform_version)?; + // The stored item: the commitment plus the type's entry payload. + let entry_value_size = crate::drive::document::INDEX_ONLY_ROW_COMMITMENT_SIZE + + index_only_entry_payload_max_size(document_type, platform_version)?; // Sum-bearing entries (`ItemWithSumItem`) carry the i64 sum // item alongside the commitment payload; mirror the insert @@ -77,9 +84,9 @@ impl Drive { // and propagates the subtraction — same as stored-type // `ReferenceWithSumItem` entries below. let estimated_value_size = if index_type.summable.is_some() { - crate::drive::document::INDEX_ONLY_ROW_COMMITMENT_SIZE + 10 + entry_value_size + 10 } else { - crate::drive::document::INDEX_ONLY_ROW_COMMITMENT_SIZE + entry_value_size }; if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info @@ -90,7 +97,7 @@ impl Drive { tree_type: member_tree_type, estimated_layer_count: PotentiallyAtMaxElements, estimated_layer_sizes: AllItems( - DEFAULT_HASH_SIZE_U8, + member_key_max_size, estimated_value_size, storage_flags.map(|s| s.serialized_size()), ), @@ -106,23 +113,19 @@ impl Drive { .document_info .get_borrowed_document_and_storage_flags() { - Some((document, _)) => document - .get_raw_for_document_type( - terminal_property, - document_type, - document_and_contract_info.owned_document_info.owner_id, - platform_version, - )? - .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( - "indexOnly terminal value must be present on delete: the \ - delete transition carries every property and the owner", - )))?, + Some((document, _)) => index_only_member_key( + document, + document_type, + terminal, + document_and_contract_info.owned_document_info.owner_id, + platform_version, + )?, None => event_id.to_vec(), }; let delete_apply_type = Self::stateless_delete_of_non_tree_for_costs( AllItems( - DEFAULT_HASH_SIZE_U8, + member_key_max_size, estimated_value_size, storage_flags.map(|s| s.serialized_size()), ), diff --git a/packages/rs-drive/src/drive/document/delete/remove_reference_for_index_level_for_contract_operations/v1/mod.rs b/packages/rs-drive/src/drive/document/delete/remove_reference_for_index_level_for_contract_operations/v1/mod.rs index 5f5b2ea2447..86e0c38e402 100644 --- a/packages/rs-drive/src/drive/document/delete/remove_reference_for_index_level_for_contract_operations/v1/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/remove_reference_for_index_level_for_contract_operations/v1/mod.rs @@ -13,9 +13,10 @@ use std::collections::HashMap; use crate::drive::constants::CONTRACT_DOCUMENTS_PATH_HEIGHT; use crate::drive::document::document_reference_size; use crate::drive::document::index_level_tree_types::terminal_member_tree_type; +use crate::drive::document::index_only::{index_only_member_key, index_only_terminal_max_key_size}; +use crate::drive::document::index_only_entry_payload_max_size; use crate::error::drive::DriveError; use crate::util::storage_flags::StorageFlags; -use dpp::document::document_methods::DocumentMethodsV0; use crate::drive::Drive; use crate::util::object_size_info::{DocumentAndContractInfo, DocumentInfoV0Methods, PathInfo}; @@ -124,10 +125,17 @@ impl Drive { // `add_reference_for_index_level_for_contract_operations_v0`. // `terminal` can only be `Some` on a PV14+ indexOnly contract, so // this branch is unreachable for every historical document. - if let Some(terminal_property) = index_type.terminal.as_deref() { + if let Some(terminal) = index_type.terminal.as_deref() { key_info_path.push(KnownKey(vec![0])); let member_tree_type = terminal_member_tree_type(index_type); + // The member key's estimated width follows the terminal property, + // mirroring the insert side. + let member_key_max_size = + index_only_terminal_max_key_size(document_type, terminal, platform_version)?; + // The stored item: the commitment plus the type's entry payload. + let entry_value_size = crate::drive::document::INDEX_ONLY_ROW_COMMITMENT_SIZE + + index_only_entry_payload_max_size(document_type, platform_version)?; // Sum-bearing entries (`ItemWithSumItem`) carry the i64 sum // item alongside the commitment payload; mirror the insert @@ -136,9 +144,9 @@ impl Drive { // and propagates the subtraction — same as stored-type // `ReferenceWithSumItem` entries below. let estimated_value_size = if index_type.summable.is_some() { - crate::drive::document::INDEX_ONLY_ROW_COMMITMENT_SIZE + 10 + entry_value_size + 10 } else { - crate::drive::document::INDEX_ONLY_ROW_COMMITMENT_SIZE + entry_value_size }; if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info @@ -149,7 +157,7 @@ impl Drive { tree_type: member_tree_type, estimated_layer_count: PotentiallyAtMaxElements, estimated_layer_sizes: AllItems( - DEFAULT_HASH_SIZE_U8, + member_key_max_size, estimated_value_size, storage_flags.map(|s| s.serialized_size()), ), @@ -165,23 +173,19 @@ impl Drive { .document_info .get_borrowed_document_and_storage_flags() { - Some((document, _)) => document - .get_raw_for_document_type( - terminal_property, - document_type, - document_and_contract_info.owned_document_info.owner_id, - platform_version, - )? - .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( - "indexOnly terminal value must be present on delete: the \ - delete transition carries every property and the owner", - )))?, + Some((document, _)) => index_only_member_key( + document, + document_type, + terminal, + document_and_contract_info.owned_document_info.owner_id, + platform_version, + )?, None => event_id.to_vec(), }; let delete_apply_type = Self::stateless_delete_of_non_tree_for_costs( AllItems( - DEFAULT_HASH_SIZE_U8, + member_key_max_size, estimated_value_size, storage_flags.map(|s| s.serialized_size()), ), @@ -202,7 +206,12 @@ impl Drive { // keeps every tree, so a later entry costs the same as this one // did. Trees a pre-flag fallback created are kept the same way // — if-not-exists inserts make the two histories converge. - let stop_path_height = if index_type.preallocated { + // A FLAT index's entries sit at `[…doctype, , 0]`: + // the flat level tree is registration-time structure (like a + // property-name tree), so the climb stops at its `0` bucket + // exactly as it does on a preallocated index. The level info + // carries the flag from the index that terminates there. + let stop_path_height = if index_type.preallocated || index_type.flat { u16::try_from(key_info_path.len() - 1).map_err(|_| { Error::Drive(DriveError::CorruptedCodeExecution( "index path height must fit in u16", diff --git a/packages/rs-drive/src/drive/document/index_level_tree_types.rs b/packages/rs-drive/src/drive/document/index_level_tree_types.rs index 05860ddd970..2245319577f 100644 --- a/packages/rs-drive/src/drive/document/index_level_tree_types.rs +++ b/packages/rs-drive/src/drive/document/index_level_tree_types.rs @@ -339,6 +339,7 @@ mod tests { ranked_averageable: false, terminal: None, preallocated: false, + flat: false, } } diff --git a/packages/rs-drive/src/drive/document/index_only.rs b/packages/rs-drive/src/drive/document/index_only.rs index f848c91b1d5..269afe34059 100644 --- a/packages/rs-drive/src/drive/document/index_only.rs +++ b/packages/rs-drive/src/drive/document/index_only.rs @@ -22,16 +22,18 @@ use crate::drive::constants::CONTRACT_DOCUMENTS_PATH_HEIGHT; use crate::drive::document::index_level_tree_types::terminal_member_tree_type; +use crate::drive::document::index_only_item_estimated_value_size; use crate::drive::document::time_range_ttl::entry_key_bucket_start; -use crate::drive::document::INDEX_ONLY_ITEM_ESTIMATED_VALUE_SIZE; use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; use crate::util::grove_operations::{DirectQueryType, QueryTarget}; +use crate::util::type_constants::DEFAULT_HASH_SIZE_U8; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::{DocumentTypeRef, Index}; use dpp::document::document_methods::DocumentMethodsV0; +use dpp::document::property_names::OWNER_ID; use dpp::document::{Document, DocumentV0Getters}; use dpp::identifier::Identifier; use dpp::version::PlatformVersion; @@ -168,19 +170,33 @@ impl Drive { }) .collect(); } + // A flat index (no prefix properties) keeps its entries under the + // level keyed by its terminal's names, between the doctype and the + // `0` bucket. + if let Some(flat_key) = index.flat_level_key() { + for path in paths.iter_mut() { + path.push(flat_key.as_bytes().to_vec()); + } + } for path in paths.iter_mut() { path.push(vec![0]); } - let terminal = - index - .terminal - .as_deref() - .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( - "index_only_entry_paths_and_key requires an indexOnly index (terminal is \ + if index.terminal.is_none() { + return Err(Error::Drive(DriveError::CorruptedCodeExecution( + "index_only_entry_paths_and_key requires an indexOnly index (terminal is \ always Some there after parse normalization)", - )))?; - let member_key = raw_value_for(terminal)?; + ))); + } + // The member key: the terminal components' encoded values, + // concatenated, exactly as the write side keys the entry. + let member_key = index_only_member_key( + document, + document_type, + index.terminal_components(), + owner_id, + platform_version, + )?; Ok((paths, member_key)) } @@ -244,8 +260,11 @@ impl Drive { &platform_version.drive, )?; let matches = match element { + // The commitment is the item's first 32 bytes; an entry + // payload (the type's value slot) follows it and is covered + // by the commitment, so the prefix is what binds. Some(grovedb::Element::Item(payload, _)) => { - payload == expected_commitment.as_slice() + payload.get(..expected_commitment.len()) == Some(expected_commitment.as_slice()) } // Summable indexes store `ItemWithSumItem(commitment, // amount)`; the commitment payload plays the same binding @@ -253,7 +272,7 @@ impl Drive { // of the document's properties, so it is already covered // by the commitment. Some(grovedb::Element::ItemWithSumItem(payload, _, _)) => { - payload == expected_commitment.as_slice() + payload.get(..expected_commitment.len()) == Some(expected_commitment.as_slice()) } _ => false, }; @@ -283,6 +302,8 @@ impl Drive { drive_operations: &mut Vec, platform_version: &PlatformVersion, ) -> Result<(), Error> { + let estimated_item_value_size = + index_only_item_estimated_value_size(document_type, platform_version)?; for index in document_type.indexes().values() { let (paths, member_key) = Self::index_only_entry_paths_and_key( contract_id, @@ -295,6 +316,16 @@ impl Drive { // keys the paths are built from, so the claimed tree type is // the one the walkers created the `0` member bucket with. let mut level = document_type.index_structure(); + // A flat index terminates at its own level, directly under + // the root. + if let Some(flat_key) = index.flat_level_key() { + level = level.sub_levels().get(&flat_key).ok_or(Error::Drive( + DriveError::CorruptedCodeExecution( + "a flat indexOnly index resolves to its flat level: the structure is \ + built from the same indexes", + ), + ))?; + } for (position, property) in index.properties.iter().enumerate() { let level_key = index.level_key(position, &property.name); level = level.sub_levels().get(&*level_key).ok_or(Error::Drive( @@ -311,9 +342,9 @@ impl Drive { ))?; let in_tree_type = terminal_member_tree_type(type_info); let estimated_value_size = if type_info.summable.is_some() { - INDEX_ONLY_ITEM_ESTIMATED_VALUE_SIZE + 10 + estimated_item_value_size + 10 } else { - INDEX_ONLY_ITEM_ESTIMATED_VALUE_SIZE + estimated_item_value_size }; for path in paths { let path_refs: Vec<&[u8]> = path.iter().map(|segment| segment.as_slice()).collect(); @@ -371,3 +402,73 @@ impl Drive { Ok(false) } } + +/// The widest member key an indexOnly index's terminal can produce: the +/// sum over its components of 32 bytes for `$ownerId` and, otherwise, the +/// component property's maximum encoded size (a bounded byte array or +/// string encodes to at most its declared bound — the string bound counts +/// characters of up to four bytes — and every other indexable type +/// encodes to a fixed width). Fee estimation sizes the `0` member bucket's +/// keys by it, so the dry run upper-bounds the applied fee for a wide or +/// composite terminal exactly as it does for a 32-byte one. The contract +/// parser keeps every terminal within grovedb's 255-byte key cap, so the +/// clamp below is a guard, not a path. +pub(crate) fn index_only_terminal_max_key_size( + document_type: DocumentTypeRef, + terminal: &[String], + platform_version: &PlatformVersion, +) -> Result { + let mut total: u32 = 0; + for component in terminal { + let width: u32 = if component == OWNER_ID { + u32::from(DEFAULT_HASH_SIZE_U8) + } else { + let property = + document_type + .flattened_properties() + .get(component) + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "indexOnly terminal names a property the document type lacks: the \ + contract parser admits only $ownerId or schema properties as terminal \ + components", + )))?; + u32::from( + property + .property_type + .max_byte_size(platform_version) + .map_err(|e| Error::Protocol(Box::new(e)))? + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "indexOnly terminal component has an unbounded type: the contract \ + parser refuses arrays and objects as terminal components", + )))?, + ) + }; + total = total.saturating_add(width); + } + Ok(u8::try_from(total).unwrap_or(u8::MAX)) +} + +/// The member key `document` produces under a terminal: the components' +/// values in their tree-key encoding, concatenated in the terminal's +/// order — one component for a plain terminal, several for a composite +/// one. The write path's twin of the query side's +/// `serialize_value_for_key` concatenation. +pub(crate) fn index_only_member_key( + document: &Document, + document_type: DocumentTypeRef, + terminal: &[String], + owner_id: Option<[u8; 32]>, + platform_version: &PlatformVersion, +) -> Result, Error> { + let mut member_key = Vec::new(); + for component in terminal { + let encoded = document + .get_raw_for_document_type(component, document_type, owner_id, platform_version)? + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "indexOnly terminal value must be present: the parser requires every \ + indexOnly property (and $ownerId) to be set", + )))?; + member_key.extend(encoded); + } + Ok(member_key) +} diff --git a/packages/rs-drive/src/drive/document/index_only_entry_payload.rs b/packages/rs-drive/src/drive/document/index_only_entry_payload.rs new file mode 100644 index 00000000000..ced0310c26f --- /dev/null +++ b/packages/rs-drive/src/drive/document/index_only_entry_payload.rs @@ -0,0 +1,326 @@ +//! The **entry payload** of an indexOnly document type: the value slot. +//! +//! A document type may list top-level properties under `entryPayload`. +//! They sit in no index; instead every entry's item carries them after the +//! 32-byte row commitment, so an entry reads `Item(commitment ‖ payload)` +//! (or `ItemWithSumItem(commitment ‖ payload, amount)` under a summable +//! index). The commitment still hashes them, so the delete probes and the +//! executed-transition verifier keep comparing the first 32 bytes only, +//! and the payload is recovered by decoding the proved element. +//! +//! Layout: for every payload property in property-name order (the order +//! the parsed `BTreeSet` iterates), a big-endian `u16` length followed by +//! the value's bytes: raw bytes for byte arrays, UTF-8 for strings, and +//! tree-key encoding for other scalars. Length framing preserves empty +//! values without the tree-key encoding's empty/null sentinels. The parser +//! bounds every payload property and caps their sum, which is what lets +//! the length frame be two bytes and fee estimation size the entry value +//! by the bounds. + +use crate::drive::document::INDEX_ONLY_ITEM_ESTIMATED_VALUE_SIZE; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; +use dpp::data_contract::document_type::{DocumentPropertyType, DocumentTypeRef}; +use dpp::document::{Document, DocumentV0Getters}; +use dpp::platform_value::Value; +use dpp::version::PlatformVersion; +use std::collections::BTreeMap; + +/// The bytes of length frame each payload property carries. +pub const INDEX_ONLY_ENTRY_PAYLOAD_LENGTH_FRAME: u32 = 2; + +/// The most bytes `document_type`'s entry payload can encode to: the sum +/// of every payload property's declared bound plus its length frame. Zero +/// on a type without an entry payload. The parser guarantees every payload +/// property is bounded, so an unbounded one here is a corrupted type. +pub fn index_only_entry_payload_max_size( + document_type: DocumentTypeRef, + platform_version: &PlatformVersion, +) -> Result { + let mut total: u32 = 0; + for property_name in document_type.entry_payload() { + let property = document_type + .properties() + .get(property_name) + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "an entryPayload property must be a top-level property of its document type: \ + the contract parser enforces it", + )))?; + let max_width = property + .property_type + .max_byte_size(platform_version) + .map_err(|e| Error::Protocol(Box::new(e)))? + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "an entryPayload property must be bounded: the contract parser enforces it", + )))?; + total = total + .saturating_add(u32::from(max_width)) + .saturating_add(INDEX_ONLY_ENTRY_PAYLOAD_LENGTH_FRAME); + } + Ok(total) +} + +/// The value size fee estimation claims for one of `document_type`'s entry +/// items: the padded commitment estimate plus the payload bound. Every +/// estimating site (the entry-insert terminal, the preallocated-tree +/// estimate, the delete walkers and the delete-side probe estimate) sizes +/// the item through this one function so they cannot drift. +pub fn index_only_item_estimated_value_size( + document_type: DocumentTypeRef, + platform_version: &PlatformVersion, +) -> Result { + Ok( + INDEX_ONLY_ITEM_ESTIMATED_VALUE_SIZE.saturating_add(index_only_entry_payload_max_size( + document_type, + platform_version, + )?), + ) +} + +/// The bytes one entry payload value contributes: raw bytes for a byte +/// array, UTF-8 for a string, and the tree-key encoding's fixed +/// order-preserving widths for the numeric kinds. Unlike a key, a payload +/// value is not capped at 255 bytes — the parser bounds it by the field +/// value limit instead. Also what the row commitment hashes for a payload +/// property, so the commitment and the stored value agree byte for byte. +pub fn encode_index_only_entry_payload_value( + property_type: &DocumentPropertyType, + value: &Value, +) -> Result, Error> { + // Payload lengths already distinguish empty values; the tree-key + // string sentinel would conflate "" and "\0" in both the stored + // payload and its row commitment. + if let (DocumentPropertyType::String(_), Value::Text(text)) = (property_type, value) { + return Ok(text.as_bytes().to_vec()); + } + property_type + .encode_value_for_tree_keys(value) + .map_err(|e| Error::Protocol(Box::new(e))) +} + +/// Encode `document`'s entry payload — every `entryPayload` property in +/// name order, length-framed in its payload encoding. Empty on a type +/// without an entry payload. A missing payload property is a corrupted +/// document: the parser requires every payload property. +pub fn encode_index_only_entry_payload( + document: &Document, + document_type: DocumentTypeRef, +) -> Result, Error> { + let mut payload = Vec::new(); + for property_name in document_type.entry_payload() { + let property = document_type + .properties() + .get(property_name) + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "an entryPayload property must be a top-level property of its document type", + )))?; + let value = document + .properties() + .get(property_name) + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "an indexOnly document must carry every entryPayload property: the parser \ + requires them", + )))?; + let encoded = encode_index_only_entry_payload_value(&property.property_type, value)?; + let length = u16::try_from(encoded.len()).map_err(|_| { + Error::Drive(DriveError::CorruptedCodeExecution( + "an entryPayload value exceeds its bound: schema validation admits no such \ + document", + )) + })?; + payload.extend_from_slice(&length.to_be_bytes()); + payload.extend_from_slice(&encoded); + } + Ok(payload) +} + +/// Decode an entry item's payload (the bytes after the 32-byte row +/// commitment) back into `document_type`'s entry payload properties. Fails +/// closed on any framing mismatch: a truncated, overlong or misframed +/// payload is a corrupted entry, never a partial document. +pub fn decode_index_only_entry_payload( + document_type: DocumentTypeRef, + payload: &[u8], +) -> Result, Error> { + let corrupted = + |message: &'static str| Error::Drive(DriveError::CorruptedCodeExecution(message)); + let mut properties = BTreeMap::new(); + let mut cursor = 0usize; + for property_name in document_type.entry_payload() { + let property = document_type + .properties() + .get(property_name) + .ok_or(corrupted( + "an entryPayload property must be a top-level property of its document type", + ))?; + let frame = payload + .get(cursor..cursor + INDEX_ONLY_ENTRY_PAYLOAD_LENGTH_FRAME as usize) + .ok_or(corrupted( + "indexOnly entry payload is truncated before a property's length frame", + ))?; + let length = usize::from(u16::from_be_bytes([frame[0], frame[1]])); + cursor += INDEX_ONLY_ENTRY_PAYLOAD_LENGTH_FRAME as usize; + let encoded = payload.get(cursor..cursor + length).ok_or(corrupted( + "indexOnly entry payload is truncated inside a property", + ))?; + cursor += length; + let value = match &property.property_type { + // These are required values, so an empty frame is an empty + // byte array or string, never the tree-key null sentinel. + DocumentPropertyType::ByteArray(_) => Value::Bytes(encoded.to_vec()), + DocumentPropertyType::String(_) => Value::Text( + String::from_utf8(encoded.to_vec()) + .map_err(|_| corrupted("indexOnly entry payload contains invalid UTF-8"))?, + ), + // Every other payload type encodes to a fixed width, so an + // empty frame is a corrupted entry; the tree-key decoder would + // read it as the null sentinel instead. + _ if encoded.is_empty() => { + return Err(corrupted( + "indexOnly entry payload carries an empty frame for a fixed-width property", + )); + } + property_type => property_type + .decode_value_for_tree_keys(encoded) + .map_err(|e| Error::Protocol(Box::new(e)))?, + }; + properties.insert(property_name.clone(), value); + } + if cursor != payload.len() { + return Err(corrupted( + "indexOnly entry payload carries bytes past its last property", + )); + } + Ok(properties) +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::data_contract::config::DataContractConfig; + use dpp::data_contract::document_type::random_document::CreateRandomDocument; + use dpp::data_contract::document_type::DocumentType; + use dpp::platform_value::platform_value; + use dpp::platform_value::Identifier; + use std::collections::BTreeMap; + + /// An indexOnly type whose value slot holds a fixed-width integer and + /// a bounded string, in that (name) order. + fn payload_type() -> DocumentType { + let platform_version = PlatformVersion::latest(); + let config = DataContractConfig::default_for_version(platform_version).expect("config"); + let schema = platform_value!({ + "type": "object", + "indexOnly": true, + "documentsMutable": false, + "canBeDeleted": true, + "indices": [{ "name": "byOwner", "terminal": "$ownerId" }], + "entryPayload": ["count", "text"], + "properties": { + "count": { "type": "integer", "position": 0 }, + "text": { "type": "string", "maxLength": 8, "position": 1 } + }, + "required": ["count", "text"], + "additionalProperties": false + }); + DocumentType::try_from_schema( + Identifier::random(), + 1, + config.version(), + "entry", + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("the payload type parses") + } + + fn frame(bytes: &[u8]) -> Vec { + let mut framed = (bytes.len() as u16).to_be_bytes().to_vec(); + framed.extend_from_slice(bytes); + framed + } + + fn count_frame(value: i64) -> Vec { + frame( + &DocumentPropertyType::I64 + .encode_value_for_tree_keys(&Value::I64(value)) + .expect("i64 key"), + ) + } + + fn decode_error(payload: &[u8]) -> String { + decode_index_only_entry_payload(payload_type().as_ref(), payload) + .expect_err("a malformed payload must be refused") + .to_string() + } + + #[test] + fn should_round_trip_a_documents_payload() { + let document_type = payload_type(); + let platform_version = PlatformVersion::latest(); + let document = document_type + .random_document(Some(7), platform_version) + .expect("random document"); + let encoded = encode_index_only_entry_payload(&document, document_type.as_ref()) + .expect("payload encodes"); + let decoded = decode_index_only_entry_payload(document_type.as_ref(), &encoded) + .expect("payload decodes"); + for name in ["count", "text"] { + assert_eq!(decoded.get(name), document.properties().get(name), "{name}"); + } + assert_eq!(decoded.len(), 2); + } + + #[test] + fn should_decode_a_well_framed_payload() { + let mut payload = count_frame(-5); + payload.extend(frame(b"abc")); + let decoded = + decode_index_only_entry_payload(payload_type().as_ref(), &payload).expect("decodes"); + assert_eq!(decoded.get("count"), Some(&Value::I64(-5))); + assert_eq!(decoded.get("text"), Some(&Value::Text("abc".to_string()))); + } + + #[test] + fn should_refuse_a_truncated_length_frame() { + let mut payload = count_frame(1); + payload.push(0); + assert!(decode_error(&payload).contains("truncated before a property's length frame")); + } + + #[test] + fn should_refuse_a_frame_longer_than_the_payload() { + let mut payload = count_frame(1); + payload.extend(frame(b"abc")); + payload.pop(); + assert!(decode_error(&payload).contains("truncated inside a property")); + } + + #[test] + fn should_refuse_bytes_past_the_last_property() { + let mut payload = count_frame(1); + payload.extend(frame(b"abc")); + payload.push(0); + assert!(decode_error(&payload).contains("past its last property")); + } + + #[test] + fn should_refuse_invalid_utf8_in_a_string_payload() { + let mut payload = count_frame(1); + payload.extend(frame(&[0xFF, 0xFE])); + assert!(decode_error(&payload).contains("invalid UTF-8")); + } + + #[test] + fn should_refuse_an_empty_frame_for_a_fixed_width_property() { + let mut payload = frame(&[]); + payload.extend(frame(b"abc")); + assert!(decode_error(&payload).contains("empty frame for a fixed-width property")); + } +} diff --git a/packages/rs-drive/src/drive/document/index_only_row_commitment.rs b/packages/rs-drive/src/drive/document/index_only_row_commitment.rs index b302e2860dd..0bbe0cec0d2 100644 --- a/packages/rs-drive/src/drive/document/index_only_row_commitment.rs +++ b/packages/rs-drive/src/drive/document/index_only_row_commitment.rs @@ -2,12 +2,14 @@ //! item stores, binding the independently stored index projections of one //! document back into one logical row. +#[cfg(any(feature = "server", feature = "verify"))] +use crate::drive::document::encode_index_only_entry_payload_value; #[cfg(any(feature = "server", feature = "verify"))] use crate::error::drive::DriveError; #[cfg(any(feature = "server", feature = "verify"))] use crate::error::Error; #[cfg(any(feature = "server", feature = "verify"))] -use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; #[cfg(any(feature = "server", feature = "verify"))] use dpp::data_contract::document_type::{DocumentPropertyType, DocumentTypeRef}; #[cfg(any(feature = "server", feature = "verify"))] @@ -99,13 +101,37 @@ pub fn index_only_row_commitment_with_preimage_size( property_names.sort(); for property_name in property_names { - let Some(raw) = document.get_raw_for_document_type( - property_name, - document_type, - owner_id, - platform_version, - )? - else { + // An entry payload property is a value, not a key: it is committed + // through the payload encoding, which carries no 255-byte key cap + // (the parser bounds it by the field value limit instead). + let raw = if document_type + .entry_payload() + .contains(property_name.as_str()) + { + match document.properties().get(property_name.as_str()) { + Some(value) => { + let property = document_type + .flattened_properties() + .get(property_name) + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "an entryPayload property must be a property of its document type", + )))?; + Some(encode_index_only_entry_payload_value( + &property.property_type, + value, + )?) + } + None => None, + } + } else { + document.get_raw_for_document_type( + property_name, + document_type, + owner_id, + platform_version, + )? + }; + let Some(raw) = raw else { if document_type .required_fields() .contains(property_name.as_str()) diff --git a/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs b/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs index 194f4b52960..064f1dde49f 100644 --- a/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs +++ b/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs @@ -42,6 +42,7 @@ mod validate_document_replace_transition_action_uniqueness; mod validate_document_purchase_transition_action_uniqueness; mod validate_document_transfer_transition_action_uniqueness; mod validate_document_update_price_transition_action_uniqueness; +mod validate_restored_document_uniqueness; #[cfg(test)] #[cfg(feature = "server")] diff --git a/packages/rs-drive/src/drive/document/index_uniqueness/validate_restored_document_uniqueness/mod.rs b/packages/rs-drive/src/drive/document/index_uniqueness/validate_restored_document_uniqueness/mod.rs new file mode 100644 index 00000000000..b123f420620 --- /dev/null +++ b/packages/rs-drive/src/drive/document/index_uniqueness/validate_restored_document_uniqueness/mod.rs @@ -0,0 +1,48 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::data_contract::document_type::DocumentTypeRef; +use dpp::data_contract::DataContract; +use dpp::document::Document; +use dpp::validation::SimpleConsensusValidationResult; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Validates that a document a moderator restores would be unique in the state: no other + /// document of its type holds a value of one of its unique indexes. The document comes + /// back as it was, timestamps and heights included, so they are read off the document + /// rather than off the block, and its own id is not allowed as the original: the + /// document is absent while its removal record stands unrestored. + pub fn validate_restored_document_uniqueness( + &self, + contract: &DataContract, + document_type: DocumentTypeRef, + document: &Document, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive + .methods + .document + .index_uniqueness + .validate_restored_document_uniqueness + { + 0 => self.validate_restored_document_uniqueness_v0( + contract, + document_type, + document, + transaction, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "validate_restored_document_uniqueness".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/document/index_uniqueness/validate_restored_document_uniqueness/v0/mod.rs b/packages/rs-drive/src/drive/document/index_uniqueness/validate_restored_document_uniqueness/v0/mod.rs new file mode 100644 index 00000000000..0a3e12f083e --- /dev/null +++ b/packages/rs-drive/src/drive/document/index_uniqueness/validate_restored_document_uniqueness/v0/mod.rs @@ -0,0 +1,41 @@ +use crate::drive::document::index_uniqueness::internal::validate_uniqueness_of_data::UniquenessOfDataRequestV0; +use crate::drive::Drive; +use crate::error::Error; +use dpp::data_contract::document_type::DocumentTypeRef; +use dpp::data_contract::DataContract; +use dpp::document::{Document, DocumentV0Getters}; +use dpp::validation::SimpleConsensusValidationResult; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Validate that a restored document would be unique in the state + #[inline(always)] + pub(super) fn validate_restored_document_uniqueness_v0( + &self, + contract: &DataContract, + document_type: DocumentTypeRef, + document: &Document, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let request = UniquenessOfDataRequestV0 { + contract, + document_type, + owner_id: document.owner_id(), + document_id: document.id(), + allow_original: false, + created_at: document.created_at(), + updated_at: document.updated_at(), + transferred_at: document.transferred_at(), + created_at_block_height: document.created_at_block_height(), + updated_at_block_height: document.updated_at_block_height(), + transferred_at_block_height: document.transferred_at_block_height(), + created_at_core_block_height: document.created_at_core_block_height(), + updated_at_core_block_height: document.updated_at_core_block_height(), + transferred_at_core_block_height: document.transferred_at_core_block_height(), + data: document.properties(), + }; + self.validate_uniqueness_of_data(request.into(), transaction, platform_version) + } +} diff --git a/packages/rs-drive/src/drive/document/insert/add_indices_for_top_index_level_for_contract_operations/v2/mod.rs b/packages/rs-drive/src/drive/document/insert/add_indices_for_top_index_level_for_contract_operations/v2/mod.rs index c04227e2ef9..465b5513c4f 100644 --- a/packages/rs-drive/src/drive/document/insert/add_indices_for_top_index_level_for_contract_operations/v2/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/add_indices_for_top_index_level_for_contract_operations/v2/mod.rs @@ -14,6 +14,7 @@ use crate::fees::op::LowLevelDriveOperation; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::config::v0::DataContractConfigGettersV0; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; +use dpp::data_contract::document_type::is_flat_level_key; use dpp::version::PlatformVersion; @@ -94,14 +95,26 @@ impl Drive { let sub_level_index_count = index_level.sub_levels().len() as u32; if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { - // On this level we will have a 0 and all the top index paths + // On this level we will have a 0 and all the top index paths. + // Property-name keys keep the historical 32-byte estimate; a + // FLAT indexOnly level is keyed by its zero-joined component + // names, which can be wider, and every entry write rewrites + // that node at its real key length. + let sub_level_key_max_size = index_level + .sub_levels() + .keys() + .filter(|name| is_flat_level_key(name)) + .map(|name| u8::try_from(name.len()).unwrap_or(u8::MAX)) + .max() + .unwrap_or(DEFAULT_HASH_SIZE_U8) + .max(DEFAULT_HASH_SIZE_U8); estimated_costs_only_with_layer_info.insert( KeyInfoPath::from_known_owned_path(contract_document_type_path.clone()), EstimatedLayerInformation { tree_type: TreeType::NormalTree, estimated_layer_count: ApproximateElements(sub_level_index_count + 1), estimated_layer_sizes: AllSubtrees( - DEFAULT_HASH_SIZE_U8, + sub_level_key_max_size, NoSumTrees, storage_flags.map(|s| s.serialized_size()), ), @@ -111,6 +124,58 @@ impl Drive { // next we need to store a reference to the document for each index for (name, sub_level) in index_level.sub_levels() { + // A FLAT indexOnly index: no property-name tree and no value + // level. Its level tree (created at registration, like a + // property-name tree) holds the `0` member bucket directly, so + // the entry is handled by the terminal branch straight below + // the level: `[…doctype, , 0, ]`. + if is_flat_level_key(name) { + let Some(index_type) = sub_level.has_index_with_type() else { + continue; + }; + let mut flat_path: Vec> = contract_document_type_path.clone(); + flat_path.push(Vec::from(name.as_bytes())); + if let Some(estimated_costs_only_with_layer_info) = + estimated_costs_only_with_layer_info + { + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_owned_path(flat_path.clone()), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: ApproximateElements(1), + estimated_layer_sizes: AllSubtrees( + 1, + NoSumTrees, + storage_flags.map(|s| s.serialized_size()), + ), + }, + ); + } + let flat_path_info = if document_and_contract_info + .owned_document_info + .document_info + .is_document_size() + { + PathInfo::PathWithSizes(KeyInfoPath::from_known_owned_path(flat_path)) + } else { + PathInfo::PathAsVec::<0>(flat_path) + }; + self.add_reference_for_index_level_for_contract_operations( + document_and_contract_info, + flat_path_info, + index_type, + false, + false, + previous_batch_operations, + &storage_flags, + estimated_costs_only_with_layer_info, + transaction, + batch_operations, + platform_version, + )?; + continue; + } + // The top-level property-name tree is created once, at // contract registration — this walker never writes it, so it // has no use for `tree_types.ranked_axes`. The resolved type diff --git a/packages/rs-drive/src/drive/document/insert/add_preallocated_index_tree_operations/mod.rs b/packages/rs-drive/src/drive/document/insert/add_preallocated_index_tree_operations/mod.rs index 086bd8255bb..50e75f259e7 100644 --- a/packages/rs-drive/src/drive/document/insert/add_preallocated_index_tree_operations/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/add_preallocated_index_tree_operations/mod.rs @@ -41,9 +41,10 @@ use crate::drive::document::index_level_tree_types::{ index_level_tree_types_with_continuation_demotion, terminal_member_tree_type, terminal_value_tree_type, }; +use crate::drive::document::index_only::index_only_terminal_max_key_size; +use crate::drive::document::index_only_item_estimated_value_size; use crate::drive::document::paths::contract_document_type_path_vec; use crate::drive::document::unique_event_id; -use crate::drive::document::INDEX_ONLY_ITEM_ESTIMATED_VALUE_SIZE; use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::fee::FeeError; @@ -66,6 +67,9 @@ use grovedb::EstimatedSumTrees::NoSumTrees; use grovedb::{EstimatedLayerInformation, TransactionArg, TreeType}; use std::collections::HashMap; +#[cfg(test)] +mod tests; + impl Drive { /// For every preallocated index (on any indexOnly document type of the /// contract) whose binding targets the document type being inserted, @@ -467,10 +471,21 @@ impl Drive { // Same per-entry padding (and sum-item worst case) the // entry-insert terminal claims for this layer — see // `add_index_only_terminal_item_operations`. + let referring_type = contract + .document_type_for_name(referring_type_name) + .map_err(|e| Error::Protocol(Box::new(dpp::ProtocolError::DataContractError(e))))?; + let estimated_item_value_size = + index_only_item_estimated_value_size(referring_type, platform_version)?; + let member_key_max_size = match level_info.terminal.as_deref() { + Some(terminal) => { + index_only_terminal_max_key_size(referring_type, terminal, platform_version)? + } + None => DEFAULT_HASH_SIZE_U8, + }; let estimated_value_size = if level_info.summable.is_some() { - INDEX_ONLY_ITEM_ESTIMATED_VALUE_SIZE + 10 + estimated_item_value_size + 10 } else { - INDEX_ONLY_ITEM_ESTIMATED_VALUE_SIZE + estimated_item_value_size }; estimated_costs_only_with_layer_info.insert( path_info.convert_to_key_info_path(), @@ -478,7 +493,7 @@ impl Drive { tree_type: member_tree_type, estimated_layer_count: PotentiallyAtMaxElements, estimated_layer_sizes: AllItems( - DEFAULT_HASH_SIZE_U8, + member_key_max_size, estimated_value_size, storage_flags.map(|s| s.serialized_size()), ), diff --git a/packages/rs-drive/src/drive/document/insert/add_preallocated_index_tree_operations/tests.rs b/packages/rs-drive/src/drive/document/insert/add_preallocated_index_tree_operations/tests.rs new file mode 100644 index 00000000000..5b8cfd589f6 --- /dev/null +++ b/packages/rs-drive/src/drive/document/insert/add_preallocated_index_tree_operations/tests.rs @@ -0,0 +1,73 @@ +use super::*; +use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; +use crate::util::object_size_info::OwnedDocumentInfo; +use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; +use dpp::data_contract::document_type::random_document::CreateRandomDocument; +use dpp::document::DocumentV0Getters; +use dpp::prelude::DataContract; +use dpp::tests::json_document::json_document_to_json_value; +use serde_json::json; + +#[test] +fn should_estimate_composite_terminal_width_in_preallocated_member_layers() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(None); + let mut schema = json_document_to_json_value( + "tests/supporting_files/contract/yappr-likes/yappr-likes-preallocated-contract.json", + ) + .expect("read contract fixture"); + let like = &mut schema["documentSchemas"]["like"]; + like["properties"]["nonce"] = json!({ + "type": "array", "byteArray": true, "minItems": 33, "maxItems": 33, "position": 2 + }); + like["required"] + .as_array_mut() + .expect("required fields") + .push(json!("nonce")); + for index in like["indices"].as_array_mut().expect("indices") { + if index["preallocated"] == true { + index["terminal"] = json!(["nonce", "$ownerId"]); + } + } + let contract = DataContract::try_from_platform_versioned( + serde_json::from_value(schema).expect("contract serialization format"), + false, + &mut vec![], + platform_version, + ) + .expect("parse composite-terminal contract"); + let post_type = contract.document_type_for_name("post").expect("post type"); + let post = post_type + .random_document(Some(1), platform_version) + .expect("post"); + let mut layers = Some(HashMap::new()); + drive + .add_preallocated_index_tree_operations_for_referring_types( + &DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo((&post, None)), + owner_id: None, + }, + contract: &contract, + document_type: post_type, + }, + &mut None, + &mut layers, + None, + &mut vec![], + platform_version, + ) + .expect("estimate preallocation"); + + let mut member_path = contract_document_type_path_vec(contract.id_ref().as_bytes(), "like"); + member_path.extend([b"postId".to_vec(), post.id().to_vec(), vec![0]]); + let layers = layers.expect("estimated layers"); + let member_layer = layers + .get(&KeyInfoPath::from_known_owned_path(member_path)) + .expect("byPost member layer must be estimated"); + // A 33-byte nonce followed by the 32-byte owner identifier keys this bucket. + assert!( + matches!(member_layer.estimated_layer_sizes, AllItems(65, _, _)), + "the estimate must cover every terminal component: {member_layer:?}", + ); +} diff --git a/packages/rs-drive/src/drive/document/insert/add_reference_for_index_level_for_contract_operations/v0/mod.rs b/packages/rs-drive/src/drive/document/insert/add_reference_for_index_level_for_contract_operations/v0/mod.rs index 823088c98ee..e835bffb67f 100644 --- a/packages/rs-drive/src/drive/document/insert/add_reference_for_index_level_for_contract_operations/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/add_reference_for_index_level_for_contract_operations/v0/mod.rs @@ -1,10 +1,13 @@ use crate::drive::constants::STORAGE_FLAGS_SIZE; use crate::drive::document::index_level_tree_types::terminal_member_tree_type; -use crate::drive::document::INDEX_ONLY_ITEM_ESTIMATED_VALUE_SIZE; +use crate::drive::document::index_only::{index_only_member_key, index_only_terminal_max_key_size}; use crate::drive::document::{ document_reference_size, make_document_reference, make_document_reference_with_sum_item, read_document_sum_contribution, }; +use crate::drive::document::{ + encode_index_only_entry_payload, index_only_item_estimated_value_size, +}; use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; @@ -24,7 +27,6 @@ use crate::util::storage_flags::StorageFlags; use crate::util::type_constants::DEFAULT_HASH_SIZE_U8; use dpp::data_contract::document_type::methods::DocumentTypeBasicMethods; use dpp::data_contract::document_type::IndexLevelTypeInfo; -use dpp::document::document_methods::DocumentMethodsV0; use dpp::document::Document; use dpp::document::DocumentV0Getters; use dpp::version::PlatformVersion; @@ -69,12 +71,12 @@ impl Drive { // below meta-schema v3), so this branch is unreachable for every // historical document — the same in-place gating the count and sum // flags in this function already rely on. - if let Some(terminal_property) = index_type.terminal.as_deref() { + if let Some(terminal) = index_type.terminal.as_deref() { return self.add_index_only_terminal_item_operations( document_and_contract_info, index_path_info, index_type, - terminal_property, + terminal, previous_batch_operations, storage_flags, estimated_costs_only_with_layer_info, @@ -349,7 +351,8 @@ impl Drive { /// The indexOnly terminal: writes `[…index path, 0, ] → /// Item(commitment, flags)` — the member key is the terminal property's - /// value (`$ownerId` or a refersTo-typed identifier) sitting exactly + /// value in its tree-key encoding (`$ownerId` or any indexable schema + /// property, sized for estimation by its declared bound) sitting exactly /// where a normal non-unique index keys by document id, and the element /// payload is the row commitment because the entry IS the row. Storage /// flags ride the element flags for epoch/owner refunds, exactly as on @@ -378,7 +381,7 @@ impl Drive { document_and_contract_info: &DocumentAndContractInfo, mut index_path_info: PathInfo<0>, index_type: &IndexLevelTypeInfo, - terminal_property: &str, + terminal: &[String], previous_batch_operations: &mut Option<&mut Vec>, storage_flags: &Option<&StorageFlags>, estimated_costs_only_with_layer_info: &mut Option< @@ -392,6 +395,19 @@ impl Drive { let member_tree_type = terminal_member_tree_type(index_type); let sum_property_name: Option<&str> = index_type.summable.as_deref(); + // The member key's estimated width follows the terminal property: + // 32 bytes for `$ownerId`, the declared bound otherwise. + let member_key_max_size = index_only_terminal_max_key_size( + document_and_contract_info.document_type, + terminal, + platform_version, + )?; + // The item's estimated value: the padded commitment plus the type's + // entry payload bound, when it declares one. + let estimated_item_value_size = index_only_item_estimated_value_size( + document_and_contract_info.document_type, + platform_version, + )?; // The `0` storage-marker tree, byte-identical in position to the // non-unique layout above. @@ -448,20 +464,20 @@ impl Drive { // element envelope; 10 bytes is the worst case the sum-aware space // helpers reserve. let estimated_value_size = if sum_property_name.is_some() { - INDEX_ONLY_ITEM_ESTIMATED_VALUE_SIZE + 10 + estimated_item_value_size + 10 } else { - INDEX_ONLY_ITEM_ESTIMATED_VALUE_SIZE + estimated_item_value_size }; let item_space = if sum_property_name.is_some() { Element::required_item_with_sum_item_space( - INDEX_ONLY_ITEM_ESTIMATED_VALUE_SIZE, + estimated_item_value_size, STORAGE_FLAGS_SIZE, &drive_version.grove_version, )? } else { Element::required_item_space( - INDEX_ONLY_ITEM_ESTIMATED_VALUE_SIZE, + estimated_item_value_size, STORAGE_FLAGS_SIZE, &drive_version.grove_version, )? @@ -474,7 +490,7 @@ impl Drive { tree_type: member_tree_type, estimated_layer_count: PotentiallyAtMaxElements, estimated_layer_sizes: AllItems( - DEFAULT_HASH_SIZE_U8, + member_key_max_size, estimated_value_size, storage_flags.map(|s| s.serialized_size()), ), @@ -482,27 +498,20 @@ impl Drive { ); } - // Member key: the terminal property's value — 32 bytes, since the - // parser only admits `$ownerId` or identifier-typed refersTo - // properties as terminals. + // Member key: the terminal property's value in its tree-key + // encoding — the same bytes a prefix level would key by. let member_key: Option> = match document_and_contract_info .owned_document_info .document_info .get_borrowed_document_and_storage_flags() { - Some((document, _)) => Some( - document - .get_raw_for_document_type( - terminal_property, - document_and_contract_info.document_type, - document_and_contract_info.owned_document_info.owner_id, - platform_version, - )? - .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( - "indexOnly terminal value must be present: the parser requires \ - every indexOnly property (and $ownerId) to be set", - )))?, - ), + Some((document, _)) => Some(index_only_member_key( + document, + document_and_contract_info.document_type, + terminal, + document_and_contract_info.owned_document_info.owner_id, + platform_version, + )?), // Estimation-only document info carries no values. None => None, }; @@ -514,7 +523,7 @@ impl Drive { // indexed-tree layers' documented under-count (see // `estimated_sum_trees_for_value_tree_type`). let item_value = if estimated_costs_only_with_layer_info.is_some() { - vec![0u8; INDEX_ONLY_ITEM_ESTIMATED_VALUE_SIZE as usize] + vec![0u8; estimated_item_value_size as usize] } else { let (document, _) = document_and_contract_info .owned_document_info @@ -523,12 +532,19 @@ impl Drive { .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( "indexOnly terminal insert needs a document outside estimation mode", )))?; - crate::drive::document::index_only_row_commitment( + // The commitment first, then the type's entry payload (empty + // without an `entryPayload` declaration). + let mut item_value = crate::drive::document::index_only_row_commitment( document, document_and_contract_info.document_type, platform_version, )? - .to_vec() + .to_vec(); + item_value.extend(encode_index_only_entry_payload( + document, + document_and_contract_info.document_type, + )?); + item_value }; let key_element_info = match &member_key { @@ -561,7 +577,7 @@ impl Drive { .document_type .unique_id_for_storage() .to_vec(), - max_size: DEFAULT_HASH_SIZE_U8, + max_size: member_key_max_size, }, item_space, )), diff --git a/packages/rs-drive/src/drive/document/insert_contested/add_contested_document_for_contract_operations/mod.rs b/packages/rs-drive/src/drive/document/insert_contested/add_contested_document_for_contract_operations/mod.rs index df1bb844000..10fcd209aa7 100644 --- a/packages/rs-drive/src/drive/document/insert_contested/add_contested_document_for_contract_operations/mod.rs +++ b/packages/rs-drive/src/drive/document/insert_contested/add_contested_document_for_contract_operations/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::drive::Drive; use crate::error::drive::DriveError; @@ -49,9 +50,20 @@ impl Drive { transaction, platform_version, ), + 1 => self.add_contested_document_for_contract_operations_v1( + document_and_contract_info, + contested_document_resource_vote_poll, + insert_without_check, + block_info, + also_insert_vote_poll_stored_info, + previous_batch_operations, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "add_contested_document_for_contract_operations".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/document/insert_contested/add_contested_document_for_contract_operations/v1/mod.rs b/packages/rs-drive/src/drive/document/insert_contested/add_contested_document_for_contract_operations/v1/mod.rs new file mode 100644 index 00000000000..0543952b4a1 --- /dev/null +++ b/packages/rs-drive/src/drive/document/insert_contested/add_contested_document_for_contract_operations/v1/mod.rs @@ -0,0 +1,233 @@ +use crate::drive::votes::paths::{ + vote_contested_resource_end_date_queries_at_time_tree_path_vec, + vote_end_date_queries_tree_path_vec, +}; +use crate::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfo; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::query::vote_poll_vote_state_query::{ + ContestedDocumentVotePollDriveQueryResultType, ResolvedContestedDocumentVotePollDriveQuery, +}; +use crate::util::grove_operations::BatchDeleteUpTreeApplyType; +use crate::util::object_size_info::DocumentAndContractInfo; +use dpp::block::block_info::BlockInfo; +use dpp::dashcore::Network; +use dpp::data_contract::document_type::ContestedIndexResolution; +use dpp::version::PlatformVersion; +use dpp::voting::vote_info_storage::contested_document_vote_poll_stored_info::{ + ContestedDocumentVotePollStatus, ContestedDocumentVotePollStoredInfo, + ContestedDocumentVotePollStoredInfoV0Getters, +}; +use dpp::voting::vote_polls::VotePoll; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, MaybeTree, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + /// Gathers the operations to add a contested document to a contract. + /// + /// Version 1 (protocol version 14) reads the contested index's resolution. A contest + /// resolved without locking (`MasternodeVoteNoLocking`) ends when its join window closes + /// while it has a single contender; the first additional contender moves its end date to + /// the full poll duration, opening the vote window. + #[inline(always)] + #[allow(clippy::too_many_arguments)] + pub(super) fn add_contested_document_for_contract_operations_v1( + &self, + document_and_contract_info: DocumentAndContractInfo, + contested_document_resource_vote_poll: ContestedDocumentResourceVotePollWithContractInfo, + insert_without_check: bool, + block_info: &BlockInfo, + also_insert_vote_poll_stored_info: Option, + previous_batch_operations: &mut Option<&mut Vec>, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let mut batch_operations: Vec = vec![]; + + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { + Self::add_estimation_costs_for_contested_document_tree_levels_up_to_contract( + document_and_contract_info.contract, + Some(document_and_contract_info.document_type), + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + } + + self.add_contested_document_to_primary_storage( + &document_and_contract_info, + insert_without_check, + estimated_costs_only_with_layer_info, + transaction, + &mut batch_operations, + platform_version, + )?; + + let (poll_time, join_time) = match self.config.network { + Network::Mainnet => ( + platform_version + .dpp + .voting_versions + .default_vote_poll_time_duration_mainnet_ms, + platform_version + .dpp + .validation + .voting + .allow_other_contenders_time_mainnet_ms, + ), + _ => ( + platform_version + .dpp + .voting_versions + .default_vote_poll_time_duration_test_network_ms, + platform_version + .dpp + .validation + .voting + .allow_other_contenders_time_testing_ms, + ), + }; + + let no_locking = contested_document_resource_vote_poll + .index()? + .contested_index + .as_ref() + .map(|contested| contested.resolution) + == Some(ContestedIndexResolution::MasternodeVoteNoLocking); + + let contest_already_existed = self.add_contested_indices_for_contract_operations( + &document_and_contract_info, + previous_batch_operations, + estimated_costs_only_with_layer_info, + transaction, + &mut batch_operations, + platform_version, + )?; + + let owner_id = document_and_contract_info.owned_document_info.owner_id; + + if !contest_already_existed { + if let Some(vote_poll_stored_start_info) = also_insert_vote_poll_stored_info { + let mut operations = self + .insert_stored_info_for_contested_resource_vote_poll_operations( + &contested_document_resource_vote_poll, + vote_poll_stored_start_info, + platform_version, + )?; + batch_operations.append(&mut operations); + } + + // Without locking, a contest runs only to the end of its join window until a + // second contender joins; with locking, it always runs the full poll duration + // so the masternodes may lock a single contender out + let end_date = if no_locking { + block_info.time_ms.saturating_add(join_time) + } else { + block_info.time_ms.saturating_add(poll_time) + }; + + self.add_vote_poll_end_date_query_operations( + owner_id, + VotePoll::ContestedDocumentResourceVotePoll( + contested_document_resource_vote_poll.into(), + ), + end_date, + block_info, + estimated_costs_only_with_layer_info, + previous_batch_operations, + &mut batch_operations, + transaction, + platform_version, + )?; + } else if no_locking && estimated_costs_only_with_layer_info.is_none() { + // The first additional contender opens the vote window: the end date moves from + // the end of the join window to the full poll duration. Later contenders find + // it there already. An estimation never reaches this branch, since a stateless + // insert reports every contest as new. + let existing_contenders = ResolvedContestedDocumentVotePollDriveQuery { + vote_poll: (&contested_document_resource_vote_poll).into(), + result_type: ContestedDocumentVotePollDriveQueryResultType::VoteTally, + offset: None, + limit: Some(2), + start_at: None, + allow_include_locked_and_abstaining_vote_tally: false, + } + .execute(self, transaction, &mut batch_operations, platform_version)? + .contenders + .len(); + + if existing_contenders == 1 { + let (fee_result, stored_info) = self + .fetch_contested_document_vote_poll_stored_info( + &contested_document_resource_vote_poll, + Some(&block_info.epoch), + transaction, + platform_version, + )?; + if let Some(fee_result) = fee_result { + batch_operations + .push(LowLevelDriveOperation::PreCalculatedFeeResult(fee_result)); + } + let Some(stored_info) = stored_info else { + return Err(Error::Drive(DriveError::CorruptedDriveState( + "a contest with a contender has no stored info".to_string(), + ))); + }; + let ContestedDocumentVotePollStatus::Started(start_block) = + stored_info.vote_poll_status() + else { + return Err(Error::Drive(DriveError::CorruptedDriveState( + "a contest accepting a contender has not started".to_string(), + ))); + }; + + let join_end = start_block.time_ms.saturating_add(join_time); + let vote_end = start_block.time_ms.saturating_add(poll_time); + let vote_poll = VotePoll::ContestedDocumentResourceVotePoll( + contested_document_resource_vote_poll.into(), + ); + + if join_end != vote_end { + let unique_id = vote_poll.unique_id()?; + // The join-window entry goes, and its time tree with it when it was + // the only entry at that time + self.batch_delete_up_tree_while_empty( + KeyInfoPath::from_known_owned_path( + vote_contested_resource_end_date_queries_at_time_tree_path_vec( + join_end, + ), + ), + unique_id.as_slice(), + Some(vote_end_date_queries_tree_path_vec().len() as u16), + BatchDeleteUpTreeApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + }, + transaction, + &*previous_batch_operations, + &mut batch_operations, + &platform_version.drive, + )?; + + self.add_vote_poll_end_date_query_operations( + owner_id, + vote_poll, + vote_end, + block_info, + estimated_costs_only_with_layer_info, + previous_batch_operations, + &mut batch_operations, + transaction, + platform_version, + )?; + } + } + } + + Ok(batch_operations) + } +} diff --git a/packages/rs-drive/src/drive/document/mod.rs b/packages/rs-drive/src/drive/document/mod.rs index aa574966e1f..6ad61c2253a 100644 --- a/packages/rs-drive/src/drive/document/mod.rs +++ b/packages/rs-drive/src/drive/document/mod.rs @@ -78,6 +78,17 @@ pub mod index_only; #[cfg(any(feature = "server", feature = "verify"))] pub mod index_only_row_commitment; +/// The entry payload of an indexOnly document type: the value slot after +/// the row commitment, its encoding and its fee-estimation bounds. +#[cfg(any(feature = "server", feature = "verify"))] +pub mod index_only_entry_payload; + +#[cfg(any(feature = "server", feature = "verify"))] +pub use index_only_entry_payload::{ + decode_index_only_entry_payload, encode_index_only_entry_payload, + encode_index_only_entry_payload_value, index_only_entry_payload_max_size, + index_only_item_estimated_value_size, +}; #[cfg(any(feature = "server", feature = "verify"))] pub use index_only_row_commitment::index_only_row_commitment; #[cfg(feature = "server")] diff --git a/packages/rs-drive/src/drive/document/ranked_index_tree_type.rs b/packages/rs-drive/src/drive/document/ranked_index_tree_type.rs index 6b09862ca28..a514cc63581 100644 --- a/packages/rs-drive/src/drive/document/ranked_index_tree_type.rs +++ b/packages/rs-drive/src/drive/document/ranked_index_tree_type.rs @@ -230,6 +230,7 @@ mod tests { ranked_averageable, terminal: None, preallocated: false, + flat: false, } } diff --git a/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs b/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs index 9613c4443a8..731e8376cb2 100644 --- a/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs +++ b/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs @@ -16,6 +16,8 @@ use dpp::data_contract::config::moderation::ContractModerationList; use dpp::data_contract::config::v0::DataContractConfigGettersV0; use dpp::data_contract::config::v2::DataContractConfigGettersV2; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; +use dpp::document::{Document, DocumentV0Getters}; use dpp::identifier::Identifier; use dpp::state_transition::address_credit_withdrawal_transition::accessors::AddressCreditWithdrawalTransitionAccessorsV0; use dpp::state_transition::address_funding_from_asset_lock_transition::accessors::AddressFundingFromAssetLockTransitionAccessorsV0; @@ -257,7 +259,9 @@ impl Drive { // every list the contract keeps (the banlist entry present, the suspension absent); // an unban, a suspend and an unsuspend prove the one entry they edit. // A document deletion proves the record it left: a document id is produced at most - // once, so the record is of that document and the document is gone. + // once, so the record is of that document and the document is gone. A document + // restore proves the same record, now marked restored; the document's id is inside + // the bytes the transition carries, read under the contract's document type. StateTransition::ContractUserModeration(st) => { let contract_id = st.data_contract_id(); if let Some((document_type_name, document_id)) = st.action().document() { @@ -271,6 +275,35 @@ impl Drive { ]), }, ) + } else if let Some((document_type_name, document_bytes)) = + st.action().restored_document() + { + let Some(contract_fetch_info) = self.get_contract_with_fetch_info( + contract_id.to_buffer(), + false, + None, + platform_version, + )? + else { + return Err(Error::Proof(ProofError::UnknownContract(format!( + "unknown contract with id {} in contract moderation proving", + contract_id + )))); + }; + let document_type = contract_fetch_info + .contract + .document_type_for_name(document_type_name)?; + let document = + Document::from_bytes(document_bytes, document_type, platform_version)?; + Drive::contract_document_removals_query( + contract_id.to_buffer(), + &ContractDocumentRemovalsQuery { + document_type_name: document_type_name.to_string(), + selection: ContractDocumentRemovalsSelection::DocumentIds(vec![ + document.id(), + ]), + }, + ) } else { let target_identity_id = st.target_identity_id().ok_or(Error::Drive( DriveError::CorruptedCodeExecution( @@ -291,11 +324,14 @@ impl Drive { contract_id )))); }; + // A ban removes a suspension too, so its proof covers the lists + // that bar; the warning list, which a ban leaves alone, is not + // among them. contract_fetch_info .contract .config() .moderation() - .map(|moderation| moderation.lists().collect::>()) + .map(|moderation| moderation.barring_lists().collect::>()) .unwrap_or_else(|| vec![ContractModerationList::Banlist]) } ContractUserModerationAction::Unban { .. } => { @@ -305,9 +341,14 @@ impl Drive { | ContractUserModerationAction::Unsuspend { .. } => { vec![ContractModerationList::Suspensions] } - ContractUserModerationAction::DeleteDocument { .. } => { + ContractUserModerationAction::Warn { .. } + | ContractUserModerationAction::ClearWarnings { .. } => { + vec![ContractModerationList::Warnings] + } + ContractUserModerationAction::DeleteDocument { .. } + | ContractUserModerationAction::RestoreDocument { .. } => { return Err(Error::Drive(DriveError::CorruptedCodeExecution( - "a document deletion is proved by the arm above", + "a document deletion or restore is proved by the arms above", ))) } }; diff --git a/packages/rs-drive/src/query/chained_document_query/mod.rs b/packages/rs-drive/src/query/chained_document_query/mod.rs index 21d8105c249..42080512394 100644 --- a/packages/rs-drive/src/query/chained_document_query/mod.rs +++ b/packages/rs-drive/src/query/chained_document_query/mod.rs @@ -265,7 +265,7 @@ impl<'a> DriveDocumentQuery<'a> { // The resolved index must carry the join property, so every // synthesized inner projection provably carries its value. let index = self.index_only_query_index(platform_version)?; - let index_carries_join_property = index.terminal.as_deref() == Some(join_property) + let index_carries_join_property = index.terminal_contains(join_property) || index .properties .iter() diff --git a/packages/rs-drive/src/query/composite_document_query/mod.rs b/packages/rs-drive/src/query/composite_document_query/mod.rs index ce26e8c4c25..c3ee3fabdff 100644 --- a/packages/rs-drive/src/query/composite_document_query/mod.rs +++ b/packages/rs-drive/src/query/composite_document_query/mod.rs @@ -513,7 +513,7 @@ impl<'a> DriveDocumentQuery<'a> { // carries, so the property must sit on that index. if source_is_index_only_query { let carries = |index: &dpp::data_contract::document_type::Index| { - index.terminal.as_deref() == Some(binding.source_property.as_str()) + index.terminal_contains(&binding.source_property) || index .properties .iter() @@ -656,7 +656,7 @@ impl<'a> DriveDocumentQuery<'a> { // The lookup's own field must be provable positionally: // the resolved index has to carry it. let index = shape.index_only_query_index(platform_version)?; - let carried = index.terminal.as_deref() == Some(binding.field.as_str()) + let carried = index.terminal_contains(&binding.field) || index .properties .iter() @@ -744,7 +744,9 @@ impl<'a> DriveDocumentQuery<'a> { .collect(); if sub_query.document_type.index_only() { let index = shape.index_only_query_index(platform_version)?; - let terminal_is_bound = index.terminal.as_deref() == Some(binding.field.as_str()); + // A composite terminal is bound only through every component; + // a single-component terminal through its one field. + let terminal_is_bound = index.single_terminal() == Some(binding.field.as_str()); let prefix_fixed = index .properties .iter() @@ -1381,13 +1383,14 @@ impl<'a> DriveDocumentQuery<'a> { let index = query.index_only_query_index(platform_version)?; return trios .into_iter() - .map(|(path, key, _)| { + .map(|(path, key, element)| { synthesize_index_only_document( query.contract.id(), query.document_type, index, &path, &key, + Some(&element), ) }) .collect(); diff --git a/packages/rs-drive/src/query/index_only_synthesis.rs b/packages/rs-drive/src/query/index_only_synthesis.rs index 2ad462c278b..569792b9f1a 100644 --- a/packages/rs-drive/src/query/index_only_synthesis.rs +++ b/packages/rs-drive/src/query/index_only_synthesis.rs @@ -33,13 +33,14 @@ //! Fail-closed: any arity or property-name mismatch between the trio and //! the index the query resolved is an error, never a partial document. +use crate::drive::document::{decode_index_only_entry_payload, INDEX_ONLY_ROW_COMMITMENT_SIZE}; use crate::error::drive::DriveError; use crate::error::Error; use crate::query::{index_admissible_for_skip_if_absent, DriveDocumentQuery}; use crate::verify::RootHash; use dpp::data_contract::accessors::v0::DataContractV0Getters; -use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; -use dpp::data_contract::document_type::{DocumentTypeRef, Index}; +use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; +use dpp::data_contract::document_type::{DocumentPropertyType, DocumentTypeRef, Index}; use dpp::document::{Document, DocumentV0}; use dpp::identifier::Identifier; use dpp::platform_value::btreemap_extensions::BTreeValueMapInsertionPathHelper; @@ -63,6 +64,13 @@ pub(crate) struct IndexOnlyTerminalRoute<'a> { /// `(position, clause)` of a range / `in` clause on a prefix /// property. When present the terminal clause is always an equality. pub prefix_pivot: Option<(usize, &'a crate::query::WhereClause)>, + /// COMPOSITE terminals only: the equality clauses on the terminal's + /// leading components, in component order (`terminal_clause` is `None` + /// on a composite terminal). + pub terminal_equalities: Vec<&'a crate::query::WhereClause>, + /// COMPOSITE terminals only: the one range / `in` clause on the first + /// component after the equality-bound ones, if any. + pub terminal_tail: Option<&'a crate::query::WhereClause>, } impl DriveDocumentQuery<'_> { @@ -195,27 +203,13 @@ impl DriveDocumentQuery<'_> { return Ok(None); } - let terminal = - index - .terminal - .as_deref() - .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( - "a terminal-using match implies an indexOnly index", - )))?; - let terminal_clause = self - .internal_clauses - .equal_clauses - .get(terminal) - .or(match &self.internal_clauses.range_clause { - Some(range_clause) if range_clause.field == terminal => Some(range_clause), - _ => None, - }) - .or_else(|| { - self.internal_clauses - .in_clauses - .iter() - .find(|in_clause| in_clause.field == terminal) - }); + let components = index.terminal_components(); + if components.is_empty() { + return Err(Error::Drive(DriveError::CorruptedCodeExecution( + "a terminal-using match implies an indexOnly index", + ))); + } + let is_component = |field: &str| components.iter().any(|component| component == field); let shape_error = |message: &str| { Error::Query(crate::error::query::QuerySyntaxError::Unsupported( @@ -223,6 +217,110 @@ impl DriveDocumentQuery<'_> { )) }; + // A single-component terminal carries at most one clause, on that + // component. A composite terminal binds its components in order: + // equality clauses on the leading components, then at most one + // range or `in` clause on the next one, nothing on the rest — the + // shape that lowers onto one contiguous range of member keys. + let (terminal_clause, terminal_equalities, terminal_tail) = match index.single_terminal() { + Some(terminal) => { + let terminal_clause = self + .internal_clauses + .equal_clauses + .get(terminal) + .or(match &self.internal_clauses.range_clause { + Some(range_clause) if range_clause.field == terminal => Some(range_clause), + _ => None, + }) + .or_else(|| { + self.internal_clauses + .in_clauses + .iter() + .find(|in_clause| in_clause.field == terminal) + }); + (terminal_clause, Vec::new(), None) + } + None => { + let mut equalities: Vec<&crate::query::WhereClause> = Vec::new(); + for component in components { + match self.internal_clauses.equal_clauses.get(component.as_str()) { + Some(clause) => equalities.push(clause), + None => break, + } + } + let bound = equalities.len(); + if components.iter().skip(bound).any(|component| { + self.internal_clauses + .equal_clauses + .contains_key(component.as_str()) + }) { + return Err(shape_error( + "equality clauses on a composite indexOnly terminal must bind its \ + components contiguously from the first one: a component cannot be \ + bound while an earlier one is not", + )); + } + // All terminal components share one lexicographically ordered + // member key. After ignoring equality-bound fields, ORDER BY + // must follow the remaining components without gaps, in one + // direction; reversing the key reverses every component. + let mut direction = None; + for (position, order) in self + .order_by + .values() + .filter(|order| { + is_component(&order.field) + && !self + .internal_clauses + .equal_clauses + .contains_key(&order.field) + }) + .enumerate() + { + if components.get(bound + position) != Some(&order.field) { + return Err(shape_error( + "orderBy on a composite indexOnly terminal must follow its \ + unbound components contiguously from the first one", + )); + } + if direction.is_some_and(|ascending| ascending != order.ascending) { + return Err(shape_error( + "orderBy on unbound composite indexOnly terminal components \ + must use the same direction", + )); + } + direction = Some(order.ascending); + } + let tail_candidates: Vec<&crate::query::WhereClause> = self + .internal_clauses + .range_clause + .iter() + .chain(self.internal_clauses.in_clauses.iter()) + .filter(|clause| is_component(&clause.field)) + .collect(); + let tail = match tail_candidates.as_slice() { + [] => None, + [clause] => { + if components.get(bound).map(String::as_str) != Some(clause.field.as_str()) + { + return Err(shape_error( + "a range or `in` clause on a composite indexOnly terminal must \ + sit on the first component after the equality-bound ones", + )); + } + Some(*clause) + } + _ => { + return Err(shape_error( + "a composite indexOnly terminal supports at most one range or `in` \ + clause, on the first component after the equality-bound ones", + )) + } + }; + (None, equalities, tail) + } + }; + // The one non-equality, non-terminal clause — the prefix pivot // candidate. (Field coverage is the matcher's job; PLACEMENT of // non-equality clauses is the shape rule here.) @@ -239,7 +337,7 @@ impl DriveDocumentQuery<'_> { .iter() .chain(self.internal_clauses.in_clauses.iter()) { - if clause.field == terminal { + if is_component(&clause.field) { continue; } let Some(position) = position_of(&clause.field) else { @@ -275,9 +373,11 @@ impl DriveDocumentQuery<'_> { orderBy limited to the index", )); } - if let Some(terminal_clause) = terminal_clause { - if terminal_clause.operator.is_range() && !self.order_by.contains_key(terminal) - { + let ranged: Option<&crate::query::WhereClause> = terminal_clause + .filter(|clause| clause.operator.is_range()) + .or(terminal_tail.filter(|clause| clause.operator.is_range())); + if let Some(ranged) = ranged { + if !self.order_by.contains_key(ranged.field.as_str()) { return Err(Error::Query( crate::error::query::QuerySyntaxError::MissingOrderByForRange( "a range or `in` clause on an indexOnly terminal property \ @@ -291,8 +391,10 @@ impl DriveDocumentQuery<'_> { // Mixed shape: everything above the pivot equality-bound, // everything below it unconstrained, terminal clause an // equality, pivot ordered by. - let terminal_is_equality = - self.internal_clauses.equal_clauses.contains_key(terminal); + let terminal_is_equality = match index.single_terminal() { + Some(terminal) => self.internal_clauses.equal_clauses.contains_key(terminal), + None => terminal_equalities.len() == components.len(), + }; if !terminal_is_equality { return Err(shape_error( "a range or `in` clause on an indexOnly prefix property requires \ @@ -334,6 +436,8 @@ impl DriveDocumentQuery<'_> { index, terminal_clause, prefix_pivot, + terminal_equalities, + terminal_tail, })) } @@ -359,6 +463,8 @@ impl DriveDocumentQuery<'_> { index, terminal_clause, prefix_pivot, + terminal_equalities, + terminal_tail, } = route; let direction_for = |field: &str, fallback: bool| { @@ -367,8 +473,8 @@ impl DriveDocumentQuery<'_> { .map(|order_clause| order_clause.ascending) .unwrap_or(fallback) }; - let terminal_query = match terminal_clause { - Some(terminal_clause) => { + let terminal_query = match (index.single_terminal(), terminal_clause) { + (Some(_), Some(terminal_clause)) => { let left_to_right = if terminal_clause.operator.is_range() { direction_for(terminal_clause.field.as_str(), true) } else { @@ -383,16 +489,19 @@ impl DriveDocumentQuery<'_> { } // First keyset page: no cursor clause yet — every member key // in the terminal's orderBy direction. - None => { - let terminal = index.terminal.as_deref().ok_or(Error::Drive( - DriveError::CorruptedCodeExecution( - "terminal-route selection guarantees an indexOnly index", - ), - ))?; + (Some(terminal), None) => { let mut query = grovedb::Query::new_with_direction(direction_for(terminal, true)); query.insert_all(); query } + // Composite terminal: the bound components form a key prefix, + // the tail clause (or its absence) a range under it. + (None, _) => self.composite_member_key_query( + index, + terminal_equalities, + *terminal_tail, + platform_version, + )?, }; let mut path = document_type_path; @@ -468,7 +577,13 @@ impl DriveDocumentQuery<'_> { )?); } match prefix_pivot { - None => path.push(vec![0]), + None => { + // A flat index keeps its entries under its own level. + if let Some(flat_key) = index.flat_level_key() { + path.push(flat_key.into_bytes()); + } + path.push(vec![0]); + } Some((pivot_position, _)) => { path.push(index.properties[*pivot_position].name.as_bytes().to_vec()) } @@ -480,6 +595,186 @@ impl DriveDocumentQuery<'_> { )) } + /// The member-key query of a composite terminal: the equality-bound + /// leading components serialize to a key prefix, and the tail clause + /// (a range or `in` on the next component), or its absence, becomes a + /// contiguous key range under that prefix. Every component but the + /// last is fixed width (the parser enforces it), so a bound on a + /// non-last component covers every key that continues past it: the + /// upper bound of "all keys under P" is P padded with 0xFF to the + /// 255-byte key cap, which every key with prefix P sorts at or below. + /// A bound on the LAST component addresses the key itself. + fn composite_member_key_query( + &self, + index: &Index, + terminal_equalities: &[&crate::query::WhereClause], + terminal_tail: Option<&crate::query::WhereClause>, + platform_version: &PlatformVersion, + ) -> Result { + use crate::query::WhereOperator; + use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; + + const MAX_KEY_LENGTH: usize = u8::MAX as usize; + let components = index.terminal_components(); + let bound = terminal_equalities.len(); + let mut prefix: Vec = Vec::new(); + for clause in terminal_equalities { + prefix.extend(self.document_type.serialize_value_for_key( + &clause.field, + &clause.value, + platform_version, + )?); + } + let direction_field = terminal_tail + .map(|clause| clause.field.as_str()) + .or_else(|| components.get(bound).map(String::as_str)); + let left_to_right = direction_field + .and_then(|field| self.order_by.get(field)) + .map(|order_clause| order_clause.ascending) + .unwrap_or(true); + let mut query = grovedb::Query::new_with_direction(left_to_right); + + if bound == components.len() { + query.insert_key(prefix); + return Ok(query); + } + + let pad_max = |mut key: Vec| { + key.resize(key.len().max(MAX_KEY_LENGTH), 0xFF); + key + }; + let Some(tail_component) = components.get(bound) else { + return Err(Error::Drive(DriveError::CorruptedCodeExecution( + "a composite terminal with unbound components has a next component", + ))); + }; + let tail_is_last = bound + 1 == components.len(); + let encode = |value: &Value| -> Result, Error> { + let mut key = prefix.clone(); + key.extend(self.document_type.serialize_value_for_key( + tail_component, + value, + platform_version, + )?); + Ok(key) + }; + // Inclusive upper bound of every key whose tail component equals + // `value`: the key itself on the last component, else the padded + // continuation. + let upper_inclusive = |value: &Value| -> Result, Error> { + let key = encode(value)?; + Ok(if tail_is_last { key } else { pad_max(key) }) + }; + // Exclusive lower bound of every key whose tail component exceeds + // `value`, for the range-after variants. + let lower_exclusive = upper_inclusive; + + let Some(tail) = terminal_tail else { + if prefix.is_empty() { + query.insert_all(); + } else { + query.insert_range_inclusive(prefix.clone()..=pad_max(prefix)); + } + return Ok(query); + }; + let between_bounds = |value: &Value| -> Result<(Value, Value), Error> { + match value { + Value::Array(values) if values.len() == 2 => { + Ok((values[0].clone(), values[1].clone())) + } + _ => Err(Error::Query( + crate::error::query::QuerySyntaxError::InvalidBetweenClause( + "when using between operator you must provide a tuple array of values", + ), + )), + } + }; + match tail.operator { + WhereOperator::Equal => { + // Unreachable through selection (equalities are consumed + // above); lowered as the key prefix anyway. + let key = encode(&tail.value)?; + query.insert_range_inclusive(key.clone()..=pad_max(key)); + } + WhereOperator::GreaterThan => { + query.insert_range_after_to_inclusive( + lower_exclusive(&tail.value)?..=pad_max(prefix.clone()), + ); + } + WhereOperator::GreaterThanOrEquals => { + query.insert_range_inclusive(encode(&tail.value)?..=pad_max(prefix.clone())); + } + WhereOperator::LessThan => { + if prefix.is_empty() { + query.insert_range_to(..encode(&tail.value)?); + } else { + query.insert_range(prefix.clone()..encode(&tail.value)?); + } + } + WhereOperator::LessThanOrEquals => { + if prefix.is_empty() { + query.insert_range_to_inclusive(..=upper_inclusive(&tail.value)?); + } else { + query.insert_range_inclusive(prefix.clone()..=upper_inclusive(&tail.value)?); + } + } + WhereOperator::Between => { + let (low, high) = between_bounds(&tail.value)?; + query.insert_range_inclusive(encode(&low)?..=upper_inclusive(&high)?); + } + WhereOperator::BetweenExcludeBounds => { + let (low, high) = between_bounds(&tail.value)?; + query.insert_range_after_to(lower_exclusive(&low)?..encode(&high)?); + } + WhereOperator::BetweenExcludeLeft => { + let (low, high) = between_bounds(&tail.value)?; + query.insert_range_after_to_inclusive( + lower_exclusive(&low)?..=upper_inclusive(&high)?, + ); + } + WhereOperator::BetweenExcludeRight => { + let (low, high) = between_bounds(&tail.value)?; + query.insert_range(encode(&low)?..encode(&high)?); + } + WhereOperator::In => { + let in_values = tail.in_values().into_data_with_error()??; + for value in in_values.iter() { + let key = encode(value)?; + if tail_is_last { + query.insert_key(key); + } else { + query.insert_range_inclusive(key.clone()..=pad_max(key)); + } + } + } + WhereOperator::StartsWith => { + return Err(Error::Query( + crate::error::query::QuerySyntaxError::Unsupported( + "startsWith is not supported on a composite indexOnly terminal \ + component" + .to_string(), + ), + )); + } + } + Ok(query) + } + + /// Whether a query with no clauses at all is a scan of a FLAT index + /// rather than a by-id query: an indexOnly type with a flat index has + /// somewhere for "everything" to land (its flat level, served by the + /// generic match in `index_only_route`), while a type without one has + /// no primary-key tree to walk. + pub(crate) fn index_only_flat_scan_applies(&self) -> bool { + self.internal_clauses.is_empty() + && self.start_at.is_none() + && self + .document_type + .indexes() + .values() + .any(|index| index.is_flat()) + } + /// The index an indexOnly query resolves to — the generic matcher /// when it can serve the query, else the terminal-clause route. Used /// by synthesis (which must decode trios against the same index the @@ -545,6 +840,24 @@ impl DriveDocumentQuery<'_> { // the prover and the no-proof executor fail before // building a path query the synthesis side would refuse. Self::refuse_bucketed_index_only_synthesis(index)?; + // A generic match on a FLAT index binds nothing (the + // index has no properties to bind), so it is a scan of + // every member under the flat level. + if let Some(flat_key) = index.flat_level_key() { + let mut path = document_type_path.to_vec(); + path.push(flat_key.into_bytes()); + path.push(vec![0]); + // An orderBy naming a component makes the matcher + // report the terminal as used, which sends the query + // down the terminal route instead; the generic match + // only ever sees the unordered scan. + let mut query = grovedb::Query::new_with_direction(true); + query.insert_all(); + return Ok(Some(grovedb::PathQuery::new( + path, + grovedb::SizedQuery::new(query, self.limit, self.offset), + ))); + } Ok(None) } crate::query::BestIndexOutcome::NoIndexMatches(no_index_error) => { @@ -588,14 +901,15 @@ impl DriveDocumentQuery<'_> { let index = self.index_only_query_index(platform_version)?; let documents = proved_key_values .into_iter() - .filter_map(|(path, key, element)| element.map(|_| (path, key))) - .map(|(path, key)| { + .filter_map(|(path, key, element)| element.map(|element| (path, key, element))) + .map(|(path, key, element)| { synthesize_index_only_document( self.contract.id(), self.document_type, index, &path, &key, + Some(&element), ) }) .collect::, Error>>()?; @@ -663,13 +977,14 @@ impl DriveDocumentQuery<'_> { let documents = elements .to_path_key_elements() .into_iter() - .map(|(path, key, _element)| { + .map(|(path, key, element)| { synthesize_index_only_document( self.contract.id(), self.document_type, index, &path, &key, + Some(&element), ) }) .collect::, Error>>()?; @@ -692,9 +1007,9 @@ pub fn index_only_proof_index<'a>(document_type: &'a DocumentTypeRef) -> Result< .indexes() .values() .find(|index| { - let carries_owner = index.terminal.as_deref() == Some(OWNER_ID) + let carries_owner = index.terminal_contains(OWNER_ID) || index.properties.iter().any(|p| p.name == OWNER_ID); - let carries_created_at = index.terminal.as_deref() == Some(CREATED_AT) + let carries_created_at = index.terminal_contains(CREATED_AT) || index.properties.iter().any(|p| p.name == CREATED_AT); carries_owner && !carries_created_at && !index.skip_if_absent }) @@ -766,16 +1081,23 @@ pub fn index_only_entry_path_and_key_from_values( path.push(property.name.as_bytes().to_vec()); path.push(encoded_value_for(&property.name)?); } + // A flat index keeps its entries under its own level, between the + // doctype and the `0` bucket. + if let Some(flat_key) = index.flat_level_key() { + path.push(flat_key.as_bytes().to_vec()); + } path.push(vec![0]); - let terminal = - index - .terminal - .as_deref() - .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( - "index_only_entry_path_and_key_from_values requires an indexOnly index", - )))?; - let member_key = encoded_value_for(terminal)?; + if index.terminal.is_none() { + return Err(Error::Drive(DriveError::CorruptedCodeExecution( + "index_only_entry_path_and_key_from_values requires an indexOnly index", + ))); + } + // The member key: the terminal components' encoded values, concatenated. + let mut member_key = Vec::new(); + for component in index.terminal_components() { + member_key.extend(encoded_value_for(component)?); + } Ok((path, member_key)) } @@ -809,21 +1131,30 @@ pub fn index_only_transition_entry_path_query( /// /// `path` is the grove path of the entry's parent (ending with the `0` /// storage marker); `member_key` is the entry's key (the terminal -/// property's encoded value). +/// components' encoded values, concatenated); `element` is the proved +/// entry item, required on a type with an `entryPayload` (the payload is +/// decoded off it) and ignored otherwise. pub fn synthesize_index_only_document( contract_id: Identifier, document_type: DocumentTypeRef, index: &Index, path: &[Vec], member_key: &[u8], + element: Option<&grovedb::Element>, ) -> Result { use dpp::document::property_names::{CREATED_AT, OWNER_ID}; let corrupted = |message: &'static str| Error::Drive(DriveError::CorruptedCodeExecution(message)); - // The path must end [, , …, , , [0]]. - let expected_suffix_len = index.properties.len() * 2 + 1; + // The path must end [, , …, , , [0]] — or, + // for a flat index, [, [0]]. + let flat_key = index.flat_level_key(); + let expected_suffix_len = if flat_key.is_some() { + 2 + } else { + index.properties.len() * 2 + 1 + }; if path.len() < expected_suffix_len { return Err(corrupted( "indexOnly synthesis: proved path is shorter than the resolved index's shape", @@ -835,6 +1166,14 @@ pub fn synthesize_index_only_document( "indexOnly synthesis: proved path does not end at the 0 storage marker", )); } + if let Some(flat_key) = &flat_key { + if suffix[0].as_slice() != flat_key.as_bytes() { + return Err(corrupted( + "indexOnly synthesis: proved path does not sit under the resolved flat \ + index's level — refusing to mislabel a value", + )); + } + } let mut properties: BTreeMap = BTreeMap::new(); let mut owner_id: Option = None; @@ -849,14 +1188,9 @@ pub fn synthesize_index_only_document( ); } CREATED_AT => { - created_at = Some( - dpp::data_contract::document_type::DocumentPropertyType::decode_date_timestamp( - encoded, - ) - .ok_or(corrupted( - "indexOnly synthesis: $createdAt key bytes are not a timestamp", - ))?, - ); + created_at = Some(DocumentPropertyType::decode_date_timestamp(encoded).ok_or( + corrupted("indexOnly synthesis: $createdAt key bytes are not a timestamp"), + )?); } name => { let property = document_type @@ -865,10 +1199,20 @@ pub fn synthesize_index_only_document( .ok_or(corrupted( "indexOnly synthesis: index names a property the document type lacks", ))?; - let value = property - .property_type - .decode_value_for_tree_keys(encoded) - .map_err(|e| Error::Protocol(Box::new(e)))?; + // Every indexed property of an indexOnly type is required, + // so an empty key never encodes an absent value: for a byte + // array it is the empty array itself, which the tree-key + // decoder would otherwise read back as the null sentinel. + let value = if encoded.is_empty() + && matches!(property.property_type, DocumentPropertyType::ByteArray(_)) + { + Value::Bytes(Vec::new()) + } else { + property + .property_type + .decode_value_for_tree_keys(encoded) + .map_err(|e| Error::Protocol(Box::new(e)))? + }; // A flattened name like `profile.targetId` must come back // as a nested `profile` map, not as a dotted top-level key // — field access, schema serialization and index encoding @@ -892,11 +1236,78 @@ pub fn synthesize_index_only_document( assign(&index_property.name, &suffix[position * 2 + 1])?; } - let terminal = index.terminal.as_deref().ok_or(corrupted( - "indexOnly synthesis requires an indexOnly index (terminal is always Some \ - after parse normalization)", - ))?; - assign(terminal, member_key)?; + // The member key splits back into the terminal's components: every + // component but the last is fixed width (the parser enforces it), and + // the last takes the remainder. + let components = index.terminal_components(); + if components.is_empty() { + return Err(corrupted( + "indexOnly synthesis requires an indexOnly index (terminal is always Some \ + after parse normalization)", + )); + } + let mut terminal_parts: Vec<(&str, &[u8])> = Vec::with_capacity(components.len()); + let mut cursor = 0usize; + for (position, component) in components.iter().enumerate() { + let is_last = position + 1 == components.len(); + let bytes: &[u8] = + if is_last { + member_key.get(cursor..).ok_or(corrupted( + "indexOnly synthesis: member key is shorter than its leading components", + ))? + } else { + let width = + if component == OWNER_ID { + 32usize + } else { + let property = + document_type + .flattened_properties() + .get(component) + .ok_or(corrupted( + "indexOnly synthesis: terminal names a property the document type lacks", + ))?; + usize::from(property.property_type.fixed_tree_key_width().ok_or(corrupted( + "indexOnly synthesis: a leading terminal component must be fixed width", + ))?) + }; + let slice = member_key.get(cursor..cursor + width).ok_or(corrupted( + "indexOnly synthesis: member key is shorter than its leading components", + ))?; + cursor += width; + slice + }; + assign(component, bytes)?; + terminal_parts.push((component.as_str(), bytes)); + } + + // The entry payload — the type's value slot — rides in the item after + // the row commitment. + if !document_type.entry_payload().is_empty() { + let Some(element) = element else { + return Err(corrupted( + "indexOnly synthesis: a type with an entryPayload needs the proved element", + )); + }; + let item = match element { + grovedb::Element::Item(bytes, _) | grovedb::Element::ItemWithSumItem(bytes, _, _) => { + bytes + } + _ => { + return Err(corrupted( + "indexOnly synthesis: the proved entry is not an item element", + )) + } + }; + let payload = item + .get(INDEX_ONLY_ROW_COMMITMENT_SIZE as usize..) + .ok_or(corrupted( + "indexOnly synthesis: the proved entry item is shorter than the row commitment", + ))?; + for (name, value) in decode_index_only_entry_payload(document_type, payload)? { + properties.insert(name, value); + } + } let owner_id = owner_id.ok_or(Error::Query( crate::error::query::QuerySyntaxError::Unsupported( @@ -929,9 +1340,11 @@ pub fn synthesize_index_only_document( frame(&mut id_preimage, &suffix[position * 2 + 1]); } } - if terminal != OWNER_ID { - frame(&mut id_preimage, terminal.as_bytes()); - frame(&mut id_preimage, member_key); + for (component, bytes) in terminal_parts { + if component != OWNER_ID { + frame(&mut id_preimage, component.as_bytes()); + frame(&mut id_preimage, bytes); + } } let id = Identifier::new(hash_double(id_preimage)); diff --git a/packages/rs-drive/src/query/mod.rs b/packages/rs-drive/src/query/mod.rs index 659d768668f..1a5175a2f21 100644 --- a/packages/rs-drive/src/query/mod.rs +++ b/packages/rs-drive/src/query/mod.rs @@ -435,7 +435,7 @@ impl InternalClauses { { roles.index_property = true; } - if index.terminal.as_deref() == Some(field) { + if index.terminal_contains(field) { roles.terminal = true; } if roles.index_property && roles.terminal { @@ -1936,7 +1936,10 @@ impl<'a> DriveDocumentQuery<'a> { // addressed by document id, so a by-id query has no tree to land on. { use dpp::data_contract::document_type::accessors::DocumentTypeV2Getters; - if self.document_type.index_only() && self.is_for_primary_key() { + if self.document_type.index_only() + && self.is_for_primary_key() + && !self.index_only_flat_scan_applies() + { return Err(Error::Query(QuerySyntaxError::Unsupported( "indexOnly documents cannot be fetched by id: there is no primary-key \ tree; query through one of the type's indexes" @@ -2153,7 +2156,10 @@ impl<'a> DriveDocumentQuery<'a> { // addressed by document id, so a by-id query has no tree to land on. { use dpp::data_contract::document_type::accessors::DocumentTypeV2Getters; - if self.document_type.index_only() && self.is_for_primary_key() { + if self.document_type.index_only() + && self.is_for_primary_key() + && !self.index_only_flat_scan_applies() + { return Err(Error::Query(QuerySyntaxError::Unsupported( "indexOnly documents cannot be fetched by id: there is no primary-key \ tree; query through one of the type's indexes" @@ -2961,7 +2967,12 @@ impl<'a> DriveDocumentQuery<'a> { // the route (no primary-key tree; keyset pagination) — let // them reach it instead of preempting with the coverage // refusal below, which would misdescribe the problem. - if !self.is_for_primary_key() && self.start_at.is_none() { + // A clause-free flat scan is classified as a primary-key + // query, but still synthesizes an index projection and must + // pass the same coverage check as a filtered query. + if (!self.is_for_primary_key() || self.index_only_flat_scan_applies()) + && self.start_at.is_none() + { let index = self.index_only_query_index(platform_version)?; let covers_every_property = self .document_type @@ -2971,7 +2982,8 @@ impl<'a> DriveDocumentQuery<'a> { !matches!(property.property_type, DocumentPropertyType::Object(_)) }) .all(|(name, _)| { - index.terminal.as_deref() == Some(name.as_str()) + index.terminal_contains(name) + || self.document_type.entry_payload().contains(name.as_str()) || index .properties .iter() diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/contract/contract_user_moderation_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/contract/contract_user_moderation_transition.rs index bdb71f64abc..94b03dc3a5a 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/contract/contract_user_moderation_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/contract/contract_user_moderation_transition.rs @@ -1,8 +1,10 @@ use crate::error::drive::DriveError; use crate::error::Error; use crate::state_transition_action::action_convert_to_operations::DriveHighLevelOperationConverter; -use crate::state_transition_action::contract::contract_user_moderation::v0::ContractDocumentDeletionContext; -use crate::state_transition_action::contract::contract_user_moderation::v0::ContractUserModerationTransitionActionV0; +use crate::state_transition_action::contract::contract_user_moderation::v0::{ + ContractDocumentDeletionContext, ContractDocumentRestorationContext, + ContractUserModerationTransitionActionV0, ContractWarningContext, +}; use crate::state_transition_action::contract::contract_user_moderation::ContractUserModerationTransitionAction; use crate::util::batch::DriveOperation::{ ContractModerationOperation, DocumentOperation, IdentityOperation, @@ -10,16 +12,20 @@ use crate::util::batch::DriveOperation::{ use crate::util::batch::{ ContractModerationOperationType, DocumentOperationType, DriveOperation, IdentityOperationType, }; -use crate::util::object_size_info::{DataContractInfo, DocumentTypeInfo}; +use crate::util::object_size_info::DocumentInfo::DocumentOwnedInfo; +use crate::util::object_size_info::{DataContractInfo, DocumentTypeInfo, OwnedDocumentInfo}; +use crate::util::storage_flags::StorageFlags; use dpp::block::epoch::Epoch; -use dpp::data_contract::config::moderation::ContractDocumentRemoval; +use dpp::data_contract::config::moderation::{ContractDocumentRemoval, ContractWarning}; +use dpp::document::DocumentV0Getters; use dpp::state_transition::contract_user_moderation_transition::ContractUserModerationAction; use dpp::version::PlatformVersion; +use std::borrow::Cow; impl DriveHighLevelOperationConverter for ContractUserModerationTransitionAction { fn into_high_level_drive_operations<'a>( self, - _epoch: &Epoch, + epoch: &Epoch, platform_version: &PlatformVersion, ) -> Result>, Error> { match platform_version @@ -37,7 +43,9 @@ impl DriveHighLevelOperationConverter for ContractUserModerationTransitionAction identity_contract_nonce, action, target_is_suspended, + warning, document_deletion, + document_restoration, .. }, ) = self; @@ -105,6 +113,38 @@ impl DriveHighLevelOperationConverter for ContractUserModerationTransitionAction }, )); } + ContractUserModerationAction::Warn { + identity_id, + reason, + } => { + let ContractWarningContext { + existing_warnings, + warned_at, + } = warning.ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "a warn action must carry what its validation read", + )))?; + // The entry is rewritten whole: the warnings it held, then this one. + let replaces_existing = !existing_warnings.is_empty(); + let mut warnings = existing_warnings; + warnings.push(ContractWarning { warned_at, reason }); + operations.push(ContractModerationOperation( + ContractModerationOperationType::AddWarning { + contract_id, + identity_id, + warnings, + replaces_existing, + moderator_id, + }, + )); + } + ContractUserModerationAction::ClearWarnings { identity_id } => { + operations.push(ContractModerationOperation( + ContractModerationOperationType::RemoveWarnings { + contract_id, + identity_id, + }, + )); + } ContractUserModerationAction::DeleteDocument { document_type_name, document_id, @@ -114,6 +154,8 @@ impl DriveHighLevelOperationConverter for ContractUserModerationTransitionAction data_contract_fetch_info, document_owner_id, removed_at, + document_hash, + replaces_restored_record, } = document_deletion .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( @@ -121,9 +163,10 @@ impl DriveHighLevelOperationConverter for ContractUserModerationTransitionAction )))?; // The deletion of the document, which keeps every index and aggregate // of its type right and does not ask `canBeDeleted` (that is the - // owner's rule, not the moderators'), then its record. The marker makes - // the batch refund nobody: the document's owner forfeits the storage - // fee. + // owner's rule, not the moderators'), then its record: a fresh one, or + // in place of the restored one a document deleted before carries. The + // marker makes the batch refund nobody: the document's owner forfeits + // the storage fee. operations.push(DocumentOperation( DocumentOperationType::DeleteDocumentByModerator { document_id, @@ -145,13 +188,67 @@ impl DriveHighLevelOperationConverter for ContractUserModerationTransitionAction moderator_id, reason, removed_at, + document_hash, + restoration: None, }, + replaces_existing: replaces_restored_record, + moderator_id, }, )); operations.push(ContractModerationOperation( ContractModerationOperationType::ForfeitStorageRefunds, )); } + ContractUserModerationAction::RestoreDocument { + document_type_name, .. + } => { + let ContractDocumentRestorationContext { + data_contract_fetch_info, + document, + removal, + } = document_restoration.ok_or(Error::Drive( + DriveError::CorruptedCodeExecution( + "a document restore action must carry what its validation read", + ), + ))?; + let document_id = document.id(); + let owner_id = document.owner_id(); + // The document goes back the way a create puts it in, every index and + // aggregate of its type included. Its storage flags name its owner, as + // they did before the deletion: the moderator pays for the bytes, and + // the refund of a later deletion is the owner's, as it always was. Then + // the record, marked restored in place: two operations on one key + // would fail the batch, so it is replaced rather than deleted and + // written again, and it is never deleted. + let storage_flags = + StorageFlags::new_single_epoch(epoch.index, Some(owner_id.to_buffer())); + operations.push(DocumentOperation(DocumentOperationType::AddDocument { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentOwnedInfo(( + document, + Some(Cow::Owned(storage_flags)), + )), + owner_id: Some(owner_id.to_buffer()), + }, + contract_info: DataContractInfo::DataContractFetchInfo( + data_contract_fetch_info, + ), + document_type_info: DocumentTypeInfo::DocumentTypeName( + document_type_name.clone(), + ), + override_document: false, + })); + operations.push(ContractModerationOperation( + ContractModerationOperationType::AddDocumentRemoval { + contract_id, + document_type_name, + document_id, + removal, + replaces_existing: true, + moderator_id, + }, + )); + } } Ok(operations) @@ -182,11 +279,79 @@ mod tests { identity_contract_nonce: 4, action, target_is_suspended, + warning: None, document_deletion: None, + document_restoration: None, user_fee_increase: 0, }) } + #[test] + fn should_rewrite_the_warning_list_entry_with_the_new_warning_last() { + let platform_version = PlatformVersion::latest(); + let epoch = Epoch::new(0).expect("epoch"); + let target = Identifier::from([0xCC; 32]); + let earlier = ContractWarning { + warned_at: 5, + reason: ContractModerationReason::from_text("first strike"), + }; + + let mut warn = action( + ContractUserModerationAction::Warn { + identity_id: target, + reason: ContractModerationReason::from_text("second strike"), + }, + false, + ); + let ContractUserModerationTransitionAction::V0(v0) = &mut warn; + v0.warning = Some(ContractWarningContext { + existing_warnings: vec![earlier.clone()], + warned_at: 9, + }); + let ops = warn + .into_high_level_drive_operations(&epoch, platform_version) + .expect("operations"); + assert_eq!(ops.len(), 2); + assert!(matches!( + &ops[1], + ContractModerationOperation(ContractModerationOperationType::AddWarning { + identity_id, + warnings, + replaces_existing: true, + .. + }) if *identity_id == target + && warnings.len() == 2 + && warnings[0] == earlier + && warnings[1].warned_at == 9 + && warnings[1].reason.text == "second strike" + )); + + // A warn that lost what its validation read can not write a whole entry. + let orphan = action( + ContractUserModerationAction::Warn { + identity_id: target, + reason: ContractModerationReason::from_text("spam"), + }, + false, + ); + assert!(orphan + .into_high_level_drive_operations(&epoch, platform_version) + .is_err()); + + let ops = action( + ContractUserModerationAction::ClearWarnings { + identity_id: target, + }, + false, + ) + .into_high_level_drive_operations(&epoch, platform_version) + .expect("operations"); + assert!(matches!( + &ops[1], + ContractModerationOperation(ContractModerationOperationType::RemoveWarnings { .. }) + )); + } + #[test] fn should_bump_the_contract_nonce_then_edit_the_list() { let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-drive/src/state_transition_action/contract/contract_user_moderation/mod.rs b/packages/rs-drive/src/state_transition_action/contract/contract_user_moderation/mod.rs index e0d3bce7102..8127253ae32 100644 --- a/packages/rs-drive/src/state_transition_action/contract/contract_user_moderation/mod.rs +++ b/packages/rs-drive/src/state_transition_action/contract/contract_user_moderation/mod.rs @@ -5,6 +5,7 @@ pub mod v0; use crate::state_transition_action::contract::contract_user_moderation::v0::{ ContractDocumentDeletionContext, ContractUserModerationTransitionActionV0, + ContractWarningContext, }; use derive_more::From; use dpp::platform_value::Identifier; @@ -12,8 +13,8 @@ use dpp::prelude::{IdentityNonce, UserFeeIncrease}; use dpp::state_transition::contract_user_moderation_transition::ContractUserModerationAction; /// The action of a contract user moderation transition: one edit of a moderated contract's -/// banlist or suspension list, with the target's status as it was read when the transition was -/// validated, or the deletion of one document, with what was read about it. +/// banlist, suspension list or warning list, with the target's status as it was read when the +/// transition was validated, or the deletion of one document, with what was read about it. #[derive(Debug, Clone, From)] pub enum ContractUserModerationTransitionAction { /// v0 @@ -56,6 +57,13 @@ impl ContractUserModerationTransitionAction { } } + /// What a warn read when the transition was validated, `None` for every other action + pub fn warning(&self) -> Option<&ContractWarningContext> { + match self { + ContractUserModerationTransitionAction::V0(action) => action.warning.as_ref(), + } + } + /// What a document deletion read when the transition was validated, `None` for an action /// on an identity pub fn document_deletion(&self) -> Option<&ContractDocumentDeletionContext> { diff --git a/packages/rs-drive/src/state_transition_action/contract/contract_user_moderation/transformer.rs b/packages/rs-drive/src/state_transition_action/contract/contract_user_moderation/transformer.rs index dd92eeed69d..81d82938ce0 100644 --- a/packages/rs-drive/src/state_transition_action/contract/contract_user_moderation/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/contract/contract_user_moderation/transformer.rs @@ -1,22 +1,27 @@ use crate::state_transition_action::contract::contract_user_moderation::v0::{ - ContractDocumentDeletionContext, ContractUserModerationTransitionActionV0, + ContractDocumentDeletionContext, ContractDocumentRestorationContext, + ContractUserModerationTransitionActionV0, }; use crate::state_transition_action::contract::contract_user_moderation::ContractUserModerationTransitionAction; use dpp::data_contract::config::moderation::ContractModerationStatus; +use dpp::identity::TimestampMillis; use dpp::state_transition::contract_user_moderation_transition::ContractUserModerationTransition; impl ContractUserModerationTransitionAction { /// The action of a borrowed transition, keeping of the target's stored status what Drive - /// needs: whether it carries a suspension + /// needs: whether it carries a suspension, and for a warn the warnings it carries and + /// `block_time_ms`, the time the new warning is stamped with pub fn from_borrowed_transition_with_status( value: &ContractUserModerationTransition, current_status: &ContractModerationStatus, + block_time_ms: TimestampMillis, ) -> Self { match value { ContractUserModerationTransition::V0(v0) => { ContractUserModerationTransitionActionV0::from_borrowed_transition_with_status( v0, current_status, + block_time_ms, ) .into() } @@ -39,4 +44,21 @@ impl ContractUserModerationTransitionAction { } } } + + /// The action of a borrowed transition that restores a document, carrying what the + /// validation read and decoded: the contract, the document and its marked record + pub fn from_borrowed_transition_with_document_restoration( + value: &ContractUserModerationTransition, + document_restoration: ContractDocumentRestorationContext, + ) -> Self { + match value { + ContractUserModerationTransition::V0(v0) => { + ContractUserModerationTransitionActionV0::from_borrowed_transition_with_document_restoration( + v0, + document_restoration, + ) + .into() + } + } + } } diff --git a/packages/rs-drive/src/state_transition_action/contract/contract_user_moderation/v0/mod.rs b/packages/rs-drive/src/state_transition_action/contract/contract_user_moderation/v0/mod.rs index 83b5f15cff0..967e8ab017d 100644 --- a/packages/rs-drive/src/state_transition_action/contract/contract_user_moderation/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/contract/contract_user_moderation/v0/mod.rs @@ -1,6 +1,8 @@ mod transformer; use crate::drive::contract::DataContractFetchInfo; +use dpp::data_contract::config::moderation::{ContractDocumentRemoval, ContractWarning}; +use dpp::document::Document; use dpp::identifier::Identifier; use dpp::identity::TimestampMillis; use dpp::prelude::{IdentityNonce, UserFeeIncrease}; @@ -22,13 +24,28 @@ pub struct ContractUserModerationTransitionActionV0 { /// Drive knows whether a suspend replaces an entry and whether a ban also removes a /// suspension, without reading again. The status itself, with its reasons, stays behind. pub target_is_suspended: bool, - /// what a document deletion read when the transition was validated, `None` for an action - /// on an identity + /// what a warn read when the transition was validated, `None` for every other action + pub warning: Option, + /// what a document deletion read when the transition was validated, `None` for every + /// other action pub document_deletion: Option, + /// what a document restore read and decoded when the transition was validated, `None` + /// for every other action + pub document_restoration: Option, /// fee multiplier pub user_fee_increase: UserFeeIncrease, } +/// What the validation of a warn read, so that Drive writes the identity's warning list entry +/// whole, the new warning last, without reading again. +#[derive(Debug, Clone)] +pub struct ContractWarningContext { + /// the warnings the identity carried, oldest first; empty when it had no entry + pub existing_warnings: Vec, + /// the time of the block the warn runs in, recorded with the warning + pub warned_at: TimestampMillis, +} + /// What the validation of a document deletion read, so that Drive deletes the document and /// writes its removal record without reading again. #[derive(Debug, Clone)] @@ -39,4 +56,23 @@ pub struct ContractDocumentDeletionContext { pub document_owner_id: Identifier, /// the time of the block the deletion runs in, recorded as the removal time pub removed_at: TimestampMillis, + /// a double SHA-256 of the document as serialized under its type, recorded so that a + /// restore can be checked against it + pub document_hash: [u8; 32], + /// whether the document has a removal record already, from a deletion a moderator + /// restored: the fresh record then replaces it + pub replaces_restored_record: bool, +} + +/// What the validation of a document restore read and decoded, so that Drive puts the +/// document back and marks its removal record without reading again. +#[derive(Debug, Clone)] +pub struct ContractDocumentRestorationContext { + /// the moderated contract, as fetched: the restore resolves the document type from it + pub data_contract_fetch_info: Arc, + /// the document, decoded from the transition's bytes under its type: what is put back + pub document: Document, + /// the document's removal record as it will be stored: the record read, marked restored + /// by the signer at the block's time + pub removal: ContractDocumentRemoval, } diff --git a/packages/rs-drive/src/state_transition_action/contract/contract_user_moderation/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/contract/contract_user_moderation/v0/transformer.rs index 7f0f1f4bc99..c7c4e1aca41 100644 --- a/packages/rs-drive/src/state_transition_action/contract/contract_user_moderation/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/contract/contract_user_moderation/v0/transformer.rs @@ -1,15 +1,20 @@ use crate::state_transition_action::contract::contract_user_moderation::v0::{ - ContractDocumentDeletionContext, ContractUserModerationTransitionActionV0, + ContractDocumentDeletionContext, ContractDocumentRestorationContext, + ContractUserModerationTransitionActionV0, ContractWarningContext, }; use dpp::data_contract::config::moderation::ContractModerationStatus; +use dpp::identity::TimestampMillis; use dpp::state_transition::contract_user_moderation_transition::v0::ContractUserModerationTransitionV0; +use dpp::state_transition::contract_user_moderation_transition::ContractUserModerationAction; impl ContractUserModerationTransitionActionV0 { /// The action of a borrowed transition, keeping of the target's stored status what Drive - /// needs: whether it carries a suspension + /// needs: whether it carries a suspension, and for a warn the warnings it carries, which + /// the entry is rewritten with, and `block_time_ms`, which the new warning is stamped with pub fn from_borrowed_transition_with_status( value: &ContractUserModerationTransitionV0, current_status: &ContractModerationStatus, + block_time_ms: TimestampMillis, ) -> Self { let ContractUserModerationTransitionV0 { owner_id, @@ -25,7 +30,14 @@ impl ContractUserModerationTransitionActionV0 { identity_contract_nonce: *identity_contract_nonce, action: action.clone(), target_is_suspended: current_status.suspension.is_some(), + warning: matches!(action, ContractUserModerationAction::Warn { .. }).then(|| { + ContractWarningContext { + existing_warnings: current_status.warnings.clone(), + warned_at: block_time_ms, + } + }), document_deletion: None, + document_restoration: None, user_fee_increase: *user_fee_increase, } } @@ -36,9 +48,27 @@ impl ContractUserModerationTransitionActionV0 { value: &ContractUserModerationTransitionV0, document_deletion: ContractDocumentDeletionContext, ) -> Self { - let mut action = - Self::from_borrowed_transition_with_status(value, &ContractModerationStatus::default()); + let mut action = Self::from_borrowed_transition_with_status( + value, + &ContractModerationStatus::default(), + 0, + ); action.document_deletion = Some(document_deletion); action } + + /// The action of a borrowed transition that restores a document, carrying what the + /// validation read and decoded: the contract, the document and its marked record + pub fn from_borrowed_transition_with_document_restoration( + value: &ContractUserModerationTransitionV0, + document_restoration: ContractDocumentRestorationContext, + ) -> Self { + let mut action = Self::from_borrowed_transition_with_status( + value, + &ContractModerationStatus::default(), + 0, + ); + action.document_restoration = Some(document_restoration); + action + } } diff --git a/packages/rs-drive/src/structure/tests.rs b/packages/rs-drive/src/structure/tests.rs index 32c0a4e2495..8babc356afe 100644 --- a/packages/rs-drive/src/structure/tests.rs +++ b/packages/rs-drive/src/structure/tests.rs @@ -322,7 +322,7 @@ mod fixtures { use dpp::data_contract::associated_token::token_pre_programmed_distribution::TokenPreProgrammedDistribution; use dpp::data_contract::config::moderation::{ ContractDocumentRemoval, ContractModerationConfig, ContractModerationReason, - ContractModerators, + ContractModerators, ContractWarning, }; use dpp::data_contract::config::v0::{DataContractConfigSettersV0, DataContractConfigV0}; use dpp::data_contract::config::DataContractConfig; @@ -679,6 +679,7 @@ mod fixtures { banlist: false, suspensions: false, moderators: ContractModerators::ContractOwner, + warnings: false, }, ))); // After the config: the keyword is refused on a contract without moderation. @@ -714,7 +715,11 @@ mod fixtures { moderator_id: contract.owner_id(), reason: ContractModerationReason::from_text("spam"), removed_at: 1_000, + document_hash: [0x25; 32], + restoration: None, }, + replaces_existing: false, + moderator_id: contract.owner_id(), }, )], true, @@ -727,7 +732,7 @@ mod fixtures { conformance_of(&drive, "contract_with_document_removals", run); } - /// A contract that keeps both moderation lists, with one ban and one suspension + /// A contract that keeps both barring lists, with one ban and one suspension fn moderated_contract(run: &mut FixtureRun) { let platform_version = PlatformVersion::latest(); let drive = setup_drive_with_initial_state_structure(Some(platform_version)); @@ -742,6 +747,7 @@ mod fixtures { banlist: true, suspensions: true, moderators: ContractModerators::ContractOwner, + warnings: false, }, ))) }), @@ -811,6 +817,54 @@ mod fixtures { conformance_of(&drive, "moderated_contract", run); } + /// A contract that keeps the banlist and the warning list, with one identity carrying two + /// warnings + fn warned_contract(run: &mut FixtureRun) { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = setup_contract( + &drive, + "tests/supporting_files/contract/family/family-contract.json", + Some([10; 32]), + None, + Some(|contract: &mut DataContract| { + contract.set_config(contract.config().clone().with_moderation(Some( + ContractModerationConfig { + banlist: true, + suspensions: false, + warnings: true, + moderators: ContractModerators::ContractOwner, + }, + ))) + }), + None, + Some(platform_version), + ); + drive + .add_contract_warning( + contract.id(), + Identifier::from([0x23; 32]), + &[ + ContractWarning { + warned_at: 1_000, + reason: ContractModerationReason::from_text("first strike"), + }, + ContractWarning { + warned_at: 2_000, + reason: ContractModerationReason::from_text("second strike"), + }, + ], + false, + contract.owner_id(), + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to warn"); + conformance_of(&drive, "warned_contract", run); + } + fn tokens_and_group_actions(run: &mut FixtureRun) { let platform_version = PlatformVersion::latest(); let drive = setup_drive_with_initial_state_structure(Some(platform_version)); @@ -1450,6 +1504,7 @@ mod fixtures { identities(&mut run); contracts_with_documents(&mut run); moderated_contract(&mut run); + warned_contract(&mut run); contract_with_document_removals(&mut run); tokens_and_group_actions(&mut run); address_balances(&mut run); diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/contract_moderation.rs b/packages/rs-drive/src/util/batch/drive_op_batch/contract_moderation.rs index 7a91305f498..a2abadab067 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/contract_moderation.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/contract_moderation.rs @@ -3,7 +3,9 @@ use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; use crate::util::batch::drive_op_batch::DriveLowLevelOperationConverter; use dpp::block::block_info::BlockInfo; -use dpp::data_contract::config::moderation::{ContractDocumentRemoval, ContractModerationReason}; +use dpp::data_contract::config::moderation::{ + ContractDocumentRemoval, ContractModerationReason, ContractWarning, +}; use dpp::identifier::Identifier; use dpp::identity::TimestampMillis; use grovedb::batch::KeyInfoPath; @@ -11,7 +13,8 @@ use grovedb::{EstimatedLayerInformation, TransactionArg}; use platform_version::version::PlatformVersion; use std::collections::HashMap; -/// Operations on a moderated contract's banlist, suspension list and document removal records. +/// Operations on a moderated contract's banlist, suspension list, warning list and document +/// removal records. #[derive(Clone, Debug)] pub enum ContractModerationOperationType { /// Puts an identity on the banlist. @@ -55,9 +58,33 @@ pub enum ContractModerationOperationType { /// The identity to unsuspend. identity_id: Identifier, }, - /// Records that a moderator deleted a document. The deletion itself is a document - /// operation of the same batch. A document id is produced at most once, so a record is - /// written once and never replaced. + /// Writes an identity's warning list entry with one warning more, replacing the entry it + /// already has. + AddWarning { + /// The moderated contract. + contract_id: Identifier, + /// The identity to warn. + identity_id: Identifier, + /// The identity's warnings after this one, oldest first: what the entry holds. + warnings: Vec, + /// Whether the identity already has an entry, which is then replaced. + replaces_existing: bool, + /// The identity that pays for the entry and receives its refund. + moderator_id: Identifier, + }, + /// Takes an identity off the warning list: every warning it carries goes. + RemoveWarnings { + /// The moderated contract. + contract_id: Identifier, + /// The identity whose warnings are cleared. + identity_id: Identifier, + }, + /// Writes the record of a moderator's deletion of a document: a fresh one, or the + /// replacement of the record the document already has. A record is replaced when a + /// moderator restores the document (the record then carries the restoration, the + /// deletion itself undone by a document operation of the same batch) and when a restored + /// document is deleted again (a fresh record, in place of the restored one). A document id + /// is produced at most once, so those are the only ways a record can exist already. AddDocumentRemoval { /// The moderated contract. contract_id: Identifier, @@ -65,8 +92,15 @@ pub enum ContractModerationOperationType { document_type_name: String, /// The id the document had. document_id: Identifier, - /// Whose it was, who removed it (and pays for the record), why and when. + /// Whose it was, who removed it, why and when, what it was, and whether it was + /// restored since. removal: ContractDocumentRemoval, + /// Whether the document already has a record, which is then replaced. + replaces_existing: bool, + /// The identity that pays for the record, or for the bytes a replacement adds, and + /// receives its refund: the moderator that removed the document, or the one that + /// restored it. + moderator_id: Identifier, }, /// Writes nothing: marks the batch it is in as one whose storage removals refund nobody /// (`Drive::apply_drive_operations` generation 1). A moderator's document deletion carries @@ -142,16 +176,48 @@ impl DriveLowLevelOperationConverter for ContractModerationOperationType { transaction, platform_version, ), + ContractModerationOperationType::AddWarning { + contract_id, + identity_id, + warnings, + replaces_existing, + moderator_id, + } => drive.add_contract_warning_operations( + contract_id, + identity_id, + &warnings, + replaces_existing, + moderator_id, + block_info, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), + ContractModerationOperationType::RemoveWarnings { + contract_id, + identity_id, + } => drive.remove_contract_warnings_operations( + contract_id, + identity_id, + block_info, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), ContractModerationOperationType::AddDocumentRemoval { contract_id, document_type_name, document_id, removal, + replaces_existing, + moderator_id, } => drive.add_contract_document_removal_operations( contract_id, &document_type_name, document_id, &removal, + replaces_existing, + moderator_id, block_info, estimated_costs_only_with_layer_info, transaction, diff --git a/packages/rs-drive/src/verify/chained_document/verify_chained_documents_proof/v0/mod.rs b/packages/rs-drive/src/verify/chained_document/verify_chained_documents_proof/v0/mod.rs index 6777d32b7e0..daaa20e1750 100644 --- a/packages/rs-drive/src/verify/chained_document/verify_chained_documents_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/chained_document/verify_chained_documents_proof/v0/mod.rs @@ -39,13 +39,14 @@ impl DriveDocumentQuery<'_> { let bootstrap_documents = bootstrap_trios .into_iter() .filter(|(_, _, element)| element.is_some()) - .map(|(path, key, _)| { + .map(|(path, key, element)| { synthesize_index_only_document( self.contract.id(), self.document_type, index, &path, &key, + element.as_ref(), ) }) .collect::, Error>>()?; @@ -92,6 +93,7 @@ impl DriveDocumentQuery<'_> { index, &path, &key, + Some(&element), )?); } Some(segment) if segment == outer_type_name => { diff --git a/packages/rs-drive/src/verify/contract_moderation/verify_contract_moderation_status/v0/mod.rs b/packages/rs-drive/src/verify/contract_moderation/verify_contract_moderation_status/v0/mod.rs index 2708ebf305e..e45afbfb5f5 100644 --- a/packages/rs-drive/src/verify/contract_moderation/verify_contract_moderation_status/v0/mod.rs +++ b/packages/rs-drive/src/verify/contract_moderation/verify_contract_moderation_status/v0/mod.rs @@ -1,5 +1,7 @@ -use crate::drive::contract::moderation::types::{decode_ban, decode_suspension}; -use crate::drive::contract::paths::{CONTRACT_BANLIST_KEY, CONTRACT_SUSPENSIONS_KEY}; +use crate::drive::contract::moderation::types::{decode_ban, decode_suspension, decode_warnings}; +use crate::drive::contract::paths::{ + CONTRACT_BANLIST_KEY, CONTRACT_SUSPENSIONS_KEY, CONTRACT_WARNINGS_KEY, +}; use crate::drive::Drive; use crate::error::proof::ProofError; use crate::error::Error; @@ -61,6 +63,9 @@ impl Drive { Some([CONTRACT_SUSPENSIONS_KEY]) => { status.suspension = Some(decode_suspension(&value).map_err(malformed)?); } + Some([CONTRACT_WARNINGS_KEY]) => { + status.warnings = decode_warnings(&value).map_err(malformed)?; + } _ => { return Err(Error::Proof(ProofError::CorruptedProof( "contract moderation status proof holds an entry outside the lists" diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs index f67243a89ba..a3958140802 100644 --- a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs @@ -11,6 +11,8 @@ use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::serialized_version::DataContractInSerializationFormat; use dpp::document::{Document, DocumentV0Getters}; use dpp::document::document_methods::DocumentMethodsV0; +use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; +use dpp::util::hash::hash_double; use dpp::document::property_names::PRICE; use dpp::fee::Credits; use dpp::group::group_action_status::GroupActionStatus; @@ -290,7 +292,13 @@ impl Drive { document_type, platform_version, )?; - if payload != expected_commitment { + // The commitment is the item's + // first 32 bytes; a type's entry + // payload follows it and is covered + // by the commitment. + if payload.get(..expected_commitment.len()) + != Some(expected_commitment.as_slice()) + { return Err(Error::Proof(ProofError::IncorrectProof(format!( "the proved indexOnly entry's row commitment does not match the created document {}: the entry belongs to a different row", create_transition.base().id() @@ -1159,10 +1167,21 @@ impl Drive { { verify_contract_document_deletion_execution(proof, transition, platform_version) } + StateTransition::ContractUserModeration(transition) + if transition.action().restored_document().is_some() => + { + verify_contract_document_restore_execution( + proof, + transition, + known_contracts_provider_fn, + platform_version, + ) + } StateTransition::ContractUserModeration(transition) => { // The proof holds the entries of the lists the moderation touched, present or - // absent, and nothing more. A ban touched both lists the contract keeps (it - // removes a suspension too), so the contract's config says which to expect. + // absent, and nothing more. A ban touched every barring list the contract + // keeps (it removes a suspension too), so the contract's config says which to + // expect; the warning list is never among them. let contract_id = transition.data_contract_id(); let identity_id = transition.target_identity_id().ok_or(Error::Proof( ProofError::CorruptedProof( @@ -1180,7 +1199,7 @@ impl Drive { contract .config() .moderation() - .map(|moderation| moderation.lists().collect::>()) + .map(|moderation| moderation.barring_lists().collect::>()) .unwrap_or_else(|| vec![ContractModerationList::Banlist]) } ContractUserModerationAction::Unban { .. } => { @@ -1190,9 +1209,14 @@ impl Drive { | ContractUserModerationAction::Unsuspend { .. } => { vec![ContractModerationList::Suspensions] } - ContractUserModerationAction::DeleteDocument { .. } => { + ContractUserModerationAction::Warn { .. } + | ContractUserModerationAction::ClearWarnings { .. } => { + vec![ContractModerationList::Warnings] + } + ContractUserModerationAction::DeleteDocument { .. } + | ContractUserModerationAction::RestoreDocument { .. } => { return Err(Error::Proof(ProofError::CorruptedProof( - "a document deletion is verified above".to_string(), + "a document deletion or restore is verified above".to_string(), ))) } }; @@ -1224,7 +1248,17 @@ impl Drive { ContractUserModerationAction::Unsuspend { .. } => { statuses.suspended_until() == Some(None) } - ContractUserModerationAction::DeleteDocument { .. } => false, + // The latest warning is the transition's: its reason, on the warning + // list. Its block time is the block's, which the verifier does not know. + ContractUserModerationAction::Warn { reason, .. } => statuses + .warnings() + .and_then(<[_]>::last) + .is_some_and(|warning| warning.reason == *reason), + ContractUserModerationAction::ClearWarnings { .. } => { + statuses.warnings().is_some_and(<[_]>::is_empty) + } + ContractUserModerationAction::DeleteDocument { .. } + | ContractUserModerationAction::RestoreDocument { .. } => false, }; if !as_expected { return Err(Error::Proof(ProofError::IncorrectProof(format!( @@ -2766,6 +2800,73 @@ fn verify_contract_document_deletion_execution( } } +/// A moderator's document restore is proved by the document's removal record, now marked +/// restored by the transition's signer, and holding the hash of the bytes the transition +/// brought back. The document itself is not in the proof: the record says it is live again, +/// and the hash says it is the document the transition carries. The id is inside those bytes, +/// read under the contract's document type, which the verifier resolves through the provider. +fn verify_contract_document_restore_execution( + proof: &[u8], + transition: &ContractUserModerationTransition, + known_contracts_provider_fn: &ContractLookupFn, + platform_version: &PlatformVersion, +) -> Result<(RootHash, StateTransitionProofResult), Error> { + let contract_id = transition.data_contract_id(); + let Some((document_type_name, document_bytes)) = transition.action().restored_document() else { + return Err(Error::Proof(ProofError::CorruptedProof( + "only a document restore is verified by its marked removal record".to_string(), + ))); + }; + let contract = known_contracts_provider_fn(&contract_id)?.ok_or(Error::Proof( + ProofError::UnknownContract(format!( + "unknown contract with id {} in contract document restore verification", + contract_id + )), + ))?; + let document_type = contract.document_type_for_name(document_type_name)?; + let document = Document::from_bytes(document_bytes, document_type, platform_version)?; + let document_id = document.id(); + let document_hash = hash_double(document_bytes); + let (root_hash, mut entries) = Drive::verify_contract_document_removals( + proof, + contract_id, + &ContractDocumentRemovalsQuery { + document_type_name: document_type_name.to_string(), + selection: ContractDocumentRemovalsSelection::DocumentIds(vec![document_id]), + }, + platform_version, + )?; + match (entries.pop(), entries.is_empty()) { + (Some(entry), true) + if entry.document_id == document_id + && entry.removal.document_hash == document_hash + && entry + .removal + .restoration + .as_ref() + .is_some_and(|restoration| { + restoration.moderator_id == transition.owner_id() + }) => + { + Ok(( + root_hash, + VerifiedContractDocumentRemoval( + contract_id, + document_type_name.to_string(), + document_id, + entry.removal, + ), + )) + } + _ => Err(Error::Proof(ProofError::IncorrectProof(format!( + "proof of state transition execution does not show the {} on contract {} by {}", + transition.action(), + contract_id, + transition.owner_id() + )))), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/packages/rs-drive/tests/supporting_files/contract/index-only-scalar-terminal/index-only-scalar-terminal-contract.json b/packages/rs-drive/tests/supporting_files/contract/index-only-scalar-terminal/index-only-scalar-terminal-contract.json new file mode 100644 index 00000000000..03ef1373fe9 --- /dev/null +++ b/packages/rs-drive/tests/supporting_files/contract/index-only-scalar-terminal/index-only-scalar-terminal-contract.json @@ -0,0 +1,339 @@ +{ + "$formatVersion": "0", + "id": "CD6KsgzPhL8xPqHjvg4XWfDRNoGRURiPknAMiApkjgHR", + "ownerId": "ASaUNbfe4QBnWU3izvnZHagrxM3Rwhjz5hBmjWZg8RSX", + "version": 1, + "documentSchemas": { + "answer": { + "type": "object", + "indexOnly": true, + "documentsMutable": false, + "canBeDeleted": true, + "indices": [ + { + "name": "byRequest", + "properties": [ + { + "requestId": "asc" + }, + { + "$ownerId": "asc" + } + ], + "terminal": "payload" + } + ], + "properties": { + "requestId": { + "type": "array", + "byteArray": true, + "minItems": 20, + "maxItems": 20, + "position": 0 + }, + "payload": { + "type": "array", + "byteArray": true, + "minItems": 33, + "maxItems": 33, + "position": 1 + } + }, + "required": [ + "requestId", + "payload" + ], + "additionalProperties": false + }, + "vote": { + "type": "object", + "indexOnly": true, + "documentsMutable": false, + "canBeDeleted": true, + "indices": [ + { + "name": "byPoll", + "properties": [ + { + "pollId": "asc" + }, + { + "$ownerId": "asc" + } + ], + "terminal": "choice" + }, + { + "name": "byChoice", + "properties": [ + { + "pollId": "asc" + }, + { + "choice": "asc" + } + ], + "terminal": "$ownerId", + "countable": true, + "rangeCountable": true, + "rankedCountable": true + } + ], + "properties": { + "pollId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "choice": { + "type": "string", + "maxLength": 16, + "position": 1 + } + }, + "required": [ + "pollId", + "choice" + ], + "additionalProperties": false + }, + "rating": { + "type": "object", + "indexOnly": true, + "documentsMutable": false, + "canBeDeleted": true, + "indices": [ + { + "name": "byPost", + "properties": [ + { + "postId": "asc" + }, + { + "$ownerId": "asc" + } + ], + "terminal": "stars" + } + ], + "properties": { + "postId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "stars": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "position": 1 + } + }, + "required": [ + "postId", + "stars" + ], + "additionalProperties": false + }, + "loginKeyResponse": { + "type": "object", + "indexOnly": true, + "documentsMutable": false, + "canBeDeleted": true, + "indices": [ + { + "name": "byRequest", + "terminal": [ + "appEphemeralPubKeyHash", + "$ownerId" + ] + } + ], + "entryPayload": [ + "walletEphemeralPubKey", + "encryptedPayload" + ], + "properties": { + "appEphemeralPubKeyHash": { + "type": "array", + "byteArray": true, + "minItems": 20, + "maxItems": 20, + "position": 0 + }, + "walletEphemeralPubKey": { + "type": "array", + "byteArray": true, + "minItems": 33, + "maxItems": 33, + "position": 1 + }, + "encryptedPayload": { + "type": "array", + "byteArray": true, + "minItems": 60, + "maxItems": 572, + "position": 2 + } + }, + "required": [ + "appEphemeralPubKeyHash", + "walletEphemeralPubKey", + "encryptedPayload" + ], + "additionalProperties": false + }, + "payloadValues": { + "type": "object", + "indexOnly": true, + "documentsMutable": false, + "canBeDeleted": true, + "indices": [ + { + "name": "byOwner", + "terminal": "$ownerId" + } + ], + "entryPayload": [ + "bytes", + "text" + ], + "properties": { + "bytes": { + "type": "array", + "byteArray": true, + "minItems": 0, + "maxItems": 16, + "position": 0 + }, + "text": { + "type": "string", + "maxLength": 16, + "position": 1 + } + }, + "required": [ + "bytes", + "text" + ], + "additionalProperties": false + }, + "tagged": { + "type": "object", + "indexOnly": true, + "documentsMutable": false, + "canBeDeleted": true, + "indices": [ + { + "name": "byOwner", + "terminal": "$ownerId" + }, + { + "name": "byTag", + "properties": [ + { + "tag": "asc" + } + ], + "skipIfAbsent": true + } + ], + "properties": { + "tag": { + "type": "string", + "maxLength": 16, + "position": 0 + } + }, + "additionalProperties": false + }, + "reaction": { + "type": "object", + "indexOnly": true, + "documentsMutable": false, + "canBeDeleted": true, + "indices": [ + { + "name": "byPostKind", + "properties": [ + { + "postId": "asc" + } + ], + "terminal": [ + "kind", + "$ownerId" + ] + } + ], + "properties": { + "postId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "kind": { + "type": "integer", + "minimum": 0, + "maximum": 10, + "position": 1 + } + }, + "required": [ + "postId", + "kind" + ], + "additionalProperties": false + }, + "note": { + "type": "object", + "indexOnly": true, + "documentsMutable": false, + "canBeDeleted": true, + "indices": [ + { + "name": "byPostBody", + "properties": [ + { + "postId": "asc" + } + ], + "terminal": [ + "$ownerId", + "body" + ] + } + ], + "properties": { + "postId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "body": { + "type": "array", + "byteArray": true, + "maxItems": 16, + "position": 1 + } + }, + "required": [ + "postId", + "body" + ], + "additionalProperties": false + } + } +} 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..49de2a0083f 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 @@ -104,7 +104,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V10: DriveAbciMethodVersions = DriveAbciMet keep_record_of_finished_contested_resource_vote_poll: 0, clean_up_after_vote_poll_end: 0, clean_up_after_contested_resources_vote_poll_end: 1, - check_for_ended_vote_polls: 0, + check_for_ended_vote_polls: 1, // changed in v14: a tie goes to the earliest contender tally_votes_for_contested_document_resource_vote_poll: 0, award_document_to_winner: 0, delay_vote_poll: 0, 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..bf3417cbf7b 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 @@ -103,7 +103,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = advanced_structure: Some(0), identity_signatures: None, nonce: Some(1), - state: 0, + state: 1, // changed in v14: refuses a Lock vote on a contested index resolved without locking transform_into_action: 0, }, masternode_vote_state_transition_balance_pre_check: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/mod.rs index 64340526f77..b7a25f8c676 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/mod.rs @@ -25,6 +25,8 @@ pub struct DriveContractModerationMethodVersions { pub remove_contract_ban: FeatureVersion, pub add_contract_suspension: FeatureVersion, pub remove_contract_suspension: FeatureVersion, + pub add_contract_warning: FeatureVersion, + pub remove_contract_warnings: FeatureVersion, pub fetch_contract_moderation_status: FeatureVersion, pub fetch_contract_moderation_entries: FeatureVersion, pub prove_contract_moderation_status: FeatureVersion, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v1.rs index e356391adc0..2286de56422 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v1.rs @@ -49,6 +49,8 @@ pub const DRIVE_CONTRACT_METHOD_VERSIONS_V1: DriveContractMethodVersions = remove_contract_ban: 0, add_contract_suspension: 0, remove_contract_suspension: 0, + add_contract_warning: 0, + remove_contract_warnings: 0, fetch_contract_moderation_status: 0, fetch_contract_moderation_entries: 0, prove_contract_moderation_status: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v2.rs index d4845641828..207a0d3f979 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v2.rs @@ -49,6 +49,8 @@ pub const DRIVE_CONTRACT_METHOD_VERSIONS_V2: DriveContractMethodVersions = remove_contract_ban: 0, add_contract_suspension: 0, remove_contract_suspension: 0, + add_contract_warning: 0, + remove_contract_warnings: 0, fetch_contract_moderation_status: 0, fetch_contract_moderation_entries: 0, prove_contract_moderation_status: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v3.rs index 67339d948ca..77d1bbe2f44 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v3.rs @@ -60,6 +60,8 @@ pub const DRIVE_CONTRACT_METHOD_VERSIONS_V3: DriveContractMethodVersions = remove_contract_ban: 0, add_contract_suspension: 0, remove_contract_suspension: 0, + add_contract_warning: 0, + remove_contract_warnings: 0, fetch_contract_moderation_status: 0, fetch_contract_moderation_entries: 0, prove_contract_moderation_status: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v4.rs b/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v4.rs index df2aa494407..e48c216956e 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v4.rs @@ -47,6 +47,8 @@ pub const DRIVE_CONTRACT_METHOD_VERSIONS_V4: DriveContractMethodVersions = remove_contract_ban: 0, add_contract_suspension: 0, remove_contract_suspension: 0, + add_contract_warning: 0, + remove_contract_warnings: 0, fetch_contract_moderation_status: 0, fetch_contract_moderation_entries: 0, prove_contract_moderation_status: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs index 4cfcfd81df0..18ce386f787 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs @@ -159,6 +159,7 @@ pub struct DriveDocumentIndexUniquenessMethodVersions { pub validate_document_transfer_transition_action_uniqueness: FeatureVersion, pub validate_document_purchase_transition_action_uniqueness: FeatureVersion, pub validate_document_update_price_transition_action_uniqueness: FeatureVersion, + pub validate_restored_document_uniqueness: FeatureVersion, } #[cfg(test)] diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs index 471d5d35457..eebb341adfb 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs @@ -82,6 +82,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V1: DriveDocumentMethodVersions = validate_document_transfer_transition_action_uniqueness: 0, validate_document_purchase_transition_action_uniqueness: 0, validate_document_update_price_transition_action_uniqueness: 0, + validate_restored_document_uniqueness: 0, }, primary_key_tree_type: 0, }; diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs index 1f9438ee229..144c40f7edd 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs @@ -84,6 +84,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V2: DriveDocumentMethodVersions = validate_document_transfer_transition_action_uniqueness: 1, // Changed validate_document_purchase_transition_action_uniqueness: 1, // Changed validate_document_update_price_transition_action_uniqueness: 1, // Changed + validate_restored_document_uniqueness: 0, }, // FROZEN AT 0 for platform versions 10 and 11. Both protocol // versions select this table (`DRIVE_DOCUMENT_METHOD_VERSIONS_V2`) diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs index b2d3a909e28..a7039e6d48f 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs @@ -103,6 +103,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V3: DriveDocumentMethodVersions = validate_document_transfer_transition_action_uniqueness: 1, validate_document_purchase_transition_action_uniqueness: 1, validate_document_update_price_transition_action_uniqueness: 1, + validate_restored_document_uniqueness: 0, }, // Bumped to 1 vs V2's frozen 0: this is the v12-gated entry // point for the sum-tree feature. The v1 dispatch arm in diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs index d75e74aacc5..1388834b000 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs @@ -128,7 +128,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V4: DriveDocumentMethodVersions = add_contested_document: 0, add_contested_document_for_contract: 0, add_contested_document_for_contract_apply_and_add_to_operations: 0, - add_contested_document_for_contract_operations: 0, + add_contested_document_for_contract_operations: 1, // changed in v14: no-locking contests end at the join window until a second contender joins add_contested_document_to_primary_storage: 0, add_contested_indices_for_contract_operations: 0, add_contested_reference_and_vote_subtree_to_document_operations: 0, @@ -159,6 +159,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V4: DriveDocumentMethodVersions = validate_document_transfer_transition_action_uniqueness: 1, validate_document_purchase_transition_action_uniqueness: 1, validate_document_update_price_transition_action_uniqueness: 1, + validate_restored_document_uniqueness: 0, }, // Unchanged from V3 — see V3's comment for the v12-gated // count/sum composition rationale. 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..c283d55151e 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,8 @@ 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 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-platform-version/src/version/mocks/v2_test.rs b/packages/rs-platform-version/src/version/mocks/v2_test.rs index bd989215e95..6ffed9849cf 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -588,6 +588,13 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { max_contract_moderators: 16, max_contract_suspension_until: 9_007_199_254_740_991, max_contract_moderation_reason_length: 1024, + max_contract_warnings_per_identity: 16, + max_contract_moderation_reason_documents: 16, + min_contract_moderation_election_window_seconds: 86_400, + max_contract_moderation_election_window_seconds: 2_419_200, + min_contract_moderation_challenge_cool_down_seconds: 1_209_600, + max_contract_moderation_challenge_cool_down_seconds: 94_608_000, + contract_document_restore_window_ms: 604_800_000, max_token_redemption_cycles: 128, max_shielded_transition_actions: 16, max_time_range_overlap_factor: None, 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..b81a0bfb104 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,7 @@ pub const SYSTEM_DATA_CONTRACT_VERSIONS_V1: SystemDataContractVersions = token_history: 1, keyword_search: 1, document_history: 1, + // The app-connect contract does not exist before protocol version 14: no + // schema generation is selected here, so loading it is refused. + app_connect: 0, }; diff --git a/packages/rs-platform-version/src/version/system_data_contract_versions/v2.rs b/packages/rs-platform-version/src/version/system_data_contract_versions/v2.rs index 9b87b90e94b..c4a4f8e8035 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,7 @@ pub const SYSTEM_DATA_CONTRACT_VERSIONS_V2: SystemDataContractVersions = token_history: 1, keyword_search: 1, document_history: 1, + // The app-connect contract does not exist before protocol version 14: no + // schema generation is selected here, so loading it is refused. + app_connect: 0, }; diff --git a/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs b/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs index 3261a215403..57f8f319595 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. The +// earlier tables carry 0, which no schema generation answers to, so the table +// itself refuses to load the contract before 14. 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-platform-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index 61f990e68a2..aa11841d772 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -115,10 +115,36 @@ pub struct SystemLimits { /// to clients. Read by the `ContractUserModeration` basic structure validation v0 /// (protocol version 14) and never reached before. pub max_contract_suspension_until: u64, - /// Maximum length, in bytes of UTF-8, of the text of the reason a ban or a suspension - /// carries (`ContractModerationReason::text`). Read by the `ContractUserModeration` basic + /// Maximum length, in bytes of UTF-8, of the text of the reason a ban, a suspension, a + /// warning or a moderator's document deletion carries (`ContractModerationReason::text`). Read by the `ContractUserModeration` basic /// structure validation v0 (protocol version 14) and never reached before. pub max_contract_moderation_reason_length: u16, + /// Maximum number of warnings one identity may carry on a contract's warning list at a + /// time: a warn that would exceed it is refused until the warnings are cleared. Read by + /// the `ContractUserModeration` state validation v0 (protocol version 14) and never + /// reached before. + pub max_contract_warnings_per_identity: u16, + /// Maximum number of documents a contract moderation reason may cite + /// (`ContractModerationReason::documents`). Read by the reason's validation (protocol + /// version 14) and never reached before. + pub max_contract_moderation_reason_documents: u16, + /// Shortest join window and vote window, in seconds, an elected moderation team + /// declaration (`ContractModerators::Elected`) may set: one day. Read by the contract's + /// `validate_moderation_config` v0 (protocol version 14) and never reached before. + pub min_contract_moderation_election_window_seconds: u32, + /// Longest join window and vote window, in seconds, such a declaration may set: four + /// weeks. + pub max_contract_moderation_election_window_seconds: u32, + /// Shortest challenge cool-down, in seconds, such a declaration may set: two weeks. The + /// cool-down is how long a seated team is safe from a challenge after a seat change. + pub min_contract_moderation_challenge_cool_down_seconds: u32, + /// Longest challenge cool-down, in seconds, such a declaration may set: three years. + pub max_contract_moderation_challenge_cool_down_seconds: u32, + /// How long after a moderator's deletion of a document, in milliseconds of block time, + /// the contract's moderators may restore it (`ContractUserModeration`'s `RestoreDocument` + /// action): a week. Read by the `ContractUserModeration` state validation v0 (protocol + /// version 14) and never reached before. + pub contract_document_restore_window_ms: u64, // This the max redemption cycles we can process if we don't use a constant distribution // For a constant perpetual distribution this is very cheap since it's just a multiplication // For other distributions we much calculate at each cycle the rewards, so we don't want to diff --git a/packages/rs-platform-version/src/version/system_limits/v1.rs b/packages/rs-platform-version/src/version/system_limits/v1.rs index 7eba07e5053..510c1426a69 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -49,6 +49,13 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { max_contract_moderators: 16, max_contract_suspension_until: 9_007_199_254_740_991, max_contract_moderation_reason_length: 1024, + max_contract_warnings_per_identity: 16, + max_contract_moderation_reason_documents: 16, + min_contract_moderation_election_window_seconds: 86_400, // one day + max_contract_moderation_election_window_seconds: 2_419_200, // four weeks + min_contract_moderation_challenge_cool_down_seconds: 1_209_600, // two weeks + max_contract_moderation_challenge_cool_down_seconds: 94_608_000, // three years of 365 days + contract_document_restore_window_ms: 604_800_000, // 7 days max_token_redemption_cycles: 128, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on // top of the 408 B serialized action), so a transition's on-wire size is diff --git a/packages/rs-platform-version/src/version/system_limits/v2.rs b/packages/rs-platform-version/src/version/system_limits/v2.rs index 4f35723113e..caa74744517 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -30,6 +30,13 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { max_contract_moderators: 16, max_contract_suspension_until: 9_007_199_254_740_991, max_contract_moderation_reason_length: 1024, + max_contract_warnings_per_identity: 16, + max_contract_moderation_reason_documents: 16, + min_contract_moderation_election_window_seconds: 86_400, // one day + max_contract_moderation_election_window_seconds: 2_419_200, // four weeks + min_contract_moderation_challenge_cool_down_seconds: 1_209_600, // two weeks + max_contract_moderation_challenge_cool_down_seconds: 94_608_000, // three years of 365 days + contract_document_restore_window_ms: 604_800_000, // 7 days max_token_redemption_cycles: 128, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on // top of the 408 B serialized action), so a transition's on-wire size is diff --git a/packages/rs-platform-version/src/version/system_limits/v3.rs b/packages/rs-platform-version/src/version/system_limits/v3.rs index 5bdc85019bc..19eb669d6c8 100644 --- a/packages/rs-platform-version/src/version/system_limits/v3.rs +++ b/packages/rs-platform-version/src/version/system_limits/v3.rs @@ -32,6 +32,13 @@ pub const SYSTEM_LIMITS_V3: SystemLimits = SystemLimits { max_contract_moderators: 16, max_contract_suspension_until: 9_007_199_254_740_991, max_contract_moderation_reason_length: 1024, + max_contract_warnings_per_identity: 16, + max_contract_moderation_reason_documents: 16, + min_contract_moderation_election_window_seconds: 86_400, // one day + max_contract_moderation_election_window_seconds: 2_419_200, // four weeks + min_contract_moderation_challenge_cool_down_seconds: 1_209_600, // two weeks + max_contract_moderation_challenge_cool_down_seconds: 94_608_000, // three years of 365 days + contract_document_restore_window_ms: 604_800_000, // 7 days max_token_redemption_cycles: 128, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on // top of the 408 B serialized action), so a transition's on-wire size is diff --git a/packages/rs-platform-version/src/version/system_limits/v4.rs b/packages/rs-platform-version/src/version/system_limits/v4.rs index dea104ec8a8..117c466fe36 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -47,6 +47,9 @@ use crate::version::system_limits::SystemLimits; /// moderator identities, its owner counted when named. A suspension runs until at most /// 2^53 - 1 milliseconds of block time, the largest value JSON clients read exactly. The /// text of the reason a ban or a suspension carries is at most 1024 bytes. +/// * Elected moderation teams (protocol version 14): a contract that declares an elected +/// moderation team sets its join window and vote window between one day and four weeks, +/// and its challenge cool-down between two weeks and three years, all in seconds. pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { estimated_contract_max_serialized_size: 16384, max_field_value_size: 5120, //5 KiB @@ -73,6 +76,13 @@ pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { max_contract_moderators: 16, max_contract_suspension_until: 9_007_199_254_740_991, max_contract_moderation_reason_length: 1024, + max_contract_warnings_per_identity: 16, + max_contract_moderation_reason_documents: 16, + min_contract_moderation_election_window_seconds: 86_400, // one day + max_contract_moderation_election_window_seconds: 2_419_200, // four weeks + min_contract_moderation_challenge_cool_down_seconds: 1_209_600, // two weeks + max_contract_moderation_challenge_cool_down_seconds: 94_608_000, // three years of 365 days + contract_document_restore_window_ms: 604_800_000, // 7 days max_token_redemption_cycles: 128, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on // top of the 408 B serialized action), so a transition's on-wire size is diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 16fb299b088..9c8c36a843d 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -336,9 +336,16 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// `SystemLimits::max_contract_suspension_until`) and unsuspends one /// identity, signed by the owner or a moderator with a CRITICAL key; a /// ban and a suspension carry a reason, stored with the entry: a text of -/// at most `SystemLimits::max_contract_moderation_reason_length` bytes and +/// at most `SystemLimits::max_contract_moderation_reason_length` bytes, /// an optional code nothing checks, reserved for ban codes a contract may -/// declare in a later version; +/// declare in a later version, and up to +/// `SystemLimits::max_contract_moderation_reason_documents` documents the +/// reason is about, named by type and id and not looked up. A contract may also keep a warning list +/// (`[64, contract, 2] / 224`): a warn appends a warning, the block time and +/// a reason, to the identity's entry, at most +/// `SystemLimits::max_contract_warnings_per_identity` at a time, and a +/// clearWarnings deletes the entry; warnings bar nothing and are what a +/// status query and the identity's clients read; /// `DRIVE_ABCI_VALIDATION_VERSIONS_V10` turns its gates on, moves the /// contract update's basic structure to 2 and the contract create and /// update state validation (already 1 here) checks the named moderators. @@ -351,7 +358,7 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// party of a transfer or a purchase, so a barred identity neither /// receives nor sells a document. Token transitions are not gated. /// `DRIVE_CONTRACT_METHOD_VERSIONS_V4` bumps `insert_contract` to 2, -/// which creates the list trees (`[64, contract, 2] / 128` and `/ 192`, inside the contract's other tree), and +/// which creates the list trees (`[64, contract, 2] / 128`, `/ 192` and `/ 224`, inside the contract's other tree), and /// adds the `moderation` method table; the verify and /// query tables gain the status and entries methods. /// @@ -402,7 +409,14 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// Ids of documents created before the upgrade can not be produced by /// the new derivation either. A client that still derives the entropy /// only id has every create rejected with -/// `InvalidDocumentTransitionIdError`. +/// `InvalidDocumentTransitionIdError`. Every create path of the clients +/// in this repository derives through `Document::generate_document_id`: +/// `DocumentCreateTransitionV0::from_document` for dpp, rs-sdk and the +/// bindings built on them, and in wasm-dpp2 the `DocumentCreateTransition` +/// constructor (which also writes the id back onto the JavaScript +/// `Document`), `Document.generateId` with its `identityContractNonce` +/// argument, `setIdForCreation` and the `identityContractNonce` +/// constructor option. /// 19. **Document deletion by moderators**: a document type of a contract /// that declares moderation may set `canBeDeletedByModerators` (meta-schema /// v3, fixed when the type is created, refused on a type that keeps @@ -448,6 +462,106 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// (40132), with one to other amounts or another pricing (40133), or /// whose epoch's multiplier rose beyond the tolerance (40134), so a /// contract whose fees change cannot make a signed transition pay them. +/// Check tx judges the agreements again on every recheck, off the action +/// the transformer rebuilt with the contract and the multiplier as they +/// are then, so a batch a block would refuse leaves the mempool instead +/// of failing there (mempool policy, not consensus). +/// +/// 21. **Document restore by moderators**: the removal record a moderator's +/// deletion leaves (19) also holds a double SHA-256 of the document as +/// serialized under its type at the deletion (`ContractDocumentRemoval:: +/// document_hash`), and `ContractUserModeration` gains the +/// `RestoreDocument` action: the owner or any current moderator brings +/// the document back, as it was, within +/// `SystemLimits::contract_document_restore_window_ms` (a week) of the +/// removal. The bytes must decode under the type and hash to what the +/// record holds; refused otherwise, or without a record (41119), past the +/// window (41120), on a hash mismatch (41121), once restored (41122), or +/// when another document took a value of one of the type's unique indexes +/// meanwhile (40105). The document goes back through the ordinary insert, +/// its storage flags naming its owner (the signer pays, the owner keeps +/// the refund of a later deletion), and the record is marked restored in +/// place (`ContractDocumentRemoval::restoration`: who, when) rather than +/// deleted; a restored document deleted again gets a fresh record in place +/// of the marked one, which the deletion transform reads to know. Neither +/// the type's creation token cost nor its `actionFees` creation fee is +/// charged, and no fee agreement is asked. `canBeDeletedByModerators` is +/// now also refused on a type with a contested index, whose deletions +/// could never be undone. The record grows on the wire +/// (`getContractDocumentRemovals`: `document_hash`, `restoration`). +/// +/// 22. **Elected moderation teams, the declaration and the interim**: a data +/// contract may declare, when it is created, that its moderators are a team +/// elected by masternodes and evonodes (`ContractModerators::Elected`, a third kind +/// beside the owner and an appointed set, in the same config V2). The +/// declaration is frozen: the join and vote windows (one day to four weeks, +/// one week by default) and the challenge cool-down (two weeks to three +/// years), all in seconds and bounded by `SYSTEM_LIMITS_V4`; an optional, +/// unbounded election delay in seconds after the contract's creation +/// before the first charter may be filed (`electionDelay`, read by the +/// `moderation: "electionOpen"` reference requirement of item 24); the document +/// types the team moderates, each with the abilities a charter may claim on +/// it; who moderates until the first team is seated (the owner, an +/// appointed set, or nobody, with the moderated types not yet usable or +/// used unmoderated meanwhile); and whether the owner is protected from the +/// team. `validate_moderation_config` v0 checks +/// it against the contract's document types (10900), and +/// `validate_config_update` 2 refuses every change to it, and entering or +/// leaving elected moderation, with `DataContractConfigUpdateError`. The +/// interim moderators moderate and claim the pot as the merged kinds do; +/// with nobody named, nobody may claim the moderators pot, which +/// accumulates for the team to come, and with the types not yet usable +/// `contract_moderation_gate` v0 refuses, paid, every document transition +/// of a moderated type (`ContractModeratedDocumentTypeNotYetUsableError`, +/// 41200). No election exists yet. +/// +/// 23. **Contested indexes without a Lock choice, and ties to the earliest +/// contender**: a contested unique index may declare `"resolution": 1`, +/// `ContestedIndexResolution::MasternodeVoteNoLocking` (meta-schema v3, +/// parser generation 3). Such a contest offers no Lock choice +/// (`VoteChoiceNotAllowedForVotePollError`, 40307, from `validate_state` 1 +/// of the masternode vote) and always ends with a winner. Its end date is +/// the end of the join window until a second contender joins, when +/// `add_contested_document_for_contract_operations` 1 moves it to the full +/// poll duration, so a contest with a single contender is awarded without +/// the vote window. `check_for_ended_vote_polls` 1 awards a tie to the +/// **earliest** contender (creation time, block height, core height, +/// document id) for every resolution, where the shipped rule awarded the +/// latest; DPNS contests ending from this version on follow the new rule. +/// +/// 24. **Contract references may require elected moderation, a minimum age, a +/// minimum time since the last update, an owner relation to the writer or +/// config flags of the referenced contract**: a `contract` `refersTo` +/// declaration may carry `contractRequirements`, what the referenced +/// contract must declare beyond existing, with `moderation: "elected"` or +/// `"electionOpen"` (elected, and the contract's own `electionDelay` since +/// its creation has passed, or it declares none), +/// `minimumAgeSeconds` (the contract's recorded creation time must be at +/// least that many seconds before the block time of the write), +/// `minimumSecondsSinceUpdate` (the same of the later of its creation and +/// last update times; a contract without a recorded creation time never +/// meets either), `owner` (`"self"`: the contract is owned by the +/// `$ownerId` of the referring document, `"other"`: by anyone else), +/// `readonly: true` (its config is read-only, so it can never be updated +/// again), `keepsHistory: true` (its config keeps history) and +/// `ownerProtected` (its elected moderation declaration protects the owner +/// from the team, or does not, as the value says; a contract without +/// elected moderation meets neither value) as the requirements +/// (meta-schema v3, `apply_property_reference` 0, +/// `ContractReferenceRequirements` on +/// `DocumentPropertyReferenceTarget::Contract`). The document reference +/// validation checks them against the contract it fetched for the +/// existence check and the write itself (its owner and block time), so +/// they cost no further read, and refuses the first unmet requirement with +/// `ReferencedContractRequirementNotMetError` (40135). A changed +/// `contractRequirements` is an incompatible schema change on update. +/// +/// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) +/// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by +/// the app's ephemeral key hash and the responding identity, with the wallet's +/// ephemeral key and encrypted grant in `entryPayload`. Genesis registers it on +/// chains born at this version; `transition_to_version_14` inserts it on upgrade. +/// The Drive and trusted SDK caches serve it only from protocol version 14. /// /// * `ShieldFromIdentity` (state transition type 21) activates: /// `SHIELD_FROM_IDENTITY_INITIAL_PROTOCOL_VERSION = 14` gates it in @@ -505,7 +619,7 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// its gates on; Drive identity methods v2 rewrite the key and raise the remaining budget). pub const PLATFORM_V14: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_14, - 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, the document removal record trees and the moderation method table; apply_drive_operations 1 (a moderator's document deletion refunds nobody) + 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, the document removal record trees and the moderation method table; apply_drive_operations 1 (a moderator's document deletion refunds nobody); index uniqueness gains validate_restored_document_uniqueness (a moderator's document restore) 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 @@ -535,7 +649,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { // the shared storage table; it is dead below v14 (the `ttl` grammar // does not parse), so no table fork is needed. fee_version: FEE_VERSION3, // changed: contested document contribution reduced to 0.1 DASH; registration surcharge for once-per-identity token distributions - system_limits: SYSTEM_LIMITS_V4, // changed: daily withdrawal limit becomes 15% of the total credits a day ago + time-range overlap-factor cap (24) + time-range TTL cap (1 week) and per-write drop cap (32) + GroveDB proof envelope floor (V1); max_contract_moderators, max_contract_suspension_until and max_contract_moderation_reason_length + system_limits: SYSTEM_LIMITS_V4, // changed: daily withdrawal limit becomes 15% of the total credits a day ago + time-range overlap-factor cap (24) + time-range TTL cap (1 week) and per-write drop cap (32) + GroveDB proof envelope floor (V1); max_contract_moderators, max_contract_suspension_until, max_contract_moderation_reason_length, max_contract_warnings_per_identity, max_contract_moderation_reason_documents and contract_document_restore_window_ms (a week) consensus: ConsensusVersions { tenderdash_consensus_version: 1, }, @@ -612,6 +726,66 @@ mod tests { /// The ranked index keywords are gated by the meta-schema version, so v14 /// must select meta-schema v3 while v13 stays on v2. + /// Contested indexes without a Lock choice (item 23): the three method + /// versions that read the resolution are selected by v14 only, so a v13 + /// replay keeps the shipped rules (a full poll for every contest, ties to + /// the latest contender, a Lock vote accepted on any contest). + #[test] + fn no_locking_contests_are_selected_by_v14_only() { + assert_eq!( + PLATFORM_V13 + .drive_abci + .methods + .voting + .check_for_ended_vote_polls, + 0 + ); + assert_eq!( + PLATFORM_V14 + .drive_abci + .methods + .voting + .check_for_ended_vote_polls, + 1 + ); + assert_eq!( + PLATFORM_V13 + .drive_abci + .validation_and_processing + .state_transitions + .masternode_vote_state_transition + .state, + 0 + ); + assert_eq!( + PLATFORM_V14 + .drive_abci + .validation_and_processing + .state_transitions + .masternode_vote_state_transition + .state, + 1 + ); + assert_eq!( + PLATFORM_V13 + .drive + .methods + .document + .insert_contested + .add_contested_document_for_contract_operations, + 0 + ); + assert_eq!( + PLATFORM_V14 + .drive + .methods + .document + .insert_contested + .add_contested_document_for_contract_operations, + 1 + ); + } + #[test] fn ranked_index_keywords_are_gated_by_meta_schema_v3() { assert_eq!( diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index a754ff2c6e2..3399060f53b 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -114,12 +114,13 @@ These are shipped ABI. Do not renumber. | 98 | `NotFound` | Sentinel — `Option` returned as an error | | 99 | `ErrorUnknown` | Sentinel — unmapped/flattened errors | -**Next allocatable integer: 58** — 27–57 are all claimed (27, 29, 31, 34–42 +**Next allocatable integer: 59** — 27–58 are all claimed (27, 29, 31, 34–42 and 46 merged; 43–45 proposed by active #4313 at head `0302b188ab`; 47 and 48 proposed by active #4356 (47 renumbered from 42, 48 from 43 — see their rows below); 49–54 proposed by active #4586 (the persister operation × kind block); 55–57 proposed by #4715 for pending identity-funded -shield debits and durable recovery errors; 28, 30, +shield debits and durable recovery errors; 58 proposed by #4799 for an unavailable +identity balance response; 28, 30, 32 and 33 reserved). **28, 30, 32 and 33 are RESERVED, not free**: 28 and 30 were vacated when the reservation trio moved to 34–36; 32 and 33 lapsed when their in-repo owners @@ -170,6 +171,7 @@ Fork-era numbers remain in the collision history, which is immutable record. | 55 | `ErrorShieldedIdentityDebitPending` | #4715 | Proposed — an earlier identity-funded shield is unresolved. This request was not built or broadcast; wait for shielded sync to reconcile the original debit. Rust's blanket and shielded-operation mappers preserve the code and message; Swift and Kotlin expose matching typed errors | | 56 | `ErrorShieldedRecoveryCorrupted` | #4715 | Proposed — durable shielded recovery data is malformed or invalid; preserved for diagnosis. Rust, Swift and Kotlin preserve this typed error | | 57 | `ErrorShieldedRecoveryKeysRequired` | #4715 | Proposed — recovery needs the account and compatible keys; ciphertext damage can produce the same symptom. Rust, Swift and Kotlin preserve this typed error | +| 58 | `ErrorIdentityBalanceUnavailable` | #4799 | Proposed — Platform returned no balance for a managed identity. Retrying the read is safe; this is distinct from missing wallet ownership and must not trigger registration or funding | **Code 31 left this table on 2026-08-04.** `ErrorSigningKeyUnavailable` sat here as #4183's proposal until #4183 merged (`189a3abb1c`); it is now in the merged diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 020bccc138c..5e7bc342baf 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -596,6 +596,10 @@ pub enum PlatformWalletFFIResultCode { /// Incompatible keys and damaged ciphertext can produce the same symptom. ErrorShieldedRecoveryKeysRequired = 57, + /// Platform returned no balance for a managed identity. Retrying this read + /// is safe; this does not imply missing ownership or require registration. + ErrorIdentityBalanceUnavailable = 58, + /// The named thing does not exist. /// /// Originally (and still mostly) the code for every `Option` returned as an @@ -860,6 +864,9 @@ impl From for PlatformWalletFFIResult { // assigned a dedicated code yet — those still carry the // typed Display rendering as the message. let code = match &error { + PlatformWalletError::IdentityBalanceUnavailable(_) => { + PlatformWalletFFIResultCode::ErrorIdentityBalanceUnavailable + } PlatformWalletError::NoSpendableInputs { .. } | PlatformWalletError::OnlyOutputAddressesFunded { .. } | PlatformWalletError::OnlyDustInputs { .. } => { @@ -2570,3 +2577,20 @@ mod tests { .into_owned() } } + +#[cfg(test)] +mod identity_balance_error_tests { + use super::*; + #[test] + fn should_distinguish_unavailable_network_balance_with_a_retryable_read_code() { + let result = PlatformWalletFFIResult::from( + PlatformWalletError::IdentityBalanceUnavailable([7; 32].into()), + ); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorIdentityBalanceUnavailable + ); + assert_eq!(result.code as u32, 58); + assert!(!result.message.is_null()); + } +} diff --git a/packages/rs-platform-wallet-ffi/src/managed_identity.rs b/packages/rs-platform-wallet-ffi/src/managed_identity.rs index d2b99febc81..dec965e28ab 100644 --- a/packages/rs-platform-wallet-ffi/src/managed_identity.rs +++ b/packages/rs-platform-wallet-ffi/src/managed_identity.rs @@ -116,7 +116,12 @@ pub unsafe extern "C" fn managed_identity_get_last_updated_balance_block_time( PlatformWalletFFIResult::ok() } -/// Set last updated balance block time. +/// Set the block time on this detached managed-identity handle only. +/// +/// Wallet lookups return snapshot clones. This setter neither updates the live +/// wallet's verified balance watermark nor persists metadata. Obtain a fresh +/// snapshot after `platform_wallet_refresh_identity_balance` to observe the +/// authoritative wallet state; this function is not a live-watermark reset API. #[no_mangle] pub unsafe extern "C" fn managed_identity_set_last_updated_balance_block_time( identity_handle: Handle, @@ -379,6 +384,79 @@ mod tests { } } + #[test] + fn should_keep_manual_balance_block_time_changes_on_detached_handles() { + use crate::identity_manager::{ + identity_manager_add_identity, identity_manager_create, identity_manager_destroy, + identity_manager_get_identity, + }; + + unsafe { + let original = + MANAGED_IDENTITY_STORAGE.insert(ManagedIdentity::new(create_test_identity(), 0)); + let mut manager = NULL_HANDLE; + assert_eq!( + identity_manager_create(&mut manager).code, + PlatformWalletFFIResultCode::Success + ); + assert_eq!( + identity_manager_add_identity(manager, original).code, + PlatformWalletFFIResultCode::Success + ); + let mut snapshot = NULL_HANDLE; + assert_eq!( + identity_manager_get_identity(manager, [1u8; 32].as_ptr(), &mut snapshot).code, + PlatformWalletFFIResultCode::Success + ); + let stamp = BlockTime { + height: u64::MAX, + core_height: u32::MAX, + timestamp: u64::MAX, + }; + assert_eq!( + managed_identity_set_last_updated_balance_block_time(snapshot, &stamp).code, + PlatformWalletFFIResultCode::Success + ); + assert_eq!( + MANAGED_IDENTITY_STORAGE.with_item(snapshot, |identity| identity + .last_updated_balance_block_time + .unwrap() + .height), + Some(u64::MAX) + ); + assert_eq!( + IDENTITY_MANAGER_STORAGE.with_item(manager, |manager| manager + .managed_identity(&Identifier::from([1; 32])) + .unwrap() + .last_updated_balance_block_time), + Some(None) + ); + + // Even adding the edited snapshot to another standalone manager + // imports only its DPP identity, not its manually assigned metadata. + let mut other_manager = NULL_HANDLE; + assert_eq!( + identity_manager_create(&mut other_manager).code, + PlatformWalletFFIResultCode::Success + ); + assert_eq!( + identity_manager_add_identity(other_manager, snapshot).code, + PlatformWalletFFIResultCode::Success + ); + assert_eq!( + IDENTITY_MANAGER_STORAGE.with_item(other_manager, |manager| manager + .managed_identity(&Identifier::from([1; 32])) + .unwrap() + .last_updated_balance_block_time), + Some(None) + ); + managed_identity_destroy(snapshot); + managed_identity_destroy(original); + identity_manager_destroy(manager); + identity_manager_destroy(other_manager); + } + } + #[test] fn test_block_time_operations() { unsafe { diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index c3d8ba193d4..20f0c421b20 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -8,7 +8,8 @@ use crate::event_handler::{ }; use crate::handle::*; use crate::persistence::{ - FFIPersister, FreeTrackedMasternodesFn, LoadTrackedMasternodesFn, PersistDpnsNameStatesFn, + FFIPersister, FreeTrackedMasternodesFn, LoadIdentityBalanceBlockTimeFn, + LoadTrackedMasternodesFn, PersistDpnsNameStatesFn, PersistIdentityBalanceBlockTimeFn, PersistTrackedMasternodesFn, PersistWalletChangesetChainLockHeightFn, PersistWalletChangesetSweepsFn, PersistWalletChangesetUtxoVerdictsFn, PersistenceCallbacks, PersistenceCallbacksExtension, PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, @@ -268,6 +269,14 @@ unsafe fn persistence_extension_callbacks( on_persist_wallet_changeset_utxo_verdicts_fn, PersistWalletChangesetUtxoVerdictsFn ), + persist_identity_balance_block_time: slot!( + on_persist_identity_balance_block_time_fn, + PersistIdentityBalanceBlockTimeFn + ), + load_identity_balance_block_time: slot!( + on_load_identity_balance_block_time_fn, + LoadIdentityBalanceBlockTimeFn + ), } } @@ -1254,6 +1263,49 @@ mod tests { assert!(read_unknown.wallet_changeset_utxo_verdicts.is_none()); } + #[test] + fn should_size_gate_identity_balance_watermark_slots_independently() { + unsafe extern "C" fn persist( + _: *mut c_void, + _: *const u8, + _: *const u8, + _: *const crate::types::BlockTime, + ) -> i32 { + 0 + } + unsafe extern "C" fn load( + _: *mut c_void, + _: *const u8, + _: *const u8, + _: *mut bool, + _: *mut crate::types::BlockTime, + ) -> i32 { + 0 + } + let mut ext = PersistenceCallbacksExtension { + on_persist_identity_balance_block_time_fn: Some(persist), + on_load_identity_balance_block_time_fn: Some(load), + ..Default::default() + }; + let parsed = unsafe { persistence_extension_callbacks(&ext) }; + assert!(parsed.persist_identity_balance_block_time.is_some()); + assert!(parsed.load_identity_balance_block_time.is_some()); + ext.struct_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_identity_balance_block_time_fn + ); + let parsed = unsafe { persistence_extension_callbacks(&ext) }; + assert!(parsed.persist_identity_balance_block_time.is_some()); + assert!(parsed.load_identity_balance_block_time.is_none()); + ext.struct_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_identity_balance_block_time_fn + ); + let parsed = unsafe { persistence_extension_callbacks(&ext) }; + assert!(parsed.persist_identity_balance_block_time.is_none()); + assert!(parsed.load_identity_balance_block_time.is_none()); + } + /// A host whose `struct_size` stops right after the chainlock-height /// slot (built before the credit-verdict slot existed) keeps every /// earlier slot and simply never has the verdict slot read; a host diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index dd049a67b83..d95a1f31db8 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -257,6 +257,25 @@ pub type PersistWalletChangesetUtxoVerdictsFn = unsafe extern "C" fn( verdicts_count: usize, ) -> i32; +/// Persist one identity's optional balance freshness stamp in the same +/// transaction as its legacy identity row. A null stamp means no watermark. +pub type PersistIdentityBalanceBlockTimeFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + identity_id: *const u8, + block_time: *const crate::types::BlockTime, +) -> i32; + +/// Load the stamp without allocating a host-owned array. `out_found` separates +/// an absent legacy stamp from a valid all-zero block time. +pub type LoadIdentityBalanceBlockTimeFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + identity_id: *const u8, + out_found: *mut bool, + out_block_time: *mut crate::types::BlockTime, +) -> i32; + /// Size- and version-tagged additive persistence callbacks. /// /// `context` is the context in the accompanying [`PersistenceCallbacks`] @@ -368,6 +387,25 @@ pub struct PersistenceCallbacksExtension { verdicts_count: usize, ) -> i32, >, + /// Additive sidecar: never change the stride of `IdentityEntryFFI` or + /// `IdentityRestoreEntryFFI`, which old hosts still allocate unchanged. + pub on_persist_identity_balance_block_time_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + identity_id: *const u8, + block_time: *const crate::types::BlockTime, + ) -> i32, + >, + pub on_load_identity_balance_block_time_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + identity_id: *const u8, + out_found: *mut bool, + out_block_time: *mut crate::types::BlockTime, + ) -> i32, + >, } impl Default for PersistenceCallbacksExtension { @@ -383,6 +421,8 @@ impl Default for PersistenceCallbacksExtension { on_persist_wallet_changeset_sweeps_fn: None, on_persist_wallet_changeset_chain_lock_height_fn: None, on_persist_wallet_changeset_utxo_verdicts_fn: None, + on_persist_identity_balance_block_time_fn: None, + on_load_identity_balance_block_time_fn: None, } } } @@ -399,6 +439,8 @@ pub struct PersistenceExtensionCallbacks { pub wallet_changeset_sweeps: Option, pub wallet_changeset_chain_lock_height: Option, pub wallet_changeset_utxo_verdicts: Option, + pub persist_identity_balance_block_time: Option, + pub load_identity_balance_block_time: Option, } /// Return value by which a persistence callback reports a **retryable** @@ -1333,6 +1375,8 @@ pub struct FFIPersister { /// Additive tracked-masternode persistence trio (persist / load / /// free), likewise extension-negotiated. tracked_masternodes_callbacks: PersistenceExtensionCallbacks, + persist_identity_balance_block_time_callback: Option, + load_identity_balance_block_time_callback: Option, /// Semantic capability declaration supplied separately from the callback /// vtable by the additive manager-create API. Keeping this out of /// `PersistenceCallbacks` preserves that established C struct's size. @@ -1453,6 +1497,9 @@ impl FFIPersister { .wallet_changeset_chain_lock_height, wallet_changeset_utxo_verdicts_callback: extensions.wallet_changeset_utxo_verdicts, tracked_masternodes_callbacks: extensions, + persist_identity_balance_block_time_callback: extensions + .persist_identity_balance_block_time, + load_identity_balance_block_time_callback: extensions.load_identity_balance_block_time, declared_capabilities, round_lock: Mutex::new(RoundGuardState::default()), } @@ -2175,6 +2222,47 @@ impl PlatformWalletPersistence for FFIPersister { } } + // Stamp sidecars share the begin/end transaction with identity scalars. + // Run after the legacy callback has staged any new identity rows. + if let (Some(id_cs), Some(cb)) = ( + changeset.identities.as_ref(), + self.persist_identity_balance_block_time_callback, + ) { + for entry in id_cs.identities.values() { + // The restore ABI carries only identities owned by this wallet. + // Clear any previous sidecar when ownership is absent or changed; + // observed identities must not leave an unrestorable watermark. + let stamp = entry + .last_updated_balance_block_time + .filter(|_| entry.wallet_id == Some(wallet_id)) + .map(crate::types::BlockTime::from); + let rc = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + entry.id.as_bytes().as_ptr(), + stamp.as_ref().map_or(std::ptr::null(), |stamp| stamp), + ) + }; + if rc != 0 { + outcome.record(rc); + } + } + for identity_id in &id_cs.removed { + let rc = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + identity_id.as_bytes().as_ptr(), + std::ptr::null(), + ) + }; + if rc != 0 { + outcome.record(rc); + } + } + } + // Send DashPay payment-history rows — the `dashpay_payments_overlay` // ONLY. `record_dashpay_payment`, the single writer for every // payment mutation (live sends, confirm-sweep flips, reconstruction @@ -3108,7 +3196,35 @@ impl PlatformWalletPersistence for FFIPersister { // fires before we leave this function. let entries = unsafe { slice::from_raw_parts(entries_ptr, count) }; for entry in entries { - let (wallet_state, platform_address_state) = build_wallet_start_state(entry)?; + let (mut wallet_state, platform_address_state) = build_wallet_start_state(entry)?; + if let Some(cb) = self.load_identity_balance_block_time_callback { + for identities in wallet_state.identity_manager.wallet_identities.values_mut() { + for managed in identities.values_mut() { + let mut found = false; + let mut stamp = crate::types::BlockTime { + height: 0, + core_height: 0, + timestamp: 0, + }; + let rc = unsafe { + cb( + self.callbacks.context, + entry.wallet_id.as_ptr(), + managed.id().as_bytes().as_ptr(), + &mut found, + &mut stamp, + ) + }; + if rc != 0 { + return Err(persist_callback_error( + rc, + "Loading identity balance block time failed".to_string(), + )); + } + managed.last_updated_balance_block_time = found.then(|| stamp.into()); + } + } + } out.wallets.insert(entry.wallet_id, wallet_state); if let Some(platform_address_state) = platform_address_state { out.platform_addresses @@ -5687,10 +5803,11 @@ fn build_wallet_start_state( // Per-wallet identities go straight into the wallet_identities // sub-map keyed by registration index. Out-of-wallet identities - // are not surfaced here — there's no SwiftData path for them - // today (PersistentIdentity always links to a wallet) — so the - // out-of-wallet bucket starts empty and is populated only via - // runtime DPNS resolution / observation. + // are not surfaced by this restore ABI: Swift supplies only identities + // linked to the wallet row. Observed SwiftData rows remain unlinked, so + // the out-of-wallet bucket starts empty and is populated only via + // runtime DPNS resolution / observation. Balance sidecars are likewise + // persisted only for wallet-owned identities. let bucket = build_wallet_identity_bucket(entry)?; let mut wallet_identities = BTreeMap::new(); if !bucket.is_empty() { @@ -7078,6 +7195,225 @@ mod tests { use super::*; + #[test] + fn should_restore_identity_balance_watermark_without_changing_legacy_rows() { + unsafe extern "C" fn load_wallet( + _: *mut c_void, + entries: *mut *const WalletRestoreEntryFFI, + count: *mut usize, + ) -> i32 { + // The legacy restore row contains only integers and raw pointers. + let mut identity: IdentityRestoreEntryFFI = std::mem::zeroed(); + identity.identity_id = [7; 32]; + identity.balance = 123; + *entries = Box::into_raw(Box::new(WalletRestoreEntryFFI { + wallet_id: [42; 32], + identities: Box::into_raw(Box::new(identity)), + identities_count: 1, + ..Default::default() + })); + *count = 1; + 0 + } + unsafe extern "C" fn free_wallet( + _: *mut c_void, + entries: *const WalletRestoreEntryFFI, + _: usize, + ) { + let row = Box::from_raw(entries.cast_mut()); + drop(Box::from_raw(row.identities.cast_mut())); + } + unsafe extern "C" fn load_stamp( + ctx: *mut c_void, + wallet: *const u8, + identity: *const u8, + found: *mut bool, + stamp: *mut crate::types::BlockTime, + ) -> i32 { + assert_eq!(std::slice::from_raw_parts(wallet, 32), &[42; 32]); + assert_eq!(std::slice::from_raw_parts(identity, 32), &[7; 32]); + let mode = *(ctx as *const u8); + if mode == 2 { + return -1; + } + *found = mode == 1; + // Known all-zero metadata must remain Some, not collapse to None. + *stamp = crate::types::BlockTime { + height: 0, + core_height: 0, + timestamp: 0, + }; + 0 + } + for mode in [0_u8, 1, 2] { + let persister = FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + context: (&mode as *const u8).cast_mut().cast(), + on_load_wallet_list_fn: Some(load_wallet), + on_load_wallet_list_free_fn: Some(free_wallet), + ..Default::default() + }, + PersistenceCapabilities::NONE, + PersistenceExtensionCallbacks { + load_identity_balance_block_time: Some(load_stamp), + ..Default::default() + }, + ); + let result = persister.load(); + if mode == 2 { + assert!(result.is_err()); + continue; + } + let restored = result.expect("restore watermark"); + let managed = &restored.wallets[&[42; 32]] + .identity_manager + .wallet_identities[&[42; 32]][&0]; + assert_eq!(managed.last_updated_balance_block_time.is_some(), mode == 1); + assert_eq!( + dpp::identity::accessors::IdentityGettersV0::balance(&managed.identity), + 123 + ); + } + let legacy = FFIPersister::new(PersistenceCallbacks { + on_load_wallet_list_fn: Some(load_wallet), + on_load_wallet_list_free_fn: Some(free_wallet), + ..Default::default() + }) + .load() + .expect("old hosts still restore"); + assert!( + legacy.wallets[&[42; 32]].identity_manager.wallet_identities[&[42; 32]][&0] + .last_updated_balance_block_time + .is_none() + ); + } + + #[test] + fn should_persist_balance_watermarks_only_for_the_owning_wallet() { + use platform_wallet::changeset::{IdentityChangeSet, IdentityEntry}; + use std::sync::Mutex; + + unsafe extern "C" fn persist( + ctx: *mut c_void, + wallet: *const u8, + _: *const u8, + stamp: *const crate::types::BlockTime, + ) -> i32 { + assert_eq!(std::slice::from_raw_parts(wallet, 32), &[42; 32]); + let stored = &*(ctx as *const Mutex>); + *stored.lock().unwrap() = stamp.as_ref().map(|stamp| stamp.height); + 0 + } + + let stored = Mutex::new(None::); + let persister = FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + context: (&stored as *const Mutex>).cast_mut().cast(), + on_changeset_begin_fn: Some(noop_begin), + on_changeset_end_fn: Some(noop_end), + ..Default::default() + }, + PersistenceCapabilities::ATOMIC_CHANGESETS, + PersistenceExtensionCallbacks { + persist_identity_balance_block_time: Some(persist), + ..Default::default() + }, + ); + let mut managed = platform_wallet::ManagedIdentity::new_out_of_wallet( + dpp::identity::Identity::V0(dpp::identity::v0::IdentityV0::default()), + ); + managed.last_updated_balance_block_time = Some(platform_wallet::BlockTime::new(42, 7, 99)); + for owner in [Some([42; 32]), None, Some([42; 32]), Some([43; 32])] { + managed.wallet_id = owner; + managed.identity_index = owner.map(|_| 0); + let mut identities = IdentityChangeSet::default(); + identities + .identities + .insert(managed.id(), IdentityEntry::from_managed(&managed)); + persister.store([42; 32], identities.into()).unwrap(); + assert_eq!( + *stored.lock().unwrap(), + (owner == Some([42; 32])).then_some(42) + ); + } + } + + #[test] + fn should_roll_back_failed_identity_balance_watermark_store_and_clear_on_removal() { + use platform_wallet::changeset::{IdentityChangeSet, IdentityEntry}; + use std::sync::Mutex; + #[derive(Default)] + struct Host { + events: Mutex>, + fail: bool, + } + unsafe extern "C" fn persist( + ctx: *mut c_void, + _: *const u8, + _: *const u8, + stamp: *const crate::types::BlockTime, + ) -> i32 { + let host = &*(ctx as *const Host); + host.events.lock().unwrap().push((true, stamp.is_null())); + if host.fail { + -1 + } else { + 0 + } + } + unsafe extern "C" fn end(ctx: *mut c_void, _: *const u8, success: bool) -> i32 { + let host = &*(ctx as *const Host); + host.events.lock().unwrap().push((false, success)); + 0 + } + for fail in [false, true] { + let host = Host { + fail, + ..Default::default() + }; + let persister = FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + context: (&host as *const Host).cast_mut().cast(), + on_changeset_begin_fn: Some(noop_begin), + on_changeset_end_fn: Some(end), + ..Default::default() + }, + PersistenceCapabilities::ATOMIC_CHANGESETS, + PersistenceExtensionCallbacks { + persist_identity_balance_block_time: Some(persist), + ..Default::default() + }, + ); + let mut managed = platform_wallet::ManagedIdentity::new( + dpp::identity::Identity::V0(dpp::identity::v0::IdentityV0::default()), + 0, + ); + managed.wallet_id = Some([42; 32]); + managed.last_updated_balance_block_time = Some(platform_wallet::BlockTime { + height: 42, + core_height: 7, + timestamp: 99, + }); + let mut identities = IdentityChangeSet::default(); + identities + .identities + .insert(managed.id(), IdentityEntry::from_managed(&managed)); + identities.removed.insert([9; 32].into()); + let result = persister.store( + [42; 32], + PlatformWalletChangeSet { + identities: Some(identities), + ..Default::default() + }, + ); + assert_eq!(result.is_err(), fail); + assert_eq!( + *host.events.lock().unwrap(), + vec![(true, false), (true, true), (false, !fail)] + ); + } + } + // --- persists_durably: the fail-closed durability attestation --- unsafe extern "C" fn noop_begin(_ctx: *mut c_void, _wallet_id: *const u8) -> i32 { @@ -8349,6 +8685,26 @@ mod tests { PersistenceCallbacksExtension, on_persist_wallet_changeset_utxo_verdicts_fn ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_identity_balance_block_time_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_identity_balance_block_time_fn + ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_identity_balance_block_time_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_identity_balance_block_time_fn + ) + std::mem::size_of::>(), std::mem::size_of::() ); assert_eq!( diff --git a/packages/rs-platform-wallet-ffi/src/wallet.rs b/packages/rs-platform-wallet-ffi/src/wallet.rs index b27125b65da..d57745b9f94 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet.rs @@ -2,9 +2,33 @@ use crate::error::*; use crate::handle::*; -use crate::runtime::runtime; +use crate::runtime::{block_on_worker, runtime}; +use crate::types::read_identifier; use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; +/// Read a managed identity's balance from Platform, update it and flush persistence. +/// +/// # Safety +/// `identity_id` points to 32 readable bytes, and `out_balance` is writable. +/// `handle` must refer to a live platform wallet for the duration of this call. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_refresh_identity_balance( + handle: Handle, + identity_id: *const u8, + out_balance: *mut u64, +) -> PlatformWalletFFIResult { + check_ptr!(out_balance); + unsafe { *out_balance = 0 }; + let id = unwrap_result_or_return!(unsafe { read_identifier(identity_id) }); + let option = PLATFORM_WALLET_STORAGE.with_item(handle, |wallet| { + let identity = wallet.identity().clone(); + block_on_worker(async move { identity.refresh_identity_balance(&id).await }) + }); + let result = unwrap_option_or_return!(option); + unsafe { *out_balance = unwrap_result_or_return!(result) }; + PlatformWalletFFIResult::ok() +} + /// Get the wallet ID (32 bytes). #[no_mangle] pub unsafe extern "C" fn platform_wallet_get_id( diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 775639f2bb2..1fe4d1eb514 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -934,12 +934,13 @@ impl IdentityEntry { /// [`ManagedIdentity::keys_snapshot_changeset`](crate::wallet::identity::ManagedIdentity) /// into an [`IdentityKeysChangeSet`]. pub fn from_managed(managed: &ManagedIdentity) -> Self { + let (balance, last_updated_balance_block_time) = managed.balance_snapshot_for_persistence(); Self { id: managed.identity.id(), - balance: managed.identity.balance(), + balance, revision: managed.identity.revision(), identity_index: managed.identity_index, - last_updated_balance_block_time: managed.last_updated_balance_block_time, + last_updated_balance_block_time, last_synced_keys_block_time: managed.last_synced_keys_block_time, dpns_names: managed.dpns_names.clone(), contested_dpns_names: managed.contested_dpns_names.clone(), @@ -1091,9 +1092,9 @@ impl Merge for IdentityChangeSet { if entry.revision >= existing.revision { existing.balance = entry.balance; existing.revision = entry.revision; + existing.last_updated_balance_block_time = + entry.last_updated_balance_block_time; } - existing.last_updated_balance_block_time = - entry.last_updated_balance_block_time; existing.last_synced_keys_block_time = entry.last_synced_keys_block_time; existing.status = entry.status; // `wallet_id` is immutable per identity (SHA256 of @@ -2437,6 +2438,38 @@ mod tests { } } + #[test] + fn should_merge_balance_and_watermark_under_the_same_revision_gate() { + let id = Identifier::from([0x53; 32]); + for revision in [6, 7, 8] { + let mut old = identity_entry_with_contested(id, &[]); + old.revision = 7; + old.balance = 100; + old.last_updated_balance_block_time = Some(BlockTime::new(10, 42, 1000)); + let mut incoming = old.clone(); + incoming.revision = revision; + incoming.balance = 200; + incoming.last_updated_balance_block_time = Some(BlockTime::new(20, 50, 2000)); + let expected = if revision >= 7 { + incoming.clone() + } else { + old.clone() + }; + let mut changes = IdentityChangeSet::default(); + changes.identities.insert(id, old); + let mut later = IdentityChangeSet::default(); + later.identities.insert(id, incoming); + changes.merge(later); + let merged = &changes.identities[&id]; + assert_eq!(merged.balance, expected.balance); + assert_eq!(merged.revision, expected.revision); + assert_eq!( + merged.last_updated_balance_block_time, + expected.last_updated_balance_block_time + ); + } + } + #[test] fn test_empty_changeset() { let cs = PlatformWalletChangeSet::default(); diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index d19d2aa401d..d87b3f1ba58 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -32,16 +32,10 @@ pub enum PlatformWalletError { #[error("failed to load persisted client state: {0}")] PersisterLoad(#[source] crate::changeset::PersistenceError), - /// The persister failed to store the wallet-registration changeset. + /// A wallet changeset could not be stored. Wallet registration and balance + /// refresh preserve the typed cause; other best-effort writers may log it. /// See [`Self::PersisterLoad`] for why the typed cause is carried. - /// - /// Scope: wallet registration is the only write that reports this today. - /// A contact un-ignore flattens its failure into `Persistence(String)`, - /// the asset-lock pool write returns the raw `PersistenceError` on its own - /// signature, and the fire-and-forget writes (DPNS marketplace, platform - /// addresses, asset-lock tracking) log and swallow it. A host branching on - /// the classification gets it for registration and nowhere else yet. - #[error("failed to persist wallet registration changeset: {0}")] + #[error("failed to persist wallet changeset: {0}")] PersisterStore(#[source] crate::changeset::PersistenceError), /// Restoring persisted platform-address state into a freshly registered @@ -62,6 +56,11 @@ pub enum PlatformWalletError { #[error("Identity not found: {0}")] IdentityNotFound(Identifier), + /// The wallet owns the identity, but the queried Platform node has no + /// balance yet. This may be transient immediately after registration. + #[error("Platform balance unavailable for identity {0}; retry against an up-to-date node")] + IdentityBalanceUnavailable(Identifier), + #[error("No primary identity set")] NoPrimaryIdentity, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/balance.rs b/packages/rs-platform-wallet/src/wallet/identity/network/balance.rs new file mode 100644 index 00000000000..392f8d27fbf --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/network/balance.rs @@ -0,0 +1,749 @@ +//! Read and persist a managed identity's current Platform credit balance. + +use dash_sdk::platform::Fetch; +use dash_sdk::query_types::IdentityBalance; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::prelude::Identifier; + +use crate::error::PlatformWalletError; +use crate::wallet::identity::state::managed_identity::ManagedIdentity; +use crate::BlockTime; + +use super::IdentityWallet; + +impl IdentityWallet { + /// Fetch the balance directly from Platform and persist it for this wallet. + /// + /// Uses the dedicated balance query rather than the local identity snapshot. + /// No keys, signatures, or state transitions are required. Network failure + /// leaves the previous balance untouched; persistence failures are returned. + pub async fn refresh_identity_balance( + &self, + identity_id: &Identifier, + ) -> Result { + { + let wm = self.wallet_manager.read().await; + let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { + PlatformWalletError::WalletNotFound("Wallet info not found".to_string()) + })?; + if info + .identity_manager + .wallet_identity(&self.wallet_id, identity_id) + .is_none() + { + return Err(PlatformWalletError::IdentityNotFound(*identity_id)); + } + } + + let (balance, metadata) = + IdentityBalance::fetch_with_metadata(&self.sdk, *identity_id, None).await?; + let balance = balance.ok_or(PlatformWalletError::IdentityBalanceUnavailable( + *identity_id, + ))?; + + let current_balance = { + let mut wm = self.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&self.wallet_id).ok_or_else(|| { + PlatformWalletError::WalletNotFound("Wallet info not found".to_string()) + })?; + // Recheck after the network await: never recreate a removed identity. + let managed = info + .identity_manager + .wallet_identity_mut(&self.wallet_id, identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + + self.persist_refreshed_balance( + managed, + balance, + BlockTime::new( + metadata.height, + metadata.core_chain_locked_height, + metadata.time_ms, + ), + )?; + managed.identity.balance() + }; + + Ok(current_balance) + } + + // The manager write lock must cover persistence and publication together. + fn persist_refreshed_balance( + &self, + managed: &mut ManagedIdentity, + balance: u64, + block_time: BlockTime, + ) -> Result<(), PlatformWalletError> { + managed.persist_refreshed_balance(balance, block_time, &self.persister) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::changeset::{ + ClientStartState, IdentityEntry, PersistenceError, PlatformWalletChangeSet, + PlatformWalletPersistence, + }; + use crate::events::{EventHandler, PlatformEventHandler}; + use crate::wallet::identity::IdentityManager; + use dpp::identity::{v0::IdentityV0, Identity}; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Mutex}; + + const OLD_BALANCE: u64 = 2_818_262_560; + const AFTER_DPNS: u64 = 2_743_797_100; + + #[derive(Default)] + struct BalancePersister { + queued: Mutex>, + committed: Mutex>, + fail_flush: AtomicBool, + fail_store: AtomicBool, + transient_store: AtomicBool, + store_reissuable: AtomicBool, + commits_inline: AtomicBool, + flush_count: std::sync::atomic::AtomicUsize, + } + + impl PlatformWalletPersistence for BalancePersister { + fn store_transient_is_reissuable(&self) -> bool { + self.store_reissuable.load(Ordering::SeqCst) + } + + fn store_commits_inline(&self) -> bool { + self.commits_inline.load(Ordering::SeqCst) + } + fn store( + &self, + wallet_id: [u8; 32], + changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + if self.transient_store.load(Ordering::SeqCst) { + return Err(PersistenceError::backend_with_kind( + crate::changeset::PersistenceErrorKind::Transient, + "busy", + )); + } + if self.fail_store.load(Ordering::SeqCst) { + return Err(PersistenceError::backend("injected store failure")); + } + if let Some(identities) = changeset.identities { + self.queued.lock().unwrap().extend( + identities + .identities + .into_values() + .map(|entry| (wallet_id, entry)), + ); + } + if self.store_commits_inline() { + self.committed + .lock() + .unwrap() + .extend(self.queued.lock().unwrap().drain(..)); + } + Ok(()) + } + + fn flush(&self, _: [u8; 32]) -> Result<(), PersistenceError> { + self.flush_count.fetch_add(1, Ordering::SeqCst); + if self.fail_flush.load(Ordering::SeqCst) { + return Err(PersistenceError::backend("injected flush failure")); + } + self.committed + .lock() + .unwrap() + .extend(self.queued.lock().unwrap().drain(..)); + Ok(()) + } + + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + + struct NoopEvents; + impl EventHandler for NoopEvents {} + impl PlatformEventHandler for NoopEvents {} + + async fn fixture(balance: Option) -> (IdentityWallet, Identifier, Arc) { + let id = Identifier::from([0xAA; 32]); + let mut sdk = dash_sdk::SdkBuilder::new_mock().build().unwrap(); + sdk.mock() + .expect_fetch::(id, balance) + .await + .unwrap(); + let backend = Arc::new(BalancePersister::default()); + let manager = + crate::PlatformWalletManager::new(Arc::new(sdk), backend.clone(), Arc::new(NoopEvents)); + let wallet = manager + .create_wallet_from_seed_bytes( + key_wallet::Network::Testnet, + &[42; 64], + WalletAccountCreationOptions::None, + Some(0), + ) + .await + .unwrap(); + let iw = wallet.identity().clone(); + { + let mut wm = iw.wallet_manager.write().await; + wm.get_wallet_info_mut(&iw.wallet_id) + .unwrap() + .identity_manager + .add_identity( + Identity::V0(IdentityV0 { + id, + public_keys: Default::default(), + balance: OLD_BALANCE, + revision: 7, + }), + 0, + iw.wallet_id, + &iw.persister, + ) + .unwrap(); + } + backend.queued.lock().unwrap().clear(); + backend.committed.lock().unwrap().clear(); + (iw, id, backend) + } + + async fn local_balance(iw: &IdentityWallet, id: &Identifier) -> u64 { + iw.wallet_manager + .read() + .await + .get_wallet_info(&iw.wallet_id) + .unwrap() + .identity_manager + .identity(id) + .unwrap() + .identity + .balance() + } + + #[tokio::test] + async fn should_fetch_balance_after_dpns_and_flush_a_reloadable_wallet_snapshot() { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + assert_eq!(iw.refresh_identity_balance(&id).await.unwrap(), AFTER_DPNS); + assert_eq!(local_balance(&iw, &id).await, AFTER_DPNS); + assert!(backend.queued.lock().unwrap().is_empty()); + let committed = backend.committed.lock().unwrap(); + assert_eq!(committed.len(), 1); + let (wallet_id, entry) = &committed[0]; + assert_eq!(*wallet_id, iw.wallet_id); + assert_eq!(entry.wallet_id, Some(iw.wallet_id)); + assert_eq!(entry.revision, 7); + let mut reloaded = IdentityManager::new(); + reloaded.apply_identity_entry(entry.clone()); + assert_eq!( + reloaded.identity(&id).unwrap().identity.balance(), + AFTER_DPNS + ); + } + + #[tokio::test] + async fn should_persist_a_real_zero_balance() { + let (iw, id, backend) = fixture(Some(0)).await; + assert_eq!(iw.refresh_identity_balance(&id).await.unwrap(), 0); + assert_eq!(backend.committed.lock().unwrap()[0].1.balance, 0); + } + + #[tokio::test] + async fn should_preserve_balance_when_platform_has_no_balance() { + let (iw, id, backend) = fixture(None).await; + assert!( + matches!(iw.refresh_identity_balance(&id).await, Err(PlatformWalletError::IdentityBalanceUnavailable(found)) if found == id) + ); + assert_eq!(local_balance(&iw, &id).await, OLD_BALANCE); + assert!(backend.committed.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn should_reject_identity_outside_this_wallet_without_changing_balance() { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + let other = Identifier::from([0xBB; 32]); + assert!(matches!(iw.refresh_identity_balance(&other).await, + Err(PlatformWalletError::IdentityNotFound(found)) if found == other)); + assert_eq!(local_balance(&iw, &id).await, OLD_BALANCE); + assert!(backend.committed.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn should_not_overwrite_a_more_recent_balance_response() { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + { + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&iw.wallet_id) + .unwrap() + .identity_manager + .managed_identity_mut(&id) + .unwrap(); + // Mock responses have height 0; this state came from a newer block. + managed.last_updated_balance_block_time = Some(BlockTime::new(1, 1, 1)); + } + assert_eq!(iw.refresh_identity_balance(&id).await.unwrap(), OLD_BALANCE); + assert!(backend.committed.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn should_report_persistence_failure_instead_of_claiming_a_durable_refresh() { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + backend.fail_flush.store(true, Ordering::SeqCst); + assert!(matches!( + iw.refresh_identity_balance(&id).await, + Err(PlatformWalletError::Persistence(_)) + )); + assert!(backend.committed.lock().unwrap().is_empty()); + assert_eq!(backend.queued.lock().unwrap()[0].1.balance, AFTER_DPNS); + assert_eq!(local_balance(&iw, &id).await, OLD_BALANCE); + } + + #[tokio::test] + async fn should_keep_confirmed_transaction_balance_when_query_is_older_or_equal() { + for proof_height in [0, 1] { + let (iw, id, backend) = fixture(Some(OLD_BALANCE)).await; + { + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&iw.wallet_id) + .unwrap() + .identity_manager + .wallet_identity_mut(&iw.wallet_id, &id) + .unwrap(); + managed.persist_confirmed_balance( + AFTER_DPNS, + BlockTime::new(proof_height, 42, 1000), + &iw.persister, + ); + } + let before = backend.flush_count.load(Ordering::SeqCst); + assert_eq!(iw.refresh_identity_balance(&id).await.unwrap(), AFTER_DPNS); + assert_eq!(backend.flush_count.load(Ordering::SeqCst), before); + assert_eq!(backend.committed.lock().unwrap().len(), 1); + assert_eq!(backend.committed.lock().unwrap()[0].1.balance, AFTER_DPNS); + } + } + + #[tokio::test] + async fn should_not_publish_balance_or_watermark_when_store_fails() { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + backend.fail_store.store(true, Ordering::SeqCst); + assert!(matches!( + iw.refresh_identity_balance(&id).await, + Err(PlatformWalletError::PersisterStore(_)) + )); + assert_eq!(local_balance(&iw, &id).await, OLD_BALANCE); + assert!(iw + .wallet_manager + .read() + .await + .get_wallet_info(&iw.wallet_id) + .unwrap() + .identity_manager + .identity(&id) + .unwrap() + .last_updated_balance_block_time + .is_none()); + backend.fail_store.store(false, Ordering::SeqCst); + assert_eq!(iw.refresh_identity_balance(&id).await.unwrap(), AFTER_DPNS); + assert_eq!(backend.committed.lock().unwrap().len(), 1); + } + + #[tokio::test] + async fn should_not_flush_an_inline_commit() { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + backend.commits_inline.store(true, Ordering::SeqCst); + backend.fail_flush.store(true, Ordering::SeqCst); + assert_eq!(iw.refresh_identity_balance(&id).await.unwrap(), AFTER_DPNS); + assert_eq!(backend.committed.lock().unwrap().len(), 1); + assert_eq!(backend.flush_count.load(Ordering::SeqCst), 0); + } + #[tokio::test] + async fn should_reject_an_observed_identity_without_fetching_or_persisting() { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + { + let mut wm = iw.wallet_manager.write().await; + let manager = &mut wm + .get_wallet_info_mut(&iw.wallet_id) + .unwrap() + .identity_manager; + let identity = manager.remove_identity(&id, &iw.persister).unwrap(); + manager + .add_out_of_wallet_identity(identity, &iw.persister) + .unwrap(); + } + backend.queued.lock().unwrap().clear(); + assert!(matches!(iw.refresh_identity_balance(&id).await, + Err(PlatformWalletError::IdentityNotFound(found)) if found == id)); + assert_eq!(local_balance(&iw, &id).await, OLD_BALANCE); + assert!(backend.queued.lock().unwrap().is_empty()); + assert_eq!(backend.flush_count.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn should_retry_the_newer_snapshot_before_accepting_an_older_response() { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&iw.wallet_id) + .unwrap() + .identity_manager + .wallet_identity_mut(&iw.wallet_id, &id) + .unwrap(); + backend.fail_store.store(true, Ordering::SeqCst); + assert!(iw + .persist_refreshed_balance(managed, 100, BlockTime::new(10, 10, 10)) + .is_err()); + assert_eq!(managed.identity.balance(), OLD_BALANCE); + assert!(managed.last_updated_balance_block_time.is_none()); + backend.fail_store.store(false, Ordering::SeqCst); + iw.persist_refreshed_balance(managed, 200, BlockTime::new(9, 9, 9)) + .unwrap(); + assert_eq!(managed.identity.balance(), 100); + let committed = backend.committed.lock().unwrap(); + assert_eq!(committed.len(), 1); + assert_eq!(committed[0].1.balance, 100); + assert_eq!( + committed[0] + .1 + .last_updated_balance_block_time + .unwrap() + .height, + 10 + ); + } + #[tokio::test] + async fn should_reject_a_query_newer_than_the_last_refresh_but_older_than_a_transaction() { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&iw.wallet_id) + .unwrap() + .identity_manager + .wallet_identity_mut(&iw.wallet_id, &id) + .unwrap(); + iw.persist_refreshed_balance(managed, 300, BlockTime::new(8, 8, 8)) + .unwrap(); + managed.persist_confirmed_balance(100, BlockTime::new(10, 42, 1000), &iw.persister); + for height in [9, 10] { + iw.persist_refreshed_balance( + managed, + 200, + BlockTime::new(height, height as u32, height), + ) + .unwrap(); + assert_eq!(managed.identity.balance(), 100); + } + assert_eq!(backend.committed.lock().unwrap().len(), 2); + iw.persist_refreshed_balance(managed, 50, BlockTime::new(11, 11, 11)) + .unwrap(); + assert_eq!(managed.identity.balance(), 50); + assert_eq!(backend.committed.lock().unwrap().len(), 3); + } + #[tokio::test] + async fn should_preserve_store_failure_kind_with_backend_retry_guarantee() { + use crate::changeset::PersistenceErrorKind; + for reissuable in [false, true] { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + backend.transient_store.store(true, Ordering::SeqCst); + backend.store_reissuable.store(reissuable, Ordering::SeqCst); + let error = iw.refresh_identity_balance(&id).await.unwrap_err(); + let PlatformWalletError::PersisterStore(source) = error else { + panic!("lost typed store failure") + }; + assert_eq!( + source.kind(), + Some(if reissuable { + PersistenceErrorKind::Transient + } else { + PersistenceErrorKind::Fatal + }) + ); + assert_eq!(local_balance(&iw, &id).await, OLD_BALANCE); + } + } + fn reload_balance(backend: &BalancePersister, id: &Identifier) -> (u64, Option) { + let mut reloaded = IdentityManager::new(); + for (_, entry) in backend.committed.lock().unwrap().iter() { + reloaded.apply_identity_entry(entry.clone()); + } + let managed = reloaded.identity(id).unwrap(); + ( + managed.identity.balance(), + managed.last_updated_balance_block_time, + ) + } + + #[tokio::test] + async fn should_retry_failed_height_ten_flush_before_height_nine_and_reload_ten() { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&iw.wallet_id) + .unwrap() + .identity_manager + .wallet_identity_mut(&iw.wallet_id, &id) + .unwrap(); + let newer = BlockTime::new(10, 42, 1000); + backend.fail_flush.store(true, Ordering::SeqCst); + assert!(iw.persist_refreshed_balance(managed, 100, newer).is_err()); + assert_eq!(managed.identity.balance(), OLD_BALANCE); + assert!(iw + .persist_refreshed_balance(managed, 200, BlockTime::new(9, 41, 900)) + .is_err()); + assert!(backend + .queued + .lock() + .unwrap() + .iter() + .all(|(_, entry)| entry.balance == 100)); + backend.fail_flush.store(false, Ordering::SeqCst); + iw.persist_refreshed_balance(managed, 200, BlockTime::new(9, 41, 900)) + .unwrap(); + assert_eq!(managed.identity.balance(), 100); + assert_eq!(reload_balance(&backend, &id), (100, Some(newer))); + assert!(backend.queued.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn should_carry_pending_balance_through_an_unrelated_scalar_write() { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&iw.wallet_id) + .unwrap() + .identity_manager + .wallet_identity_mut(&iw.wallet_id, &id) + .unwrap(); + let newer = BlockTime::new(10, 42, 1000); + backend.fail_flush.store(true, Ordering::SeqCst); + assert!(iw.persist_refreshed_balance(managed, 100, newer).is_err()); + // This used to queue the published OLD_BALANCE behind the height-10 row. + managed.update_keys_sync_block_time(BlockTime::new(11, 43, 1100), &iw.persister); + backend.fail_flush.store(false, Ordering::SeqCst); + iw.persister.flush().unwrap(); + assert_eq!(reload_balance(&backend, &id), (100, Some(newer))); + iw.persist_refreshed_balance(managed, 200, BlockTime::new(9, 41, 900)) + .unwrap(); + assert_eq!(managed.identity.balance(), 100); + assert_eq!( + managed.last_synced_keys_block_time, + Some(BlockTime::new(11, 43, 1100)) + ); + } + + #[tokio::test] + async fn should_retry_failed_confirmed_balance_store_before_equal_or_older_refresh() { + for refresh_height in [9, 10] { + for inline in [false, true] { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&iw.wallet_id) + .unwrap() + .identity_manager + .wallet_identity_mut(&iw.wallet_id, &id) + .unwrap(); + let confirmed = BlockTime::new(10, 42, 1000); + backend.commits_inline.store(inline, Ordering::SeqCst); + backend.fail_store.store(true, Ordering::SeqCst); + assert_eq!( + managed.persist_confirmed_balance(100, confirmed, &iw.persister), + 100 + ); + assert_eq!(managed.last_updated_balance_block_time, Some(confirmed)); + assert!(!managed.needs_balance_update(1050, 100)); + assert!(backend.committed.lock().unwrap().is_empty()); + backend.fail_store.store(false, Ordering::SeqCst); + iw.persist_refreshed_balance(managed, 200, BlockTime::new(refresh_height, 41, 900)) + .unwrap(); + assert_eq!(reload_balance(&backend, &id), (100, Some(confirmed))); + } + } + } + + #[tokio::test] + async fn should_keep_one_snapshot_per_height_and_return_the_retained_balance() { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&iw.wallet_id) + .unwrap() + .identity_manager + .wallet_identity_mut(&iw.wallet_id, &id) + .unwrap(); + let confirmed = BlockTime::new(10, 42, 1000); + assert_eq!( + managed.persist_confirmed_balance(100, confirmed, &iw.persister), + 100 + ); + for height in [9, 10] { + // A delayed result cannot replace the height-pinned state, return a + // different balance to the host, or queue an obsolete snapshot. + assert_eq!( + managed.persist_confirmed_balance( + 200, + BlockTime::new(height, 41, 900), + &iw.persister + ), + 100 + ); + } + assert_eq!(backend.committed.lock().unwrap().len(), 1); + assert_eq!(reload_balance(&backend, &id), (100, Some(confirmed))); + } + + #[tokio::test] + async fn should_compare_query_and_transaction_proofs_on_the_same_chain_height() { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&iw.wallet_id) + .unwrap() + .identity_manager + .wallet_identity_mut(&iw.wallet_id, &id) + .unwrap(); + let query = BlockTime::new(1000, 42, 1000); + iw.persist_refreshed_balance(managed, 100, query).unwrap(); + assert_eq!( + managed.persist_confirmed_balance(200, BlockTime::new(999, 41, 900), &iw.persister), + 100 + ); + assert_eq!(backend.committed.lock().unwrap().len(), 1); + assert_eq!(reload_balance(&backend, &id), (100, Some(query))); + } + + #[tokio::test] + async fn should_replace_a_failed_query_write_with_a_newer_transaction_snapshot() { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&iw.wallet_id) + .unwrap() + .identity_manager + .wallet_identity_mut(&iw.wallet_id, &id) + .unwrap(); + backend.fail_flush.store(true, Ordering::SeqCst); + assert!(iw + .persist_refreshed_balance(managed, 200, BlockTime::new(9, 41, 900)) + .is_err()); + backend.fail_flush.store(false, Ordering::SeqCst); + let confirmed = BlockTime::new(10, 42, 1000); + assert_eq!( + managed.persist_confirmed_balance(100, confirmed, &iw.persister), + 100 + ); + assert_eq!(reload_balance(&backend, &id), (100, Some(confirmed))); + let count = backend.committed.lock().unwrap().len(); + iw.persist_refreshed_balance(managed, 200, BlockTime::new(9, 41, 900)) + .unwrap(); + assert_eq!(backend.committed.lock().unwrap().len(), count); + } + #[tokio::test] + async fn should_publish_a_confirmation_matching_a_failed_query_without_losing_retry() { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&iw.wallet_id) + .unwrap() + .identity_manager + .wallet_identity_mut(&iw.wallet_id, &id) + .unwrap(); + let confirmed = BlockTime::new(10, 42, 1000); + backend.fail_store.store(true, Ordering::SeqCst); + assert!(iw + .persist_refreshed_balance(managed, 100, confirmed) + .is_err()); + assert_eq!(managed.identity.balance(), OLD_BALANCE); + assert_eq!( + managed.persist_confirmed_balance(100, confirmed, &iw.persister), + 100 + ); + assert_eq!(managed.last_updated_balance_block_time, Some(confirmed)); + assert!(backend.committed.lock().unwrap().is_empty()); + backend.fail_store.store(false, Ordering::SeqCst); + iw.persist_refreshed_balance(managed, 200, BlockTime::new(9, 41, 900)) + .unwrap(); + assert_eq!(reload_balance(&backend, &id), (100, Some(confirmed))); + } + + #[tokio::test] + async fn should_preserve_balance_watermark_and_pending_write_when_replay_revision_is_rejected() + { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + let mut wm = iw.wallet_manager.write().await; + let manager = &mut wm + .get_wallet_info_mut(&iw.wallet_id) + .unwrap() + .identity_manager; + let managed = manager.wallet_identity_mut(&iw.wallet_id, &id).unwrap(); + let previous = BlockTime::new(9, 41, 900); + managed.last_updated_balance_block_time = Some(previous); + let pending = BlockTime::new(10, 42, 1000); + backend.fail_store.store(true, Ordering::SeqCst); + assert!(iw.persist_refreshed_balance(managed, 100, pending).is_err()); + let mut entry = IdentityEntry::from_managed(managed); + entry.revision = 6; + entry.balance = 200; + entry.last_updated_balance_block_time = Some(BlockTime::new(20, 50, 2000)); + manager.apply_identity_entry(entry); + let managed = manager.wallet_identity_mut(&iw.wallet_id, &id).unwrap(); + assert_eq!(managed.identity.balance(), OLD_BALANCE); + assert_eq!(managed.identity.revision(), 7); + assert_eq!(managed.last_updated_balance_block_time, Some(previous)); + assert_eq!( + managed.balance_snapshot_for_persistence(), + (100, Some(pending)) + ); + backend.fail_store.store(false, Ordering::SeqCst); + managed.retry_pending_balance(&iw.persister).unwrap(); + assert_eq!(reload_balance(&backend, &id), (100, Some(pending))); + // The rejected entry must not poison the gate for a later proven read. + let next = BlockTime::new(20, 50, 2000); + iw.persist_refreshed_balance(managed, 150, next).unwrap(); + assert_eq!(reload_balance(&backend, &id), (150, Some(next))); + } + + #[tokio::test] + async fn should_clear_superseded_pending_balance_when_replaying_an_accepted_entry() { + for revision in [7, 8] { + for watermark in [ + None, + Some(BlockTime::new(9, 41, 900)), + Some(BlockTime::new(11, 43, 1100)), + ] { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + let mut wm = iw.wallet_manager.write().await; + let manager = &mut wm + .get_wallet_info_mut(&iw.wallet_id) + .unwrap() + .identity_manager; + let managed = manager.wallet_identity_mut(&iw.wallet_id, &id).unwrap(); + backend.fail_store.store(true, Ordering::SeqCst); + assert!(iw + .persist_refreshed_balance(managed, 100, BlockTime::new(10, 42, 1000)) + .is_err()); + let mut entry = IdentityEntry::from_managed(managed); + entry.revision = revision; + entry.balance = 200; + entry.last_updated_balance_block_time = watermark; + manager.apply_identity_entry(entry); + let managed = manager.wallet_identity_mut(&iw.wallet_id, &id).unwrap(); + assert_eq!(managed.identity.balance(), 200); + assert_eq!(managed.identity.revision(), revision); + assert_eq!(managed.last_updated_balance_block_time, watermark); + assert_eq!(managed.balance_snapshot_for_persistence(), (200, watermark)); + backend.fail_store.store(false, Ordering::SeqCst); + managed.retry_pending_balance(&iw.persister).unwrap(); + assert!(backend.queued.lock().unwrap().is_empty()); + assert!(backend.committed.lock().unwrap().is_empty()); + managed.update_keys_sync_block_time(BlockTime::new(30, 60, 3000), &iw.persister); + iw.persister.flush().unwrap(); + assert_eq!(reload_balance(&backend, &id), (200, watermark)); + } + } + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs index 6b20dd79964..d3dbd956b26 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -20,6 +20,7 @@ //! `SpvBroadcaster` so most call sites don't need to name it. // Core handle + identity-lifecycle operations. +mod balance; mod contract; mod discovery; mod document; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs b/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs index 9cdb0e59977..517bb230f80 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs @@ -50,7 +50,6 @@ use std::collections::BTreeMap; -use dpp::identity::accessors::IdentitySettersV0; use dpp::identity::signer::Signer; use dpp::identity::v0::IdentityV0; use dpp::identity::Identity; @@ -63,13 +62,14 @@ use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundin use dash_sdk::platform::transition::put_identity::PutIdentity; use dash_sdk::platform::transition::put_settings::PutSettings; -use dash_sdk::platform::transition::top_up_identity::TopUpIdentity; +use dash_sdk::platform::transition::top_up_identity::TopUpIdentityWithMetadata; use crate::error::{is_instant_lock_proof_invalid, PlatformWalletError}; use crate::wallet::asset_lock::orchestration::{ out_point_from_proof, submit_with_cl_height_retry, FundingResolution, ResolvedFunding, }; use crate::wallet::asset_lock::AssetLockFunding; +use crate::BlockTime; use super::*; @@ -467,7 +467,7 @@ impl IdentityWallet { // same outpoint. let proof_out_point = out_point_from_proof(&proof); let (submit_result, effective_proof) = match submit_with_cl_height_retry(settings, |s| { - identity.top_up_identity_with_signer( + identity.top_up_identity_with_signer_with_metadata( &self.sdk, proof.clone(), &path, @@ -490,7 +490,7 @@ impl IdentityWallet { .upgrade_to_chain_lock_proof(&out_point, None) .await?; let submit_result = submit_with_cl_height_retry(settings, |s| { - identity.top_up_identity_with_signer( + identity.top_up_identity_with_signer_with_metadata( &self.sdk, chain_proof.clone(), &path, @@ -503,7 +503,7 @@ impl IdentityWallet { } Err(e) => (Err(e), proof.clone()), }; - let new_balance = self + let (mut new_balance, metadata) = self .asset_locks .reconcile_asset_lock_submit_result( submit_result, @@ -529,14 +529,11 @@ impl IdentityWallet { match wm.get_wallet_info_mut(&self.wallet_id) { Some(info) => { if let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) { - managed.identity.set_balance(new_balance); - if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { - tracing::error!( - identity = %identity_id, - error = %e, - "Failed to persist identity balance update after top_up" - ); - } + new_balance = managed.persist_confirmed_balance( + new_balance, + BlockTime::from(metadata), + &self.persister, + ); } } None => { diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs b/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs index 35f79e8ac37..38096472926 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs @@ -2,12 +2,11 @@ use std::collections::BTreeMap; -use dpp::identity::accessors::IdentitySettersV0; use dpp::identity::signer::Signer; use dpp::prelude::Identifier; use dash_sdk::platform::transition::put_settings::PutSettings; -use dash_sdk::platform::transition::top_up_identity_from_addresses::TopUpIdentityFromAddresses; +use dash_sdk::platform::transition::top_up_identity_from_addresses::TopUpIdentityFromAddressesWithMetadata; use dpp::address_funds::PlatformAddress; use dpp::fee::Credits; @@ -15,6 +14,7 @@ use dpp::fee::Credits; use dash_sdk::query_types::AddressInfos; use crate::error::PlatformWalletError; +use crate::BlockTime; use super::*; @@ -72,8 +72,8 @@ impl IdentityWallet { .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))? }; - let (address_infos, new_balance, proof_height) = identity - .top_up_from_addresses(&self.sdk, inputs, address_signer, settings) + let (address_infos, mut new_balance, metadata) = identity + .top_up_from_addresses_with_metadata(&self.sdk, inputs, address_signer, settings) .await .map_err(|e| { crate::error::promote_address_nonce_error(&e).unwrap_or_else(|| { @@ -84,11 +84,9 @@ impl IdentityWallet { }) })?; - // Update the identity's balance in the local manager and - // queue the snapshot so the new balance survives relaunch. - // See the comment on `top_up` for rationale on driving the - // persister directly from the call site instead of through - // a dedicated `ManagedIdentity::set_balance` method. + let proof_height = metadata.height; + + // Reconcile the verified identity snapshot; failed cache writes remain pending. { let mut wm = self.wallet_manager.write().await; let info = wm.get_wallet_info_mut(&self.wallet_id).ok_or_else(|| { @@ -97,14 +95,11 @@ impl IdentityWallet { ) })?; if let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) { - managed.identity.set_balance(new_balance); - if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { - tracing::error!( - identity = %identity_id, - error = %e, - "Failed to persist identity balance update after top_up_from_addresses" - ); - } + new_balance = managed.persist_confirmed_balance( + new_balance, + BlockTime::from(metadata), + &self.persister, + ); } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/transfer.rs b/packages/rs-platform-wallet/src/wallet/identity/network/transfer.rs index 5ea517a1a09..3fefc2e7fb4 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/transfer.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/transfer.rs @@ -2,7 +2,6 @@ use async_trait::async_trait; use dpp::address_funds::AddressWitness; -use dpp::identity::accessors::IdentitySettersV0; use dpp::identity::Identity; use dpp::identity::IdentityPublicKey; use dpp::platform_value::BinaryData; @@ -12,9 +11,12 @@ use dpp::ProtocolError; use dpp::identity::signer::Signer; use dash_sdk::platform::transition::put_settings::PutSettings; -use dash_sdk::platform::transition::transfer::TransferToIdentity; +use dash_sdk::platform::transition::transfer::{ + TransferToIdentity, TransferToIdentityWithMetadata, +}; use crate::error::PlatformWalletError; +use crate::BlockTime; use super::signing_key::credit_signing_key; use super::*; @@ -97,8 +99,8 @@ impl IdentityWallet { .ok_or(PlatformWalletError::IdentityNotFound(*from_id))? }; - let (sender_balance, _receiver_balance) = identity - .transfer_credits( + let ((sender_balance, _receiver_balance), metadata) = identity + .transfer_credits_with_metadata( &self.sdk, *to_id, amount, @@ -129,14 +131,11 @@ impl IdentityWallet { ) })?; if let Some(managed) = info.identity_manager.managed_identity_mut(from_id) { - managed.identity.set_balance(sender_balance); - if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { - tracing::error!( - identity = %from_id, - error = %e, - "Failed to persist identity balance update after transfer (external signer)" - ); - } + managed.persist_confirmed_balance( + sender_balance, + BlockTime::from(metadata), + &self.persister, + ); } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/transfer_to_addresses.rs b/packages/rs-platform-wallet/src/wallet/identity/network/transfer_to_addresses.rs index e8dbed92db4..66d0862cd45 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/transfer_to_addresses.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/transfer_to_addresses.rs @@ -4,7 +4,6 @@ use std::collections::BTreeMap; use async_trait::async_trait; use dpp::address_funds::AddressWitness; -use dpp::identity::accessors::IdentitySettersV0; use dpp::identity::signer::Signer; use dpp::identity::IdentityPublicKey; use dpp::platform_value::BinaryData; @@ -12,12 +11,13 @@ use dpp::prelude::Identifier; use dpp::ProtocolError; use dash_sdk::platform::transition::put_settings::PutSettings; -use dash_sdk::platform::transition::transfer_to_addresses::TransferToAddresses; +use dash_sdk::platform::transition::transfer_to_addresses::TransferToAddressesWithMetadata; use dpp::address_funds::PlatformAddress; use dpp::fee::Credits; use crate::error::PlatformWalletError; +use crate::BlockTime; use super::signing_key::credit_signing_key; use super::*; @@ -101,8 +101,8 @@ impl IdentityWallet { .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))? }; - let (address_infos, new_balance, proof_height) = identity - .transfer_credits_to_addresses( + let (address_infos, mut new_balance, metadata) = identity + .transfer_credits_to_addresses_with_metadata( &self.sdk, recipient_addresses, Some(credit_signing_key(&identity, None, signer, false)?), @@ -122,6 +122,8 @@ impl IdentityWallet { }) })?; + let proof_height = metadata.height; + { let mut wm = self.wallet_manager.write().await; let info_guard = wm.get_wallet_info_mut(&self.wallet_id).ok_or_else(|| { @@ -133,15 +135,11 @@ impl IdentityWallet { .identity_manager .managed_identity_mut(identity_id) { - managed.identity.set_balance(new_balance); - if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { - tracing::error!( - identity = %identity_id, - error = %e, - "Failed to persist identity balance update after \ - transfer_to_addresses (external signer)" - ); - } + new_balance = managed.persist_confirmed_balance( + new_balance, + BlockTime::from(metadata), + &self.persister, + ); } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs b/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs index 1340f634a38..d7219ed37c1 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs @@ -3,7 +3,6 @@ use async_trait::async_trait; use dashcore::Address as DashAddress; use dpp::address_funds::AddressWitness; -use dpp::identity::accessors::IdentitySettersV0; use dpp::identity::Identity; use dpp::identity::IdentityPublicKey; use dpp::identity::Purpose; @@ -14,9 +13,12 @@ use dpp::ProtocolError; use dpp::identity::signer::Signer; use dash_sdk::platform::transition::put_settings::PutSettings; -use dash_sdk::platform::transition::withdraw_from_identity::WithdrawFromIdentity; +use dash_sdk::platform::transition::withdraw_from_identity::{ + WithdrawFromIdentity, WithdrawFromIdentityWithMetadata, +}; use crate::error::PlatformWalletError; +use crate::BlockTime; use super::signing_key::credit_signing_key; use super::*; @@ -94,8 +96,8 @@ impl IdentityWallet { .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))? }; - let new_balance = identity - .withdraw( + let (new_balance, metadata) = identity + .withdraw_with_metadata( &self.sdk, Some(to_address.clone()), amount, @@ -125,14 +127,11 @@ impl IdentityWallet { ) })?; if let Some(managed) = info_guard.identity_manager.identity_mut(identity_id) { - managed.identity.set_balance(new_balance); - if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { - tracing::error!( - identity = %identity_id, - error = %e, - "Failed to persist identity balance update after withdraw (external signer)" - ); - } + managed.persist_confirmed_balance( + new_balance, + BlockTime::from(metadata), + &self.persister, + ); } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index 1f95e1e1c23..1a4a839651a 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -80,6 +80,7 @@ impl ManagedIdentity { identity, identity_index: Some(identity_index), last_updated_balance_block_time: None, + pending_balance_snapshot: None, last_synced_keys_block_time: None, status: Default::default(), dpns_names: Vec::new(), @@ -100,6 +101,7 @@ impl ManagedIdentity { identity, identity_index: None, last_updated_balance_block_time: None, + pending_balance_snapshot: None, last_synced_keys_block_time: None, status: Default::default(), dpns_names: Vec::new(), diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs index c1afd616375..6038779d572 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs @@ -53,6 +53,11 @@ pub struct ManagedIdentity { /// Last block time when balance was updated for this identity pub last_updated_balance_block_time: Option, + /// Latest verified balance still owed to persistence. Retained after either + /// store or flush fails; scalar snapshots must carry it until a successful + /// retry. Query results are published only after that retry commits. + pending_balance_snapshot: Option<(u64, BlockTime)>, + /// Last block time when keys were synced for this identity pub last_synced_keys_block_time: Option, diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs index 6e09b493126..0450f0f690c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs @@ -1,11 +1,134 @@ //! Synchronization and block time management for ManagedIdentity use super::ManagedIdentity; +use crate::error::PlatformWalletError; use crate::wallet::persister::WalletPersister; use crate::BlockTime; +use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; use dpp::prelude::TimestampMillis; impl ManagedIdentity { + /// Replay a persisted snapshot selected by the manager's revision policy. + /// Its balance and watermark replace the old pair together, including any + /// uncommitted snapshot superseded by the accepted persisted entry. + pub(crate) fn restore_persisted_balance( + &mut self, + balance: u64, + block_time: Option, + ) { + self.identity.set_balance(balance); + self.last_updated_balance_block_time = block_time; + self.pending_balance_snapshot = None; + } + + /// The pending balance also rides unrelated scalar snapshots so a later + /// profile/key-sync write cannot queue the old balance behind a failed flush. + pub(crate) fn balance_snapshot_for_persistence(&self) -> (u64, Option) { + if let Some((balance, block_time)) = self.pending_balance_snapshot { + if self + .last_updated_balance_block_time + .is_none_or(|current| block_time.height >= current.height) + { + return (balance, Some(block_time)); + } + } + ( + self.identity.balance(), + self.last_updated_balance_block_time, + ) + } + + fn balance_snapshot_is_newer(&self, block_time: BlockTime) -> bool { + // Both Fetch and the transaction affected-state waits + // verify a GroveDB root AND its quorum-signed StateId. Their heights + // identify committed snapshots of the same chain, not independent node + // clocks. Equal heights cannot order two transaction completions. + let (_, previous) = self.balance_snapshot_for_persistence(); + if let Some(previous) = previous { + if block_time.height < previous.height { + tracing::warn!( + identity = %self.id(), + response_height = block_time.height, + retained_height = previous.height, + "Ignoring an older verified balance snapshot" + ); + return false; + } + if block_time.height == previous.height { + tracing::debug!(identity = %self.id(), height = block_time.height, + "Ignoring a duplicate-height verified balance snapshot"); + return false; + } + } + true + } + + /// Retry an idempotent scalar snapshot before the watermark can suppress it. + /// The caller holds the wallet-manager write lock through commit/publication. + pub(crate) fn retry_pending_balance( + &mut self, + persister: &WalletPersister, + ) -> Result<(), PlatformWalletError> { + if self.pending_balance_snapshot.is_none() { + return Ok(()); + } + let (balance, block_time) = self.balance_snapshot_for_persistence(); + persister + .store(self.snapshot_changeset().into()) + .map_err(|e| persister.classify_store_failure(e))?; + if !persister.store_commits_inline() { + persister + .flush() + .map_err(|e| PlatformWalletError::Persistence(e.to_string()))?; + } + self.identity.set_balance(balance); + self.last_updated_balance_block_time = block_time; + self.pending_balance_snapshot = None; + Ok(()) + } + + pub(crate) fn persist_refreshed_balance( + &mut self, + balance: u64, + block_time: BlockTime, + persister: &WalletPersister, + ) -> Result<(), PlatformWalletError> { + self.retry_pending_balance(persister)?; + if self.balance_snapshot_is_newer(block_time) { + self.pending_balance_snapshot = Some((balance, block_time)); + self.retry_pending_balance(persister)?; + } + Ok(()) + } + + /// Keep a verified post-broadcast snapshot even if its cache write fails. + /// A cache failure must not invite a second payment. Retain the write for a + /// later refresh/transaction, and return the balance actually kept locally. + pub(crate) fn persist_confirmed_balance( + &mut self, + balance: u64, + block_time: BlockTime, + persister: &WalletPersister, + ) -> u64 { + if self.balance_snapshot_is_newer(block_time) { + self.identity.set_balance(balance); + self.last_updated_balance_block_time = Some(block_time); + self.pending_balance_snapshot = Some((balance, block_time)); + } + // A transaction response can confirm the exact pending query snapshot + // whose cache write failed. Publish that verified value now, while + // retaining the same write obligation if storage is still unavailable. + if self.pending_balance_snapshot == Some((balance, block_time)) { + self.identity.set_balance(balance); + self.last_updated_balance_block_time = Some(block_time); + } + if let Err(error) = self.retry_pending_balance(persister) { + tracing::error!(identity = %self.id(), %error, + "Failed to persist confirmed identity balance; snapshot retained for retry"); + } + self.identity.balance() + } + /// Update the last balance update block time. /// /// Persists the resulting changeset via `persister` and returns `()`. diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs index 7317eb129cf..03f8ed10004 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs @@ -49,10 +49,12 @@ impl IdentityManager { // Updating an existing identity — find it across both buckets. if let Some(existing) = self.locate_mut(&id) { if entry.revision >= existing.identity.revision() { - existing.identity.set_balance(entry.balance); + existing.restore_persisted_balance( + entry.balance, + entry.last_updated_balance_block_time, + ); existing.identity.set_revision(entry.revision); } - existing.last_updated_balance_block_time = entry.last_updated_balance_block_time; existing.last_synced_keys_block_time = entry.last_synced_keys_block_time; existing.status = entry.status; *existing.dashpay_profile_mut() = entry.dashpay_profile; diff --git a/packages/rs-platform-wallet/src/wallet/identity/types/block_time.rs b/packages/rs-platform-wallet/src/wallet/identity/types/block_time.rs index b4291e5b57a..073ad187b9d 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/types/block_time.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/types/block_time.rs @@ -39,10 +39,33 @@ impl BlockTime { } } +impl From for BlockTime { + fn from(metadata: dash_sdk::dapi_grpc::platform::v0::ResponseMetadata) -> Self { + Self::new( + metadata.height, + metadata.core_chain_locked_height, + metadata.time_ms, + ) + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn should_preserve_all_verified_response_block_fields() { + let metadata = dash_sdk::dapi_grpc::platform::v0::ResponseMetadata { + height: 1000, + core_chain_locked_height: 42, + time_ms: 1_700_000_000_000, + ..Default::default() + }; + let block_time = BlockTime::from(metadata); + assert_eq!(block_time, BlockTime::new(1000, 42, 1_700_000_000_000)); + assert!(!block_time.is_older_than(1_700_000_000_050, 100)); + } + #[test] fn test_block_time_creation() { let block_time = BlockTime::new(100000, 900000, 1234567890); diff --git a/packages/rs-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs index fec0ce4638f..fbfefcb2f9d 100644 --- a/packages/rs-platform-wallet/src/wallet/persister.rs +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -13,6 +13,7 @@ use crate::changeset::{ ClientStartState, DpnsNameStateEntry, PersistenceCapabilities, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, }; +use crate::error::PlatformWalletError; use crate::wallet::platform_wallet::WalletId; use dpp::prelude::Identifier; @@ -66,6 +67,12 @@ impl WalletPersister { self.inner.store(self.wallet_id, changeset) } + /// Preserve backend failure kinds, granting retry only when the backend + /// guarantees a failed store retained and committed nothing. + pub(crate) fn classify_store_failure(&self, error: PersistenceError) -> PlatformWalletError { + PlatformWalletError::from_store_failure(self.inner.as_ref(), error) + } + pub(crate) fn flush(&self) -> Result<(), PersistenceError> { self.inner.flush(self.wallet_id) } diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 06ba7152022..7cbe111776d 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -23,6 +23,8 @@ use super::platform_addresses::merge_platform_payment_candidate_addresses; use super::platform_addresses::PlatformAddressWallet; #[cfg(feature = "shielded")] use super::shielded::operations::shield_fee_reserve_credits; +#[cfg(feature = "shielded")] +use crate::BlockTime; // Phase 4d.3 deleted the `ShieldedWallet` wrapper; per-account // keysets now live in `self.shielded_keys` directly. Spend // operations source the shared commitment-tree store from @@ -35,7 +37,6 @@ use crate::error::PlatformWalletError; use dash_sdk::platform::transition::put_settings::PutSettings; use dpp::address_funds::PlatformAddress; use dpp::fee::Credits; -use dpp::identity::accessors::IdentitySettersV0; use dpp::identity::signer::Signer; use dpp::identity::{Identity, IdentityPublicKey}; use dpp::prelude::Identifier; @@ -1467,18 +1468,19 @@ impl PlatformWallet { let _shield_guard = self.shield_guard.lock().await; let keyset = self.derive_spend_keyset(seed, account).await?; - let proven_balance = super::shielded::operations::identity_top_up_from_pool( - &self.sdk, - coordinator.store(), - Some(&self.persister), - self.wallet_id, - &keyset, - account, - *identity_id, - amount, - &prover, - ) - .await?; + let (mut proven_balance, metadata) = + super::shielded::operations::identity_top_up_from_pool_with_metadata( + &self.sdk, + coordinator.store(), + Some(&self.persister), + self.wallet_id, + &keyset, + account, + *identity_id, + amount, + &prover, + ) + .await?; // The target may be one of this wallet's identities. Apply the proof-attested // balance rather than adding `amount` locally: the fee is carved from the @@ -1490,14 +1492,11 @@ impl PlatformWallet { .get_wallet_info_mut(&self.wallet_id) .and_then(|info| info.identity_manager.managed_identity_mut(identity_id)); if let Some(managed) = managed { - managed.identity.set_balance(balance); - if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { - tracing::error!( - identity = %identity_id, - error = %e, - "Failed to persist identity balance update after shielded top-up" - ); - } + proven_balance = Some(managed.persist_confirmed_balance( + balance, + BlockTime::from(metadata), + &self.persister, + )); } } @@ -2078,7 +2077,7 @@ impl PlatformWallet { })? .clone() }; - let new_balance = super::shielded::operations::shield_from_identity_to( + let (mut new_balance, metadata) = super::shielded::operations::shield_from_identity_to( &self.sdk, coordinator.store(), Some(&self.persister), @@ -2104,18 +2103,11 @@ impl PlatformWallet { .get_wallet_info_mut(&self.wallet_id) .and_then(|info| info.identity_manager.managed_identity_mut(identity_id)); if let Some(managed) = managed { - managed.identity.set_balance(new_balance); - if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { - // Broadcast already happened. Returning a transaction error - // could prompt a second payment; it cannot undo the debit. - // Keep the proven in-memory balance and report the cache - // failure separately in diagnostics. - tracing::error!( - identity = %identity_id, - error = %e, - "Failed to persist identity balance update after shield from identity" - ); - } + new_balance = managed.persist_confirmed_balance( + new_balance, + BlockTime::from(metadata), + &self.persister, + ); } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 0eda804a40d..399d5160f1f 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -37,6 +37,7 @@ use crate::wallet::platform_wallet::WalletId; use std::collections::{BTreeMap, HashSet}; use std::sync::Arc; +use dash_sdk::dapi_grpc::platform::v0::ResponseMetadata; use dash_sdk::platform::fetch_current_no_parameters::FetchCurrent; use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; use dash_sdk::platform::transition::identity_create_from_shielded_pool::IdentityCreateFromShieldedPool; @@ -938,7 +939,7 @@ pub(in crate::wallet) async fn shield_from_identity_to< memo: [u8; 36], signer: &Sig, prover: &P, -) -> Result { +) -> Result<(Credits, ResponseMetadata), PlatformWalletError> { let ShieldRecipient { address: recipient_addr, counterparty: external_counterparty, @@ -1060,18 +1061,19 @@ pub(in crate::wallet) async fn shield_from_identity_to< // `wait_for_affected_state` only converts the proof generically, so the variant // and the identity are enforced here: only this identity's balance proof is // accepted as the post-debit balance. - let proof_outcome: Result = match state_transition - .wait_for_affected_state::(sdk, None) + let proof_outcome: Result<(Credits, ResponseMetadata), String> = match state_transition + .wait_for_affected_state_with_metadata::(sdk, None) .await { - Ok(StateTransitionProofResult::VerifiedPartialIdentity(partial)) + Ok((StateTransitionProofResult::VerifiedPartialIdentity(partial), metadata)) if partial.id == identity_id => { partial .balance .ok_or_else(|| "the identity proof did not include the updated balance".to_string()) + .map(|balance| (balance, metadata)) } - Ok(StateTransitionProofResult::VerifiedPartialIdentity(partial)) => Err(format!( + Ok((StateTransitionProofResult::VerifiedPartialIdentity(partial), _)) => Err(format!( "the proof returned identity {} but {} initiated the shield", partial.id, identity_id )), @@ -1362,6 +1364,36 @@ pub async fn identity_top_up_from_pool( amount: u64, prover: &P, ) -> Result, PlatformWalletError> { + identity_top_up_from_pool_with_metadata( + sdk, + store, + persister, + wallet_id, + keys, + account, + identity_id, + amount, + prover, + ) + .await + .map(|(result, _)| result) +} + +#[allow(clippy::too_many_arguments)] +pub(in crate::wallet) async fn identity_top_up_from_pool_with_metadata< + S: ShieldedStore, + P: OrchardProver, +>( + sdk: &Arc, + store: &Arc>, + persister: Option<&WalletPersister>, + wallet_id: WalletId, + keys: &OrchardKeySet, + account: u32, + identity_id: Identifier, + amount: u64, + prover: &P, +) -> Result<(Option, ResponseMetadata), PlatformWalletError> { let views = keys.viewing_keys(); let change_addr = default_orchard_address(&views)?; let id = SubwalletId::new(wallet_id, account); @@ -1431,7 +1463,7 @@ pub async fn identity_top_up_from_pool( // still proves the reserved notes are consumed and authenticates the // credited identity's balance; the shield, shield-from-identity and // identity-create paths accept the same class of outcome. - broadcast_shielded_spend_with_redrive( + broadcast_shielded_spend_with_redrive_with_metadata( sdk, store, id, @@ -1447,7 +1479,7 @@ pub async fn identity_top_up_from_pool( .await; match result { - Ok(proof) => { + Ok((proof, metadata)) => { record_activity_status( store, persister, @@ -1500,7 +1532,7 @@ pub async fn identity_top_up_from_pool( None } }; - Ok(proven_balance) + Ok((proven_balance, metadata)) } Err(e @ PlatformWalletError::ShieldedSpendUnconfirmed { .. }) => Err(e), Err(e) => { @@ -2903,6 +2935,33 @@ async fn broadcast_shielded_spend_with_redrive( operation: &'static str, wait: SpendResultWait, ) -> Result { + broadcast_shielded_spend_with_redrive_with_metadata( + sdk, + store, + id, + pending_entry, + anchor, + notes, + state_transition, + operation, + wait, + ) + .await + .map(|(result, _)| result) +} + +#[allow(clippy::too_many_arguments)] +async fn broadcast_shielded_spend_with_redrive_with_metadata( + sdk: &Arc, + store: &Arc>, + id: SubwalletId, + pending_entry: &Option, + anchor: [u8; 32], + notes: &[ShieldedNote], + state_transition: &StateTransition, + operation: &'static str, + wait: SpendResultWait, +) -> Result<(StateTransitionProofResult, ResponseMetadata), PlatformWalletError> { let result = broadcast_shielded_spend(sdk, state_transition, operation, wait).await; if matches!( &result, @@ -3314,7 +3373,7 @@ async fn broadcast_shielded_spend( state_transition: &StateTransition, operation: &'static str, wait: SpendResultWait, -) -> Result { +) -> Result<(StateTransitionProofResult, ResponseMetadata), PlatformWalletError> { match state_transition.broadcast(sdk, None).await { Ok(()) => {} Err(e) if broadcast_definitely_failed(&e) => { @@ -3338,12 +3397,12 @@ async fn broadcast_shielded_spend( let waited = match wait { SpendResultWait::ExecutionProved => { state_transition - .wait_for_response::(sdk, None) + .wait_for_response_with_metadata::(sdk, None) .await } SpendResultWait::AffectedState => { state_transition - .wait_for_affected_state::(sdk, None) + .wait_for_affected_state_with_metadata::(sdk, None) .await } }; 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..eb520875fe4 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -18,9 +18,12 @@ 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}; +#[cfg(any(feature = "app-connect-contract", feature = "all-system-contracts"))] +use dpp::version::feature_initial_protocol_versions::APP_CONNECT_CONTRACT_INITIAL_PROTOCOL_VERSION; use dpp::version::PlatformVersion; use lru::LruCache; @@ -781,6 +784,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 +890,23 @@ impl ContextProvider for TrustedHttpContextProvider { )) }); } + + #[cfg(any(feature = "app-connect-contract", feature = "all-system-contracts"))] + // 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| { + ContextProviderError::Generic(format!( + "Failed to load AppConnect contract: {}", + e + )) + }); + } } // If not found in known contracts or system contracts, delegate to fallback provider if available @@ -1552,6 +1573,36 @@ mod tests { // The builder pattern is more appropriate since contracts are only added during initialization } + /// The app-connect system contract is served only from its 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 should_serve_app_connect_only_from_protocol_14() { + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::version::PlatformVersion; + + // A numeric loopback URL avoids DNS; contract lookups make no HTTP requests. + let provider = TrustedHttpContextProvider::new_with_url( + Network::Testnet, + "https://127.0.0.1".to_string(), + NonZeroUsize::new(100).unwrap(), + ) + .unwrap(); + let id = SystemDataContract::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::latest()) + .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); + } + #[test] fn test_domain_resolution_check() { // Test with a domain that should resolve (using localhost) diff --git a/packages/rs-sdk/Cargo.toml b/packages/rs-sdk/Cargo.toml index 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/packages/rs-sdk/src/mock/requests.rs b/packages/rs-sdk/src/mock/requests.rs index e3ef6b11ee0..e5d2adb1622 100644 --- a/packages/rs-sdk/src/mock/requests.rs +++ b/packages/rs-sdk/src/mock/requests.rs @@ -28,9 +28,9 @@ use drive::grovedb::Element; use drive_proof_verifier::types::identity_keys_remaining_budgets::IdentityKeysRemainingBudgets; use drive_proof_verifier::types::contract_moderation::{ ContractDocumentRemoval, ContractDocumentRemovalEntry, ContractDocumentRemovals, - ContractFeePotLastClaim, ContractFeePotState, ContractFeePots, ContractModerationEntries, + ContractDocumentRestoration, ContractFeePotLastClaim, ContractFeePotState, ContractFeePots, ContractModerationEntries, ContractModerationEntry, ContractModerationListStatus, - ContractModerationListStatuses, ContractModerationReason, + ContractModerationListStatuses, ContractModerationReason, ContractWarning, }; use drive_proof_verifier::types::contract_groups::{ ContractGroupInfo, ContractGroupMembersPage, ContractGroupMembershipsForContract, @@ -437,12 +437,28 @@ impl MockResponse for ContractFeePots { } } +/// One moderation list entry as a fixture holds it: the identity, the suspension end, the +/// reason and the warnings. `ContractModerationEntry` has no bincode encoding of its own. +type EncodedContractModerationEntry = ( + Identifier, + Option, + ContractModerationReason, + Vec, +); + impl MockResponse for ContractModerationEntries { fn mock_serialize(&self, _sdk: &MockDashPlatformSdk) -> Vec { - let entries: Vec<(Identifier, Option, ContractModerationReason)> = self + let entries: Vec = self .entries() .iter() - .map(|entry| (entry.identity_id, entry.until, entry.reason.clone())) + .map(|entry| { + ( + entry.identity_id, + entry.until, + entry.reason.clone(), + entry.warnings.clone(), + ) + }) .collect(); bincode::encode_to_vec(entries, BINCODE_CONFIG).expect("encode ContractModerationEntries") } @@ -451,30 +467,36 @@ impl MockResponse for ContractModerationEntries { where Self: Sized, { - let (entries, _): (Vec<(Identifier, Option, ContractModerationReason)>, _) = + let (entries, _): (Vec, _) = bincode::decode_from_slice(buf, BINCODE_CONFIG) .expect("decode ContractModerationEntries"); ContractModerationEntries( entries .into_iter() - .map(|(identity_id, until, reason)| ContractModerationEntry { - identity_id, - until, - reason, - }) + .map( + |(identity_id, until, reason, warnings)| ContractModerationEntry { + identity_id, + until, + reason, + warnings, + }, + ) .collect(), ) } } /// One removal record as a fixture holds it: the document id, its owner, the moderator, when -/// the removal happened and why. `ContractDocumentRemoval` has no bincode encoding of its own. +/// the removal happened, why, what the document hashed to, and who restored it and when if +/// anyone did. type EncodedContractDocumentRemoval = ( Identifier, Identifier, Identifier, u64, ContractModerationReason, + [u8; 32], + Option<(Identifier, u64)>, ); impl MockResponse for ContractDocumentRemovals { @@ -489,6 +511,12 @@ impl MockResponse for ContractDocumentRemovals { entry.removal.moderator_id, entry.removal.removed_at, entry.removal.reason.clone(), + entry.removal.document_hash, + entry + .removal + .restoration + .as_ref() + .map(|restoration| (restoration.moderator_id, restoration.restored_at)), ) }) .collect(); @@ -506,7 +534,15 @@ impl MockResponse for ContractDocumentRemovals { removals .into_iter() .map( - |(document_id, document_owner_id, moderator_id, removed_at, reason)| { + |( + document_id, + document_owner_id, + moderator_id, + removed_at, + reason, + document_hash, + restoration, + )| { ContractDocumentRemovalEntry { document_id, removal: ContractDocumentRemoval { @@ -514,6 +550,13 @@ impl MockResponse for ContractDocumentRemovals { moderator_id, reason, removed_at, + document_hash, + restoration: restoration.map(|(moderator_id, restored_at)| { + ContractDocumentRestoration { + moderator_id, + restored_at, + } + }), }, } }, diff --git a/packages/rs-sdk/src/platform/contract_moderation.rs b/packages/rs-sdk/src/platform/contract_moderation.rs index ce38a7a143a..435211fecf8 100644 --- a/packages/rs-sdk/src/platform/contract_moderation.rs +++ b/packages/rs-sdk/src/platform/contract_moderation.rs @@ -1,7 +1,7 @@ //! Contract moderation queries: one identity's status on a moderated contract -//! (`getContractModerationStatus`), one page of a contract's banlist or suspension list -//! (`getContractModerationEntries`) and the records of the documents its moderators deleted -//! (`getContractDocumentRemovals`). +//! (`getContractModerationStatus`), one page of a contract's banlist, suspension list or +//! warning list (`getContractModerationEntries`) and the records of the documents its +//! moderators deleted (`getContractDocumentRemovals`). //! //! A moderated contract declares in its config which lists it keeps //! (`DataContractConfig::moderation`). A status query names the lists to read, and each must be @@ -45,6 +45,7 @@ pub use drive_proof_verifier::types::contract_moderation::{ ContractDocumentRemovals, ContractDocumentRemovalsQuery, ContractDocumentRemovalsSelection, ContractModerationEntries, ContractModerationEntriesQuery, ContractModerationEntry, ContractModerationList, ContractModerationListStatus, ContractModerationListStatuses, + ContractWarning, }; /// Query for one identity's status on a moderated contract. diff --git a/packages/rs-sdk/src/platform/transition/contract_user_moderation.rs b/packages/rs-sdk/src/platform/transition/contract_user_moderation.rs index d77c337d7ad..eacda8e1131 100644 --- a/packages/rs-sdk/src/platform/transition/contract_user_moderation.rs +++ b/packages/rs-sdk/src/platform/transition/contract_user_moderation.rs @@ -1,14 +1,16 @@ -//! Ban, unban, suspend and unsuspend identities on a moderated data contract, and delete -//! documents of the document types that let moderators do so (protocol version 14). +//! Ban, unban, suspend, unsuspend, warn and clear the warnings of identities on a moderated +//! data contract, and delete documents of the document types that let moderators do so +//! (protocol version 14). //! -//! A contract whose config declares moderation keeps a banlist and/or a suspension list. The -//! contract owner, or a moderator the config names, edits them with a +//! A contract whose config declares moderation keeps a banlist, a suspension list and/or a +//! warning list. The contract owner, or a moderator the config names, edits them with a //! [`ContractUserModerationTransition`] signed by a CRITICAL authentication key. A banned or -//! suspended identity cannot act on the contract at the document level. +//! suspended identity cannot act on the contract at the document level; a warned one can, the +//! warnings being a record it and everyone else can read. //! //! The same transition deletes one document of a document type that sets //! `canBeDeletedByModerators`, whoever owns it, and leaves a record of the deletion under the -//! contract. +//! contract; and it restores such a document, as it was, within a week of its deletion. //! //! ```ignore //! let reason = ContractModerationReason::from_text("spam"); @@ -24,10 +26,13 @@ use crate::platform::Fetch; use dash_context_provider::ContextProvider; +use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::config::moderation::{ ContractDocumentRemoval, ContractModerationListStatuses, ContractModerationReason, }; use dpp::data_contract::DataContract; +use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; +use dpp::document::Document; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::signer::Signer; @@ -48,10 +53,11 @@ use crate::{Error, Sdk}; use super::waitable::Waitable; /// The target identity's status on the lists a moderation touched, as the proof of the -/// moderation shows it. A ban proves every list the contract keeps (it removes a suspension -/// too); an unban, a suspend and an unsuspend prove the one list they edit and say nothing -/// about the other, so an identity shown as no longer suspended may still be banned. Fetch -/// `ContractModerationListStatuses` over every list the contract keeps for the whole picture. +/// moderation shows it. A ban proves every barring list the contract keeps (it removes a +/// suspension too); an unban, a suspend, an unsuspend, a warn and a clearing prove the one +/// list they edit and say nothing about the others, so an identity shown as no longer +/// suspended may still be banned. Fetch `ContractModerationListStatuses` over every list the +/// contract keeps for the whole picture. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ModeratedUserStatus { /// The moderated contract @@ -221,13 +227,65 @@ pub trait ModerateContractUser: Waitable { .await } + /// Adds a warning for `reason` (as for a ban) to the entry of `identity_id` on the + /// warning list of `contract_id`, stamped with the block time. Warnings bar nothing and + /// accumulate, at most `SystemLimits::max_contract_warnings_per_identity` at a time: + /// past that, the warn is refused until they are cleared. + #[allow(clippy::too_many_arguments)] + async fn warn_contract_user + Send>( + &self, + sdk: &Sdk, + contract_id: Identifier, + identity_id: Identifier, + reason: ContractModerationReason, + signing_key_to_use: Option<&IdentityPublicKey>, + signer: S, + settings: Option, + ) -> Result { + self.moderate_contract_user( + sdk, + contract_id, + ContractUserModerationAction::Warn { + identity_id, + reason, + }, + signing_key_to_use, + signer, + settings, + ) + .await + } + + /// Takes `identity_id` off the warning list of `contract_id`: every warning it carries + /// goes. + async fn clear_contract_user_warnings + Send>( + &self, + sdk: &Sdk, + contract_id: Identifier, + identity_id: Identifier, + signing_key_to_use: Option<&IdentityPublicKey>, + signer: S, + settings: Option, + ) -> Result { + self.moderate_contract_user( + sdk, + contract_id, + ContractUserModerationAction::ClearWarnings { identity_id }, + signing_key_to_use, + signer, + settings, + ) + .await + } + /// Deletes document `document_id` of `document_type_name` on `contract_id`, whoever owns /// it, for `reason` (as for a ban). The document type must set /// `canBeDeletedByModerators`. Resolves with the record the deletion left under the /// contract: whose the document was, who removed it, why and when. /// - /// The document's owner gets no storage refund, and nothing ever deletes the record, so - /// the author may create the same document id again without the record going away. + /// The document's owner gets no storage refund, and nothing ever deletes the record. The + /// record holds a hash of the document as it was: keep the document (or its bytes) if the + /// deletion may have to be undone, since `restore_contract_document` needs it. #[allow(clippy::too_many_arguments)] async fn delete_contract_document + Send>( &self, @@ -240,6 +298,28 @@ pub trait ModerateContractUser: Waitable { signer: S, settings: Option, ) -> Result; + + /// Brings back `document`, of `document_type_name` on `contract`, that a moderator deleted: + /// the document as it was when it was deleted, which must hash to what its removal record + /// holds, within `SystemLimits::contract_document_restore_window_ms` (a week) of the + /// deletion. Any current moderator or the contract owner may restore, whoever deleted. + /// The document goes back through an ordinary insert, so a unique index value another + /// document took meanwhile refuses it. Resolves with the record, now marked restored. + /// + /// The signer pays for the document's storage; the refund of a later deletion stays its + /// owner's. `contract` serializes the document and is what the proof of the restore is + /// verified against, so it is registered with the SDK's context provider. + #[allow(clippy::too_many_arguments)] + async fn restore_contract_document + Send>( + &self, + sdk: &Sdk, + contract: &DataContract, + document_type_name: String, + document: &Document, + signing_key_to_use: Option<&IdentityPublicKey>, + signer: S, + settings: Option, + ) -> Result; } #[async_trait::async_trait] @@ -253,13 +333,18 @@ impl ModerateContractUser for Identity { signer: S, settings: Option, ) -> Result { - // A document deletion is proved by its removal record, not by a status. Refused before - // the nonce is taken: sent from here it would execute, be paid for, and then fail to - // read its own result. - if matches!(action, ContractUserModerationAction::DeleteDocument { .. }) { + // A document deletion or restore is proved by its removal record, not by a status. + // Refused before the nonce is taken: sent from here it would execute, be paid for, and + // then fail to read its own result. + if matches!( + action, + ContractUserModerationAction::DeleteDocument { .. } + | ContractUserModerationAction::RestoreDocument { .. } + ) { return Err(Error::Generic( - "a document deletion names no identity to report a status of: send it with \ - `delete_contract_document`, which returns the removal record" + "a document deletion or restore names no identity to report a status of: send \ + it with `delete_contract_document` or `restore_contract_document`, which \ + return the removal record" .to_string(), )); } @@ -302,6 +387,47 @@ impl ModerateContractUser for Identity { .await?; Ok(removal) } + + async fn restore_contract_document + Send>( + &self, + sdk: &Sdk, + contract: &DataContract, + document_type_name: String, + document: &Document, + signing_key_to_use: Option<&IdentityPublicKey>, + signer: S, + settings: Option, + ) -> Result { + let document_type = contract + .document_type_for_name(&document_type_name) + .map_err(dpp::ProtocolError::from)?; + // Serialized under the contract as given, which must be its current version: Drive + // decodes the bytes under the type as the contract holds it now and hashes them + // against the document as it was serialized when deleted, so a type whose layout + // changed inside the restore window leaves the record unrestorable whatever the + // caller serializes with. + let document_bytes = document.serialize(document_type, contract, sdk.version())?; + // The verifier reads the document's id out of the bytes under the contract's document + // type, through the context provider: what the caller serialized with is what it must + // resolve. + if let Some(provider) = sdk.context_provider() { + provider.register_data_contract(Arc::new(contract.clone())); + } + let VerifiedDocumentRemoval(removal) = broadcast_moderation( + self, + sdk, + contract.id(), + ContractUserModerationAction::RestoreDocument { + document_type_name, + document: document_bytes.into(), + }, + signing_key_to_use, + signer, + settings, + ) + .await?; + Ok(removal) + } } /// Makes sure the SDK's context provider can resolve `contract_id`, which the verifier of an @@ -354,14 +480,19 @@ where None => signing_key_for_moderation(identity, &signer)?, }; - // The proof of a ban covers every list the contract keeps, which the verifier reads from - // the contract through the context provider. A provider that can not resolve the contract + // The proof of a ban covers every barring list the contract keeps, which the verifier reads + // from the contract through the context provider. A provider that can not resolve the contract // would refuse a result the network already accepted, so before the nonce is taken and // anything is signed or paid for, the provider is asked, and only when it does not have // the contract (the lists never change, so whatever copy it holds will do) is the contract // fetched and registered with it. A document deletion is proved by its own removal record - // and needs no contract. - if matches!(action, ContractUserModerationAction::Ban { .. }) { + // and needs no contract; a restore's verifier decodes the document under the contract's + // document type, so it needs the contract too. + if matches!( + action, + ContractUserModerationAction::Ban { .. } + | ContractUserModerationAction::RestoreDocument { .. } + ) { ensure_provider_resolves_contract(sdk, contract_id, false).await?; } diff --git a/packages/rs-sdk/src/platform/transition/top_up_identity.rs b/packages/rs-sdk/src/platform/transition/top_up_identity.rs index 13b1257f5a2..aa98bf00219 100644 --- a/packages/rs-sdk/src/platform/transition/top_up_identity.rs +++ b/packages/rs-sdk/src/platform/transition/top_up_identity.rs @@ -3,6 +3,8 @@ use super::put_settings::PutSettings; use super::validation::ensure_valid_state_transition_structure; use super::waitable::Waitable; use crate::{Error, Sdk}; +#[cfg(feature = "core_key_wallet")] +use dapi_grpc::platform::v0::ResponseMetadata; use dpp::dashcore::PrivateKey; use dpp::identity::{Identity, PartialIdentity}; use dpp::prelude::AssetLockProof; @@ -47,6 +49,23 @@ pub trait TopUpIdentity: Waitable { AS: dpp::key_wallet::signer::Signer + Send + Sync; } +/// Balance operations that also expose the committed proof metadata. +#[async_trait::async_trait] +pub trait TopUpIdentityWithMetadata: Waitable { + /// Returns the confirmed balance result and its proof metadata. + #[cfg(feature = "core_key_wallet")] + async fn top_up_identity_with_signer_with_metadata( + &self, + sdk: &Sdk, + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &dpp::key_wallet::bip32::DerivationPath, + asset_lock_signer: &AS, + settings: Option, + ) -> Result<(u64, ResponseMetadata), Error> + where + AS: dpp::key_wallet::signer::Signer + Send + Sync; +} + #[async_trait::async_trait] impl TopUpIdentity for Identity { async fn top_up_identity_with_private_key( @@ -86,6 +105,32 @@ impl TopUpIdentity for Identity { asset_lock_signer: &AS, settings: Option, ) -> Result + where + AS: dpp::key_wallet::signer::Signer + Send + Sync, + { + self.top_up_identity_with_signer_with_metadata( + sdk, + asset_lock_proof, + asset_lock_proof_path, + asset_lock_signer, + settings, + ) + .await + .map(|(balance, _)| balance) + } +} + +#[async_trait::async_trait] +impl TopUpIdentityWithMetadata for Identity { + #[cfg(feature = "core_key_wallet")] + async fn top_up_identity_with_signer_with_metadata( + &self, + sdk: &Sdk, + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &dpp::key_wallet::bip32::DerivationPath, + asset_lock_signer: &AS, + settings: Option, + ) -> Result<(u64, ResponseMetadata), Error> where AS: dpp::key_wallet::signer::Signer + Send + Sync, { @@ -103,12 +148,13 @@ impl TopUpIdentity for Identity { ) .await?; ensure_valid_state_transition_structure(&state_transition, sdk.version())?; - let identity: PartialIdentity = state_transition - .broadcast_and_wait_for_affected_state(sdk, settings) + let (identity, metadata): (PartialIdentity, _) = state_transition + .broadcast_and_wait_for_affected_state_with_metadata(sdk, settings) .await?; identity .balance .ok_or(Error::Generic("expected an identity balance".to_string())) + .map(|balance| (balance, metadata)) } } diff --git a/packages/rs-sdk/src/platform/transition/top_up_identity_from_addresses.rs b/packages/rs-sdk/src/platform/transition/top_up_identity_from_addresses.rs index 27377577cc9..01526d11fae 100644 --- a/packages/rs-sdk/src/platform/transition/top_up_identity_from_addresses.rs +++ b/packages/rs-sdk/src/platform/transition/top_up_identity_from_addresses.rs @@ -1,3 +1,4 @@ +use dapi_grpc::platform::v0::ResponseMetadata; use std::collections::{BTreeMap, BTreeSet}; use super::address_inputs::fetch_inputs_with_nonce; @@ -42,6 +43,28 @@ pub trait TopUpIdentityFromAddresses>: Waitable { ) -> Result<(AddressInfos, Credits, u64), Error>; } +/// Identity top-ups that preserve the full metadata of the balance proof. +#[async_trait::async_trait] +pub trait TopUpIdentityFromAddressesWithMetadata>: Waitable { + /// Top up with automatically resolved address nonces and return proof metadata. + async fn top_up_from_addresses_with_metadata( + &self, + sdk: &Sdk, + inputs: BTreeMap, + signer: &S, + settings: Option, + ) -> Result<(AddressInfos, Credits, ResponseMetadata), Error>; + + /// Top up with explicit address nonces and return proof metadata. + async fn top_up_from_addresses_with_nonce_with_metadata( + &self, + sdk: &Sdk, + inputs: BTreeMap, + signer: &S, + settings: Option, + ) -> Result<(AddressInfos, Credits, ResponseMetadata), Error>; +} + #[async_trait::async_trait] impl> TopUpIdentityFromAddresses for Identity { async fn top_up_from_addresses( @@ -51,9 +74,9 @@ impl> TopUpIdentityFromAddresses for Identity { signer: &S, settings: Option, ) -> Result<(AddressInfos, Credits, u64), Error> { - let inputs_with_nonce = nonce_inc(fetch_inputs_with_nonce(sdk, &inputs).await?); - self.top_up_from_addresses_with_nonce(sdk, inputs_with_nonce, signer, settings) + self.top_up_from_addresses_with_metadata(sdk, inputs, signer, settings) .await + .map(|(infos, balance, metadata)| (infos, balance, metadata.height)) } async fn top_up_from_addresses_with_nonce( @@ -63,6 +86,38 @@ impl> TopUpIdentityFromAddresses for Identity { signer: &S, settings: Option, ) -> Result<(AddressInfos, Credits, u64), Error> { + self.top_up_from_addresses_with_nonce_with_metadata(sdk, inputs, signer, settings) + .await + .map(|(infos, balance, metadata)| (infos, balance, metadata.height)) + } +} + +#[async_trait::async_trait] +impl> TopUpIdentityFromAddressesWithMetadata for Identity { + async fn top_up_from_addresses_with_metadata( + &self, + sdk: &Sdk, + inputs: BTreeMap, + signer: &S, + settings: Option, + ) -> Result<(AddressInfos, Credits, ResponseMetadata), Error> { + let inputs_with_nonce = nonce_inc(fetch_inputs_with_nonce(sdk, &inputs).await?); + self.top_up_from_addresses_with_nonce_with_metadata( + sdk, + inputs_with_nonce, + signer, + settings, + ) + .await + } + + async fn top_up_from_addresses_with_nonce_with_metadata( + &self, + sdk: &Sdk, + inputs: BTreeMap, + signer: &S, + settings: Option, + ) -> Result<(AddressInfos, Credits, ResponseMetadata), Error> { let user_fee_increase = settings .as_ref() .and_then(|settings| settings.user_fee_increase) @@ -111,7 +166,7 @@ impl> TopUpIdentityFromAddresses for Identity { ) })?; - Ok((address_infos, balance, metadata.height)) + Ok((address_infos, balance, metadata)) } other => Err(Error::InvalidProvedResponse(format!( "identity proof was expected for {:?}, but received {:?}", diff --git a/packages/rs-sdk/src/platform/transition/transfer.rs b/packages/rs-sdk/src/platform/transition/transfer.rs index 5908acc91b6..7801f1c775b 100644 --- a/packages/rs-sdk/src/platform/transition/transfer.rs +++ b/packages/rs-sdk/src/platform/transition/transfer.rs @@ -1,3 +1,4 @@ +use dapi_grpc::platform::v0::ResponseMetadata; use dpp::identifier::Identifier; use dpp::identity::accessors::IdentityGettersV0; @@ -35,6 +36,21 @@ pub trait TransferToIdentity: Waitable { ) -> Result<(u64, u64), Error>; } +/// Balance operations that also expose the committed proof metadata. +#[async_trait::async_trait] +pub trait TransferToIdentityWithMetadata: Waitable { + /// Returns the confirmed balance result and its proof metadata. + async fn transfer_credits_with_metadata + Send>( + &self, + sdk: &Sdk, + to_identity_id: Identifier, + amount: u64, + signing_transfer_key_to_use: Option<&IdentityPublicKey>, + signer: S, + settings: Option, + ) -> Result<((u64, u64), ResponseMetadata), Error>; +} + #[async_trait::async_trait] impl TransferToIdentity for Identity { async fn transfer_credits + Send>( @@ -46,6 +62,30 @@ impl TransferToIdentity for Identity { signer: S, settings: Option, ) -> Result<(u64, u64), Error> { + self.transfer_credits_with_metadata( + sdk, + to_identity_id, + amount, + signing_transfer_key_to_use, + signer, + settings, + ) + .await + .map(|(balance, _)| balance) + } +} + +#[async_trait::async_trait] +impl TransferToIdentityWithMetadata for Identity { + async fn transfer_credits_with_metadata + Send>( + &self, + sdk: &Sdk, + to_identity_id: Identifier, + amount: u64, + signing_transfer_key_to_use: Option<&IdentityPublicKey>, + signer: S, + settings: Option, + ) -> Result<((u64, u64), ResponseMetadata), Error> { let new_identity_nonce = sdk.get_identity_nonce(self.id(), true, settings).await?; let user_fee_increase = settings.and_then(|settings| settings.user_fee_increase); let state_transition = IdentityCreditTransferTransition::try_from_identity( @@ -62,9 +102,10 @@ impl TransferToIdentity for Identity { .await?; ensure_valid_state_transition_structure(&state_transition, sdk.version())?; - let (sender, receiver): (PartialIdentity, PartialIdentity) = state_transition - .broadcast_and_wait_for_affected_state(sdk, settings) - .await?; + let ((sender, receiver), metadata): ((PartialIdentity, PartialIdentity), _) = + state_transition + .broadcast_and_wait_for_affected_state_with_metadata(sdk, settings) + .await?; let sender_balance = sender.balance.ok_or_else(|| { Error::Generic("expected an identity balance after transfer (sender)".to_string()) @@ -74,6 +115,6 @@ impl TransferToIdentity for Identity { Error::Generic("expected an identity balance after transfer (receiver)".to_string()) })?; - Ok((sender_balance, receiver_balance)) + Ok(((sender_balance, receiver_balance), metadata)) } } diff --git a/packages/rs-sdk/src/platform/transition/transfer_to_addresses.rs b/packages/rs-sdk/src/platform/transition/transfer_to_addresses.rs index 84005cecc93..7bf487ae671 100644 --- a/packages/rs-sdk/src/platform/transition/transfer_to_addresses.rs +++ b/packages/rs-sdk/src/platform/transition/transfer_to_addresses.rs @@ -1,3 +1,4 @@ +use dapi_grpc::platform::v0::ResponseMetadata; use std::collections::{BTreeMap, BTreeSet}; use super::address_inputs::collect_address_infos_from_proof; @@ -42,6 +43,20 @@ pub trait TransferToAddresses: Waitable { ) -> Result<(AddressInfos, Credits, u64), Error>; } +/// Identity transfers that preserve the full metadata of the balance proof. +#[async_trait::async_trait] +pub trait TransferToAddressesWithMetadata: Waitable { + /// Return recipient address infos, the identity balance, and proof metadata. + async fn transfer_credits_to_addresses_with_metadata + Send>( + &self, + sdk: &Sdk, + recipient_addresses: BTreeMap, + signing_transfer_key_to_use: Option<&IdentityPublicKey>, + signer: &S, + settings: Option, + ) -> Result<(AddressInfos, Credits, ResponseMetadata), Error>; +} + #[async_trait::async_trait] impl TransferToAddresses for Identity { async fn transfer_credits_to_addresses + Send>( @@ -52,6 +67,28 @@ impl TransferToAddresses for Identity { signer: &S, settings: Option, ) -> Result<(AddressInfos, Credits, u64), Error> { + self.transfer_credits_to_addresses_with_metadata( + sdk, + recipient_addresses, + signing_transfer_key_to_use, + signer, + settings, + ) + .await + .map(|(infos, balance, metadata)| (infos, balance, metadata.height)) + } +} + +#[async_trait::async_trait] +impl TransferToAddressesWithMetadata for Identity { + async fn transfer_credits_to_addresses_with_metadata + Send>( + &self, + sdk: &Sdk, + recipient_addresses: BTreeMap, + signing_transfer_key_to_use: Option<&IdentityPublicKey>, + signer: &S, + settings: Option, + ) -> Result<(AddressInfos, Credits, ResponseMetadata), Error> { if recipient_addresses.is_empty() { return Err(Error::Generic( "recipient_addresses must contain at least one address".to_string(), @@ -109,7 +146,7 @@ impl TransferToAddresses for Identity { ) })?; - Ok((address_infos, balance, metadata.height)) + Ok((address_infos, balance, metadata)) } other => Err(Error::InvalidProvedResponse(format!( "identity proof was expected for {:?}, but received {:?}", diff --git a/packages/rs-sdk/src/platform/transition/withdraw_from_identity.rs b/packages/rs-sdk/src/platform/transition/withdraw_from_identity.rs index a4d80b84b54..c12250084be 100644 --- a/packages/rs-sdk/src/platform/transition/withdraw_from_identity.rs +++ b/packages/rs-sdk/src/platform/transition/withdraw_from_identity.rs @@ -1,3 +1,4 @@ +use dapi_grpc::platform::v0::ResponseMetadata; use dpp::dashcore::Address; use dpp::identity::accessors::IdentityGettersV0; @@ -34,6 +35,23 @@ pub trait WithdrawFromIdentity { ) -> Result; } +/// Balance operations that also expose the committed proof metadata. +#[async_trait::async_trait] +pub trait WithdrawFromIdentityWithMetadata { + /// Returns the confirmed balance result and its proof metadata. + #[allow(clippy::too_many_arguments)] + async fn withdraw_with_metadata + Send>( + &self, + sdk: &Sdk, + address: Option
, + amount: u64, + core_fee_per_byte: Option, + signing_withdrawal_key_to_use: Option<&IdentityPublicKey>, + signer: S, + settings: Option, + ) -> Result<(u64, ResponseMetadata), Error>; +} + #[async_trait::async_trait] impl WithdrawFromIdentity for Identity { async fn withdraw + Send>( @@ -46,6 +64,33 @@ impl WithdrawFromIdentity for Identity { signer: S, settings: Option, ) -> Result { + self.withdraw_with_metadata( + sdk, + address, + amount, + core_fee_per_byte, + signing_withdrawal_key_to_use, + signer, + settings, + ) + .await + .map(|(balance, _)| balance) + } +} + +#[async_trait::async_trait] +impl WithdrawFromIdentityWithMetadata for Identity { + #[allow(clippy::too_many_arguments)] + async fn withdraw_with_metadata + Send>( + &self, + sdk: &Sdk, + address: Option
, + amount: u64, + core_fee_per_byte: Option, + signing_withdrawal_key_to_use: Option<&IdentityPublicKey>, + signer: S, + settings: Option, + ) -> Result<(u64, ResponseMetadata), Error> { let new_identity_nonce = sdk.get_identity_nonce(self.id(), true, settings).await?; let script = address.map(|address| CoreScript::new(address.script_pubkey())); let user_fee_increase = settings.and_then(|settings| settings.user_fee_increase); @@ -66,16 +111,17 @@ impl WithdrawFromIdentity for Identity { .await?; ensure_valid_state_transition_structure(&state_transition, sdk.version())?; - let result = state_transition - .broadcast_and_wait_for_affected_state(sdk, settings) + let (result, metadata) = state_transition + .broadcast_and_wait_for_affected_state_with_metadata(sdk, settings) .await?; match result { - StateTransitionProofResult::VerifiedPartialIdentity(identity) => { - identity.balance.ok_or(Error::Generic( + StateTransitionProofResult::VerifiedPartialIdentity(identity) => identity + .balance + .ok_or(Error::Generic( "expected an identity balance after withdrawal".to_string(), )) - } + .map(|balance| (balance, metadata)), _ => Err(Error::Generic("proved a non identity".to_string())), } } diff --git a/packages/swift-sdk/SCHEMA_RELEASES.md b/packages/swift-sdk/SCHEMA_RELEASES.md new file mode 100644 index 00000000000..2183bfbe76d --- /dev/null +++ b/packages/swift-sdk/SCHEMA_RELEASES.md @@ -0,0 +1,310 @@ +# App Store schema snapshots + +SwiftData schemas become supported history when a build reaches App Store +distribution. TestFlight uploads capture provenance and a synthetic SQLite +fixture, but do not by themselves register a released schema. The accepted frozen V1 remains unchanged. Historical V2 is now reconstructed +from `52e8d4ec68f0c772313fa1bbef223fb1eabbf1cc`; all 35 entity hashes and the +model checksum match the observed App Store 9.0.2 database. Active models are +V3. Other intermediate development shapes remain unsupported. + +The old V2 fixture was generated from September 8 sources containing 13 +properties added on August 28, after the August 27 App Store release. The +reconstructed V2 instead predates those additions. Its synthetic fixture and +source provenance live in `schema-releases.json` under `historical_schemas`, +separate from archive-captured releases. This identifies a matching model +source, not the confirmed build commit of Apple's binary. V2 is reserved: +automated release capture must not register another shape under that number. + +The main migration plan is historical V2 → V3. Accepted V1 has a separate +V1 → V3 plan: V1 already contains the 13 properties missing from historical +V2, so a V1 → V2 → V3 chain could discard values. Routing uses model metadata +and runs after recovery; a version label alone never selects an unknown beta +schema. The former live V2 is accepted only when its complete graph matches +current V3 exactly, entity hashes and checksum alike. That alias therefore +lasts only until the next live-graph change: after it, every store still +labelled `2.0.0` with the former live shape becomes `unsupported-v2` and fails +closed. Check internal devices still carrying that label before the next +shape change and migrate or deliberately reset them then, rather than +discovering them afterwards as failed opens. + +The route decision is logged as `store_migration_route` with the validated +`source_version`, `source_checksum` and one of `new-store`, +`accepted-v1-to-v3`, `historical-v2-to-v3`, `previous-live-v2-current-shape`, +`unsupported-v2`, `labelled-current-v3`, `ordinary-current-plan` or, from the +bridge, `legacy-v1-bridge-to-v3`. Resolving a frozen schema's identity builds +a temporary store, so it is memoized per process and consulted only where a +label is undecidable without it (`1.0.0`, `2.0.0`); a `3.0.0` label takes the +default plan without any probe, and a probe failure on the undecidable labels +refuses the open with the probe's own error while leaving the store untouched. + +## Legacy stores before the release registry + +Frozen V1 and its fixture remain unchanged. They describe the accepted baseline, +but do not establish the exact schema shipped by the first App Store binary. +Some older app sources created an unversioned `Schema(modelTypes)` and omitted +the explicit migration plan; those databases still report version `1.0.0`. + +The shared `DashModelContainer` factory recognizes registered schema +fingerprints before opening an existing store. Unknown legacy `1.0.0` stores +may use the compatibility bridge: take a consistent backup including committed +WAL data, migrate an isolated copy automatically to `DashSchemaV3`, verify that +existing stored values and relationship rows survived, and reopen it through +the ordinary migration plan before installing it. The backup is retained for +recovery. Corruption, removed fields/entities, changed stored data, and newer +unknown schema versions are errors; this is not a general retry for any +container failure and never resets the store. Preservation is deliberately +strict: existing SQLite primary keys, foreign keys and join rows must survive +unchanged (entity ordinals are normalized by name). Even a semantically equivalent +Core Data migration that renumbers primary keys is rejected; such a layout +requires an explicit, separately validated migration rather than relaxing this +bridge's data-loss checks. + +Before making full-size copies, the bridge acquires its exclusive store lock +and removes abandoned UUID attempt directories only when no migration journal +is pending. This also recovers space after a process was killed before writing +its journal. It then measures actual free space on the store's volume; failed +deletions are never counted as available space. A missing, nonpositive or failing +ImportantUsage capacity reading falls back to filesystem free bytes; if both +report zero, migration remains blocked. Its conservative estimate is four times +the combined main-file and WAL size, plus the larger of 64 MiB or half that +combined size. This budgets the two +copies, inferred-migration/promotion journals and schema/index growth. It is a +preflight estimate, not a reservation: other processes or larger-than-estimated +growth can still exhaust space, and SQLite errors remain fatal without replacing +the original. Insufficient headroom reports the needed and available space and +asks the user to free device storage and retry; it does not delete wallet data. + +Applications with their own database paths must use +`DashModelContainer.create(url:)` or its async twin before opening the store +elsewhere. Both synchronous `create` overloads may block for seconds while +opening or migrating a large database and should not run on a UI actor. Direct +`ModelContainer` construction bypasses this compatibility bridge. The iOS host +uses `DashModelContainer.createAsync(url:)` while retaining its existing store +path and lifecycle. This async variant opens/migrates on a dedicated serial +queue and returns only the Sendable container; callers create and use contexts +on their owning actor. Coalesce concurrent opens of the same URL in the app. +The bridge is intentionally local-only: copying +and replacing SQLite does not establish preservation of CloudKit's synchronization +state. CloudKit and in-memory containers use their ordinary migration plan; +unknown CloudKit schemas require a separately supported migration. + +Recovery files live beside the original store under +`.legacy-v2-backups/`. A successful bridge retains an +`original.store` backup through that launch. A later successful ordinary open +reclaims completed backup directories. That optional cleanup takes the same +nonblocking store lock and rechecks the journal; lock contention skips cleanup +without failing an ordinary open. Known stores without attempt directories do +not create a bridge lock file. Failed opens and pending migrations never trigger +cleanup. Cleanup failures do not prevent opening the wallet. +Explicit wallet deletion has a stricter contract: +`PlatformWalletPersistenceHandler.deleteCompletedMigrationSnapshots()` removes +completed copies under the same store lock and propagates cleanup errors. +`deleteWalletData` and the manager's wallet deletion invoke it before deleting +SDK keys or live rows. Call it for a full-store wipe even when no wallet rows +remain, and do so for every affected network store. Pending recovery is preserved +and blocks deletion. Because a snapshot contains the whole historical database, +removing one wallet discards the whole completed snapshot, while unrelated live +wallets and other stores remain untouched. A cached container does not bypass +this deletion boundary. This is separate from best-effort startup cleanup. +The `active.json` journal records an interrupted installation and a fingerprint +of the validated final data; the next open reconciles it before exposing a +container, even if scratch copies were removed. Older journals still require +their candidate when validating a committed installation. These +are local wallet data, protected like the original store and excluded from +device backup. Do not upload them as release fixtures or edit the recovery +journal to bypass a failure. Clearing the journal is part of a successful open, +not optional cleanup: the factory has not yet returned the container to the app. +If clearing fails, the open fails and the next attempt validates recovery evidence +again. Ignoring that error could expose a writable container while leaving a +stale marker that rejects the app's legitimate later writes. + +A missing primary database with a pending journal requires deliberate recovery. +The bridge never deletes or renames that primary file, so its absence is not a +normal interrupted-install state; it may be an intentional external reset. The +SDK preserves the journal and any copies and reports their location. It does +not silently restore `original.store` (which could be older than a committed +installation) or clear the marker and create an empty database. With the app +closed, restore the authoritative original from a verified backup, or use support +to identify an appropriate recovery source. If no source can be verified, retain +the evidence and stop; do not edit the journal to force startup. + +The historical regression fixture reconstructs Platform +`fd8d8d13e5d7cea17b00df5974934ab1910e8039` from the same checkout pair as iOS +`8094751eb2be8d52b57da3589fdd2ae2dcd0ecc6` in +[Actions run 32706880873](https://github.com/dashpay/dashwallet-ios/actions/runs/32706880873). +It contains synthetic records, not user data or an extracted App Store store. +The run failed before upload, so this provenance establishes a tested source +layout rather than proof of publication. Regression tests exercise the public +factory, data/default preservation, writes, reopen, and failure recovery. + +Keep the bridge for installations that skip the V3 app release. When advancing +to V4, bind `DashSchemaV3` to its released snapshot and retain the legacy-to-V3 +step before the normal V3-to-current plan. The bridge must never automatically +follow the latest live model graph. The release observer's one-time `bootstrap` +only records its observation baseline; it neither runs this migration nor +proves V1's App Store provenance. + +After publication, changing a runtime version's model shape in place can leave +existing App Store stores with no registered matching checksum and block startup. +`DashReleasedSchemaTests.testPublishedSnapshotsAndRuntimeVersionsMatchCapturedStores` +compares both the archival snapshot and the runtime version against the captured +published store; it fails on that drift. Keep the published version bound to its +snapshot, introduce a new live version and register the connecting migration. +The companion `testPublishedStoresMigrateAndRemainWritableThroughLiveTypes` then +checks that published stores still open and remain writable. No mutable live +fixture is needed for this publication boundary. + +## Release flow + +The iOS release workflow saves an immutable build manifest and content-addressed +fixture on `dashpay/dashwallet-ios`'s `schema-release-data` branch. Its publication +monitor polls App Store Connect twice daily, or on manual request, and matches +the published version to the exact Apple build. A publication proof refers to +that build's manifest and its digest. The manifest contains the full Platform +commit; the latest development commit is never substituted. +Versions marked `REPLACED_WITH_NEW_VERSION` also count as published history: +a release superseded between the twice-daily checks still requires its snapshot. + +Before storing candidate evidence or uploading to TestFlight, iOS retains the +Platform commit under the lightweight tag `swift-schema-source/`. +This tag is source retention, not a schema freeze or a product release: it lets +the later freeze read that exact commit even if its development branch has been +force-updated during Apple review. The worker also verifies/creates these tags +for every registered source before publishing a snapshot PR. Existing tags must +point directly to their named commit; mismatches stop processing and are never +force-updated. Full-history Actions checkouts fetch these tags. A manual shallow +checkout must fetch the retained source tags before regenerating snapshots. + +The monitor dispatches **Freeze SwiftData App Store release** with `release_id` +and `data_commit`. The worker verifies that this commit belongs to the metadata +branch, verifies the proof, build identity and artifact digests, and generates +the snapshot from the manifest's Platform commit. It uses a temporary clone; +the source checkout is unchanged. +The generator executable is copied from the reviewed `v4.2-dev` base into a +separate temporary directory before the draft is checked out. It runs with an +explicit target repository, isolated Python imports and a credential-free +environment. Draft files are input/output data, including any edits to the +generator itself; they are not executed by this worker. Only authenticated Git +fetch/push processes receive the PAT, with ambient Git configuration and hooks +disabled. Human changes on the draft remain available for review. + +The worker opens a draft PR on `codex/freeze-swift-schema-v`, +targeting `v4.2-dev`. The PR contains the generated snapshot, synthetic fixture +and release association in `schema-releases.json`. Standard Swift SDK checks +run on these same-repository draft branches. A maintainer must review and merge +the PR; the worker never enables auto-merge or updates runtime model types. +The iOS monitor considers the release handled only once its registry entry is +merged. New production-capable uploads must use a Platform commit containing +all required releases, not merely a commit from before the snapshot merge. + +## Setup and manual operation + +1. Deploy the generator, registry and worker to `v4.2-dev`, and register the + dispatch workflow on the repository's default branch. GitHub requires the + workflow to exist on the default branch for `workflow_dispatch`. +2. Configure `SCHEMA_RELEASE_TOKEN` in both repositories: a fine-grained PAT + limited to `dashpay/platform` and `dashpay/dashwallet-ios`, with repository + Contents, Actions and Pull requests permissions needed by the workflow. + The Platform worker needs metadata read access, Platform branch/PR write + access, and uses the PAT so its PR events trigger CI. Apple credentials stay + in the iOS repository. Do not put tokens in command arguments or manifests. +3. Initialize the iOS release baseline before the first newly tracked release, + then run its monitor manually in dry-run mode. The baseline must reference + the verified historical V2 binding for 9.0.2. Correct the old V1 association + without moving the publication cutoff; see the iOS runbook. +4. After publication, use the iOS manual monitor for the normal operator flow. + For a worker retry, select the recorded Apple version ID and a full commit + on `schema-release-data`. Select `dry_run` to validate and generate the patch + without committing, creating source tags, pushing or creating a PR. Dry runs still require read + credentials and query GitHub. + Existing source tags are validated in dry runs too, including for already + merged releases; missing tags are allowed and are not created. + +Do not use the Platform workflow to bypass App Store publication: it requires +a published-state proof written by the trusted iOS monitor. Protect the data +branch against deletion/force-push and restrict write access to release operators +and automation. On PAT expiry, replace the repository secret in both repos and +retry; no schema should be regenerated manually just to recover authentication. +Protect `swift-schema-source/*` tags against update/deletion while allowing the +release automation to create new ones. These tags do not match this repository's +branch-only push workflows and do not trigger product release publishing. + +## Developing the next schema + +Keep `schema-models.json` complete for models and their stored value types. +Run `freeze_schema_models.py --check-inventory` before capture; the iOS capture +workflow does this automatically, and snapshot rendering validates the historical +source inventory again. Validation follows explicit fields and enum payloads, +including nested optional/array/set/dictionary values. Missing user-defined +types and unsupported storage declarations fail instead of silently binding to +live definitions. The validator uses a restricted Swift declaration grammar; +standard Swift/Foundation names are assumed not to be shadowed by application +types. It does not prove custom encoding or helper-method behavior, so native +captured-store hash/index checks and code review remain required. + +A released snapshot has its own namespace, for example `DashSchemaSnapshotV3`. +It is not automatically registered alongside identical current models. When +changing the structure after a release, explicitly register the historical +snapshot as the old schema, introduce the next active version and its migration, +and retain the released fixture. A release with unchanged version, hashes and +indexes associates another App Store version with the existing snapshot. + +If development changes while Apple reviews a build, freezing still uses the +uploaded commit. CI must expose any missing migration or incompatible current +models; reconcile the next version and migration in the draft PR before merging. +Never roll development back automatically and never change a released fixture +or snapshot to make a test pass. A changed structure under an already released +schema number is an error, including changes to SQLite indexes (which entity +hashes alone do not cover). + +Fixtures contain synthetic records created by the release code on an arm64 +simulator in Release configuration. They are not extracted from the device IPA +and never contain user wallet material. + +## Retries and recovery + +- Re-running the monitor or worker reuses the deterministic branch and open PR. + The worker merges current development into the branch, preserves human edits, + and pushes without force. Resolve merge conflicts manually before retrying. +- A push that succeeds before PR creation fails is recovered on the next run. + If an unregistered release's PR was closed without merge, reopen it deliberately + before retrying. Already-registered releases are validated against the merged + registry first; a later rejected association for another release on the shared + schema branch does not prevent their source-tag validation or repair. +- A missing build record, changed digest, conflicting schema number or + rewritten release association stops processing. Restore the correct original + record through the release-data recovery process; never guess a source SHA. +- If a retained source tag is missing, rerun the worker while the exact original + commit remains available. It verifies all registry sources and recreates only + missing references. If the commit has already been garbage-collected, recover + that exact object from an original checkout/backup first; a replacement commit + with similar source does not satisfy its identity. A conflicting tag requires + investigation instead of an automatic overwrite. +- An unsupported development database may require an explicit app-data reset + by its owner. Export/recover any needed development wallet first. The app does + not silently erase an unrecognized database to make migrations succeed. + +Run the automation tests with: + +```sh +python3 -m unittest discover -s packages/swift-sdk/scripts -p 'test_*.py' +python3 packages/swift-sdk/scripts/freeze_schema_models.py --check +python3 packages/swift-sdk/scripts/freeze_schema_models.py --check-inventory +python3 packages/swift-sdk/scripts/historical_schema_fixture.py --check +``` + +Swift SDK CI additionally checks the generated schemas against the released +fixtures, including entity hashes, indexes and migration behavior. + +## Local verification with a private database copy + +`DashModelMigrationTests.testPrivateHistoricalStoreMigrationWhenExplicitlyProvided` +accepts a local standalone SQLite input through `DASH_PRIVATE_MIGRATION_STORE`. +For an Xcode simulator test run, forward it using the +`TEST_RUNNER_DASH_PRIVATE_MIGRATION_STORE` environment variable. The test copies +that input to a disposable directory, verifies preservation of every supported +SQLite table/relationship, writes through current models and reopens the copy. +Without the explicit input it skips; normal CI uses only synthetic fixtures. +Do not add a real wallet database to resources, XCTest attachments, commits or +CI artifacts. If capturing another input, preserve committed WAL contents with +a consistent SQLite backup rather than copying an active main file alone. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift index 3bbefb52c37..b083ae12383 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift @@ -237,6 +237,14 @@ private final class SDKLoggerState: @unchecked Sendable { } } + func removeSink() { + let removed: SDKLogFileSink? = lock.withLock { + defer { sink = nil } + return sink + } + removed?.flush() + } + func destination(for severity: SDKLogSeverity) -> SDKLogFileSink? { lock.withLock { guard severity != .debug || includeDebug else { return nil } @@ -503,6 +511,13 @@ public enum SDKLogger { state.updateDebugSetting(includeDebug) } + /// Detach the process-wide file sink after flushing it. Tests that install + /// a sink in a disposable directory call this before removing the + /// directory, so later events are not written through a stale handle. + static func removeFileSink() { + state.removeSink() + } + public static func log(_ message: String, minimumLevel level: LoggingPreset = .medium) { guard LoggingPreferences.allows(level) else { return } // Mirror to NSLog (unified logging) in addition to stdout so diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift index 7fe5a40851e..0c696701c3e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift @@ -322,8 +322,14 @@ public struct DataContractParser { if let rankedAverageable = indexData["rankedAverageable"] as? Bool { index.rankedAverageable = rankedAverageable } + // A composite terminal is an ordered list of component names; + // the persisted string keeps them joined, in order, so display + // layers show the whole member key. if let terminal = indexData["terminal"] as? String { index.terminal = terminal + } else if let components = indexData["terminal"] as? [String], + !components.isEmpty { + index.terminal = components.joined(separator: " ‖ ") } if let preallocated = indexData["preallocated"] as? Bool { index.preallocated = preallocated diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift new file mode 100644 index 00000000000..4c9438002b5 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift @@ -0,0 +1,453 @@ +import CoreData +import Darwin +import Foundation +import SwiftData + +/// One-time, local-store bridge into the fixed V3 schema. The ordinary migration +/// plan owns recognized versions. This path accepts only an older 1.0.0 graph +/// whose complete existing SQLite data survives an inferred migration unchanged. +/// Call before publishing any container/context for the same URL. +enum DashLegacySchemaBridge { + typealias SQLite = DashLegacyStoreSQLite + enum Phase: String { case afterSnapshot, afterMigration, beforeInstall, writeLocked, afterCommit } + struct Hooks { + var visit: (Phase, URL) throws -> Void = { _, _ in } + var availableCapacity: (URL) throws -> Int64 = DashLegacySchemaBridge.availableCapacity(at:) + } + struct Identity: Codable, Equatable { + let versions: [String] + let checksum: String + let hashes: [String: Data] + } + private struct Journal: Codable { + let formatVersion: Int + let operation: UUID + let source: Identity + let destination: Identity + let destinationData: SQLite.StoreEvidence? + } + + static func open(configuration: ModelConfiguration, schema: Schema, + plan: any SchemaMigrationPlan.Type, hooks: Hooks = Hooks()) throws -> ModelContainer { + let url = configuration.url + func visit(_ phase: Phase, _ candidate: URL) throws { + SDKLogger.event("legacy_migration_phase", category: .persistence, + fields: ["phase": .publicText(phase.rawValue)]) + try hooks.visit(phase, candidate) + } + func ordinary() throws -> ModelContainer { + let selectedPlan = configuration.isStoredInMemoryOnly ? plan : + try DashModelContainer.migrationPlan(at: url, defaultPlan: plan) + return try ModelContainer(for: schema, migrationPlan: selectedPlan, + configurations: [configuration]) + } + guard !configuration.isStoredInMemoryOnly else { return try ordinary() } + let root = backupDirectory(for: url) + let marker = root.appendingPathComponent("active.json") + if !FileManager.default.fileExists(atPath: url.path), + !FileManager.default.fileExists(atPath: marker.path) { return try ordinary() } + if !FileManager.default.fileExists(atPath: marker.path) { + // Detection must not impose bridge-specific metadata or locking + // requirements on stores handled by SwiftData's ordinary path. + let needsMigration = (try? identity(at: url)).flatMap { try? needsBridge($0) } ?? false + // Recheck after reading identity: a concurrent bridge publishes its + // journal before the store can change to the destination schema. + if !needsMigration && !FileManager.default.fileExists(atPath: marker.path) { + let container = try ordinary() + reclaimAfterSuccessfulOpen(at: url, root: root) + return container + } + } + let lock = try StoreLock(url: url) + defer { lock.close() } + // Recheck under the recovery lock. Our transactional installation never + // removes the primary file, so absence may be an intentional external + // reset. Neither resurrect an older backup nor create an empty store. + guard FileManager.default.fileExists(atPath: url.path) else { + throw SQLite.Failure.unsupported( + "The original database is missing while migration recovery is pending. Recovery files remain at \(root.path). Restore the original database from a verified backup with the app closed, or contact support for deliberate recovery. Do not delete the journal or create an empty database.") + } + SDKLogger.event("legacy_migration_phase", category: .persistence, + fields: ["phase": .publicText("recovery")]) + try SQLite.recoverRollbackJournal(at: url) + try recoverIfNeeded(at: url, root: root) + // Another opener may have finished migration before this lock was + // acquired. Recovery errors above remain fatal; ordinary detection does not. + guard let source = try? identity(at: url), + (try? needsBridge(source)) == true else { return try ordinary() } + SDKLogger.event("store_migration_route", category: .persistence, fields: [ + "source_version": .publicText("1.0.0"), + "source_checksum": .publicText(Data(base64Encoded: source.checksum)?.count == 32 ? source.checksum : "unavailable"), + "route": .publicText("legacy-v1-bridge-to-v3"), + "target_version": .publicText("3.0.0")]) + guard configuration.allowsSave else { throw SQLite.Failure.unsupported("The store is read-only") } + let permitted = Set(Schema(versionedSchema: DashSchemaV1.self).entities.map(\.name)) + let required: Set = ["PersistentWallet", "PersistentAccount", "PersistentTransaction", "PersistentTxo"] + let names = Set(source.hashes.keys) + guard names.isSubset(of: permitted), required.isSubset(of: names) else { + throw SQLite.Failure.unsupported("The old model contains unsupported or missing entities") + } + try rejectExternalStorage(at: url) + // A killed attempt may leave copies before it ever published a journal. + // Under the lock, no active marker means these directories are inactive. + // Remove them before measuring capacity; never credit hypothetical space. + reclaimInactiveAttempts(at: root, holding: lock) + let requiredSpace = try requiredFreeSpace(at: url) + let availableSpace = max(0, try hooks.availableCapacity(url.deletingLastPathComponent())) + guard availableSpace >= requiredSpace else { + throw SQLite.Failure.insufficientDiskSpace(required: requiredSpace, available: availableSpace) + } + let operation = UUID() + let directory = root.appendingPathComponent(operation.uuidString, isDirectory: true) + var directoryAttributes = try protectionAttributes(like: url) + directoryAttributes[.posixPermissions] = 0o700 + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true, + attributes: directoryAttributes) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false, + attributes: directoryAttributes) + var rootValues = URLResourceValues() + rootValues.isExcludedFromBackup = true + var excludedRoot = root + try excludedRoot.setResourceValues(rootValues) + let backup = directory.appendingPathComponent("original.store") + let candidate = directory.appendingPathComponent("candidate.store") + var committed = false + defer { + if !committed && !FileManager.default.fileExists(atPath: root.appendingPathComponent("active.json").path) { + try? FileManager.default.removeItem(at: directory) + } + } + let rawSource = try SQLite.rawDigest(url) + try createProtectedFile(backup, like: url) + try SQLite.copy(from: url, to: backup) + guard try SQLite.rawDigest(url) == rawSource else { throw SQLite.Failure.sourceChanged } + try SQLite.integrityCheck(backup) + guard try identity(at: backup) == source else { throw SQLite.Failure.sourceChanged } + try visit(.afterSnapshot, url) + try createProtectedFile(candidate, like: url) + try SQLite.copy(from: backup, to: candidate) + // Never replace this with `schema` or the latest version. Users can skip + // the V3 app; later releases must keep V3's frozen graph as this target. + try autoreleasepool { + let bridgeSchema = Schema(versionedSchema: DashSchemaV3.self) + _ = try ModelContainer(for: bridgeSchema, configurations: [ + ModelConfiguration(schema: bridgeSchema, url: candidate, cloudKitDatabase: .none) + ]) + } + try SQLite.checkpoint(candidate) + try visit(.afterMigration, candidate) + try rejectExternalStorage(at: candidate) + try SQLite.integrityCheck(candidate) + try SQLite.validatePreservation(from: backup, to: candidate) + // The normal plan must accept the fixed V3 result. In a future release + // it may continue through additional explicitly registered stages here. + try autoreleasepool { + _ = try ModelContainer(for: schema, migrationPlan: plan, configurations: [ + ModelConfiguration(schema: schema, url: candidate, cloudKitDatabase: .none) + ]) + } + try SQLite.checkpoint(candidate) + try rejectExternalStorage(at: candidate) + try SQLite.integrityCheck(candidate) + let destination = try identity(at: candidate) + let journal = Journal(formatVersion: 2, operation: operation, source: source, destination: destination, + destinationData: try SQLite.evidence(at: candidate)) + // Publish the marker only after both copy filenames and their data are + // durable. The marker lives in the parent directory, synced separately. + for fileURL in [backup, candidate] { + let file = try FileHandle(forWritingTo: fileURL) + defer { try? file.close() } + try file.synchronize() + } + try synchronizeDirectory(directory) + try writeJournal(journal, at: root) + try visit(.beforeInstall, url) + try SQLite.copy(from: candidate, to: url) { + // backup_step(0) owns SQLite's write lock, closing the race between + // comparison and commit. Main/WAL bytes must still be the snapshot's + // bytes; unrelated or concurrent writes cause a safe retry. + guard try SQLite.rawDigest(url) == rawSource else { throw SQLite.Failure.sourceChanged } + try visit(.writeLocked, url) + } + committed = true + try visit(.afterCommit, url) + let container = try ordinary() + // This container has not escaped to the app, so no application writes + // are permitted yet. Clearing must succeed before returning it: a stale + // marker would incorrectly compare later writes with migration evidence. + try clearJournal(at: root) + try? FileManager.default.removeItem(at: candidate) + return container + } + + static func backupDirectory(for url: URL) -> URL { + url.deletingLastPathComponent().appendingPathComponent(url.lastPathComponent + ".legacy-v2-backups", isDirectory: true) + } + + /// Conservative estimate, not a reservation: two complete copies plus + /// inferred-migration and promotion journals, with room for new columns and + /// indexes. Include WAL bytes because the backup incorporates committed WAL. + static func requiredFreeSpace(at url: URL) throws -> Int64 { + var sourceBytes: Int64 = 0 + for suffix in ["", "-wal"] { + let path = url.path + suffix + if suffix.isEmpty || FileManager.default.fileExists(atPath: path) { + let attributes = try FileManager.default.attributesOfItem(atPath: path) + guard let size = (attributes[.size] as? NSNumber)?.int64Value, size >= 0 else { + throw SQLite.Failure.database("Cannot estimate migration storage requirements") + } + let sum = sourceBytes.addingReportingOverflow(size) + guard !sum.overflow else { return Int64.max } + sourceBytes = sum.partialValue + } + } + let copiesAndJournals = sourceBytes.multipliedReportingOverflow(by: 4) + let margin = max(64 * 1024 * 1024, sourceBytes / 2) + let total = copiesAndJournals.partialValue.addingReportingOverflow(margin) + return copiesAndJournals.overflow || total.overflow ? Int64.max : total.partialValue + } + + private static func availableCapacity(at directory: URL) throws -> Int64 { + try resolveAvailableCapacity(importantUsage: { + try directory.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]) + .volumeAvailableCapacityForImportantUsage + }, fileSystem: { + let attributes = try FileManager.default.attributesOfFileSystem(forPath: directory.path) + guard let capacity = (attributes[.systemFreeSize] as? NSNumber)?.int64Value else { + throw SQLite.Failure.database("Cannot determine available storage; check device storage and retry") + } + return capacity + }) + } + + /// Some macOS volumes report zero/negative or no ImportantUsage capacity + /// despite free filesystem space. Confirm those results with a real free-byte + /// query; never replace them with estimated savings or a test-only allowance. + static func resolveAvailableCapacity( + importantUsage: () throws -> Int64?, fileSystem: () throws -> Int64 + ) throws -> Int64 { + if let capacity = try? importantUsage(), capacity > 0 { return capacity } + return max(0, try fileSystem()) + } + + private static func needsBridge(_ source: Identity) throws -> Bool { + guard source.versions == ["1.0.0"] else { return false } + // Accepted V1 has its own direct route to V3, independent of the + // primary historical V2 migration plan. + if source == (try identity(for: DashSchemaV1.self)) { return false } + return true + } + + /// Privacy boundary for explicit wallet deletion, including cached stores. + /// A snapshot contains the whole old database, so deleting one wallet must + /// discard the completed snapshots rather than edit their historical graph. + /// This never touches live rows, another store's copies, or a pending journal. + static func deleteCompletedSnapshots(at url: URL) throws { + let root = backupDirectory(for: url) + guard FileManager.default.fileExists(atPath: root.path) else { return } + let lock = try StoreLock(url: url) + defer { lock.close() } + guard !FileManager.default.fileExists(atPath: root.appendingPathComponent("active.json").path) else { + throw SQLite.Failure.unsupported("Wallet deletion cannot discard pending migration recovery. Reopen the store successfully before deleting wallets.") + } + let attributes = try root.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + guard attributes.isDirectory == true, attributes.isSymbolicLink != true else { + throw SQLite.Failure.unsupported("The migration snapshot directory is not a local directory") + } + for directory in try attemptDirectories(at: root) { + // Unlike opportunistic startup cleanup, explicit deletion must + // report any failure rather than leave a separately readable copy. + try FileManager.default.removeItem(at: directory) + } + try synchronizeDirectory(root) + } + + /// Cleanup is optional after an ordinary open. Never make known/current + /// stores fail because a legacy opener holds the lock, and do not create a + /// lock file unless there are actual attempt directories to reclaim. + private static func reclaimAfterSuccessfulOpen(at url: URL, root: URL) { + guard let directories = try? attemptDirectories(at: root), !directories.isEmpty, + let lock = try? StoreLock(url: url) else { return } + defer { lock.close() } + reclaimInactiveAttempts(at: root, holding: lock) + } + + /// Requires exclusive ownership for both checking the marker and deleting + /// copies. Call before a legacy attempt starts, or after a later ordinary + /// open; the successful migration/recovery launch retains its own backup. + private static func reclaimInactiveAttempts(at root: URL, holding _: StoreLock) { + guard !FileManager.default.fileExists(atPath: root.appendingPathComponent("active.json").path) else { return } + for directory in (try? attemptDirectories(at: root)) ?? [] { + // Failure only affects disk usage. Preflight measures the actual + // remaining free capacity after these attempts, not estimated savings. + try? FileManager.default.removeItem(at: directory) + } + } + + private static func attemptDirectories(at root: URL) throws -> [URL] { + let entries = try FileManager.default.contentsOfDirectory( + at: root, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + return try entries.filter { entry in + guard UUID(uuidString: entry.lastPathComponent) != nil else { return false } + let attributes = try entry.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + guard attributes.isSymbolicLink != true else { + throw SQLite.Failure.unsupported("A migration snapshot is a symbolic link; refusing to follow it") + } + return attributes.isDirectory == true + } + } + + private static func recoverIfNeeded(at url: URL, root: URL) throws { + let marker = root.appendingPathComponent("active.json") + guard FileManager.default.fileExists(atPath: marker.path) else { return } + let journal = try JSONDecoder().decode(Journal.self, from: Data(contentsOf: marker)) + guard [1, 2].contains(journal.formatVersion) else { + throw SQLite.Failure.unsupported("Unknown migration recovery format") + } + let directory = root.appendingPathComponent(journal.operation.uuidString, isDirectory: true) + // SQLite commits or rolls back the entire page replacement, including + // WAL recovery. We never restore a backup over potentially newer writes. + let current = try identity(at: url) + if current == journal.destination { + try SQLite.integrityCheck(url) + if journal.formatVersion == 2 { + guard let expected = journal.destinationData, + try SQLite.evidence(at: url) == expected else { + throw SQLite.Failure.unsupported("Installed migration data differs from the validated candidate") + } + } else { + // Older journals require their candidate as the data evidence. + // Schema identity and SQLite integrity alone cannot prove preservation. + let candidate = directory.appendingPathComponent("candidate.store") + guard try identity(at: candidate) == journal.destination else { + throw SQLite.Failure.unsupported("Validated migration candidate is missing or has changed") + } + try SQLite.validatePreservation(from: candidate, to: url) + } + } else if current != journal.source { + throw SQLite.Failure.unsupported("Store changed while migration recovery was pending") + } else { + // The original remains authoritative even if scratch copies were + // removed. Validate it, clear the attempt, then take fresh copies. + try SQLite.integrityCheck(url) + } + try clearJournal(at: root) + if current == journal.source { + // Promotion never committed; the original is still authoritative. + try? FileManager.default.removeItem(at: directory) + } else { + try? FileManager.default.removeItem(at: directory.appendingPathComponent("candidate.store")) + } + } + + static func identity(at url: URL) throws -> Identity { + let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(type: .sqlite, at: url) + guard let versions = metadata[NSStoreModelVersionIdentifiersKey] as? [String], + let checksum = metadata["NSStoreModelVersionChecksumKey"] as? String, + let hashes = metadata[NSStoreModelVersionHashesKey] as? [String: Data], + !versions.isEmpty, !checksum.isEmpty, !hashes.isEmpty else { + throw SQLite.Failure.unsupported("Missing Core Data schema metadata") + } + return Identity(versions: versions, checksum: checksum, hashes: hashes) + } + + /// The identity Core Data records for a versioned schema. It is a pure + /// function of the frozen model graph, but computing it writes a complete + /// temporary store, so each successful result is kept for the process. + static func identity(for type: any VersionedSchema.Type) throws -> Identity { + try identityCache.identity(for: type) { try computeIdentity(for: type) } + } + + private static let identityCache = SchemaIdentityCache() + + private static func computeIdentity(for type: any VersionedSchema.Type) throws -> Identity { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("schema.store") + try autoreleasepool { + let schema = Schema(versionedSchema: type) + _ = try ModelContainer(for: schema, configurations: [ + ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none) + ]) + } + return try identity(at: url) + } + + /// Process-lifetime memo of schema identities. Only successful + /// computations are stored: a failed probe (for example a full disk) is + /// retried on the next request rather than remembered. + final class SchemaIdentityCache: @unchecked Sendable { + private let lock = NSLock() + private var identities: [ObjectIdentifier: Identity] = [:] + + func identity(for type: any VersionedSchema.Type, + compute: () throws -> Identity) throws -> Identity { + let key = ObjectIdentifier(type) + if let cached = lock.withLock({ identities[key] }) { return cached } + let computed = try compute() + lock.withLock { identities[key] = computed } + return computed + } + } + + private static func rejectExternalStorage(at url: URL) throws { + let parent = url.deletingLastPathComponent() + let names = Set([url.lastPathComponent, url.deletingPathExtension().lastPathComponent]) + for name in names { + for companion in [".\(name)_SUPPORT", "\(name)_SUPPORT", "\(name).support", ".\(name).support"] { + if FileManager.default.fileExists(atPath: parent.appendingPathComponent(companion).path) { + throw SQLite.Failure.unsupported("External binary storage requires a separate migration") + } + } + } + } + + private static func protectionAttributes(like original: URL) throws -> [FileAttributeKey: Any] { + let attributes = try FileManager.default.attributesOfItem(atPath: original.path) + var retained: [FileAttributeKey: Any] = [.posixPermissions: attributes[.posixPermissions] ?? 0o600] + if let protection = attributes[.protectionKey] { retained[.protectionKey] = protection } + return retained + } + private static func createProtectedFile(_ url: URL, like original: URL) throws { + guard FileManager.default.createFile(atPath: url.path, contents: Data(), + attributes: try protectionAttributes(like: original)) else { + throw SQLite.Failure.database("Cannot create protected migration copy") + } + } + + private static func writeJournal(_ journal: Journal, at root: URL) throws { + let url = root.appendingPathComponent("active.json") + try JSONEncoder().encode(journal).write(to: url, options: .atomic) + let file = try FileHandle(forWritingTo: url) + defer { try? file.close() } + try file.synchronize() + try synchronizeDirectory(root) + } + private static func clearJournal(at root: URL) throws { + try FileManager.default.removeItem(at: root.appendingPathComponent("active.json")) + try synchronizeDirectory(root) + } + private static func synchronizeDirectory(_ url: URL) throws { + let descriptor = Darwin.open(url.path, O_RDONLY) + guard descriptor >= 0 else { throw SQLite.Failure.database("Cannot open migration directory") } + defer { Darwin.close(descriptor) } + guard fsync(descriptor) == 0 else { throw SQLite.Failure.database("Cannot persist migration journal") } + } + private final class StoreLock { + private var descriptor: Int32 + init(url: URL) throws { + descriptor = Darwin.open(url.path + ".legacy-v2.lock", O_CREAT | O_RDWR | O_NOFOLLOW, 0o600) + guard descriptor >= 0 else { throw SQLite.Failure.database("Cannot open database migration lock") } + guard flock(descriptor, LOCK_EX | LOCK_NB) == 0 else { + Darwin.close(descriptor) + descriptor = -1 + throw SQLite.Failure.database("Another opener is using this database; retry after it finishes") + } + } + func close() { + if descriptor >= 0 { flock(descriptor, LOCK_UN); Darwin.close(descriptor); descriptor = -1 } + } + deinit { close() } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacyStoreSQLite.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacyStoreSQLite.swift new file mode 100644 index 00000000000..4e5e1fe6784 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacyStoreSQLite.swift @@ -0,0 +1,315 @@ +import CryptoKit +import Foundation +import SQLite3 + +/// SQLite operations used only before a legacy store is opened by SwiftData. +/// The backup API copies committed WAL content and promotes a candidate in one +/// SQLite transaction; it never renames or deletes journal sidecars. +enum DashLegacyStoreSQLite { + enum Failure: Error, LocalizedError { + case database(String) + case sqlite(operation: String, code: Int32, reason: String) + case insufficientDiskSpace(required: Int64, available: Int64) + case unsupported(String) + case sourceChanged + + var errorDescription: String? { + switch self { + case .database(let reason): return "Legacy database migration failed: \(reason)" + case .sqlite(let operation, let code, let reason): + return "Legacy database migration failed during \(operation) (SQLite \(code)): \(reason)" + case .insufficientDiskSpace(let required, let available): + let needed = ByteCountFormatter.string(fromByteCount: required, countStyle: .file) + let free = ByteCountFormatter.string(fromByteCount: available, countStyle: .file) + return "Legacy database migration needs approximately \(needed) of free space; \(free) is available. Free device storage and retry. The original database has not been replaced." + case .unsupported(let reason): return "Legacy database migration is not safe: \(reason)" + case .sourceChanged: return "The database changed during migration. Close other users of the store and retry." + } + } + } + + final class Connection { + let handle: OpaquePointer + init(_ url: URL, writable: Bool, create: Bool = false) throws { + var result: OpaquePointer? + let flags = writable ? SQLITE_OPEN_READWRITE | (create ? SQLITE_OPEN_CREATE : 0) : SQLITE_OPEN_READONLY + let status = sqlite3_open_v2(url.path, &result, flags | SQLITE_OPEN_FULLMUTEX, nil) + guard status == SQLITE_OK, let result else { + let failure = sqliteFailure(handle: result, operation: "opening a database", status: status) + sqlite3_close(result) + throw failure + } + handle = result + sqlite3_busy_timeout(handle, 0) + } + deinit { sqlite3_close(handle) } + func execute(_ sql: String) throws { + guard sqlite3_exec(handle, sql, nil, nil, nil) == SQLITE_OK else { + throw Failure.database(String(cString: sqlite3_errmsg(handle))) + } + } + func query(_ sql: String, _ row: (OpaquePointer) throws -> Void) throws { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(handle, sql, -1, &statement, nil) == SQLITE_OK, let statement else { + throw Failure.database(String(cString: sqlite3_errmsg(handle))) + } + defer { sqlite3_finalize(statement) } + var status = sqlite3_step(statement) + while status == SQLITE_ROW { + try row(statement) + status = sqlite3_step(statement) + } + guard status == SQLITE_DONE else { throw Failure.database(String(cString: sqlite3_errmsg(handle))) } + } + } + + static func rawDigest(_ url: URL) throws -> [String: String] { + var result: [String: String] = [:] + for suffix in ["", "-wal"] { + let path = URL(fileURLWithPath: url.path + suffix) + guard FileManager.default.fileExists(atPath: path.path) else { continue } + let file = try FileHandle(forReadingFrom: path) + defer { try? file.close() } + var digest = SHA256() + while let chunk = try file.read(upToCount: 1_048_576), !chunk.isEmpty { digest.update(data: chunk) } + result[suffix] = hex(digest.finalize()) + } + guard result[""] != nil else { throw Failure.database("Store file is missing") } + return result + } + + static func copy(from source: URL, to destination: URL, + lockedDestinationCheck: (() throws -> Void)? = nil) throws { + let input = try Connection(source, writable: false) + let output = try Connection(destination, writable: true, create: lockedDestinationCheck == nil) + // C backup handles borrow both Swift connection owners, including on errors. + try withExtendedLifetime((input, output)) { + guard let backup = sqlite3_backup_init(output.handle, "main", input.handle, "main") else { + throw sqliteFailure(handle: output.handle, operation: "initializing the database copy", + status: sqlite3_errcode(output.handle)) + } + var finished = false + defer { if !finished { sqlite3_backup_finish(backup) } } + // Zero pages still acquires the destination write lock. No destination + // connection APIs may run until backup_finish; raw file reads are safe. + let lockStatus = sqlite3_backup_step(backup, 0) + guard lockStatus == SQLITE_OK else { + // The destination cannot be queried until backup_finish. It + // propagates the backup error to the destination connection. + sqlite3_backup_finish(backup) + finished = true + throw sqliteFailure(handle: output.handle, operation: "acquiring the migration write lock", + status: lockStatus) + } + try lockedDestinationCheck?() + let copyStatus = sqlite3_backup_step(backup, -1) + guard copyStatus == SQLITE_DONE else { + sqlite3_backup_finish(backup) + finished = true + throw sqliteFailure(handle: output.handle, operation: "copying the database", + status: copyStatus) + } + let status = sqlite3_backup_finish(backup) + finished = true + guard status == SQLITE_OK else { + throw sqliteFailure(handle: output.handle, operation: "committing the database copy", status: status) + } + } + } + + private static func sqliteFailure(handle: OpaquePointer?, operation: String, status: Int32) -> Failure { + let extended = handle.map { sqlite3_extended_errcode($0) } ?? status + // Use the connection's extended code only if it describes this error. + // For example, SQLITE_BUSY from backup_step may leave no connection error. + let matches = (extended & 0xff) == (status & 0xff) && extended != SQLITE_OK + let code = matches ? extended : status + let reason = matches ? String(cString: sqlite3_errmsg(handle)) : String(cString: sqlite3_errstr(status)) + return .sqlite(operation: operation, code: code, reason: reason) + } + + static func recoverRollbackJournal(at url: URL) throws { + guard FileManager.default.fileExists(atPath: url.path + "-journal") else { return } + // A process killed during SQLite promotion can leave a hot rollback + // journal. A writable SQLite read recovers it before metadata inspection. + let connection = try Connection(url, writable: true) + try connection.query("PRAGMA schema_version") { _ in } + } + + static func integrityCheck(_ url: URL) throws { + let connection = try Connection(url, writable: false) + var answers: [String] = [] + try connection.query("PRAGMA quick_check") { answers.append(string($0, 0)) } + guard answers == ["ok"] else { throw Failure.unsupported("SQLite integrity check failed") } + } + + static func checkpoint(_ url: URL) throws { + let connection = try Connection(url, writable: true) + guard sqlite3_wal_checkpoint_v2(connection.handle, nil, SQLITE_CHECKPOINT_TRUNCATE, nil, nil) == SQLITE_OK else { + throw Failure.database("Cannot close the migrated WAL") + } + var modes: [String] = [] + try connection.query("PRAGMA journal_mode=DELETE") { modes.append(string($0, 0).lowercased()) } + guard modes == ["delete"] else { + throw Failure.database("Migrated store did not leave WAL mode") + } + } + + struct Column: Equatable { + let actual: String + let normalized: String + let declaredType: String + } + struct Table { + let actual: String + let columns: [String: Column] + } + struct Layout { + let entities: [Int64: String] + let tables: [String: Table] + } + + struct TableEvidence: Codable, Equatable { + let columns: [String: String] + let rowsDigest: String + } + struct StoreEvidence: Codable, Equatable { + let entities: [String] + let tables: [String: TableEvidence] + } + + /// Durable evidence of the validated final candidate, independent of + /// SQLite page layout, WAL state and disposable migration copy files. + static func evidence(at url: URL) throws -> StoreEvidence { + let connection = try Connection(url, writable: false) + let layout = try layout(connection) + var tables: [String: TableEvidence] = [:] + for (name, table) in layout.tables { + tables[name] = TableEvidence( + columns: table.columns.mapValues(\.declaredType), + rowsDigest: try rowsDigest(connection, table: table, + names: table.columns.keys.sorted(), entities: layout.entities)) + } + return StoreEvidence(entities: layout.entities.values.sorted(), tables: tables) + } + + /// Compare every original application column and typed cell, including + /// relationship foreign keys/join rows. Extra destination columns/tables + /// are allowed; removals, type conversions and changed values are not. + static func validatePreservation(from source: URL, to destination: URL) throws { + let old = try Connection(source, writable: false) + let new = try Connection(destination, writable: false) + let before = try layout(old) + let after = try layout(new) + guard Set(before.entities.values).isSubset(of: Set(after.entities.values)) else { + throw Failure.unsupported("Migration removed an entity") + } + for (name, table) in before.tables { + guard let next = after.tables[name] else { throw Failure.unsupported("Migration removed table \(name)") } + let names = table.columns.keys.sorted() + for name in names { + guard let nextColumn = next.columns[name], nextColumn.declaredType == table.columns[name]?.declaredType else { + throw Failure.unsupported("Migration removed or converted a column in \(table.actual)") + } + } + let oldDigest = try rowsDigest(old, table: table, names: names, entities: before.entities) + let newDigest = try rowsDigest(new, table: next, names: names, entities: after.entities) + guard oldDigest == newDigest else { throw Failure.unsupported("Migration changed existing data in \(name)") } + } + } + + private static func layout(_ connection: Connection) throws -> Layout { + var entities: [Int64: String] = [:] + try connection.query("SELECT Z_ENT, Z_NAME FROM Z_PRIMARYKEY") { + entities[sqlite3_column_int64($0, 0)] = string($0, 1).uppercased() + } + guard !entities.isEmpty else { throw Failure.unsupported("Missing Core Data entity map") } + // Core Data metadata and persistent-history bookkeeping are not model + // rows. SwiftData may rebuild them while inferring a lightweight map. + let internalTables: Set = ["Z_METADATA", "Z_MODELCACHE", "Z_PRIMARYKEY", "ACHANGE", "ATRANSACTION", "ATRANSACTIONSTRING"] + var tableNames: [String] = [] + try connection.query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") { + let name = string($0, 0) + if !internalTables.contains(name) { tableNames.append(name) } + } + var tables: [String: Table] = [:] + for tableName in tableNames { + let normalized = try normalize(tableName, entities: entities) + guard tables[normalized] == nil else { throw Failure.unsupported("Ambiguous Core Data table name") } + var columns: [String: Column] = [:] + try connection.query("PRAGMA table_info(\(quote(tableName)))") { + let name = string($0, 1) + // Optimistic-lock revision changes are not application values. + guard name != "Z_OPT" else { return } + let key = try normalize(name, entities: entities) + guard columns[key] == nil else { throw Failure.unsupported("Ambiguous Core Data column name") } + columns[key] = Column(actual: name, normalized: key, declaredType: string($0, 2).uppercased()) + } + tables[normalized] = Table(actual: tableName, columns: columns) + } + return Layout(entities: entities, tables: tables) + } + + private static func normalize(_ name: String, entities: [Int64: String]) throws -> String { + guard name.hasPrefix("Z_") else { return name } + let suffix = name.dropFirst(2) + let digits = suffix.prefix(while: { $0.isNumber }) + guard !digits.isEmpty else { return name } + guard let number = Int64(digits), let entity = entities[number] else { + throw Failure.unsupported("Unknown entity ordinal in relationship table") + } + return "Z_" + entity + "_" + suffix.dropFirst(digits.count) + } + + private static func rowsDigest(_ connection: Connection, table: Table, names: [String], + entities: [Int64: String]) throws -> String { + let columns = names.compactMap { table.columns[$0] } + let selected = columns.map { quote($0.actual) }.joined(separator: ",") + let ordering = table.columns["Z_PK"].map { quote($0.actual) } ?? selected + var digest = SHA256() + var count: UInt64 = 0 + try connection.query("SELECT \(selected) FROM \(quote(table.actual)) ORDER BY \(ordering)") { row in + count += 1 + for (offset, column) in columns.enumerated() { + let index = Int32(offset) + let type = sqlite3_column_type(row, index) + digest.update(data: Data([UInt8(type)])) + var data = Data() + switch type { + case SQLITE_INTEGER: + let value = sqlite3_column_int64(row, index) + if column.actual == "Z_ENT" { + guard let entity = entities[value] else { throw Failure.unsupported("Unknown row entity ordinal") } + data = Data(entity.utf8) + } else { + var bits = value.bigEndian + data = withUnsafeBytes(of: &bits) { Data($0) } + } + case SQLITE_FLOAT: + var bits = sqlite3_column_double(row, index).bitPattern.bigEndian + data = withUnsafeBytes(of: &bits) { Data($0) } + case SQLITE_TEXT, SQLITE_BLOB: + let size = Int(sqlite3_column_bytes(row, index)) + let bytes = type == SQLITE_TEXT ? sqlite3_column_text(row, index).map(UnsafeRawPointer.init) : sqlite3_column_blob(row, index) + if size > 0, let bytes { data = Data(bytes: bytes, count: size) } + case SQLITE_NULL: break + default: throw Failure.unsupported("Unknown SQLite value type") + } + var length = UInt64(data.count).bigEndian + digest.update(data: withUnsafeBytes(of: &length) { Data($0) }) + digest.update(data: data) + } + } + var rows = count.bigEndian + digest.update(data: withUnsafeBytes(of: &rows) { Data($0) }) + return hex(digest.finalize()) + } + + private static func quote(_ name: String) -> String { "\"" + name.replacingOccurrences(of: "\"", with: "\"\"") + "\"" } + private static func string(_ statement: OpaquePointer, _ column: Int32) -> String { + guard let text = sqlite3_column_text(statement, column) else { return "" } + return String(decoding: UnsafeBufferPointer(start: text, count: Int(sqlite3_column_bytes(statement, column))), as: UTF8.self) + } + private static func hex(_ data: D) -> String where D.Element == UInt8 { + data.map { String(format: "%02x", $0) }.joined() + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 53d9399b1a2..ea878415c36 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -1,28 +1,12 @@ +import CoreData import Foundation +import Dispatch import SwiftData /// Factory for creating SwiftData model containers for Dash Platform persistence public enum DashModelContainer { - /// The wallet-and-platform model graph as every released schema version - /// registered it, built from the frozen copies under `FrozenSchemas/` - /// and parameterised on the one slot whose frozen shape differs between - /// versions (`PersistentAssetLock`, which V3 changed). - /// - /// Every entry is a nested frozen type, never a live one. A released - /// version's checksum is the hash of every entity it declares — and a - /// relationship binds its destination by entity NAME, so a version that - /// mixed one live model into an otherwise frozen graph would have that - /// live model's current shape hashed into it (the entity name resolves - /// to whichever Swift type claimed it first in the process). Freezing - /// the whole relationship-connected graph per version is what keeps a - /// released checksum stable no matter what the live models do next, and - /// `DashModelMigrationTests` proves it against stores an older build - /// actually wrote. - /// - /// Ordering is load-bearing only in the sense that it must not need to - /// change: keeping each model in the slot its live counterpart occupies - /// makes a frozen version's list positionally identical to what that - /// version shipped. + /// Accepted V1 graph. Every registered type stays frozen: relationship + /// destinations can otherwise rebind by entity name to a changed live type. private static func frozenModelGraph( assetLock: any PersistentModel.Type ) -> [any PersistentModel.Type] { @@ -70,81 +54,9 @@ public enum DashModelContainer { frozenModelGraph(assetLock: DashSchemaV1.PersistentAssetLock.self) } - /// The exact model set registered as schema V2 — V1 plus - /// `PersistentTrackedMasternode`. Frozen for the same reason as - /// `v1ModelTypes`. - fileprivate static var v2ModelTypes: [any PersistentModel.Type] { - v1ModelTypes + [DashSchemaV2.PersistentTrackedMasternode.self] - } - - /// The exact model set registered as schema V3 — V2 with the asset-lock - /// shape that gained `recipientIsExternal`. Frozen for the same reason - /// as `v1ModelTypes`. - fileprivate static var v3ModelTypes: [any PersistentModel.Type] { - frozenModelGraph(assetLock: DashSchemaV3.PersistentAssetLock.self) - + [DashSchemaV2.PersistentTrackedMasternode.self] - } - - /// The exact model set registered as schema V4: the same entities as - /// V3, with the wallet transaction models V4 widened. Frozen as its own - /// whole graph rather than as a row for the three models that changed: - /// `PersistentTxo`, `PersistentPendingInput` and `PersistentWallet` all - /// carry relationships, and a frozen model naming a relationship target - /// that its own schema does not declare binds that bare name to the live - /// type (the earlier partial rows get away with it because the models - /// they freeze are relationship-isolated). - fileprivate static var v4ModelTypes: [any PersistentModel.Type] { - [ - DashSchemaV4.PersistentIdentity.self, - DashSchemaV4.PersistentDPNSName.self, - DashSchemaV4.PersistentDashpayProfile.self, - DashSchemaV4.PersistentDashpayContactProfile.self, - DashSchemaV4.PersistentDashpayContactRequest.self, - DashSchemaV4.PersistentDashpayPayment.self, - DashSchemaV4.PersistentDashpayIgnoredSender.self, - DashSchemaV4.PersistentDocument.self, - DashSchemaV4.PersistentDataContract.self, - DashSchemaV4.PersistentPublicKey.self, - DashSchemaV4.PersistentTokenBalance.self, - DashSchemaV4.PersistentKeyword.self, - DashSchemaV4.PersistentToken.self, - DashSchemaV4.PersistentDocumentType.self, - DashSchemaV4.PersistentIndex.self, - DashSchemaV4.PersistentProperty.self, - DashSchemaV4.PersistentTokenHistoryEvent.self, - DashSchemaV4.PersistentPlatformAddress.self, - DashSchemaV4.PersistentPlatformAddressesSyncState.self, - DashSchemaV4.PersistentWallet.self, - DashSchemaV4.PersistentAccount.self, - DashSchemaV4.PersistentCoreAddress.self, - DashSchemaV4.PersistentTransaction.self, - DashSchemaV4.PersistentTxo.self, - DashSchemaV4.PersistentPendingInput.self, - DashSchemaV4.PersistentWalletManagerMetadata.self, - DashSchemaV4.PersistentShieldedNote.self, - DashSchemaV4.PersistentShieldedOutgoingNote.self, - DashSchemaV4.PersistentShieldedSyncState.self, - DashSchemaV4.PersistentShieldedActivity.self, - DashSchemaV4.PersistentShieldedViewingKey.self, - DashSchemaV4.PersistentAssetLock.self, - DashSchemaV4.PersistentInvitation.self, - DashSchemaV4.PersistentMasternode.self, - DashSchemaV4.PersistentTrackedMasternode.self - ] - } - - /// All persistent model types in the current Dash SDK schema (V5). - /// Unlike the released versions above this list tracks the LIVE models, - /// so it moves whenever a model gains a property — which is exactly why - /// the released versions must not. When the next property lands: freeze - /// every model here into the version being retired - /// (`scripts/freeze_schema_models.py`), add a version, add a stage, and - /// commit a store written by this build for the new version under the - /// test fixtures (`DashModelMigrationTests.testWriteTheLiveSchemaFixtureStore`). - /// `DashModelMigrationTests` proves a version's shape (what the entity - /// hash covers, plus its indexes) only against such a store, for the - /// live version too: changing a model here before the version ships - /// means rewriting the live fixture on purpose in the same change. + /// Live models for the next App Store schema. A release snapshot does not + /// replace these types until a subsequent shape change introduces a new live + /// version; callers must continue fetching the top-level model types. public static var modelTypes: [any PersistentModel.Type] { [ PersistentIdentity.self, @@ -181,16 +93,22 @@ public enum DashModelContainer { PersistentAssetLock.self, PersistentInvitation.self, PersistentMasternode.self, - PersistentTrackedMasternode.self + PersistentTrackedMasternode.self, + PersistentIdentityBalanceMetadata.self ] } /// Create the schema for all Dash Platform models public static var schema: Schema { - Schema(versionedSchema: DashSchemaV5.self) + Schema(versionedSchema: DashSchemaV3.self) } - /// Create a persistent model container for storing data + /// Create a persistent model container for storing data. + /// This synchronous call can copy and migrate a full database and block for + /// seconds. Do not call it on a UI actor. Apps with an explicit local URL + /// can use `createAsync(url:)` to open on the SDK's dedicated queue. + /// The legacy compatibility bridge is local-only; CloudKit uses the normal + /// migration plan because copying SQLite cannot preserve its sync state. /// - Parameters: /// - cloudKit: Whether to enable CloudKit sync (default: disabled) /// - groupContainer: App group container configuration @@ -206,14 +124,15 @@ public enum DashModelContainer { groupContainer: groupContainer, cloudKitDatabase: cloudKit ? .automatic : .none ) - return try makeContainer(configuration: modelConfiguration) + return try makeContainer(configuration: modelConfiguration, bridgeLegacyStore: !cloudKit) } /// Open (or create) the store at an explicit file URL through the same /// schema and migration plan as `create(cloudKit:groupContainer:)`. The /// migration tests use it to open stores written by older builds exactly - /// the way the app would. - static func create(url: URL) throws -> ModelContainer { + /// the way the app would. This synchronous call can block for seconds; + /// use `createAsync(url:)` on startup or from a UI actor. + public static func create(url: URL) throws -> ModelContainer { let modelConfiguration = ModelConfiguration( schema: schema, url: url, @@ -223,21 +142,135 @@ public enum DashModelContainer { return try makeContainer(configuration: modelConfiguration) } + private static let storeOpenQueue = DispatchQueue( + label: "org.dash.swift-sdk.store-open", qos: .userInitiated) + + /// Open and migrate a local store without blocking the caller's actor. + /// Only the Sendable container crosses the queue; create/use contexts on + /// their owning actor after this returns. Callers sharing a URL should + /// coalesce in-flight opens and retain one container for that store. + public static func createAsync(url: URL) async throws -> ModelContainer { + try await withCheckedThrowingContinuation { continuation in + storeOpenQueue.async { + do { + let container = try autoreleasepool { try create(url: url) } + continuation.resume(returning: container) + } catch { + continuation.resume(throwing: error) + } + } + } + } + /// The one place a persistent container is built: the live schema is /// constructed first (`schema`), and the container then runs the /// migration plan over it. That order is what the frozen versions are /// tested against, because it is the order under which a mixed /// live/frozen graph would rebind a released version's entities. private static func makeContainer( - configuration: ModelConfiguration + configuration: ModelConfiguration, + bridgeLegacyStore: Bool = true ) throws -> ModelContainer { - // Always wire the migration plan so stores created by an older SDK - // advance through the registered versioned schemas. - try ModelContainer( - for: schema, - migrationPlan: DashMigrationPlan.self, - configurations: [configuration] - ) + SDKLogger.event("store_open_started", category: .persistence, + fields: ["target_version": .publicText("3.0.0")]) + do { + let container: ModelContainer + if bridgeLegacyStore { + container = try DashLegacySchemaBridge.open( + configuration: configuration, schema: schema, plan: DashMigrationPlan.self) + } else { + container = try ModelContainer( + for: schema, + migrationPlan: try migrationPlan(at: configuration.url, defaultPlan: DashMigrationPlan.self), + configurations: [configuration]) + } + SDKLogger.event("store_open_succeeded", category: .persistence, + fields: ["target_version": .publicText("3.0.0")]) + return container + } catch { + logMigrationFailure(error) + throw error + } + } + + /// Error descriptions/userInfo can include rows, URLs and other private + /// values. Record only a known system domain (or the error's type) and code. + private static func logMigrationFailure(_ error: Error) { + let nsError = error as NSError + let systemDomains: Set = [NSCocoaErrorDomain, NSPOSIXErrorDomain, NSOSStatusErrorDomain, "NSSQLiteErrorDomain"] + let domain = systemDomains.contains(nsError.domain) ? nsError.domain : String(reflecting: type(of: error)) + SDKLogger.event("store_open_failed", category: .persistence, severity: .error, + fields: ["error_domain": .publicText(domain), "error_code": .integer(Int64(nsError.code)), + "target_version": .publicText("3.0.0")]) + } + + /// Select by the complete stored model identity, after journal recovery. + /// Accepted V1 contains fields absent from the real historical V2; sending + /// it through V2 would delete those values before V3 adds the fields again. + /// `identity` resolves a schema's stored identity; tests inject a failing + /// probe to pin which routes may depend on it. Only labels whose route is + /// undecidable without it (1.0.0, 2.0.0) consult the probe at all. + static func migrationPlan( + at url: URL, defaultPlan: any SchemaMigrationPlan.Type, + identity: (any VersionedSchema.Type) throws -> DashLegacySchemaBridge.Identity + = DashLegacySchemaBridge.identity(for:) + ) throws -> any SchemaMigrationPlan.Type { + guard ObjectIdentifier(defaultPlan) == ObjectIdentifier(DashMigrationPlan.self) else { return defaultPlan } + guard FileManager.default.fileExists(atPath: url.path) else { + SDKLogger.event("store_migration_route", category: .persistence, fields: [ + "route": .publicText("new-store"), "target_version": .publicText("3.0.0")]) + return defaultPlan + } + let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(type: .sqlite, at: url) + let versions = metadata[NSStoreModelVersionIdentifiersKey] as? [String] + let hashes = metadata[NSStoreModelVersionHashesKey] as? [String: Data] + let checksum = metadata["NSStoreModelVersionChecksumKey"] as? String + // Only validated schema metadata is public diagnostic material. Never + // log arbitrary strings from malformed store metadata or any store path. + let safeVersions = versions.flatMap { values -> String? in + guard !values.isEmpty, values.count <= 4, + values.allSatisfy({ $0.count <= 32 && $0.range(of: #"^\d+\.\d+\.\d+$"#, options: .regularExpression) != nil }) else { return nil } + return values.joined(separator: ",") + } ?? "unavailable" + let safeChecksum = checksum.flatMap { Data(base64Encoded: $0)?.count == 32 ? $0 : nil } ?? "unavailable" + func logRoute(_ route: String) { + SDKLogger.event("store_migration_route", category: .persistence, fields: [ + "source_version": .publicText(safeVersions), "source_checksum": .publicText(safeChecksum), + "route": .publicText(route), "target_version": .publicText("3.0.0")]) + } + func matches(_ type: any VersionedSchema.Type) throws -> Bool { + let expected = try identity(type) + return hashes == expected.hashes && (checksum == nil || checksum == expected.checksum) + } + // A 1.0.0 label alone cannot choose between the accepted-V1 route and + // the legacy bridge, and the default plan has no V1 stage, so a failed + // probe stays fatal here: the open fails with the probe's own error + // instead of an inapplicable plan, and the store is untouched. + if versions == ["1.0.0"], try matches(DashSchemaV1.self) { + logRoute("accepted-v1-to-v3") + return DashAcceptedV1MigrationPlan.self + } + if versions == ["2.0.0"] { + // The immediately preceding candidate used the current graph with + // a V2 label. Accept its exact shape, never arbitrary beta V2 data. + // A probe failure is equally undecidable and refuses the same way. + let historical = try matches(DashSchemaV2.self) + let previousCurrent = try !historical && matches(DashSchemaV3.self) + guard historical || previousCurrent else { + logRoute("unsupported-v2") + throw DashLegacyStoreSQLite.Failure.unsupported( + "The database identifies itself as schema 2.0.0 but its model does not match the supported historical or current schema. The original database has not been replaced. Contact support; do not delete the app.") + } + logRoute(historical ? "historical-v2-to-v3" : "previous-live-v2-current-shape") + } else if versions == ["3.0.0"] { + // Same plan either way. Opening a current store must neither wait + // on nor fail with a schema probe that could only refine this line; + // `source_checksum` above already identifies the exact graph. + logRoute("labelled-current-v3") + } else { + logRoute("ordinary-current-plan") + } + return defaultPlan } /// Create an in-memory model container for testing @@ -254,144 +287,35 @@ public enum DashModelContainer { /// SwiftData migration plan for Dash Platform model updates public enum DashMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { - [ - DashSchemaV1.self, DashSchemaV2.self, DashSchemaV3.self, DashSchemaV4.self, - DashSchemaV5.self - ] + [DashSchemaV2.self, DashSchemaV3.self] } public static var stages: [MigrationStage] { [ - .lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV2.self), - .lightweight(fromVersion: DashSchemaV2.self, toVersion: DashSchemaV3.self), - .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self), - .lightweight(fromVersion: DashSchemaV4.self, toVersion: DashSchemaV5.self) + .lightweight(fromVersion: DashSchemaV2.self, toVersion: DashSchemaV3.self) ] } } -/// Version 1 of the Dash Platform schema -/// Includes `PersistentCoreAddress` to match the example app's former container schema. -/// The model is additive with optional relationships, so existing narrower stores can -/// use SwiftData's lightweight migration path. +/// Separate compatibility route: V1 has fields missing from historical V2. +/// Never insert V2 between this baseline and the current schema. +enum DashAcceptedV1MigrationPlan: SchemaMigrationPlan { + static var schemas: [any VersionedSchema.Type] { [DashSchemaV1.self, DashSchemaV3.self] } + static var stages: [MigrationStage] { + [.lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV3.self)] + } +} + +/// The accepted historical baseline, pinned by the unchanged generated V1 +/// models and `Fixtures/SchemaStores/dash-v1.store`. Preserve those definitions +/// and fixture bytes when introducing later schema versions. /// -/// Note: this V1 identifier has accumulated several destructive -/// dev-only changes that cannot be expressed via the lightweight -/// migration path: -/// - `PersistentTransaction.txid` and the renamed -/// `PersistentTxo.outpoint` switched from `String` to raw `Data` -/// (unique-attribute retype). -/// - The `PersistentUtxo` model was renamed to `PersistentTxo`, -/// gained `walletId` + `spendingTransaction`, and the schema -/// topology shifted: `PersistentTransaction` lost both -/// `walletId` and `account` and now hangs on transactions purely -/// through the `outputs` / `inputs` TXO relationships. -/// - `PersistentAccount.outputs` (the cascade-owned -/// `[PersistentTxo]` collection paired with -/// `PersistentTxo.account`) was removed. Per-account TXOs are -/// now derived through `coreAddresses.flatMap(\.txos)` — -/// `PersistentTxo.account` survives as a one-way fallback -/// pointer with no inverse. Removing the inverse changes the -/// relationship topology for the underlying SQLite store, so -/// existing dev stores can't be opened with the new schema. -/// - `PersistentAccount.wallet` was tightened from -/// `PersistentWallet?` to non-optional `PersistentWallet`. Every -/// account currently belongs to a wallet; the type system now -/// reflects that invariant. Switching the optionality of a -/// relationship column rewrites the SQLite schema, so existing -/// dev stores can't be reused. -/// - `PersistentWallet.isWatchOnly` and -/// `PersistentAccount.isWatchOnly` were removed. The runtime -/// watch-only state lives on the native `Wallet` / -/// `ManagedAccount` (FFI-backed); persisting it on the SwiftData -/// side was redundant and the persister never wrote it. -/// - `PersistentDPNSName` was added (cascade-owned by -/// `PersistentIdentity` via the new `dpnsNames` relationship) -/// so DPNS labels are persisted instead of recomputed on every -/// `IdentityDetailView` open. Existing dev stores predate the -/// row collection and rebuild on next sync; the changeset's -/// append-only merge policy populates the new rows from the -/// persister callback. -/// - `PersistentDashpayProfile` was added (cascade-owned by -/// `PersistentIdentity` via the new `dashpayProfile` optional -/// relationship). Mirrors `IdentityEntry::dashpay_profile` from -/// the FFI so DashPay profile fields (display name, public -/// message, avatar URL / hash / fingerprint, bio) are persisted -/// across launches instead of being refetched. Existing dev -/// stores predate the row and rebuild on next profile sync; the -/// persister upserts in place via -/// `PlatformWalletPersistenceHandler.upsertDashpayProfile`. -/// - `PersistentDashpayContactRequest` was added (cascade-owned by -/// `PersistentIdentity` via the new `contactRequests` collection). -/// Mirrors `ContactChangeSet::sent_requests` / -/// `incoming_requests` / `established` projected through the new -/// `on_persist_contacts_fn` FFI callback, with one row per -/// `(network, owner, contact, isOutgoing)` quad. Existing dev -/// stores predate the row collection and rebuild on next -/// DashPay contact sync. -/// - `PersistentDashpayContactRequest` gained the additive -/// `paymentChannelBroken` column (defaulted `false`) so the G1c -/// broken-channel flag projected by the persister survives -/// restarts. Additive-with-default ⇒ lightweight migration. -/// - `PersistentDashpayPayment` was added (cascade-owned by -/// `PersistentIdentity` via the new `dashpayPayments` -/// collection). Mirrors the per-identity `dashpay_payments` map -/// read through `managed_identity_get_dashpay_payments`; rows are -/// refreshed by `PlatformWalletManager.refreshDashPayPayments` -/// (the persister doesn't project payment history). Additive -/// model + additive relationship ⇒ lightweight migration. -/// - `PersistentDashpayIgnoredSender` was added (cascade-owned by -/// `PersistentIdentity` via the new `dashpayIgnoredSenders` -/// collection). Persists per-sender ignores (local-only mute, = -/// block, reversible) the persister projects in the `ignored` -/// changeset array so the Rust `ignored_senders` set can be restored -/// at load — without it an ignored sender resurfaces on relaunch. -/// Keyed per-sender (no `accountReference`), so an ignored sender's -/// rotated requests are suppressed too. Additive model + additive -/// relationship ⇒ lightweight migration. (Replaces the earlier -/// per-`(sender, accountReference)` `PersistentDashpayRejectedRequest` -/// — the model decision collapsed reject into ignore.) -/// - `PersistentDashpayContactProfile` was added (cascade-owned by -/// `PersistentIdentity` via the new `contactProfiles` collection). -/// Mirrors one entry of the per-identity `contact_profiles` map -/// (cached contacts' public profiles, keyed by the contact's -/// identity id) projected by the persister as -/// `IdentityEntryFFI.contact_profiles` rows, and read back at load to -/// rebuild the Rust cache so contacts don't refetch on every -/// relaunch. Distinct from `PersistentDashpayProfile` (the owner's -/// own profile). Additive model + additive relationship ⇒ -/// lightweight migration. -/// - `PersistentAccount` gained `#Unique<…>([\.wallet, \.accountType, -/// \.accountIndex, \.userIdentityId, \.friendIdentityId])` plus -/// `@Attribute(.unique)` on `accountExtendedPubKeyBytes`. The -/// xpub field also flipped from `Data` to `Data?` so multiple -/// unhydrated rows (xpub not yet known) don't collide on the -/// UNIQUE constraint — SQL allows multiple `NULL`s. Together -/// these enforce "one row per account identity, one xpub per -/// account" at the database layer; pre-refactor the persister's -/// `applyAccountChangeset` was string-keyed on the legacy -/// `Debug`-formatted `account_type_name` and could grow -/// duplicate rows for the same logical account. -/// - `PersistentTokenBalance.balance` remains the original `Int64` SwiftData -/// property and SQLite column. Protocol `u64` values use its raw bits via a -/// computed accessor, so full-domain support does not alter this V1 schema. -/// - `PersistentDPNSName` gained the DPNS username-marketplace -/// columns `documentIdBase58`, `priceCredits`, `saleStatusRaw`, -/// `counterpartyIdBase58`, the three optional document timestamps, -/// and `marketplaceUpdatedAt`, written by -/// the new `on_persist_dpns_name_states_fn` persister callback -/// (`DpnsNameStateFFI`). All optional or defaulted, and the -/// `(networkRaw, normalizedParentDomainName, normalizedLabel)` -/// uniqueness is unchanged ⇒ lightweight migration. Existing rows -/// migrate with a nil `documentIdBase58`, which is the documented -/// "no marketplace state tracked" signal — the next marketplace -/// sync pass fills them in. -/// Each of those is a destructive change to a unique-attribute -/// column or to relationship topology, so any pre-existing dev -/// store will fail to open and get rebuilt from scratch on next -/// sync. Bumping the version isn't useful without a real -/// `MigrationStage` (and there's nothing worth preserving in dev -/// databases at this point), so we let the container recreate. +/// Migration tests establish compatibility from this accepted baseline into +/// the current live schema. They do not reconstruct or verify the database +/// written by the original App Store binary. Other historical development +/// layouts are accepted only by the local legacy bridge when every existing +/// value and relationship survives migration to fixed V3. Other layouts fail; +/// the container never erases or recreates a user's database. public enum DashSchemaV1: VersionedSchema { public static var versionIdentifier: Schema.Version { Schema.Version(1, 0, 0) @@ -402,117 +326,55 @@ public enum DashSchemaV1: VersionedSchema { } } -/// Version 2 adds wallet-independent tracked masternodes. The new model has -/// no relationship or required-data dependency on V1 rows, so a lightweight -/// migration preserves every existing row and creates its table. +/// Historical V2 reconstructed from commit 52e8d4ec68. Its complete frozen +/// graph matches the database observed on the released application. Other +/// development layouts that reused the V2 number are not historical V2. public enum DashSchemaV2: VersionedSchema { - public static var versionIdentifier: Schema.Version { - Schema.Version(2, 0, 0) - } - + public static var versionIdentifier: Schema.Version { Schema.Version(2, 0, 0) } public static var models: [any PersistentModel.Type] { - DashModelContainer.v2ModelTypes + [ + DashSchemaV2.PersistentIdentity.self, + DashSchemaV2.PersistentDPNSName.self, + DashSchemaV2.PersistentDashpayProfile.self, + DashSchemaV2.PersistentDashpayContactProfile.self, + DashSchemaV2.PersistentDashpayContactRequest.self, + DashSchemaV2.PersistentDashpayPayment.self, + DashSchemaV2.PersistentDashpayIgnoredSender.self, + DashSchemaV2.PersistentDocument.self, + DashSchemaV2.PersistentDataContract.self, + DashSchemaV2.PersistentPublicKey.self, + DashSchemaV2.PersistentTokenBalance.self, + DashSchemaV2.PersistentKeyword.self, + DashSchemaV2.PersistentToken.self, + DashSchemaV2.PersistentDocumentType.self, + DashSchemaV2.PersistentIndex.self, + DashSchemaV2.PersistentProperty.self, + DashSchemaV2.PersistentTokenHistoryEvent.self, + DashSchemaV2.PersistentPlatformAddress.self, + DashSchemaV2.PersistentPlatformAddressesSyncState.self, + DashSchemaV2.PersistentWallet.self, + DashSchemaV2.PersistentAccount.self, + DashSchemaV2.PersistentCoreAddress.self, + DashSchemaV2.PersistentTransaction.self, + DashSchemaV2.PersistentTxo.self, + DashSchemaV2.PersistentPendingInput.self, + DashSchemaV2.PersistentWalletManagerMetadata.self, + DashSchemaV2.PersistentShieldedNote.self, + DashSchemaV2.PersistentShieldedOutgoingNote.self, + DashSchemaV2.PersistentShieldedSyncState.self, + DashSchemaV2.PersistentShieldedActivity.self, + DashSchemaV2.PersistentShieldedViewingKey.self, + DashSchemaV2.PersistentAssetLock.self, + DashSchemaV2.PersistentInvitation.self, + DashSchemaV2.PersistentMasternode.self, + DashSchemaV2.PersistentTrackedMasternode.self + ] } } -/// Version 3 adds `recipientIsExternal` to `PersistentAssetLock` — an -/// optional column on an existing entity, so a lightweight migration -/// preserves every existing row and backfills `NULL`. -/// -/// This is the first version to be registered alongside a genuinely frozen -/// copy of the model it changes (`DashSchemaV1.PersistentAssetLock`). Without -/// that copy, adding the property would have mutated V1's and V2's checksums -/// in place and a store written by the V2 binary would have matched no -/// registered schema, failing to open with Cocoa error 134504 rather than -/// migrating. Follow the same pattern for the next property added to any -/// model: freeze the old shape, add a version, add a stage. +/// Current working schema. A later shape change must preserve this graph as +/// the fixed legacy-bridge target before introducing another live version. public enum DashSchemaV3: VersionedSchema { - public static var versionIdentifier: Schema.Version { - Schema.Version(3, 0, 0) - } - - public static var models: [any PersistentModel.Type] { - DashModelContainer.v3ModelTypes - } -} - -/// Version 4 adds the sweep columns, on the same entity set as V3: -/// - `PersistentTxo.supersededByTxid` (optional) and -/// `PersistentPendingInput.isSweptTombstone` (defaulted `false`). -/// Together they let a sweep's claim on an input whose funding TXO -/// hasn't arrived yet survive the loser transaction's deletion — -/// previously that claim lived only on the doomed row's -/// `PersistentPendingInput`, which cascades away with it. Existing -/// rows migrate as ordinary (non-tombstone, non-superseded) entries. -/// - `PersistentPendingInput.winnerMinedHeight` (optional — a -/// block-context sweep tombstone's finality stamp, the winner's own -/// mined height) and `PersistentWallet.lastAppliedChainLockHeight` -/// (optional — the numeric chainlock watermark delivered by -/// `on_persist_wallet_changeset_chain_lock_height_fn`, stored -/// monotonic-max). Together they drive the bounded tombstone lifetime: -/// a tombstone is collected exactly when -/// `min(chainlockHeight, syncedHeight)` reaches its stamp. -/// Pre-existing rows read as unstamped (held forever) over a wallet -/// with no boundary yet. -/// - The `(walletId, isSweptTombstone)` index on -/// `PersistentPendingInput`, serving the collector's tombstone-only -/// scan. -/// Every column is additive with a default or optional and the index is -/// additive, so a lightweight migration preserves each existing row. -/// -/// Registering it required freezing every model V1–V3 register — the -/// generated copies under `FrozenSchemas/`, see -/// `scripts/freeze_schema_models.py`. -public enum DashSchemaV4: VersionedSchema { - public static var versionIdentifier: Schema.Version { - Schema.Version(4, 0, 0) - } - - public static var models: [any PersistentModel.Type] { - DashModelContainer.v4ModelTypes - } -} - -/// Version 5 adds three optional columns to `PersistentPublicKey`, on the -/// same entity set as V4: -/// - `totalBudget`: the credits an authentication key may take from its -/// identity over its whole lifetime, `nil` for a key registered without -/// a budget. Signed carrier for the protocol's unsigned `Credits`, read -/// through `totalBudgetCredits`. -/// - `expiresAt`: the block time in milliseconds from which the key can -/// no longer sign, `nil` for a key registered without an expiry. Read -/// through `expiresAtMillis`. -/// - `contractBoundsKind`: the FFI `contract_bounds_kind` discriminant -/// (0 none, 1 SingleContract, 2 SingleContractDocumentType, -/// 3 ContractGroup) the key row was written with. The two columns V4 -/// had (`contractBoundsData`, `contractBoundsDocumentTypeName`) cannot -/// tell a contract-group bound apart from a whole-contract one, so -/// restoring an identity that held a group-bound AUTHENTICATION key -/// brought the key back unbounded and changed its authorization -/// metadata. `NULL` reads as "legacy row, infer the variant the way V4 -/// did" (`PersistentPublicKey.effectiveContractBoundsKind`). -/// The two limits are what make a key an `IdentityPublicKey::V1` (protocol -/// version 14); without the columns a limited key would come back unlimited -/// on cold restart and the wallet would offer it for signing work consensus -/// refuses. Existing rows migrate with all three `NULL`, which is exactly "a -/// version 0 key, no limits, bounds as V4 stored them". -/// -/// All three columns are additive and optional, so a lightweight migration -/// preserves every existing row. The bounds kind joined V5 before the -/// version shipped, in the change that rewrote the `dash-v5` fixture store -/// on purpose (see the `DashModelContainer.modelTypes` doc); a store written -/// by a V5 build from before that change matches no registered version. -/// -/// Registering V5 required freezing every model V4 registers: the generated -/// copies under `FrozenSchemas/`, see `scripts/freeze_schema_models.py`. -/// V4 needed the whole graph rather than a row for `PersistentPublicKey` -/// alone: see `DashModelContainer.v4ModelTypes`. -public enum DashSchemaV5: VersionedSchema { - public static var versionIdentifier: Schema.Version { - Schema.Version(5, 0, 0) - } - - public static var models: [any PersistentModel.Type] { - DashModelContainer.modelTypes - } + public static var versionIdentifier: Schema.Version { Schema.Version(3, 0, 0) } + public static var models: [any PersistentModel.Type] { DashModelContainer.modelTypes } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentAccount.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentAccount.swift similarity index 96% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentAccount.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentAccount.swift index e5c5757dfca..df18a8da5eb 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentAccount.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentAccount.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentAccount` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentAccount` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentAccount { #Unique([ diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV3+PersistentAssetLock.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentAssetLock.swift similarity index 86% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV3+PersistentAssetLock.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentAssetLock.swift index d54a4343f1f..0eccf4e8fef 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV3+PersistentAssetLock.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentAssetLock.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentAssetLock` exactly as schema DashSchemaV3 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// `PersistentAssetLock` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV3 { +extension DashSchemaV2 { @Model final class PersistentAssetLock { #Index([\.walletId]) @@ -35,8 +35,6 @@ extension DashSchemaV3 { var recipientPlatformAddressType: UInt8? - var recipientIsExternal: Bool? - var createdAt: Date var updatedAt: Date @@ -66,9 +64,9 @@ extension DashSchemaV3 { } } -extension DashSchemaV3.PersistentAssetLock { - static func predicate(walletId: Data) -> Predicate { - #Predicate { entry in +extension DashSchemaV2.PersistentAssetLock { + static func predicate(walletId: Data) -> Predicate { + #Predicate { entry in entry.walletId == walletId } } @@ -76,15 +74,15 @@ extension DashSchemaV3.PersistentAssetLock { static func predicate( walletId: Data, identityIndex: UInt32 - ) -> Predicate { + ) -> Predicate { let identityIndexRaw = Int32(bitPattern: identityIndex) - return #Predicate { entry in + return #Predicate { entry in entry.walletId == walletId && entry.identityIndexRaw == identityIndexRaw } } } -extension DashSchemaV3.PersistentAssetLock { +extension DashSchemaV2.PersistentAssetLock { static func encodeOutPoint(rawBytes: Data) -> String { precondition(rawBytes.count == 36, "outpoint must be 36 bytes") let txid = rawBytes.prefix(32) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentCoreAddress.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentCoreAddress.swift similarity index 93% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentCoreAddress.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentCoreAddress.swift index f2949819ba4..3252a13241b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentCoreAddress.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentCoreAddress.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentCoreAddress` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentCoreAddress` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentCoreAddress { @Attribute(.unique) var address: String @@ -55,7 +55,7 @@ extension DashSchemaV4 { } } -extension DashSchemaV4.PersistentCoreAddress { +extension DashSchemaV2.PersistentCoreAddress { var poolTypeName: String { switch poolTypeTag { case 0: return "External" diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDPNSName.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentDPNSName.swift similarity index 91% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDPNSName.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentDPNSName.swift index 893dc47285e..668be5316b9 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDPNSName.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentDPNSName.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentDPNSName` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentDPNSName` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentDPNSName { #Unique([\.networkRaw, \.normalizedParentDomainName, \.normalizedLabel]) @@ -82,7 +82,7 @@ extension DashSchemaV4 { } } -extension DashSchemaV4.PersistentDPNSName { +extension DashSchemaV2.PersistentDPNSName { var saleStatus: DpnsNameSaleStatus? { guard documentIdBase58 != nil else { return nil } switch saleStatusRaw { @@ -109,7 +109,7 @@ extension DashSchemaV4.PersistentDPNSName { } } -extension DashSchemaV4.PersistentDPNSName { +extension DashSchemaV2.PersistentDPNSName { static func normalize(_ input: String) -> String { String(input.map { c -> Character in switch c { @@ -122,10 +122,10 @@ extension DashSchemaV4.PersistentDPNSName { } } -extension DashSchemaV4.PersistentDPNSName { - static func predicate(identityId: Data) -> Predicate { +extension DashSchemaV2.PersistentDPNSName { + static func predicate(identityId: Data) -> Predicate { let target = identityId - return #Predicate { name in + return #Predicate { name in name.identity.identityId == target && name.isOwned == true } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayContactProfile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentDashpayContactProfile.swift similarity index 87% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayContactProfile.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentDashpayContactProfile.swift index 5053e0e2a28..e8267e0c949 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayContactProfile.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentDashpayContactProfile.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentDashpayContactProfile` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentDashpayContactProfile` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentDashpayContactProfile { #Unique([ @@ -73,12 +73,12 @@ extension DashSchemaV4 { } } -extension DashSchemaV4.PersistentDashpayContactProfile { +extension DashSchemaV2.PersistentDashpayContactProfile { static func predicate( ownerIdentityId: Data - ) -> Predicate { + ) -> Predicate { let target = ownerIdentityId - return #Predicate { row in + return #Predicate { row in row.ownerIdentityId == target } } @@ -86,10 +86,10 @@ extension DashSchemaV4.PersistentDashpayContactProfile { static func predicate( ownerIdentityId: Data, contactIdentityId: Data - ) -> Predicate { + ) -> Predicate { let target = ownerIdentityId let contact = contactIdentityId - return #Predicate { row in + return #Predicate { row in row.ownerIdentityId == target && row.contactIdentityId == contact } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayContactRequest.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentDashpayContactRequest.swift similarity index 89% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayContactRequest.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentDashpayContactRequest.swift index 744c5572ddf..2fa06b963fd 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayContactRequest.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentDashpayContactRequest.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentDashpayContactRequest` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentDashpayContactRequest` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentDashpayContactRequest { #Unique([ @@ -95,12 +95,12 @@ extension DashSchemaV4 { } } -extension DashSchemaV4.PersistentDashpayContactRequest { +extension DashSchemaV2.PersistentDashpayContactRequest { static func predicate( ownerIdentityId: Data - ) -> Predicate { + ) -> Predicate { let target = ownerIdentityId - return #Predicate { row in + return #Predicate { row in row.ownerIdentityId == target } } @@ -108,10 +108,10 @@ extension DashSchemaV4.PersistentDashpayContactRequest { static func predicate( ownerIdentityId: Data, isOutgoing: Bool - ) -> Predicate { + ) -> Predicate { let target = ownerIdentityId let direction = isOutgoing - return #Predicate { row in + return #Predicate { row in row.ownerIdentityId == target && row.isOutgoing == direction } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayIgnoredSender.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentDashpayIgnoredSender.swift similarity index 82% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayIgnoredSender.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentDashpayIgnoredSender.swift index d332a6ffa47..2e7a9976776 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayIgnoredSender.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentDashpayIgnoredSender.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentDashpayIgnoredSender` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentDashpayIgnoredSender` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentDashpayIgnoredSender { #Unique([ @@ -43,12 +43,12 @@ extension DashSchemaV4 { } } -extension DashSchemaV4.PersistentDashpayIgnoredSender { +extension DashSchemaV2.PersistentDashpayIgnoredSender { static func predicate( ownerIdentityId: Data - ) -> Predicate { + ) -> Predicate { let target = ownerIdentityId - return #Predicate { row in + return #Predicate { row in row.ownerIdentityId == target } } @@ -56,10 +56,10 @@ extension DashSchemaV4.PersistentDashpayIgnoredSender { static func predicate( ownerIdentityId: Data, ignoredSenderId: Data - ) -> Predicate { + ) -> Predicate { let target = ownerIdentityId let sender = ignoredSenderId - return #Predicate { row in + return #Predicate { row in row.ownerIdentityId == target && row.ignoredSenderId == sender } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayPayment.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentDashpayPayment.swift similarity index 87% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayPayment.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentDashpayPayment.swift index 7dc4b69c6f1..a370bc00593 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayPayment.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentDashpayPayment.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentDashpayPayment` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentDashpayPayment` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentDashpayPayment { #Unique([ @@ -75,12 +75,12 @@ extension DashSchemaV4 { } } -extension DashSchemaV4.PersistentDashpayPayment { +extension DashSchemaV2.PersistentDashpayPayment { static func predicate( ownerIdentityId: Data - ) -> Predicate { + ) -> Predicate { let target = ownerIdentityId - return #Predicate { row in + return #Predicate { row in row.ownerIdentityId == target } } @@ -88,10 +88,10 @@ extension DashSchemaV4.PersistentDashpayPayment { static func predicate( ownerIdentityId: Data, counterpartyIdentityId: Data - ) -> Predicate { + ) -> Predicate { let target = ownerIdentityId let counterparty = counterpartyIdentityId - return #Predicate { row in + return #Predicate { row in row.ownerIdentityId == target && row.counterpartyIdentityId == counterparty } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayProfile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentDashpayProfile.swift similarity index 88% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayProfile.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentDashpayProfile.swift index 49ecf016725..c05772d4de9 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayProfile.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentDashpayProfile.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentDashpayProfile` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentDashpayProfile` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentDashpayProfile { #Unique([\.networkRaw, \.identity]) @@ -60,10 +60,10 @@ extension DashSchemaV4 { } } -extension DashSchemaV4.PersistentDashpayProfile { - static func predicate(identityId: Data) -> Predicate { +extension DashSchemaV2.PersistentDashpayProfile { + static func predicate(identityId: Data) -> Predicate { let target = identityId - return #Predicate { profile in + return #Predicate { profile in profile.identity.identityId == target } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDataContract.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentDataContract.swift similarity index 89% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDataContract.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentDataContract.swift index 58068875996..6506ef09b7c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDataContract.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentDataContract.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentDataContract` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentDataContract` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentDataContract { #Index([\.networkRaw]) @@ -230,56 +230,56 @@ extension DashSchemaV4 { } } -extension DashSchemaV4.PersistentDataContract { - static func predicate(contractId: String) -> Predicate { +extension DashSchemaV2.PersistentDataContract { + static func predicate(contractId: String) -> Predicate { guard let idData = Data.identifier(fromBase58: contractId) else { - return #Predicate { _ in false } + return #Predicate { _ in false } } - return #Predicate { contract in + return #Predicate { contract in contract.id == idData } } - static func predicate(ownerId: Data) -> Predicate { - #Predicate { contract in + static func predicate(ownerId: Data) -> Predicate { + #Predicate { contract in contract.ownerId == ownerId } } - static func predicate(name: String) -> Predicate { - #Predicate { contract in + static func predicate(name: String) -> Predicate { + #Predicate { contract in contract.name.localizedStandardContains(name) } } - static var contractsWithTokensPredicate: Predicate { - #Predicate { contract in + static var contractsWithTokensPredicate: Predicate { + #Predicate { contract in contract.hasTokens == true } } - static func predicate(keyword: String) -> Predicate { - #Predicate { contract in + static func predicate(keyword: String) -> Predicate { + #Predicate { contract in contract.keywordRelations.contains { $0.keyword == keyword } } } - static func needsSyncPredicate(olderThan date: Date) -> Predicate { - #Predicate { contract in + static func needsSyncPredicate(olderThan date: Date) -> Predicate { + #Predicate { contract in contract.lastSyncedAt == nil || contract.lastSyncedAt! < date } } - static func predicate(network: Network) -> Predicate { + static func predicate(network: Network) -> Predicate { let target = network.rawValue - return #Predicate { contract in + return #Predicate { contract in contract.networkRaw == target } } - static func contractsWithTokensPredicate(network: Network) -> Predicate { + static func contractsWithTokensPredicate(network: Network) -> Predicate { let target = network.rawValue - return #Predicate { contract in + return #Predicate { contract in contract.hasTokens == true && contract.networkRaw == target } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDocument.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentDocument.swift similarity index 98% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDocument.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentDocument.swift index 896b6010580..9b87c818a03 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDocument.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentDocument.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentDocument` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentDocument` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentDocument { #Index([\.networkRaw]) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDocumentType.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentDocumentType.swift similarity index 92% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDocumentType.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentDocumentType.swift index d6cbbf62bfd..70b356986f3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDocumentType.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentDocumentType.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentDocumentType` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentDocumentType` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentDocumentType { @Attribute(.unique) var id: Data @@ -23,8 +23,6 @@ extension DashSchemaV4 { var documentsCanBeDeleted: Bool var documentsTransferable: Bool - var indexOnly: Bool = false - var requiredFieldsJSON: Data? var securityLevel: Int @@ -73,7 +71,7 @@ extension DashSchemaV4 { } } -extension DashSchemaV4.PersistentDocumentType { +extension DashSchemaV2.PersistentDocumentType { var contractIdBase58: String { contractId.toBase58String() } @@ -86,7 +84,7 @@ extension DashSchemaV4.PersistentDocumentType { try? JSONSerialization.jsonObject(with: propertiesJSON, options: []) as? [String: Any] } - var persistentProperties: [DashSchemaV4.PersistentProperty]? { + var persistentProperties: [DashSchemaV2.PersistentProperty]? { return propertiesList } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentIdentity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentIdentity.swift similarity index 90% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentIdentity.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentIdentity.swift index 9cf09acdedb..7334a17c9fd 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentIdentity.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentIdentity.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentIdentity` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentIdentity` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentIdentity { #Index([\.networkRaw]) @@ -173,42 +173,42 @@ extension DashSchemaV4 { } } -extension DashSchemaV4.PersistentIdentity { - static func predicate(identityId: Data) -> Predicate { - #Predicate { identity in +extension DashSchemaV2.PersistentIdentity { + static func predicate(identityId: Data) -> Predicate { + #Predicate { identity in identity.identityId == identityId } } - static var walletOwnedIdentitiesPredicate: Predicate { - #Predicate { identity in + static var walletOwnedIdentitiesPredicate: Predicate { + #Predicate { identity in identity.wallet != nil } } - static func predicate(type: IdentityType) -> Predicate { + static func predicate(type: IdentityType) -> Predicate { let typeString = type.rawValue - return #Predicate { identity in + return #Predicate { identity in identity.identityType == typeString } } - static func needsSyncPredicate(olderThan date: Date) -> Predicate { - #Predicate { identity in + static func needsSyncPredicate(olderThan date: Date) -> Predicate { + #Predicate { identity in identity.lastSyncedAt == nil || identity.lastSyncedAt! < date } } - static func predicate(network: Network) -> Predicate { + static func predicate(network: Network) -> Predicate { let target = network.rawValue - return #Predicate { identity in + return #Predicate { identity in identity.networkRaw == target } } - static func walletOwnedIdentitiesPredicate(network: Network) -> Predicate { + static func walletOwnedIdentitiesPredicate(network: Network) -> Predicate { let target = network.rawValue - return #Predicate { identity in + return #Predicate { identity in identity.wallet != nil && identity.networkRaw == target } } @@ -216,16 +216,16 @@ extension DashSchemaV4.PersistentIdentity { static func fetch( in context: ModelContext, identityId: Data - ) -> DashSchemaV4.PersistentIdentity? { + ) -> DashSchemaV2.PersistentIdentity? { let target = identityId - let descriptor = FetchDescriptor( + let descriptor = FetchDescriptor( predicate: #Predicate { $0.identityId == target } ) return try? context.fetch(descriptor).first } } -extension DashSchemaV4.PersistentIdentity { +extension DashSchemaV2.PersistentIdentity { @discardableResult static func updateBalance( in context: ModelContext, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentIndex.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentIndex.swift similarity index 71% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentIndex.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentIndex.swift index 9b93607a0d4..c5460923a9f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentIndex.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentIndex.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentIndex` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentIndex` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentIndex { @Attribute(.unique) var id: Data @@ -20,23 +20,6 @@ extension DashSchemaV4 { var nullSearchable: Bool var contested: Bool - var countable: String? - var rangeCountable: Bool = false - var summable: String? - var rangeSummable: Bool = false - var averageable: String? - var rangeAverageable: Bool = false - - var rankedCountable: Bool = false - var rankedSummable: Bool = false - var rankedAverageable: Bool = false - - var terminal: String? - - var preallocated: Bool = false - - var timeRangeJSON: Data? - var propertiesJSON: Data var contestedDetailsJSON: Data? @@ -69,7 +52,7 @@ extension DashSchemaV4 { } } -extension DashSchemaV4.PersistentIndex { +extension DashSchemaV2.PersistentIndex { var properties: [String]? { try? JSONSerialization.jsonObject(with: propertiesJSON, options: []) as? [String] } @@ -78,9 +61,4 @@ extension DashSchemaV4.PersistentIndex { guard let data = contestedDetailsJSON else { return nil } return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] } - - var timeRange: [String: Any]? { - guard let data = timeRangeJSON else { return nil } - return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] - } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentInvitation.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentInvitation.swift similarity index 89% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentInvitation.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentInvitation.swift index 71cc5bb3f33..29b102f4451 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentInvitation.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentInvitation.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentInvitation` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentInvitation` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentInvitation { #Index([\.walletId]) @@ -64,9 +64,9 @@ extension DashSchemaV4 { } } -extension DashSchemaV4.PersistentInvitation { - static func predicate(walletId: Data) -> Predicate { - #Predicate { entry in +extension DashSchemaV2.PersistentInvitation { + static func predicate(walletId: Data) -> Predicate { + #Predicate { entry in entry.walletId == walletId } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentKeyword.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentKeyword.swift similarity index 77% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentKeyword.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentKeyword.swift index b42f37707f7..36f5a3a3d4f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentKeyword.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentKeyword.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentKeyword` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentKeyword` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentKeyword { @Attribute(.unique) var id: String @@ -25,15 +25,15 @@ extension DashSchemaV4 { } } -extension DashSchemaV4.PersistentKeyword { - static func predicate(keyword: String) -> Predicate { - #Predicate { item in +extension DashSchemaV2.PersistentKeyword { + static func predicate(keyword: String) -> Predicate { + #Predicate { item in item.keyword.localizedStandardContains(keyword) } } - static func predicate(contractId: String) -> Predicate { - #Predicate { item in + static func predicate(contractId: String) -> Predicate { + #Predicate { item in item.contractId == contractId } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentMasternode.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentMasternode.swift similarity index 98% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentMasternode.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentMasternode.swift index bd028d65783..d864f91e480 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentMasternode.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentMasternode.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentMasternode` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentMasternode` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentMasternode { #Unique([\.walletId, \.proTxHash]) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPendingInput.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentPendingInput.swift similarity index 85% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPendingInput.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentPendingInput.swift index 725f19910fd..366c766ba99 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPendingInput.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentPendingInput.swift @@ -1,17 +1,17 @@ import Foundation import SwiftData -// `PersistentPendingInput` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentPendingInput` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentPendingInput { - #Index([\.outpoint], [\.walletId], [\.walletId, \.isSweptTombstone]) + #Index([\.outpoint], [\.walletId]) var outpoint: Data var inputIndex: UInt32 @@ -24,10 +24,6 @@ extension DashSchemaV4 { var createdAt: Date - var isSweptTombstone: Bool = false - - var winnerMinedHeight: UInt32? - init( outpoint: Data, inputIndex: UInt32, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPlatformAddress.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentPlatformAddress.swift similarity index 85% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPlatformAddress.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentPlatformAddress.swift index 90998081e7b..510abb2232d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPlatformAddress.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentPlatformAddress.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentPlatformAddress` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentPlatformAddress` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentPlatformAddress { #Index([\.walletId]) @@ -63,15 +63,15 @@ extension DashSchemaV4 { } } -extension DashSchemaV4.PersistentPlatformAddress { - static func predicate(walletId: Data) -> Predicate { - #Predicate { entry in +extension DashSchemaV2.PersistentPlatformAddress { + static func predicate(walletId: Data) -> Predicate { + #Predicate { entry in entry.walletId == walletId } } - static var nonZeroBalancesPredicate: Predicate { - #Predicate { entry in + static var nonZeroBalancesPredicate: Predicate { + #Predicate { entry in entry.balance > 0 } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPlatformAddressesSyncState.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentPlatformAddressesSyncState.swift similarity index 95% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPlatformAddressesSyncState.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentPlatformAddressesSyncState.swift index fbc04ff24cf..6d728296fc8 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPlatformAddressesSyncState.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentPlatformAddressesSyncState.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentPlatformAddressesSyncState` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentPlatformAddressesSyncState` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentPlatformAddressesSyncState { @Attribute(.unique) var walletId: Data diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentProperty.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentProperty.swift similarity index 94% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentProperty.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentProperty.swift index 233efafe6bd..a86f3487eb4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentProperty.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentProperty.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentProperty` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentProperty` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentProperty { @Attribute(.unique) var id: Data diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPublicKey.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentPublicKey.swift similarity index 95% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPublicKey.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentPublicKey.swift index 68c9d28cb32..3abc50b89c9 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPublicKey.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentPublicKey.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentPublicKey` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentPublicKey` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentPublicKey { var keyId: Int32 @@ -110,7 +110,7 @@ extension DashSchemaV4 { } } -extension DashSchemaV4.PersistentPublicKey { +extension DashSchemaV2.PersistentPublicKey { func toIdentityPublicKey() -> IdentityPublicKey? { guard let purpose = purposeEnum, let securityLevel = securityLevelEnum, @@ -141,7 +141,7 @@ extension DashSchemaV4.PersistentPublicKey { ) } - static func from(_ publicKey: IdentityPublicKey, identityId: String) -> DashSchemaV4.PersistentPublicKey? { + static func from(_ publicKey: IdentityPublicKey, identityId: String) -> DashSchemaV2.PersistentPublicKey? { let boundsIds: [Data]? let docTypeName: String? switch publicKey.contractBounds { @@ -155,7 +155,7 @@ extension DashSchemaV4.PersistentPublicKey { boundsIds = nil docTypeName = nil } - return DashSchemaV4.PersistentPublicKey( + return DashSchemaV2.PersistentPublicKey( keyId: Int32(publicKey.id), purpose: publicKey.purpose, securityLevel: publicKey.securityLevel, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedActivity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentShieldedActivity.swift similarity index 96% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedActivity.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentShieldedActivity.swift index e916eb9174a..b2b240d78f5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedActivity.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentShieldedActivity.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentShieldedActivity` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentShieldedActivity` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentShieldedActivity { #Unique([\.walletId, \.accountIndex, \.entryId]) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedNote.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentShieldedNote.swift similarity index 83% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedNote.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentShieldedNote.swift index 2646b312466..28dd289fdc1 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedNote.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentShieldedNote.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentShieldedNote` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentShieldedNote` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentShieldedNote { #Index([\.walletId, \.accountIndex]) @@ -53,14 +53,14 @@ extension DashSchemaV4 { } } -extension DashSchemaV4.PersistentShieldedNote { - static func unspentPredicate(walletId: Data) -> Predicate { - #Predicate { +extension DashSchemaV2.PersistentShieldedNote { + static func unspentPredicate(walletId: Data) -> Predicate { + #Predicate { $0.walletId == walletId && $0.isSpent == false } } - static var unspentPredicate: Predicate { - #Predicate { $0.isSpent == false } + static var unspentPredicate: Predicate { + #Predicate { $0.isSpent == false } } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedOutgoingNote.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentShieldedOutgoingNote.swift similarity index 95% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedOutgoingNote.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentShieldedOutgoingNote.swift index 5dd20e6b091..9cac644ff57 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedOutgoingNote.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentShieldedOutgoingNote.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentShieldedOutgoingNote` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentShieldedOutgoingNote` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentShieldedOutgoingNote { #Unique([\.walletId, \.accountIndex, \.cmx]) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedSyncState.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentShieldedSyncState.swift similarity index 91% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedSyncState.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentShieldedSyncState.swift index 6c520c0f965..d0f45d91b6b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedSyncState.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentShieldedSyncState.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentShieldedSyncState` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentShieldedSyncState` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentShieldedSyncState { #Unique([\.walletId, \.accountIndex]) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedViewingKey.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentShieldedViewingKey.swift similarity index 91% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedViewingKey.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentShieldedViewingKey.swift index a51d1e46f9f..60b59600ec1 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedViewingKey.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentShieldedViewingKey.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentShieldedViewingKey` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentShieldedViewingKey` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentShieldedViewingKey { #Unique([\.walletId, \.accountIndex]) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentToken.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentToken.swift similarity index 88% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentToken.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentToken.swift index f06de9f8f3b..0eb0284547f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentToken.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentToken.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentToken` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentToken` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentToken { @Attribute(.unique) var id: Data @@ -96,7 +96,7 @@ extension DashSchemaV4 { } } -extension DashSchemaV4.PersistentToken { +extension DashSchemaV2.PersistentToken { var displayName: String { if let desc = tokenDescription, !desc.isEmpty { return desc @@ -246,12 +246,12 @@ extension DashSchemaV4.PersistentToken { } } -extension DashSchemaV4.PersistentToken { +extension DashSchemaV2.PersistentToken { func setLocalization(languageCode: String, singularForm: String, pluralForm: String, description: String? = nil) { if localizations == nil { localizations = [:] } - localizations?[languageCode] = DashSchemaV4.TokenLocalization( + localizations?[languageCode] = DashSchemaV2.TokenLocalization( singularForm: singularForm, pluralForm: pluralForm, description: description @@ -268,8 +268,8 @@ extension DashSchemaV4.PersistentToken { } } -extension DashSchemaV4.PersistentToken { - func getChangeControlRules(for type: ChangeControlRuleType) -> DashSchemaV4.ChangeControlRules? { +extension DashSchemaV2.PersistentToken { + func getChangeControlRules(for type: ChangeControlRuleType) -> DashSchemaV2.ChangeControlRules? { switch type { case .conventions: return conventionsChangeRules case .maxSupply: return maxSupplyChangeRules @@ -283,7 +283,7 @@ extension DashSchemaV4.PersistentToken { } } - func setChangeControlRules(_ rules: DashSchemaV4.ChangeControlRules, for type: ChangeControlRuleType) { + func setChangeControlRules(_ rules: DashSchemaV2.ChangeControlRules, for type: ChangeControlRuleType) { switch type { case .conventions: conventionsChangeRules = rules case .maxSupply: maxSupplyChangeRules = rules @@ -300,75 +300,75 @@ extension DashSchemaV4.PersistentToken { } } -extension DashSchemaV4.PersistentToken { - static func mintableTokensPredicate() -> Predicate { - #Predicate { token in +extension DashSchemaV2.PersistentToken { + static func mintableTokensPredicate() -> Predicate { + #Predicate { token in token.manualMintingRules != nil } } - static func burnableTokensPredicate() -> Predicate { - #Predicate { token in + static func burnableTokensPredicate() -> Predicate { + #Predicate { token in token.manualBurningRules != nil } } - static func freezableTokensPredicate() -> Predicate { - #Predicate { token in + static func freezableTokensPredicate() -> Predicate { + #Predicate { token in token.freezeRules != nil } } - static func distributionTokensPredicate() -> Predicate { - #Predicate { token in + static func distributionTokensPredicate() -> Predicate { + #Predicate { token in token.perpetualDistribution != nil || token.preProgrammedDistribution != nil } } - static func pausedTokensPredicate() -> Predicate { - #Predicate { token in + static func pausedTokensPredicate() -> Predicate { + #Predicate { token in token.isPaused == true } } - static func tokensByContractPredicate(contractId: Data) -> Predicate { - #Predicate { token in + static func tokensByContractPredicate(contractId: Data) -> Predicate { + #Predicate { token in token.contractId == contractId } } - static func tokensWithControlRulePredicate(rule: ControlRuleType) -> Predicate { + static func tokensWithControlRulePredicate(rule: ControlRuleType) -> Predicate { switch rule { case .manualMinting: - return #Predicate { token in + return #Predicate { token in token.manualMintingRules != nil } case .manualBurning: - return #Predicate { token in + return #Predicate { token in token.manualBurningRules != nil } case .freeze: - return #Predicate { token in + return #Predicate { token in token.freezeRules != nil } case .unfreeze: - return #Predicate { token in + return #Predicate { token in token.unfreezeRules != nil } case .destroyFrozenFunds: - return #Predicate { token in + return #Predicate { token in token.destroyFrozenFundsRules != nil } case .emergencyAction: - return #Predicate { token in + return #Predicate { token in token.emergencyActionRules != nil } case .conventions: - return #Predicate { token in + return #Predicate { token in token.conventionsChangeRules != nil } case .maxSupply: - return #Predicate { token in + return #Predicate { token in token.maxSupplyChangeRules != nil } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTokenBalance.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentTokenBalance.swift similarity index 86% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTokenBalance.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentTokenBalance.swift index 31d7c67c1bc..6ae2d707b74 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTokenBalance.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentTokenBalance.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentTokenBalance` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentTokenBalance` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentTokenBalance { #Index([\.networkRaw]) @@ -153,45 +153,45 @@ extension DashSchemaV4 { } } -extension DashSchemaV4.PersistentTokenBalance { +extension DashSchemaV2.PersistentTokenBalance { func toTokenBalance() -> (tokenId: String, balance: UInt64, frozen: Bool) { return (tokenId: tokenId, balance: unsignedBalance, frozen: frozen) } } -extension DashSchemaV4.PersistentTokenBalance { - static func predicate(tokenId: String, identityId: Data) -> Predicate { - #Predicate { balance in +extension DashSchemaV2.PersistentTokenBalance { + static func predicate(tokenId: String, identityId: Data) -> Predicate { + #Predicate { balance in balance.tokenId == tokenId && balance.identityId == identityId } } - static func predicate(identityId: Data) -> Predicate { - #Predicate { balance in + static func predicate(identityId: Data) -> Predicate { + #Predicate { balance in balance.identityId == identityId } } - static func predicate(tokenId: String) -> Predicate { - #Predicate { balance in + static func predicate(tokenId: String) -> Predicate { + #Predicate { balance in balance.tokenId == tokenId } } - static var nonZeroBalancesPredicate: Predicate { - #Predicate { balance in + static var nonZeroBalancesPredicate: Predicate { + #Predicate { balance in balance.balance != 0 } } - static var frozenBalancesPredicate: Predicate { - #Predicate { balance in + static var frozenBalancesPredicate: Predicate { + #Predicate { balance in balance.frozen == true } } - static func needsSyncPredicate(olderThan date: Date) -> Predicate { - #Predicate { balance in + static func needsSyncPredicate(olderThan date: Date) -> Predicate { + #Predicate { balance in balance.lastSyncedAt == nil || balance.lastSyncedAt! < date } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTokenHistoryEvent.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentTokenHistoryEvent.swift similarity index 97% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTokenHistoryEvent.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentTokenHistoryEvent.swift index 5972942e7fb..6b0054aea43 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTokenHistoryEvent.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentTokenHistoryEvent.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentTokenHistoryEvent` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentTokenHistoryEvent` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentTokenHistoryEvent { @Attribute(.unique) var id: UUID diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentTrackedMasternode.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentTrackedMasternode.swift index b4c09ccc3c1..44ab9a23870 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentTrackedMasternode.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentTrackedMasternode.swift @@ -2,7 +2,7 @@ import Foundation import SwiftData // `PersistentTrackedMasternode` exactly as schema DashSchemaV2 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentTransaction.swift similarity index 98% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTransaction.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentTransaction.swift index 9550581b615..6610ec87d63 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTransaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentTransaction.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentTransaction` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentTransaction` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentTransaction { #Index([\.firstSeen]) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTxo.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentTxo.swift similarity index 95% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTxo.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentTxo.swift index 548d513f9b2..1f18b7b8f07 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTxo.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentTxo.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentTxo` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentTxo` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentTxo { #Index([\.walletId]) @@ -33,8 +33,6 @@ extension DashSchemaV4 { var spendingTransaction: PersistentTransaction? - var supersededByTxid: Data? - var spendingInputIndex: UInt32? = nil var account: PersistentAccount? diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentWallet.swift similarity index 86% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentWallet.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentWallet.swift index 8301f4bc246..d688cd576eb 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentWallet.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentWallet` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentWallet` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentWallet { #Index([\.networkRaw], [\.walletGroupId]) @@ -31,7 +31,6 @@ extension DashSchemaV4 { var syncedHeight: UInt32 var lastSynced: UInt64 var lastAppliedChainLockBytes: Data? - var lastAppliedChainLockHeight: UInt32? var isImported: Bool = false var seedBindingVerifiedMarker: String? var createdAt: Date @@ -70,7 +69,7 @@ extension DashSchemaV4 { } } -extension DashSchemaV4.PersistentWallet { +extension DashSchemaV2.PersistentWallet { var label: String { if let name = name, !name.isEmpty { return name @@ -82,14 +81,14 @@ extension DashSchemaV4.PersistentWallet { } } -extension DashSchemaV4.PersistentWallet { - static func predicate(walletId: Data) -> Predicate { - #Predicate { $0.walletId == walletId } +extension DashSchemaV2.PersistentWallet { + static func predicate(walletId: Data) -> Predicate { + #Predicate { $0.walletId == walletId } } static func predicate( walletGroupId: Data - ) -> Predicate { - #Predicate { $0.walletGroupId == walletGroupId } + ) -> Predicate { + #Predicate { $0.walletGroupId == walletGroupId } } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentWalletManagerMetadata.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentWalletManagerMetadata.swift similarity index 94% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentWalletManagerMetadata.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentWalletManagerMetadata.swift index 66eeeabc41f..697c6753283 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentWalletManagerMetadata.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentWalletManagerMetadata.swift @@ -1,14 +1,14 @@ import Foundation import SwiftData -// `PersistentWalletManagerMetadata` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// `PersistentWalletManagerMetadata` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 52e8d4ec68. // Do not edit: every stored property, its optionality and default, and // every @Attribute / @Relationship / #Unique here is an input to that // version's checksum, and every #Index to the store's SQLite indexes; // changing any of them re-breaks the stores this copy exists to keep // openable. See the live model for what each column means. -extension DashSchemaV4 { +extension DashSchemaV2 { @Model final class PersistentWalletManagerMetadata { @Attribute(.unique) var networkRaw: UInt32 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+TokenTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+TokenTypes.swift similarity index 97% rename from packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+TokenTypes.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+TokenTypes.swift index 7e6f1bb0c13..ed1fe3238d3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+TokenTypes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+TokenTypes.swift @@ -1,12 +1,12 @@ import Foundation import SwiftData -// Inline value types exactly as schema DashSchemaV4 stored them, generated +// Inline value types exactly as schema DashSchemaV2 stored them, generated // by scripts/freeze_schema_models.py from TokenTypes.swift -// at commit 787cac09e7. SwiftData expands a stored Codable struct into composite +// at commit 52e8d4ec68. SwiftData expands a stored Codable struct into composite // attributes of the owning entity, so these shapes are inputs to that // version's checksum just like the model's own properties. Do not edit. -extension DashSchemaV4 { +extension DashSchemaV2 { struct ChangeControlRules: Codable, Equatable, Sendable { var authorizedToMakeChange: String var adminActionTakers: String diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentAssetLock.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentAssetLock.swift deleted file mode 100644 index f693fa47678..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentAssetLock.swift +++ /dev/null @@ -1,102 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentAssetLock` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// Do not edit: every stored property, its optionality and default, and -// every @Attribute / @Relationship / #Unique here is an input to that -// version's checksum, and every #Index to the store's SQLite indexes; -// changing any of them re-breaks the stores this copy exists to keep -// openable. See the live model for what each column means. -extension DashSchemaV4 { - @Model - final class PersistentAssetLock { - #Index([\.walletId]) - - @Attribute(.unique) var outPointHex: String - - var walletId: Data - - var transactionBytes: Data - - var fundingTypeRaw: Int - - var identityIndexRaw: Int32 - - var accountIndexRaw: Int32 = 0 - - var amountDuffs: Int64 - - var statusRaw: Int - - var proofBytes: Data? - - var recipientPlatformAddressHash: Data? - - var recipientPlatformAddressType: UInt8? - - var recipientIsExternal: Bool? - - var createdAt: Date - var updatedAt: Date - - init( - outPointHex: String, - walletId: Data, - transactionBytes: Data, - fundingTypeRaw: Int, - identityIndexRaw: Int32, - accountIndexRaw: Int32 = 0, - amountDuffs: Int64, - statusRaw: Int, - proofBytes: Data? = nil - ) { - self.outPointHex = outPointHex - self.walletId = walletId - self.transactionBytes = transactionBytes - self.fundingTypeRaw = fundingTypeRaw - self.identityIndexRaw = identityIndexRaw - self.accountIndexRaw = accountIndexRaw - self.amountDuffs = amountDuffs - self.statusRaw = statusRaw - self.proofBytes = proofBytes - self.createdAt = Date() - self.updatedAt = Date() - } - } -} - -extension DashSchemaV4.PersistentAssetLock { - static func predicate(walletId: Data) -> Predicate { - #Predicate { entry in - entry.walletId == walletId - } - } - - static func predicate( - walletId: Data, - identityIndex: UInt32 - ) -> Predicate { - let identityIndexRaw = Int32(bitPattern: identityIndex) - return #Predicate { entry in - entry.walletId == walletId && entry.identityIndexRaw == identityIndexRaw - } - } -} - -extension DashSchemaV4.PersistentAssetLock { - static func encodeOutPoint(rawBytes: Data) -> String { - precondition(rawBytes.count == 36, "outpoint must be 36 bytes") - let txid = rawBytes.prefix(32) - let voutBytes = rawBytes.suffix(4) - let vout = voutBytes.withUnsafeBytes { raw -> UInt32 in - var value: UInt32 = 0 - withUnsafeMutableBytes(of: &value) { dst in - dst.copyBytes(from: raw.prefix(MemoryLayout.size)) - } - return UInt32(littleEndian: value) - } - let txidHex = txid.reversed().map { String(format: "%02x", $0) }.joined() - return "\(txidHex):\(vout)" - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTrackedMasternode.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTrackedMasternode.swift deleted file mode 100644 index 4a2e857dbd9..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTrackedMasternode.swift +++ /dev/null @@ -1,42 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentTrackedMasternode` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// Do not edit: every stored property, its optionality and default, and -// every @Attribute / @Relationship / #Unique here is an input to that -// version's checksum, and every #Index to the store's SQLite indexes; -// changing any of them re-breaks the stores this copy exists to keep -// openable. See the live model for what each column means. -extension DashSchemaV4 { - @Model - final class PersistentTrackedMasternode { - #Unique([\.networkRaw, \.proTxHash]) - #Index([\.networkRaw]) - - var networkRaw: UInt32 - var proTxHash: Data - var label: String? - var addedAt: UInt64 - var snapshotJSON: String - - var network: Network? { - get { Network(rawValue: networkRaw) } - set { networkRaw = newValue?.rawValue ?? networkRaw } - } - - init( - networkRaw: UInt32, - proTxHash: Data, - label: String?, - addedAt: UInt64, - snapshotJSON: String - ) { - self.networkRaw = networkRaw - self.proTxHash = proTxHash - self.label = label - self.addedAt = addedAt - self.snapshotJSON = snapshotJSON - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift index 676d7ebf599..19b5f55d181 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift @@ -194,16 +194,10 @@ public final class PersistentAssetLock { /// mutates the checksum of every registered schema version that /// references it. The model LIST being unchanged is irrelevant. /// - /// Left unaddressed, a store written by the V2 binary would match no - /// schema in `DashMigrationPlan.schemas` and - /// `ModelContainer(for:migrationPlan:configurations:)` would fail to - /// open it with Cocoa error 134504 ("Cannot use staged migration with - /// an unknown model version"). So V1 and V2 now reference a frozen - /// copy of this model (`DashSchemaV1.PersistentAssetLock`, generated - /// under `FrozenSchemas/`), this property is what schema - /// `DashSchemaV3` adds, and a lightweight V2 -> V3 stage carries - /// existing stores across. Do the same for the next property added - /// here. + /// V1 retains its frozen copy without this property. The collapsed + /// V2 adds the column along with the other unreleased model changes; + /// migration backfills NULL. After publication, freeze its exact graph + /// before introducing a subsequent version for further shape changes. public var recipientIsExternal: Bool? /// Record timestamps. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentityBalanceMetadata.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentityBalanceMetadata.swift new file mode 100644 index 00000000000..db0d21b4886 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentityBalanceMetadata.swift @@ -0,0 +1,26 @@ +import Foundation +import SwiftData + +/// Freshness of the identity balance, committed atomically with its identity row. +/// A separate entity preserves every released identity model's schema hash. +@Model +public final class PersistentIdentityBalanceMetadata { + #Unique([\.networkRaw, \.walletId, \.identityId]) + public var networkRaw: UInt32 + public var walletId: Data + public var identityId: Data + // Bit patterns preserve the full unsigned FFI domain in SQLite integers. + public var platformHeight: Int64 + public var coreHeight: UInt32 + public var timestampMillis: Int64 + + public init(networkRaw: UInt32, walletId: Data, identityId: Data, + platformHeight: UInt64, coreHeight: UInt32, timestampMillis: UInt64) { + self.networkRaw = networkRaw + self.walletId = walletId + self.identityId = identityId + self.platformHeight = Int64(bitPattern: platformHeight) + self.coreHeight = coreHeight + self.timestampMillis = Int64(bitPattern: timestampMillis) + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift index a2f85fc8b23..550781f65ee 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift @@ -62,7 +62,8 @@ public final class PersistentPublicKey { /// those fall back to the legacy inference, which never yields 3 /// because no writer could produce a group bound back then. See /// `effectiveContractBoundsKind`. Additive optional column, so - /// SwiftData's lightweight migration backfills `NULL` (schema V5). + /// SwiftData's lightweight migration backfills `NULL` when migrating + /// the accepted V1 baseline to V2. public var contractBoundsKind: Int? // MARK: - Private Key Reference (optional) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentToken.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentToken.swift index ef696034fdc..f0649429e26 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentToken.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentToken.swift @@ -191,11 +191,10 @@ extension PersistentToken { /// contract's `serializedContract` already holds the whole contract JSON, /// distribution rules included, so the value is persisted with every /// contract the parser writes, and a new stored property here would move - /// this model's entity hash. That costs a schema version and a fixture - /// store (see `DashModelContainer.modelTypes` and - /// `DashModelMigrationTests`), which a display-only amount does not - /// justify. `perpetualDistribution` and `preProgrammedDistribution` - /// predate that discipline and kept their columns. + /// this model's entity hash. Keeping this value derived avoids a redundant + /// column and preserves compatibility with released snapshots. + /// `perpetualDistribution` and `preProgrammedDistribution` keep their + /// existing columns. /// /// Nil both when the token declares no such distribution and when the /// contract JSON cannot be read: a token row whose `dataContract` @@ -210,7 +209,9 @@ extension PersistentToken { public var oncePerIdentityDistribution: TokenOncePerIdentityDistribution? { guard let contract = dataContract else { return nil } return TokenOncePerIdentityDistributionCache.shared.distribution( - for: contract, + contractId: contract.id, + serializedContract: contract.serializedContract, + lastUpdated: contract.lastUpdated, position: position ) } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/TokenOncePerIdentityDistributionCache.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/TokenOncePerIdentityDistributionCache.swift index 004afdd1e90..7a894966fc8 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/TokenOncePerIdentityDistributionCache.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/TokenOncePerIdentityDistributionCache.swift @@ -13,10 +13,9 @@ import Foundation /// would otherwise decode a full contract to learn nothing. This decodes once /// per distinct contract payload instead and answers the rest from memory. /// -/// A stored property on the model would have been the obvious place to keep -/// the answer, but `DashSchemaV5` is frozen: a new stored property moves -/// `PersistentToken`'s entity hash and costs a schema version. A process-wide -/// memo sidesteps the schema entirely. +/// The contract JSON already persists the answer. A process-wide memo +/// avoids adding a redundant column to the model graph or changing any +/// released schema. Stored values also let frozen model copies use this cache. /// /// Thread-safe: SwiftData rows are read from whichever actor owns their /// context, so the map is guarded by a lock rather than pinned to the main @@ -71,17 +70,18 @@ final class TokenOncePerIdentityDistributionCache: @unchecked Sendable { } /// The once-per-identity distribution declared by the token at - /// `position` of `contract`, or nil when it declares none (or when the + /// `position` of a contract payload, or nil when it declares none (or when the /// contract's JSON cannot be read at all). func distribution( - for contract: PersistentDataContract, + contractId: Data, + serializedContract: Data, + lastUpdated: Date, position: Int ) -> TokenOncePerIdentityDistribution? { - let serialized = contract.serializedContract let key = Key( - contractId: contract.id, - byteCount: serialized.count, - lastUpdated: contract.lastUpdated + contractId: contractId, + byteCount: serializedContract.count, + lastUpdated: lastUpdated ) return lock.withLock { @@ -91,7 +91,7 @@ final class TokenOncePerIdentityDistributionCache: @unchecked Sendable { // The decode runs under the lock so two callers racing on a cold // contract decode it once between them rather than twice each. - let parsed = Self.parseAllPositions(serialized) + let parsed = Self.parseAllPositions(serializedContract) decodes += 1 entries[key] = parsed insertionOrder.append(key) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedIdentity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedIdentity.swift index 85b115aaec0..39b9feb3e0e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedIdentity.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedIdentity.swift @@ -224,7 +224,12 @@ public final class ManagedIdentity: @unchecked Sendable { return blockTime } - /// Set the last updated balance block time + /// Set the block time on this detached identity snapshot only. + /// + /// This does not change or persist the live wallet's verified balance watermark. + /// Use `ManagedPlatformWallet.refreshIdentityBalance(identityId:)`, then obtain + /// a new managed identity snapshot to read the authoritative balance and time. + /// This method cannot reset a live wallet's watermark. public func setLastUpdatedBalanceBlockTime(_ blockTime: BlockTime) throws { var bt = blockTime try managed_identity_set_last_updated_balance_block_time(handle, &bt).check() diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 84dae9131a2..917519ee3cd 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -27,6 +27,28 @@ public final class ManagedPlatformWallet: @unchecked Sendable { _ = platform_wallet_destroy(handle) } + /// Read a managed identity's credit balance from Platform and persist it. + /// This read-only operation requires no signer or wallet unlock. + public func refreshIdentityBalance(identityId: Identifier) async throws -> UInt64 { + guard identityId.count == 32 else { + throw PlatformWalletError.invalidParameter("identityId must be 32 bytes") + } + return try await Task.detached(priority: .userInitiated) { [self] in + try withExtendedLifetime(self) { + var balance: UInt64 = 0 + let result = identityId.withUnsafeBytes { bytes in + platform_wallet_refresh_identity_balance( + handle, + bytes.bindMemory(to: UInt8.self).baseAddress!, + &balance + ) + } + try result.check() + return balance + } + }.value + } + // MARK: - Balance (lock-free) /// Wallet balance breakdown. These are atomic reads — no lock contention. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 23729b1aa92..b5551dae598 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -2574,6 +2574,9 @@ public class PlatformWalletManager: ObservableObject { ) } + // Snapshot cleanup must succeed before deleting keys or live rows. + try persistenceHandler.deleteCompletedMigrationSnapshots() + let identityIds = try persistenceHandler.identityIdsForWallet(walletId: walletId) // Wipe Keychain BEFORE the SwiftData identity deletion runs. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 304c197df47..d9ffdea6697 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -2144,14 +2144,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return try backgroundContext.fetch(descriptor).first } - /// Predicate matching the `PersistentWallet` row owned by THIS - /// handler. A handler is constructed per-network, so when - /// `self.network` is set we scope to `(walletId, networkRaw)` — - /// otherwise the mainnet handler would find and overwrite the - /// devnet row (and vice versa) now that the same `walletId` can - /// have one row per network. When `self.network` is `nil` (the - /// advanced `configure(sdkPointer:network:nil)` path) we fall - /// back to walletId-only matching to preserve that behaviour. + /// Match this handler's wallet and, when supplied, its network. + /// Wallet IDs are network-scoped and globally unique in the current model; + /// checking the network also rejects stale or mismatched rows. Legacy + /// `network: nil` handlers retain walletId-only matching. private func walletRecordPredicate(walletId: Data) -> Predicate { if let network = self.network { let networkRaw = network.rawValue @@ -3199,6 +3195,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // so `upsertUtxo` has them in hand (see `roundUtxoCreditVerdicts`). extensionCallbacks.on_persist_wallet_changeset_utxo_verdicts_fn = persistWalletChangesetUtxoVerdictsCallback + extensionCallbacks.on_persist_identity_balance_block_time_fn = persistIdentityBalanceBlockTimeCallback + extensionCallbacks.on_load_identity_balance_block_time_fn = loadIdentityBalanceBlockTimeCallback return extensionCallbacks } @@ -4915,6 +4913,55 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return (written, skipped, failed) } + // MARK: - Identity balance freshness (additive persistence extension) + + private func balanceMetadataDescriptor(walletId: Data, identityId: Data) + -> FetchDescriptor { + // Match legacy identity persistence when the wallet's network is unresolved. + let network = self.network ?? walletNetwork(walletId: walletId) ?? .testnet + let networkRaw = network.rawValue + return FetchDescriptor(predicate: #Predicate { + $0.networkRaw == networkRaw && $0.walletId == walletId && $0.identityId == identityId + }) + } + + func persistIdentityBalanceBlockTime(walletId: Data, identityId: Data, blockTime: BlockTime?) throws { + try onQueue { + guard inChangeset else { + throw PlatformWalletError.walletOperation("Balance metadata requires an identity changeset") + } + let descriptor = balanceMetadataDescriptor(walletId: walletId, identityId: identityId) + let existing = try backgroundContext.fetch(descriptor).first + guard let blockTime else { + if let existing { backgroundContext.delete(existing) } + return + } + if let existing { + existing.platformHeight = Int64(bitPattern: blockTime.height) + existing.coreHeight = blockTime.core_height + existing.timestampMillis = Int64(bitPattern: blockTime.timestamp) + } else { + // An unresolved legacy network can mislabel this sidecar as testnet; + // scoped deletion may retain it until an unscoped orphan purge. + let network = self.network ?? walletNetwork(walletId: walletId) ?? .testnet + backgroundContext.insert(PersistentIdentityBalanceMetadata( + networkRaw: network.rawValue, walletId: walletId, identityId: identityId, + platformHeight: blockTime.height, coreHeight: blockTime.core_height, + timestampMillis: blockTime.timestamp)) + } + // endChangeset performs the atomic save with the balance itself. + } + } + + func loadIdentityBalanceBlockTime(walletId: Data, identityId: Data) throws -> BlockTime? { + try onQueue { + let descriptor = balanceMetadataDescriptor(walletId: walletId, identityId: identityId) + guard let row = try backgroundContext.fetch(descriptor).first else { return nil } + return BlockTime(height: UInt64(bitPattern: row.platformHeight), core_height: row.coreHeight, + timestamp: UInt64(bitPattern: row.timestampMillis)) + } + } + // MARK: - Identity snapshot structs /// Swift-side snapshot of the Rust `IdentityEntryFFI` with C @@ -6261,7 +6308,24 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } - /// Wipe a wallet's SwiftData footprint. + /// Discard completed legacy-migration snapshots for this store before an + /// explicit wallet deletion, even when there are no live wallet rows left. + /// Full snapshots can contain multiple wallets; their removal leaves all + /// current live rows intact. Pending recovery and cleanup errors are fatal. + /// Call before removing associated keys so failures remain retryable. + public func deleteCompletedMigrationSnapshots() throws { + try onQueue { try deleteCompletedMigrationSnapshotsOnQueue() } + } + + private func deleteCompletedMigrationSnapshotsOnQueue() throws { + let urls = Set(modelContainer.configurations + .filter { !$0.isStoredInMemoryOnly }.map(\.url)) + for url in urls.sorted(by: { $0.path < $1.path }) { + try DashLegacySchemaBridge.deleteCompletedSnapshots(at: url) + } + } + + /// Wipe a wallet's SwiftData footprint, including completed store snapshots. public func deleteWalletData(walletId: Data) throws { SDKLogger.event( "persistence_wallet_delete_started", @@ -6270,11 +6334,37 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) try onQueue { do { + // Run before the first saved deletion, including retries where + // the wallet row is already absent. Stay on the handler queue. + try deleteCompletedMigrationSnapshotsOnQueue() let walletDescriptor = FetchDescriptor( predicate: walletRecordPredicate(walletId: walletId) ) let walletRow = try backgroundContext.fetch(walletDescriptor).first let walletNetwork = walletRow?.network + // Sidecars retain an explicit network key. Preserve other-network + // rows even when retrying after this handler's wallet row is gone. + let metadataNetwork = self.network ?? walletNetwork + let metadata: FetchDescriptor + if let raw = metadataNetwork?.rawValue { + metadata = FetchDescriptor(predicate: #Predicate { + $0.walletId == walletId && $0.networkRaw == raw + }) + } else { + metadata = FetchDescriptor(predicate: #Predicate { $0.walletId == walletId }) + } + // Without a network, only unclaimed sidecars are safe to purge. + // Do not infer ownership from an unrelated handler's network. + var claimedNetworks = Set() + if metadataNetwork == nil { + let owners = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId }) + claimedNetworks = Set(try backgroundContext.fetch(owners).compactMap(\.networkRaw)) + } + for row in try backgroundContext.fetch(metadata) + where !claimedNetworks.contains(row.networkRaw) { + backgroundContext.delete(row) + } if let walletRow = walletRow { // Wallet → identities is `.nullify`; this delete @@ -11450,3 +11540,34 @@ extension PlatformWalletPersistenceHandler { } } } + +private func persistIdentityBalanceBlockTimeCallback( + context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, + identityId: UnsafePointer?, blockTime: UnsafePointer? +) -> Int32 { + guard let context, let walletId, let identityId else { return -1 } + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + do { + try handler.persistIdentityBalanceBlockTime( + walletId: Data(bytes: walletId, count: 32), identityId: Data(bytes: identityId, count: 32), + blockTime: blockTime?.pointee) + return 0 + } catch { return -1 } +} + +private func loadIdentityBalanceBlockTimeCallback( + context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, identityId: UnsafePointer?, + outFound: UnsafeMutablePointer?, outBlockTime: UnsafeMutablePointer? +) -> Int32 { + guard let context, let walletId, let identityId, let outFound, let outBlockTime else { return -1 } + outFound.pointee = false + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + do { + if let stamp = try handler.loadIdentityBalanceBlockTime( + walletId: Data(bytes: walletId, count: 32), identityId: Data(bytes: identityId, count: 32)) { + outBlockTime.pointee = stamp + outFound.pointee = true + } + return 0 + } catch { return -1 } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 2827c6921b1..a68427794de 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -256,6 +256,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { case errorShieldedRecoveryCorrupted = 56 /// Recovery needs its account and compatible keys; damaged ciphertext can look the same. case errorShieldedRecoveryKeysRequired = 57 + /// Platform returned no balance. Retrying the read is safe; ownership is unchanged. + case errorIdentityBalanceUnavailable = 58 /// The named thing does not exist. Besides the handle/lookup failures this /// has always covered, BOTH deferred-send paths report the /// wallet-was-REMOVED case here. @@ -381,6 +383,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorShieldedRecoveryCorrupted case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHIELDED_RECOVERY_KEYS_REQUIRED: self = .errorShieldedRecoveryKeysRequired + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_IDENTITY_BALANCE_UNAVAILABLE: + self = .errorIdentityBalanceUnavailable case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -516,6 +520,8 @@ public enum PlatformWalletError: LocalizedError { case invalidNetwork(String) case walletOperation(String) case identityNotFound(String) + /// A managed identity has no balance in the Platform response; the read may be retried. + case identityBalanceUnavailable(String) case contactNotFound(String) case utf8Conversion(String) case serialization(String) @@ -752,7 +758,7 @@ public enum PlatformWalletError: LocalizedError { switch self { case .nullPointer(let m), .invalidHandle(let m), .invalidParameter(let m), .invalidIdentifier(let m), .invalidNetwork(let m), .walletOperation(let m), - .identityNotFound(let m), .contactNotFound(let m), .utf8Conversion(let m), + .identityNotFound(let m), .identityBalanceUnavailable(let m), .contactNotFound(let m), .utf8Conversion(let m), .serialization(let m), .deserialization(let m), .memoryAllocation(let m), .arithmeticOverflow(let m), .noSelectableInputs(let m), .coreInsufficientFunds(let m), @@ -859,6 +865,7 @@ public enum PlatformWalletError: LocalizedError { case .errorDeserialization: self = .deserialization(detail) case .errorWalletOperation: self = .walletOperation(detail) case .errorIdentityNotFound: self = .identityNotFound(detail) + case .errorIdentityBalanceUnavailable: self = .identityBalanceUnavailable(detail) case .errorContactNotFound: self = .contactNotFound(detail) case .errorInvalidNetwork: self = .invalidNetwork(detail) case .errorInvalidIdentifier: self = .invalidIdentifier(detail) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/OncePerIdentityClaimStore.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/OncePerIdentityClaimStore.swift index 29a2ab88705..f10a3c9e9c8 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/OncePerIdentityClaimStore.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/OncePerIdentityClaimStore.swift @@ -26,11 +26,10 @@ protocol OncePerIdentityClaimRecording: OncePerIdentityClaimReading { /// `UserDefaults`-backed record of once-per-identity claims this app saw /// succeed, plus the ones Drive told us had already happened. /// -/// Deliberately not SwiftData: `DashSchemaV5` is frozen, and a new stored -/// property or model would cost a schema version for what is a local hint, -/// not protocol state. The hint is one-way, set and never cleared, which is -/// safe because the fact it caches cannot become false again: a spent claim -/// stays spent, and identity ids are not reused. +/// Kept in UserDefaults to avoid changing the shared SwiftData model graph +/// for a local hint rather than protocol state. The hint is one-way, set and +/// never cleared. This is safe because the fact it caches cannot become false +/// again: a spent claim stays spent, and identity ids are not reused. /// /// It is a hint, not an authority. An identity that claimed on another /// device is absent from it, and that claim is still caught the expensive diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift index 64bd42c8867..a567917dedf 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift @@ -27,6 +27,13 @@ struct StorageExplorerView: View { modelRow("Identities", icon: "person.crop.circle", type: PersistentIdentity.self) { IdentityStorageListView(network: network) } + modelRow( + "Identity Balance Metadata", + icon: "clock.badge.checkmark", + type: PersistentIdentityBalanceMetadata.self + ) { + IdentityBalanceMetadataStorageListView(network: network) + } // Identity-relationship caches: cascade-owned by // `PersistentIdentity`, surfaced as their own explorer // sections so the row counts and per-row drill-downs @@ -277,6 +284,7 @@ struct StorageExplorerView: View { // Models with a direct `networkRaw` column — predicate-friendly, // no in-memory pass needed. directCount(PersistentIdentity.self, predicate: #Predicate { $0.networkRaw == raw }) + directCount(PersistentIdentityBalanceMetadata.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentDPNSName.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentDashpayProfile.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentDashpayContactRequest.self, predicate: #Predicate { $0.networkRaw == raw }) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift index 6a735390481..b4627318cf0 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift @@ -30,6 +30,39 @@ struct IdentityStorageListView: View { } } +// MARK: - PersistentIdentityBalanceMetadata + +struct IdentityBalanceMetadataStorageListView: View { + let network: Network + @Query private var records: [PersistentIdentityBalanceMetadata] + + private var filtered: [PersistentIdentityBalanceMetadata] { + records.filter { $0.networkRaw == network.rawValue }.sorted { + // Stored Int64 values carry unsigned bit patterns, so sort the decoded heights. + UInt64(bitPattern: $0.platformHeight) > UInt64(bitPattern: $1.platformHeight) + } + } + + var body: some View { + let visible = filtered + List(visible) { record in + NavigationLink(destination: IdentityBalanceMetadataStorageDetailView(record: record)) { + VStack(alignment: .leading, spacing: 4) { + Text(record.identityId.toHexString()) + .font(.body).lineLimit(1).truncationMode(.middle) + Text("Wallet: \(record.walletId.toHexString())") + .font(.caption).foregroundColor(.secondary) + .lineLimit(1).truncationMode(.middle) + Text("Platform height \(UInt64(bitPattern: record.platformHeight))") + .font(.caption).foregroundColor(.secondary) + } + } + } + .navigationTitle("Balance Metadata (\(visible.count))") + .overlay { if visible.isEmpty { ContentUnavailableView("No Records", systemImage: "clock.badge.checkmark") } } + } +} + // MARK: - PersistentDocument struct DocumentStorageListView: View { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index 5a1007a6391..6bbceba4243 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -63,6 +63,29 @@ private func jsonString(_ data: Data?) -> String? { return str } +// MARK: - PersistentIdentityBalanceMetadata + +struct IdentityBalanceMetadataStorageDetailView: View { + let record: PersistentIdentityBalanceMetadata + + var body: some View { + Form { + Section("Identity") { + FieldRow(label: "Network", value: Network(rawValue: record.networkRaw)?.displayName ?? "raw \(record.networkRaw)") + FieldRow(label: "Wallet ID", value: hexString(record.walletId)) + FieldRow(label: "Identity ID", value: hexString(record.identityId)) + } + Section("Balance Freshness") { + FieldRow(label: "Platform Height", value: String(UInt64(bitPattern: record.platformHeight))) + FieldRow(label: "Core Height", value: String(record.coreHeight)) + FieldRow(label: "Timestamp (ms)", value: String(UInt64(bitPattern: record.timestampMillis))) + } + } + .navigationTitle("Balance Metadata") + .navigationBarTitleDisplayMode(.inline) + } +} + // MARK: - PersistentIdentity struct IdentityStorageDetailView: View { diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift new file mode 100644 index 00000000000..4bdc2221cfa --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift @@ -0,0 +1,914 @@ +import CoreData +import Darwin +import Foundation +import SQLite3 +import SwiftData +import XCTest +@testable import SwiftDashSDK + +@Model +private final class BridgeFutureMarker { + var value: String = "future" + init() {} +} +private enum BridgeFutureV4: VersionedSchema { + static var versionIdentifier: Schema.Version { Schema.Version(4, 0, 0) } + static var models: [any PersistentModel.Type] { DashSchemaV3.models + [BridgeFutureMarker.self] } +} +private enum BridgeFuturePlan: SchemaMigrationPlan { + static var schemas: [any VersionedSchema.Type] { [DashSchemaV1.self, DashSchemaV3.self, BridgeFutureV4.self] } + static var stages: [MigrationStage] { + [.lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV3.self), + .custom(fromVersion: DashSchemaV3.self, toVersion: BridgeFutureV4.self, + willMigrate: { context in + for wallet in try context.fetch(FetchDescriptor()) { + wallet.name = "explicit future transformation" + } + try context.save() + }, didMigrate: nil)] + } +} + +// The candidate immediately before restoring historical V2 used today's +// complete graph with a V2 version label. This is one exact supported alias. +private enum PreviousLiveV2: VersionedSchema { + static var versionIdentifier: Schema.Version { Schema.Version(2, 0, 0) } + static var models: [any PersistentModel.Type] { DashSchemaV3.models } +} +private enum UnsupportedBetaV2: VersionedSchema { + static var versionIdentifier: Schema.Version { Schema.Version(2, 0, 0) } + static var models: [any PersistentModel.Type] { DashSchemaV3.models + [BridgeFutureMarker.self] } +} + +@MainActor +final class DashLegacySchemaMigrationTests: XCTestCase { + private enum Injected: Error { case stop } + + private func withStore(baseline: Bool = false, _ body: (URL) throws -> Void) throws { + let (directory, url) = try makeStore(baseline: baseline) + defer { try? FileManager.default.removeItem(at: directory) } + try autoreleasepool { try body(url) } + } + + private func makeStore(baseline: Bool = false) throws -> (URL, URL) { + let source = try XCTUnwrap(Bundle.module.url( + forResource: baseline ? "dash-v1" : "fixture", withExtension: "store", + subdirectory: baseline ? "Fixtures/SchemaStores" : "Fixtures/SchemaStores/legacy-fd8d8d13e5")) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("DashModel.store") + try FileManager.default.copyItem(at: source, to: url) + return (directory, url) + } + private func open(_ url: URL, hooks: DashLegacySchemaBridge.Hooks = .init()) throws -> ModelContainer { + let schema = DashModelContainer.schema + return try DashLegacySchemaBridge.open( + configuration: ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none), + schema: schema, plan: DashMigrationPlan.self, hooks: hooks) + } + private func operationDirectories(_ url: URL) throws -> [URL] { + let root = DashLegacySchemaBridge.backupDirectory(for: url) + guard FileManager.default.fileExists(atPath: root.path) else { return [] } + return try FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: [.isDirectoryKey]) + .filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true } + } + private func verifyRows(_ context: ModelContext, walletName: String = "historical audit wallet") throws { + XCTAssertEqual(try context.fetchCount(FetchDescriptor()), 1) + XCTAssertEqual(try context.fetchCount(FetchDescriptor()), 1) + XCTAssertEqual(try context.fetchCount(FetchDescriptor()), 1) + XCTAssertEqual(try context.fetchCount(FetchDescriptor()), 1) + let wallet = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + let contract = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + let type = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + let index = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + XCTAssertEqual(wallet.walletId, Data(repeating: 0x61, count: 32)) + XCTAssertEqual(wallet.name, walletName) + XCTAssertEqual(wallet.network, .testnet) + XCTAssertEqual(contract.id, Data(repeating: 0x62, count: 32)) + XCTAssertEqual(contract.serializedContract, Data("{}".utf8)) + XCTAssertEqual(type.dataContract?.persistentModelID, contract.persistentModelID) + XCTAssertEqual(index.documentType?.persistentModelID, type.persistentModelID) + XCTAssertEqual(contract.documentTypes?.count, 1) + XCTAssertEqual(type.indices?.count, 1) + XCTAssertEqual(index.properties, ["name"]) + XCTAssertFalse(type.indexOnly) + XCTAssertNil(index.countable) + XCTAssertNil(index.summable) + XCTAssertNil(index.averageable) + XCTAssertNil(index.terminal) + XCTAssertNil(index.timeRangeJSON) + XCTAssertEqual([index.rangeCountable, index.rangeSummable, index.rangeAverageable, + index.rankedCountable, index.rankedSummable, index.rankedAverageable, + index.preallocated], Array(repeating: false, count: 7)) + } + + func testAcceptedV1UsesNormalPlanWithoutBridge() throws { + try withStore(baseline: true) { url in + _ = try open(url, hooks: .init(visit: { _, _ in XCTFail("Known V1 must not bridge") })) + XCTAssertTrue(try operationDirectories(url).isEmpty) + XCTAssertFalse(FileManager.default.fileExists(atPath: url.path + ".legacy-v2.lock")) + } + _ = try DashModelContainer.createInMemory() + } + + func testOrdinaryStoresIgnoreBridgeLockButLegacyAndPendingRecoveryRequireIt() throws { + try withStore(baseline: true) { url in + let descriptor = Darwin.open(url.path + ".legacy-v2.lock", O_CREAT | O_RDWR, 0o600) + XCTAssertGreaterThanOrEqual(descriptor, 0) + defer { Darwin.close(descriptor) } + XCTAssertEqual(flock(descriptor, LOCK_EX | LOCK_NB), 0) + try autoreleasepool { _ = try open(url) } // Known V1. + let current = try open(url) // Current V3. + XCTAssertEqual(try current.mainContext.fetchCount(FetchDescriptor()), 1) + } + for interrupted in [false, true] { + try withStore { url in + if interrupted { + XCTAssertThrowsError(try open(url, hooks: .init(visit: { phase, _ in + if phase == .afterCommit { throw Injected.stop } + }))) + } + let descriptor = Darwin.open(url.path + ".legacy-v2.lock", O_CREAT | O_RDWR, 0o600) + XCTAssertGreaterThanOrEqual(descriptor, 0) + defer { Darwin.close(descriptor) } + XCTAssertEqual(flock(descriptor, LOCK_EX | LOCK_NB), 0) + XCTAssertThrowsError(try open(url)) + } + } + } + + func testMissingBridgeMetadataUsesOrdinaryOpeningWithoutCreatingBridgeFiles() throws { + try withStore(baseline: true) { url in + var metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(type: .sqlite, at: url) + metadata.removeValue(forKey: "NSStoreModelVersionChecksumKey") + try NSPersistentStoreCoordinator.setMetadata(metadata, type: .sqlite, at: url) + let container = try open(url, hooks: .init(visit: { _, _ in XCTFail("Must use the ordinary plan") })) + XCTAssertEqual(try container.mainContext.fetchCount(FetchDescriptor()), 1) + XCTAssertFalse(FileManager.default.fileExists(atPath: url.path + ".legacy-v2.lock")) + } + } + + func testDiskPreflightIncludesWALAndRejectsBeforeCopying() throws { + try withStore { url in + let writer = try DashLegacyStoreSQLite.Connection(url, writable: true) + try writer.execute("PRAGMA journal_mode=WAL; PRAGMA wal_autocheckpoint=0") + try writer.execute("UPDATE ZPERSISTENTWALLET SET ZNAME='committed WAL data'") + let main = try XCTUnwrap(FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber).int64Value + let wal = try XCTUnwrap(FileManager.default.attributesOfItem(atPath: url.path + "-wal")[.size] as? NSNumber).int64Value + XCTAssertGreaterThan(wal, 0) + let required = try DashLegacySchemaBridge.requiredFreeSpace(at: url) + XCTAssertEqual(required, 4 * (main + wal) + max(64 * 1024 * 1024, (main + wal) / 2)) + let original = try DashLegacyStoreSQLite.rawDigest(url) + XCTAssertThrowsError(try open(url, hooks: .init( + visit: { _, _ in XCTFail("Insufficient space must reject before snapshotting") }, + availableCapacity: { directory in + XCTAssertEqual(directory, url.deletingLastPathComponent()) + return required - 1 + }))) { error in + guard case DashLegacyStoreSQLite.Failure.insufficientDiskSpace(let needed, let available) = error else { + return XCTFail("Expected an actionable storage error, got \(error)") + } + XCTAssertEqual(needed, required) + XCTAssertEqual(available, required - 1) + XCTAssertTrue(error.localizedDescription.contains("Free device storage and retry")) + } + XCTAssertEqual(try DashLegacyStoreSQLite.rawDigest(url), original) + XCTAssertFalse(FileManager.default.fileExists(atPath: DashLegacySchemaBridge.backupDirectory(for: url).path)) + withExtendedLifetime(writer) {} + } + try withStore(baseline: true) { url in + _ = try open(url, hooks: .init(availableCapacity: { _ in + XCTFail("Registered stores must not require bridge copy headroom") + return 0 + })) + } + } + + func testCapacityResolverConfirmsUnavailableImportantUsageWithFilesystemFreeSpace() throws { + XCTAssertEqual(try DashLegacySchemaBridge.resolveAvailableCapacity( + importantUsage: { 1024 }, fileSystem: { XCTFail("Positive capacity needs no fallback"); return 0 }), 1024) + for reported: Int64? in [0, -1, nil] { + XCTAssertEqual(try DashLegacySchemaBridge.resolveAvailableCapacity( + importantUsage: { reported }, fileSystem: { 2048 }), 2048) + } + XCTAssertEqual(try DashLegacySchemaBridge.resolveAvailableCapacity( + importantUsage: { throw Injected.stop }, fileSystem: { 4096 }), 4096) + XCTAssertEqual(try DashLegacySchemaBridge.resolveAvailableCapacity( + importantUsage: { 0 }, fileSystem: { 0 }), 0, "A genuinely full volume remains full") + XCTAssertThrowsError(try DashLegacySchemaBridge.resolveAvailableCapacity( + importantUsage: { 0 }, fileSystem: { throw Injected.stop }), "Cannot invent capacity when both queries fail") + } + + func testSufficientDiskHeadroomPermitsMigration() throws { + try withStore { url in + let required = try DashLegacySchemaBridge.requiredFreeSpace(at: url) + let container = try open(url, hooks: .init(availableCapacity: { _ in required })) + try verifyRows(container.mainContext) + } + } + + func testCopyReportsNonContentionSQLiteFailureWithoutReplacingDestination() throws { + try withStore { destination in + let source = destination.deletingLastPathComponent().appendingPathComponent("invalid.store") + try Data(repeating: 0x61, count: 4096).write(to: source) + let original = try DashLegacyStoreSQLite.rawDigest(destination) + XCTAssertThrowsError(try DashLegacyStoreSQLite.copy(from: source, to: destination)) { error in + guard case DashLegacyStoreSQLite.Failure.sqlite(let operation, let code, let reason) = error else { + return XCTFail("Expected an actual SQLite status, got \(error)") + } + XCTAssertEqual(code & 0xff, SQLITE_NOTADB) + XCTAssertFalse(reason.lowercased().contains("busy")) + XCTAssertTrue(error.localizedDescription.contains("SQLite \(code)")) + XCTAssertFalse(operation.isEmpty) + } + XCTAssertEqual(try DashLegacyStoreSQLite.rawDigest(destination), original) + } + } + + func testAbandonedAttemptsAreRemovedBeforeDiskCapacityIsMeasured() throws { + try withStore { url in + let root = DashLegacySchemaBridge.backupDirectory(for: url) + let abandoned = root.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: abandoned, withIntermediateDirectories: true) + try Data(repeating: 0x61, count: 4096).write(to: abandoned.appendingPathComponent("original.store")) + try Data(repeating: 0x62, count: 4096).write(to: abandoned.appendingPathComponent("candidate.store")) + let unrelated = root.appendingPathComponent("operator-note.txt") + try Data("keep".utf8).write(to: unrelated) + let required = try DashLegacySchemaBridge.requiredFreeSpace(at: url) + let container = try open(url, hooks: .init(availableCapacity: { _ in + XCTAssertFalse(FileManager.default.fileExists(atPath: abandoned.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: unrelated.path)) + return required + })) + try verifyRows(container.mainContext) + XCTAssertEqual(try operationDirectories(url).count, 1, "Keep the new migration backup through this open") + } + try withStore { url in + let root = DashLegacySchemaBridge.backupDirectory(for: url) + let abandoned = root.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: abandoned, withIntermediateDirectories: true) + // A failed removal must not be counted as reclaimed free space. + try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: root.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path) } + XCTAssertThrowsError(try open(url, hooks: .init(availableCapacity: { _ in + XCTAssertTrue(FileManager.default.fileExists(atPath: abandoned.path)) + return 0 + }))) { error in + guard case DashLegacyStoreSQLite.Failure.insufficientDiskSpace(_, let available) = error else { + return XCTFail("Expected actual available capacity to decide admission: \(error)") + } + XCTAssertEqual(available, 0) + } + } + } + + func testOrdinaryOpenSkipsCleanupWhileAnotherOpenerOwnsTheLock() throws { + try withStore(baseline: true) { url in + try autoreleasepool { _ = try open(url) } + let root = DashLegacySchemaBridge.backupDirectory(for: url) + let active = root.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: active, withIntermediateDirectories: true) + let copy = active.appendingPathComponent("original.store") + try Data("active snapshot".utf8).write(to: copy) + let descriptor = Darwin.open(url.path + ".legacy-v2.lock", O_CREAT | O_RDWR, 0o600) + XCTAssertGreaterThanOrEqual(descriptor, 0) + defer { Darwin.close(descriptor) } + XCTAssertEqual(flock(descriptor, LOCK_EX | LOCK_NB), 0) + try autoreleasepool { + let container = try open(url) + XCTAssertEqual(try container.mainContext.fetchCount(FetchDescriptor()), 1) + } + XCTAssertEqual(try Data(contentsOf: copy), Data("active snapshot".utf8)) + XCTAssertEqual(flock(descriptor, LOCK_UN), 0) + _ = try open(url) + XCTAssertFalse(FileManager.default.fileExists(atPath: active.path), "A later unlocked successful open may reclaim it") + } + } + + func testPendingRecoveryProtectsAllAttemptsAndRetainsRecoveredBackup() throws { + try withStore { url in + XCTAssertThrowsError(try open(url, hooks: .init(visit: { phase, _ in + if phase == .afterCommit { throw Injected.stop } + }))) + let root = DashLegacySchemaBridge.backupDirectory(for: url) + let marker = root.appendingPathComponent("active.json") + let originalJournal = try Data(contentsOf: marker) + let abandoned = root.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: abandoned, withIntermediateDirectories: true) + let attempts = Set(try operationDirectories(url)) + try DashLegacyStoreSQLite.Connection(url, writable: true) + .execute("UPDATE ZPERSISTENTWALLET SET ZNAME='unverified change'") + XCTAssertThrowsError(try open(url)) + XCTAssertEqual(Set(try operationDirectories(url)), attempts) + XCTAssertEqual(try Data(contentsOf: marker), originalJournal) + try DashLegacyStoreSQLite.Connection(url, writable: true) + .execute("UPDATE ZPERSISTENTWALLET SET ZNAME='historical audit wallet'") + try autoreleasepool { + let recovered = try open(url) + try verifyRows(recovered.mainContext) + } + XCTAssertEqual(Set(try operationDirectories(url)), attempts, "The successful recovery open retains its backup") + let reopened = try open(url) + try verifyRows(reopened.mainContext) + XCTAssertTrue(try operationDirectories(url).isEmpty) + } + } + + func testJournalRemovalFailureDoesNotExposeAMigratedContainer() throws { + try withStore { url in + let root = DashLegacySchemaBridge.backupDirectory(for: url) + let marker = root.appendingPathComponent("active.json") + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path) } + XCTAssertThrowsError(try open(url, hooks: .init(visit: { phase, _ in + if phase == .afterCommit { + try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: root.path) + } + }))) + XCTAssertTrue(FileManager.default.fileExists(atPath: marker.path)) + XCTAssertEqual(try operationDirectories(url).count, 1) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path) + let recovered = try open(url) + try verifyRows(recovered.mainContext) + XCTAssertFalse(FileManager.default.fileExists(atPath: marker.path)) + } + } + + func testHistoricalStorePreservesDataDefaultsBackupAndDoesNotBridgeAgain() throws { + try withStore { url in + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + try verifyRows(container.mainContext) + let wallet = try XCTUnwrap(container.mainContext.fetch(FetchDescriptor()).first) + wallet.name = "saved after migration" + try container.mainContext.save() + } + let operations = try operationDirectories(url) + XCTAssertEqual(operations.count, 1) + let backup = try XCTUnwrap(operations.first).appendingPathComponent("original.store") + XCTAssertEqual(try DashSchemaFixtureSupport.describeStore(at: backup, version: Schema.Version(1, 0, 0)).model_checksum, + "wOm/tD2jkxoKsyP7GFXVNeebjqpLZbKZZ4EqYLkwlMk=") + let reopened = try open(url, hooks: .init(visit: { _, _ in XCTFail("Completed bridge must not repeat") })) + try verifyRows(reopened.mainContext, walletName: "saved after migration") + XCTAssertTrue(try operationDirectories(url).isEmpty, "A later successful open reclaims the retained backup") + } + } + + func testHistoricalBridgeAddsEmptyWatermarkStorageAndPreservesNewStampOnReopen() throws { + try withStore { url in + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + try verifyRows(container.mainContext) + XCTAssertEqual(try container.mainContext.fetchCount( + FetchDescriptor()), 0) + container.mainContext.insert(PersistentIdentityBalanceMetadata( + networkRaw: Network.testnet.rawValue, walletId: Data(repeating: 0x61, count: 32), + identityId: Data(repeating: 0x73, count: 32), platformHeight: 42, + coreHeight: 7, timestampMillis: 123)) + try container.mainContext.save() + } + let reopened = try open(url, hooks: .init(visit: { _, _ in + XCTFail("Persisting a watermark must not trigger another legacy migration") + })) + try verifyRows(reopened.mainContext) + let metadata = try XCTUnwrap(reopened.mainContext.fetch( + FetchDescriptor()).first) + XCTAssertEqual(metadata.walletId, Data(repeating: 0x61, count: 32)) + XCTAssertEqual(metadata.identityId, Data(repeating: 0x73, count: 32)) + XCTAssertEqual(metadata.platformHeight, 42) + XCTAssertEqual(metadata.coreHeight, 7) + XCTAssertEqual(metadata.timestampMillis, 123) + } + } + + func testWalletDeletionRemovesMigrationSnapshotsWithoutReopeningCachedContainer() throws { + try withStore { url in + let survivorId = Data(repeating: 0x63, count: 32) + // Seed a second wallet into the actual historical fixture before + // migrating. Both wallet rows must be present in the retained copy. + try autoreleasepool { + let connection = try DashLegacyStoreSQLite.Connection(url, writable: true) + var columns: [String] = [] + try connection.query("PRAGMA table_info(ZPERSISTENTWALLET)") { + columns.append(String(cString: sqlite3_column_text($0, 1))) + } + let names = columns.map { "\"\($0)\"" }.joined(separator: ",") + let values = columns.map { column in + switch column { + case "Z_PK": return "Z_PK + 1" + case "ZWALLETID": return "X'" + survivorId.map { String(format: "%02x", $0) }.joined() + "'" + case "ZNAME": return "'surviving wallet'" + default: return "\"\(column)\"" + } + }.joined(separator: ",") + try connection.execute("INSERT INTO ZPERSISTENTWALLET (\(names)) SELECT \(values) FROM ZPERSISTENTWALLET LIMIT 1") + try connection.execute("UPDATE Z_PRIMARYKEY SET Z_MAX=(SELECT MAX(Z_PK) FROM ZPERSISTENTWALLET) WHERE Z_NAME='PersistentWallet'") + } + let container = try DashModelContainer.create(url: url) + let snapshot = try XCTUnwrap(operationDirectories(url).first).appendingPathComponent("original.store") + var originalWalletCount: Int64 = 0 + try DashLegacyStoreSQLite.Connection(snapshot, writable: false).query("SELECT COUNT(*) FROM ZPERSISTENTWALLET") { + originalWalletCount = sqlite3_column_int64($0, 0) + } + XCTAssertEqual(originalWalletCount, 2) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + try handler.deleteWalletData(walletId: Data(repeating: 0x61, count: 32)) + XCTAssertTrue(try operationDirectories(url).isEmpty) + let rows = try ModelContext(container).fetch(FetchDescriptor()) + XCTAssertEqual(rows.map(\.walletId), [survivorId]) + XCTAssertEqual(rows.first?.name, "surviving wallet") + } + } + + func testDeleteAllClearsSnapshotsForEmptyInactiveStoreWithoutTouchingAnotherStore() throws { + try withStore { activeURL in + try withStore { inactiveURL in + let active = try DashModelContainer.create(url: activeURL) + let inactive = try DashModelContainer.create(url: inactiveURL) + let activeSnapshots = try operationDirectories(activeURL) + XCTAssertEqual(activeSnapshots.count, 1) + // Reproduce an earlier deletion path that removed rows while + // leaving a snapshot, without reopening the cached container. + let context = ModelContext(inactive) + for wallet in try context.fetch(FetchDescriptor()) { context.delete(wallet) } + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: inactive, network: .testnet) + XCTAssertTrue(handler.restorableWalletIds().isEmpty) + try handler.deleteCompletedMigrationSnapshots() + XCTAssertTrue(try operationDirectories(inactiveURL).isEmpty) + XCTAssertEqual(try operationDirectories(activeURL), activeSnapshots) + XCTAssertEqual(try ModelContext(active).fetchCount(FetchDescriptor()), 1) + XCTAssertEqual(try ModelContext(inactive).fetchCount(FetchDescriptor()), 0) + } + } + } + + func testSnapshotDeletionFailurePreservesLiveWalletAndCanBeRetried() throws { + try withStore { url in + let container = try DashModelContainer.create(url: url) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + let root = DashLegacySchemaBridge.backupDirectory(for: url) + let snapshots = try operationDirectories(url) + try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: root.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path) } + XCTAssertThrowsError(try handler.deleteWalletData(walletId: Data(repeating: 0x61, count: 32))) + XCTAssertEqual(try operationDirectories(url), snapshots) + XCTAssertEqual(try ModelContext(container).fetchCount(FetchDescriptor()), 1) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path) + try handler.deleteWalletData(walletId: Data(repeating: 0x61, count: 32)) + XCTAssertTrue(try operationDirectories(url).isEmpty) + XCTAssertEqual(try ModelContext(container).fetchCount(FetchDescriptor()), 0) + } + } + + func testSnapshotDeletionRefusesPendingRecoveryBeforeDeletingLiveRows() throws { + try withStore { url in + XCTAssertThrowsError(try open(url, hooks: .init(visit: { phase, _ in + if phase == .afterCommit { throw Injected.stop } + }))) + let root = DashLegacySchemaBridge.backupDirectory(for: url) + let marker = root.appendingPathComponent("active.json") + let evidence = try Data(contentsOf: marker) + let snapshots = try operationDirectories(url) + // Bypass factory recovery only to exercise the deletion boundary + // when a cached container and a pending recovery marker coexist. + let schema = DashModelContainer.schema + let container = try ModelContainer(for: schema, migrationPlan: DashMigrationPlan.self, + configurations: [ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none)]) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertThrowsError(try handler.deleteWalletData(walletId: Data(repeating: 0x61, count: 32))) + XCTAssertThrowsError(try handler.deleteCompletedMigrationSnapshots()) + XCTAssertEqual(try Data(contentsOf: marker), evidence) + XCTAssertEqual(try operationDirectories(url), snapshots) + XCTAssertEqual(try ModelContext(container).fetchCount(FetchDescriptor()), 1) + } + } + + func testCommittedWALDataIsIncludedAndConcurrentWriterIsLockedOutAtPromotion() throws { + try withStore { url in + let connection = try DashLegacyStoreSQLite.Connection(url, writable: true) + try connection.execute("PRAGMA journal_mode=WAL") + try connection.execute("PRAGMA wal_autocheckpoint=0") + try connection.execute("UPDATE ZPERSISTENTWALLET SET ZNAME='committed in WAL'") + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path + "-wal")) + var checkedLock = false + let migrated = try open(url, hooks: .init(visit: { phase, _ in + if phase == .writeLocked { + XCTAssertThrowsError(try connection.execute("UPDATE ZPERSISTENTWALLET SET ZNAME='racing writer'")) + checkedLock = true + } + })) + XCTAssertTrue(checkedLock) + try verifyRows(migrated.mainContext, walletName: "committed in WAL") + } + } + + func testClosedWALStoreMigratesOnColdOpen() throws { + try withStore { url in + try autoreleasepool { + let connection = try DashLegacyStoreSQLite.Connection(url, writable: true) + try connection.execute("PRAGMA journal_mode=WAL") + try connection.execute("UPDATE ZPERSISTENTWALLET SET ZNAME='closed WAL store'") + } + XCTAssertFalse(FileManager.default.fileExists(atPath: url.path + "-wal")) + let migrated = try open(url) + try verifyRows(migrated.mainContext, walletName: "closed WAL store") + } + } + + func testCheckpointRejectsBusyWALAndConfirmsDeleteMode() throws { + try withStore { url in + try autoreleasepool { + let writer = try DashLegacyStoreSQLite.Connection(url, writable: true) + try writer.execute("PRAGMA journal_mode=WAL") + try writer.execute("PRAGMA wal_autocheckpoint=0") + let reader = try DashLegacyStoreSQLite.Connection(url, writable: false) + try reader.execute("BEGIN") + try reader.query("SELECT ZNAME FROM ZPERSISTENTWALLET") { _ in } + try writer.execute("UPDATE ZPERSISTENTWALLET SET ZNAME='checkpointed'") + XCTAssertThrowsError(try DashLegacyStoreSQLite.checkpoint(url)) + try reader.execute("ROLLBACK") + } + try DashLegacyStoreSQLite.checkpoint(url) + let connection = try DashLegacyStoreSQLite.Connection(url, writable: false) + var mode = "" + try connection.query("PRAGMA journal_mode") { + mode = String(cString: sqlite3_column_text($0, 0)) + } + XCTAssertEqual(mode, "delete") + XCTAssertFalse(FileManager.default.fileExists(atPath: url.path + "-wal")) + } + } + + func testChangedSourceIsNotOverwrittenAndRetryPreservesNewWrite() throws { + try withStore { url in + XCTAssertThrowsError(try open(url, hooks: .init(visit: { phase, source in + if phase == .afterSnapshot { + try DashLegacyStoreSQLite.Connection(source, writable: true) + .execute("UPDATE ZPERSISTENTWALLET SET ZNAME='new concurrent value'") + } + }))) + let migrated = try open(url) + try verifyRows(migrated.mainContext, walletName: "new concurrent value") + } + } + + func testFailedValidationAndPrecommitErrorLeaveOriginalUnchanged() throws { + try withStore { url in + let original = try DashLegacyStoreSQLite.rawDigest(url) + XCTAssertThrowsError(try open(url, hooks: .init(visit: { phase, candidate in + if phase == .afterMigration { + try DashLegacyStoreSQLite.Connection(candidate, writable: true) + .execute("DELETE FROM ZPERSISTENTINDEX") + } + }))) + XCTAssertEqual(try DashLegacyStoreSQLite.rawDigest(url), original) + XCTAssertTrue(try operationDirectories(url).isEmpty, "Uncommitted copies must not accumulate on retries") + XCTAssertThrowsError(try open(url, hooks: .init(visit: { phase, candidate in + if phase == .afterMigration { + try DashLegacyStoreSQLite.Connection(candidate, writable: true) + .execute("UPDATE ZPERSISTENTINDEX SET ZDOCUMENTTYPE=NULL") + } + })), "A same-count relationship change must fail validation") + XCTAssertEqual(try DashLegacyStoreSQLite.rawDigest(url), original) + XCTAssertThrowsError(try open(url, hooks: .init(visit: { phase, _ in + if phase == .writeLocked { throw Injected.stop } + }))) + XCTAssertEqual(try DashLegacyStoreSQLite.rawDigest(url), original) + let recovered = try open(url) + try verifyRows(recovered.mainContext) + } + } + + func testAfterCommitInterruptionRecoversWithoutRepeatingMigration() throws { + try withStore { url in + XCTAssertThrowsError(try open(url, hooks: .init(visit: { phase, _ in + if phase == .afterCommit { throw Injected.stop } + }))) + let marker = DashLegacySchemaBridge.backupDirectory(for: url).appendingPathComponent("active.json") + XCTAssertTrue(FileManager.default.fileExists(atPath: marker.path)) + let recovered = try open(url, hooks: .init(visit: { _, _ in XCTFail("Committed candidate should only recover") })) + try verifyRows(recovered.mainContext) + XCTAssertFalse(FileManager.default.fileExists(atPath: marker.path)) + XCTAssertEqual(try operationDirectories(url).count, 1) + } + } + + func testPendingRecoveryWithMissingOriginalRequiresDeliberateRecovery() throws { + for removeBackup in [false, true] { + try withStore { url in + XCTAssertThrowsError(try open(url, hooks: .init(visit: { phase, _ in + if phase == .beforeInstall { throw Injected.stop } + }))) + let root = DashLegacySchemaBridge.backupDirectory(for: url) + let marker = root.appendingPathComponent("active.json") + let journal = try Data(contentsOf: marker) + let displaced = url.deletingLastPathComponent().appendingPathComponent("displaced.store") + try FileManager.default.moveItem(at: url, to: displaced) + if removeBackup { + for directory in try operationDirectories(url) { try FileManager.default.removeItem(at: directory) } + } + XCTAssertThrowsError(try open(url)) { error in + XCTAssertTrue(error.localizedDescription.contains("Restore the original database from a verified backup")) + XCTAssertTrue(error.localizedDescription.contains(root.path)) + } + XCTAssertFalse(FileManager.default.fileExists(atPath: url.path), "Never resurrect a possibly intentional reset") + XCTAssertEqual(try Data(contentsOf: marker), journal, "Preserve recovery evidence even when copies are absent") + XCTAssertTrue(FileManager.default.fileExists(atPath: displaced.path)) + // Deliberate restoration of the authoritative source unblocks + // normal recovery; the bridge can recreate missing scratch files. + try FileManager.default.moveItem(at: displaced, to: url) + let recovered = try open(url) + try verifyRows(recovered.mainContext) + } + } + } + + func testRecoveryWorksWithoutScratchFilesBeforeAndAfterCommit() throws { + for phase: DashLegacySchemaBridge.Phase in [.beforeInstall, .afterCommit] { + try withStore { url in + XCTAssertThrowsError(try open(url, hooks: .init(visit: { current, _ in + if current == phase { throw Injected.stop } + }))) + for directory in try operationDirectories(url) { + try FileManager.default.removeItem(at: directory) + } + let recovered = try open(url) + try verifyRows(recovered.mainContext) + XCTAssertFalse(FileManager.default.fileExists(atPath: + DashLegacySchemaBridge.backupDirectory(for: url).appendingPathComponent("active.json").path)) + } + } + } + + func testMissingCandidateCannotHideChangedInstalledRows() throws { + try withStore { url in + XCTAssertThrowsError(try open(url, hooks: .init(visit: { phase, _ in + if phase == .afterCommit { throw Injected.stop } + }))) + for directory in try operationDirectories(url) { + try FileManager.default.removeItem(at: directory) + } + try DashLegacyStoreSQLite.Connection(url, writable: true) + .execute("DELETE FROM ZPERSISTENTINDEX") + XCTAssertThrowsError(try open(url)) + XCTAssertTrue(FileManager.default.fileExists(atPath: + DashLegacySchemaBridge.backupDirectory(for: url).appendingPathComponent("active.json").path)) + } + } + + func testFailedOrdinaryOpenDoesNotReclaimRetainedBackup() throws { + try withStore { url in + try autoreleasepool { _ = try open(url) } + let directories = try operationDirectories(url) + XCTAssertEqual(directories.count, 1) + try Data("unreadable store".utf8).write(to: url) + XCTAssertThrowsError(try open(url)) + XCTAssertEqual(try operationDirectories(url), directories) + } + } + + func testCorruptionExternalStorageAndNewerUnknownVersionDoNotBridge() throws { + try withStore { url in + try Data("not a database".utf8).write(to: url) + let bytes = try Data(contentsOf: url) + XCTAssertThrowsError(try open(url)) + XCTAssertEqual(try Data(contentsOf: url), bytes) + XCTAssertTrue(try operationDirectories(url).isEmpty) + } + try withStore { url in + let support = url.deletingLastPathComponent().appendingPathComponent("." + url.lastPathComponent + "_SUPPORT") + try FileManager.default.createDirectory(at: support, withIntermediateDirectories: true) + let original = try DashLegacyStoreSQLite.rawDigest(url) + XCTAssertThrowsError(try open(url)) + XCTAssertEqual(try DashLegacyStoreSQLite.rawDigest(url), original) + XCTAssertTrue(try operationDirectories(url).isEmpty) + } + try withStore { url in + var metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(type: .sqlite, at: url) + metadata[NSStoreModelVersionIdentifiersKey] = ["2.0.0"] + try NSPersistentStoreCoordinator.setMetadata(metadata, type: .sqlite, at: url) + let copy = url.deletingLastPathComponent().appendingPathComponent("newer-original.store") + try DashLegacyStoreSQLite.copy(from: url, to: copy) + XCTAssertThrowsError(try open(url, hooks: .init(visit: { _, _ in XCTFail("Newer unknown store must not bridge") }))) + // The ordinary SwiftData path may switch journal modes before + // rejecting an unknown version; application values must remain intact. + try DashLegacyStoreSQLite.validatePreservation(from: copy, to: url) + XCTAssertTrue(try operationDirectories(url).isEmpty) + } + } + + func testUnknownEntityAndCandidateExternalStorageAreRejected() throws { + try withStore { url in + var metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(type: .sqlite, at: url) + var hashes = try XCTUnwrap(metadata[NSStoreModelVersionHashesKey] as? [String: Data]) + hashes["UnknownEntity"] = Data(repeating: 1, count: 32) + metadata[NSStoreModelVersionHashesKey] = hashes + try NSPersistentStoreCoordinator.setMetadata(metadata, type: .sqlite, at: url) + XCTAssertThrowsError(try open(url)) + XCTAssertTrue(try operationDirectories(url).isEmpty) + } + try withStore { url in + let original = try DashLegacyStoreSQLite.rawDigest(url) + XCTAssertThrowsError(try open(url, hooks: .init(visit: { phase, candidate in + if phase == .afterMigration { + let support = candidate.deletingLastPathComponent().appendingPathComponent("." + candidate.lastPathComponent + "_SUPPORT") + try FileManager.default.createDirectory(at: support, withIntermediateDirectories: true) + } + }))) + XCTAssertEqual(try DashLegacyStoreSQLite.rawDigest(url), original) + XCTAssertTrue(try operationDirectories(url).isEmpty) + } + } + + func testSkippingV3UsesFixedBridgeThenRegisteredCustomFutureStage() throws { + try withStore { url in + let schema = Schema(versionedSchema: BridgeFutureV4.self) + var checkedV3 = false + let configuration = ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none) + XCTAssertThrowsError(try DashLegacySchemaBridge.open( + configuration: configuration, schema: schema, plan: BridgeFuturePlan.self, + hooks: .init(visit: { phase, candidate in + if phase == .afterMigration { + _ = try DashSchemaFixtureSupport.describeStore(at: candidate, version: Schema.Version(3, 0, 0)) + checkedV3 = true + } + if phase == .afterCommit { throw Injected.stop } + }))) + XCTAssertTrue(checkedV3) + let recovered = try DashLegacySchemaBridge.open( + configuration: configuration, schema: schema, plan: BridgeFuturePlan.self) + try verifyRows(recovered.mainContext, walletName: "explicit future transformation") + XCTAssertEqual(try recovered.mainContext.fetchCount(FetchDescriptor()), 0) + } + } + + func testValidatorRejectsColumnTypeChangesAndPreservesTypedValues() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let original = directory.appendingPathComponent("source.store") + let candidate = directory.appendingPathComponent("candidate.store") + let source = try DashLegacyStoreSQLite.Connection(original, writable: true, create: true) + try source.execute("CREATE TABLE Z_PRIMARYKEY(Z_ENT INTEGER,Z_NAME TEXT)") + try source.execute("INSERT INTO Z_PRIMARYKEY VALUES(1,'PersistentWallet')") + try source.execute("CREATE TABLE ZPERSISTENTWALLET(Z_PK INTEGER,Z_ENT INTEGER,Z_OPT INTEGER,V BLOB,T TEXT,N INTEGER)") + try source.execute("INSERT INTO ZPERSISTENTWALLET VALUES(1,1,1,X'000100',CAST(X'610062' AS TEXT),9223372036854775807)") + try DashLegacyStoreSQLite.copy(from: original, to: candidate) + try DashLegacyStoreSQLite.validatePreservation(from: original, to: candidate) + let copy = try DashLegacyStoreSQLite.Connection(candidate, writable: true) + try copy.execute("UPDATE ZPERSISTENTWALLET SET T='a'") + XCTAssertThrowsError(try DashLegacyStoreSQLite.validatePreservation(from: original, to: candidate), "Embedded NUL bytes must be compared") + try copy.execute("DROP TABLE ZPERSISTENTWALLET") + try copy.execute("CREATE TABLE ZPERSISTENTWALLET(Z_PK INTEGER,Z_ENT INTEGER,Z_OPT INTEGER,V TEXT,T TEXT,N INTEGER)") + try copy.execute("INSERT INTO ZPERSISTENTWALLET VALUES(1,1,1,X'000100',CAST(X'610062' AS TEXT),9223372036854775807)") + XCTAssertThrowsError(try DashLegacyStoreSQLite.validatePreservation(from: original, to: candidate), "Declared storage type changes must be rejected even when cells agree") + try copy.execute("ALTER TABLE ZPERSISTENTWALLET DROP COLUMN T") + XCTAssertThrowsError(try DashLegacyStoreSQLite.validatePreservation(from: original, to: candidate), "Removed original columns must fail validation") + } + + func testLargeHistoricalStoreMigratesAsynchronouslyWhileMainActorRemainsResponsive() async throws { + let (directory, url) = try makeStore() + defer { try? FileManager.default.removeItem(at: directory) } + // Synthetic storage load only: these payloads are not broadcastable + // transactions or a production wallet. Populate the pinned old layout. + try autoreleasepool { + let connection = try DashLegacyStoreSQLite.Connection(url, writable: true) + try connection.execute(""" + WITH RECURSIVE rows(n) AS (VALUES(1) UNION ALL SELECT n+1 FROM rows WHERE n<10000) + INSERT INTO ZPERSISTENTTRANSACTION + (Z_PK,Z_ENT,Z_OPT,ZBLOCKHEIGHT,ZBLOCKPOSITION,ZBLOCKTIMESTAMP,ZCONTEXT,ZDIRECTION, + ZFIRSTSEEN,ZHASBLOCKPOSITION,ZNETAMOUNT,ZPROVIDERCOLLATERALVOUT,ZTRANSACTIONTYPEKIND, + ZCREATEDAT,ZLASTUPDATED,ZLABEL,ZTRANSACTIONTYPE,ZTRANSACTIONDATA,ZTXID) + SELECT n,(SELECT Z_ENT FROM Z_PRIMARYKEY WHERE Z_NAME='PersistentTransaction'),1, + 0,0,0,0,0,n,0,0,0,255,0,0,'synthetic','Standard',zeroblob(8192), + CAST(printf('%032d',n) AS BLOB) FROM rows; + UPDATE Z_PRIMARYKEY SET Z_MAX=10000 WHERE Z_NAME='PersistentTransaction'; + """) + } + let bytes = (try FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)?.int64Value ?? 0 + let heartbeat = Task { @MainActor in + var ticks = 0 + var longestGap = 0.0 + var previous = Date.timeIntervalSinceReferenceDate + while !Task.isCancelled { + // Nonthrowing tick: cancellation must not produce a secondary + // CancellationError while reporting an actual migration failure. + await withCheckedContinuation { continuation in + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + continuation.resume() + } + } + guard !Task.isCancelled else { break } + let now = Date.timeIntervalSinceReferenceDate + longestGap = max(longestGap, now - previous) + previous = now + ticks += 1 + } + return (ticks, longestGap) + } + defer { heartbeat.cancel() } + let started = Date.timeIntervalSinceReferenceDate + let container: ModelContainer + do { + container = try await DashModelContainer.createAsync(url: url) + } catch { + heartbeat.cancel() + _ = await heartbeat.value + throw error // Preserve the migration failure after draining the tick. + } + let elapsed = Date.timeIntervalSinceReferenceDate - started + heartbeat.cancel() + let (ticks, longestGap) = await heartbeat.value + XCTAssertGreaterThan(ticks, 1, "The main actor must keep executing while migration runs") + XCTAssertEqual(try container.mainContext.fetchCount(FetchDescriptor()), 10_000) + try verifyRows(container.mainContext) + let report = "Legacy migration benchmark: bytes=\(bytes) transactions=10000 seconds=\(elapsed) mainTicks=\(ticks) maxMainGap=\(longestGap)" + Swift.print(report) + let attachment = XCTAttachment(string: report) + attachment.name = "legacy-migration-benchmark" + attachment.lifetime = .keepAlways + add(attachment) + let reopened = try await DashModelContainer.createAsync(url: url) + XCTAssertEqual(try reopened.mainContext.fetchCount(FetchDescriptor()), 10_000) + } + + func testExactPreviousLiveV2RemainsWritableWithoutReinterpretingHistoricalV2() throws { + try withStore { url in + try autoreleasepool { + let schema = Schema(versionedSchema: PreviousLiveV2.self) + let container = try ModelContainer(for: schema, configurations: [ + ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none) + ]) + try verifyRows(container.mainContext) + } + XCTAssertEqual(try DashLegacySchemaBridge.identity(at: url).versions, ["2.0.0"]) + let container = try open(url, hooks: .init(visit: { _, _ in + XCTFail("An exact previous current shape needs no legacy inferred migration") + })) + try verifyRows(container.mainContext) + let wallet = try XCTUnwrap(container.mainContext.fetch(FetchDescriptor()).first) + wallet.name = "previous capture remains writable" + try container.mainContext.save() + } + } + + func testUnsupportedBetaV2FailsWithoutMutation() throws { + try withStore { url in + try autoreleasepool { + let schema = Schema(versionedSchema: UnsupportedBetaV2.self) + _ = try ModelContainer(for: schema, configurations: [ + ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none) + ]) + } + try DashLegacyStoreSQLite.checkpoint(url) + let before = try DashLegacyStoreSQLite.rawDigest(url) + XCTAssertThrowsError(try open(url)) { error in + XCTAssertTrue(error.localizedDescription.contains("does not match")) + } + XCTAssertEqual(try DashLegacyStoreSQLite.rawDigest(url), before) + XCTAssertTrue(try operationDirectories(url).isEmpty) + } + } + + func testPreviousLiveV2JournalRecoversBeforeChoosingMigrationRoute() throws { + struct OldJournal: Encodable { + let formatVersion: Int + let operation: UUID + let source: DashLegacySchemaBridge.Identity + let destination: DashLegacySchemaBridge.Identity + let destinationData: DashLegacyStoreSQLite.StoreEvidence? + } + for format in [1, 2] { + for installed in [false, true] { + try withStore { url in + let operation = UUID() + let root = DashLegacySchemaBridge.backupDirectory(for: url) + let directory = root.appendingPathComponent(operation.uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let backup = directory.appendingPathComponent("original.store") + let candidate = directory.appendingPathComponent("candidate.store") + try DashLegacyStoreSQLite.copy(from: url, to: backup) + try DashLegacyStoreSQLite.copy(from: url, to: candidate) + let source = try DashLegacySchemaBridge.identity(at: url) + try autoreleasepool { + let schema = Schema(versionedSchema: PreviousLiveV2.self) + _ = try ModelContainer(for: schema, configurations: [ + ModelConfiguration(schema: schema, url: candidate, cloudKitDatabase: .none) + ]) + } + try DashLegacyStoreSQLite.checkpoint(candidate) + try DashLegacyStoreSQLite.validatePreservation(from: backup, to: candidate) + let journal = OldJournal(formatVersion: format, operation: operation, source: source, + destination: try DashLegacySchemaBridge.identity(at: candidate), + destinationData: format == 2 ? try DashLegacyStoreSQLite.evidence(at: candidate) : nil) + if installed { try DashLegacyStoreSQLite.copy(from: candidate, to: url) } + let marker = root.appendingPathComponent("active.json") + try JSONEncoder().encode(journal).write(to: marker) + let container = try open(url) + try verifyRows(container.mainContext) + XCTAssertFalse(FileManager.default.fileExists(atPath: marker.path)) + } + } + } + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index 2c2708d4f81..1ac6248f4cc 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -6,40 +6,9 @@ import XCTest @testable import SwiftDashSDK -/// Migration coverage from two directions: source stores built in this -/// process from each registered version, and stores that OLDER BUILDS -/// actually wrote. -/// -/// The fixture stores under `Fixtures/SchemaStores/` were written by the -/// builds that shipped each version: `dash-v1` to `dash-v3` by a build of -/// the persistence sources as of commit 5f58417079 — the last state before -/// V4, the state the frozen copies under `FrozenSchemas/` are generated -/// from — through that build's own `DashSchemaV1` / `DashSchemaV2` / -/// `DashSchemaV3`, `dash-v4` by the build that registered V4, and `dash-v5` -/// by the build that last changed V5 before it shipped (the bounds kind -/// column), all through -/// `DashModelContainer.create`. They pin the frozen copies as the pre-V4 -/// build defined them, not what the original V1 release wrote (see the -/// `DashSchemaV1` doc for why those stores are expected to fail open and -/// be rebuilt). Each carries a wallet, an account, a core address, two -/// transactions, a TXO linked to both, a pending input, an identity, a -/// keyword, an asset lock and (from V2) a tracked masternode — enough to -/// exercise every relationship in the wallet graph; the rows are the ones -/// `testWriteTheLiveSchemaFixtureStore` writes. -/// -/// The live version has a fixture too, so a change to a live model's -/// shape fails the hash test against that version's own store — the -/// failure a store in the field would otherwise report as Cocoa error -/// 134504. Changing the live shape before it ships is legitimate; doing -/// so means regenerating the live fixture on purpose, with -/// `testWriteTheLiveSchemaFixtureStore`, in the same change. -/// -/// A source store written in this process by `Schema(versionedSchema:)` -/// cannot replace them: SwiftData binds an entity name to the first Swift -/// type that claims it, so such a store carries whatever shape the process -/// had already bound, and a frozen version whose entities had silently -/// rebound to the live shape would round-trip itself and pass vacuously. -/// Only a store from a build that knew nothing of the live shape can tell. +/// The accepted V1 baseline remains byte-for-byte unchanged. Actual App Store +/// releases captured by the pipeline are additionally checked by +/// DashReleasedSchemaTests. A development version is not a released version. final class DashModelMigrationTests: XCTestCase { /// SwiftData binds an entity name to the first Swift type that claims it /// in the process, so whether the live schema is built before or after @@ -62,30 +31,11 @@ final class DashModelMigrationTests: XCTestCase { Fixture( name: "dash-v1", version: DashSchemaV1.self, hasTrackedMasternode: false, assetLockRecipientIsExternal: nil), - Fixture( - name: "dash-v2", version: DashSchemaV2.self, - hasTrackedMasternode: true, assetLockRecipientIsExternal: nil), - Fixture( - name: "dash-v3", version: DashSchemaV3.self, - hasTrackedMasternode: true, assetLockRecipientIsExternal: true), - Fixture( - name: "dash-v4", version: DashSchemaV4.self, - hasTrackedMasternode: true, assetLockRecipientIsExternal: true), - Fixture( - name: "dash-v5", version: DashSchemaV5.self, - hasTrackedMasternode: true, assetLockRecipientIsExternal: true), + Fixture(name: "historical-v2", version: DashSchemaV2.self, + hasTrackedMasternode: true, assetLockRecipientIsExternal: nil), ] - /// Every schema version that has ever shipped, oldest first, as - /// `major.minor.patch`. APPEND-ONLY: a version that shipped wrote stores - /// that exist in the field, so it can never be removed from, reordered - /// in, or replaced in the migration plan, and the plan is checked - /// against this list rather than the other way round. Adding a version - /// to the plan is shipping it: append it here in the same change and - /// give it a fixture store in `fixtures`, written by that build with - /// `testWriteTheLiveSchemaFixtureStore`. Every entry has a fixture, - /// the live one included. - private static let shippedVersions = ["1.0.0", "2.0.0", "3.0.0", "4.0.0", "5.0.0"] + private static var acceptedBaselineVersions: [Schema.Version] { [Schema.Version(1, 0, 0)] } private static let fixtureWalletId = Data(repeating: 0x31, count: 32) private static let fixtureSpendTxid = Data(repeating: 0x32, count: 32) @@ -108,6 +58,107 @@ final class DashModelMigrationTests: XCTestCase { return (directory, copy) } + @MainActor + func testMigrationDiagnosticsReachExportFileWithoutVerboseLogging() throws { + let fixture = try XCTUnwrap(Self.fixtures.first { $0.name == "historical-v2" }) + let (directory, url) = try copyFixture(fixture) + defer { try? FileManager.default.removeItem(at: directory) } + let session = directory.appendingPathComponent("logs", isDirectory: true) + // This is the same file sink setting installed by the default low preset. + XCTAssertTrue(SDKLogger.installFileSink(at: session, includeDebug: false)) + // The sink is process-wide: detach it before the directory disappears. + defer { SDKLogger.removeFileSink() } + let sourceChecksum = try Self.storeHashes(at: url).0 + + try autoreleasepool { _ = try DashModelContainer.create(url: url) } + try autoreleasepool { _ = try DashModelContainer.create(url: url) } + SDKLogger.flush() + let log = try String(contentsOf: session.appendingPathComponent("swift/run.log"), encoding: .utf8) + XCTAssertTrue(log.contains("event=store_open_started")) + XCTAssertTrue(log.contains("route=\"historical-v2-to-v3\"")) + XCTAssertTrue(log.contains("source_version=\"2.0.0\"")) + XCTAssertTrue(log.contains("source_checksum=\"\(sourceChecksum)\"")) + XCTAssertTrue(log.contains("target_version=\"3.0.0\"")) + XCTAssertTrue(log.contains("route=\"labelled-current-v3\"")) + XCTAssertEqual(log.components(separatedBy: "event=store_open_succeeded").count - 1, 2) + XCTAssertFalse(log.contains("event=store_open_failed")) + XCTAssertFalse(log.contains(directory.path)) + XCTAssertFalse(log.contains("fixture wallet")) + + let freshURL = directory.appendingPathComponent("fresh.store") + try autoreleasepool { _ = try DashModelContainer.create(url: freshURL) } + // A malformed store must produce a safe error diagnostic, without its contents. + let invalidURL = directory.appendingPathComponent("invalid.store") + try Data("private-invalid-store-content".utf8).write(to: invalidURL) + XCTAssertThrowsError(try DashModelContainer.create(url: invalidURL)) + SDKLogger.flush() + let updatedLog = try String(contentsOf: session.appendingPathComponent("swift/run.log"), encoding: .utf8) + XCTAssertTrue(updatedLog.contains("route=\"new-store\"")) + let failure = try XCTUnwrap(updatedLog.split(separator: "\n").first { $0.contains("event=store_open_failed") }) + XCTAssertTrue(failure.contains("target_version=\"3.0.0\"")) + XCTAssertTrue(failure.contains("error_code=")) + XCTAssertFalse(updatedLog.contains(directory.path)) + XCTAssertFalse(updatedLog.contains("private-invalid-store-content")) + } + + /// Opening a current store must not depend on the temporary-store probe + /// that resolves a frozen schema's identity: the plan is the default + /// either way. Labels whose route is undecidable without that probe + /// (accepted V1 versus the bridge, historical V2 versus a beta layout) + /// keep failing closed, leaving the store untouched. + func testCurrentV3RouteNeverDependsOnTheSchemaIdentityProbe() throws { + struct ProbeUnavailable: Error {} + let failingProbe: (any VersionedSchema.Type) throws -> DashLegacySchemaBridge.Identity = { _ in + throw ProbeUnavailable() + } + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let current = directory.appendingPathComponent("current.store") + try autoreleasepool { _ = try DashModelContainer.create(url: current) } + XCTAssertEqual(try DashLegacySchemaBridge.identity(at: current).versions, ["3.0.0"]) + let plan = try DashModelContainer.migrationPlan( + at: current, defaultPlan: DashMigrationPlan.self, identity: failingProbe) + XCTAssertTrue(ObjectIdentifier(plan) == ObjectIdentifier(DashMigrationPlan.self)) + + for fixture in Self.fixtures { + let (fixtureDirectory, url) = try copyFixture(fixture) + defer { try? FileManager.default.removeItem(at: fixtureDirectory) } + let before = try DashLegacyStoreSQLite.rawDigest(url) + XCTAssertThrowsError(try DashModelContainer.migrationPlan( + at: url, defaultPlan: DashMigrationPlan.self, identity: failingProbe), fixture.name) { error in + XCTAssertTrue(error is ProbeUnavailable, fixture.name) + } + XCTAssertEqual(try DashLegacyStoreSQLite.rawDigest(url), before, fixture.name) + } + } + + func testSchemaIdentityIsComputedOncePerProcessAndFailuresAreNotRemembered() throws { + struct ProbeUnavailable: Error {} + let cache = DashLegacySchemaBridge.SchemaIdentityCache() + let identity = DashLegacySchemaBridge.Identity( + versions: ["3.0.0"], checksum: "checksum", hashes: ["PersistentWallet": Data([1])]) + var computations = 0 + XCTAssertThrowsError(try cache.identity(for: DashSchemaV3.self) { + computations += 1 + throw ProbeUnavailable() + }) + XCTAssertEqual(try cache.identity(for: DashSchemaV3.self) { computations += 1; return identity }, identity) + XCTAssertEqual(try cache.identity(for: DashSchemaV3.self) { + computations += 1 + throw ProbeUnavailable() + }, identity) + XCTAssertEqual(computations, 2) + XCTAssertThrowsError(try cache.identity(for: DashSchemaV2.self) { + computations += 1 + throw ProbeUnavailable() + }) + XCTAssertEqual(computations, 3) + XCTAssertEqual(try DashLegacySchemaBridge.identity(for: DashSchemaV3.self), + try DashLegacySchemaBridge.identity(for: DashSchemaV3.self)) + } + private static func storeHashes(at url: URL) throws -> (String, [String: Data]) { let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore( type: .sqlite, at: url) @@ -120,7 +171,7 @@ final class DashModelMigrationTests: XCTestCase { return (checksum, hashes) } - /// Every fixture opens through `DashModelContainer.create`'s exact + /// The baseline fixture opens through `DashModelContainer.create`'s exact /// order — live schema built first, then the migration plan — and its /// rows come back through the live types with the relationships intact /// and the new columns at their migration defaults. @@ -215,56 +266,15 @@ final class DashModelMigrationTests: XCTestCase { } } - /// Each frozen version, built after the live schema (the order - /// `DashModelContainer.create` uses, established process-wide in - /// `setUp`), still hashes every entity exactly as the build that shipped - /// it did. A partial freeze cannot give this: a frozen wallet reached - /// from a live `PersistentAccount.wallet` is rebound to the live - /// wallet's shape the moment the live schema is built first, and the - /// released checksum moves with it. - /// - /// This test is THE authority on whether a freeze is complete in every - /// respect the entity hash covers: stored properties, their types, - /// optionality and defaults, relationships and their inverses, and - /// `#Unique` constraints. The generator's `--check` - /// (`scripts/freeze_schema_models.py`) only proves the committed frozen - /// files are the generator's byte-for-byte output; it does not, and - /// must not try to, decide whether the `FREEZES` table covers every - /// relationship target and stored value type. A static scan of Swift - /// source cannot: it misses whatever syntax it does not understand, and - /// it flags references SwiftData does not hash at all (a struct stored - /// directly on a model is part of the entity hash; an array of structs - /// nested inside it is not), so it fails silently in both directions. - /// Only building the schema and reading the hash SwiftData computes, - /// against a store a shipping build wrote, answers the question, and - /// that is what this does: an omitted relationship target fails here as - /// soon as the live target has changed shape, an omitted stored value - /// type fails here on the change that would have broken the store, and - /// a version registering the wrong entity set fails on membership. - /// - /// What the hash does NOT cover is `#Index`: Core Data leaves indexes - /// out of entity version hashes, so an index that drifted in a frozen - /// copy, or one a migration never created, passes here. That is what - /// `testFixturesAndMigratedStoresCarryTheIndexesFreshStoresHave` is for. - /// - /// Its reach is exactly the fixtures: it guards a version only once a - /// store written by a build that shipped that version is committed - /// under `Fixtures/SchemaStores/` and listed in `fixtures`. So the first - /// thing checked is that `fixtures` lists every version in - /// `shippedVersions`, the live one included, once each: cutting a new - /// schema version fails this test until its fixture is committed, and - /// changing a live model's shape fails it against the live fixture - /// until that fixture is deliberately rewritten. The expectation comes - /// from the append-only list, not from the migration plan, so a plan - /// that dropped a version cannot shrink it - /// (`testShippedSchemaVersionsStayInTheMigrationPlan`). + /// Keep the accepted V1 hashes stable even after the live graph has been + /// built. A source-store round trip alone can miss accidentally live + /// relationships and inline value types; compare the existing fixture. + /// Captured App Store versions are covered by DashReleasedSchemaTests. func testFrozenVersionsBuiltAfterTheLiveSchemaHashLikeTheStoresTheyShipped() throws { XCTAssertEqual( - Self.fixtures.map { Self.describe($0.version.versionIdentifier) }, - Self.shippedVersions, - "every shipped schema version, the live one included, needs a fixture store " - + "written by the build that shipped it, listed once in `fixtures`; without " - + "one its shape is unguarded") + Self.fixtures.map { $0.version.versionIdentifier }, + Self.acceptedBaselineVersions + [Schema.Version(2, 0, 0)], + "the accepted baseline must retain its existing fixture") for fixture in Self.fixtures { let (directory, url) = try copyFixture(fixture) @@ -295,29 +305,34 @@ final class DashModelMigrationTests: XCTestCase { "\(version.major).\(version.minor).\(version.patch)" } - /// The migration plan must list exactly the versions that ever shipped, - /// in the order they shipped, with nothing removed, reordered or - /// replaced: a store written by any of them is still in the field and - /// must be recognised. The expectation is the append-only - /// `shippedVersions`, never the plan itself, so editing the plan cannot - /// move the goalposts; a version can only enter the plan by being - /// appended to that list in the same change. - /// - /// Versions are compared by identifier, not by enum: two enums both - /// declaring `4.0.0` are indistinguishable here, so the identifiers in - /// the list must be unique, and whether the enum behind an identifier - /// still has the shape that shipped is decided by that version's - /// fixture in the hash test. - func testShippedSchemaVersionsStayInTheMigrationPlan() { - XCTAssertEqual( - Set(Self.shippedVersions).count, Self.shippedVersions.count, - "a version identifier can ship once") + func testMigrationPlanContainsBaselinePublishedAndLiveVersionsInOrder() { + let publishedVersions = DashReleasedSchemaRegistry.fixtures.map { + $0.version.versionIdentifier + } + let expected = Set( + [Schema.Version(2, 0, 0)] + publishedVersions + [DashModelContainer.schema.version] + ).sorted() XCTAssertEqual( - DashMigrationPlan.schemas.map { Self.describe($0.versionIdentifier) }, - Self.shippedVersions, - "the migration plan must list exactly the shipped versions, oldest first; a new " - + "version is appended to `shippedVersions` in the same change, and nothing " - + "that shipped is ever removed, reordered or replaced") + DashMigrationPlan.schemas.map { $0.versionIdentifier }, expected, + "The primary plan retains historical V2 and published versions; accepted V1 has a separate non-destructive route") + } + + func testMigrationStagesConnectAdjacentRegisteredSchemas() { + let plans: [any SchemaMigrationPlan.Type] = [DashMigrationPlan.self, DashAcceptedV1MigrationPlan.self] + for plan in plans { + let schemas = plan.schemas + let stages = plan.stages + XCTAssertEqual(stages.count, schemas.count - 1) + for (stage, adjacent) in zip(stages, zip(schemas, schemas.dropFirst())) { + switch stage { + case .lightweight(let from, let to), .custom(let from, let to, _, _): + XCTAssertEqual(ObjectIdentifier(from), ObjectIdentifier(adjacent.0)) + XCTAssertEqual(ObjectIdentifier(to), ObjectIdentifier(adjacent.1)) + @unknown default: + XCTFail("Unsupported migration stage") + } + } + } } /// The schema the app opens stores with must be the last version of @@ -332,101 +347,13 @@ final class DashModelMigrationTests: XCTestCase { Self.describe(DashModelContainer.schema.version), Self.describe(last.versionIdentifier), "DashModelContainer.schema must be built from the migration plan's last version") - } - - /// Writes the live version's fixture store — the rows every fixture - /// carries, through `DashModelContainer.create`, so the file is what - /// this build ships. Skipped unless `DASH_SCHEMA_FIXTURE_OUTPUT` names a - /// directory to write into; run it on purpose when the live shape - /// changes before shipping, or when a new version is cut: - /// - /// DASH_SCHEMA_FIXTURE_OUTPUT=/some/dir swift test \ - /// --filter DashModelMigrationTests/testWriteTheLiveSchemaFixtureStore - /// - /// then move `dash-vN.store` into `Fixtures/SchemaStores/`. A retired - /// version's fixture is never rewritten: only the build that shipped it - /// could write it. - @MainActor - func testWriteTheLiveSchemaFixtureStore() throws { - guard let output = ProcessInfo.processInfo.environment["DASH_SCHEMA_FIXTURE_OUTPUT"] - else { - throw XCTSkip("set DASH_SCHEMA_FIXTURE_OUTPUT to write the live schema's fixture") - } - let version = try XCTUnwrap(DashMigrationPlan.schemas.last).versionIdentifier - let url = URL(fileURLWithPath: output, isDirectory: true) - .appendingPathComponent("dash-v\(version.major).store") - // Stop before opening: creating a container over an existing store rewrites its - // checksum and leaves WAL sidecars behind, so a second run would silently - // replace the committed fixture rather than refusing to. - guard !FileManager.default.fileExists(atPath: url.path) else { - XCTFail("\(url.path) already exists; delete it to rewrite the fixture") - return - } - - var container: ModelContainer? = try DashModelContainer.create(url: url) - let context = try XCTUnwrap(container?.mainContext) - let wallet = PersistentWallet( - walletId: Self.fixtureWalletId, network: .testnet, name: "fixture wallet", - syncedHeight: 120) - context.insert(wallet) - let account = PersistentAccount( - wallet: wallet, accountType: 0, accountIndex: 0, accountTypeName: "standard") - context.insert(account) - let address = PersistentCoreAddress( - address: "yFixtureAddress", poolTypeTag: 0, addressIndex: 0, derivationPath: "m/0") - address.account = account - context.insert(address) - let funding = PersistentTransaction( - txid: Self.fixtureFundingTxid, transactionData: Data([3, 0]), context: 2, - blockHeight: 100) - let spend = PersistentTransaction( - txid: Self.fixtureSpendTxid, transactionData: Data([3, 0]), context: 2, - blockHeight: 110) - context.insert(funding) - context.insert(spend) - account.involvedTransactions = [funding, spend] - let txo = PersistentTxo( - transaction: funding, vout: 0, amount: 1_000, address: "yFixtureAddress", - height: 100) - txo.walletId = Self.fixtureWalletId - txo.isSpent = true - txo.spendingTransaction = spend - txo.coreAddress = address - txo.account = account - context.insert(txo) - context.insert(PersistentPendingInput( - outpoint: Data(repeating: 0x11, count: 36), inputIndex: 0, - spendingTxid: Self.fixtureSpendTxid, spendingTransaction: spend, - walletId: Self.fixtureWalletId)) - let identity = PersistentIdentity( - identityId: Self.fixtureIdentityId, balance: 5, network: .testnet) - identity.wallet = wallet - context.insert(identity) - context.insert(PersistentKeyword(keyword: "preserved", contractId: "contract")) - let lock = PersistentAssetLock( - outPointHex: String(repeating: "ab", count: 32) + ":0", - walletId: Self.fixtureWalletId, transactionBytes: Data([1, 2, 3]), - fundingTypeRaw: 4, identityIndexRaw: -1, amountDuffs: 100_000, statusRaw: 4) - lock.recipientIsExternal = true - context.insert(lock) - context.insert(PersistentTrackedMasternode( - networkRaw: Network.testnet.rawValue, proTxHash: Data(repeating: 7, count: 32), - label: "fixture", addedAt: 1, snapshotJSON: "{}")) - try context.save() - container = nil - - // A fixture has to be one self-contained file that opens read-only - // from any directory, so the write-ahead log is folded back in and - // the store left in rollback-journal mode, which also removes the - // -wal and -shm sidecars. - var database: OpaquePointer? + let registeredModels = last.models.map { ObjectIdentifier($0) } + let liveModels = DashModelContainer.modelTypes.map { ObjectIdentifier($0) } + XCTAssertEqual(Set(registeredModels).count, registeredModels.count) + XCTAssertEqual(registeredModels.count, liveModels.count) XCTAssertEqual( - sqlite3_open_v2(url.path, &database, SQLITE_OPEN_READWRITE, nil), SQLITE_OK) - XCTAssertEqual(sqlite3_exec(database, "PRAGMA journal_mode=DELETE", nil, nil, nil), SQLITE_OK) - sqlite3_close(database) - XCTAssertFalse( - FileManager.default.fileExists(atPath: url.path + "-wal"), - "the store still has a write-ahead log") + Set(registeredModels), Set(liveModels), + "The last version must register the live model types used by SDK callers") } /// The SQLite indexes of a store, one line per index: table, name and @@ -520,171 +447,43 @@ final class DashModelMigrationTests: XCTestCase { } } - @MainActor - func testV1StoreMigratesToV2AndAcceptsTrackedMasternodes() throws { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory( - at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - let storeURL = directory.appendingPathComponent("dash.store") - - let v1Schema = Schema(versionedSchema: DashSchemaV1.self) - let v1Configuration = ModelConfiguration( - "DashMigrationTest", - schema: v1Schema, - url: storeURL, - allowsSave: true, - cloudKitDatabase: .none) - var v1Container: ModelContainer? = try ModelContainer( - for: v1Schema, - configurations: [v1Configuration]) - // V1 registers the frozen graph (see `FrozenSchemas/`), - // so a row written into a V1 container is that type — inserting the - // live one would materialise as the frozen entity and then fail its - // cast on read. - v1Container?.mainContext.insert(DashSchemaV1.PersistentKeyword( - keyword: "preserved", - contractId: "contract")) - try v1Container?.mainContext.save() - v1Container = nil - - let v2Schema = Schema(versionedSchema: DashSchemaV2.self) - let v2Configuration = ModelConfiguration( - "DashMigrationTest", - schema: v2Schema, - url: storeURL, - allowsSave: true, - cloudKitDatabase: .none) - let migrated = try ModelContainer( - for: v2Schema, - migrationPlan: DashMigrationPlan.self, - configurations: [v2Configuration]) - - // V2 registers the same frozen copy, so the read side is frozen too. - let keywords = try migrated.mainContext.fetch( - FetchDescriptor()) - XCTAssertEqual(keywords.map(\.keyword), ["preserved"]) - - // V2 registers the frozen `PersistentTrackedMasternode`, so the row - // written into a V2 container is that type too. - migrated.mainContext.insert(DashSchemaV2.PersistentTrackedMasternode( - networkRaw: Network.testnet.rawValue, - proTxHash: Data(repeating: 7, count: 32), - label: "new in V2", - addedAt: 1, - snapshotJSON: "{}")) - try migrated.mainContext.save() + func testV3AddsTrackedMasternodesAndBalanceMetadataToTheBaselineEntitySet() { XCTAssertEqual( - try migrated.mainContext.fetchCount( - FetchDescriptor()), - 1) + Set(Schema(versionedSchema: DashSchemaV3.self).entities.map(\.name)) + .subtracting(Schema(versionedSchema: DashSchemaV1.self).entities.map(\.name)), + ["PersistentTrackedMasternode", "PersistentIdentityBalanceMetadata"]) } - /// The V3 -> V4 stage: a V3 store must migrate to V4 and read back with - /// the sweep columns backfilled to their "nothing swept yet" values. - /// Both versions register frozen graphs, so the row goes in as V3's - /// copy and comes out as V4's, which is the whole point of the freeze: - /// the same entity, one property wider. A pending-input row rides along - /// so the tombstone index V4 adds is exercised by the migration too. @MainActor - func testV3StoreMigratesToV4AndBackfillsTheSweepColumns() throws { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory( - at: directory, withIntermediateDirectories: true) + func testV1BalanceGetsNoWatermarkUntilOneIsPersistedInLiveV3() throws { + let (directory, url) = try copyFixture(Self.fixtures[0]) defer { try? FileManager.default.removeItem(at: directory) } - let storeURL = directory.appendingPathComponent("dash.store") - - let walletId = Data(repeating: 0x5A, count: 32) - - let v3Schema = Schema(versionedSchema: DashSchemaV3.self) - let v3Configuration = ModelConfiguration( - "DashSweepMigrationTest", - schema: v3Schema, - url: storeURL, - allowsSave: true, - cloudKitDatabase: .none) - var v3Container: ModelContainer? = try ModelContainer( - for: v3Schema, - configurations: [v3Configuration]) - v3Container?.mainContext.insert(DashSchemaV1.PersistentWallet( - walletId: walletId, - network: .testnet)) - v3Container?.mainContext.insert(DashSchemaV1.PersistentPendingInput( - outpoint: Data(repeating: 0x11, count: 36), - inputIndex: 0, - spendingTxid: Data(repeating: 0x22, count: 32), - spendingTransaction: nil, - walletId: walletId)) - // A transaction with one spent output: the two other widened - // models, so the migration is exercised on every column V4 adds. - let v3Funding = DashSchemaV1.PersistentTransaction( - txid: Data(repeating: 0x33, count: 32), - transactionData: Data([0x03, 0x00]), - context: 2, - blockHeight: 100) - v3Container?.mainContext.insert(v3Funding) - let v3Coin = DashSchemaV1.PersistentTxo( - transaction: v3Funding, - vout: 0, - amount: 1_000, - address: "yV3Coin", - height: 100) - v3Coin.walletId = walletId - v3Coin.isSpent = true - v3Container?.mainContext.insert(v3Coin) - try v3Container?.mainContext.save() - v3Container = nil - - let v4Schema = Schema(versionedSchema: DashSchemaV4.self) - let v4Configuration = ModelConfiguration( - "DashSweepMigrationTest", - schema: v4Schema, - url: storeURL, - allowsSave: true, - cloudKitDatabase: .none) - let migrated = try ModelContainer( - for: v4Schema, - migrationPlan: DashMigrationPlan.self, - configurations: [v4Configuration]) - - // V4 registers its own frozen graph, so the read side is those - // types: the live models are schema V5's. - let wallets = try migrated.mainContext.fetch( - FetchDescriptor()) - XCTAssertEqual(wallets.count, 1, "the V3 row must survive the migration") - XCTAssertNil( - wallets.first?.lastAppliedChainLockHeight, - "a wallet migrated from V3 has no chainlock boundary yet, so no " - + "tombstone it later takes can be collected on a fabricated one") - let pending = try migrated.mainContext.fetch( - FetchDescriptor()) - XCTAssertEqual(pending.count, 1, "the V3 pending row must survive the migration") - XCTAssertEqual(pending.first?.isSweptTombstone, false, "backfilled as an ordinary claim") - XCTAssertNil(pending.first?.winnerMinedHeight, "and unstamped") - let coins = try migrated.mainContext.fetch( - FetchDescriptor()) - XCTAssertEqual(coins.count, 1, "the V3 TXO row must survive the migration") - XCTAssertEqual(coins.first?.isSpent, true, "its spent flag is carried as stored") - XCTAssertNil( - coins.first?.supersededByTxid, - "a coin migrated from V3 was never held by a sweep — the stamp backfills to nil, " - + "so the release and re-delivery rules see an ordinary spent coin") - let transactions = try migrated.mainContext.fetch( - FetchDescriptor()) - XCTAssertEqual(transactions.map(\.context), [2], "the V3 transaction row survives unchanged") + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + let context = container.mainContext + XCTAssertEqual(try context.fetch(FetchDescriptor()).first?.balance, 5) + XCTAssertEqual(try context.fetchCount(FetchDescriptor()), 0) + context.insert(PersistentIdentityBalanceMetadata( + networkRaw: Network.testnet.rawValue, walletId: Self.fixtureWalletId, + identityId: Self.fixtureIdentityId, platformHeight: 42, coreHeight: 7, + timestampMillis: 123)) + try context.save() + } + let reopened = try DashModelContainer.create(url: url) + let metadata = try XCTUnwrap(reopened.mainContext.fetch( + FetchDescriptor()).first) + XCTAssertEqual(metadata.identityId, Self.fixtureIdentityId) + XCTAssertEqual(metadata.walletId, Self.fixtureWalletId) + XCTAssertEqual(metadata.platformHeight, 42) + XCTAssertEqual(metadata.coreHeight, 7) + XCTAssertEqual(metadata.timestampMillis, 123) + XCTAssertEqual(try reopened.mainContext.fetch(FetchDescriptor()).first?.balance, 5) } - /// The whole chain from the oldest registered version, on the models this - /// V4 actually widens: a V1 store carrying a wallet, a transaction - /// and a coin must arrive at V4 with every row intact and the V4 columns - /// at their backfill values. Every version here registers a frozen - /// graph, so the rows go in as V1's copies and come out as V4's: the - /// property the freeze exists to guarantee, pinned here where it - /// matters most. + /// The accepted V1 graph predates key limits. Its keys must arrive in + /// live V3 unlimited, then accept limits through the public accessors. @MainActor - func testV1StoreWithWalletTransactionAndCoinMigratesToV4() throws { + func testV1StoreMigratesToV3AndBackfillsTheKeyLimitColumns() throws { let directory = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory( @@ -692,12 +491,11 @@ final class DashModelMigrationTests: XCTestCase { defer { try? FileManager.default.removeItem(at: directory) } let storeURL = directory.appendingPathComponent("dash.store") - let walletId = Data(repeating: 0x1A, count: 32) - let txid = Data(repeating: 0x1B, count: 32) + let identityId = "FixtureIdentityBase58" let v1Schema = Schema(versionedSchema: DashSchemaV1.self) let v1Configuration = ModelConfiguration( - "DashChainMigrationTest", + "DashKeyLimitsMigrationTest", schema: v1Schema, url: storeURL, allowsSave: true, @@ -705,116 +503,35 @@ final class DashModelMigrationTests: XCTestCase { var v1Container: ModelContainer? = try ModelContainer( for: v1Schema, configurations: [v1Configuration]) - v1Container?.mainContext.insert(DashSchemaV1.PersistentWallet( - walletId: walletId, - network: .testnet)) - let v1Funding = DashSchemaV1.PersistentTransaction( - txid: txid, - transactionData: Data([0x03, 0x00]), - context: 3, - blockHeight: 50, - netAmount: 2_000) - v1Container?.mainContext.insert(v1Funding) - let v1Coin = DashSchemaV1.PersistentTxo( - transaction: v1Funding, - vout: 1, - amount: 2_000, - address: "yV1Coin", - height: 50) - v1Coin.walletId = walletId - v1Container?.mainContext.insert(v1Coin) - try v1Container?.mainContext.save() - v1Container = nil - - let v4Schema = Schema(versionedSchema: DashSchemaV4.self) - let v4Configuration = ModelConfiguration( - "DashChainMigrationTest", - schema: v4Schema, - url: storeURL, - allowsSave: true, - cloudKitDatabase: .none) - let migrated = try ModelContainer( - for: v4Schema, - migrationPlan: DashMigrationPlan.self, - configurations: [v4Configuration]) - - let wallets = try migrated.mainContext.fetch( - FetchDescriptor()) - XCTAssertEqual(wallets.map(\.walletId), [walletId]) - XCTAssertNil(wallets.first?.lastAppliedChainLockHeight) - let transactions = try migrated.mainContext.fetch( - FetchDescriptor()) - XCTAssertEqual(transactions.map(\.txid), [txid]) - XCTAssertEqual(transactions.first?.context, 3) - XCTAssertEqual(transactions.first?.netAmount, 2_000) - let coins = try migrated.mainContext.fetch( - FetchDescriptor()) - XCTAssertEqual(coins.count, 1) - XCTAssertEqual(coins.first?.vout, 1) - XCTAssertEqual(coins.first?.amount, 2_000) - XCTAssertEqual(coins.first?.walletId, walletId) - XCTAssertEqual(coins.first?.isSpent, false) - XCTAssertNil(coins.first?.supersededByTxid) - XCTAssertEqual( - coins.first?.transaction?.txid, txid, - "the coin's relationship to its funding transaction survives three stages") - } - - /// The V4 -> V5 stage: a V4 store must migrate to V5 and read back with - /// the key usage-limit columns backfilled to "no limits", which is - /// exactly what a version 0 key is. V4 registers a frozen graph, so the - /// row goes in as V4's copy and comes out as the live one: the same - /// entity, three properties wider. - @MainActor - func testV4StoreMigratesToV5AndBackfillsTheKeyLimitColumns() throws { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory( - at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - let storeURL = directory.appendingPathComponent("dash.store") - - let identityId = "FixtureIdentityBase58" - - let v4Schema = Schema(versionedSchema: DashSchemaV4.self) - let v4Configuration = ModelConfiguration( - "DashKeyLimitsMigrationTest", - schema: v4Schema, - url: storeURL, - allowsSave: true, - cloudKitDatabase: .none) - var v4Container: ModelContainer? = try ModelContainer( - for: v4Schema, - configurations: [v4Configuration]) - v4Container?.mainContext.insert(DashSchemaV4.PersistentPublicKey( + v1Container?.mainContext.insert(DashSchemaV1.PersistentPublicKey( keyId: 3, purpose: .authentication, securityLevel: .high, keyType: .ecdsaSecp256k1, publicKeyData: Data(repeating: 0x02, count: 33), identityId: identityId)) - try v4Container?.mainContext.save() - v4Container = nil + try v1Container?.mainContext.save() + v1Container = nil - let v5Schema = Schema(versionedSchema: DashSchemaV5.self) - let v5Configuration = ModelConfiguration( + let v2Schema = Schema(versionedSchema: DashSchemaV3.self) + let v2Configuration = ModelConfiguration( "DashKeyLimitsMigrationTest", - schema: v5Schema, + schema: v2Schema, url: storeURL, allowsSave: true, cloudKitDatabase: .none) let migrated = try ModelContainer( - for: v5Schema, - migrationPlan: DashMigrationPlan.self, - configurations: [v5Configuration]) + for: v2Schema, + migrationPlan: DashAcceptedV1MigrationPlan.self, + configurations: [v2Configuration]) let keys = try migrated.mainContext.fetch(FetchDescriptor()) - XCTAssertEqual(keys.count, 1, "the V4 key row must survive the migration") + XCTAssertEqual(keys.count, 1, "the V1 key row must survive the migration") let key = try XCTUnwrap(keys.first) XCTAssertEqual(key.keyId, 3) XCTAssertEqual(key.identityId, identityId) XCTAssertEqual(key.publicKeyData, Data(repeating: 0x02, count: 33)) - XCTAssertNil(key.totalBudget, "a key migrated from V4 carries no budget") + XCTAssertNil(key.totalBudget, "a key migrated from V1 carries no budget") XCTAssertNil(key.expiresAt, "nor an expiry; together, that is a version 0 key") XCTAssertFalse(key.hasLimits) @@ -830,12 +547,10 @@ final class DashModelMigrationTests: XCTestCase { XCTAssertTrue(reread.hasLimits) } - /// The bounds half of the same stage: a V4 store's key rows arrive with - /// `contractBoundsKind` backfilled to NULL, which the model reads as - /// "legacy row, infer the variant the way V4 did", and the column is - /// writable on the migrated row. + /// Legacy contract bounds keep their inferred variant after V1 -> V3, + /// and the new discriminator can then represent contract groups. @MainActor - func testV4StoreMigratesToV5AndBackfillsTheContractBoundsKind() throws { + func testV1StoreMigratesToV3AndBackfillsTheContractBoundsKind() throws { let directory = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory( @@ -845,17 +560,17 @@ final class DashModelMigrationTests: XCTestCase { let contractId = Data(repeating: 0x7C, count: 32) - let v4Schema = Schema(versionedSchema: DashSchemaV4.self) - let v4Configuration = ModelConfiguration( + let v1Schema = Schema(versionedSchema: DashSchemaV1.self) + let v1Configuration = ModelConfiguration( "DashContractBoundsKindMigrationTest", - schema: v4Schema, + schema: v1Schema, url: storeURL, allowsSave: true, cloudKitDatabase: .none) - var v4Container: ModelContainer? = try ModelContainer( - for: v4Schema, - configurations: [v4Configuration]) - v4Container?.mainContext.insert(DashSchemaV4.PersistentPublicKey( + var v1Container: ModelContainer? = try ModelContainer( + for: v1Schema, + configurations: [v1Configuration]) + v1Container?.mainContext.insert(DashSchemaV1.PersistentPublicKey( keyId: 3, purpose: .authentication, securityLevel: .high, @@ -864,7 +579,7 @@ final class DashModelMigrationTests: XCTestCase { contractBounds: [contractId], contractBoundsDocumentTypeName: "contactRequest", identityId: "legacyDocTypeKey")) - v4Container?.mainContext.insert(DashSchemaV4.PersistentPublicKey( + v1Container?.mainContext.insert(DashSchemaV1.PersistentPublicKey( keyId: 4, purpose: .authentication, securityLevel: .high, @@ -872,24 +587,24 @@ final class DashModelMigrationTests: XCTestCase { publicKeyData: Data(repeating: 0x03, count: 33), contractBounds: [contractId], identityId: "legacyContractKey")) - try v4Container?.mainContext.save() - v4Container = nil + try v1Container?.mainContext.save() + v1Container = nil - let v5Schema = Schema(versionedSchema: DashSchemaV5.self) - let v5Configuration = ModelConfiguration( + let v2Schema = Schema(versionedSchema: DashSchemaV3.self) + let v2Configuration = ModelConfiguration( "DashContractBoundsKindMigrationTest", - schema: v5Schema, + schema: v2Schema, url: storeURL, allowsSave: true, cloudKitDatabase: .none) let migrated = try ModelContainer( - for: v5Schema, - migrationPlan: DashMigrationPlan.self, - configurations: [v5Configuration]) + for: v2Schema, + migrationPlan: DashAcceptedV1MigrationPlan.self, + configurations: [v2Configuration]) let rows = try migrated.mainContext.fetch( FetchDescriptor(sortBy: [SortDescriptor(\.keyId)])) - XCTAssertEqual(rows.map(\.keyId), [3, 4], "both V4 rows survive the migration") + XCTAssertEqual(rows.map(\.keyId), [3, 4], "both V1 rows survive the migration") XCTAssertEqual( rows.map(\.contractBoundsKind), [nil, nil], "a row written before the column backfills NULL") @@ -909,184 +624,232 @@ final class DashModelMigrationTests: XCTestCase { XCTAssertEqual(reread[1].effectiveContractBoundsKind, 3) } - /// What makes the V4 -> V5 stage lightweight: the two versions name the - /// same entity set, and V5 only widens `PersistentPublicKey`. Also pins - /// that V4's frozen copy does NOT carry the three columns: one that came - /// back would silently change V4's checksum and strand every store the - /// V4 build wrote. - func testV4AndV5NameTheSameEntitySet() throws { - let v4 = Schema(versionedSchema: DashSchemaV4.self) - let v5 = Schema(versionedSchema: DashSchemaV5.self) - XCTAssertEqual( - v4.entities.map(\.name).sorted(), - v5.entities.map(\.name).sorted()) - - let key = try XCTUnwrap(v5.entities.first { $0.name == "PersistentPublicKey" }) - XCTAssertNotNil(key.attributesByName["totalBudget"]) - XCTAssertNotNil(key.attributesByName["expiresAt"]) - XCTAssertNotNil(key.attributesByName["contractBoundsKind"]) - - let frozenKey = try XCTUnwrap(v4.entities.first { $0.name == "PersistentPublicKey" }) - XCTAssertNil(frozenKey.attributesByName["totalBudget"]) - XCTAssertNil(frozenKey.attributesByName["expiresAt"]) - XCTAssertNil( - frozenKey.attributesByName["contractBoundsKind"], - "V4's frozen copy predates the column") - - // V5 widens that entity and nothing else. - for name in v5.entities.map(\.name) where name != "PersistentPublicKey" { - let live = try XCTUnwrap(v5.entities.first { $0.name == name }) - let frozen = try XCTUnwrap(v4.entities.first { $0.name == name }) - XCTAssertEqual( - live.attributesByName.keys.sorted(), - frozen.attributesByName.keys.sorted(), - "V5 adds no column to \(name)") + func testV3AddsKeyColumnsWithoutChangingTheFrozenBaseline() throws { + let baseline = Schema(versionedSchema: DashSchemaV1.self) + let live = Schema(versionedSchema: DashSchemaV3.self) + let oldKey = try XCTUnwrap(baseline.entities.first { $0.name == "PersistentPublicKey" }) + let newKey = try XCTUnwrap(live.entities.first { $0.name == "PersistentPublicKey" }) + for column in ["totalBudget", "expiresAt", "contractBoundsKind"] { + XCTAssertNil(oldKey.attributesByName[column]) + XCTAssertNotNil(newKey.attributesByName[column]) } } - /// What makes the V3 -> V4 stage lightweight: the two versions name the - /// same entity set, and V4 only widens three of them. Also pins that - /// `PersistentTransaction` is NOT one of the three — a swept row is - /// deleted outright, so the transaction entity carries no sweep marker, - /// and one that came back would silently change V4's checksum. - func testV3AndV4NameTheSameEntitySet() throws { - let v3 = Schema(versionedSchema: DashSchemaV3.self) - let v4 = Schema(versionedSchema: DashSchemaV4.self) - XCTAssertEqual( - v3.entities.map(\.name).sorted(), - v4.entities.map(\.name).sorted()) - - let transaction = try XCTUnwrap(v4.entities.first { $0.name == "PersistentTransaction" }) - let frozenTransaction = try XCTUnwrap(v3.entities.first { $0.name == "PersistentTransaction" }) - XCTAssertEqual( - transaction.attributesByName.keys.sorted(), - frozenTransaction.attributesByName.keys.sorted(), - "V4 adds no column to PersistentTransaction") - let txo = try XCTUnwrap(v4.entities.first { $0.name == "PersistentTxo" }) - XCTAssertNotNil(txo.attributesByName["supersededByTxid"]) - let pendingInput = try XCTUnwrap(v4.entities.first { $0.name == "PersistentPendingInput" }) - XCTAssertNotNil(pendingInput.attributesByName["isSweptTombstone"]) - XCTAssertNotNil(pendingInput.attributesByName["winnerMinedHeight"]) - let wallet = try XCTUnwrap(v4.entities.first { $0.name == "PersistentWallet" }) - XCTAssertNotNil(wallet.attributesByName["lastAppliedChainLockHeight"]) - - // And V3's frozen copies do not carry them. - let frozenTxo = try XCTUnwrap(v3.entities.first { $0.name == "PersistentTxo" }) - XCTAssertNil(frozenTxo.attributesByName["supersededByTxid"]) + /// Synthetic historical evidence generated from the reconstructed source; + /// this test never accesses a device database or keychain. + @MainActor + func testCaptureHistoricalV2Fixture() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("historical-v2.store") + try autoreleasepool { + let schema = Schema(versionedSchema: DashSchemaV2.self) + let container = try ModelContainer(for: schema, configurations: [ + ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none) + ]) + let context = container.mainContext + let wallet = DashSchemaV2.PersistentWallet( + walletId: Data(repeating: 0x31, count: 32), network: .testnet, name: "fixture wallet", + syncedHeight: 120) + context.insert(wallet) + let account = DashSchemaV2.PersistentAccount( + wallet: wallet, accountType: 0, accountIndex: 0, accountTypeName: "standard") + context.insert(account) + let address = DashSchemaV2.PersistentCoreAddress( + address: "yFixtureAddress", poolTypeTag: 0, addressIndex: 0, derivationPath: "m/0") + address.account = account + context.insert(address) + let funding = DashSchemaV2.PersistentTransaction( + txid: Data(repeating: 0x34, count: 32), transactionData: Data([3, 0]), context: 2, + blockHeight: 100) + let spend = DashSchemaV2.PersistentTransaction( + txid: Data(repeating: 0x32, count: 32), transactionData: Data([3, 0]), context: 2, + blockHeight: 110) + context.insert(funding) + context.insert(spend) + account.involvedTransactions = [funding, spend] + let txo = DashSchemaV2.PersistentTxo( + transaction: funding, vout: 0, amount: 1_000, address: "yFixtureAddress", + height: 100) + txo.walletId = Data(repeating: 0x31, count: 32) + txo.isSpent = true + txo.spendingTransaction = spend + txo.coreAddress = address + txo.account = account + context.insert(txo) + context.insert(DashSchemaV2.PersistentPendingInput( + outpoint: Data(repeating: 0x11, count: 36), inputIndex: 0, + spendingTxid: Data(repeating: 0x32, count: 32), spendingTransaction: spend, + walletId: Data(repeating: 0x31, count: 32))) + let identity = DashSchemaV2.PersistentIdentity( + identityId: Data(repeating: 0x35, count: 32), balance: 5, network: .testnet) + identity.wallet = wallet + context.insert(identity) + let key = DashSchemaV2.PersistentPublicKey( + keyId: 3, purpose: .authentication, securityLevel: .high, + keyType: .ecdsaSecp256k1, publicKeyData: Data(repeating: 0x02, count: 33), + identityId: identity.identityIdString) + key.identity = identity + context.insert(key) + context.insert(DashSchemaV2.PersistentKeyword(keyword: "preserved", contractId: "contract")) + let lock = DashSchemaV2.PersistentAssetLock( + outPointHex: String(repeating: "ab", count: 32) + ":0", + walletId: Data(repeating: 0x31, count: 32), transactionBytes: Data([1, 2, 3]), + fundingTypeRaw: 4, identityIndexRaw: -1, amountDuffs: 100_000, statusRaw: 4) + context.insert(lock) + context.insert(DashSchemaV2.PersistentTrackedMasternode( + networkRaw: Network.testnet.rawValue, proTxHash: Data(repeating: 7, count: 32), + label: "fixture", addedAt: 1, snapshotJSON: "{}")) + try context.save() + } + try DashLegacyStoreSQLite.checkpoint(url) + let metadata = try DashSchemaFixtureSupport.describeStore(at: url, version: Schema.Version(2, 0, 0)) + XCTAssertEqual(metadata.entity_hashes.count, 35) + XCTAssertEqual(metadata.model_checksum, "RrRj/iNbS9izgLQvNb2APed4iwaR7pftEE2+4tea7K8=") + let attachment = XCTAttachment(contentsOfFile: url) + attachment.name = "historical-v2.store" + attachment.lifetime = .keepAlways + add(attachment) } - /// Guards the freeze itself: `DashSchemaV1.PersistentAssetLock` only - /// keeps V1/V2 stores openable if SwiftData names its entity - /// "PersistentAssetLock" — i.e. from the UNQUALIFIED type name. If a - /// future SwiftData release qualified nested types instead, the frozen - /// copy would silently register a *different* entity and the V2 -> V3 - /// stage would become a drop+create rather than an add-column, so this - /// has to fail loudly rather than in the field. - func testFrozenAssetLockKeepsTheLiveEntityName() throws { - for schema in [ - Schema(versionedSchema: DashSchemaV1.self), - Schema(versionedSchema: DashSchemaV2.self), - Schema(versionedSchema: DashSchemaV3.self), - Schema(versionedSchema: DashSchemaV4.self), - Schema(versionedSchema: DashSchemaV5.self) - ] { - let names = schema.entities.map(\.name) - XCTAssertTrue( - names.contains("PersistentAssetLock"), - "expected an entity named PersistentAssetLock, got \(names.sorted())") + @MainActor + func testAcceptedV1PreservesAllThirteenFieldsMissingFromHistoricalV2() throws { + let (directory, url) = try copyFixture(Self.fixtures[0]) + defer { try? FileManager.default.removeItem(at: directory) } + try autoreleasepool { + let schema = Schema(versionedSchema: DashSchemaV1.self) + let container = try ModelContainer(for: schema, configurations: [ + ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none) + ]) + let context = container.mainContext + let type = DashSchemaV1.PersistentDocumentType( + contractId: Data([41]), name: "retained", schemaJSON: Data(), propertiesJSON: Data()) + type.indexOnly = true + let index = DashSchemaV1.PersistentIndex( + contractId: Data([41]), documentTypeName: "retained", name: "retained", properties: ["name"]) + index.documentType = type + index.countable = "countableAllowingOffset" + index.summable = "amount" + index.averageable = "amount" + index.terminal = "$ownerId" + index.timeRangeJSON = Data("{\"on\":\"createdAt\"}".utf8) + index.rangeCountable = true + index.rangeSummable = true + index.rangeAverageable = true + index.rankedCountable = true + index.rankedSummable = true + index.rankedAverageable = true + index.preallocated = true + context.insert(type) + context.insert(index) + try context.save() } - - // V2 and V3 differ ONLY in that one entity's shape, never in which - // entities exist — that is what makes the stage lightweight. - XCTAssertEqual( - Schema(versionedSchema: DashSchemaV2.self).entities.map(\.name).sorted(), - Schema(versionedSchema: DashSchemaV3.self).entities.map(\.name).sorted()) - - // V1 -> V2 remains exactly "add PersistentTrackedMasternode". - XCTAssertEqual( - Set(Schema(versionedSchema: DashSchemaV2.self).entities.map(\.name)) - .subtracting(Schema(versionedSchema: DashSchemaV1.self).entities.map(\.name)), - ["PersistentTrackedMasternode"]) + let before = directory.appendingPathComponent("before.store") + try DashLegacyStoreSQLite.copy(from: url, to: before) + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + let context = container.mainContext + let type = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + let index = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + XCTAssertTrue(type.indexOnly) + XCTAssertEqual(index.countable, "countableAllowingOffset") + XCTAssertEqual(index.summable, "amount") + XCTAssertEqual(index.averageable, "amount") + XCTAssertEqual(index.terminal, "$ownerId") + XCTAssertEqual(index.timeRangeJSON, Data("{\"on\":\"createdAt\"}".utf8)) + XCTAssertEqual([index.rangeCountable, index.rangeSummable, index.rangeAverageable, + index.rankedCountable, index.rankedSummable, index.rankedAverageable, index.preallocated], + Array(repeating: true, count: 7)) + XCTAssertEqual(index.documentType?.persistentModelID, type.persistentModelID) + index.name = "writable" + try context.save() + index.name = "retained" + try context.save() + } + try DashLegacyStoreSQLite.validatePreservation(from: before, to: url) } - /// The regression this whole freeze exists for: a store written by the - /// schema-V2 definition of `PersistentAssetLock` (no `recipientIsExternal`) - /// must still open once the live model has grown that property. - /// - /// The source store is created from `DashSchemaV2`, which references the - /// frozen `DashSchemaV1.PersistentAssetLock` — not the live type — so - /// this exercises the real cross-version path rather than trivially - /// round-tripping today's model. + /// Local diagnostic only. The input never enters test resources, attachments + /// or logs, and all migration/writes happen on a disposable copy. @MainActor - func testV2AssetLockStoreMigratesToV3AndBackfillsRecipientIsExternal() throws { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory( - at: directory, withIntermediateDirectories: true) + func testPrivateHistoricalStoreMigrationWhenExplicitlyProvided() throws { + let path = ProcessInfo.processInfo.environment["DASH_PRIVATE_MIGRATION_STORE"] + try XCTSkipIf(path == nil, "No private migration diagnostic input was explicitly provided") + let source = URL(fileURLWithPath: try XCTUnwrap(path)) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) defer { try? FileManager.default.removeItem(at: directory) } - let storeURL = directory.appendingPathComponent("dash.store") - - let outPointHex = String(repeating: "ab", count: 32) + ":0" - let walletId = Data(repeating: 3, count: 32) - - let v2Schema = Schema(versionedSchema: DashSchemaV2.self) - let v2Configuration = ModelConfiguration( - "DashAssetLockMigrationTest", - schema: v2Schema, - url: storeURL, - allowsSave: true, - cloudKitDatabase: .none) - var v2Container: ModelContainer? = try ModelContainer( - for: v2Schema, - migrationPlan: DashMigrationPlan.self, - configurations: [v2Configuration]) - let legacyRow = DashSchemaV1.PersistentAssetLock( - outPointHex: outPointHex, - walletId: walletId, - transactionBytes: Data([1, 2, 3]), - fundingTypeRaw: 4, - identityIndexRaw: -1, - accountIndexRaw: 0, - amountDuffs: 100_000, - statusRaw: 4) - legacyRow.recipientPlatformAddressHash = Data(repeating: 9, count: 20) - legacyRow.recipientPlatformAddressType = 0 - v2Container?.mainContext.insert(legacyRow) - try v2Container?.mainContext.save() - v2Container = nil - - // Reopen exactly the way `DashModelContainer.create` does. - let v3Schema = Schema(versionedSchema: DashSchemaV3.self) - let v3Configuration = ModelConfiguration( - "DashAssetLockMigrationTest", - schema: v3Schema, - url: storeURL, - allowsSave: true, - cloudKitDatabase: .none) - let migrated = try ModelContainer( - for: v3Schema, - migrationPlan: DashMigrationPlan.self, - configurations: [v3Configuration]) - - // V3 registers the frozen `DashSchemaV3.PersistentAssetLock`, the - // shape that gained the column, so the read side is that type. - let locks = try migrated.mainContext.fetch( - FetchDescriptor()) - XCTAssertEqual(locks.count, 1) - let lock = try XCTUnwrap(locks.first) - XCTAssertEqual(lock.outPointHex, outPointHex) - XCTAssertEqual(lock.walletId, walletId) - XCTAssertEqual(lock.recipientPlatformAddressHash, Data(repeating: 9, count: 20)) - XCTAssertEqual(lock.recipientPlatformAddressType, 0) - // Backfilled NULL — the documented "treat as own" signal. - XCTAssertNil(lock.recipientIsExternal) + let before = directory.appendingPathComponent("before.store") + let migrated = directory.appendingPathComponent("migrated.store") + try DashLegacyStoreSQLite.copy(from: source, to: before) + try DashLegacyStoreSQLite.copy(from: before, to: migrated) + try autoreleasepool { _ = try DashModelContainer.create(url: migrated) } + try DashLegacyStoreSQLite.validatePreservation(from: before, to: migrated) + let walletId: Data = try autoreleasepool { + let container = try DashModelContainer.create(url: migrated) + let context = container.mainContext + let wallet = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + wallet.name = "temporary migration write verification" + try context.save() + return wallet.walletId + } + try autoreleasepool { + let container = try DashModelContainer.create(url: migrated) + let wallets = try container.mainContext.fetch(FetchDescriptor()) + XCTAssertTrue(wallets.contains { + $0.walletId == walletId && $0.name == "temporary migration write verification" + }, "A write to the diagnostic copy must survive reopening") + } + } - // And the new column is writable on the migrated row. - lock.recipientIsExternal = true - try migrated.mainContext.save() - XCTAssertEqual( - try migrated.mainContext.fetch(FetchDescriptor()) - .first?.recipientIsExternal, - true) + @MainActor + func testHistoricalV2AddsDefaultsAndPreservesDocumentRelationships() throws { + let (directory, url) = try copyFixture(Self.fixtures[1]) + defer { try? FileManager.default.removeItem(at: directory) } + try autoreleasepool { + let schema = Schema(versionedSchema: DashSchemaV2.self) + let container = try ModelContainer(for: schema, configurations: [ + ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none) + ]) + let type = DashSchemaV2.PersistentDocumentType( + contractId: Data([42]), name: "historical", schemaJSON: Data([123, 125]), propertiesJSON: Data([123, 125])) + let index = DashSchemaV2.PersistentIndex( + contractId: Data([42]), documentTypeName: "historical", name: "original", properties: ["name"]) + index.documentType = type + container.mainContext.insert(type) + container.mainContext.insert(index) + try container.mainContext.save() + } + let before = directory.appendingPathComponent("before.store") + try DashLegacyStoreSQLite.copy(from: url, to: before) + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + let context = container.mainContext + let type = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + let index = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + XCTAssertFalse(type.indexOnly) + XCTAssertNil(index.countable) + XCTAssertNil(index.summable) + XCTAssertNil(index.averageable) + XCTAssertNil(index.terminal) + XCTAssertNil(index.timeRangeJSON) + XCTAssertEqual([index.rangeCountable, index.rangeSummable, index.rangeAverageable, + index.rankedCountable, index.rankedSummable, index.rankedAverageable, index.preallocated], + Array(repeating: false, count: 7)) + XCTAssertEqual(index.documentType?.persistentModelID, type.persistentModelID) + XCTAssertEqual(type.indices?.count, 1) + XCTAssertEqual(index.properties, ["name"]) + } + try DashLegacyStoreSQLite.validatePreservation(from: before, to: url) + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + let index = try XCTUnwrap(container.mainContext.fetch(FetchDescriptor()).first) + index.preallocated = true + try container.mainContext.save() + } + let reopened = try DashModelContainer.create(url: url) + XCTAssertTrue(try XCTUnwrap(reopened.mainContext.fetch(FetchDescriptor()).first).preallocated) } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaRegistry.generated.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaRegistry.generated.swift new file mode 100644 index 00000000000..192033ab627 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaRegistry.generated.swift @@ -0,0 +1,8 @@ +// Generated by scripts/freeze_schema_models.py. Do not edit. +@testable import SwiftDashSDK + +enum DashReleasedSchemaRegistry { + static let fixtures: [DashReleasedSchemaFixture] = [ + + ] +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift new file mode 100644 index 00000000000..d283b5a20c0 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift @@ -0,0 +1,123 @@ +import Foundation +import SwiftData +import XCTest + +@testable import SwiftDashSDK + +struct DashReleasedSchemaFixture: Sendable { + let version: any VersionedSchema.Type + let resourceName: String +} + +/// Publication adds archival snapshots, never additional runtime stages. These +/// checks deliberately fail if development changed a published version in place: +/// move the old version onto its snapshot, add a live version and a migration. +final class DashReleasedSchemaTests: XCTestCase { + override class func setUp() { + super.setUp() + _ = DashModelContainer.schema + } + + private func source(_ fixture: DashReleasedSchemaFixture) throws -> URL { + try XCTUnwrap(Bundle.module.url( + forResource: fixture.resourceName, withExtension: "store", + subdirectory: "Fixtures/SchemaStores/releases")) + } + + @MainActor + func testPublishedSnapshotsAndRuntimeVersionsMatchCapturedStores() throws { + try XCTSkipIf( + DashReleasedSchemaRegistry.fixtures.isEmpty, + "No App Store schema snapshots have been registered yet") + for fixture in DashReleasedSchemaRegistry.fixtures { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let version = fixture.version.versionIdentifier + let expected = try DashSchemaFixtureSupport.describeStore(at: source(fixture), version: version) + let registered = try XCTUnwrap(DashMigrationPlan.schemas.first { + $0.versionIdentifier == version + }, "A published version is missing from the runtime migration plan") + for (name, type) in [("snapshot", fixture.version), ("runtime", registered)] { + let url = directory.appendingPathComponent("\(name).store") + try autoreleasepool { + let schema = Schema(versionedSchema: type) + _ = try ModelContainer(for: schema, configurations: [ + ModelConfiguration(name, schema: schema, url: url, cloudKitDatabase: .none) + ]) + } + XCTAssertEqual( + try DashSchemaFixtureSupport.describeStore(at: url, version: version), expected, + "\(name) changed published \(expected.schema_version); retire it onto its snapshot and introduce a new live version") + } + } + } + + @MainActor + func testPublishedStoresMigrateAndRemainWritableThroughLiveTypes() throws { + try XCTSkipIf( + DashReleasedSchemaRegistry.fixtures.isEmpty, + "No App Store schema snapshots have been registered yet") + for fixture in DashReleasedSchemaRegistry.fixtures { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("migrated.store") + try FileManager.default.copyItem(at: source(fixture), to: url) + let container = try DashModelContainer.create(url: url) + let context = container.mainContext + let wallet = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + XCTAssertEqual(wallet.walletId, Data(repeating: 0x31, count: 32)) + XCTAssertEqual(wallet.name, "fixture wallet") + XCTAssertEqual(wallet.accounts.count, 1) + XCTAssertEqual(wallet.accounts.first?.coreAddresses.first?.address, "yFixtureAddress") + let txo = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + XCTAssertEqual(txo.amount, 1_000) + XCTAssertEqual(txo.transaction?.txid, Data(repeating: 0x34, count: 32)) + XCTAssertEqual(txo.spendingTransaction?.txid, Data(repeating: 0x32, count: 32)) + XCTAssertEqual(txo.account?.wallet.walletId, wallet.walletId) + XCTAssertEqual(try context.fetchCount(FetchDescriptor()), 1) + XCTAssertEqual(try context.fetchCount(FetchDescriptor()), 1) + XCTAssertEqual(try context.fetch(FetchDescriptor()).first?.keyword, "preserved") + XCTAssertEqual(try context.fetch(FetchDescriptor()).first?.balance, 5) + let key = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + XCTAssertEqual(key.keyId, 3) + XCTAssertEqual(key.totalBudgetCredits, 100_000) + XCTAssertEqual(key.expiresAtMillis, 1_800_000_000_000) + XCTAssertEqual(key.identity?.identityId, Data(repeating: 0x35, count: 32)) + let lock = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + XCTAssertEqual(lock.amountDuffs, 100_000) + XCTAssertEqual(lock.recipientIsExternal, true) + XCTAssertEqual(try context.fetchCount(FetchDescriptor()), 2) + wallet.name = "migrated and writable" + try context.save() + XCTAssertEqual(try context.fetch(FetchDescriptor()).first?.name, "migrated and writable") + let fresh = directory.appendingPathComponent("fresh.store") + _ = try DashModelContainer.create(url: fresh) + XCTAssertTrue(Set(try DashSchemaFixtureSupport.indexes(at: fresh)) + .isSubset(of: Set(try DashSchemaFixtureSupport.indexes(at: url)))) + } + } + + @MainActor + func testRuntimePlanHasNoDuplicateModelChecksums() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let plans: [any SchemaMigrationPlan.Type] = [DashMigrationPlan.self, DashAcceptedV1MigrationPlan.self] + for (planIndex, plan) in plans.enumerated() { + var checksums = Set() + for type in plan.schemas { + let version = type.versionIdentifier + let url = directory.appendingPathComponent("\(planIndex)-" + DashSchemaFixtureSupport.version(version) + ".store") + let schema = Schema(versionedSchema: type) + _ = try ModelContainer(for: schema, configurations: [ + ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none) + ]) + let metadata = try DashSchemaFixtureSupport.describeStore(at: url, version: version) + XCTAssertTrue(checksums.insert(metadata.model_checksum).inserted, + "A snapshot with unchanged shape must not become an additional runtime migration stage") + } + } + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashSchemaFixtureSupport.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashSchemaFixtureSupport.swift new file mode 100644 index 00000000000..acb6f1e12c6 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashSchemaFixtureSupport.swift @@ -0,0 +1,146 @@ +import CoreData +import Foundation +import SQLite3 +import SwiftData +import XCTest + +@testable import SwiftDashSDK + +/// Shared synthetic release evidence; never reads a user's wallet or keychain. +enum DashSchemaFixtureSupport { + struct Description: Codable, Equatable { + let schema_version: String + let model_checksum: String + let entity_hashes: [String: String] + let indexes: [String] + } + + static func version(_ version: Schema.Version) -> String { + "\(version.major).\(version.minor).\(version.patch)" + } + + static func describeStore(at url: URL, version: Schema.Version) throws -> Description { + let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(type: .sqlite, at: url) + let identifiers = try XCTUnwrap(metadata["NSStoreModelVersionIdentifiers"] as? [String]) + guard identifiers == [self.version(version)] else { + throw NSError(domain: "DashSchemaFixtureVersionMismatch", code: 1) + } + let checksum = try XCTUnwrap(metadata["NSStoreModelVersionChecksumKey"] as? String) + let hashes = try XCTUnwrap(metadata["NSStoreModelVersionHashes"] as? [String: Data]) + return Description( + schema_version: self.version(version), model_checksum: checksum, + entity_hashes: hashes.mapValues { data in data.map { String(format: "%02x", $0) }.joined() }, + indexes: try indexes(at: url)) + } + + static func indexes(at url: URL) throws -> [String] { + var database: OpaquePointer? + guard sqlite3_open_v2(url.path, &database, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + sqlite3_close(database) + throw NSError(domain: "DashSchemaFixture", code: 1) + } + defer { sqlite3_close(database) } + var statement: OpaquePointer? + let query = "SELECT tbl_name, name, sql FROM sqlite_master WHERE type = 'index'" + guard sqlite3_prepare_v2(database, query, -1, &statement, nil) == SQLITE_OK else { + throw NSError(domain: "DashSchemaFixture", code: 2) + } + defer { sqlite3_finalize(statement) } + var result: [String] = [] + var step = sqlite3_step(statement) + while step == SQLITE_ROW { + let table = String(cString: sqlite3_column_text(statement, 0)) + let name = String(cString: sqlite3_column_text(statement, 1)) + let sql = sqlite3_column_text(statement, 2).map { String(cString: $0) } ?? "(auto)" + result.append("\(table) \(name): \(sql)") + step = sqlite3_step(statement) + } + guard step == SQLITE_DONE else { throw NSError(domain: "DashSchemaFixture", code: Int(step)) } + return result.sorted() + } + + @MainActor + static func writeLiveStore(at url: URL) throws { + guard !FileManager.default.fileExists(atPath: url.path) else { + throw NSError(domain: "DashSchemaFixtureAlreadyExists", code: 1) + } + try autoreleasepool { try populateStore(at: url) } + var database: OpaquePointer? + guard sqlite3_open_v2(url.path, &database, SQLITE_OPEN_READWRITE, nil) == SQLITE_OK else { + sqlite3_close(database) + throw NSError(domain: "DashSchemaFixture", code: 3) + } + defer { sqlite3_close(database) } + guard sqlite3_wal_checkpoint_v2(database, nil, SQLITE_CHECKPOINT_TRUNCATE, nil, nil) == SQLITE_OK, + sqlite3_exec(database, "PRAGMA journal_mode=DELETE", nil, nil, nil) == SQLITE_OK + else { throw NSError(domain: "DashSchemaFixture", code: 4) } + guard !FileManager.default.fileExists(atPath: url.path + "-wal"), + !FileManager.default.fileExists(atPath: url.path + "-shm") + else { throw NSError(domain: "DashSchemaFixtureSidecars", code: 1) } + } + + @MainActor + private static func populateStore(at url: URL) throws { + let container = try DashModelContainer.create(url: url) + let context = container.mainContext + let wallet = PersistentWallet( + walletId: Data(repeating: 0x31, count: 32), network: .testnet, name: "fixture wallet", + syncedHeight: 120) + context.insert(wallet) + let account = PersistentAccount( + wallet: wallet, accountType: 0, accountIndex: 0, accountTypeName: "standard") + context.insert(account) + let address = PersistentCoreAddress( + address: "yFixtureAddress", poolTypeTag: 0, addressIndex: 0, derivationPath: "m/0") + address.account = account + context.insert(address) + let funding = PersistentTransaction( + txid: Data(repeating: 0x34, count: 32), transactionData: Data([3, 0]), context: 2, + blockHeight: 100) + let spend = PersistentTransaction( + txid: Data(repeating: 0x32, count: 32), transactionData: Data([3, 0]), context: 2, + blockHeight: 110) + context.insert(funding) + context.insert(spend) + account.involvedTransactions = [funding, spend] + let txo = PersistentTxo( + transaction: funding, vout: 0, amount: 1_000, address: "yFixtureAddress", + height: 100) + txo.walletId = Data(repeating: 0x31, count: 32) + txo.isSpent = true + txo.spendingTransaction = spend + txo.coreAddress = address + txo.account = account + context.insert(txo) + context.insert(PersistentPendingInput( + outpoint: Data(repeating: 0x11, count: 36), inputIndex: 0, + spendingTxid: Data(repeating: 0x32, count: 32), spendingTransaction: spend, + walletId: Data(repeating: 0x31, count: 32))) + let identity = PersistentIdentity( + identityId: Data(repeating: 0x35, count: 32), balance: 5, network: .testnet) + identity.wallet = wallet + context.insert(identity) + let key = PersistentPublicKey( + keyId: 3, purpose: .authentication, securityLevel: .high, + keyType: .ecdsaSecp256k1, publicKeyData: Data(repeating: 0x02, count: 33), + totalBudget: 100_000, expiresAt: 1_800_000_000_000, + identityId: identity.identityIdString) + key.identity = identity + context.insert(key) + context.insert(PersistentKeyword(keyword: "preserved", contractId: "contract")) + let lock = PersistentAssetLock( + outPointHex: String(repeating: "ab", count: 32) + ":0", + walletId: Data(repeating: 0x31, count: 32), transactionBytes: Data([1, 2, 3]), + fundingTypeRaw: 4, identityIndexRaw: -1, amountDuffs: 100_000, statusRaw: 4) + lock.recipientIsExternal = true + context.insert(lock) + context.insert(PersistentTrackedMasternode( + networkRaw: Network.testnet.rawValue, proTxHash: Data(repeating: 7, count: 32), + label: "fixture", addedAt: 1, snapshotJSON: "{}")) + try context.save() + let storedKey = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + XCTAssertEqual(storedKey.totalBudgetCredits, 100_000) + XCTAssertEqual(storedKey.expiresAtMillis, 1_800_000_000_000) + XCTAssertEqual(storedKey.identity?.identityId, identity.identityId) + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashSchemaReleaseCaptureTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashSchemaReleaseCaptureTests.swift new file mode 100644 index 00000000000..67a0a842052 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashSchemaReleaseCaptureTests.swift @@ -0,0 +1,30 @@ +import Foundation +import SwiftData +import XCTest + +@testable import SwiftDashSDK + +/// Run on an iOS simulator built from the same pinned sources as the archive. +/// The workflow exports these attachments before uploading that archive. +final class DashSchemaReleaseCaptureTests: XCTestCase { + @MainActor + func testCaptureReleaseSchema() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = directory.appendingPathComponent("fixture.store") + try DashSchemaFixtureSupport.writeLiveStore(at: store) + let description = try DashSchemaFixtureSupport.describeStore( + at: store, version: DashModelContainer.schema.version) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .prettyPrinted] + let metadata = directory.appendingPathComponent("schema.json") + try encoder.encode(description).write(to: metadata) + for url in [metadata, store] { + let attachment = XCTAttachment(contentsOfFile: url) + attachment.name = url.lastPathComponent + attachment.lifetime = .keepAlways + add(attachment) + } + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserOncePerIdentityTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserOncePerIdentityTests.swift index 62f75619c78..b3bc5e014fc 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserOncePerIdentityTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserOncePerIdentityTests.swift @@ -25,9 +25,9 @@ import SwiftData /// protocol constant. /// /// Unlike the perpetual and pre-programmed kinds this one has no column on -/// `PersistentToken`: `DashSchemaV5` is frozen, and a new stored property -/// would move the model's entity hash (see `DashModelContainer.modelTypes` -/// and `DashModelMigrationTests`). `PersistentToken.oncePerIdentityDistribution` +/// `PersistentToken`: the contract JSON already persists it, so another +/// column would duplicate the value and change the model's entity hash. +/// `PersistentToken.oncePerIdentityDistribution` /// therefore derives the value from the contract JSON stored on the owning /// `PersistentDataContract`, which is why these tests seed /// `serializedContract` rather than leaving it empty like the sibling parser @@ -407,6 +407,32 @@ final class DataContractParserOncePerIdentityTests: XCTestCase { ) } + /// Frozen and live model copies pass the same stored values. Updating + /// the payload timestamp must invalidate the memo even at the same byte count. + func testCacheAcceptsStoredValuesAndInvalidatesUpdatedPayload() throws { + let cache = TokenOncePerIdentityDistributionCache() + let firstPayload = try JSONSerialization.data(withJSONObject: [ + "tokens": ["0": tokenDict(oncePerIdentity: ["amount": 100])] + ]) + let nextPayload = try JSONSerialization.data(withJSONObject: [ + "tokens": ["0": tokenDict(oncePerIdentity: ["amount": 200])] + ]) + XCTAssertEqual(firstPayload.count, nextPayload.count) + let firstUpdate = Date(timeIntervalSince1970: 1_000) + let nextUpdate = firstUpdate.addingTimeInterval(1) + + for _ in 0..<2 { + XCTAssertEqual(cache.distribution( + contractId: contractId, serializedContract: firstPayload, + lastUpdated: firstUpdate, position: 0)?.amount, "100") + } + XCTAssertEqual(cache.decodeCount, 1) + XCTAssertEqual(cache.distribution( + contractId: contractId, serializedContract: nextPayload, + lastUpdated: nextUpdate, position: 0)?.amount, "200") + XCTAssertEqual(cache.decodeCount, 2) + } + /// Two contracts are two payloads: the memo is keyed per contract, not /// shared across them. func testSeparateContractsDecodeSeparately() throws { diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v2.store b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v2.store deleted file mode 100644 index 6ce7443421e..00000000000 Binary files a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v2.store and /dev/null differ diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v3.store b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v3.store deleted file mode 100644 index 20d03939d0e..00000000000 Binary files a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v3.store and /dev/null differ diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v4.store b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/historical-v2.store similarity index 86% rename from packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v4.store rename to packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/historical-v2.store index d0a9ea7aad8..d411b495ed2 100644 Binary files a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v4.store and b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/historical-v2.store differ diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/legacy-fd8d8d13e5/README.md b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/legacy-fd8d8d13e5/README.md new file mode 100644 index 00000000000..83ed2744bc6 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/legacy-fd8d8d13e5/README.md @@ -0,0 +1,66 @@ +# Historical source reconstruction + +`fixture.store` is a synthetic SQLite store reconstructed from Platform +`fd8d8d13e5d7cea17b00df5974934ab1910e8039`. The associated iOS source is +`8094751eb2be8d52b57da3589fdd2ae2dcd0ecc6`, referenced by +[Actions run 32706880873](https://github.com/dashpay/dashwallet-ios/actions/runs/32706880873). +This source pair is not verified App Store provenance. The fixture was created +on a simulator; it contains no user data and is not extracted from an app binary. + +The store contains one synthetic wallet, data contract, document type and index, +including the type-to-contract and index-to-type relationships. Its 34 entities +come from the historical factory's literal `modelTypes` list. It was created +using an unversioned `Schema` and no migration plan, as in that historical app. +Its `PersistentDocumentType` and `PersistentIndex` hashes differ from the +accepted frozen V1. The accepted V1 definitions and `dash-v1.store` remain +unchanged and cover a separate migration regression. + +`manifest.json` records the exact source pair, toolchain, original fixture +checksum, schema metadata, SQLite indexes, synthetic row counts, source-file +digests, deterministic generated-file digests and capture recipe digest. The +recorded capture used Xcode 26.6 (17F113), an arm64 iPhone 17 simulator running +iOS 26.5, and Release configuration. The fixture SHA-256 is +`1cb3d6c2c299eb9afa9a2152e023acff6c108f1f4caebafc9db8da4a90fbd0da`. + +## Verify and reproduce + +From a full-history Platform checkout, verify the committed SQLite evidence and +regenerate all historical Swift source in memory: + +```sh +python3 packages/swift-sdk/scripts/historical_schema_fixture.py --check +``` + +To recapture on macOS, first build the current SDK's simulator XCFramework as +described in the SDK build guide. Then export the historical models and their +dedicated capture test into a new temporary SDK directory: + +```sh +python3 packages/swift-sdk/scripts/historical_schema_fixture.py \ + --prepare-sdk /tmp/dash-historical-capture-sdk +cd /tmp/dash-historical-capture-sdk +xcodebuild test -scheme SwiftDashSDK -configuration Release \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.5,arch=arm64' \ + -parallel-testing-enabled NO \ + -derivedDataPath /tmp/dash-historical-capture-derived \ + -resultBundlePath /tmp/dash-historical-capture.xcresult \ + -only-testing:SwiftDashSDKTests/DashHistoricalFixtureCaptureTests \ + CODE_SIGNING_ALLOWED=NO ARCHS=arm64 ENABLE_TESTABILITY=YES +xcrun xcresulttool export attachments \ + --path /tmp/dash-historical-capture.xcresult \ + --output-path /tmp/dash-historical-capture-attachments +``` + +Choose unused paths for another run. For comparison with this fixture, use the +recorded toolchain/runtime. The test compares the regenerated store's entity +hashes, model checksum, version identifier and indexes with the manifest before +attaching its standalone store and JSON metadata. SQLite UUIDs and timestamps +vary, so recaptured bytes are not expected to match the original file checksum. +Do not overwrite the committed fixture to make a migration or metadata check +pass; investigate differences first. + +The export uses the current SDK as a build harness, while every historical model +and stored value type comes from the pinned Platform source. Only the temporary +SDK receives these 36 generated Swift files and the capture test; the shipping +SDK does not gain a second historical model graph. The normal migration tests +consume the committed SQLite fixture directly. diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v5.store b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/legacy-fd8d8d13e5/fixture.store similarity index 83% rename from packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v5.store rename to packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/legacy-fd8d8d13e5/fixture.store index 05766e4bce8..944474f2fc6 100644 Binary files a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v5.store and b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/legacy-fd8d8d13e5/fixture.store differ diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/legacy-fd8d8d13e5/manifest.json b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/legacy-fd8d8d13e5/manifest.json new file mode 100644 index 00000000000..1237f9ba1f0 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/legacy-fd8d8d13e5/manifest.json @@ -0,0 +1,291 @@ +{ + "format_version": 1, + "scope": "source-reconstructed-synthetic", + "app_store_provenance": "not-verified", + "wallet_sha": "8094751eb2be8d52b57da3589fdd2ae2dcd0ecc6", + "platform_sha": "fd8d8d13e5d7cea17b00df5974934ab1910e8039", + "source_pair_reference": "https://github.com/dashpay/dashwallet-ios/actions/runs/32706880873", + "fixture_sha256": "1cb3d6c2c299eb9afa9a2152e023acff6c108f1f4caebafc9db8da4a90fbd0da", + "capture": { + "captured_at": "2026-09-20T07:47:06Z", + "xcode_version": "26.6", + "xcode_build": "17F113", + "simulator_runtime": "com.apple.CoreSimulator.SimRuntime.iOS-26-5", + "simulator_device": "iPhone 17", + "architecture": "arm64", + "configuration": "Release", + "sdk_harness_sha": "6faa0545cfeeb279ba5ae3784f848206cfe0f6da", + "source_recipe": "Unversioned Schema(DashSchemaSnapshotV1.models), no migration plan, four synthetic rows", + "byte_reproducibility": "SQLite UUIDs and timestamps vary; compare schema metadata and seeded records, not regenerated file bytes" + }, + "schema": { + "entity_hashes": { + "PersistentAccount": "a5d5dc075d2d634d1a6f0ce025fe0aa323e2747443119c94eb65d024aa47985d", + "PersistentAssetLock": "7fb4ccbc526302d09df6f3f11f9d9eb269c2dd33ee93e1a4a31f72714145399c", + "PersistentCoreAddress": "2518791fe1ebe9fbab8586ba08cb0a527259360e6cfe710287ca73f8959dd14c", + "PersistentDPNSName": "0b3c83f1ed265b4ef9e4ee404d0354f3da7eb70004d38d8bded2f5bae4debaed", + "PersistentDashpayContactProfile": "a5c249ffa9d298c53329262cb85edbb5fc5b77a67f43d7343277ce58d8d4b9d3", + "PersistentDashpayContactRequest": "3ed2ec9984523024a43af961442d85cf02f5db87302e1c8aa68ef9c407d12901", + "PersistentDashpayIgnoredSender": "5386ff4fc63f0b1dfd6a42d6e9b3db3c54339691cb55593ce169c5ba4378f680", + "PersistentDashpayPayment": "5aac0e1c8d892a243dba2967170a0cee067fc56c183b2f4af06c4b66b2839630", + "PersistentDashpayProfile": "e88ec9d87a07af8c2e1faef17b0206a61c9d59322552aab0ffe3133b7059885e", + "PersistentDataContract": "c869c0a2df469be22643847a3e8331e156f364e93aefde2aedd13ef6067bcd66", + "PersistentDocument": "1e143af2e59ef70f72385d661d7ba5d1484fee34f97d9e8d0b2b56fdc879a590", + "PersistentDocumentType": "c3689448841fb9d75e55153213f960504ee839bbc6d695b33b5021d88076c41b", + "PersistentIdentity": "d0e738b1edb8dae40c4b3f916196b6bdcaa9fe499752d211bb69204d80a83418", + "PersistentIndex": "889455232bbb1ac94ab79ccefb6a1ad7de185c6b6e8c9aa7cdc01ed453d659af", + "PersistentInvitation": "4a0a75d2196279e70a911f6d7686f9e29441b582b19ac00b67bc66e88452cf57", + "PersistentKeyword": "286ac09d230fd54050fa33f989e478745766f6e456377137b124d05f2e1a82fb", + "PersistentMasternode": "b33b6a7c27a4df67e0319b96e1f4337e9a67e8213d0d97d540167a140967d656", + "PersistentPendingInput": "483084c729757b6a0acc3118f74773ade4f68b3d6210229229e95a9e49e7ca1e", + "PersistentPlatformAddress": "0cae31a85f8e343c9fe45a8bb44a14958f688456ad6a5c9e39a1331c46e16039", + "PersistentPlatformAddressesSyncState": "de52a03d68033a8cf1bf7e95f2e8f1b8e318ef67cd289ffa9f60595c0a4e24b9", + "PersistentProperty": "c7082d8dcaf538d2eb88916033b8b40f228bd399d758999f2dd0470708fc94b6", + "PersistentPublicKey": "d369346faee596c187c6851eec77d9251414f788f9a11579842cf03492380585", + "PersistentShieldedActivity": "1aef5f0ec88b4be4ccd1a549379c7ca0dfa75b6f48909c22538ba2687d487409", + "PersistentShieldedNote": "d2986310786e015f98f92c5b6cd84db1b1e98eadd623e98d94ebae778cb1e650", + "PersistentShieldedOutgoingNote": "e749ad68716dccc346b798b23c98e21072fd0df19f344bf4b752e15605fe42f1", + "PersistentShieldedSyncState": "2130e7484885f36267753ce4f6d39e216aa6b932e3660234a12b29a136335769", + "PersistentShieldedViewingKey": "bc07713a4b0a63ffdec2e53369e97f089e8de905ab2e8e7e270f71bec7668cdc", + "PersistentToken": "85dacb8b1556f06c9ece6bac94e185e0fdbd8f62554f2b5eca444c121649567c", + "PersistentTokenBalance": "6ea32b253749c13392e4c7aa471b0872d4b1a9d36bb260beb9c6bb4d40a7474e", + "PersistentTokenHistoryEvent": "6e67103e1a6caf7d04f4b5a44d1012a470fef341c36c6cefbfc0e76b440a00ab", + "PersistentTransaction": "daad9b22c2b6c554cc281e7e00eff39ec366373fbaf9553e03ec38ad8f794fbb", + "PersistentTxo": "0a8a83ff1b93058115b34147cc5e797c5691a4975a6892e5d11474ba9d6772b7", + "PersistentWallet": "d2ae76969b1d92b66c6e7df62f7e0ae12e05be2498c9ecf79e3b7d0aa7f96c81", + "PersistentWalletManagerMetadata": "19dbe2cfb7e0c63fa6cd69291e5b1865f58642f43a156db0d9e06028a7bd4d99" + }, + "indexes": [ + "ACHANGE ACHANGE_ZTRANSACTIONID_INDEX: CREATE INDEX ACHANGE_ZTRANSACTIONID_INDEX ON ACHANGE (ZTRANSACTIONID)", + "ATRANSACTION ATRANSACTION_ZAUTHORTS_INDEX: CREATE INDEX ATRANSACTION_ZAUTHORTS_INDEX ON ATRANSACTION (ZAUTHORTS)", + "ATRANSACTION ATRANSACTION_ZBUNDLEIDTS_INDEX: CREATE INDEX ATRANSACTION_ZBUNDLEIDTS_INDEX ON ATRANSACTION (ZBUNDLEIDTS)", + "ATRANSACTION ATRANSACTION_ZCONTEXTNAMETS_INDEX: CREATE INDEX ATRANSACTION_ZCONTEXTNAMETS_INDEX ON ATRANSACTION (ZCONTEXTNAMETS)", + "ATRANSACTION ATRANSACTION_ZPROCESSIDTS_INDEX: CREATE INDEX ATRANSACTION_ZPROCESSIDTS_INDEX ON ATRANSACTION (ZPROCESSIDTS)", + "ATRANSACTION Z_TRANSACTION_TransactionAuthorIndex: CREATE INDEX Z_TRANSACTION_TransactionAuthorIndex ON ATRANSACTION (ZAUTHOR COLLATE BINARY ASC)", + "ATRANSACTION Z_TRANSACTION_TransactionTimestampIndex: CREATE INDEX Z_TRANSACTION_TransactionTimestampIndex ON ATRANSACTION (ZTIMESTAMP COLLATE BINARY ASC)", + "ATRANSACTIONSTRING Z_TRANSACTIONSTRING_UNIQUE_NAME: CREATE UNIQUE INDEX Z_TRANSACTIONSTRING_UNIQUE_NAME ON ATRANSACTIONSTRING (ZNAME COLLATE BINARY ASC)", + "ZPERSISTENTACCOUNT ZPERSISTENTACCOUNT_ZWALLET_INDEX: CREATE INDEX ZPERSISTENTACCOUNT_ZWALLET_INDEX ON ZPERSISTENTACCOUNT (ZWALLET)", + "ZPERSISTENTACCOUNT Z_PersistentAccount_UNIQUE_accountExtendedPubKeyBytes: CREATE UNIQUE INDEX Z_PersistentAccount_UNIQUE_accountExtendedPubKeyBytes ON ZPERSISTENTACCOUNT (ZACCOUNTEXTENDEDPUBKEYBYTES COLLATE BINARY ASC)", + "ZPERSISTENTACCOUNT Z_PersistentAccount_UNIQUE_wallet_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId: CREATE UNIQUE INDEX Z_PersistentAccount_UNIQUE_wallet_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId ON ZPERSISTENTACCOUNT (ZWALLET COLLATE BINARY ASC, ZACCOUNTTYPE COLLATE BINARY ASC, ZACCOUNTINDEX COLLATE BINARY ASC, ZSTANDARDTAG COLLATE BINARY ASC, ZREGISTRATIONINDEX COLLATE BINARY ASC, ZKEYCLASS COLLATE BINARY ASC, ZUSERIDENTITYID COLLATE BINARY ASC, ZFRIENDIDENTITYID COLLATE BINARY ASC)", + "ZPERSISTENTASSETLOCK Z_PersistentAssetLock_SwiftDataIndexOnBinarywalletId: CREATE INDEX Z_PersistentAssetLock_SwiftDataIndexOnBinarywalletId ON ZPERSISTENTASSETLOCK (ZWALLETID COLLATE BINARY ASC)", + "ZPERSISTENTASSETLOCK Z_PersistentAssetLock_UNIQUE_outPointHex: CREATE UNIQUE INDEX Z_PersistentAssetLock_UNIQUE_outPointHex ON ZPERSISTENTASSETLOCK (ZOUTPOINTHEX COLLATE BINARY ASC)", + "ZPERSISTENTCOREADDRESS ZPERSISTENTCOREADDRESS_ZACCOUNT_INDEX: CREATE INDEX ZPERSISTENTCOREADDRESS_ZACCOUNT_INDEX ON ZPERSISTENTCOREADDRESS (ZACCOUNT)", + "ZPERSISTENTCOREADDRESS Z_PersistentCoreAddress_UNIQUE_address: CREATE UNIQUE INDEX Z_PersistentCoreAddress_UNIQUE_address ON ZPERSISTENTCOREADDRESS (ZADDRESS COLLATE BINARY ASC)", + "ZPERSISTENTDASHPAYCONTACTPROFILE ZPERSISTENTDASHPAYCONTACTPROFILE_ZOWNER_INDEX: CREATE INDEX ZPERSISTENTDASHPAYCONTACTPROFILE_ZOWNER_INDEX ON ZPERSISTENTDASHPAYCONTACTPROFILE (ZOWNER)", + "ZPERSISTENTDASHPAYCONTACTPROFILE Z_PersistentDashpayContactProfile_UNIQUE_networkRaw_ownerIdentityId_contactIdentityId: CREATE UNIQUE INDEX Z_PersistentDashpayContactProfile_UNIQUE_networkRaw_ownerIdentityId_contactIdentityId ON ZPERSISTENTDASHPAYCONTACTPROFILE (ZNETWORKRAW COLLATE BINARY ASC, ZOWNERIDENTITYID COLLATE BINARY ASC, ZCONTACTIDENTITYID COLLATE BINARY ASC)", + "ZPERSISTENTDASHPAYCONTACTREQUEST ZPERSISTENTDASHPAYCONTACTREQUEST_ZOWNER_INDEX: CREATE INDEX ZPERSISTENTDASHPAYCONTACTREQUEST_ZOWNER_INDEX ON ZPERSISTENTDASHPAYCONTACTREQUEST (ZOWNER)", + "ZPERSISTENTDASHPAYCONTACTREQUEST Z_PersistentDashpayContactRequest_UNIQUE_networkRaw_ownerIdentityId_contactIdentityId_isOutgoing: CREATE UNIQUE INDEX Z_PersistentDashpayContactRequest_UNIQUE_networkRaw_ownerIdentityId_contactIdentityId_isOutgoing ON ZPERSISTENTDASHPAYCONTACTREQUEST (ZNETWORKRAW COLLATE BINARY ASC, ZOWNERIDENTITYID COLLATE BINARY ASC, ZCONTACTIDENTITYID COLLATE BINARY ASC, ZISOUTGOING COLLATE BINARY ASC)", + "ZPERSISTENTDASHPAYIGNOREDSENDER ZPERSISTENTDASHPAYIGNOREDSENDER_ZOWNER_INDEX: CREATE INDEX ZPERSISTENTDASHPAYIGNOREDSENDER_ZOWNER_INDEX ON ZPERSISTENTDASHPAYIGNOREDSENDER (ZOWNER)", + "ZPERSISTENTDASHPAYIGNOREDSENDER Z_PersistentDashpayIgnoredSender_UNIQUE_networkRaw_ownerIdentityId_ignoredSenderId: CREATE UNIQUE INDEX Z_PersistentDashpayIgnoredSender_UNIQUE_networkRaw_ownerIdentityId_ignoredSenderId ON ZPERSISTENTDASHPAYIGNOREDSENDER (ZNETWORKRAW COLLATE BINARY ASC, ZOWNERIDENTITYID COLLATE BINARY ASC, ZIGNOREDSENDERID COLLATE BINARY ASC)", + "ZPERSISTENTDASHPAYPAYMENT ZPERSISTENTDASHPAYPAYMENT_ZOWNER_INDEX: CREATE INDEX ZPERSISTENTDASHPAYPAYMENT_ZOWNER_INDEX ON ZPERSISTENTDASHPAYPAYMENT (ZOWNER)", + "ZPERSISTENTDASHPAYPAYMENT Z_PersistentDashpayPayment_UNIQUE_networkRaw_ownerIdentityId_txid: CREATE UNIQUE INDEX Z_PersistentDashpayPayment_UNIQUE_networkRaw_ownerIdentityId_txid ON ZPERSISTENTDASHPAYPAYMENT (ZNETWORKRAW COLLATE BINARY ASC, ZOWNERIDENTITYID COLLATE BINARY ASC, ZTXID COLLATE BINARY ASC)", + "ZPERSISTENTDASHPAYPROFILE ZPERSISTENTDASHPAYPROFILE_ZIDENTITY_INDEX: CREATE INDEX ZPERSISTENTDASHPAYPROFILE_ZIDENTITY_INDEX ON ZPERSISTENTDASHPAYPROFILE (ZIDENTITY)", + "ZPERSISTENTDASHPAYPROFILE Z_PersistentDashpayProfile_UNIQUE_networkRaw_identity: CREATE UNIQUE INDEX Z_PersistentDashpayProfile_UNIQUE_networkRaw_identity ON ZPERSISTENTDASHPAYPROFILE (ZNETWORKRAW COLLATE BINARY ASC, ZIDENTITY COLLATE BINARY ASC)", + "ZPERSISTENTDATACONTRACT ZPERSISTENTDATACONTRACT_ZOWNERIDENTITY_INDEX: CREATE INDEX ZPERSISTENTDATACONTRACT_ZOWNERIDENTITY_INDEX ON ZPERSISTENTDATACONTRACT (ZOWNERIDENTITY)", + "ZPERSISTENTDATACONTRACT Z_PersistentDataContract_SwiftDataIndexOnBinarynetworkRaw: CREATE INDEX Z_PersistentDataContract_SwiftDataIndexOnBinarynetworkRaw ON ZPERSISTENTDATACONTRACT (ZNETWORKRAW COLLATE BINARY ASC)", + "ZPERSISTENTDATACONTRACT Z_PersistentDataContract_UNIQUE_id: CREATE UNIQUE INDEX Z_PersistentDataContract_UNIQUE_id ON ZPERSISTENTDATACONTRACT (ZID COLLATE BINARY ASC)", + "ZPERSISTENTDOCUMENT ZPERSISTENTDOCUMENT_ZDATACONTRACT_INDEX: CREATE INDEX ZPERSISTENTDOCUMENT_ZDATACONTRACT_INDEX ON ZPERSISTENTDOCUMENT (ZDATACONTRACT)", + "ZPERSISTENTDOCUMENT ZPERSISTENTDOCUMENT_ZDOCUMENTTYPE_RELATION_INDEX: CREATE INDEX ZPERSISTENTDOCUMENT_ZDOCUMENTTYPE_RELATION_INDEX ON ZPERSISTENTDOCUMENT (ZDOCUMENTTYPE_RELATION)", + "ZPERSISTENTDOCUMENT ZPERSISTENTDOCUMENT_ZOWNERIDENTITY_INDEX: CREATE INDEX ZPERSISTENTDOCUMENT_ZOWNERIDENTITY_INDEX ON ZPERSISTENTDOCUMENT (ZOWNERIDENTITY)", + "ZPERSISTENTDOCUMENT Z_PersistentDocument_SwiftDataIndexOnBinarynetworkRaw: CREATE INDEX Z_PersistentDocument_SwiftDataIndexOnBinarynetworkRaw ON ZPERSISTENTDOCUMENT (ZNETWORKRAW COLLATE BINARY ASC)", + "ZPERSISTENTDOCUMENT Z_PersistentDocument_UNIQUE_documentId: CREATE UNIQUE INDEX Z_PersistentDocument_UNIQUE_documentId ON ZPERSISTENTDOCUMENT (ZDOCUMENTID COLLATE BINARY ASC)", + "ZPERSISTENTDOCUMENTTYPE ZPERSISTENTDOCUMENTTYPE_ZDATACONTRACT_INDEX: CREATE INDEX ZPERSISTENTDOCUMENTTYPE_ZDATACONTRACT_INDEX ON ZPERSISTENTDOCUMENTTYPE (ZDATACONTRACT)", + "ZPERSISTENTDOCUMENTTYPE Z_PersistentDocumentType_UNIQUE_id: CREATE UNIQUE INDEX Z_PersistentDocumentType_UNIQUE_id ON ZPERSISTENTDOCUMENTTYPE (ZID COLLATE BINARY ASC)", + "ZPERSISTENTDPNSNAME ZPERSISTENTDPNSNAME_ZIDENTITY_INDEX: CREATE INDEX ZPERSISTENTDPNSNAME_ZIDENTITY_INDEX ON ZPERSISTENTDPNSNAME (ZIDENTITY)", + "ZPERSISTENTDPNSNAME Z_PersistentDPNSName_UNIQUE_networkRaw_normalizedParentDomainName_normalizedLabel: CREATE UNIQUE INDEX Z_PersistentDPNSName_UNIQUE_networkRaw_normalizedParentDomainName_normalizedLabel ON ZPERSISTENTDPNSNAME (ZNETWORKRAW COLLATE BINARY ASC, ZNORMALIZEDPARENTDOMAINNAME COLLATE BINARY ASC, ZNORMALIZEDLABEL COLLATE BINARY ASC)", + "ZPERSISTENTIDENTITY ZPERSISTENTIDENTITY_ZDASHPAYPROFILE_INDEX: CREATE INDEX ZPERSISTENTIDENTITY_ZDASHPAYPROFILE_INDEX ON ZPERSISTENTIDENTITY (ZDASHPAYPROFILE)", + "ZPERSISTENTIDENTITY ZPERSISTENTIDENTITY_ZWALLET_INDEX: CREATE INDEX ZPERSISTENTIDENTITY_ZWALLET_INDEX ON ZPERSISTENTIDENTITY (ZWALLET)", + "ZPERSISTENTIDENTITY Z_PersistentIdentity_SwiftDataIndexOnBinarynetworkRaw: CREATE INDEX Z_PersistentIdentity_SwiftDataIndexOnBinarynetworkRaw ON ZPERSISTENTIDENTITY (ZNETWORKRAW COLLATE BINARY ASC)", + "ZPERSISTENTIDENTITY Z_PersistentIdentity_UNIQUE_identityId: CREATE UNIQUE INDEX Z_PersistentIdentity_UNIQUE_identityId ON ZPERSISTENTIDENTITY (ZIDENTITYID COLLATE BINARY ASC)", + "ZPERSISTENTINDEX ZPERSISTENTINDEX_ZDOCUMENTTYPE_INDEX: CREATE INDEX ZPERSISTENTINDEX_ZDOCUMENTTYPE_INDEX ON ZPERSISTENTINDEX (ZDOCUMENTTYPE)", + "ZPERSISTENTINDEX Z_PersistentIndex_UNIQUE_id: CREATE UNIQUE INDEX Z_PersistentIndex_UNIQUE_id ON ZPERSISTENTINDEX (ZID COLLATE BINARY ASC)", + "ZPERSISTENTINVITATION Z_PersistentInvitation_SwiftDataIndexOnBinarywalletId: CREATE INDEX Z_PersistentInvitation_SwiftDataIndexOnBinarywalletId ON ZPERSISTENTINVITATION (ZWALLETID COLLATE BINARY ASC)", + "ZPERSISTENTINVITATION Z_PersistentInvitation_UNIQUE_outPointHex: CREATE UNIQUE INDEX Z_PersistentInvitation_UNIQUE_outPointHex ON ZPERSISTENTINVITATION (ZOUTPOINTHEX COLLATE BINARY ASC)", + "ZPERSISTENTKEYWORD ZPERSISTENTKEYWORD_ZDATACONTRACT_INDEX: CREATE INDEX ZPERSISTENTKEYWORD_ZDATACONTRACT_INDEX ON ZPERSISTENTKEYWORD (ZDATACONTRACT)", + "ZPERSISTENTKEYWORD Z_PersistentKeyword_UNIQUE_id: CREATE UNIQUE INDEX Z_PersistentKeyword_UNIQUE_id ON ZPERSISTENTKEYWORD (ZID COLLATE BINARY ASC)", + "ZPERSISTENTMASTERNODE Z_PersistentMasternode_UNIQUE_walletId_proTxHash: CREATE UNIQUE INDEX Z_PersistentMasternode_UNIQUE_walletId_proTxHash ON ZPERSISTENTMASTERNODE (ZWALLETID COLLATE BINARY ASC, ZPROTXHASH COLLATE BINARY ASC)", + "ZPERSISTENTPENDINGINPUT ZPERSISTENTPENDINGINPUT_ZSPENDINGTRANSACTION_INDEX: CREATE INDEX ZPERSISTENTPENDINGINPUT_ZSPENDINGTRANSACTION_INDEX ON ZPERSISTENTPENDINGINPUT (ZSPENDINGTRANSACTION)", + "ZPERSISTENTPENDINGINPUT Z_PersistentPendingInput_SwiftDataIndexOnBinaryoutpoint: CREATE INDEX Z_PersistentPendingInput_SwiftDataIndexOnBinaryoutpoint ON ZPERSISTENTPENDINGINPUT (ZOUTPOINT COLLATE BINARY ASC)", + "ZPERSISTENTPENDINGINPUT Z_PersistentPendingInput_SwiftDataIndexOnBinarywalletId: CREATE INDEX Z_PersistentPendingInput_SwiftDataIndexOnBinarywalletId ON ZPERSISTENTPENDINGINPUT (ZWALLETID COLLATE BINARY ASC)", + "ZPERSISTENTPLATFORMADDRESS ZPERSISTENTPLATFORMADDRESS_ZACCOUNT_INDEX: CREATE INDEX ZPERSISTENTPLATFORMADDRESS_ZACCOUNT_INDEX ON ZPERSISTENTPLATFORMADDRESS (ZACCOUNT)", + "ZPERSISTENTPLATFORMADDRESS Z_PersistentPlatformAddress_SwiftDataIndexOnBinarywalletId: CREATE INDEX Z_PersistentPlatformAddress_SwiftDataIndexOnBinarywalletId ON ZPERSISTENTPLATFORMADDRESS (ZWALLETID COLLATE BINARY ASC)", + "ZPERSISTENTPLATFORMADDRESS Z_PersistentPlatformAddress_UNIQUE_address: CREATE UNIQUE INDEX Z_PersistentPlatformAddress_UNIQUE_address ON ZPERSISTENTPLATFORMADDRESS (ZADDRESS COLLATE BINARY ASC)", + "ZPERSISTENTPLATFORMADDRESS Z_PersistentPlatformAddress_UNIQUE_addressHash: CREATE UNIQUE INDEX Z_PersistentPlatformAddress_UNIQUE_addressHash ON ZPERSISTENTPLATFORMADDRESS (ZADDRESSHASH COLLATE BINARY ASC)", + "ZPERSISTENTPLATFORMADDRESSESSYNCSTATE Z_PersistentPlatformAddressesSyncState_UNIQUE_walletId: CREATE UNIQUE INDEX Z_PersistentPlatformAddressesSyncState_UNIQUE_walletId ON ZPERSISTENTPLATFORMADDRESSESSYNCSTATE (ZWALLETID COLLATE BINARY ASC)", + "ZPERSISTENTPROPERTY ZPERSISTENTPROPERTY_ZDOCUMENTTYPE_INDEX: CREATE INDEX ZPERSISTENTPROPERTY_ZDOCUMENTTYPE_INDEX ON ZPERSISTENTPROPERTY (ZDOCUMENTTYPE)", + "ZPERSISTENTPROPERTY Z_PersistentProperty_UNIQUE_id: CREATE UNIQUE INDEX Z_PersistentProperty_UNIQUE_id ON ZPERSISTENTPROPERTY (ZID COLLATE BINARY ASC)", + "ZPERSISTENTPUBLICKEY ZPERSISTENTPUBLICKEY_ZIDENTITY_INDEX: CREATE INDEX ZPERSISTENTPUBLICKEY_ZIDENTITY_INDEX ON ZPERSISTENTPUBLICKEY (ZIDENTITY)", + "ZPERSISTENTSHIELDEDACTIVITY Z_PersistentShieldedActivity_SwiftDataIndexOnBinarywalletIdaccountIndex: CREATE INDEX Z_PersistentShieldedActivity_SwiftDataIndexOnBinarywalletIdaccountIndex ON ZPERSISTENTSHIELDEDACTIVITY (ZWALLETID COLLATE BINARY ASC, ZACCOUNTINDEX COLLATE BINARY ASC)", + "ZPERSISTENTSHIELDEDACTIVITY Z_PersistentShieldedActivity_UNIQUE_walletId_accountIndex_entryId: CREATE UNIQUE INDEX Z_PersistentShieldedActivity_UNIQUE_walletId_accountIndex_entryId ON ZPERSISTENTSHIELDEDACTIVITY (ZWALLETID COLLATE BINARY ASC, ZACCOUNTINDEX COLLATE BINARY ASC, ZENTRYID COLLATE BINARY ASC)", + "ZPERSISTENTSHIELDEDNOTE Z_PersistentShieldedNote_SwiftDataIndexOnBinarywalletIdaccountIndex: CREATE INDEX Z_PersistentShieldedNote_SwiftDataIndexOnBinarywalletIdaccountIndex ON ZPERSISTENTSHIELDEDNOTE (ZWALLETID COLLATE BINARY ASC, ZACCOUNTINDEX COLLATE BINARY ASC)", + "ZPERSISTENTSHIELDEDNOTE Z_PersistentShieldedNote_UNIQUE_nullifier: CREATE UNIQUE INDEX Z_PersistentShieldedNote_UNIQUE_nullifier ON ZPERSISTENTSHIELDEDNOTE (ZNULLIFIER COLLATE BINARY ASC)", + "ZPERSISTENTSHIELDEDOUTGOINGNOTE Z_PersistentShieldedOutgoingNote_SwiftDataIndexOnBinarywalletIdaccountIndex: CREATE INDEX Z_PersistentShieldedOutgoingNote_SwiftDataIndexOnBinarywalletIdaccountIndex ON ZPERSISTENTSHIELDEDOUTGOINGNOTE (ZWALLETID COLLATE BINARY ASC, ZACCOUNTINDEX COLLATE BINARY ASC)", + "ZPERSISTENTSHIELDEDOUTGOINGNOTE Z_PersistentShieldedOutgoingNote_UNIQUE_walletId_accountIndex_cmx: CREATE UNIQUE INDEX Z_PersistentShieldedOutgoingNote_UNIQUE_walletId_accountIndex_cmx ON ZPERSISTENTSHIELDEDOUTGOINGNOTE (ZWALLETID COLLATE BINARY ASC, ZACCOUNTINDEX COLLATE BINARY ASC, ZCMX COLLATE BINARY ASC)", + "ZPERSISTENTSHIELDEDSYNCSTATE Z_PersistentShieldedSyncState_SwiftDataIndexOnBinarywalletId: CREATE INDEX Z_PersistentShieldedSyncState_SwiftDataIndexOnBinarywalletId ON ZPERSISTENTSHIELDEDSYNCSTATE (ZWALLETID COLLATE BINARY ASC)", + "ZPERSISTENTSHIELDEDSYNCSTATE Z_PersistentShieldedSyncState_UNIQUE_walletId_accountIndex: CREATE UNIQUE INDEX Z_PersistentShieldedSyncState_UNIQUE_walletId_accountIndex ON ZPERSISTENTSHIELDEDSYNCSTATE (ZWALLETID COLLATE BINARY ASC, ZACCOUNTINDEX COLLATE BINARY ASC)", + "ZPERSISTENTSHIELDEDVIEWINGKEY Z_PersistentShieldedViewingKey_SwiftDataIndexOnBinarywalletId: CREATE INDEX Z_PersistentShieldedViewingKey_SwiftDataIndexOnBinarywalletId ON ZPERSISTENTSHIELDEDVIEWINGKEY (ZWALLETID COLLATE BINARY ASC)", + "ZPERSISTENTSHIELDEDVIEWINGKEY Z_PersistentShieldedViewingKey_UNIQUE_walletId_accountIndex: CREATE UNIQUE INDEX Z_PersistentShieldedViewingKey_UNIQUE_walletId_accountIndex ON ZPERSISTENTSHIELDEDVIEWINGKEY (ZWALLETID COLLATE BINARY ASC, ZACCOUNTINDEX COLLATE BINARY ASC)", + "ZPERSISTENTTOKEN ZPERSISTENTTOKEN_ZDATACONTRACT_INDEX: CREATE INDEX ZPERSISTENTTOKEN_ZDATACONTRACT_INDEX ON ZPERSISTENTTOKEN (ZDATACONTRACT)", + "ZPERSISTENTTOKEN Z_PersistentToken_UNIQUE_id: CREATE UNIQUE INDEX Z_PersistentToken_UNIQUE_id ON ZPERSISTENTTOKEN (ZID COLLATE BINARY ASC)", + "ZPERSISTENTTOKENBALANCE ZPERSISTENTTOKENBALANCE_Z13TOKENBALANCES_INDEX: CREATE INDEX ZPERSISTENTTOKENBALANCE_Z13TOKENBALANCES_INDEX ON ZPERSISTENTTOKENBALANCE (Z13TOKENBALANCES)", + "ZPERSISTENTTOKENBALANCE ZPERSISTENTTOKENBALANCE_ZIDENTITY_INDEX: CREATE INDEX ZPERSISTENTTOKENBALANCE_ZIDENTITY_INDEX ON ZPERSISTENTTOKENBALANCE (ZIDENTITY)", + "ZPERSISTENTTOKENBALANCE ZPERSISTENTTOKENBALANCE_ZTOKEN_INDEX: CREATE INDEX ZPERSISTENTTOKENBALANCE_ZTOKEN_INDEX ON ZPERSISTENTTOKENBALANCE (ZTOKEN)", + "ZPERSISTENTTOKENBALANCE Z_PersistentTokenBalance_SwiftDataIndexOnBinarynetworkRaw: CREATE INDEX Z_PersistentTokenBalance_SwiftDataIndexOnBinarynetworkRaw ON ZPERSISTENTTOKENBALANCE (ZNETWORKRAW COLLATE BINARY ASC)", + "ZPERSISTENTTOKENHISTORYEVENT ZPERSISTENTTOKENHISTORYEVENT_ZTOKEN_INDEX: CREATE INDEX ZPERSISTENTTOKENHISTORYEVENT_ZTOKEN_INDEX ON ZPERSISTENTTOKENHISTORYEVENT (ZTOKEN)", + "ZPERSISTENTTOKENHISTORYEVENT Z_PersistentTokenHistoryEvent_UNIQUE_id: CREATE UNIQUE INDEX Z_PersistentTokenHistoryEvent_UNIQUE_id ON ZPERSISTENTTOKENHISTORYEVENT (ZID COLLATE BINARY ASC)", + "ZPERSISTENTTRANSACTION Z_PersistentTransaction_SwiftDataIndexOnBinaryfirstSeen: CREATE INDEX Z_PersistentTransaction_SwiftDataIndexOnBinaryfirstSeen ON ZPERSISTENTTRANSACTION (ZFIRSTSEEN COLLATE BINARY ASC)", + "ZPERSISTENTTRANSACTION Z_PersistentTransaction_UNIQUE_txid: CREATE UNIQUE INDEX Z_PersistentTransaction_UNIQUE_txid ON ZPERSISTENTTRANSACTION (ZTXID COLLATE BINARY ASC)", + "ZPERSISTENTTXO ZPERSISTENTTXO_ZACCOUNT_INDEX: CREATE INDEX ZPERSISTENTTXO_ZACCOUNT_INDEX ON ZPERSISTENTTXO (ZACCOUNT)", + "ZPERSISTENTTXO ZPERSISTENTTXO_ZCOREADDRESS_INDEX: CREATE INDEX ZPERSISTENTTXO_ZCOREADDRESS_INDEX ON ZPERSISTENTTXO (ZCOREADDRESS)", + "ZPERSISTENTTXO ZPERSISTENTTXO_ZSPENDINGTRANSACTION_INDEX: CREATE INDEX ZPERSISTENTTXO_ZSPENDINGTRANSACTION_INDEX ON ZPERSISTENTTXO (ZSPENDINGTRANSACTION)", + "ZPERSISTENTTXO ZPERSISTENTTXO_ZTRANSACTION_INDEX: CREATE INDEX ZPERSISTENTTXO_ZTRANSACTION_INDEX ON ZPERSISTENTTXO (ZTRANSACTION)", + "ZPERSISTENTTXO Z_PersistentTxo_SwiftDataIndexOnBinarywalletId: CREATE INDEX Z_PersistentTxo_SwiftDataIndexOnBinarywalletId ON ZPERSISTENTTXO (ZWALLETID COLLATE BINARY ASC)", + "ZPERSISTENTTXO Z_PersistentTxo_UNIQUE_outpoint: CREATE UNIQUE INDEX Z_PersistentTxo_UNIQUE_outpoint ON ZPERSISTENTTXO (ZOUTPOINT COLLATE BINARY ASC)", + "ZPERSISTENTWALLET Z_PersistentWallet_SwiftDataIndexOnBinarynetworkRaw: CREATE INDEX Z_PersistentWallet_SwiftDataIndexOnBinarynetworkRaw ON ZPERSISTENTWALLET (ZNETWORKRAW COLLATE BINARY ASC)", + "ZPERSISTENTWALLET Z_PersistentWallet_SwiftDataIndexOnBinarywalletGroupId: CREATE INDEX Z_PersistentWallet_SwiftDataIndexOnBinarywalletGroupId ON ZPERSISTENTWALLET (ZWALLETGROUPID COLLATE BINARY ASC)", + "ZPERSISTENTWALLET Z_PersistentWallet_UNIQUE_walletId: CREATE UNIQUE INDEX Z_PersistentWallet_UNIQUE_walletId ON ZPERSISTENTWALLET (ZWALLETID COLLATE BINARY ASC)", + "ZPERSISTENTWALLETMANAGERMETADATA Z_PersistentWalletManagerMetadata_UNIQUE_networkRaw: CREATE UNIQUE INDEX Z_PersistentWalletManagerMetadata_UNIQUE_networkRaw ON ZPERSISTENTWALLETMANAGERMETADATA (ZNETWORKRAW COLLATE BINARY ASC)", + "Z_1INVOLVEDTRANSACTIONS Z_1INVOLVEDTRANSACTIONS_Z_31INVOLVEDTRANSACTIONS_INDEX: CREATE INDEX Z_1INVOLVEDTRANSACTIONS_Z_31INVOLVEDTRANSACTIONS_INDEX ON Z_1INVOLVEDTRANSACTIONS (Z_31INVOLVEDTRANSACTIONS, Z_1INVOLVEDACCOUNTS)", + "Z_1INVOLVEDTRANSACTIONS sqlite_autoindex_Z_1INVOLVEDTRANSACTIONS_1: (auto)" + ], + "model_checksum": "wOm/tD2jkxoKsyP7GFXVNeebjqpLZbKZZ4EqYLkwlMk=", + "schema_version": "1.0.0" + }, + "inventory": { + "format_version": 1, + "models": { + "PersistentIdentity": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentity.swift", + "PersistentDPNSName": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDPNSName.swift", + "PersistentDashpayProfile": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayProfile.swift", + "PersistentDashpayContactProfile": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactProfile.swift", + "PersistentDashpayContactRequest": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactRequest.swift", + "PersistentDashpayPayment": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayPayment.swift", + "PersistentDashpayIgnoredSender": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayIgnoredSender.swift", + "PersistentDocument": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocument.swift", + "PersistentDataContract": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDataContract.swift", + "PersistentPublicKey": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift", + "PersistentTokenBalance": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTokenBalance.swift", + "PersistentKeyword": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentKeyword.swift", + "PersistentToken": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentToken.swift", + "PersistentDocumentType": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift", + "PersistentIndex": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIndex.swift", + "PersistentProperty": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentProperty.swift", + "PersistentTokenHistoryEvent": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTokenHistoryEvent.swift", + "PersistentPlatformAddress": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPlatformAddress.swift", + "PersistentPlatformAddressesSyncState": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPlatformAddressesSyncState.swift", + "PersistentWallet": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift", + "PersistentAccount": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift", + "PersistentCoreAddress": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentCoreAddress.swift", + "PersistentTransaction": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift", + "PersistentTxo": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift", + "PersistentPendingInput": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift", + "PersistentWalletManagerMetadata": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWalletManagerMetadata.swift", + "PersistentShieldedNote": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedNote.swift", + "PersistentShieldedOutgoingNote": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedOutgoingNote.swift", + "PersistentShieldedSyncState": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedSyncState.swift", + "PersistentShieldedActivity": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedActivity.swift", + "PersistentShieldedViewingKey": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedViewingKey.swift", + "PersistentAssetLock": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift", + "PersistentInvitation": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentInvitation.swift", + "PersistentMasternode": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentMasternode.swift" + }, + "value_types": [ + { + "path": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift", + "names": [ + "ChangeControlRules", + "AuthorizedActionTakers", + "TokenPerpetualDistribution", + "TokenPreProgrammedDistribution", + "DistributionEvent", + "TokenDistributionChangeRules", + "TokenTradeMode", + "TokenLocalization" + ] + } + ] + }, + "synthetic_record_counts": { + "ZPERSISTENTWALLET": 1, + "ZPERSISTENTDATACONTRACT": 1, + "ZPERSISTENTDOCUMENTTYPE": 1, + "ZPERSISTENTINDEX": 1 + }, + "capture_test_sha256": "84631bea640a9d88e35bd90d499a727cc929d8c5fc68154e8c85baf4d5b271a4", + "source_file_sha256": { + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift": "18d73c03dd50b71a4840301eb4cecf55d7c66aaeec2cd91471d256b6f416669d", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift": "d41e5c30fa0153f558965fc029541efbca6e11aab91edb8f54247957b97b499b", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift": "d7be59c9732e2a9323343b2e9dd4c87c6fa54fe3b117576f46c4ae882b96d30b", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentCoreAddress.swift": "83004a3791c8ddc2a47c079fe92b76ad322e0756f1d94b806822c3644986008b", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDPNSName.swift": "78b448497bfab1a7afd14373be528d2e6d64417d0342dbe6b8e4ff93dde022dc", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactProfile.swift": "ff1ca4b467acc4226a939d1c2280780e43f14f9f1efff079ba8f31445420be4f", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactRequest.swift": "3989bd07e064cad9bf6cefcfdf9bfa297f2d1b4ed0d568cb3c7696b27d593e56", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayIgnoredSender.swift": "ff95f860223b4e6a9cf25c47aaa77d8a50ae64a2536e0043a984d4838b6b5bc9", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayPayment.swift": "942342d9ae8dc1a7fc249028a0e8cb7efd37866db74ceb5d0a72090ef12241ce", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayProfile.swift": "ab1a8940629b6c24f65e0883a9162abc853d78959ff2bcfc34af193b7b4177ca", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDataContract.swift": "3b978e0e68079f8c43be75e30fc3e9a89919825219d0dfc4086e6b2aa7bba371", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocument.swift": "cf0d597ef74f657bec9f1d29177865c580f30d5fe597793b696ce26a48afe2cf", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift": "921452a774c00c01e3edcc3287a7c306666e8a0633bb77c413bb0c12fc6b7133", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentity.swift": "45717fbaf11052656d67121308952c729ea3f0026b9db9c876650987abcb6186", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIndex.swift": "b413448aae646fa910165bec2f5cb3d9d757ecd54b442caebceec4bccc161245", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentInvitation.swift": "2c7bfcb10891bf4e18426899239e05ec2497e22b480fc7095bd02f631b687b70", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentKeyword.swift": "955911db8593628f3f21a72ea2c1919ed67b23a1e656562b210a1699c6bf5e64", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentMasternode.swift": "74d07907b89b7fcc107b9096c32487d09dfb8840b121286b27639e84cd66abc9", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift": "99164c7a878ff66b4e7b77437da370a36aae277d0013ab58b9bb025f0ebaa0ba", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPlatformAddress.swift": "ccc8b1b748ab9fce12c4bba0c9d51ee4cce1c165f840146d0e4548519a4414a8", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPlatformAddressesSyncState.swift": "d110299d6f7eaaeadadab6ed7ef9a974a096d907a754e17856b12d9c6df40a37", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentProperty.swift": "971f0590cf5997656dd485c4b348aabad3d7ee6aab3f18422bbfd9694b272c15", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift": "07fbbd0a2f0022c40b6f5bc6a9684c50d6041877e4a86f9060e3d320c14f335a", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedActivity.swift": "87a1edf2f7f0e2ed7ba8ad6e8edd6b2f6971f83396854123fcdb1857559e2ed4", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedNote.swift": "26d386a26eb57505dab1d6bdbaf1d383e3af8b72e386631d87c7940db09f2cd9", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedOutgoingNote.swift": "209b7863cbb46cefe0b22ff46632f24cc8d182cc9209c1bfc305b37fb99252eb", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedSyncState.swift": "0c5a752cac01a40f2283ba5e0411e88296ec7256357f4f7c37a4e212c41efed5", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedViewingKey.swift": "0ba3e4ca1ee691c528c65c8708858c4b71c569d4ef53f7fe98ea6bd8bcdafa77", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentToken.swift": "0fc7393eac877e6a771d1aa8bfbbde98b4d7b4b06aa98853574508a3bd9e94f2", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTokenBalance.swift": "fdc0d15c10cb197b19aa416c33b27fc1423acb722dd21fc4440e611448882ee7", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTokenHistoryEvent.swift": "e0ea0a67954757944b557511adcac1ca21155128a27731ef251c3ea101214b34", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift": "2c546218c12a2c003aa62a7b16f077e517d67d320f035a18caf6b0d203b3138d", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift": "992249f8379300e43de74ce0bf4efcc05b186b1c9981068213e47d99aed24702", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift": "a84b5b7d85fdfbdb2b4cd6e191a148b7c8120031709de82b84a3eb4a641a1e08", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWalletManagerMetadata.swift": "50f2aa9a5c045e95e765181765941c7bda0f1830c447e79fd612c99300f5a39a", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift": "d03b8b0454739cceba6c09a4e1fe197fc462aacb621948ce4bf0939f7f38c9d0" + }, + "generated_file_sha256": { + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentAccount.swift": "865cf76f699756dacbaf0b82d912754aa50c5917a6e1052bdaa69b2ef9b74ec1", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentAssetLock.swift": "416bbe0d1c65bfbecdc52008a0f80ebb0e6b3d67526bba8171812de5e3ad2eaa", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentCoreAddress.swift": "eb4ce2e66b3bcb67b73ad4e92d290a85cb7310de09616f0bfe2df147e69e1684", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentDPNSName.swift": "23f4c4dc81a824606a97a433e4385352958385402435fb1ed2fe68bbd82da888", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentDashpayContactProfile.swift": "4834a1df5d7143bdf8d159a18ff66f87085e44979187e3ac836d614bc5d4cffb", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentDashpayContactRequest.swift": "cc3b07c012b1a7a76d18197211f3f5e0ca0c80630398164c892dcc293091c5ae", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentDashpayIgnoredSender.swift": "74954331970a5121161514e1a5cca2a2c2a0c9203aa877643be660f575a79270", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentDashpayPayment.swift": "c4e680f8c4dfdac4de9805bc3f9f270d3df160f56b35439e52c154d1d9a004ed", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentDashpayProfile.swift": "79d57bec33e6e451fb21ff9bd94b9298272f4673ea09c430d658c5659d2f74e0", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentDataContract.swift": "43b9a7273112cc78856160f1ea123787f5c03339fa80c9fdfa52ace09fc2092c", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentDocument.swift": "65a596be6f34c330b659a8728aa3dead63971e479d9c46ea1180f76add1c4552", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentDocumentType.swift": "d1ad81d8c53496363e290c40e242c4696db1308202bb477b578423152dc5edf4", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentIdentity.swift": "8e7bfc91d1938645762382f23e95c84fb8307eaa69faea7ba46acd18d30ea350", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentIndex.swift": "787f6f67590a17c47e59f610644c8dac1c224ea0bb799fba4e6bf82618c87f2a", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentInvitation.swift": "1bf9cecfb38e7dc9cad7d801b0d8b9ed66af047796713498915bcc244805fde5", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentKeyword.swift": "ac87c6205cd9874c268fa6bcafbdf1360a2da881de06905799a1f98184c5c676", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentMasternode.swift": "d720b933fa75cbf15784d48682361607553d68377e119c79236a289df047353d", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentPendingInput.swift": "77066c3cefc7b013c6dbe9ddaf0cdae9b963cdd952a0036ac205753b0ba80aec", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentPlatformAddress.swift": "af395900368c6e4c173c1728b54c0db503dab0df5a50ac88623dfa03e1fecf06", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentPlatformAddressesSyncState.swift": "639bf3f74ffb57a0fa1f327a5172b329292b65e02db6bd1537f32af0dc90f7a1", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentProperty.swift": "6b18f01fa5c8224ab5236a08ff969a3dce76ed4cde691eaa10022fdf66d5ebf1", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentPublicKey.swift": "32b690d6fb38bdb8bc5da3591599f72ad8699c01dd4fadd649a69b8d831dacea", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentShieldedActivity.swift": "59c5ee79088df4d28a8c1948e1a1b18d9ef818096e1891e46b3708eae0fdb0cf", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentShieldedNote.swift": "c0bda341fa61204d8b246fdd567d7c19429e60607b785de2aeee4f891f2a852d", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentShieldedOutgoingNote.swift": "0a1e69420ad3f67addc4c085b1671760699e743239f86ac367b8d67e9168a672", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentShieldedSyncState.swift": "46da086c3602d3f1dc55752e52dc671524455f55d3a5825e59924734b485d241", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentShieldedViewingKey.swift": "089c19f1185748434e45bdd7a6604c22839762a1ae1d8c9302f26c46ee5fa2ef", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentToken.swift": "fa5c3d0cd447b24ad380e788a26b1b531f8fa4a9921ed05fb497bd4c74953538", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentTokenBalance.swift": "f36f5d2bdb80731ccd23746ebe1a0a35ced50f6b7408b62e03144f455ee4d94d", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentTokenHistoryEvent.swift": "cea23ca88e8b77d1ce880c93ddb23b9c32b14eb8c87a2bbed5d689dd5439d011", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentTransaction.swift": "c0144541bcfc60ef5fb03fb27fdcf5b1bb032c050f7dcbb4e7690b9d4fb15f93", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentTxo.swift": "d20ef71b56be0a36ecfd74d9a535aa475ed003b2938efe72a56c55d2e1e0793d", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentWallet.swift": "333bbbc5356f229a9be73e9f12c037ea3bcf2b1522c9041ddf4c992682e3e308", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentWalletManagerMetadata.swift": "8ca2fb8d019235340fcca7aa2a2813cec206be1b9b7273583a7f3a357b387e92", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+Schema.swift": "cd99a70478c35346cdc069ff10a61b656b49fd4086479bbebcbbe4ecb7556a74", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+TokenTypes.swift": "3d6c8bf8af4f714951917b64b51bb056f43667cf78c3e3d0549425c7c3abd858" + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityBalanceMetadataPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityBalanceMetadataPersistenceTests.swift new file mode 100644 index 00000000000..c80eb07e7ef --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityBalanceMetadataPersistenceTests.swift @@ -0,0 +1,241 @@ +import XCTest +import SwiftData +import DashSDKFFI +@testable import SwiftDashSDK + +final class IdentityBalanceMetadataPersistenceTests: XCTestCase { + private let walletId = Data(repeating: 42, count: 32) + private let identityId = Data(repeating: 7, count: 32) + + private func persist(_ handler: PlatformWalletPersistenceHandler, balance: UInt64, + stamp: BlockTime?, success: Bool = true) throws { + handler.beginChangeset(walletId: walletId) + handler.persistIdentities(walletId: walletId, upserts: [.init( + identityId: identityId, balance: balance, revision: 1, identityIndex: 0, + label: nil, status: 2, walletId: walletId, dpnsNames: [], + dashpayProfile: nil, contactProfiles: [])], removed: []) + try handler.persistIdentityBalanceBlockTime(walletId: walletId, identityId: identityId, blockTime: stamp) + XCTAssertEqual(handler.endChangeset(walletId: walletId, success: success), success) + } + + private func seedWallet(_ container: ModelContainer) throws { + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + } + + func testBalanceAndWatermarkCommitAndRollbackTogether() throws { + let container = try DashModelContainer.createInMemory() + try seedWallet(container) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + try persist(handler, balance: 100, stamp: BlockTime(height: 20, core_height: 10, timestamp: 999)) + try persist(handler, balance: 50, stamp: BlockTime(height: 21, core_height: 11, timestamp: 1000), success: false) + let rows = try ModelContext(container).fetch(FetchDescriptor()) + XCTAssertEqual(rows.first?.balance, 100) + let stamp = try XCTUnwrap(handler.loadIdentityBalanceBlockTime(walletId: walletId, identityId: identityId)) + XCTAssertEqual(stamp.height, 20) + XCTAssertEqual(stamp.timestamp, 999) + } + + func testMetadataSurvivesClosingAndReopeningStore() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("wallet.store") + try writeStore(url) + let reopened = try DashModelContainer.create(url: url) + let handler = PlatformWalletPersistenceHandler(modelContainer: reopened, network: .testnet) + let stamp = try XCTUnwrap(handler.loadIdentityBalanceBlockTime(walletId: walletId, identityId: identityId)) + XCTAssertEqual(stamp.height, UInt64.max) + XCTAssertEqual(stamp.core_height, UInt32.max) + XCTAssertEqual(stamp.timestamp, UInt64.max) + XCTAssertEqual(try ModelContext(reopened).fetch(FetchDescriptor()).first?.balance, 123) + } + + private func writeStore(_ url: URL) throws { + let container = try DashModelContainer.create(url: url) + try seedWallet(container) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + try persist(handler, balance: 123, stamp: BlockTime(height: .max, core_height: .max, timestamp: .max)) + } + + func testAbsentAndZeroMetadataAreDistinctAndScopedByNetworkAndWallet() throws { + let container = try DashModelContainer.createInMemory() + try seedWallet(container) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertNil(try handler.loadIdentityBalanceBlockTime(walletId: walletId, identityId: identityId)) + try persist(handler, balance: 0, stamp: BlockTime(height: 0, core_height: 0, timestamp: 0)) + XCTAssertNotNil(try handler.loadIdentityBalanceBlockTime(walletId: walletId, identityId: identityId)) + XCTAssertNil(try handler.loadIdentityBalanceBlockTime(walletId: Data(repeating: 43, count: 32), identityId: identityId)) + let otherNetwork = PlatformWalletPersistenceHandler(modelContainer: container, network: .mainnet) + XCTAssertNil(try otherNetwork.loadIdentityBalanceBlockTime(walletId: walletId, identityId: identityId)) + try persist(handler, balance: 0, stamp: nil) + XCTAssertNil(try handler.loadIdentityBalanceBlockTime(walletId: walletId, identityId: identityId)) + } + + func testLegacyHandlerDefaultsUnresolvedWalletNetworkToTestnet() throws { + for hasWalletRow in [false, true] { + let container = try DashModelContainer.createInMemory() + if hasWalletRow { + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId)) + try context.save() + } + let handler = PlatformWalletPersistenceHandler(modelContainer: container) + XCTAssertNil(try handler.loadIdentityBalanceBlockTime(walletId: walletId, identityId: identityId)) + try persist(handler, balance: 100, stamp: BlockTime(height: 20, core_height: 10, timestamp: 999)) + + let context = ModelContext(container) + let identity = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + let metadata = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + XCTAssertEqual(identity.balance, 100) + XCTAssertEqual(identity.networkRaw, Network.testnet.rawValue) + XCTAssertEqual(metadata.networkRaw, identity.networkRaw) + let reader = PlatformWalletPersistenceHandler(modelContainer: container) + let stamp = try XCTUnwrap(reader.loadIdentityBalanceBlockTime(walletId: walletId, identityId: identityId)) + XCTAssertEqual(stamp.height, 20) + XCTAssertEqual(stamp.core_height, 10) + XCTAssertEqual(stamp.timestamp, 999) + + try persist(handler, balance: 50, stamp: nil) + XCTAssertNil(try handler.loadIdentityBalanceBlockTime(walletId: walletId, identityId: identityId)) + } + } + + func testLegacyHandlerUsesResolvedWalletNetworkBeforeTestnetFallback() throws { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .mainnet)) + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container) + try persist(handler, balance: 100, stamp: BlockTime(height: 20, core_height: 10, timestamp: 999)) + let rows = try ModelContext(container).fetch(FetchDescriptor()) + XCTAssertEqual(rows.count, 1) + XCTAssertEqual(rows.first?.networkRaw, Network.mainnet.rawValue) + XCTAssertEqual(try handler.loadIdentityBalanceBlockTime(walletId: walletId, identityId: identityId)?.height, 20) + } + + func testWalletDeletionPurgesOrphanedMetadataWithoutNetworkAndPreservesOtherWallets() throws { + for hasWalletRow in [false, true] { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + if hasWalletRow { + context.insert(PersistentWallet(walletId: walletId)) + } + // Neither network has a claiming wallet row for this ID. + // Both sidecars are orphans; another wallet's rows must survive. + let otherWalletId = Data(repeating: 43, count: 32) + for id in [walletId, otherWalletId] { + for network in [Network.testnet, .mainnet] { + context.insert(PersistentIdentityBalanceMetadata( + networkRaw: network.rawValue, walletId: id, identityId: identityId, + platformHeight: 20, coreHeight: 10, timestampMillis: 999)) + } + } + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container) + try handler.deleteWalletData(walletId: walletId) + // Retrying after the wallet row is gone remains safe and idempotent. + try handler.deleteWalletData(walletId: walletId) + let remaining = try ModelContext(container).fetch(FetchDescriptor()) + XCTAssertEqual(remaining.count, 2) + XCTAssertTrue(remaining.allSatisfy { $0.walletId == otherWalletId }) + XCTAssertEqual(Set(remaining.map(\.networkRaw)), Set([Network.testnet.rawValue, Network.mainnet.rawValue])) + } + } + + func testNetworkScopedDeletionPreservesSameIdSidecarsOnOtherNetworks() throws { + for walletNetwork in [Network.testnet, .mainnet] { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + // The model has globally unique wallet IDs: use one real wallet row, + // including a surviving mainnet row when the testnet row is absent. + context.insert(PersistentWallet(walletId: walletId, network: walletNetwork)) + for network in [Network.testnet, .mainnet] { + context.insert(PersistentIdentityBalanceMetadata( + networkRaw: network.rawValue, walletId: walletId, identityId: identityId, + platformHeight: 20, coreHeight: 10, timestampMillis: 999)) + } + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + try handler.deleteWalletData(walletId: walletId) + try handler.deleteWalletData(walletId: walletId) + + let readContext = ModelContext(container) + let remaining = try readContext.fetch(FetchDescriptor()) + XCTAssertEqual(remaining.count, 1) + XCTAssertEqual(remaining.first?.networkRaw, Network.mainnet.rawValue) + let wallets = try readContext.fetch(FetchDescriptor()) + XCTAssertEqual(wallets.count, walletNetwork == .mainnet ? 1 : 0) + if walletNetwork == .mainnet { + XCTAssertEqual(wallets.first?.network, .mainnet) + } + } + } + + func testLegacyDeletionUsesWalletNetworkThenPurgesUnclaimedOrphansOnRetry() throws { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .mainnet)) + for network in [Network.testnet, .mainnet] { + context.insert(PersistentIdentityBalanceMetadata( + networkRaw: network.rawValue, walletId: walletId, identityId: identityId, + platformHeight: 20, coreHeight: 10, timestampMillis: 999)) + } + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container) + try handler.deleteWalletData(walletId: walletId) + let remaining = try ModelContext(container).fetch(FetchDescriptor()) + XCTAssertEqual(remaining.count, 1) + XCTAssertEqual(remaining.first?.networkRaw, Network.testnet.rawValue) + // With no wallet row or explicit network left, this row is unclaimed. + try handler.deleteWalletData(walletId: walletId) + XCTAssertTrue(try ModelContext(container).fetch(FetchDescriptor()).isEmpty) + } + + func testWalletDeletionRemovesMetadata() throws { + let container = try DashModelContainer.createInMemory() + try seedWallet(container) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + try persist(handler, balance: 1, stamp: BlockTime(height: 1, core_height: 1, timestamp: 1)) + try handler.deleteWalletData(walletId: walletId) + XCTAssertNil(try handler.loadIdentityBalanceBlockTime(walletId: walletId, identityId: identityId)) + } + + func testExtensionCallbacksRoundTripAndClearMetadata() throws { + let container = try DashModelContainer.createInMemory() + try seedWallet(container) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + let callbacks = handler.makePersistenceCallbacksExtension() + let persist = try XCTUnwrap(callbacks.on_persist_identity_balance_block_time_fn) + let load = try XCTUnwrap(callbacks.on_load_identity_balance_block_time_fn) + let context = Unmanaged.passUnretained(handler).toOpaque() + walletId.withUnsafeBytes { wallet in + identityId.withUnsafeBytes { identity in + let walletBytes = wallet.bindMemory(to: UInt8.self).baseAddress! + let identityBytes = identity.bindMemory(to: UInt8.self).baseAddress! + var stamp = BlockTime(height: 42, core_height: 7, timestamp: 123) + handler.beginChangeset(walletId: walletId) + XCTAssertEqual(persist(context, walletBytes, identityBytes, &stamp), 0) + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + var found = false + var loaded = BlockTime() + XCTAssertEqual(load(context, walletBytes, identityBytes, &found, &loaded), 0) + XCTAssertTrue(found) + XCTAssertEqual(loaded.height, 42) + XCTAssertEqual(loaded.core_height, 7) + XCTAssertEqual(loaded.timestamp, 123) + handler.beginChangeset(walletId: walletId) + XCTAssertEqual(persist(context, walletBytes, identityBytes, nil), 0) + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + XCTAssertEqual(load(context, walletBytes, identityBytes, &found, &loaded), 0) + XCTAssertFalse(found) + XCTAssertEqual(load(nil, walletBytes, identityBytes, &found, &loaded), -1) + } + } + } + + func testUnavailableBalanceHasDistinctSwiftError() throws { + XCTAssertEqual(PlatformWalletResultCode(ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_IDENTITY_BALANCE_UNAVAILABLE).rawValue, 58) + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityBalanceMetadataSchemaTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityBalanceMetadataSchemaTests.swift new file mode 100644 index 00000000000..8d0fb78c04f --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityBalanceMetadataSchemaTests.swift @@ -0,0 +1,47 @@ +import XCTest +import SwiftData +@testable import SwiftDashSDK + +final class IdentityBalanceMetadataSchemaTests: XCTestCase { + func testFullWidthMetadataSurvivesStoreReopen() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("wallet.store") + try writeMetadata(url) + let reopened = try DashModelContainer.create(url: url) + let context = ModelContext(reopened) + let rows = try context.fetch(FetchDescriptor()) + XCTAssertEqual(rows.count, 1) + let row = try XCTUnwrap(rows.first) + XCTAssertEqual(UInt64(bitPattern: row.platformHeight), .max) + XCTAssertEqual(row.coreHeight, .max) + XCTAssertEqual(UInt64(bitPattern: row.timestampMillis), .max) + XCTAssertEqual(row.walletId, Data(repeating: 42, count: 32)) + XCTAssertEqual(row.identityId, Data(repeating: 7, count: 32)) + } + + private func writeMetadata(_ url: URL) throws { + let container = try DashModelContainer.create(url: url) + let context = ModelContext(container) + context.insert(PersistentIdentityBalanceMetadata( + networkRaw: Network.testnet.rawValue, walletId: Data(repeating: 42, count: 32), + identityId: Data(repeating: 7, count: 32), platformHeight: .max, + coreHeight: .max, timestampMillis: .max)) + try context.save() + } + + func testSameIdentityMetadataIsIsolatedByNetworkAndWallet() throws { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + for (network, wallet, height): (UInt32, UInt8, UInt64) in [(0, 42, 1), (1, 42, 2), (1, 43, 3)] { + context.insert(PersistentIdentityBalanceMetadata(networkRaw: network, + walletId: Data(repeating: wallet, count: 32), identityId: Data(repeating: 7, count: 32), + platformHeight: height, coreHeight: 0, timestampMillis: 0)) + } + try context.save() + let rows = try ModelContext(container).fetch(FetchDescriptor()) + XCTAssertEqual(rows.count, 3) + XCTAssertEqual(Set(rows.map { $0.platformHeight }), [1, 2, 3]) + } +} diff --git a/packages/swift-sdk/schema-models.json b/packages/swift-sdk/schema-models.json new file mode 100644 index 00000000000..439a181f8e3 --- /dev/null +++ b/packages/swift-sdk/schema-models.json @@ -0,0 +1,56 @@ +{ + "format_version": 1, + "models": { + "PersistentIdentity": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentity.swift", + "PersistentDPNSName": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDPNSName.swift", + "PersistentDashpayProfile": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayProfile.swift", + "PersistentDashpayContactProfile": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactProfile.swift", + "PersistentDashpayContactRequest": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactRequest.swift", + "PersistentDashpayPayment": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayPayment.swift", + "PersistentDashpayIgnoredSender": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayIgnoredSender.swift", + "PersistentDocument": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocument.swift", + "PersistentDataContract": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDataContract.swift", + "PersistentPublicKey": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift", + "PersistentTokenBalance": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTokenBalance.swift", + "PersistentKeyword": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentKeyword.swift", + "PersistentToken": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentToken.swift", + "PersistentDocumentType": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift", + "PersistentIndex": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIndex.swift", + "PersistentProperty": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentProperty.swift", + "PersistentTokenHistoryEvent": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTokenHistoryEvent.swift", + "PersistentPlatformAddress": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPlatformAddress.swift", + "PersistentPlatformAddressesSyncState": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPlatformAddressesSyncState.swift", + "PersistentWallet": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift", + "PersistentAccount": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift", + "PersistentCoreAddress": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentCoreAddress.swift", + "PersistentTransaction": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift", + "PersistentTxo": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift", + "PersistentPendingInput": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift", + "PersistentWalletManagerMetadata": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWalletManagerMetadata.swift", + "PersistentShieldedNote": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedNote.swift", + "PersistentShieldedOutgoingNote": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedOutgoingNote.swift", + "PersistentShieldedSyncState": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedSyncState.swift", + "PersistentShieldedActivity": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedActivity.swift", + "PersistentShieldedViewingKey": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedViewingKey.swift", + "PersistentAssetLock": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift", + "PersistentInvitation": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentInvitation.swift", + "PersistentMasternode": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentMasternode.swift", + "PersistentTrackedMasternode": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTrackedMasternode.swift", + "PersistentIdentityBalanceMetadata": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentityBalanceMetadata.swift" + }, + "value_types": [ + { + "path": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift", + "names": [ + "ChangeControlRules", + "AuthorizedActionTakers", + "TokenPerpetualDistribution", + "TokenPreProgrammedDistribution", + "DistributionEvent", + "TokenDistributionChangeRules", + "TokenTradeMode", + "TokenLocalization" + ] + } + ] +} diff --git a/packages/swift-sdk/schema-releases.json b/packages/swift-sdk/schema-releases.json new file mode 100644 index 00000000000..1cb3a4f4365 --- /dev/null +++ b/packages/swift-sdk/schema-releases.json @@ -0,0 +1,155 @@ +{ + "format_version": 1, + "schemas": {}, + "releases": {}, + "historical_schemas": { + "2.0.0": { + "schema": { + "schema_version": "2.0.0", + "model_checksum": "RrRj/iNbS9izgLQvNb2APed4iwaR7pftEE2+4tea7K8=", + "entity_hashes": { + "PersistentAccount": "a5d5dc075d2d634d1a6f0ce025fe0aa323e2747443119c94eb65d024aa47985d", + "PersistentAssetLock": "7fb4ccbc526302d09df6f3f11f9d9eb269c2dd33ee93e1a4a31f72714145399c", + "PersistentCoreAddress": "2518791fe1ebe9fbab8586ba08cb0a527259360e6cfe710287ca73f8959dd14c", + "PersistentDPNSName": "0b3c83f1ed265b4ef9e4ee404d0354f3da7eb70004d38d8bded2f5bae4debaed", + "PersistentDashpayContactProfile": "a5c249ffa9d298c53329262cb85edbb5fc5b77a67f43d7343277ce58d8d4b9d3", + "PersistentDashpayContactRequest": "3ed2ec9984523024a43af961442d85cf02f5db87302e1c8aa68ef9c407d12901", + "PersistentDashpayIgnoredSender": "5386ff4fc63f0b1dfd6a42d6e9b3db3c54339691cb55593ce169c5ba4378f680", + "PersistentDashpayPayment": "5aac0e1c8d892a243dba2967170a0cee067fc56c183b2f4af06c4b66b2839630", + "PersistentDashpayProfile": "e88ec9d87a07af8c2e1faef17b0206a61c9d59322552aab0ffe3133b7059885e", + "PersistentDataContract": "c869c0a2df469be22643847a3e8331e156f364e93aefde2aedd13ef6067bcd66", + "PersistentDocument": "1e143af2e59ef70f72385d661d7ba5d1484fee34f97d9e8d0b2b56fdc879a590", + "PersistentDocumentType": "c3689448841fb9d75e55153213f960504ee839bbc6d695b33b5021d88076c41b", + "PersistentIdentity": "d0e738b1edb8dae40c4b3f916196b6bdcaa9fe499752d211bb69204d80a83418", + "PersistentIndex": "889455232bbb1ac94ab79ccefb6a1ad7de185c6b6e8c9aa7cdc01ed453d659af", + "PersistentInvitation": "4a0a75d2196279e70a911f6d7686f9e29441b582b19ac00b67bc66e88452cf57", + "PersistentKeyword": "286ac09d230fd54050fa33f989e478745766f6e456377137b124d05f2e1a82fb", + "PersistentMasternode": "b33b6a7c27a4df67e0319b96e1f4337e9a67e8213d0d97d540167a140967d656", + "PersistentPendingInput": "483084c729757b6a0acc3118f74773ade4f68b3d6210229229e95a9e49e7ca1e", + "PersistentPlatformAddress": "0cae31a85f8e343c9fe45a8bb44a14958f688456ad6a5c9e39a1331c46e16039", + "PersistentPlatformAddressesSyncState": "de52a03d68033a8cf1bf7e95f2e8f1b8e318ef67cd289ffa9f60595c0a4e24b9", + "PersistentProperty": "c7082d8dcaf538d2eb88916033b8b40f228bd399d758999f2dd0470708fc94b6", + "PersistentPublicKey": "d369346faee596c187c6851eec77d9251414f788f9a11579842cf03492380585", + "PersistentShieldedActivity": "1aef5f0ec88b4be4ccd1a549379c7ca0dfa75b6f48909c22538ba2687d487409", + "PersistentShieldedNote": "d2986310786e015f98f92c5b6cd84db1b1e98eadd623e98d94ebae778cb1e650", + "PersistentShieldedOutgoingNote": "e749ad68716dccc346b798b23c98e21072fd0df19f344bf4b752e15605fe42f1", + "PersistentShieldedSyncState": "2130e7484885f36267753ce4f6d39e216aa6b932e3660234a12b29a136335769", + "PersistentShieldedViewingKey": "bc07713a4b0a63ffdec2e53369e97f089e8de905ab2e8e7e270f71bec7668cdc", + "PersistentToken": "85dacb8b1556f06c9ece6bac94e185e0fdbd8f62554f2b5eca444c121649567c", + "PersistentTokenBalance": "6ea32b253749c13392e4c7aa471b0872d4b1a9d36bb260beb9c6bb4d40a7474e", + "PersistentTokenHistoryEvent": "6e67103e1a6caf7d04f4b5a44d1012a470fef341c36c6cefbfc0e76b440a00ab", + "PersistentTrackedMasternode": "a187e0384fc3ae3619b61b4fcb4004920846d2dce7c533875146db8c6aeb882a", + "PersistentTransaction": "daad9b22c2b6c554cc281e7e00eff39ec366373fbaf9553e03ec38ad8f794fbb", + "PersistentTxo": "0a8a83ff1b93058115b34147cc5e797c5691a4975a6892e5d11474ba9d6772b7", + "PersistentWallet": "d2ae76969b1d92b66c6e7df62f7e0ae12e05be2498c9ecf79e3b7d0aa7f96c81", + "PersistentWalletManagerMetadata": "19dbe2cfb7e0c63fa6cd69291e5b1865f58642f43a156db0d9e06028a7bd4d99" + }, + "indexes": [ + "ACHANGE ACHANGE_ZTRANSACTIONID_INDEX: CREATE INDEX ACHANGE_ZTRANSACTIONID_INDEX ON ACHANGE (ZTRANSACTIONID)", + "ATRANSACTION ATRANSACTION_ZAUTHORTS_INDEX: CREATE INDEX ATRANSACTION_ZAUTHORTS_INDEX ON ATRANSACTION (ZAUTHORTS)", + "ATRANSACTION ATRANSACTION_ZBUNDLEIDTS_INDEX: CREATE INDEX ATRANSACTION_ZBUNDLEIDTS_INDEX ON ATRANSACTION (ZBUNDLEIDTS)", + "ATRANSACTION ATRANSACTION_ZCONTEXTNAMETS_INDEX: CREATE INDEX ATRANSACTION_ZCONTEXTNAMETS_INDEX ON ATRANSACTION (ZCONTEXTNAMETS)", + "ATRANSACTION ATRANSACTION_ZPROCESSIDTS_INDEX: CREATE INDEX ATRANSACTION_ZPROCESSIDTS_INDEX ON ATRANSACTION (ZPROCESSIDTS)", + "ATRANSACTION Z_TRANSACTION_TransactionAuthorIndex: CREATE INDEX Z_TRANSACTION_TransactionAuthorIndex ON ATRANSACTION (ZAUTHOR COLLATE BINARY ASC)", + "ATRANSACTION Z_TRANSACTION_TransactionTimestampIndex: CREATE INDEX Z_TRANSACTION_TransactionTimestampIndex ON ATRANSACTION (ZTIMESTAMP COLLATE BINARY ASC)", + "ATRANSACTIONSTRING Z_TRANSACTIONSTRING_UNIQUE_NAME: CREATE UNIQUE INDEX Z_TRANSACTIONSTRING_UNIQUE_NAME ON ATRANSACTIONSTRING (ZNAME COLLATE BINARY ASC)", + "ZPERSISTENTACCOUNT ZPERSISTENTACCOUNT_ZWALLET_INDEX: CREATE INDEX ZPERSISTENTACCOUNT_ZWALLET_INDEX ON ZPERSISTENTACCOUNT (ZWALLET)", + "ZPERSISTENTACCOUNT Z_PersistentAccount_UNIQUE_accountExtendedPubKeyBytes: CREATE UNIQUE INDEX Z_PersistentAccount_UNIQUE_accountExtendedPubKeyBytes ON ZPERSISTENTACCOUNT (ZACCOUNTEXTENDEDPUBKEYBYTES COLLATE BINARY ASC)", + "ZPERSISTENTACCOUNT Z_PersistentAccount_UNIQUE_wallet_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId: CREATE UNIQUE INDEX Z_PersistentAccount_UNIQUE_wallet_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId ON ZPERSISTENTACCOUNT (ZWALLET COLLATE BINARY ASC, ZACCOUNTTYPE COLLATE BINARY ASC, ZACCOUNTINDEX COLLATE BINARY ASC, ZSTANDARDTAG COLLATE BINARY ASC, ZREGISTRATIONINDEX COLLATE BINARY ASC, ZKEYCLASS COLLATE BINARY ASC, ZUSERIDENTITYID COLLATE BINARY ASC, ZFRIENDIDENTITYID COLLATE BINARY ASC)", + "ZPERSISTENTASSETLOCK Z_PersistentAssetLock_SwiftDataIndexOnBinarywalletId: CREATE INDEX Z_PersistentAssetLock_SwiftDataIndexOnBinarywalletId ON ZPERSISTENTASSETLOCK (ZWALLETID COLLATE BINARY ASC)", + "ZPERSISTENTASSETLOCK Z_PersistentAssetLock_UNIQUE_outPointHex: CREATE UNIQUE INDEX Z_PersistentAssetLock_UNIQUE_outPointHex ON ZPERSISTENTASSETLOCK (ZOUTPOINTHEX COLLATE BINARY ASC)", + "ZPERSISTENTCOREADDRESS ZPERSISTENTCOREADDRESS_ZACCOUNT_INDEX: CREATE INDEX ZPERSISTENTCOREADDRESS_ZACCOUNT_INDEX ON ZPERSISTENTCOREADDRESS (ZACCOUNT)", + "ZPERSISTENTCOREADDRESS Z_PersistentCoreAddress_UNIQUE_address: CREATE UNIQUE INDEX Z_PersistentCoreAddress_UNIQUE_address ON ZPERSISTENTCOREADDRESS (ZADDRESS COLLATE BINARY ASC)", + "ZPERSISTENTDASHPAYCONTACTPROFILE ZPERSISTENTDASHPAYCONTACTPROFILE_ZOWNER_INDEX: CREATE INDEX ZPERSISTENTDASHPAYCONTACTPROFILE_ZOWNER_INDEX ON ZPERSISTENTDASHPAYCONTACTPROFILE (ZOWNER)", + "ZPERSISTENTDASHPAYCONTACTPROFILE Z_PersistentDashpayContactProfile_UNIQUE_networkRaw_ownerIdentityId_contactIdentityId: CREATE UNIQUE INDEX Z_PersistentDashpayContactProfile_UNIQUE_networkRaw_ownerIdentityId_contactIdentityId ON ZPERSISTENTDASHPAYCONTACTPROFILE (ZNETWORKRAW COLLATE BINARY ASC, ZOWNERIDENTITYID COLLATE BINARY ASC, ZCONTACTIDENTITYID COLLATE BINARY ASC)", + "ZPERSISTENTDASHPAYCONTACTREQUEST ZPERSISTENTDASHPAYCONTACTREQUEST_ZOWNER_INDEX: CREATE INDEX ZPERSISTENTDASHPAYCONTACTREQUEST_ZOWNER_INDEX ON ZPERSISTENTDASHPAYCONTACTREQUEST (ZOWNER)", + "ZPERSISTENTDASHPAYCONTACTREQUEST Z_PersistentDashpayContactRequest_UNIQUE_networkRaw_ownerIdentityId_contactIdentityId_isOutgoing: CREATE UNIQUE INDEX Z_PersistentDashpayContactRequest_UNIQUE_networkRaw_ownerIdentityId_contactIdentityId_isOutgoing ON ZPERSISTENTDASHPAYCONTACTREQUEST (ZNETWORKRAW COLLATE BINARY ASC, ZOWNERIDENTITYID COLLATE BINARY ASC, ZCONTACTIDENTITYID COLLATE BINARY ASC, ZISOUTGOING COLLATE BINARY ASC)", + "ZPERSISTENTDASHPAYIGNOREDSENDER ZPERSISTENTDASHPAYIGNOREDSENDER_ZOWNER_INDEX: CREATE INDEX ZPERSISTENTDASHPAYIGNOREDSENDER_ZOWNER_INDEX ON ZPERSISTENTDASHPAYIGNOREDSENDER (ZOWNER)", + "ZPERSISTENTDASHPAYIGNOREDSENDER Z_PersistentDashpayIgnoredSender_UNIQUE_networkRaw_ownerIdentityId_ignoredSenderId: CREATE UNIQUE INDEX Z_PersistentDashpayIgnoredSender_UNIQUE_networkRaw_ownerIdentityId_ignoredSenderId ON ZPERSISTENTDASHPAYIGNOREDSENDER (ZNETWORKRAW COLLATE BINARY ASC, ZOWNERIDENTITYID COLLATE BINARY ASC, ZIGNOREDSENDERID COLLATE BINARY ASC)", + "ZPERSISTENTDASHPAYPAYMENT ZPERSISTENTDASHPAYPAYMENT_ZOWNER_INDEX: CREATE INDEX ZPERSISTENTDASHPAYPAYMENT_ZOWNER_INDEX ON ZPERSISTENTDASHPAYPAYMENT (ZOWNER)", + "ZPERSISTENTDASHPAYPAYMENT Z_PersistentDashpayPayment_UNIQUE_networkRaw_ownerIdentityId_txid: CREATE UNIQUE INDEX Z_PersistentDashpayPayment_UNIQUE_networkRaw_ownerIdentityId_txid ON ZPERSISTENTDASHPAYPAYMENT (ZNETWORKRAW COLLATE BINARY ASC, ZOWNERIDENTITYID COLLATE BINARY ASC, ZTXID COLLATE BINARY ASC)", + "ZPERSISTENTDASHPAYPROFILE ZPERSISTENTDASHPAYPROFILE_ZIDENTITY_INDEX: CREATE INDEX ZPERSISTENTDASHPAYPROFILE_ZIDENTITY_INDEX ON ZPERSISTENTDASHPAYPROFILE (ZIDENTITY)", + "ZPERSISTENTDASHPAYPROFILE Z_PersistentDashpayProfile_UNIQUE_networkRaw_identity: CREATE UNIQUE INDEX Z_PersistentDashpayProfile_UNIQUE_networkRaw_identity ON ZPERSISTENTDASHPAYPROFILE (ZNETWORKRAW COLLATE BINARY ASC, ZIDENTITY COLLATE BINARY ASC)", + "ZPERSISTENTDATACONTRACT ZPERSISTENTDATACONTRACT_ZOWNERIDENTITY_INDEX: CREATE INDEX ZPERSISTENTDATACONTRACT_ZOWNERIDENTITY_INDEX ON ZPERSISTENTDATACONTRACT (ZOWNERIDENTITY)", + "ZPERSISTENTDATACONTRACT Z_PersistentDataContract_SwiftDataIndexOnBinarynetworkRaw: CREATE INDEX Z_PersistentDataContract_SwiftDataIndexOnBinarynetworkRaw ON ZPERSISTENTDATACONTRACT (ZNETWORKRAW COLLATE BINARY ASC)", + "ZPERSISTENTDATACONTRACT Z_PersistentDataContract_UNIQUE_id: CREATE UNIQUE INDEX Z_PersistentDataContract_UNIQUE_id ON ZPERSISTENTDATACONTRACT (ZID COLLATE BINARY ASC)", + "ZPERSISTENTDOCUMENT ZPERSISTENTDOCUMENT_ZDATACONTRACT_INDEX: CREATE INDEX ZPERSISTENTDOCUMENT_ZDATACONTRACT_INDEX ON ZPERSISTENTDOCUMENT (ZDATACONTRACT)", + "ZPERSISTENTDOCUMENT ZPERSISTENTDOCUMENT_ZDOCUMENTTYPE_RELATION_INDEX: CREATE INDEX ZPERSISTENTDOCUMENT_ZDOCUMENTTYPE_RELATION_INDEX ON ZPERSISTENTDOCUMENT (ZDOCUMENTTYPE_RELATION)", + "ZPERSISTENTDOCUMENT ZPERSISTENTDOCUMENT_ZOWNERIDENTITY_INDEX: CREATE INDEX ZPERSISTENTDOCUMENT_ZOWNERIDENTITY_INDEX ON ZPERSISTENTDOCUMENT (ZOWNERIDENTITY)", + "ZPERSISTENTDOCUMENT Z_PersistentDocument_SwiftDataIndexOnBinarynetworkRaw: CREATE INDEX Z_PersistentDocument_SwiftDataIndexOnBinarynetworkRaw ON ZPERSISTENTDOCUMENT (ZNETWORKRAW COLLATE BINARY ASC)", + "ZPERSISTENTDOCUMENT Z_PersistentDocument_UNIQUE_documentId: CREATE UNIQUE INDEX Z_PersistentDocument_UNIQUE_documentId ON ZPERSISTENTDOCUMENT (ZDOCUMENTID COLLATE BINARY ASC)", + "ZPERSISTENTDOCUMENTTYPE ZPERSISTENTDOCUMENTTYPE_ZDATACONTRACT_INDEX: CREATE INDEX ZPERSISTENTDOCUMENTTYPE_ZDATACONTRACT_INDEX ON ZPERSISTENTDOCUMENTTYPE (ZDATACONTRACT)", + "ZPERSISTENTDOCUMENTTYPE Z_PersistentDocumentType_UNIQUE_id: CREATE UNIQUE INDEX Z_PersistentDocumentType_UNIQUE_id ON ZPERSISTENTDOCUMENTTYPE (ZID COLLATE BINARY ASC)", + "ZPERSISTENTDPNSNAME ZPERSISTENTDPNSNAME_ZIDENTITY_INDEX: CREATE INDEX ZPERSISTENTDPNSNAME_ZIDENTITY_INDEX ON ZPERSISTENTDPNSNAME (ZIDENTITY)", + "ZPERSISTENTDPNSNAME Z_PersistentDPNSName_UNIQUE_networkRaw_normalizedParentDomainName_normalizedLabel: CREATE UNIQUE INDEX Z_PersistentDPNSName_UNIQUE_networkRaw_normalizedParentDomainName_normalizedLabel ON ZPERSISTENTDPNSNAME (ZNETWORKRAW COLLATE BINARY ASC, ZNORMALIZEDPARENTDOMAINNAME COLLATE BINARY ASC, ZNORMALIZEDLABEL COLLATE BINARY ASC)", + "ZPERSISTENTIDENTITY ZPERSISTENTIDENTITY_ZDASHPAYPROFILE_INDEX: CREATE INDEX ZPERSISTENTIDENTITY_ZDASHPAYPROFILE_INDEX ON ZPERSISTENTIDENTITY (ZDASHPAYPROFILE)", + "ZPERSISTENTIDENTITY ZPERSISTENTIDENTITY_ZWALLET_INDEX: CREATE INDEX ZPERSISTENTIDENTITY_ZWALLET_INDEX ON ZPERSISTENTIDENTITY (ZWALLET)", + "ZPERSISTENTIDENTITY Z_PersistentIdentity_SwiftDataIndexOnBinarynetworkRaw: CREATE INDEX Z_PersistentIdentity_SwiftDataIndexOnBinarynetworkRaw ON ZPERSISTENTIDENTITY (ZNETWORKRAW COLLATE BINARY ASC)", + "ZPERSISTENTIDENTITY Z_PersistentIdentity_UNIQUE_identityId: CREATE UNIQUE INDEX Z_PersistentIdentity_UNIQUE_identityId ON ZPERSISTENTIDENTITY (ZIDENTITYID COLLATE BINARY ASC)", + "ZPERSISTENTINDEX ZPERSISTENTINDEX_ZDOCUMENTTYPE_INDEX: CREATE INDEX ZPERSISTENTINDEX_ZDOCUMENTTYPE_INDEX ON ZPERSISTENTINDEX (ZDOCUMENTTYPE)", + "ZPERSISTENTINDEX Z_PersistentIndex_UNIQUE_id: CREATE UNIQUE INDEX Z_PersistentIndex_UNIQUE_id ON ZPERSISTENTINDEX (ZID COLLATE BINARY ASC)", + "ZPERSISTENTINVITATION Z_PersistentInvitation_SwiftDataIndexOnBinarywalletId: CREATE INDEX Z_PersistentInvitation_SwiftDataIndexOnBinarywalletId ON ZPERSISTENTINVITATION (ZWALLETID COLLATE BINARY ASC)", + "ZPERSISTENTINVITATION Z_PersistentInvitation_UNIQUE_outPointHex: CREATE UNIQUE INDEX Z_PersistentInvitation_UNIQUE_outPointHex ON ZPERSISTENTINVITATION (ZOUTPOINTHEX COLLATE BINARY ASC)", + "ZPERSISTENTKEYWORD ZPERSISTENTKEYWORD_ZDATACONTRACT_INDEX: CREATE INDEX ZPERSISTENTKEYWORD_ZDATACONTRACT_INDEX ON ZPERSISTENTKEYWORD (ZDATACONTRACT)", + "ZPERSISTENTKEYWORD Z_PersistentKeyword_UNIQUE_id: CREATE UNIQUE INDEX Z_PersistentKeyword_UNIQUE_id ON ZPERSISTENTKEYWORD (ZID COLLATE BINARY ASC)", + "ZPERSISTENTMASTERNODE Z_PersistentMasternode_UNIQUE_walletId_proTxHash: CREATE UNIQUE INDEX Z_PersistentMasternode_UNIQUE_walletId_proTxHash ON ZPERSISTENTMASTERNODE (ZWALLETID COLLATE BINARY ASC, ZPROTXHASH COLLATE BINARY ASC)", + "ZPERSISTENTPENDINGINPUT ZPERSISTENTPENDINGINPUT_ZSPENDINGTRANSACTION_INDEX: CREATE INDEX ZPERSISTENTPENDINGINPUT_ZSPENDINGTRANSACTION_INDEX ON ZPERSISTENTPENDINGINPUT (ZSPENDINGTRANSACTION)", + "ZPERSISTENTPENDINGINPUT Z_PersistentPendingInput_SwiftDataIndexOnBinaryoutpoint: CREATE INDEX Z_PersistentPendingInput_SwiftDataIndexOnBinaryoutpoint ON ZPERSISTENTPENDINGINPUT (ZOUTPOINT COLLATE BINARY ASC)", + "ZPERSISTENTPENDINGINPUT Z_PersistentPendingInput_SwiftDataIndexOnBinarywalletId: CREATE INDEX Z_PersistentPendingInput_SwiftDataIndexOnBinarywalletId ON ZPERSISTENTPENDINGINPUT (ZWALLETID COLLATE BINARY ASC)", + "ZPERSISTENTPLATFORMADDRESS ZPERSISTENTPLATFORMADDRESS_ZACCOUNT_INDEX: CREATE INDEX ZPERSISTENTPLATFORMADDRESS_ZACCOUNT_INDEX ON ZPERSISTENTPLATFORMADDRESS (ZACCOUNT)", + "ZPERSISTENTPLATFORMADDRESS Z_PersistentPlatformAddress_SwiftDataIndexOnBinarywalletId: CREATE INDEX Z_PersistentPlatformAddress_SwiftDataIndexOnBinarywalletId ON ZPERSISTENTPLATFORMADDRESS (ZWALLETID COLLATE BINARY ASC)", + "ZPERSISTENTPLATFORMADDRESS Z_PersistentPlatformAddress_UNIQUE_address: CREATE UNIQUE INDEX Z_PersistentPlatformAddress_UNIQUE_address ON ZPERSISTENTPLATFORMADDRESS (ZADDRESS COLLATE BINARY ASC)", + "ZPERSISTENTPLATFORMADDRESS Z_PersistentPlatformAddress_UNIQUE_addressHash: CREATE UNIQUE INDEX Z_PersistentPlatformAddress_UNIQUE_addressHash ON ZPERSISTENTPLATFORMADDRESS (ZADDRESSHASH COLLATE BINARY ASC)", + "ZPERSISTENTPLATFORMADDRESSESSYNCSTATE Z_PersistentPlatformAddressesSyncState_UNIQUE_walletId: CREATE UNIQUE INDEX Z_PersistentPlatformAddressesSyncState_UNIQUE_walletId ON ZPERSISTENTPLATFORMADDRESSESSYNCSTATE (ZWALLETID COLLATE BINARY ASC)", + "ZPERSISTENTPROPERTY ZPERSISTENTPROPERTY_ZDOCUMENTTYPE_INDEX: CREATE INDEX ZPERSISTENTPROPERTY_ZDOCUMENTTYPE_INDEX ON ZPERSISTENTPROPERTY (ZDOCUMENTTYPE)", + "ZPERSISTENTPROPERTY Z_PersistentProperty_UNIQUE_id: CREATE UNIQUE INDEX Z_PersistentProperty_UNIQUE_id ON ZPERSISTENTPROPERTY (ZID COLLATE BINARY ASC)", + "ZPERSISTENTPUBLICKEY ZPERSISTENTPUBLICKEY_ZIDENTITY_INDEX: CREATE INDEX ZPERSISTENTPUBLICKEY_ZIDENTITY_INDEX ON ZPERSISTENTPUBLICKEY (ZIDENTITY)", + "ZPERSISTENTSHIELDEDACTIVITY Z_PersistentShieldedActivity_SwiftDataIndexOnBinarywalletIdaccountIndex: CREATE INDEX Z_PersistentShieldedActivity_SwiftDataIndexOnBinarywalletIdaccountIndex ON ZPERSISTENTSHIELDEDACTIVITY (ZWALLETID COLLATE BINARY ASC, ZACCOUNTINDEX COLLATE BINARY ASC)", + "ZPERSISTENTSHIELDEDACTIVITY Z_PersistentShieldedActivity_UNIQUE_walletId_accountIndex_entryId: CREATE UNIQUE INDEX Z_PersistentShieldedActivity_UNIQUE_walletId_accountIndex_entryId ON ZPERSISTENTSHIELDEDACTIVITY (ZWALLETID COLLATE BINARY ASC, ZACCOUNTINDEX COLLATE BINARY ASC, ZENTRYID COLLATE BINARY ASC)", + "ZPERSISTENTSHIELDEDNOTE Z_PersistentShieldedNote_SwiftDataIndexOnBinarywalletIdaccountIndex: CREATE INDEX Z_PersistentShieldedNote_SwiftDataIndexOnBinarywalletIdaccountIndex ON ZPERSISTENTSHIELDEDNOTE (ZWALLETID COLLATE BINARY ASC, ZACCOUNTINDEX COLLATE BINARY ASC)", + "ZPERSISTENTSHIELDEDNOTE Z_PersistentShieldedNote_UNIQUE_nullifier: CREATE UNIQUE INDEX Z_PersistentShieldedNote_UNIQUE_nullifier ON ZPERSISTENTSHIELDEDNOTE (ZNULLIFIER COLLATE BINARY ASC)", + "ZPERSISTENTSHIELDEDOUTGOINGNOTE Z_PersistentShieldedOutgoingNote_SwiftDataIndexOnBinarywalletIdaccountIndex: CREATE INDEX Z_PersistentShieldedOutgoingNote_SwiftDataIndexOnBinarywalletIdaccountIndex ON ZPERSISTENTSHIELDEDOUTGOINGNOTE (ZWALLETID COLLATE BINARY ASC, ZACCOUNTINDEX COLLATE BINARY ASC)", + "ZPERSISTENTSHIELDEDOUTGOINGNOTE Z_PersistentShieldedOutgoingNote_UNIQUE_walletId_accountIndex_cmx: CREATE UNIQUE INDEX Z_PersistentShieldedOutgoingNote_UNIQUE_walletId_accountIndex_cmx ON ZPERSISTENTSHIELDEDOUTGOINGNOTE (ZWALLETID COLLATE BINARY ASC, ZACCOUNTINDEX COLLATE BINARY ASC, ZCMX COLLATE BINARY ASC)", + "ZPERSISTENTSHIELDEDSYNCSTATE Z_PersistentShieldedSyncState_SwiftDataIndexOnBinarywalletId: CREATE INDEX Z_PersistentShieldedSyncState_SwiftDataIndexOnBinarywalletId ON ZPERSISTENTSHIELDEDSYNCSTATE (ZWALLETID COLLATE BINARY ASC)", + "ZPERSISTENTSHIELDEDSYNCSTATE Z_PersistentShieldedSyncState_UNIQUE_walletId_accountIndex: CREATE UNIQUE INDEX Z_PersistentShieldedSyncState_UNIQUE_walletId_accountIndex ON ZPERSISTENTSHIELDEDSYNCSTATE (ZWALLETID COLLATE BINARY ASC, ZACCOUNTINDEX COLLATE BINARY ASC)", + "ZPERSISTENTSHIELDEDVIEWINGKEY Z_PersistentShieldedViewingKey_SwiftDataIndexOnBinarywalletId: CREATE INDEX Z_PersistentShieldedViewingKey_SwiftDataIndexOnBinarywalletId ON ZPERSISTENTSHIELDEDVIEWINGKEY (ZWALLETID COLLATE BINARY ASC)", + "ZPERSISTENTSHIELDEDVIEWINGKEY Z_PersistentShieldedViewingKey_UNIQUE_walletId_accountIndex: CREATE UNIQUE INDEX Z_PersistentShieldedViewingKey_UNIQUE_walletId_accountIndex ON ZPERSISTENTSHIELDEDVIEWINGKEY (ZWALLETID COLLATE BINARY ASC, ZACCOUNTINDEX COLLATE BINARY ASC)", + "ZPERSISTENTTOKEN ZPERSISTENTTOKEN_ZDATACONTRACT_INDEX: CREATE INDEX ZPERSISTENTTOKEN_ZDATACONTRACT_INDEX ON ZPERSISTENTTOKEN (ZDATACONTRACT)", + "ZPERSISTENTTOKEN Z_PersistentToken_UNIQUE_id: CREATE UNIQUE INDEX Z_PersistentToken_UNIQUE_id ON ZPERSISTENTTOKEN (ZID COLLATE BINARY ASC)", + "ZPERSISTENTTOKENBALANCE ZPERSISTENTTOKENBALANCE_Z13TOKENBALANCES_INDEX: CREATE INDEX ZPERSISTENTTOKENBALANCE_Z13TOKENBALANCES_INDEX ON ZPERSISTENTTOKENBALANCE (Z13TOKENBALANCES)", + "ZPERSISTENTTOKENBALANCE ZPERSISTENTTOKENBALANCE_ZIDENTITY_INDEX: CREATE INDEX ZPERSISTENTTOKENBALANCE_ZIDENTITY_INDEX ON ZPERSISTENTTOKENBALANCE (ZIDENTITY)", + "ZPERSISTENTTOKENBALANCE ZPERSISTENTTOKENBALANCE_ZTOKEN_INDEX: CREATE INDEX ZPERSISTENTTOKENBALANCE_ZTOKEN_INDEX ON ZPERSISTENTTOKENBALANCE (ZTOKEN)", + "ZPERSISTENTTOKENBALANCE Z_PersistentTokenBalance_SwiftDataIndexOnBinarynetworkRaw: CREATE INDEX Z_PersistentTokenBalance_SwiftDataIndexOnBinarynetworkRaw ON ZPERSISTENTTOKENBALANCE (ZNETWORKRAW COLLATE BINARY ASC)", + "ZPERSISTENTTOKENHISTORYEVENT ZPERSISTENTTOKENHISTORYEVENT_ZTOKEN_INDEX: CREATE INDEX ZPERSISTENTTOKENHISTORYEVENT_ZTOKEN_INDEX ON ZPERSISTENTTOKENHISTORYEVENT (ZTOKEN)", + "ZPERSISTENTTOKENHISTORYEVENT Z_PersistentTokenHistoryEvent_UNIQUE_id: CREATE UNIQUE INDEX Z_PersistentTokenHistoryEvent_UNIQUE_id ON ZPERSISTENTTOKENHISTORYEVENT (ZID COLLATE BINARY ASC)", + "ZPERSISTENTTRACKEDMASTERNODE Z_PersistentTrackedMasternode_SwiftDataIndexOnBinarynetworkRaw: CREATE INDEX Z_PersistentTrackedMasternode_SwiftDataIndexOnBinarynetworkRaw ON ZPERSISTENTTRACKEDMASTERNODE (ZNETWORKRAW COLLATE BINARY ASC)", + "ZPERSISTENTTRACKEDMASTERNODE Z_PersistentTrackedMasternode_UNIQUE_networkRaw_proTxHash: CREATE UNIQUE INDEX Z_PersistentTrackedMasternode_UNIQUE_networkRaw_proTxHash ON ZPERSISTENTTRACKEDMASTERNODE (ZNETWORKRAW COLLATE BINARY ASC, ZPROTXHASH COLLATE BINARY ASC)", + "ZPERSISTENTTRANSACTION Z_PersistentTransaction_SwiftDataIndexOnBinaryfirstSeen: CREATE INDEX Z_PersistentTransaction_SwiftDataIndexOnBinaryfirstSeen ON ZPERSISTENTTRANSACTION (ZFIRSTSEEN COLLATE BINARY ASC)", + "ZPERSISTENTTRANSACTION Z_PersistentTransaction_UNIQUE_txid: CREATE UNIQUE INDEX Z_PersistentTransaction_UNIQUE_txid ON ZPERSISTENTTRANSACTION (ZTXID COLLATE BINARY ASC)", + "ZPERSISTENTTXO ZPERSISTENTTXO_ZACCOUNT_INDEX: CREATE INDEX ZPERSISTENTTXO_ZACCOUNT_INDEX ON ZPERSISTENTTXO (ZACCOUNT)", + "ZPERSISTENTTXO ZPERSISTENTTXO_ZCOREADDRESS_INDEX: CREATE INDEX ZPERSISTENTTXO_ZCOREADDRESS_INDEX ON ZPERSISTENTTXO (ZCOREADDRESS)", + "ZPERSISTENTTXO ZPERSISTENTTXO_ZSPENDINGTRANSACTION_INDEX: CREATE INDEX ZPERSISTENTTXO_ZSPENDINGTRANSACTION_INDEX ON ZPERSISTENTTXO (ZSPENDINGTRANSACTION)", + "ZPERSISTENTTXO ZPERSISTENTTXO_ZTRANSACTION_INDEX: CREATE INDEX ZPERSISTENTTXO_ZTRANSACTION_INDEX ON ZPERSISTENTTXO (ZTRANSACTION)", + "ZPERSISTENTTXO Z_PersistentTxo_SwiftDataIndexOnBinarywalletId: CREATE INDEX Z_PersistentTxo_SwiftDataIndexOnBinarywalletId ON ZPERSISTENTTXO (ZWALLETID COLLATE BINARY ASC)", + "ZPERSISTENTTXO Z_PersistentTxo_UNIQUE_outpoint: CREATE UNIQUE INDEX Z_PersistentTxo_UNIQUE_outpoint ON ZPERSISTENTTXO (ZOUTPOINT COLLATE BINARY ASC)", + "ZPERSISTENTWALLET Z_PersistentWallet_SwiftDataIndexOnBinarynetworkRaw: CREATE INDEX Z_PersistentWallet_SwiftDataIndexOnBinarynetworkRaw ON ZPERSISTENTWALLET (ZNETWORKRAW COLLATE BINARY ASC)", + "ZPERSISTENTWALLET Z_PersistentWallet_SwiftDataIndexOnBinarywalletGroupId: CREATE INDEX Z_PersistentWallet_SwiftDataIndexOnBinarywalletGroupId ON ZPERSISTENTWALLET (ZWALLETGROUPID COLLATE BINARY ASC)", + "ZPERSISTENTWALLET Z_PersistentWallet_UNIQUE_walletId: CREATE UNIQUE INDEX Z_PersistentWallet_UNIQUE_walletId ON ZPERSISTENTWALLET (ZWALLETID COLLATE BINARY ASC)", + "ZPERSISTENTWALLETMANAGERMETADATA Z_PersistentWalletManagerMetadata_UNIQUE_networkRaw: CREATE UNIQUE INDEX Z_PersistentWalletManagerMetadata_UNIQUE_networkRaw ON ZPERSISTENTWALLETMANAGERMETADATA (ZNETWORKRAW COLLATE BINARY ASC)", + "Z_1INVOLVEDTRANSACTIONS Z_1INVOLVEDTRANSACTIONS_Z_32INVOLVEDTRANSACTIONS_INDEX: CREATE INDEX Z_1INVOLVEDTRANSACTIONS_Z_32INVOLVEDTRANSACTIONS_INDEX ON Z_1INVOLVEDTRANSACTIONS (Z_32INVOLVEDTRANSACTIONS, Z_1INVOLVEDACCOUNTS)", + "Z_1INVOLVEDTRANSACTIONS sqlite_autoindex_Z_1INVOLVEDTRANSACTIONS_1: (auto)" + ] + }, + "source_sha": "52e8d4ec68f0c772313fa1bbef223fb1eabbf1cc", + "provenance": "reconstructed-model-match", + "fixture_path": "packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/historical-v2.store", + "fixture_sha256": "4c7c0516627c2cc3dc588bfe3e764755205adc0f3848f7e8b7028bf3c53f67ae", + "app_store_baseline": { + "bundle_id": "org.dashfoundation.dash", + "app_id": "1206647026", + "app_version": "9.0.2", + "release_id": "679060c3-2e64-49d6-b28e-baa307c817be" + } + } + } +} diff --git a/packages/swift-sdk/scripts/fixtures/DashHistoricalFixtureCaptureTests.swift b/packages/swift-sdk/scripts/fixtures/DashHistoricalFixtureCaptureTests.swift new file mode 100644 index 00000000000..f0a495dec56 --- /dev/null +++ b/packages/swift-sdk/scripts/fixtures/DashHistoricalFixtureCaptureTests.swift @@ -0,0 +1,79 @@ +// Reproduction recipe, copied into a disposable SDK by historical_schema_fixture.py. +// This file and the historical model graph are not compiled into the shipping SDK. +import Foundation +import SQLite3 +import SwiftData +import XCTest + +@testable import SwiftDashSDK + +@MainActor +final class DashHistoricalFixtureCaptureTests: XCTestCase { + func testCaptureHistoricalSourceFixture() throws { + // Match the original audit's model-registration order. + _ = DashModelContainer.schema + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = directory.appendingPathComponent("historical.store") + try autoreleasepool { + // The historical iOS app used an unversioned Schema with no migration plan. + let schema = Schema(DashSchemaSnapshotV1.models) + let configuration = ModelConfiguration( + schema: schema, url: store, allowsSave: true, cloudKitDatabase: .none) + let container = try ModelContainer(for: schema, configurations: [configuration]) + let wallet = DashSchemaSnapshotV1.PersistentWallet( + walletId: Data(repeating: 0x61, count: 32), network: .testnet, + name: "historical audit wallet") + let contractId = Data(repeating: 0x62, count: 32) + let contract = DashSchemaSnapshotV1.PersistentDataContract( + id: contractId, name: "historical audit contract", + serializedContract: Data("{}".utf8), network: .testnet) + let type = DashSchemaSnapshotV1.PersistentDocumentType( + contractId: contractId, name: "audit", + schemaJSON: Data("{}".utf8), propertiesJSON: Data("{}".utf8)) + let index = DashSchemaSnapshotV1.PersistentIndex( + contractId: contractId, documentTypeName: "audit", name: "byName", properties: ["name"]) + container.mainContext.insert(wallet) + container.mainContext.insert(contract) + container.mainContext.insert(type) + container.mainContext.insert(index) + type.dataContract = contract + index.documentType = type + try container.mainContext.save() + } + try checkpoint(store) + let captured = try DashSchemaFixtureSupport.describeStore(at: store, version: Schema.Version(1, 0, 0)) + let provenanceURL = try XCTUnwrap(Bundle.module.url( + forResource: "manifest", withExtension: "json", + subdirectory: "Fixtures/SchemaStores/legacy-fd8d8d13e5")) + struct Provenance: Decodable { let schema: DashSchemaFixtureSupport.Description } + let provenance = try JSONDecoder().decode(Provenance.self, from: Data(contentsOf: provenanceURL)) + XCTAssertEqual(captured, provenance.schema, "Recreated model hashes and indexes must match the committed fixture") + let attachment = XCTAttachment(contentsOfFile: store) + attachment.name = "legacy-fd8d8d13e5.store" + attachment.lifetime = .keepAlways + add(attachment) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .prettyPrinted] + let metadata = XCTAttachment(data: try encoder.encode(captured), uniformTypeIdentifier: "public.json") + metadata.name = "legacy-fd8d8d13e5.schema.json" + metadata.lifetime = .keepAlways + add(metadata) + } + + private func checkpoint(_ url: URL) throws { + var database: OpaquePointer? + guard sqlite3_open_v2(url.path, &database, SQLITE_OPEN_READWRITE, nil) == SQLITE_OK else { + sqlite3_close(database) + throw NSError(domain: "HistoricalFixtureSQLite", code: 1) + } + defer { sqlite3_close(database) } + guard sqlite3_wal_checkpoint_v2(database, nil, SQLITE_CHECKPOINT_TRUNCATE, nil, nil) == SQLITE_OK, + sqlite3_exec(database, "PRAGMA journal_mode=DELETE", nil, nil, nil) == SQLITE_OK, + !FileManager.default.fileExists(atPath: url.path + "-wal"), + !FileManager.default.fileExists(atPath: url.path + "-shm") else { + throw NSError(domain: "HistoricalFixtureSQLite", code: 2) + } + } +} diff --git a/packages/swift-sdk/scripts/freeze_appstore_release.py b/packages/swift-sdk/scripts/freeze_appstore_release.py new file mode 100644 index 00000000000..801b49a64ce --- /dev/null +++ b/packages/swift-sdk/scripts/freeze_appstore_release.py @@ -0,0 +1,446 @@ +#!/usr/bin/env python3 +"""Prepare a reviewed SwiftData snapshot from the iOS release-data branch. + +The caller supplies a release ID and a metadata commit, never a Platform ref. +Only the iOS monitor has Apple credentials; its publication proof is trusted +only after proving it belongs to the fixed release-data branch. All Git writes +take place in a temporary clone. --dry-run generates and checks the patch but +does not commit, push, or write to the GitHub API. +""" + +import argparse +import base64 +import contextlib +import hashlib +import http.client +import json +import os +from pathlib import Path +import re +import sqlite3 +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request + + +IOS_REPO = "dashpay/dashwallet-ios" +PLATFORM_REPO = "dashpay/platform" +BASE_BRANCH = "v4.2-dev" +DATA_BRANCH = "schema-release-data" +SDK = "packages/swift-sdk" +REGISTRY = f"{SDK}/schema-releases.json" +GENERATOR = f"{SDK}/scripts/freeze_schema_models.py" +GENERATED_TEST = f"{SDK}/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaRegistry.generated.swift" +SHA = re.compile(r"[0-9a-f]{40}") +DIGEST = re.compile(r"[0-9a-f]{64}") +COMPONENT = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,254}") +VERSION = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+") +SOURCE_TAG_PREFIX = "refs/tags/swift-schema-source/" + + +class ReleaseError(RuntimeError): + pass + + +def process_environment(): + """Branch files are data, never a source of credentials or Git/Python setup.""" + return { + "PATH": os.defpath, "HOME": os.devnull, "LC_ALL": "C", + "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_TERMINAL_PROMPT": "0", "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "core.hooksPath", "GIT_CONFIG_VALUE_0": os.devnull, + } + + +def run(directory, *args, env=None): + result = subprocess.run(args, cwd=directory, + env=process_environment() if env is None else env, capture_output=True) + if result.returncode: + # Never echo commands or credentials from subprocess diagnostics. + raise ReleaseError(f"{args[0]} {args[1] if len(args) > 1 else ''} failed: " + f"{result.stderr.decode(errors='replace')[-2000:]}") + return result.stdout + + +def git(directory, *args, env=None): + return run(directory, "git", *args, env=env).decode().strip() + + +def require_string(value, pattern, label): + if not isinstance(value, str) or not pattern.fullmatch(value): + raise ReleaseError(f"Invalid {label}") + return value + + +def read_blob(directory, commit, path, *, allow_executable=False): + entry = git(directory, "ls-tree", commit, "--", path) + modes = ("100644 blob ", "100755 blob ") if allow_executable else ("100644 blob ",) + if not entry.startswith(modes): + raise ReleaseError(f"Missing regular data file: {path}") + return run(directory, "git", "show", f"{commit}:{path}") + + +def json_object(data, label): + try: + value = json.loads(data) + except (ValueError, UnicodeError) as error: + raise ReleaseError(f"Invalid JSON: {label}") from error + if not isinstance(value, dict) or value.get("format_version") != 1: + raise ReleaseError(f"Unsupported format: {label}") + return value + + +def verify_data_origin(directory): + origin = git(directory, "remote", "get-url", "origin") + if origin not in (f"https://github.com/{IOS_REPO}", f"https://github.com/{IOS_REPO}.git", + f"git@github.com:{IOS_REPO}.git"): + raise ReleaseError("Release metadata must come from dashpay/dashwallet-ios") + + +def validate_publication(data_repo, data_commit, release_id): + """Return verified publication proof, manifest, and fixture bytes.""" + require_string(data_commit, SHA, "metadata commit") + require_string(release_id, COMPONENT, "Apple release ID") + verify_data_origin(data_repo) + git(data_repo, "merge-base", "--is-ancestor", data_commit, f"origin/{DATA_BRANCH}") + proof = json_object(read_blob(data_repo, data_commit, f"releases/{release_id}.json"), "release proof") + if proof.get("release_id") != release_id: + raise ReleaseError("Release proof ID does not match the requested release") + if proof.get("observed_state") not in ( + "READY_FOR_DISTRIBUTION", "READY_FOR_SALE", "REPLACED_WITH_NEW_VERSION" + ): + raise ReleaseError("The version has not been published in the App Store") + for field in ("app_id", "bundle_id", "app_version", "build_number", "build_id"): + require_string(proof.get(field), COMPONENT, field) + expected_path = (f"builds/{proof['bundle_id']}/{proof['app_version']}/" + f"{proof['build_number']}/manifest.json") + if proof.get("manifest_path") != expected_path: + raise ReleaseError("Release proof does not point to its exact build manifest") + manifest_bytes = read_blob(data_repo, data_commit, expected_path) + require_string(proof.get("manifest_sha256"), DIGEST, "manifest digest") + if hashlib.sha256(manifest_bytes).hexdigest() != proof["manifest_sha256"]: + raise ReleaseError("Build manifest checksum mismatch") + manifest = json_object(manifest_bytes, "build manifest") + for field in ("bundle_id", "app_version", "build_number"): + if manifest.get(field) != proof[field]: + raise ReleaseError(f"Build identity mismatch: {field}") + require_string(manifest.get("platform_sha"), SHA, "Platform commit") + require_string(manifest.get("wallet_sha"), SHA, "wallet commit") + schema = manifest.get("schema") + if not isinstance(schema, dict): + raise ReleaseError("Missing schema descriptor") + require_string(schema.get("schema_version"), VERSION, "schema version") + if not isinstance(schema.get("model_checksum"), str) or not schema["model_checksum"]: + raise ReleaseError("Missing model checksum") + hashes = schema.get("entity_hashes") + if not isinstance(hashes, dict) or not hashes: + raise ReleaseError("Missing entity hashes") + for name, digest in hashes.items(): + require_string(name, COMPONENT, "entity name") + require_string(digest, re.compile(r"(?:[0-9a-f]{2})+"), "entity hash") + indexes = schema.get("indexes") + if not isinstance(indexes, list) or not all(isinstance(x, str) for x in indexes): + raise ReleaseError("Missing SQLite indexes") + if len(set(indexes)) != len(indexes): + raise ReleaseError("Duplicate SQLite index descriptors") + require_string(manifest.get("fixture_sha256"), DIGEST, "fixture digest") + fixture_path = f"stores/{manifest['fixture_sha256']}.store" + if manifest.get("fixture_path") != fixture_path: + raise ReleaseError("Fixture must use its content-addressed store path") + fixture = read_blob(data_repo, data_commit, fixture_path) + if hashlib.sha256(fixture).hexdigest() != manifest["fixture_sha256"]: + raise ReleaseError("Fixture checksum mismatch") + if not fixture.startswith(b"SQLite format 3\x00"): + raise ReleaseError("Fixture is not a SQLite store") + return proof, manifest, fixture + + +def release_entry(proof, manifest, data_commit): + entry = {field: proof[field] for field in + ("app_id", "bundle_id", "app_version", "build_number", "build_id", "manifest_sha256")} + entry.update(schema_version=manifest["schema"]["schema_version"], + platform_sha=manifest["platform_sha"], data_commit=data_commit) + return entry + + +def associate_release(registry, release_id, entry): + """An observation at a newer metadata commit must not rewrite old provenance.""" + releases = registry.setdefault("releases", {}) + previous = releases.get(release_id) + if previous: + expected = dict(entry, data_commit=previous.get("data_commit")) + if previous != expected: + raise ReleaseError("This App Store release already has different provenance") + return False + if entry["schema_version"] not in registry.get("schemas", {}): + raise ReleaseError("Cannot associate a release without its generated snapshot") + releases[release_id] = entry + return True + + +def permitted_change(path): + return (path in (REGISTRY, GENERATED_TEST) + or (path.startswith(f"{SDK}/Sources/SwiftDashSDK/Persistence/FrozenSchemas/") + and path.endswith(".swift")) + or (path.startswith(f"{SDK}/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/releases/") + and path.endswith(".store"))) + + +class GitHub: + def __init__(self, token, opener=urllib.request.urlopen, sleep=time.sleep): + self.token, self.opener, self.sleep = token, opener, sleep + + def request(self, method, path, payload=None): + request = urllib.request.Request( + f"https://api.github.com/repos/{PLATFORM_REPO}/{path}", method=method, + data=None if payload is None else json.dumps(payload).encode(), + headers={"Authorization": f"Bearer {self.token}", "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", "Content-Type": "application/json"}) + # Retry reads only. A POST may have succeeded before a connection failed; + # the next workflow run reconciles against existing PRs instead. + for attempt in range(4): + try: + with self.opener(request, timeout=45) as response: + return json.load(response) + except urllib.error.HTTPError as error: + if method == "GET" and error.code in (429, 500, 502, 503, 504) and attempt < 3: + self.sleep(min(30, 2 ** attempt)) + continue + raise ReleaseError(f"GitHub {method} failed with HTTP {error.code}") from error + except (urllib.error.URLError, TimeoutError, ConnectionError, http.client.HTTPException, + json.JSONDecodeError, UnicodeDecodeError) as error: + if method == "GET" and attempt < 3: + self.sleep(2 ** attempt) + continue + raise ReleaseError(f"GitHub {method} response could not be read or decoded; " + "retry the workflow to reconcile its state") from error + + def pull_requests(self, branch): + results = [] + page = 1 + while True: + query = urllib.parse.urlencode({"head": f"dashpay:{branch}", "base": BASE_BRANCH, + "state": "all", "sort": "created", "direction": "desc", + "per_page": 100, "page": page}) + rows = self.request("GET", f"pulls?{query}") + results.extend(rows) + if len(rows) < 100: + return results + page += 1 + + +def git_environment(token): + env = process_environment() + # This is process-local and never written to git config or command output. + credential = base64.b64encode(f"x-access-token:{token}".encode()).decode() + env.update(GIT_CONFIG_COUNT="2", GIT_CONFIG_KEY_1="http.https://github.com/.extraheader", + GIT_CONFIG_VALUE_1=f"AUTHORIZATION: basic {credential}") + return env + + +def source_commits(registry): + """Every commit a later regeneration must be able to read, including the + reconstructed historical sources that never went through a release.""" + commits = { + require_string(entry.get("platform_sha"), SHA, "registered Platform commit") + for group in ("schemas", "releases") for entry in registry.get(group, {}).values() + } + commits.update( + require_string(entry.get("source_sha"), SHA, "historical schema source commit") + for entry in registry.get("historical_schemas", {}).values() + ) + return commits + + +def fetch_sources(clone, commits, env): + """Fetch all recorded sources, including objects outside development history.""" + git(clone, "fetch", "origin", f"{SOURCE_TAG_PREFIX}*:{SOURCE_TAG_PREFIX}*", env=env) + for commit in sorted(commits): + require_string(commit, SHA, "source commit") + git(clone, "fetch", "origin", commit, env=env) + if git(clone, "cat-file", "-t", commit) != "commit": + raise ReleaseError("A registered source SHA does not identify a commit") + + +def retain_source(clone, commit, env, *, dry_run=False): + """Validate an existing source tag; create a missing tag only in write mode.""" + require_string(commit, SHA, "source commit") + ref = SOURCE_TAG_PREFIX + commit + + def existing(): + row = git(clone, "ls-remote", "--refs", "origin", ref, env=env) + if not row: + return False + if row != f"{commit}\t{ref}": + raise ReleaseError(f"Source retention tag points to a different object: {ref}") + return True + + if existing() or dry_run: + return + try: + git(clone, "push", "origin", f"{commit}:{ref}", env=env) + except ReleaseError: + # A concurrent upload may have created the exact same immutable tag, + # or the server accepted the push before the connection was lost. + if not existing(): + raise + + +def pr_body(proof, manifest, data_commit): + schema = manifest["schema"]["schema_version"] + return f"""## Issue being fixed or feature implemented +App Store version {proof['app_version']} (build {proof['build_number']}) shipped SwiftData schema {schema}. +Preserve its model definitions and synthetic migration fixture before changing that schema. + +## What was done? +Generated the snapshot from Platform commit `{manifest['platform_sha']}` and associated Apple release `{proof['release_id']}`. +The publication proof and immutable build manifest are recorded in [iOS release metadata](https://github.com/{IOS_REPO}/blob/{data_commit}/releases/{proof['release_id']}.json). +This PR does not change the active runtime schema or invent a migration. If development has moved, reconcile the next schema version and migration before merging. + +## How Has This Been Tested? +Metadata checksums and snapshot regeneration were checked by the freeze worker. Normal Swift SDK CI checks the stored schema, indexes and migrations. + +## Breaking Changes +No runtime schema switch is included. This draft requires human review and a manual merge; no auto-merge is enabled. +""" + + +def prepare(repo, data_repo, release_id, data_commit, token, dry_run=False): + proof, manifest, fixture = validate_publication(data_repo, data_commit, release_id) + branch = f"codex/freeze-swift-schema-v{manifest['schema']['schema_version']}" + api = GitHub(token) + pulls = api.pull_requests(branch) + opened = [pr for pr in pulls if pr["state"] == "open"] + if len(opened) > 1: + raise ReleaseError("Multiple open snapshot pull requests need manual reconciliation") + env = git_environment(token) + with tempfile.TemporaryDirectory(prefix="appstore-schema-") as temporary: + clone = Path(temporary) / "platform" + run(None, "git", "clone", "--shared", "--no-checkout", str(repo), str(clone)) + git(clone, "remote", "set-url", "origin", f"https://github.com/{PLATFORM_REPO}.git") + git(clone, "config", "user.name", "Dash schema release automation") + git(clone, "config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com") + git(clone, "config", "commit.gpgsign", "false") + git(clone, "fetch", "origin", f"{BASE_BRANCH}:refs/remotes/origin/{BASE_BRANCH}", env=env) + # Read executable code only from the reviewed base. A contributor may + # edit code on the draft branch; it must never become this worker's + # generator, even when running a dry-run with no remote writes. + trusted_generator = Path(temporary) / "trusted-freeze-schema-models.py" + trusted_generator.write_bytes(read_blob(clone, f"origin/{BASE_BRANCH}", GENERATOR, + allow_executable=True)) + + def generate(*args): + run(temporary, sys.executable, "-I", str(trusted_generator), "--repo", str(clone), *args) + + git(clone, "checkout", "--detach", f"origin/{BASE_BRANCH}") + merged_registry = json_object((clone / REGISTRY).read_bytes(), "merged snapshot registry") + if release_id in merged_registry.get("releases", {}): + associate_release(merged_registry, release_id, release_entry(proof, manifest, data_commit)) + commits = source_commits(merged_registry) | {manifest["platform_sha"]} + fetch_sources(clone, commits, env) + generate("--check") + for commit in sorted(commits): + retain_source(clone, commit, env, dry_run=dry_run) + print("This release is already present in the merged registry.") + return + # The branch is shared by releases with the same schema. A rejected + # follow-up must block an unregistered release, not invalidate one + # already verified in the merged registry above. + if not opened and pulls and not pulls[0].get("merged_at"): + raise ReleaseError("The snapshot pull request was closed without merging; reopen it to retry") + remote_branch = git(clone, "ls-remote", "--heads", "origin", branch, env=env) + if remote_branch: + git(clone, "fetch", "origin", f"{branch}:refs/remotes/origin/{branch}", env=env) + git(clone, "checkout", "-b", branch, f"origin/{branch}") + # Merge with no commit so --dry-run never writes a commit. The + # publication commit below includes the merge and generated patch. + git(clone, "merge", "--no-commit", "--no-ff", f"origin/{BASE_BRANCH}") + else: + git(clone, "checkout", "-b", branch, f"origin/{BASE_BRANCH}") + before_registry = json_object((clone / REGISTRY).read_bytes(), "snapshot registry") + fetch_sources(clone, source_commits(before_registry) | {manifest["platform_sha"]}, env) + generate("--check") + immutable_files = {path: (clone / path).read_bytes() + for path in git(clone, "ls-files").splitlines() + if permitted_change(path) and path not in (REGISTRY, GENERATED_TEST)} + manifest_file = Path(temporary) / "manifest.json" + fixture_file = Path(temporary) / "fixture.store" + manifest_file.write_text(json.dumps(manifest)) + fixture_file.write_bytes(fixture) + with contextlib.closing(sqlite3.connect(f"file:{fixture_file}?immutable=1", uri=True)) as database: + if database.execute("PRAGMA quick_check").fetchone() != ("ok",): + raise ReleaseError("The release fixture is corrupt or needs a WAL file") + generate("--release-manifest", str(manifest_file), "--fixture", str(fixture_file)) + if any(not (clone / path).is_file() or (clone / path).read_bytes() != content + for path, content in immutable_files.items()): + raise ReleaseError("Attempted to replace an existing snapshot or fixture") + registry = json_object((clone / REGISTRY).read_bytes(), "snapshot registry") + # The generator may append a schema, but existing snapshots are immutable. + for key in ("schemas", "releases", "historical_schemas"): + for identifier, value in before_registry.get(key, {}).items(): + if registry.get(key, {}).get(identifier) != value: + raise ReleaseError(f"Attempted to rewrite existing {key} entry: {identifier}") + associate_release(registry, release_id, release_entry(proof, manifest, data_commit)) + (clone / REGISTRY).write_text(json.dumps(registry, indent=2, sort_keys=True) + "\n") + generate("--check") + # Inspect only unstaged changes: a non-conflicting base merge may have + # staged unrelated source updates, which are preserved in the merge. + changed = set(git(clone, "diff", "--name-only").splitlines()) + changed.update(git(clone, "ls-files", "--others", "--exclude-standard").splitlines()) + if any(not permitted_change(path) for path in changed): + raise ReleaseError("The generator changed files outside the snapshot allowlist") + print(json.dumps({"branch": branch, "release_id": release_id, + "files": sorted(changed), "dry_run": dry_run}, indent=2)) + # The source objects must remain reachable in a fresh checkout before + # the PR references them. Neither tags nor source history are rewritten. + # Dry runs verify existing references too, but never create missing tags. + for commit in sorted(source_commits(registry) | {manifest["platform_sha"]}): + retain_source(clone, commit, env, dry_run=dry_run) + if dry_run: + return + if changed: + git(clone, "add", "--", *sorted(changed)) + staged = git(clone, "diff", "--cached", "--name-only") + if staged or (clone / ".git/MERGE_HEAD").exists(): + git(clone, "commit", "-m", f"chore(swift-sdk): freeze App Store schema {manifest['schema']['schema_version']}") + # Deliberately no force or force-with-lease. Concurrent human edits + # cause a rejection; the next run starts from the new branch tip. + git(clone, "push", "origin", f"HEAD:refs/heads/{branch}", env=env) + # A previous push may have succeeded while PR creation failed. Even + # when generation is a no-op, recover by creating the missing PR. + if opened: + print(f"Snapshot pull request: {opened[0]['html_url']}") + else: + created = api.request("POST", "pulls", { + "title": f"chore(swift-sdk): freeze App Store schema {manifest['schema']['schema_version']}", + "head": branch, "base": BASE_BRANCH, "draft": True, + "body": pr_body(proof, manifest, data_commit)}) + print(f"Snapshot pull request: {created['html_url']}") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--release-id", required=True) + parser.add_argument("--data-commit", required=True) + parser.add_argument("--data-repo", required=True, type=Path) + parser.add_argument("--repo", default=Path.cwd(), type=Path) + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + token = os.environ.get("SCHEMA_RELEASE_TOKEN", "") + if not token: + parser.error("SCHEMA_RELEASE_TOKEN is required for read access, including dry runs") + try: + prepare(args.repo.resolve(), args.data_repo.resolve(), args.release_id, + args.data_commit, token, args.dry_run) + except (ReleaseError, OSError, sqlite3.Error) as error: + print(f"Freeze stopped: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/swift-sdk/scripts/freeze_schema_models.py b/packages/swift-sdk/scripts/freeze_schema_models.py index d1b3d18bb35..ef4cf8b08d2 100755 --- a/packages/swift-sdk/scripts/freeze_schema_models.py +++ b/packages/swift-sdk/scripts/freeze_schema_models.py @@ -1,91 +1,46 @@ #!/usr/bin/env python3 -"""Generate the frozen SwiftData model copies for released schema versions. - -A `VersionedSchema` identifies a store by the checksum of the entities it -declares, so a released version may only reference model types whose shape -never changes again. Pointing a released version at a live `@Model` type -means the next property added to that type silently changes the released -checksum: a store written by the previously shipped build then matches no -registered version and fails to open with Cocoa error 134504 ("Cannot use -staged migration with an unknown model version") instead of migrating. - -This script copies each live `@Model` class as it existed at a given commit -into a nested type of the schema enum that version registers -(`extension DashSchemaV1 { final class PersistentX { ... } }`), one file per -model, under `Persistence/FrozenSchemas/`. SwiftData derives the entity name -from the unqualified type name, so `DashSchemaV1.PersistentX` and the live -`PersistentX` describe the same entity, which is what lets a migration stage -map one onto the other. - -The class body and the extensions declared in the model's own file are -copied; doc comments, `public` modifiers and top-level enums are dropped. -Extensions add no stored properties (so they are not part of the entity) -but the class body may call into them. The stored properties, their -optionality and defaults, `@Attribute`, `@Relationship` and `#Unique` are -what the checksum hashes, and they are copied verbatim. `#Index` is copied -verbatim too but is NOT part of the hash (Core Data leaves indexes out of -entity version hashes), which is why index drift needs its own check. - -Value types a model stores inline (Codable structs and raw enums SwiftData -expands into composite attributes, such as `ChangeControlRules` on -`PersistentToken`) are entity-hash inputs too, so they are frozen the same -way, into one nested file per schema, and every frozen model body then -resolves those names to the nested copies. Names are qualified per schema: -a model frozen only under `DashSchemaV2` that mentioned a `DashSchemaV1` -model by bare name in an extension would still bind to the live type. - -Every released version is frozen as a whole graph, never partially: a -relationship binds its destination by entity name, and SwiftData resolves -that name to whichever Swift type claimed it first in the process, so a -frozen model whose relationship pointed at a live type could be hashed with -the live type's current shape. - -`FREEZES` below is the record of what each released version registers and -the commit its shapes are taken from. Rows are append-only: retiring a -version means adding rows for it (normally one row listing every model at -the last commit before the change), adding the new `DashSchemaVN` and a -migration stage, and rerunning this script. Never edit an existing row; a -released checksum cannot move. - -Nothing here checks that the table is COMPLETE. That is deliberate. A -frozen model whose relationship target or stored value type is missing -from the table binds that bare name to the live type, and the released -checksum then moves with the live type's next change; but whether a given -Swift reference feeds the entity hash is decided by SwiftData (a struct -stored directly on a model does, an array of structs nested inside one does -not), and a text scan of Swift source cannot know that, nor keep up with -optionals, generics, extensions, nested types and enum payloads. Every -reference such a scan misses is a silent failure in the field, and every -one it wrongly flags is a false alarm. The authority for -hash-relevant completeness (properties, relationships, `#Unique`) is -`DashModelMigrationTests.testFrozenVersionsBuiltAfterTheLiveSchemaHashLikeTheStoresTheyShipped`, -which builds each released version after the live schema and compares the -hashes SwiftData computes against a store the shipping build wrote; its -sibling `testFixturesAndMigratedStoresCarryTheIndexesFreshStoresHave` -covers `#Index`, which the hash cannot see, by comparing SQLite indexes. -Do not add static validation here; extend those tests (and their -fixtures) instead. - -Usage, from anywhere inside the repository: - - scripts/freeze_schema_models.py # regenerate every frozen file - scripts/freeze_schema_models.py --check # exit 1 if any file would change - -`--check` is a regeneration check and nothing more: the frozen files are a -pure function of `FREEZES` and the repository history, so a clean check -proves that the committed files are exactly this generator's output, byte -for byte, and that no one edited a frozen copy by hand. It says nothing -about whether the freeze is complete. CI runs it (the -`swift-sdk-frozen-schema` job in `.github/workflows/tests.yml`) on a -full-history checkout, because it needs the commits named in `FREEZES`. - -The generator's own tests, from the repository root: - - python3 -m unittest discover -s packages/swift-sdk/scripts -p 'test_*.py' +"""Generate immutable SwiftData snapshots from the exact sources that shipped. + +V1's accepted historical FREEZES rows remain unchanged. New App Store releases +are recorded in schema-releases.json, using the full Platform SHA and the model +inventory committed at that SHA. Intermediate TestFlight builds only capture +evidence; they do not add released schema versions. + +Each release snapshot copies the complete model graph and stored value types +into a separate DashSchemaSnapshotVN namespace. It is not an additional runtime +migration stage: DashSchemaVN stays on live model types until a later shape +change moves it onto the snapshot and introduces a new live version/migration. + +--check verifies deterministic generated sources, registry bindings and immutable +fixture digests. --check-inventory rejects missing transitive stored value types +within the supported explicit storage grammar. Standard Swift/Foundation type +names must not be shadowed elsewhere in the SDK. Custom Codable methods and +other helper behavior remain outside this declaration check. The runtime +DashReleasedSchemaTests compares SwiftData's hashes and SQLite indexes against +the captured fixture, after constructing the live schema first. This also +catches inline value types or relationship references accidentally left live. +Do not replace those runtime checks with a static scan of model source. + +Usage: + python3 packages/swift-sdk/scripts/freeze_schema_models.py --check + python3 packages/swift-sdk/scripts/freeze_schema_models.py \ + --release-manifest build.json --fixture fixture.store + +A full-history checkout including swift-schema-source/* tags is required. New +manifests must refer to a commit that already contains schema-models.json. +Historical fixture files are never rebuilt from today's sources. --repo selects +the data repository explicitly when a trusted copy of this generator runs from +outside the checkout. """ import argparse +import contextlib import dataclasses +import hashlib +import json +import pathlib +import plistlib +import sqlite3 import os import re import subprocess @@ -107,10 +62,8 @@ "TokenLocalization", ] -# Every model registered by the versions that share the V1 graph, in the -# order `DashModelContainer` lists them, minus the two that have their own -# rows below (`PersistentAssetLock`, whose shape differs between V2 and V3, -# and `PersistentTrackedMasternode`, which V2 added). +# Accepted V1 model inventory, excluding the asset lock whose earlier shape +# has a separate unchanged row below. V1_GRAPH_MODELS = [ "PersistentIdentity", "PersistentDPNSName", @@ -147,26 +100,6 @@ "PersistentMasternode", ] -# Every model registered by V4, in the order `DashModelContainer` lists -# them: the shared graph with the asset lock back in its own slot and the -# tracked-masternode registry V2 added appended at the end. -# -# V4 is frozen as a whole graph rather than as a row for the three models it -# widened (`PersistentTxo`, `PersistentPendingInput`, `PersistentWallet`). -# Those three carry relationships, and a frozen model that names a -# relationship target absent from its own schema binds that bare name to the -# live type, which is the partial-freeze failure this file's header warns -# about. The rows above get away with being partial only because the models -# they freeze (`PersistentAssetLock`, `PersistentTrackedMasternode`) are -# relationship-isolated. -_V4_ASSET_LOCK_SLOT = V1_GRAPH_MODELS.index("PersistentInvitation") -V4_GRAPH_MODELS = ( - V1_GRAPH_MODELS[:_V4_ASSET_LOCK_SLOT] - + ["PersistentAssetLock"] - + V1_GRAPH_MODELS[_V4_ASSET_LOCK_SLOT:] - + ["PersistentTrackedMasternode"] -) - @dataclasses.dataclass(frozen=True) class Freeze: @@ -179,12 +112,13 @@ class Freeze: value_types: tuple = () +HISTORICAL_V2_SOURCE = "52e8d4ec68f0c772313fa1bbef223fb1eabbf1cc" + FREEZES = [ - # The asset lock as V1 and V2 shipped it: the last commit before + # The accepted V1 asset lock: the last commit before # `recipientIsExternal` was added to the live model. Freeze("DashSchemaV1", "7127c38566", ("PersistentAssetLock",)), - # The rest of the graph, shared by V1, V2 and V3, at the last commit - # before V4 widened the wallet transaction models. + # The accepted V1 graph at the last commit before the sweep additions. Freeze( "DashSchemaV1", "5f58417079", @@ -192,18 +126,13 @@ class Freeze: TOKEN_TYPES_FILE, tuple(TOKEN_VALUE_TYPES), ), - # V2 adds the tracked-masternode registry. - Freeze("DashSchemaV2", "5f58417079", ("PersistentTrackedMasternode",)), - # V3 replaces the asset lock with the shape that has `recipientIsExternal`. - Freeze("DashSchemaV3", "5f58417079", ("PersistentAssetLock",)), - # V4 as it shipped: the whole graph at the last commit before V5 added - # the key usage-limit columns to `PersistentPublicKey`. + # Reconstructed pre-August-28 V2: all 35 hashes and the model checksum + # match the observed App Store store. This is reconstruction provenance, + # not a claim that this exact commit built the released app binary. Freeze( - "DashSchemaV4", - "787cac09e7", - tuple(V4_GRAPH_MODELS), - TOKEN_TYPES_FILE, - tuple(TOKEN_VALUE_TYPES), + "DashSchemaV2", HISTORICAL_V2_SOURCE, + tuple(V1_GRAPH_MODELS + ["PersistentAssetLock", "PersistentTrackedMasternode"]), + TOKEN_TYPES_FILE, tuple(TOKEN_VALUE_TYPES), ), ] @@ -217,13 +146,13 @@ def git(root, *args): ) except subprocess.CalledProcessError as error: raise SystemExit( - f"git {' '.join(args)} failed (exit {error.returncode}); a commit named in " - "FREEZES may not be fetched locally" + f"git {' '.join(args)} failed (exit {error.returncode}); a baseline or " + "release source commit may not be fetched locally" ) -def repo_root(): - return git(os.getcwd(), "rev-parse", "--show-toplevel").strip() +def repo_root(directory=None): + return git(directory or os.getcwd(), "rev-parse", "--show-toplevel").strip() def strip_comments(lines): @@ -369,7 +298,7 @@ def render_model(freeze, sha, source, model, sibling): ) -def render_all(root): +def render_baseline(root): """Every frozen file as {relative path: text}.""" # Names frozen under a schema, across all of its rows: any of them # mentioned inside an extension body must resolve to the nested copy. @@ -407,9 +336,522 @@ def emit(path, text): return files +REGISTRY_FILE = "packages/swift-sdk/schema-releases.json" +INVENTORY_FILE = "packages/swift-sdk/schema-models.json" +TEST_REGISTRY_FILE = "packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaRegistry.generated.swift" +FIXTURE_DIR = "packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/releases" + + +def read_registry(root): + with open(os.path.join(root, REGISTRY_FILE), encoding="utf-8") as source: + registry = json.load(source) + if registry.get("format_version") != 1 or not isinstance(registry.get("schemas"), dict): + raise SystemExit("unsupported schema release registry") + return registry + + +def validate_schema(schema): + if not isinstance(schema, dict) or set(schema) != { + "schema_version", "model_checksum", "entity_hashes", "indexes" + }: + raise SystemExit("invalid captured schema description") + version = schema["schema_version"] + if not isinstance(version, str) or not re.fullmatch(r"[1-9][0-9]*\.0\.0", version): + raise SystemExit("schema versions must be major.0.0") + if not isinstance(schema["model_checksum"], str) or not schema["model_checksum"]: + raise SystemExit("missing model checksum") + hashes = schema["entity_hashes"] + if not isinstance(hashes, dict) or not hashes or any( + not re.fullmatch(r"[A-Za-z_][A-Za-z_0-9]*", name) + or not isinstance(value, str) or not re.fullmatch(r"[0-9a-f]+", value) + for name, value in hashes.items() + ): + raise SystemExit("invalid entity hashes") + indexes = schema["indexes"] + if not isinstance(indexes, list) or any(not isinstance(item, str) for item in indexes): + raise SystemExit("invalid index description") + if indexes != sorted(set(indexes)): + raise SystemExit("indexes must be sorted and unique") + + +def read_inventory(root, commit): + inventory = json.loads(git(root, "show", f"{commit}:{INVENTORY_FILE}")) + return validate_inventory(inventory) + + +def validate_inventory(inventory): + if inventory.get("format_version") != 1: + raise SystemExit("unsupported historical schema model inventory") + models = inventory["models"] + groups = inventory["value_types"] + names = list(models) + [name for group in groups for name in group["names"]] + if len(names) != len(set(names)): + raise SystemExit("duplicate type in model inventory") + for name in names: + if not re.fullmatch(r"[A-Za-z_][A-Za-z_0-9]*", name): + raise SystemExit("invalid Swift name in model inventory") + for path in list(models.values()) + [group["path"] for group in groups]: + if not path.startswith("packages/swift-sdk/Sources/SwiftDashSDK/") or ".." in pathlib.PurePosixPath(path).parts: + raise SystemExit("model inventory path is outside the Swift SDK") + return inventory + + +# This is deliberately a restricted declaration grammar, not a Swift compiler. +# It checks explicit stored type expressions against the copied graph and +# standard types; standard names must not be shadowed elsewhere in the SDK. +# Unsupported storage syntax fails closed; SwiftData's native hash/index tests +# still verify the generated models' semantics and registration order. +SWIFT_SCALARS = set("Bool String Character Int Int8 Int16 Int32 Int64 UInt UInt8 UInt16 UInt32 UInt64 Float Double".split()) +FOUNDATION_SCALARS = set("Data Date UUID Decimal URL TimeInterval".split()) +STORAGE_CONTAINERS = {"Array": 1, "Set": 1, "Optional": 1, "Dictionary": 2} +STORAGE_PROTOCOLS = set("Codable Decodable Encodable Equatable Hashable Sendable CaseIterable Identifiable".split()) + + +def storage_tokens(lines, owner): + tokens = [] + for line in lines: + # The existing source copier supports ordinary single-line strings. + # Do not guess where a raw/multiline string or backtick identifier ends. + if re.search(r'#+"|"""|`', line.split("//", 1)[0]): + raise SystemExit(f"{owner}: raw/multiline strings and escaped identifiers need explicit parser support") + code = code_only(line) + tokens.extend(re.findall(r'[A-Za-z_][A-Za-z_0-9]*|""|[^\s]', code)) + tokens.append("\n") + return tokens + + +def matching_token(tokens, start, owner): + opening = tokens[start] + closing = {"(": ")", "[": "]", "{": "}"}[opening] + index = start + 1 + while index < len(tokens): + if tokens[index] == closing: + return index + if tokens[index] in ("(", "[", "{"): + index = matching_token(tokens, index, owner) + elif tokens[index] in (")", "]", "}"): + break + index += 1 + raise SystemExit(f"{owner}: unsupported or unbalanced storage declaration") + + +def validate_stored_type(tokens, names, owner): + tokens = [token for token in tokens if token != "\n"] + index = 0 + + def consume(): + nonlocal index + if index == len(tokens): + raise SystemExit(f"{owner}: missing stored type") + token = tokens[index] + index += 1 + if token == "[": + consume() + if index < len(tokens) and tokens[index] == ":": + index += 1 + consume() + if index == len(tokens) or tokens[index] != "]": + raise SystemExit(f"{owner}: unsupported collection type") + index += 1 + elif re.fullmatch(r"[A-Za-z_][A-Za-z_0-9]*", token): + name = token + if index < len(tokens) and tokens[index] == ".": + index += 1 + if index == len(tokens): + raise SystemExit(f"{owner}: incomplete qualified type") + name = tokens[index] + index += 1 + permitted = SWIFT_SCALARS | set(STORAGE_CONTAINERS) if token == "Swift" else FOUNDATION_SCALARS if token == "Foundation" else set() + if name not in permitted: + raise SystemExit(f"{owner}: module-qualified or nested stored type {token}.{name} is not isolated") + if name in STORAGE_CONTAINERS: + if index == len(tokens) or tokens[index] != "<": + raise SystemExit(f"{owner}: collection requires explicit type arguments") + index += 1 + for argument in range(STORAGE_CONTAINERS[name]): + if argument: + if index == len(tokens) or tokens[index] != ",": + raise SystemExit(f"{owner}: invalid generic collection arguments") + index += 1 + consume() + if index == len(tokens) or tokens[index] != ">": + raise SystemExit(f"{owner}: invalid generic collection arguments") + index += 1 + elif name not in names | SWIFT_SCALARS | FOUNDATION_SCALARS: + raise SystemExit(f"{owner}: stored type {name} is absent from schema-models.json; include its transitive value graph") + else: + raise SystemExit(f"{owner}: unsupported stored type expression {' '.join(tokens)}") + while index < len(tokens) and tokens[index] == "?": + index += 1 + + consume() + if index != len(tokens): + raise SystemExit(f"{owner}: unsupported stored type expression {' '.join(tokens)}") + + +def validate_storage_declaration(lines, name, names): + tokens = storage_tokens(lines, name) + start = tokens.index("{") + header = [token for token in tokens[:start] if token != "\n"] + if any(header[index + 1] != "Model" for index, token in enumerate(header) if token == "@"): + raise SystemExit(f"{name}: unsupported type declaration macro") + kind = next(token for token in header if token in ("class", "struct", "enum")) + inherited = header[header.index(name) + 1:] + if inherited: + if inherited[0] != ":" or any(token not in STORAGE_PROTOCOLS | SWIFT_SCALARS | {":", ","} for token in inherited): + raise SystemExit(f"{name}: generic types, custom conformances and inherited storage need explicit parser support") + end = matching_token(tokens, start, name) + index = start + 1 + prefix = [] + attributes = [] + while index < end: + token = tokens[index] + if token in ("\n", ";"): + index += 1 + continue + if token == "@": + attribute = tokens[index + 1] + index += 2 + if index < end and tokens[index] == "(": + close = matching_token(tokens, index, name) + arguments = tokens[index + 1:close] + if attribute == "Attribute" and "transformable" in arguments: + raise SystemExit(f"{name}: transformable storage requires explicit isolation support") + index = close + 1 + if attribute not in ("Attribute", "Relationship", "Transient"): + raise SystemExit(f"{name}: unsupported property macro @{attribute}") + attributes.append(attribute) + continue + if token in ("#", "typealias", "associatedtype"): + if token == "#" and tokens[index + 1] in ("Index", "Unique"): + opening = index + 2 + if tokens[opening:opening + 3] == ["<", name, ">"]: + opening += 3 + if tokens[opening] != "(": + raise SystemExit(f"{name}: unsupported index/unique declaration") + index = matching_token(tokens, opening, name) + 1 + prefix, attributes = [], [] + continue + raise SystemExit(f"{name}: conditional declarations, aliases and declaration macros need explicit isolation support") + if token == "class" and tokens[index + 1] in ("var", "func"): + prefix.append(token) + index += 1 + continue + if token in ("struct", "class", "enum", "actor", "protocol"): + nested_name = tokens[index + 1] + if nested_name in names | SWIFT_SCALARS | FOUNDATION_SCALARS | set(STORAGE_CONTAINERS) | {"Swift", "Foundation"}: + raise SystemExit(f"{name}: nested type {nested_name} shadows a stored type") + # Unused nested helpers are copied with their enclosing type. Any + # stored reference to them is rejected by validate_stored_type. + opening = tokens.index("{", index) + index = matching_token(tokens, opening, name) + 1 + prefix, attributes = [], [] + continue + if token in ("var", "let"): + field = tokens[index + 1] + if not re.fullmatch(r"[A-Za-z_][A-Za-z_0-9]*", field): + raise SystemExit(f"{name}: destructured stored declarations are unsupported") + cursor = index + 2 + expression = [] + while cursor < end and tokens[cursor] not in ("=", "{", ";", "\n"): + if tokens[cursor] in ("[", "("): + close = matching_token(tokens, cursor, name) + expression.extend(tokens[cursor:close + 1]) + cursor = close + 1 + else: + expression.append(tokens[cursor]) + cursor += 1 + # An accessor body is computed, except willSet/didSet observers. + # A closure initializer follows '=' and is still stored. + next_code = cursor + while tokens[next_code] == "\n": + next_code += 1 + computed = tokens[next_code] == "{" + if computed: + close = matching_token(tokens, next_code, name) + body = tokens[next_code + 1:close] + computed = "willSet" not in body and "didSet" not in body + if not (computed or "static" in prefix or "class" in prefix or "Transient" in attributes): + if not expression or expression[0] != ":": + raise SystemExit(f"{name}.{field}: inferred stored types need an explicit type annotation") + validate_stored_type(expression[1:], names, f"{name}.{field}") + # Skip the initializer/accessors, not just the type. A comma at + # declaration level could start another binding and is refused. + index = cursor + while index < end and tokens[index] not in ("\n", ";"): + if tokens[index] in ("{", "(", "["): + index = matching_token(tokens, index, name) + 1 + elif tokens[index] == ",": + raise SystemExit(f"{name}.{field}: multiple property bindings need separate declarations") + else: + index += 1 + if next_code < end and tokens[next_code] == "{" and cursor != next_code: + index = matching_token(tokens, next_code, name) + 1 + prefix, attributes = [], [] + continue + if token == "case" and kind == "enum": + index += 1 + while index < end and tokens[index] not in ("\n", ";"): + if tokens[index] == "(": + close = matching_token(tokens, index, name) + payload = tokens[index + 1:close] + # Split only outer commas; generic dictionary arguments + # and nested bracket syntax belong to the same payload. + groups, group, depth = [], [], 0 + for part in payload + [","]: + if part == "," and depth == 0: + groups.append(group) + group = [] + else: + group.append(part) + depth += int(part in ("[", "<", "(")) - int(part in ("]", ">", ")")) + for group in groups: + if ":" in group and group.index(":") < 2: + group = group[group.index(":") + 1:] + validate_stored_type(group, names, f"{name} enum payload") + index = close + 1 + else: + index += 1 + prefix, attributes = [], [] + continue + if token in ("func", "init", "deinit", "subscript"): + opening = index + 1 + while opening < end and tokens[opening] != "{": + if tokens[opening] in ("(", "["): + opening = matching_token(tokens, opening, name) + opening += 1 + if opening == end: + raise SystemExit(f"{name}: declaration without a body needs explicit parser support") + index = matching_token(tokens, opening, name) + 1 + prefix, attributes = [], [] + continue + if token == "(": + if tokens[index:index + 3] != ["(", "set", ")"]: + raise SystemExit(f"{name}: unsupported declaration continuation") + index = matching_token(tokens, index, name) + 1 + continue + if token not in {"public", "private", "fileprivate", "internal", "package", "open", "static", + "final", "override", "required", "convenience", "mutating", "nonmutating", + "lazy", "weak", "unowned", "dynamic", "nonisolated"}: + raise SystemExit(f"{name}: unsupported declaration token {token!r}") + prefix.append(token) + index += 1 + + +def validate_storage_graph(inventory, sources): + names = set(inventory["models"]) | {name for group in inventory["value_types"] for name in group["names"]} + if names & (SWIFT_SCALARS | FOUNDATION_SCALARS | set(STORAGE_CONTAINERS) | {"Swift", "Foundation"}): + raise SystemExit("inventory shadows a standard stored type") + for name, path in inventory["models"].items(): + validate_storage_declaration(extract_class(sources[path], name), name, names) + for group in inventory["value_types"]: + for name in group["names"]: + validate_storage_declaration(extract_value_type(sources[group["path"]], name), name, names) + + +def inventory_sources(root, inventory, commit=None): + paths = set(inventory["models"].values()) | {group["path"] for group in inventory["value_types"]} + return {path: git(root, "show", f"{commit}:{path}") if commit else pathlib.Path(root, path).read_text(encoding="utf-8") + for path in sorted(paths)} + + +def render_snapshot(root, version, entry, *, inventory=None): + validate_schema(entry["schema"]) + if entry["schema"]["schema_version"] != version: + raise SystemExit("registry key does not match captured schema version") + commit = entry["platform_sha"] + if not re.fullmatch(r"[0-9a-f]{40}", commit): + raise SystemExit("release source must be a full Git SHA") + namespace = "DashSchemaSnapshotV" + version.split(".")[0] + if entry["namespace"] != namespace: + raise SystemExit("unexpected snapshot namespace") + inventory = read_inventory(root, commit) if inventory is None else validate_inventory(inventory) + models = inventory["models"] + if set(models) != set(entry["schema"]["entity_hashes"]): + raise SystemExit("historical inventory differs from captured model membership") + sources = inventory_sources(root, inventory, commit) + validate_storage_graph(inventory, sources) + names = set(models) | {name for group in inventory["value_types"] for name in group["names"]} + sibling = re.compile(r"(?gateway', b'\xff'): + with self.subTest(broken=broken): + opener = mock.Mock(side_effect=[io.BytesIO(broken), io.BytesIO(b'[]')]) + sleep = mock.Mock() + self.assertEqual(worker.GitHub("secret", opener=opener, sleep=sleep).request("GET", "pulls"), []) + self.assertEqual(opener.call_count, 2) + sleep.assert_called_once_with(1) + + def test_invalid_read_response_has_bounded_retries_and_a_clear_error(self): + opener = mock.Mock(side_effect=lambda *_args, **_kwargs: io.BytesIO(b'{truncated')) + sleep = mock.Mock() + with self.assertRaisesRegex(worker.ReleaseError, "GET response could not be read or decoded"): + worker.GitHub("secret", opener=opener, sleep=sleep).request("GET", "pulls") + self.assertEqual(opener.call_count, 4) + self.assertEqual(sleep.call_args_list, [mock.call(1), mock.call(2), mock.call(4)]) + + def test_retries_connection_failure_while_reading_response_body(self): + response = mock.MagicMock() + response.__enter__.return_value.read.side_effect = http.client.IncompleteRead(b'partial') + opener = mock.Mock(side_effect=[response, io.BytesIO(b'[]')]) + self.assertEqual(worker.GitHub("secret", opener=opener, sleep=lambda _: None).request("GET", "pulls"), []) + self.assertEqual(opener.call_count, 2) + + def test_does_not_repeat_writes_after_an_unreadable_response(self): + for method in ("POST", "PATCH"): + with self.subTest(method=method): + opener = mock.Mock(return_value=io.BytesIO(b'{truncated')) + with self.assertRaisesRegex(worker.ReleaseError, "reconcile"): + worker.GitHub("secret", opener=opener).request(method, "pulls", {}) + self.assertEqual(opener.call_count, 1) + + def test_pr_listing_is_paginated(self): + api = worker.GitHub("secret") + api.request = mock.Mock(side_effect=[[{}] * 100, [{"number": 101}]]) + self.assertEqual(len(api.pull_requests("codex/freeze-swift-schema-v2.0.0")), 101) + self.assertIn("page=2", api.request.call_args.args[1]) + self.assertIn("sort=created", api.request.call_args.args[1]) + self.assertIn("direction=desc", api.request.call_args.args[1]) + + +class WorkerIntegrationTests(unittest.TestCase): + """The production worker pushes only to a temporary local bare repository.""" + + save = PublicationTests.save + + def setUp(self): + PublicationTests.setUp(self) + self.platform = self.root / "platform" + self.platform.mkdir() + git(self.platform, "init", "-b", worker.BASE_BRANCH) + git(self.platform, "config", "user.name", "Test") + git(self.platform, "config", "user.email", "test@example.invalid") + git(self.platform, "config", "commit.gpgsign", "false") + write_json(self.platform / worker.REGISTRY, {"format_version": 1, "schemas": {}, "releases": {}}) + generator = self.platform / worker.GENERATOR + generator.parent.mkdir(parents=True) + generator.write_text('''import json, os, pathlib, subprocess, sys +assert sys.flags.isolated +assert "SCHEMA_RELEASE_TOKEN" not in os.environ +assert "GITHUB_TOKEN" not in os.environ +assert "GIT_CONFIG_VALUE_1" not in os.environ +os.chdir(sys.argv[sys.argv.index("--repo") + 1]) +if "--check" in sys.argv: + registry = json.loads(pathlib.Path("packages/swift-sdk/schema-releases.json").read_text()) + for group in ("schemas", "releases"): + for entry in registry[group].values(): + subprocess.run(["git", "cat-file", "-e", entry["platform_sha"] + "^{commit}"], check=True) + sys.exit(0) +m = json.loads(pathlib.Path(sys.argv[sys.argv.index("--release-manifest") + 1]).read_text()) +p = pathlib.Path("packages/swift-sdk/schema-releases.json") +r = json.loads(p.read_text()) +v = m["schema"]["schema_version"] +if v in r["schemas"] and r["schemas"][v]["schema"] != m["schema"]: + sys.exit("Schema conflict") +r["schemas"].setdefault(v, {"schema": m["schema"], "platform_sha": m["platform_sha"]}) +p.write_text(json.dumps(r)) +''') + generator.chmod(0o755) + git(self.platform, "add", ".") + git(self.platform, "commit", "-m", "base") + self.manifest["platform_sha"] = git(self.platform, "rev-parse", "HEAD") + self.commit = self.save() + self.remote = self.root / "platform.git" + git(self.root, "clone", "--bare", str(self.platform), str(self.remote)) + self.api = mock.Mock() + self.api.pull_requests.return_value = [] + self.api.request.return_value = {"html_url": "https://github.com/dashpay/platform/pull/1"} + self.real_git = worker.git + + def redirected_git(self, directory, *args, **kwargs): + if args[:3] == ("remote", "set-url", "origin"): + args = (*args[:3], str(self.remote)) + return self.real_git(directory, *args, **kwargs) + + def prepare(self, dry_run=False): + with mock.patch.object(worker, "git", side_effect=self.redirected_git), \ + mock.patch.object(worker, "GitHub", return_value=self.api), \ + contextlib.redirect_stdout(io.StringIO()): + worker.prepare(self.platform, self.data, self.proof["release_id"], self.commit, "test-token", dry_run) + + def test_dry_run_does_not_publish_or_modify_checkout(self): + before = git(self.remote, "show-ref") + self.prepare(dry_run=True) + self.assertEqual(git(self.remote, "show-ref"), before) + self.api.request.assert_not_called() + self.assertEqual(git(self.platform, "status", "--porcelain"), "") + + def test_fixture_check_closes_connection_on_success_or_failure(self): + for corrupt in (False, True): + database = sqlite3.connect(self.store) + checked_database = mock.Mock(wraps=database) + if corrupt: + cursor = mock.Mock() + cursor.fetchone.return_value = ("corrupt",) + checked_database.execute.return_value = cursor + with mock.patch.object(worker.sqlite3, "connect", return_value=checked_database): + if corrupt: + with self.assertRaisesRegex(worker.ReleaseError, "corrupt or needs a WAL"): + self.prepare(dry_run=True) + else: + self.prepare(dry_run=True) + with self.assertRaises(sqlite3.ProgrammingError): + database.execute("SELECT 1") + + def test_newest_closed_unmerged_attempt_is_not_overridden_by_an_older_merge(self): + self.api.pull_requests.return_value = [ + {"state": "closed", "merged_at": None}, + {"state": "closed", "merged_at": "2026-09-18"}, + ] + before = git(self.remote, "show-ref") + for dry_run in (True, False): + with self.subTest(dry_run=dry_run), self.assertRaisesRegex(worker.ReleaseError, "reopen it to retry"): + self.prepare(dry_run=dry_run) + self.assertEqual(git(self.remote, "show-ref"), before) + self.api.request.assert_not_called() + + def test_push_then_retry_reuses_draft_pr_and_commit(self): + self.prepare() + self.assertTrue(self.api.request.call_args.args[2]["draft"]) + branch = "codex/freeze-swift-schema-v2.0.0" + before = git(self.remote, "rev-parse", branch) + self.api.pull_requests.return_value = [{"state": "open", "html_url": "https://example.invalid/pr"}] + self.api.request.reset_mock() + self.prepare() + self.assertEqual(git(self.remote, "rev-parse", branch), before) + self.api.request.assert_not_called() + + def test_retry_recovers_push_succeeded_pr_creation_failed(self): + self.api.request.side_effect = worker.ReleaseError("API unavailable") + with self.assertRaisesRegex(worker.ReleaseError, "API unavailable"): + self.prepare() + self.api.request.side_effect = None + self.api.request.reset_mock() + self.prepare() + self.api.request.assert_called_once() + + def test_human_edits_on_existing_branch_are_preserved(self): + self.prepare() + branch = "codex/freeze-swift-schema-v2.0.0" + git(self.platform, "fetch", str(self.remote), branch) + git(self.platform, "checkout", "-b", "human", "FETCH_HEAD") + (self.platform / "human-review.txt").write_text("Keep this review change\n") + git(self.platform, "add", "human-review.txt") + git(self.platform, "commit", "-m", "human review") + git(self.platform, "push", str(self.remote), f"HEAD:refs/heads/{branch}") + self.api.pull_requests.return_value = [{"state": "open", "html_url": "https://example.invalid/pr"}] + self.prepare() + self.assertEqual(git(self.remote, "show", f"{branch}:human-review.txt"), "Keep this review change") + + def test_draft_generator_and_imports_never_run_with_ambient_credentials(self): + self.prepare() + branch = "codex/freeze-swift-schema-v2.0.0" + git(self.platform, "fetch", str(self.remote), branch) + git(self.platform, "checkout", "-b", "untrusted-draft", "FETCH_HEAD") + marker = self.root / "untrusted-code-executed" + attack = f"import pathlib\npathlib.Path({str(marker)!r}).write_text('executed')\nraise RuntimeError('draft code ran')\n" + (self.platform / worker.GENERATOR).write_text(attack) + (self.platform / "json.py").write_text(attack) + (self.platform / "sitecustomize.py").write_text(attack) + git(self.platform, "add", ".") + git(self.platform, "commit", "-m", "unreviewed executable changes") + git(self.platform, "push", str(self.remote), f"HEAD:refs/heads/{branch}") + self.api.pull_requests.return_value = [{"state": "open", "html_url": "https://example.invalid/pr"}] + with mock.patch.dict(os.environ, {"SCHEMA_RELEASE_TOKEN": "synthetic-secret", + "GITHUB_TOKEN": "synthetic-other-secret", + "PYTHONPATH": str(self.platform)}): + self.prepare(dry_run=True) + self.prepare() + self.assertFalse(marker.exists()) + self.assertEqual(git(self.remote, "show", f"{branch}:{worker.GENERATOR}"), attack.strip()) + + def test_ambient_git_hooks_never_receive_worker_credentials(self): + hooks = self.root / "hooks" + hooks.mkdir() + marker = self.root / "hook-ran" + for name in ("post-checkout", "pre-commit", "pre-push"): + hook = hooks / name + hook.write_text(f"#!/bin/sh\ntouch '{marker}'\nexit 1\n") + hook.chmod(0o755) + config = self.root / "global.gitconfig" + config.write_text(f"[core]\n hooksPath = {hooks}\n") + git(self.root, "config", "--file", str(config), "filter.capture.smudge", f"touch '{marker}'; cat") + git(self.root, "config", "--file", str(config), "filter.capture.clean", f"touch '{marker}'; cat") + (self.platform / ".gitattributes").write_text("* filter=capture\n") + git(self.platform, "add", ".gitattributes") + git(self.platform, "commit", "-m", "draft-controlled filter selection") + git(self.platform, "push", str(self.remote), worker.BASE_BRANCH) + with mock.patch.dict(os.environ, {"GIT_CONFIG_GLOBAL": str(config), + "SCHEMA_RELEASE_TOKEN": "synthetic-secret"}): + self.prepare() + self.assertFalse(marker.exists()) + + def unreachable_source(self, name): + """Publish a source commit, then remove its only advertised branch.""" + git(self.platform, "checkout", "-b", name) + (self.platform / f"{name}.txt").write_text(name) + git(self.platform, "add", ".") + git(self.platform, "commit", "-m", name) + commit = git(self.platform, "rev-parse", "HEAD") + git(self.platform, "push", str(self.remote), f"HEAD:refs/heads/{name}") + git(self.platform, "checkout", worker.BASE_BRANCH) + git(self.platform, "branch", "-D", name) + git(self.remote, "update-ref", "-d", f"refs/heads/{name}") + return commit + + def test_source_tag_retains_rewritten_history_for_fresh_clones_and_later_freezes(self): + source = self.unreachable_source("released-before-force-push") + self.manifest["platform_sha"] = source + self.commit = self.save() + self.prepare() + self.assertEqual(git(self.remote, "rev-parse", worker.SOURCE_TAG_PREFIX + source), source) + branch = "codex/freeze-swift-schema-v2.0.0" + git(self.remote, "update-ref", f"refs/heads/{worker.BASE_BRANCH}", git(self.remote, "rev-parse", branch)) + git(self.remote, "reflog", "expire", "--expire=now", "--all") + git(self.remote, "gc", "--prune=now") + fresh = self.root / "fresh" + git(self.root, "clone", "--no-local", str(self.remote), str(fresh)) + self.assertEqual(git(fresh, "cat-file", "-t", source), "commit") + git(fresh, "config", "user.name", "Test") + git(fresh, "config", "user.email", "test@example.invalid") + git(fresh, "config", "commit.gpgsign", "false") + self.platform = fresh + second_source = self.unreachable_source("second-released-before-force-push") + self.manifest.update(platform_sha=second_source, app_version="3.0", build_number="31") + self.manifest["schema"].update(schema_version="3.0.0", model_checksum="new-checksum") + self.proof.update(release_id="release-31", app_version="3.0", build_number="31", + build_id="build-31", manifest_path="builds/org.dash.wallet/3.0/31/manifest.json") + self.commit = self.save() + without_tags = self.root / "checkout-without-source-tags" + git(self.root, "clone", "--no-local", "--no-tags", "--single-branch", "--branch", + worker.BASE_BRANCH, str(self.remote), str(without_tags)) + with self.assertRaisesRegex(RuntimeError, "Test git cat-file failed"): + git(without_tags, "cat-file", "-t", source) + self.platform = without_tags + self.api.pull_requests.return_value = [] + self.prepare() + self.assertEqual(git(self.remote, "rev-parse", worker.SOURCE_TAG_PREFIX + second_source), second_source) + newest = self.root / "newest" + git(self.root, "clone", "--no-local", str(self.remote), str(newest)) + self.assertEqual(git(newest, "cat-file", "-t", source), "commit") + self.assertEqual(git(newest, "cat-file", "-t", second_source), "commit") + + def test_historical_schema_source_is_fetched_and_retained_like_release_sources(self): + registry = {"format_version": 1, "schemas": {"3.0.0": {"platform_sha": "3" * 40}}, + "releases": {"r": {"platform_sha": "4" * 40}}, + "historical_schemas": {"2.0.0": {"source_sha": "5" * 40, "provenance": "reconstructed-model-match"}}} + self.assertEqual(worker.source_commits(registry), {"3" * 40, "4" * 40, "5" * 40}) + registry["historical_schemas"]["2.0.0"]["source_sha"] = "not-a-commit" + with self.assertRaisesRegex(worker.ReleaseError, "historical schema source commit"): + worker.source_commits(registry) + del registry["historical_schemas"]["2.0.0"]["source_sha"] + with self.assertRaisesRegex(worker.ReleaseError, "historical schema source commit"): + worker.source_commits(registry) + + source = self.unreachable_source("historical-v2-reconstruction-source") + base_registry = json.loads((self.platform / worker.REGISTRY).read_text()) + base_registry["historical_schemas"] = {"2.0.0": { + "source_sha": source, "provenance": "reconstructed-model-match"}} + write_json(self.platform / worker.REGISTRY, base_registry) + git(self.platform, "add", ".") + git(self.platform, "commit", "-m", "register historical source") + git(self.platform, "push", str(self.remote), f"{worker.BASE_BRANCH}:refs/heads/{worker.BASE_BRANCH}") + ref = worker.SOURCE_TAG_PREFIX + source + self.assertNotIn(ref, git(self.remote, "show-ref")) + self.prepare(dry_run=True) + self.assertNotIn(ref, git(self.remote, "show-ref")) + self.prepare() + self.assertEqual(git(self.remote, "rev-parse", ref), source) + self.assertEqual(git(self.remote, "rev-parse", worker.SOURCE_TAG_PREFIX + self.manifest["platform_sha"]), + self.manifest["platform_sha"]) + + def test_conflicting_source_tag_is_never_rewritten(self): + source = self.unreachable_source("released-source") + self.manifest["platform_sha"] = source + self.commit = self.save() + wrong = git(self.remote, "rev-parse", worker.BASE_BRANCH) + git(self.remote, "update-ref", worker.SOURCE_TAG_PREFIX + source, wrong) + for dry_run in (True, False): + with self.subTest(dry_run=dry_run), self.assertRaisesRegex(worker.ReleaseError, "different object"): + self.prepare(dry_run=dry_run) + self.assertEqual(git(self.remote, "rev-parse", worker.SOURCE_TAG_PREFIX + source), wrong) + self.api.request.assert_not_called() + + def test_annotated_source_tag_is_rejected_even_when_it_peels_to_the_named_commit(self): + source = self.manifest["platform_sha"] + tag = worker.SOURCE_TAG_PREFIX.removeprefix("refs/tags/") + source + git(self.platform, "tag", "--no-sign", "-a", tag, "-m", "annotated, not a direct commit ref", source) + git(self.platform, "push", str(self.remote), f"refs/tags/{tag}") + self.assertEqual(git(self.remote, "rev-parse", f"refs/tags/{tag}^{{commit}}"), source) + before = git(self.remote, "show-ref") + for dry_run in (True, False): + with self.subTest(dry_run=dry_run), self.assertRaisesRegex(worker.ReleaseError, "different object"): + self.prepare(dry_run=dry_run) + self.assertEqual(git(self.remote, "show-ref"), before) + self.api.request.assert_not_called() + + def test_valid_source_tag_passes_dry_run_without_writes(self): + source = self.manifest["platform_sha"] + git(self.remote, "update-ref", worker.SOURCE_TAG_PREFIX + source, source) + before = git(self.remote, "show-ref") + self.prepare(dry_run=True) + self.assertEqual(git(self.remote, "show-ref"), before) + self.api.request.assert_not_called() + + def test_source_tag_retry_reconciles_a_concurrent_matching_creation(self): + commit = self.manifest["platform_sha"] + ref = worker.SOURCE_TAG_PREFIX + commit + with mock.patch.object(worker, "git", side_effect=["", worker.ReleaseError("race"), f"{commit}\t{ref}"]): + worker.retain_source(self.platform, commit, worker.git_environment("synthetic")) + + def test_closed_unmerged_pr_requires_intervention(self): + self.api.pull_requests.return_value = [{"state": "closed", "merged_at": None}] + with self.assertRaisesRegex(worker.ReleaseError, "closed without merging"): + self.prepare() + self.api.request.assert_not_called() + + def test_already_merged_release_creates_no_commit_or_pr(self): + self.prepare() + branch = "codex/freeze-swift-schema-v2.0.0" + git(self.remote, "update-ref", f"refs/heads/{worker.BASE_BRANCH}", git(self.remote, "rev-parse", branch)) + self.api.pull_requests.return_value = [{"state": "closed", "merged_at": "2026-09-18"}] + self.api.request.reset_mock() + before = git(self.remote, "show-ref") + for dry_run in (True, False): + with self.subTest(dry_run=dry_run): + self.prepare(dry_run=dry_run) + self.assertEqual(git(self.remote, "show-ref"), before) + self.api.request.assert_not_called() + + def test_already_merged_release_checks_source_tags_without_creating_missing_tags_in_dry_run(self): + self.prepare() + branch = "codex/freeze-swift-schema-v2.0.0" + merged_commit = git(self.remote, "rev-parse", branch) + git(self.remote, "update-ref", f"refs/heads/{worker.BASE_BRANCH}", merged_commit) + self.api.pull_requests.return_value = [{"state": "closed", "merged_at": "2026-09-18"}] + self.api.request.reset_mock() + ref = worker.SOURCE_TAG_PREFIX + self.manifest["platform_sha"] + git(self.remote, "update-ref", "-d", ref) + before = git(self.remote, "show-ref") + self.prepare(dry_run=True) + self.assertEqual(git(self.remote, "show-ref"), before) + git(self.remote, "update-ref", ref, merged_commit) + before = git(self.remote, "show-ref") + for dry_run in (True, False): + with self.subTest(dry_run=dry_run), self.assertRaisesRegex(worker.ReleaseError, "different object"): + self.prepare(dry_run=dry_run) + self.assertEqual(git(self.remote, "show-ref"), before) + self.api.request.assert_not_called() + + def test_handled_release_survives_a_later_rejected_pr_and_still_repairs_source_tags(self): + self.prepare() + branch = "codex/freeze-swift-schema-v2.0.0" + merged_commit = git(self.remote, "rev-parse", branch) + git(self.remote, "update-ref", f"refs/heads/{worker.BASE_BRANCH}", merged_commit) + self.api.pull_requests.return_value = [ + {"state": "closed", "merged_at": None}, + {"state": "closed", "merged_at": "2026-09-18"}, + ] + self.api.request.reset_mock() + source = self.manifest["platform_sha"] + ref = worker.SOURCE_TAG_PREFIX + source + git(self.remote, "update-ref", "-d", ref) + before = git(self.remote, "show-ref") + self.prepare(dry_run=True) + self.assertEqual(git(self.remote, "show-ref"), before) + self.prepare() + self.assertEqual(git(self.remote, "rev-parse", ref), source) + self.assertEqual(git(self.remote, "rev-parse", branch), merged_commit) + git(self.remote, "update-ref", ref, merged_commit) + for dry_run in (True, False): + with self.subTest(dry_run=dry_run), self.assertRaisesRegex(worker.ReleaseError, "different object"): + self.prepare(dry_run=dry_run) + self.api.request.assert_not_called() + + def test_new_release_of_same_schema_appends_association_to_existing_pr(self): + self.prepare() + branch = "codex/freeze-swift-schema-v2.0.0" + before = json.loads(git(self.remote, "show", f"{branch}:{worker.REGISTRY}")) + self.manifest.update(app_version="2.1", build_number="22") + self.proof.update(release_id="release-22", app_version="2.1", build_number="22", + build_id="build-22", manifest_path="builds/org.dash.wallet/2.1/22/manifest.json") + self.commit = self.save() + self.api.pull_requests.return_value = [{"state": "open", "html_url": "https://example.invalid/pr"}] + self.api.request.reset_mock() + self.prepare() + after = json.loads(git(self.remote, "show", f"{branch}:{worker.REGISTRY}")) + self.assertEqual(after["schemas"], before["schemas"]) + self.assertEqual(set(after["releases"]), {"release-21", "release-22"}) + self.api.request.assert_not_called() + + def test_schema_conflict_cannot_change_existing_branch(self): + self.prepare() + branch = "codex/freeze-swift-schema-v2.0.0" + before = git(self.remote, "rev-parse", branch) + self.manifest["schema"]["indexes"] = ["new index"] + self.commit = self.save() + self.api.pull_requests.return_value = [{"state": "open", "html_url": "https://example.invalid/pr"}] + self.api.request.reset_mock() + with self.assertRaisesRegex(worker.ReleaseError, "Schema conflict"): + self.prepare() + self.assertEqual(git(self.remote, "rev-parse", branch), before) + self.api.request.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/swift-sdk/scripts/test_freeze_schema_models.py b/packages/swift-sdk/scripts/test_freeze_schema_models.py index 7820bb19e7c..128f501bd42 100644 --- a/packages/swift-sdk/scripts/test_freeze_schema_models.py +++ b/packages/swift-sdk/scripts/test_freeze_schema_models.py @@ -13,6 +13,13 @@ """ import os +import contextlib +import hashlib +import json +import plistlib +import sqlite3 +from pathlib import Path +from unittest import mock import shutil import sys import tempfile @@ -39,9 +46,13 @@ def setUp(self): shutil.copytree( os.path.join(ROOT, gen.OUT_DIR), os.path.join(self.scratch, gen.OUT_DIR) ) + target = os.path.join(self.scratch, gen.TEST_REGISTRY_FILE) + os.makedirs(os.path.dirname(target), exist_ok=True) + shutil.copyfile(os.path.join(ROOT, gen.TEST_REGISTRY_FILE), target) def test_should_find_the_committed_files_are_the_generators_output(self): - self.assertEqual(len(self.files), 73) + self.assertEqual(len(gen.render_baseline(ROOT)), 71) + self.assertIn(gen.TEST_REGISTRY_FILE, self.files) self.assertEqual(gen.check_problems(ROOT, self.files), []) def test_should_report_a_hand_edit_to_a_frozen_file(self): @@ -51,7 +62,7 @@ def test_should_report_a_hand_edit_to_a_frozen_file(self): self.assertEqual(gen.check_problems(self.scratch, self.files), [f"differs: {path}"]) def test_should_report_a_missing_and_a_stale_file(self): - missing = f"{gen.OUT_DIR}/DashSchemaV3+PersistentAssetLock.swift" + missing = f"{gen.OUT_DIR}/DashSchemaV1+PersistentAssetLock.swift" stale = f"{gen.OUT_DIR}/DashSchemaV9+PersistentGhost.swift" os.rename( os.path.join(self.scratch, missing), os.path.join(self.scratch, stale) @@ -82,5 +93,323 @@ def test_should_refuse_a_block_comment_rather_than_misread_it(self): gen.block_end(lines, 0) +class StorageGraphTests(unittest.TestCase): + def setUp(self): + self.model_path = gen.MODELS_DIR + "/PersistentThing.swift" + self.values_path = "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/Values.swift" + self.inventory = {"format_version": 1, "models": {"PersistentThing": self.model_path}, "value_types": []} + self.sources = {self.model_path: "@Model\npublic final class PersistentThing {\n var payload: Payload?\n}\n"} + + def include_values(self, names, source): + self.inventory["value_types"] = [{"path": self.values_path, "names": names}] + self.sources[self.values_path] = source + + def check(self): + gen.validate_storage_graph(gen.validate_inventory(self.inventory), self.sources) + + def test_should_refuse_a_real_omitted_stored_codable_type(self): + self.sources[self.values_path] = "public struct Payload: Codable {\n var value: String\n}\n" + with self.assertRaisesRegex(SystemExit, "PersistentThing.payload: stored type Payload is absent"): + self.check() + self.include_values(["Payload"], self.sources[self.values_path]) + self.check() + + def test_should_follow_transitive_struct_fields_and_enum_payloads_through_containers(self): + source = ("public struct Payload: Codable {\n var values: [String: Array>]\n}\n" + "public enum Event: Codable {\n case changed(detail: [String: Leaf?]), deleted\n}\n" + "public struct Leaf: Codable {\n var values: Swift.Set\n}\n") + self.include_values(["Payload", "Event"], source) + with self.assertRaisesRegex(SystemExit, "Event enum payload: stored type Leaf is absent"): + self.check() + self.inventory["value_types"][0]["names"].append("Leaf") + self.check() + self.sources[self.values_path] = source.replace("Swift.Set", "Dictionary") + with self.assertRaisesRegex(SystemExit, "Leaf.values: stored type Missing is absent"): + self.check() + + def test_should_ignore_computed_static_and_transient_helpers_but_not_observed_storage(self): + self.sources[self.model_path] = """@Model +public final class PersistentThing { + @Transient var helper: LiveHelper? + static var cache = LiveHelper() + var computed: LiveHelper { LiveHelper() } + var nextLine: LiveHelper + { LiveHelper() } + var stored: String = "var phantom: Missing { // }" + // var omitted: Missing + func helper(defaultValue: () -> LiveHelper = { LiveHelper() }) { } +} +""" + self.check() + self.sources[self.model_path] = self.sources[self.model_path].replace( + 'var stored: String = "var phantom: Missing { // }"', + "var stored: Missing { didSet { print(stored) } }") + with self.assertRaisesRegex(SystemExit, "PersistentThing.stored: stored type Missing is absent"): + self.check() + + def test_should_fail_closed_for_unsupported_storage_forms(self): + forms = { + "var payload = Payload()": "inferred stored types", + "var payload: External.Payload?": "not isolated", + "typealias Alias = Payload\nvar payload: Alias": "aliases", + "var payload: Alias": "stored type Alias is absent", + "var payload: (String, Int)": "unsupported stored type", + "var payload: Wrapper": "stored type Wrapper is absent", + "var a: String, b: Missing": "unsupported stored type", + "#if DEBUG\nvar payload: String\n#endif": "conditional declarations", + "@Attribute(.transformable(by: LiveTransformer.self)) var payload: Data": "transformable", + "@MyStorage var payload: String": "unsupported property macro", + "enum Swift { }\nvar payload: Swift.String": "shadows a stored type", + } + for declaration, error in forms.items(): + self.sources[self.model_path] = "@Model\npublic final class PersistentThing {\n" + declaration + "\n}\n" + with self.subTest(declaration=declaration), self.assertRaisesRegex(SystemExit, error): + self.check() + + def test_should_refuse_to_guess_unsupported_lexical_syntax(self): + for declaration, error in (("var `payload`: String", "escaped identifiers"), + ('var payload: String = #"raw"#', "raw/multiline"), + ("/* hidden } */ var payload: Missing", "block comments")): + self.sources[self.model_path] = "@Model\npublic final class PersistentThing {\n" + declaration + "\n}\n" + with self.subTest(declaration=declaration), self.assertRaisesRegex(SystemExit, error): + self.check() + + def test_should_check_the_current_inventory_and_detect_a_real_transitive_omission(self): + inventory = gen.validate_inventory(json.loads(Path(ROOT, gen.INVENTORY_FILE).read_text())) + sources = gen.inventory_sources(ROOT, inventory) + gen.validate_storage_graph(inventory, sources) + inventory["value_types"][0]["names"].remove("DistributionEvent") + with self.assertRaisesRegex(SystemExit, "TokenPreProgrammedDistribution.distributionSchedule: stored type DistributionEvent is absent"): + gen.validate_storage_graph(inventory, sources) + + def test_should_reject_omitted_values_before_rendering_a_snapshot(self): + schema = {"schema_version": "2.0.0", "model_checksum": "checksum", "entity_hashes": {"PersistentThing": "ab"}, "indexes": []} + entry = {"platform_sha": "a" * 40, "namespace": "DashSchemaSnapshotV2", "schema": schema} + def read(root, operation, object_path): + self.assertEqual(operation, "show") + path = object_path.split(":", 1)[1] + return json.dumps(self.inventory) if path == gen.INVENTORY_FILE else self.sources[path] + with mock.patch.object(gen, "git", side_effect=read): + with self.assertRaisesRegex(SystemExit, "stored type Payload is absent"): + gen.render_snapshot(ROOT, "2.0.0", entry) + + +class ReleaseTests(unittest.TestCase): + def setUp(self): + self.root = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.root) + registry = Path(self.root, gen.REGISTRY_FILE) + registry.parent.mkdir(parents=True) + registry.write_text(json.dumps({"format_version": 1, "schemas": {}, "releases": {"apple": {"build_id": "existing"}}})) + self.fixture = Path(self.root, "capture.store") + self.schema = { + "schema_version": "2.0.0", "model_checksum": "checksum", + "entity_hashes": {"PersistentThing": "abcd"}, "indexes": []} + with contextlib.closing(sqlite3.connect(self.fixture)) as database, database: + database.execute("CREATE TABLE Z_METADATA (Z_PLIST BLOB)") + metadata = {"NSStoreModelVersionIdentifiers": ["2.0.0"], "NSStoreModelVersionChecksumKey": "checksum", "NSStoreModelVersionHashes": {"PersistentThing": bytes.fromhex("abcd")}} + database.execute("INSERT INTO Z_METADATA VALUES (?)", (plistlib.dumps(metadata, fmt=plistlib.FMT_BINARY),)) + digest = hashlib.sha256(self.fixture.read_bytes()).hexdigest() + self.manifest = { + "format_version": 1, "platform_sha": "a" * 40, "schema": self.schema, + "fixture_sha256": digest, "fixture_path": f"stores/{digest}.store"} + self.inventory = { + "format_version": 1, + "models": {"PersistentThing": gen.MODELS_DIR + "/PersistentThing.swift"}, + "value_types": []} + self.git_calls = [] + + def git(root, *args): + self.git_calls.append(args) + if args[0] == "show" and args[1].split(":", 1)[1] == gen.INVENTORY_FILE: + return json.dumps(self.inventory) + if args[0] == "show" and args[1].split(":", 1)[1] == gen.MODELS_DIR + "/PersistentThing.swift": + return "@Model\npublic final class PersistentThing {\n var old: String = \"released\"\n}\n" + raise AssertionError(f"unexpected historical read: {args}") + + self.addCleanup(mock.patch.stopall) + mock.patch.object(gen, "git", side_effect=git).start() + + def historical_registry(self): + path = "packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/historical-v2.store" + target = Path(self.root, path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(self.fixture.read_bytes()) + registry = gen.read_registry(self.root) + registry["historical_schemas"] = {"2.0.0": { + "schema": dict(self.schema), "fixture_path": path, + "fixture_sha256": hashlib.sha256(target.read_bytes()).hexdigest(), + "source_sha": gen.HISTORICAL_V2_SOURCE, "provenance": "reconstructed-model-match", + "app_store_baseline": { + "bundle_id": "org.dashfoundation.dash", "app_id": "1206647026", "app_version": "9.0.2", + "release_id": "679060c3-2e64-49d6-b28e-baa307c817be"}}} + Path(self.root, gen.REGISTRY_FILE).write_text(json.dumps(registry)) + return registry + + def test_should_validate_the_historical_app_store_baseline_the_ios_gate_reads(self): + self.assertEqual(gen.validate_historical_schemas(self.root, self.historical_registry()).keys(), {"2.0.0"}) + mutations = { + "missing": lambda entry: entry.pop("app_store_baseline"), + "not an object": lambda entry: entry.__setitem__("app_store_baseline", "9.0.2"), + "missing field": lambda entry: entry["app_store_baseline"].pop("release_id"), + "extra field": lambda entry: entry["app_store_baseline"].__setitem__("build_number", "30"), + "empty version": lambda entry: entry["app_store_baseline"].__setitem__("app_version", ""), + "non-string app id": lambda entry: entry["app_store_baseline"].__setitem__("app_id", 1206647026), + "malformed release id": lambda entry: entry["app_store_baseline"].__setitem__("release_id", "release-30"), + "malformed bundle id": lambda entry: entry["app_store_baseline"].__setitem__("bundle_id", "dash"), + } + for name, mutate in mutations.items(): + registry = self.historical_registry() + mutate(registry["historical_schemas"]["2.0.0"]) + with self.subTest(case=name), self.assertRaisesRegex(SystemExit, "App Store baseline"): + gen.validate_historical_schemas(self.root, registry) + + def test_should_reserve_historical_v2_without_writing_snapshot(self): + self.historical_registry() + before = Path(self.root, gen.REGISTRY_FILE).read_bytes() + with self.assertRaisesRegex(SystemExit, "Historical schema version is reserved"): + gen.add_release(self.root, self.manifest, self.fixture) + self.assertEqual(Path(self.root, gen.REGISTRY_FILE).read_bytes(), before) + self.assertFalse(Path(self.root, gen.FIXTURE_DIR).exists()) + + def test_should_reject_modified_historical_fixture(self): + registry = self.historical_registry() + Path(self.root, registry["historical_schemas"]["2.0.0"]["fixture_path"]).write_bytes(b"changed") + with self.assertRaisesRegex(SystemExit, "immutable historical fixture"): + gen.validate_historical_schemas(self.root, registry) + + def test_should_reject_changed_historical_schema_or_reconstruction_source(self): + for field in ("source_sha", "schema"): + registry = self.historical_registry() + entry = registry["historical_schemas"]["2.0.0"] + if field == "source_sha": + entry[field] = "f" * 40 + else: + entry[field]["model_checksum"] = "changed" + with self.subTest(field=field), self.assertRaises(SystemExit): + gen.validate_historical_schemas(self.root, registry) + + def test_should_copy_exact_captured_commit_and_preserve_release_metadata(self): + registry = gen.add_release(self.root, self.manifest, self.fixture) + entry = registry["schemas"]["2.0.0"] + self.assertEqual(registry["releases"], {"apple": {"build_id": "existing"}}) + self.assertEqual(Path(self.root, entry["fixture_path"]).read_bytes(), self.fixture.read_bytes()) + rendered = gen.render_snapshot(self.root, "2.0.0", entry) + self.assertIn('var old: String = "released"', rendered[f"{gen.OUT_DIR}/DashSchemaSnapshotV2+PersistentThing.swift"]) + self.assertIn("enum DashSchemaSnapshotV2", rendered[f"{gen.OUT_DIR}/DashSchemaSnapshotV2+Schema.swift"]) + self.assertTrue(all("a" * 40 + ":" in args[-1] for args in self.git_calls)) + + def test_should_close_fixture_connection_after_success_or_validation_failure(self): + for version in ("2.0.0", "3.0.0"): + database = sqlite3.connect(self.fixture) + schema = dict(self.schema, schema_version=version) + with mock.patch.object(gen.sqlite3, "connect", return_value=database): + if version == "2.0.0": + gen.validate_fixture_description(self.fixture, schema) + else: + with self.assertRaisesRegex(SystemExit, "schema version does not match"): + gen.validate_fixture_description(self.fixture, schema) + with self.assertRaises(sqlite3.ProgrammingError): + database.execute("SELECT 1") + + def test_should_reuse_same_shape_even_when_a_later_build_has_different_bytes_and_sha(self): + first = gen.add_release(self.root, self.manifest, self.fixture) + with contextlib.closing(sqlite3.connect(self.fixture)) as database, database: + database.execute("CREATE TABLE unimportant (value INTEGER)") + digest = hashlib.sha256(self.fixture.read_bytes()).hexdigest() + next_manifest = {**self.manifest, "platform_sha": "b" * 40, + "fixture_sha256": digest, "fixture_path": f"stores/{digest}.store"} + self.assertEqual(gen.add_release(self.root, next_manifest, self.fixture), first) + + def test_should_reject_missing_value_types_before_admitting_new_or_same_shape_evidence(self): + original_read = gen.git + + def omitted_value(root, *args): + if args[0] == "show" and args[1].endswith("/PersistentThing.swift"): + return "@Model\npublic final class PersistentThing {\n var payload: MissingCodable?\n}\n" + return original_read(root, *args) + + for already_registered in (False, True): + if already_registered: + gen.add_release(self.root, self.manifest, self.fixture) + before = Path(self.root, gen.REGISTRY_FILE).read_bytes() + with self.subTest(already_registered=already_registered), mock.patch.object(gen, "git", side_effect=omitted_value): + with self.assertRaisesRegex(SystemExit, "stored type MissingCodable is absent"): + gen.add_release(self.root, self.manifest, self.fixture) + self.assertEqual(Path(self.root, gen.REGISTRY_FILE).read_bytes(), before) + if not already_registered: + self.assertFalse(Path(self.root, gen.FIXTURE_DIR).exists()) + + def test_should_reject_changed_shape_under_published_version_including_indexes(self): + gen.add_release(self.root, self.manifest, self.fixture) + original = self.fixture.read_bytes() + for field in ["model_checksum", "indexes"]: + self.fixture.write_bytes(original) + schema = dict(self.schema) + with contextlib.closing(sqlite3.connect(self.fixture)) as database, database: + if field == "model_checksum": + metadata = plistlib.loads(database.execute("SELECT Z_PLIST FROM Z_METADATA").fetchone()[0]) + metadata["NSStoreModelVersionChecksumKey"] = "changed" + database.execute("UPDATE Z_METADATA SET Z_PLIST = ?", (plistlib.dumps(metadata),)) + schema[field] = "changed" + else: + database.execute("CREATE INDEX fixture_index ON Z_METADATA (Z_PLIST)") + schema[field] = ["Z_METADATA fixture_index: CREATE INDEX fixture_index ON Z_METADATA (Z_PLIST)"] + digest = hashlib.sha256(self.fixture.read_bytes()).hexdigest() + manifest = {**self.manifest, "schema": schema, + "fixture_sha256": digest, "fixture_path": f"stores/{digest}.store"} + with self.subTest(field=field), self.assertRaisesRegex(SystemExit, "immutable shape"): + gen.add_release(self.root, manifest, self.fixture) + + def test_should_reject_duplicate_checksum_under_another_version(self): + gen.add_release(self.root, self.manifest, self.fixture) + with contextlib.closing(sqlite3.connect(self.fixture)) as database, database: + metadata = plistlib.loads(database.execute("SELECT Z_PLIST FROM Z_METADATA").fetchone()[0]) + metadata["NSStoreModelVersionIdentifiers"] = ["3.0.0"] + database.execute("UPDATE Z_METADATA SET Z_PLIST = ?", (plistlib.dumps(metadata),)) + digest = hashlib.sha256(self.fixture.read_bytes()).hexdigest() + manifest = {**self.manifest, "schema": {**self.schema, "schema_version": "3.0.0"}, + "fixture_sha256": digest, "fixture_path": f"stores/{digest}.store"} + with self.assertRaisesRegex(SystemExit, "reuse a published model checksum"): + gen.add_release(self.root, manifest, self.fixture) + + def test_should_reject_mismatched_evidence_before_writing_registry(self): + self.fixture.write_bytes(b"modified") + with self.assertRaisesRegex(SystemExit, "fixture does not match"): + gen.add_release(self.root, self.manifest, self.fixture) + self.assertEqual(gen.read_registry(self.root)["schemas"], {}) + + def test_should_reject_correct_digest_with_fabricated_schema_description(self): + manifest = {**self.manifest, "schema": {**self.schema, "entity_hashes": {"PersistentThing": "beef"}}} + with self.assertRaisesRegex(SystemExit, "does not match SQLite fixture metadata"): + gen.add_release(self.root, manifest, self.fixture) + self.assertEqual(gen.read_registry(self.root)["schemas"], {}) + + def test_should_reject_an_incorrect_store_version(self): + manifest = {**self.manifest, "schema": {**self.schema, "schema_version": "3.0.0"}} + with self.assertRaisesRegex(SystemExit, "schema version does not match"): + gen.add_release(self.root, manifest, self.fixture) + + def test_should_reject_inventory_that_omits_a_captured_entity(self): + self.inventory["models"] = {} + with self.assertRaisesRegex(SystemExit, "model membership"): + gen.add_release(self.root, self.manifest, self.fixture) + self.assertEqual(gen.read_registry(self.root)["schemas"], {}) + + def test_should_never_accept_a_new_v1_snapshot(self): + manifest = {**self.manifest, "schema": {**self.schema, "schema_version": "1.0.0"}} + with self.assertRaisesRegex(SystemExit, "V1 must remain unchanged"): + gen.add_release(self.root, manifest, self.fixture) + + def test_should_check_generated_registry_and_immutable_fixture(self): + registry = gen.add_release(self.root, self.manifest, self.fixture) + with mock.patch.object(gen, "render_baseline", return_value={}): + files = gen.render_all(self.root) + self.assertIn("DashSchemaSnapshotV2.self", files[gen.TEST_REGISTRY_FILE]) + Path(self.root, registry["schemas"]["2.0.0"]["fixture_path"]).write_bytes(b"edited") + with self.assertRaisesRegex(SystemExit, "immutable release fixture"): + gen.render_all(self.root) + + if __name__ == "__main__": unittest.main() diff --git a/packages/swift-sdk/scripts/test_historical_schema_fixture.py b/packages/swift-sdk/scripts/test_historical_schema_fixture.py new file mode 100644 index 00000000000..566407e7d7e --- /dev/null +++ b/packages/swift-sdk/scripts/test_historical_schema_fixture.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Regression checks for the pinned, source-reconstructed legacy fixture.""" + +import copy +import json +from pathlib import Path +import tempfile +import unittest +from unittest import mock + +import historical_schema_fixture as historical + + +ROOT = Path(historical.freeze.repo_root()) + + +class HistoricalFixtureTests(unittest.TestCase): + def setUp(self): + self.manifest = historical.read_manifest(ROOT) + + def test_should_reproduce_the_committed_artifacts_from_the_pinned_source_pair(self): + graph = historical.verify(ROOT) + self.assertEqual(len(graph), 36) + self.assertEqual(len(self.manifest["schema"]["entity_hashes"]), 34) + self.assertNotIn("PersistentTrackedMasternode", self.manifest["inventory"]["models"]) + + def test_should_reject_provenance_that_claims_a_different_source_or_app_store_release(self): + with tempfile.TemporaryDirectory() as scratch: + path = Path(scratch, historical.MANIFEST) + path.parent.mkdir(parents=True) + for key, value in (("platform_sha", "a" * 40), ("wallet_sha", "b" * 40), + ("app_store_provenance", "verified")): + path.write_text(json.dumps(dict(self.manifest, **{key: value}))) + with self.subTest(key=key), self.assertRaisesRegex(SystemExit, "provenance changed"): + historical.read_manifest(scratch) + + def test_should_reject_changed_fixture_bytes_before_reading_history(self): + with tempfile.TemporaryDirectory() as scratch: + fixture = Path(scratch, historical.FIXTURE_DIR, "fixture.store") + fixture.parent.mkdir(parents=True) + fixture.write_bytes(b"not the recorded fixture") + with mock.patch.object(historical.freeze, "git") as git: + with self.assertRaisesRegex(SystemExit, "checksum mismatch"): + historical.verify(scratch, self.manifest) + git.assert_not_called() + + def test_should_reject_a_schema_description_that_does_not_match_the_sqlite_store(self): + manifest = copy.deepcopy(self.manifest) + manifest["schema"]["entity_hashes"]["PersistentDocumentType"] = "00" * 32 + with self.assertRaisesRegex(SystemExit, "SQLite fixture metadata"): + historical.verify(ROOT, manifest) + + def test_should_reject_changes_to_the_recorded_synthetic_rows(self): + self.manifest["synthetic_record_counts"]["ZPERSISTENTWALLET"] = 2 + with self.assertRaisesRegex(SystemExit, "synthetic records changed"): + historical.verify(ROOT, self.manifest) + + def test_should_reject_an_inventory_that_differs_from_the_historical_factory(self): + self.manifest["inventory"]["models"].pop("PersistentWallet") + with self.assertRaisesRegex(SystemExit, "pinned factory"): + historical.render_graph(ROOT, self.manifest) + + def test_should_detect_source_digest_and_generator_output_drift(self): + for field, error in (("source_file_sha256", "source file digests"), + ("generated_file_sha256", "graph regeneration")): + manifest = copy.deepcopy(self.manifest) + manifest[field][next(iter(manifest[field]))] = "0" * 64 + with self.subTest(field=field), self.assertRaisesRegex(SystemExit, error): + historical.verify(ROOT, manifest) + + def test_should_detect_a_changed_capture_recipe(self): + self.manifest["capture_test_sha256"] = "0" * 64 + with self.assertRaisesRegex(SystemExit, "capture recipe changed"): + historical.verify(ROOT, self.manifest) + + +class CapturePreparationTests(unittest.TestCase): + def test_should_refuse_to_export_into_the_repository_or_an_existing_directory(self): + with tempfile.TemporaryDirectory() as scratch: + root = Path(scratch, "repository") + root.mkdir() + for destination in (root / "new-sdk", Path(scratch)): + with self.subTest(destination=destination), self.assertRaisesRegex(SystemExit, "new directory outside"): + historical.prepare_sdk(root, destination) + self.assertEqual(list(root.iterdir()), []) + + def test_should_export_historical_models_only_into_a_disposable_sdk_copy(self): + with tempfile.TemporaryDirectory() as scratch: + root, destination = Path(scratch, "repository"), Path(scratch, "capture-sdk") + sdk = root / historical.SDK + for folder in ("Sources/SwiftDashSDK", "SwiftTests/SwiftDashSDKTests", "DashSDKFFI.xcframework"): + (sdk / folder).mkdir(parents=True) + (sdk / "Package.swift").write_text("// package\n") + (sdk / "Sources/SwiftDashSDK/Current.swift").write_text("// current\n") + template = root / historical.CAPTURE_TEST + template.parent.mkdir(parents=True) + template.write_text("// capture test\n") + generated = f"{historical.freeze.OUT_DIR}/DashSchemaSnapshotV1+Schema.swift" + with mock.patch.object(historical, "verify", return_value={generated: "// historical\n"}): + historical.prepare_sdk(root, destination) + self.assertFalse((root / generated).exists()) + self.assertEqual((destination / Path(generated).relative_to(historical.SDK)).read_text(), "// historical\n") + self.assertEqual((destination / "Sources/SwiftDashSDK/Current.swift").read_text(), "// current\n") + self.assertEqual((destination / "SwiftTests/SwiftDashSDKTests/DashHistoricalFixtureCaptureTests.swift").read_bytes(), template.read_bytes()) + self.assertEqual((destination / "DashSDKFFI.xcframework").resolve(), (sdk / "DashSDKFFI.xcframework").resolve()) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 6f776a59f28..06fa5f6d474 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -91,21 +91,23 @@ use dpp::consensus::basic::contract_group::{ InvalidContractGroupAdminsError, RedundantContractGroupMembershipError, }; use dpp::consensus::basic::contract_moderation::{ - ContractModerationReasonTooLongError, ContractModerationSelfTargetError, + ContractModerationReasonTooLongError, InvalidContractModerationReasonDocumentsError, ContractModerationSelfTargetError, DocumentActionFeesWithoutModerationError, InvalidContractModerationConfigError, }; use dpp::consensus::state::contract_moderation::{ ContractFeeClaimNotAllowedError, ContractFeesAlreadyClaimedThisEpochError, - ContractFeesNothingToClaimError, + ContractFeesNothingToClaimError, ContractModeratedDocumentTypeNotYetUsableError, ContractModerationNotEnabledError, ContractModerationTargetNotAllowedError, ContractModerationCounterpartyBarredError, ContractModerationTargetNotFoundError, ContractModeratorIdentityNotFoundError, ContractSuspensionNotInFutureError, ContractUserAlreadyBannedError, ContractUserBannedError, ContractUserNotBannedError, - ContractUserNotSuspendedError, ContractUserSuspendedError, - DocumentModerationWindowElapsedError, DocumentTypeNotDeletableByModeratorsError, - IdentityNotContractModeratorError, + ContractUserNotSuspendedError, ContractUserNotWarnedError, ContractUserSuspendedError, + ContractUserWarningLimitReachedError, ContractDocumentAlreadyRestoredError, + ContractDocumentRemovalNotFoundError, DocumentModerationWindowElapsedError, + DocumentRestoreHashMismatchError, DocumentRestoreWindowElapsedError, + DocumentTypeNotDeletableByModeratorsError, IdentityNotContractModeratorError, }; use dpp::consensus::state::contract_group::{ ContractGroupAdminNotFoundError, ContractGroupAlreadyExistsError, ContractGroupNotFoundError, @@ -156,12 +158,14 @@ use dpp::consensus::state::shielded::invalid_anchor_error::InvalidAnchorError; use dpp::consensus::state::shielded::invalid_shielded_proof_error::InvalidShieldedProofError; use dpp::consensus::state::shielded::nullifier_already_spent_error::NullifierAlreadySpentError; use dpp::consensus::basic::state_transition::{StateTransitionNotActiveError, TransitionOverMaxInputsError, TransitionOverMaxOutputsError, InputWitnessCountMismatchError, TransitionNoInputsError, TransitionNoOutputsError, FeeStrategyEmptyError, FeeStrategyDuplicateError, FeeStrategyIndexOutOfBoundsError, FeeStrategyTooManyStepsError, InputBelowMinimumError, OutputBelowMinimumError, InputOutputBalanceMismatchError, OutputsNotGreaterThanInputsError, WithdrawalBalanceMismatchError, InsufficientFundingAmountError, InputsNotLessThanOutputsError, OutputAddressAlsoInputError, InvalidRemainderOutputCountError, WithdrawalBelowMinAmountError, ShieldedNoActionsError, ShieldedTooManyActionsError, ShieldedEmptyProofError, ShieldedZeroAnchorError, ShieldedInvalidValueBalanceError, ShieldedEncryptedNoteSizeMismatchError, ShieldedImplicitFeeCapExceededError, ShieldedInvalidDenominationError}; +use dpp::consensus::state::document::referenced_contract_requirement_not_met_error::ReferencedContractRequirementNotMetError; use dpp::consensus::state::voting::masternode_incorrect_voter_identity_id_error::MasternodeIncorrectVoterIdentityIdError; use dpp::consensus::state::voting::masternode_incorrect_voting_address_error::MasternodeIncorrectVotingAddressError; use dpp::consensus::state::voting::masternode_not_found_error::MasternodeNotFoundError; use dpp::consensus::state::voting::masternode_vote_already_present_error::MasternodeVoteAlreadyPresentError; use dpp::consensus::state::voting::masternode_voted_too_many_times::MasternodeVotedTooManyTimesError; use dpp::consensus::state::voting::vote_poll_not_available_for_voting_error::VotePollNotAvailableForVotingError; +use dpp::consensus::state::voting::vote_choice_not_allowed_for_vote_poll_error::VoteChoiceNotAllowedForVotePollError; use dpp::consensus::state::voting::vote_poll_not_found_error::VotePollNotFoundError; use crate::errors::consensus::basic::data_contract::{ @@ -656,6 +660,33 @@ pub fn from_state_error(state_error: &StateError) -> JsValue { StateError::DocumentModerationWindowElapsedError(e) => { generic_consensus_error!(DocumentModerationWindowElapsedError, e).into() } + StateError::ContractDocumentRemovalNotFoundError(e) => { + generic_consensus_error!(ContractDocumentRemovalNotFoundError, e).into() + } + StateError::DocumentRestoreWindowElapsedError(e) => { + generic_consensus_error!(DocumentRestoreWindowElapsedError, e).into() + } + StateError::DocumentRestoreHashMismatchError(e) => { + generic_consensus_error!(DocumentRestoreHashMismatchError, e).into() + } + StateError::ContractDocumentAlreadyRestoredError(e) => { + generic_consensus_error!(ContractDocumentAlreadyRestoredError, e).into() + } + StateError::ContractUserNotWarnedError(e) => { + generic_consensus_error!(ContractUserNotWarnedError, e).into() + } + StateError::ContractUserWarningLimitReachedError(e) => { + generic_consensus_error!(ContractUserWarningLimitReachedError, e).into() + } + StateError::ContractModeratedDocumentTypeNotYetUsableError(e) => { + generic_consensus_error!(ContractModeratedDocumentTypeNotYetUsableError, e).into() + } + StateError::VoteChoiceNotAllowedForVotePollError(e) => { + generic_consensus_error!(VoteChoiceNotAllowedForVotePollError, e).into() + } + StateError::ReferencedContractRequirementNotMetError(e) => { + generic_consensus_error!(ReferencedContractRequirementNotMetError, e).into() + } } } @@ -1230,6 +1261,9 @@ fn from_basic_error(basic_error: &BasicError) -> JsValue { BasicError::ContractModerationReasonTooLongError(e) => { generic_consensus_error!(ContractModerationReasonTooLongError, e).into() } + BasicError::InvalidContractModerationReasonDocumentsError(e) => { + generic_consensus_error!(InvalidContractModerationReasonDocumentsError, e).into() + } } } diff --git a/packages/wasm-dpp/test/unit/dataContract/stateTransition/DataContractCreateTransition/DataContractCreateTransition.spec.js b/packages/wasm-dpp/test/unit/dataContract/stateTransition/DataContractCreateTransition/DataContractCreateTransition.spec.js index 0ea73fcab0e..a87cc2dcd50 100644 --- a/packages/wasm-dpp/test/unit/dataContract/stateTransition/DataContractCreateTransition/DataContractCreateTransition.spec.js +++ b/packages/wasm-dpp/test/unit/dataContract/stateTransition/DataContractCreateTransition/DataContractCreateTransition.spec.js @@ -72,7 +72,9 @@ describe('DataContractCreateTransition', () => { it('should return serialized State Transition', () => { const result = stateTransition.toBuffer(); expect(result).to.be.instanceOf(Buffer); - expect(result).to.have.lengthOf(2370); + // The fixture contract carries a V2 config (protocol version 14), whose optional + // moderation declaration is one more bincode byte than a V1 config. + expect(result).to.have.lengthOf(2371); }); it('should be able to restore contract config from bytes', () => { diff --git a/packages/wasm-dpp/test/unit/dataContract/stateTransition/DataContractUpdateTransition/DataContractUpdateTransition.spec.js b/packages/wasm-dpp/test/unit/dataContract/stateTransition/DataContractUpdateTransition/DataContractUpdateTransition.spec.js index d53fa0dc500..d13fa4d42b6 100644 --- a/packages/wasm-dpp/test/unit/dataContract/stateTransition/DataContractUpdateTransition/DataContractUpdateTransition.spec.js +++ b/packages/wasm-dpp/test/unit/dataContract/stateTransition/DataContractUpdateTransition/DataContractUpdateTransition.spec.js @@ -66,7 +66,9 @@ describe('DataContractUpdateTransition', () => { it('should return serialized State Transition', () => { const result = stateTransition.toBuffer(); expect(result).to.be.instanceOf(Buffer); - expect(result).to.have.lengthOf(2370); + // The fixture contract carries a V2 config (protocol version 14), whose optional + // moderation declaration is one more bincode byte than a V1 config. + expect(result).to.have.lengthOf(2371); }); it('should be able to restore contract config from bytes', () => { diff --git a/packages/wasm-dpp2/README.md b/packages/wasm-dpp2/README.md index 4f821701242..4d9aa64fa0a 100644 --- a/packages/wasm-dpp2/README.md +++ b/packages/wasm-dpp2/README.md @@ -11,3 +11,38 @@ Internal build of the Dash Platform Protocol v2 WebAssembly bindings. The build scripts defer to `packages/scripts/build-wasm.sh` to keep behaviour consistent with other WASM packages such as `@dashevo/wasm-sdk`. + +## Document ids + +From protocol version 14 the id of a new document commits to the identity +contract nonce of its create transition (see the book, *Data Model → +Documents → Document ID Generation*), so a `Document` built with +`new Document({...})` and no `identityContractNonce` carries a **placeholder** +id: the entropy-only derivation of earlier versions, which consensus no longer +accepts. + +The id becomes final where the nonce is known, and the bindings derive it for +you: + +```ts +const document = new Document({ properties, documentTypeName, dataContractId, ownerId }); + +// Derives the id from the document's entropy and the nonce, writes it onto +// the transition and back onto `document`: document.id equals transition.base.id. +const transition = new DocumentCreateTransition({ document, identityContractNonce: nonce }); + +// To know the id before the transition exists (another document in the same +// batch references it): +document.setIdForCreation(nonce); +// or derive it without a Document: +const idBytes = Document.generateId(documentTypeName, ownerId, dataContractId, entropy, nonce); +// or build the document with its final id from the start: +const ready = new Document({ properties, documentTypeName, dataContractId, ownerId, identityContractNonce: nonce }); +``` + +Each of these takes an optional `platformVersion` (latest by default); before +protocol version 14 the derivation ignores the nonce. Whatever id a `Document` +carried before it is passed to `DocumentCreateTransition` is replaced: the +transition can only carry the id consensus recomputes. For the same reason +`new Document({...})` refuses an explicit `id` that disagrees with the one its +`identityContractNonce` derives. No app needs to reimplement the hash. diff --git a/packages/wasm-dpp2/src/consensus_error.rs b/packages/wasm-dpp2/src/consensus_error.rs index 5c59abe6315..d1bf70c531d 100644 --- a/packages/wasm-dpp2/src/consensus_error.rs +++ b/packages/wasm-dpp2/src/consensus_error.rs @@ -51,11 +51,18 @@ pub enum DocumentReferenceErrorCodeWasm { /// reference; a `permanentDocument` reference is the one for a type /// declaring `canBeDeleted: false`. ReferencedDocumentTypeNotDeletable = 40131, + /// The referenced contract exists but does not meet what the reference's + /// `contractRequirements` require of it: elected moderation, its election + /// open, a minimum age or a minimum time since its last update at the block + /// time of the write, an owner relation to the writer of the referring + /// document, or a config flag (read-only, keeping history, the owner + /// protection of its elected moderation declaration). + ReferencedContractRequirementNotMet = 40135, } impl DocumentReferenceErrorCodeWasm { /// The reference-validation error a code names, or `None` when the code - /// is neither in the 40120-40125 range nor 40131. + /// is not in the 40120-40125 range, 40131 or 40135. fn from_code(code: u32) -> Option { match code { 40120 => Some(Self::ReferencedEntityNotFound), @@ -65,6 +72,7 @@ impl DocumentReferenceErrorCodeWasm { 40124 => Some(Self::ReferencedIdentityKeyDisabled), 40125 => Some(Self::ReferencedKeyIdPropertyInvalid), 40131 => Some(Self::ReferencedDocumentTypeNotDeletable), + 40135 => Some(Self::ReferencedContractRequirementNotMet), _ => None, } } @@ -150,6 +158,7 @@ impl_wasm_type_info!(ConsensusErrorWasm, ConsensusError); #[cfg(test)] mod tests { use super::*; + use dpp::consensus::state::document::referenced_contract_requirement_not_met_error::ReferencedContractRequirementNotMetError; use dpp::consensus::state::document::referenced_document_type_deletable_error::ReferencedDocumentTypeDeletableError; use dpp::consensus::state::document::referenced_document_type_not_found_error::ReferencedDocumentTypeNotFoundError; use dpp::consensus::state::document::referenced_entity_not_found_error::ReferencedEntityNotFoundError; @@ -215,6 +224,18 @@ mod tests { .into(), DocumentReferenceErrorCodeWasm::ReferencedEntityNotFound, ), + ( + StateError::ReferencedContractRequirementNotMetError( + ReferencedContractRequirementNotMetError::new( + id(), + "moderation".to_string(), + "elected".to_string(), + "targetContractId".to_string(), + ), + ) + .into(), + DocumentReferenceErrorCodeWasm::ReferencedContractRequirementNotMet, + ), ( StateError::ReferencedDocumentTypeNotFoundError( ReferencedDocumentTypeNotFoundError::new( diff --git a/packages/wasm-dpp2/src/data_contract/document/model.rs b/packages/wasm-dpp2/src/data_contract/document/model.rs index 525119515cb..db3a5ad200b 100644 --- a/packages/wasm-dpp2/src/data_contract/document/model.rs +++ b/packages/wasm-dpp2/src/data_contract/document/model.rs @@ -6,8 +6,8 @@ use crate::impl_try_from_options; use crate::impl_wasm_type_info; use crate::serialization; use crate::utils::{ - ToSerdeJSONExt, try_from_options, try_from_options_optional, try_from_options_with, - try_vec_to_fixed_bytes, + ToSerdeJSONExt, try_from_options, try_from_options_optional, try_from_options_optional_with, + try_from_options_with, try_to_u64, try_vec_to_fixed_bytes, }; use crate::version::{PlatformVersionLikeJs, PlatformVersionWasm}; use dpp::document::serialization_traits::{ @@ -20,11 +20,13 @@ use dpp::document::serialization_traits::{ // are imported inline at the call sites. use dpp::document::{Document, DocumentV0, DocumentV0Getters, DocumentV0Setters}; use dpp::identifier::Identifier; -use dpp::platform_value::string_encoding::Encoding::{Base64, Hex}; +use dpp::platform_value::string_encoding::Encoding::{Base58, Base64, Hex}; use dpp::platform_value::string_encoding::encode; use dpp::platform_value::{Value, ValueMapHelper}; +use dpp::prelude::IdentityNonce; use dpp::util::entropy_generator; use dpp::util::entropy_generator::EntropyGenerator; +use dpp::version::PlatformVersion; use serde::Deserialize; use wasm_bindgen::JsValue; use wasm_bindgen::prelude::wasm_bindgen; @@ -46,10 +48,28 @@ export interface DocumentOptions { ownerId: IdentifierLike; /** Document revision (default: 1n) */ revision?: bigint; - /** Document ID (auto-generated if not provided) */ + /** + * Document ID. Derived when not provided (see `identityContractNonce`). + * Together with `identityContractNonce` it must equal the derived id, or + * the constructor throws: the nonce fixes the id, and a different explicit + * one could only be referenced, never created. Whatever is given here is + * replaced by `new DocumentCreateTransition(...)`, which can only carry the + * id consensus recomputes. + */ id?: IdentifierLike; /** Entropy bytes (32 bytes, auto-generated if not provided) */ entropy?: Uint8Array; + /** + * Identity contract nonce the create transition of this document is going + * to use. From protocol version 14 the id of a new document commits to + * that nonce, so without it the derived `id` is a placeholder that + * `new DocumentCreateTransition(...)` replaces (and mirrors back onto this + * document). Pass the nonce here, or call `setIdForCreation`, to have the + * final id from the start. + */ + identityContractNonce?: bigint; + /** Platform version the id is derived for (default: latest) */ + platformVersion?: PlatformVersionLike; } /** @@ -161,6 +181,38 @@ impl DocumentWasm { pub fn set_data_contract_id(&mut self, data_contract_id: &IdentifierWasm) { self.data_contract_id = *data_contract_id; } + + /// Gives the document the id its create transition will carry: what + /// `DocumentCreateTransitionV0::from_document` does in dpp, for a + /// document that carries its entropy and knows its contract and type + /// itself. + /// + /// Errors when the document has no entropy, which is the case for one + /// deserialized from Platform: such a document already exists and can + /// not be created. + pub fn set_id_for_creation( + &mut self, + identity_contract_nonce: IdentityNonce, + platform_version: &PlatformVersion, + ) -> WasmDppResult<()> { + let entropy = self.entropy.ok_or_else(|| { + WasmDppError::invalid_argument( + "document has no entropy: only a document built with `new Document(...)` can be \ + created", + ) + })?; + + let id = Document::generate_document_id( + &self.data_contract_id.into(), + &self.document.owner_id(), + &self.document_type_name, + &entropy, + identity_contract_nonce, + platform_version, + )?; + self.document.set_id(id); + Ok(()) + } } #[wasm_bindgen] @@ -202,6 +254,16 @@ impl DocumentWasm { let id: Option = try_from_options_optional(&options, "id")?; + let identity_contract_nonce: Option = + try_from_options_optional_with(&options, "identityContractNonce", |v| { + try_to_u64(v, "identityContractNonce") + })?; + + let platform_version: PlatformVersion = + try_from_options_optional::(&options, "platformVersion")? + .unwrap_or_default() + .into(); + let properties = try_from_options_with(&options, "properties", |v| { v.with_serde_to_platform_value_map() })?; @@ -222,17 +284,47 @@ impl DocumentWasm { Ok, )?; - let doc_id: Identifier = id.map_or_else( - || { - crate::utils::generate_document_id_v0( + let doc_id: Identifier = match (id, identity_contract_nonce) { + // The nonce fixes the id: it is the one the create transition + // will carry. An explicit id may only restate it; one that + // differs would let the caller reference (from another document + // in the batch, say) an id no transition ever creates. + (id, Some(identity_contract_nonce)) => { + let derived = Document::generate_document_id( &data_contract_id, &owner_id, &document_type_name, &entropy, - ) - }, - |id| Ok(id.into()), - )?; + identity_contract_nonce, + &platform_version, + )?; + + if let Some(id) = id { + let id: Identifier = id.into(); + if id != derived { + return Err(WasmDppError::invalid_argument(format!( + "id {} does not match the id {} derived from the document's entropy \ + and identityContractNonce {}: pass one or the other", + id.to_string(Base58), + derived.to_string(Base58), + identity_contract_nonce, + ))); + } + } + + derived + } + (Some(id), None) => id.into(), + // Without the nonce of the create transition the id can only be + // the entropy-only one, which from protocol version 14 is a + // placeholder: `DocumentCreateTransition` replaces it. + (None, None) => Document::generate_document_id_v0( + &data_contract_id, + &owner_id, + &document_type_name, + &entropy, + ), + }; let document = Document::V0(DocumentV0 { contract_version: None, @@ -359,6 +451,34 @@ impl DocumentWasm { Ok(()) } + /// Gives the document the id its create transition will carry, derived + /// from its entropy and the identity contract nonce that transition is + /// going to use. + /// + /// `new DocumentCreateTransition(...)` does this on its own; call it + /// yourself to know the id before the transition exists (a document that + /// another document in the same batch references). The transition must + /// then be built with the same nonce. + /// + /// Throws when the document carries no entropy (one read back from + /// Platform), since only a new document can be created. + #[wasm_bindgen(js_name = "setIdForCreation")] + pub fn set_id_for_creation_js( + &mut self, + #[wasm_bindgen(js_name = "identityContractNonce")] identity_contract_nonce: u64, + #[wasm_bindgen(js_name = "platformVersion")] platform_version: Option< + PlatformVersionLikeJs, + >, + ) -> WasmDppResult<()> { + let platform_version: PlatformVersion = platform_version + .map(PlatformVersionWasm::try_from) + .transpose()? + .unwrap_or_default() + .into(); + + self.set_id_for_creation(identity_contract_nonce, &platform_version) + } + #[wasm_bindgen(setter=entropy)] pub fn set_entropy(&mut self, entropy: Option>) -> WasmDppResult<()> { match entropy { @@ -721,12 +841,23 @@ impl DocumentWasm { ) } + /// Derives the id of a document that is about to be created, the way + /// consensus recomputes it for the create transition. + /// + /// From protocol version 14 the id commits to the identity contract + /// nonce of the create transition, so `identityContractNonce` is + /// required at that version (and ignored before it). The platform + /// version defaults to the latest. #[wasm_bindgen(js_name = "generateId")] pub fn generate_id( #[wasm_bindgen(js_name = "documentTypeName")] document_type_name: &str, #[wasm_bindgen(js_name = "ownerId")] owner_id: IdentifierLikeJs, #[wasm_bindgen(js_name = "dataContractId")] data_contract_id: IdentifierLikeJs, entropy: Option>, + #[wasm_bindgen(js_name = "identityContractNonce")] identity_contract_nonce: Option, + #[wasm_bindgen(js_name = "platformVersion")] platform_version: Option< + PlatformVersionLikeJs, + >, ) -> WasmDppResult> { let owner_id: Identifier = owner_id.try_into()?; let data_contract_id: Identifier = data_contract_id.try_into()?; @@ -738,11 +869,30 @@ impl DocumentWasm { .map_err(|err| WasmDppError::serialization(err.to_string()))?, }; - let identifier = crate::utils::generate_document_id_v0( + let platform_version: PlatformVersion = platform_version + .map(PlatformVersionWasm::try_from) + .transpose()? + .unwrap_or_default() + .into(); + + let identity_contract_nonce = match identity_contract_nonce { + Some(identity_contract_nonce) => identity_contract_nonce, + None if Document::document_id_depends_on_nonce(&platform_version)? => { + return Err(WasmDppError::invalid_argument( + "'identityContractNonce' is required: from protocol version 14 the id of a \ + new document commits to the identity contract nonce of its create transition", + )); + } + None => 0, + }; + + let identifier = Document::generate_document_id( &data_contract_id, &owner_id, document_type_name, &entropy_bytes, + identity_contract_nonce, + &platform_version, )?; Ok(identifier.to_vec()) @@ -798,5 +948,77 @@ impl DocumentWasm { } impl_try_from_js_value!(DocumentWasm, "Document"); + impl_try_from_options!(DocumentWasm); impl_wasm_type_info!(DocumentWasm, Document); + +#[cfg(test)] +mod tests { + use super::*; + use dpp::platform_value::string_encoding::Encoding; + use std::collections::BTreeMap; + + fn note(entropy: Option<[u8; 32]>) -> DocumentWasm { + let document = Document::V0(DocumentV0 { + id: Identifier::from([9u8; 32]), + owner_id: Identifier::from([2u8; 32]), + properties: BTreeMap::new(), + revision: Some(1), + ..Default::default() + }); + DocumentWasm::new( + document, + Identifier::from([1u8; 32]), + "note".to_string(), + entropy, + ) + } + + #[test] + fn set_id_for_creation_derives_the_id_dpp_pins() { + // The vector `Document::generate_document_id_v1` pins in rs-dpp; the + // wasm wrapper must feed its own contract id, type name and entropy + // into the same derivation. + let mut document = note(Some([7u8; 32])); + + document + .set_id_for_creation(1, PlatformVersion::latest()) + .expect("expected an id"); + + assert_eq!( + document.document.id().to_string(Encoding::Hex), + "e574ae73396611a517691d1f89275b6e99642cb9c176ce8cf879b1665c50f15f" + ); + } + + #[test] + fn set_id_for_creation_keeps_the_entropy_only_id_before_protocol_version_14() { + let mut document = note(Some([7u8; 32])); + let version_13 = PlatformVersion::get(13).expect("expected version 13"); + + document + .set_id_for_creation(1, version_13) + .expect("expected an id"); + + assert_eq!( + document.document.id(), + Document::generate_document_id_v0( + &Identifier::from([1u8; 32]), + &Identifier::from([2u8; 32]), + "note", + &[7u8; 32], + ) + ); + } + + #[test] + fn set_id_for_creation_refuses_a_document_without_entropy() { + let mut document = note(None); + + let error = document + .set_id_for_creation(1, PlatformVersion::latest()) + .expect_err("expected an error"); + + assert!(error.to_string().contains("entropy"), "{error}"); + } +} 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 7ff15ebf4f7..5315e8c8e4e 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,37 @@ const DOCUMENT_PROPERTY_REFERENCE_TS: &'static str = r#" */ export type DocumentPropertyReferenceTarget = | { type: 'identity' } - | { type: 'contract' } + | { + type: 'contract'; + /** + * What the referenced contract must declare beyond existing, checked + * by consensus when the referring document is written against the + * contract fetched for the existence check and the block time: + * `moderation: 'elected'` requires an elected moderation team and + * `'electionOpen'` one whose own `electionDelay` has passed since the + * contract's creation (or which declares none), + * `minimumAgeSeconds` requires the contract's recorded creation time + * to be at least that many seconds before the block time of the write, + * and `minimumSecondsSinceUpdate` the same of the later of its creation + * and last update times, and `owner: 'self'` requires the contract to + * be owned by the writer of the referring document (its `$ownerId`), + * `'other'` by anyone else; `readonly: true` requires a read-only + * contract (one that can never be updated again), `keepsHistory: true` + * one keeping its history, and `ownerProtected` an elected moderation + * declaration whose owner protection flag has that value (code 40135 + * when any is unmet). Absent when the declaration carries no + * requirement. + */ + contractRequirements?: { + moderation?: 'elected' | 'electionOpen'; + minimumAgeSeconds?: number; + minimumSecondsSinceUpdate?: number; + owner?: 'self' | 'other'; + readonly?: true; + keepsHistory?: true; + ownerProtected?: boolean; + }; + } | { type: 'token' } | { type: 'permanentDocument'; @@ -156,7 +186,7 @@ fn reference_to_js( let kind = match target { DocumentPropertyReferenceTarget::Identity => "identity", - DocumentPropertyReferenceTarget::Contract => "contract", + DocumentPropertyReferenceTarget::Contract { .. } => "contract", DocumentPropertyReferenceTarget::Token => "token", DocumentPropertyReferenceTarget::PermanentDocument { .. } => "permanentDocument", DocumentPropertyReferenceTarget::IdentityPublicKey { .. } => "identityPublicKey", @@ -165,9 +195,53 @@ fn reference_to_js( set_field(&object, "type", &JsValue::from_str(kind), path)?; match target { - DocumentPropertyReferenceTarget::Identity - | DocumentPropertyReferenceTarget::Contract - | DocumentPropertyReferenceTarget::Token => {} + DocumentPropertyReferenceTarget::Identity | DocumentPropertyReferenceTarget::Token => {} + DocumentPropertyReferenceTarget::Contract { + contract_requirements, + } => { + // Absent, not `{}`-valued, when the declaration requires nothing, + // matching the schema's own omission. + if !contract_requirements.is_empty() { + let fields = Object::new(); + if let Some(moderation) = contract_requirements.moderation { + set_field( + &fields, + "moderation", + &JsValue::from_str(moderation.as_str()), + path, + )?; + } + if let Some(seconds) = contract_requirements.minimum_age_seconds { + set_field( + &fields, + "minimumAgeSeconds", + &JsValue::from_f64(f64::from(seconds)), + path, + )?; + } + if let Some(seconds) = contract_requirements.minimum_seconds_since_update { + set_field( + &fields, + "minimumSecondsSinceUpdate", + &JsValue::from_f64(f64::from(seconds)), + path, + )?; + } + if let Some(owner) = contract_requirements.owner { + set_field(&fields, "owner", &JsValue::from_str(owner.as_str()), path)?; + } + for (name, flag) in [ + ("readonly", contract_requirements.readonly), + ("keepsHistory", contract_requirements.keeps_history), + ("ownerProtected", contract_requirements.owner_protected), + ] { + if let Some(flag) = flag { + set_field(&fields, name, &JsValue::from_bool(flag), path)?; + } + } + set_field(&object, "contractRequirements", &fields, path)?; + } + } DocumentPropertyReferenceTarget::PermanentDocument { contract_id, document_type_name, diff --git a/packages/wasm-dpp2/src/data_contract/mod.rs b/packages/wasm-dpp2/src/data_contract/mod.rs index eb036e22e2d..57c8a39ce52 100644 --- a/packages/wasm-dpp2/src/data_contract/mod.rs +++ b/packages/wasm-dpp2/src/data_contract/mod.rs @@ -20,6 +20,7 @@ pub use transitions::create::DataContractCreateTransitionWasm; pub use transitions::fee_claim::{ContractFeeClaimWasm, contract_fee_pot_from_str}; pub use transitions::update::DataContractUpdateTransitionWasm; pub use transitions::user_moderation::{ - ContractModerationReasonJs, ContractUserModerationActionParts, ContractUserModerationWasm, - moderation_action_from_parts, moderation_reason_to_js, + ContractModerationReasonInput, ContractModerationReasonJs, ContractUserModerationActionParts, + ContractUserModerationWasm, ContractWarningsJs, moderation_action_from_parts, + moderation_reason_to_js, moderation_warnings_to_js, }; diff --git a/packages/wasm-dpp2/src/data_contract/model.rs b/packages/wasm-dpp2/src/data_contract/model.rs index 17614a53c9a..7d01a65bd15 100644 --- a/packages/wasm-dpp2/src/data_contract/model.rs +++ b/packages/wasm-dpp2/src/data_contract/model.rs @@ -114,20 +114,60 @@ export interface DataContractConfig { requiresIdentityEncryptionBoundedKey?: number; requiresIdentityDecryptionBoundedKey?: number; /** - * Contract moderation (protocol version 14): the banlist and/or suspension list the - * contract keeps and who may edit them. Absent for an unmoderated contract. + * Contract moderation (protocol version 14): the banlist, suspension list and/or warning + * list the contract keeps and who may edit them. Absent for an unmoderated contract. */ moderation?: ContractModerationConfig; } /** - * Who may ban and suspend identities on a moderated contract: the owner alone, or the owner - * and a fixed set of identities (at most 16, each of which must exist). The owner always may - * and need not be named; naming it counts toward the 16. + * Who may ban, suspend and warn identities on a moderated contract: the owner alone, the owner + * and a fixed set of identities (at most 16, each of which must exist; the owner always may + * and need not be named, naming it counts toward the 16), or an elected team. + * + * An elected declaration is fixed when the contract is created and never changes. Until a + * team is seated (no election exists yet) the contract is moderated by its `interim` + * moderators, or by nobody: with the moderated document types not yet usable (every + * document transition of one is refused) or used unmoderated meanwhile. Windows and the + * cool-down are in seconds: the windows one day + * to four weeks (one week when left out), the cool-down two weeks to three years. */ export type ContractModerators = | { $type: "contractOwner" } - | { $type: "appointedModerators"; identities: string[] }; + | { $type: "appointedModerators"; identities: string[] } + | { + $type: "elected"; + joinWindow?: number; + voteWindow?: number; + challengeCoolDown: number; + /** + * Seconds after the contract's creation before the first charter may be filed + * against it, unbounded; the election may be called at once when left out. A + * reference requiring `moderation: 'electionOpen'` reads it. + */ + electionDelay?: number; + /** + * The moderated document types of the contract, each with the non-empty abilities a + * charter may claim on it: `ban`, `suspend` and `warn` need the list the contract + * keeps, `deleteDocuments` the type flagged `canBeDeletedByModerators`. The lists stay + * contract-wide. A charter does not price actions: a type's `actionFees.moderators` + * amount is the most a team may charge. + */ + moderatedDocumentTypes: Record; + interim: InterimModerators; + /** Whether the owner is protected from the team; false when left out. */ + ownerProtected?: boolean; + }; + +/** What a charter may claim on an elected contract. */ +export type ModerationAbility = "deleteDocuments" | "ban" | "suspend" | "warn"; + +/** Who moderates an elected contract until its first team is seated. */ +export type InterimModerators = + | { $type: "contractOwner" } + | { $type: "appointedModerators"; identities: string[] } + | { $type: "notYetUsable" } + | { $type: "noModeration" }; /** * The moderation a data contract declares. At least one list must be kept, unless a document @@ -137,6 +177,8 @@ export type ContractModerators = export interface ContractModerationConfig { banlist: boolean; suspensions: boolean; + /** The contract keeps a warning list: warnings bar nothing and accumulate until cleared. */ + warnings: boolean; moderators: ContractModerators; } "#; diff --git a/packages/wasm-dpp2/src/data_contract/transitions/user_moderation.rs b/packages/wasm-dpp2/src/data_contract/transitions/user_moderation.rs index 5736565ae4b..b30b5fb06c5 100644 --- a/packages/wasm-dpp2/src/data_contract/transitions/user_moderation.rs +++ b/packages/wasm-dpp2/src/data_contract/transitions/user_moderation.rs @@ -4,9 +4,11 @@ use crate::impl_wasm_conversions_inner; use crate::impl_wasm_type_info; use crate::state_transitions::StateTransitionWasm; use crate::utils::{ - try_from_options, try_from_options_optional, try_to_u16, try_to_u32, try_to_u64, + try_from_options, try_from_options_optional, try_to_bytes, try_to_u16, try_to_u32, try_to_u64, +}; +use dpp::data_contract::config::moderation::{ + ContractModerationDocument, ContractModerationReason, ContractWarning, }; -use dpp::data_contract::config::moderation::ContractModerationReason; use dpp::platform_value::BinaryData; use dpp::platform_value::string_encoding::Encoding::{Base64, Hex}; use dpp::platform_value::string_encoding::{decode, encode}; @@ -27,10 +29,19 @@ use wasm_bindgen::prelude::wasm_bindgen; #[wasm_bindgen(typescript_custom_section)] const CONTRACT_USER_MODERATION_TS: &str = r#" +/** A document a moderation reason is about. */ +export interface ContractModerationDocument { + /** The document type of the document, on the moderated contract. */ + documentTypeName: string; + /** The document's id, as a base58 string. */ + documentId: string; +} + /** - * Why a moderator banned or suspended an identity, or deleted a document. Every ban, every - * suspension and every document deletion carries one, and it is stored with the entry or the - * removal record. Nothing checks what a moderator writes. + * Why a moderator banned, suspended or warned an identity, or deleted a document. Every ban, + * every suspension, every warning and every document deletion carries one, and it is stored + * with the entry or the removal record. Nothing checks what a moderator writes, and the + * documents a reason cites are not looked up. */ export interface ContractModerationReason { /** @@ -41,6 +52,19 @@ export interface ContractModerationReason { code?: number | null; /** Free text, at most 1024 bytes of UTF-8. May be empty. */ text: string; + /** + * The documents the reason is about: the posts a warning or a ban is for. At most 16, + * none twice. A reason read back carries it only when there are any. + */ + documents?: ContractModerationDocument[]; +} + +/** One warning an identity carries on a contract's warning list. */ +export interface ContractWarning { + /** The time of the block that issued it, in milliseconds. */ + warnedAt: bigint; + /** Why, as the moderator wrote it. */ + reason: ContractModerationReason; } /** @@ -52,11 +76,16 @@ export type ContractUserModerationActionJSON = | { $type: "unban"; identityId: string } | { $type: "suspend"; identityId: string; until: number | string; reason: ContractModerationReason } | { $type: "unsuspend"; identityId: string } - | { $type: "deleteDocument"; documentTypeName: string; documentId: string; reason: ContractModerationReason }; + | { $type: "warn"; identityId: string; reason: ContractModerationReason } + | { $type: "clearWarnings"; identityId: string } + | { $type: "deleteDocument"; documentTypeName: string; documentId: string; reason: ContractModerationReason } + | { $type: "restoreDocument"; documentTypeName: string; document: string }; /** - * Bans, unbans, suspends or unsuspends one identity on a moderated data contract, or deletes - * one document of a document type that sets `canBeDeletedByModerators` (protocol version 14). + * Bans, unbans, suspends, unsuspends, warns or clears the warnings of one identity on a + * moderated data contract, or deletes one document of a document type that sets + * `canBeDeletedByModerators`, or restores one such document a moderator deleted (protocol + * version 14). * Signed by the contract owner or a moderator its config names, with a CRITICAL * authentication key, under the signer's contract-scoped nonce. */ @@ -68,21 +97,29 @@ export interface ContractUserModerationTransitionOptions { /** The signer's nonce for the contract */ identityContractNonce: bigint; /** What is done */ - action: "ban" | "unban" | "suspend" | "unsuspend" | "deleteDocument"; + action: "ban" | "unban" | "suspend" | "unsuspend" | "warn" | "clearWarnings" | "deleteDocument" | "restoreDocument"; /** - * The identity the action targets. Needed by every action but a deleteDocument, which - * names a document instead and refuses it. + * The identity the action targets. Needed by every action but a deleteDocument and a + * restoreDocument, which name a document instead and refuse it. */ identityId?: IdentifierLike; - /** For a deleteDocument, which needs it: the document type of the document. Refused beside another action. */ + /** For a deleteDocument or a restoreDocument, which need it: the document type of the document. Refused beside another action. */ documentTypeName?: string; /** For a deleteDocument, which needs it: the document. Refused beside another action. */ documentId?: IdentifierLike; + /** + * For a restoreDocument, which needs it: the document as it was serialized under its + * document type when it was deleted (`Document.toBytes()` of the document as fetched + * before the deletion), which must hash to what its removal record holds. Refused beside + * another action. + */ + document?: Uint8Array; /** For a suspend: the block time, in milliseconds, at which the suspension lapses */ until?: bigint; /** - * Why. A ban and a suspend both need one. A deleteDocument may leave it out, which stores - * no code and an empty text. Refused beside an unban or an unsuspend. + * Why. A ban, a suspend and a warn each need one. A deleteDocument may leave it out, which + * stores no code and an empty text. Refused beside an unban, an unsuspend or a + * clearWarnings. */ reason?: ContractModerationReason; userFeeIncrease?: number; @@ -100,6 +137,7 @@ export interface ContractUserModerationObject { identityId?: Uint8Array; documentTypeName?: string; documentId?: Uint8Array; + document?: Uint8Array; until?: bigint; reason?: ContractModerationReason; }; @@ -135,20 +173,104 @@ extern "C" { #[wasm_bindgen(typescript_type = "ContractModerationReason")] pub type ContractModerationReasonJs; + + #[wasm_bindgen(typescript_type = "ContractWarning[]")] + pub type ContractWarningsJs; } -/// A reason as the plain object JavaScript reads: `code`, `null` when there is none, and -/// `text`. The shape `toJSON()` and `toObject()` give the reason inside a transition, so a -/// reason compares equal whichever call produced it. +/// A reason as the plain object JavaScript reads: `code`, `null` when there is none, `text`, +/// and `documents` when it cites any, each with its type name and its id as a base58 string. +/// The shape `toJSON()` and `toObject()` give the reason inside a transition, so a reason +/// compares equal whichever call produced it. pub fn moderation_reason_to_js(reason: &ContractModerationReason) -> JsValue { let object = js_sys::Object::new(); // Setting a property on a fresh plain object cannot fail. let code = reason.code.map_or(JsValue::NULL, JsValue::from); let _ = js_sys::Reflect::set(&object, &"code".into(), &code); let _ = js_sys::Reflect::set(&object, &"text".into(), &JsValue::from_str(&reason.text)); + if !reason.documents.is_empty() { + let documents = js_sys::Array::new(); + for document in &reason.documents { + let entry = js_sys::Object::new(); + let _ = js_sys::Reflect::set( + &entry, + &"documentTypeName".into(), + &JsValue::from_str(&document.document_type_name), + ); + let _ = js_sys::Reflect::set( + &entry, + &"documentId".into(), + &JsValue::from_str(&IdentifierWasm::from(document.document_id).to_base58()), + ); + documents.push(&entry); + } + let _ = js_sys::Reflect::set(&object, &"documents".into(), &documents); + } object.into() } +/// Warnings as the plain objects JavaScript reads, oldest first: `warnedAt` as a BigInt in +/// `toObject()` and as a number in `toJSON()`, and the reason as [`moderation_reason_to_js`] +/// gives it. +pub fn moderation_warnings_to_js(warnings: &[ContractWarning], as_json: bool) -> JsValue { + let array = js_sys::Array::new(); + for warning in warnings { + let object = js_sys::Object::new(); + let warned_at = if as_json { + JsValue::from_f64(warning.warned_at as f64) + } else { + JsValue::from(js_sys::BigInt::from(warning.warned_at)) + }; + // Setting a property on a fresh plain object cannot fail. + let _ = js_sys::Reflect::set(&object, &"warnedAt".into(), &warned_at); + let _ = js_sys::Reflect::set( + &object, + &"reason".into(), + &moderation_reason_to_js(&warning.reason), + ); + array.push(&object); + } + array.into() +} + +/// A document a reason cites, as the options of the JavaScript surfaces carry it: the id is +/// an `IdentifierLike`, which serde alone would not read from a base58 string. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ContractModerationDocumentInput { + document_type_name: String, + document_id: IdentifierWasm, +} + +/// A reason as the options of the JavaScript surfaces carry it. See +/// [`ContractModerationDocumentInput`] for why it is not the reason itself. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ContractModerationReasonInput { + #[serde(default)] + code: Option, + text: String, + #[serde(default)] + documents: Vec, +} + +impl From for ContractModerationReason { + fn from(input: ContractModerationReasonInput) -> Self { + ContractModerationReason { + code: input.code, + text: input.text, + documents: input + .documents + .into_iter() + .map(|document| ContractModerationDocument { + document_type_name: document.document_type_name, + document_id: document.document_id.into(), + }) + .collect(), + } + } +} + /// Serde struct for the primitive fields of the options #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -160,7 +282,7 @@ struct ContractUserModerationOptionsInput { #[serde(default)] until: Option, #[serde(default)] - reason: Option, + reason: Option, /// `undefined` reaches serde as a unit value, so the fee is read as an option and defaulted #[serde(default)] user_fee_increase: Option, @@ -188,21 +310,24 @@ impl From for ContractUserModerationTransition { /// [`moderation_action_from_parts`]. #[derive(Default)] pub struct ContractUserModerationActionParts { - /// The identity a ban, an unban, a suspend or an unsuspend targets + /// The identity a ban, an unban, a suspend, an unsuspend, a warn or a clearWarnings targets pub identity_id: Option, /// The document type of the document a deleteDocument targets pub document_type_name: Option, /// The document a deleteDocument targets pub document_id: Option, + /// The document a restoreDocument brings back, as it was serialized when it was deleted + pub document: Option>, /// The end of a suspend pub until: Option, - /// Why: needed by a ban and a suspend, optional for a deleteDocument + /// Why: needed by a ban, a suspend and a warn, optional for a deleteDocument pub reason: Option, } /// The action for a name and its parts: an identity for the actions on an identity, with -/// `until` for a suspend and `reason` for a ban and a suspend, and a document type name and a -/// document id for a deleteDocument, with or without a `reason`. +/// `until` for a suspend and `reason` for a ban, a suspend and a warn, a document type +/// name and a document id for a deleteDocument, with or without a `reason`, and a document +/// type name and the document's bytes for a restoreDocument. pub fn moderation_action_from_parts( action: &str, parts: ContractUserModerationActionParts, @@ -211,6 +336,7 @@ pub fn moderation_action_from_parts( identity_id, document_type_name, document_id, + document, until, reason, } = parts; @@ -223,25 +349,41 @@ pub fn moderation_action_from_parts( } // The same goes for a reason: only an action that adds an entry or a removal record stores // one. - let stores_a_reason = matches!(action, "ban" | "suspend" | "deleteDocument"); + let stores_a_reason = matches!(action, "ban" | "suspend" | "warn" | "deleteDocument"); if reason.is_some() && !stores_a_reason { return Err(WasmDppError::invalid_argument(format!( - "`reason` is only valid for a ban, a suspend or a deleteDocument action, not for `{action}`" + "`reason` is only valid for a ban, a suspend, a warn or a deleteDocument action, not for `{action}`" ))); } - // And for the target: a deletion names a document and every other action an identity. A - // target of the other kind is refused rather than dropped, or a caller that mixed the two - // up would sign something it did not mean. - if action == "deleteDocument" { - if identity_id.is_some() { - return Err(WasmDppError::invalid_argument( - "`identityId` is not valid for a deleteDocument action, which names a document", - )); + // And for the target: a deletion and a restore name a document and every other action an + // identity. A target of the other kind is refused rather than dropped, or a caller that + // mixed the two up would sign something it did not mean. A deletion names its document by + // id, a restore carries it. + match action { + "deleteDocument" | "restoreDocument" => { + if identity_id.is_some() { + return Err(WasmDppError::invalid_argument(format!( + "`identityId` is not valid for a {action} action, which names a document" + ))); + } } - } else if document_type_name.is_some() || document_id.is_some() { - return Err(WasmDppError::invalid_argument(format!( - "`documentTypeName` and `documentId` are only valid for a deleteDocument action, not for `{action}`" - ))); + _ => { + if document_type_name.is_some() || document_id.is_some() || document.is_some() { + return Err(WasmDppError::invalid_argument(format!( + "`documentTypeName`, `documentId` and `document` are only valid for a deleteDocument or a restoreDocument action, not for `{action}`" + ))); + } + } + } + if action == "deleteDocument" && document.is_some() { + return Err(WasmDppError::invalid_argument( + "`document` is only valid for a restoreDocument action: a deleteDocument names its document by `documentId`", + )); + } + if action == "restoreDocument" && document_id.is_some() { + return Err(WasmDppError::invalid_argument( + "`documentId` is only valid for a deleteDocument action: a restoreDocument carries its document in `document`", + )); } // What an action that lacks one of its parts is refused with. let needs = @@ -262,6 +404,13 @@ pub fn moderation_action_from_parts( "unsuspend" => Ok(ContractUserModerationAction::Unsuspend { identity_id: identity_id.ok_or_else(|| needs("an `identityId`"))?, }), + "warn" => Ok(ContractUserModerationAction::Warn { + identity_id: identity_id.ok_or_else(|| needs("an `identityId`"))?, + reason: reason.ok_or_else(|| needs("a `reason`"))?, + }), + "clearWarnings" => Ok(ContractUserModerationAction::ClearWarnings { + identity_id: identity_id.ok_or_else(|| needs("an `identityId`"))?, + }), "deleteDocument" => Ok(ContractUserModerationAction::DeleteDocument { document_type_name: document_type_name.ok_or_else(|| needs("a `documentTypeName`"))?, document_id: document_id.ok_or_else(|| needs("a `documentId`"))?, @@ -269,8 +418,12 @@ pub fn moderation_action_from_parts( // left out, the removal record stores no code and an empty text. reason: reason.unwrap_or_default(), }), + "restoreDocument" => Ok(ContractUserModerationAction::RestoreDocument { + document_type_name: document_type_name.ok_or_else(|| needs("a `documentTypeName`"))?, + document: BinaryData::new(document.ok_or_else(|| needs("a `document`"))?), + }), other => Err(WasmDppError::invalid_argument(format!( - "unknown moderation action `{other}`: expected ban, unban, suspend, unsuspend or deleteDocument" + "unknown moderation action `{other}`: expected ban, unban, suspend, unsuspend, warn, clearWarnings, deleteDocument or restoreDocument" ))), } } @@ -288,6 +441,15 @@ impl ContractUserModerationWasm { try_from_options_optional(&options, "identityId")?; let document_id: Option = try_from_options_optional(&options, "documentId")?; + let document = { + let value = js_sys::Reflect::get(&options, &"document".into()) + .map_err(|_| WasmDppError::invalid_argument("failed to read `document`"))?; + if value.is_undefined() || value.is_null() { + None + } else { + Some(try_to_bytes(value, "document")?) + } + }; // Deserialize primitive fields via serde last (consumes options) let input: ContractUserModerationOptionsInput = @@ -300,8 +462,9 @@ impl ContractUserModerationWasm { identity_id: identity_id.map(Into::into), document_type_name: input.document_type_name, document_id: document_id.map(Into::into), + document, until: input.until, - reason: input.reason, + reason: input.reason.map(Into::into), }, )?; @@ -429,26 +592,36 @@ impl ContractUserModerationWasm { self.0.identity_contract_nonce() } - /// The action's name: ban, unban, suspend, unsuspend or deleteDocument + /// The action's name: ban, unban, suspend, unsuspend, warn, clearWarnings, deleteDocument or restoreDocument #[wasm_bindgen(getter = "action")] pub fn action(&self) -> String { self.0.action().name().to_string() } - /// The identity the action targets, undefined for a deleteDocument: it names a document, - /// and whose it is is only known once the document is read + /// The identity the action targets, undefined for a deleteDocument and a restoreDocument: + /// they name a document, and whose it is is only known once the document is read #[wasm_bindgen(getter = "identityId")] pub fn identity_id(&self) -> Option { self.0.target_identity_id().map(Into::into) } - /// For a deleteDocument, the document type of the document it deletes + /// For a deleteDocument or a restoreDocument, the document type of its document #[wasm_bindgen(getter = "documentTypeName")] pub fn document_type_name(&self) -> Option { self.0 .action() - .document() - .map(|(document_type_name, _)| document_type_name.to_string()) + .document_type_name() + .map(ToString::to_string) + } + + /// For a restoreDocument, the document it brings back, as it was serialized when it was + /// deleted + #[wasm_bindgen(getter = "document")] + pub fn document(&self) -> Option> { + self.0 + .action() + .restored_document() + .map(|(_, document)| document.to_vec()) } /// For a deleteDocument, the document it deletes @@ -466,7 +639,7 @@ impl ContractUserModerationWasm { self.0.action().until() } - /// For a ban, a suspend and a deleteDocument, why + /// For a ban, a suspend, a warn and a deleteDocument, why #[wasm_bindgen(getter = "reason")] pub fn reason(&self) -> Option { self.0 diff --git a/packages/wasm-dpp2/src/state_transitions/batch/document_transitions/create.rs b/packages/wasm-dpp2/src/state_transitions/batch/document_transitions/create.rs index dc366241ace..8aa514d7dfb 100644 --- a/packages/wasm-dpp2/src/state_transitions/batch/document_transitions/create.rs +++ b/packages/wasm-dpp2/src/state_transitions/batch/document_transitions/create.rs @@ -9,25 +9,36 @@ use crate::state_transitions::batch::generators::generate_create_transition; use crate::state_transitions::batch::prefunded_voting_balance::PrefundedVotingBalanceWasm; use crate::state_transitions::batch::token_payment_info::TokenPaymentInfoWasm; use crate::utils::{ - try_from_options, try_from_options_optional, try_from_options_with, try_to_u64, + try_from_options_mut, try_from_options_optional, try_from_options_with, try_to_u64, try_vec_to_fixed_bytes, ToSerdeJSONExt, }; +use crate::version::PlatformVersionWasm; use dpp::prelude::IdentityNonce; use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransition; use dpp::state_transition::batch_transition::document_base_transition::document_base_transition_trait::DocumentBaseTransitionAccessors; use dpp::state_transition::batch_transition::document_create_transition::v0::v0_methods::DocumentCreateTransitionV0Methods; use dpp::state_transition::batch_transition::DocumentCreateTransition; +use dpp::version::PlatformVersion; use wasm_bindgen::prelude::wasm_bindgen; use wasm_bindgen::JsValue; #[wasm_bindgen(typescript_custom_section)] const DOCUMENT_CREATE_OPTIONS_TS: &str = r#" export interface DocumentCreateTransitionOptions { + /** + * The document to create. Its id is derived here from its entropy and + * `identityContractNonce` (from protocol version 14 the id of a new + * document commits to that nonce), replacing whatever id the document + * carried, and written back onto this object: after construction + * `document.id` equals `transition.base.id`. + */ document: Document; identityContractNonce: bigint; prefundedVotingBalance?: PrefundedVotingBalance; tokenPaymentInfo?: TokenPaymentInfo; actionFeeAgreement?: DocumentActionFeeAgreement; + /** Platform version the id is derived for (default: latest) */ + platformVersion?: PlatformVersionLike; } "#; @@ -62,13 +73,16 @@ impl DocumentCreateTransitionWasm { pub fn constructor( options: DocumentCreateTransitionOptionsJs, ) -> WasmDppResult { - let document: DocumentWasm = try_from_options(&options, "document")?; - let identity_contract_nonce: IdentityNonce = try_from_options_with(&options, "identityContractNonce", |v| { try_to_u64(v, "identityContractNonce") })?; + let platform_version: PlatformVersion = + try_from_options_optional::(&options, "platformVersion")? + .unwrap_or_default() + .into(); + let prefunded_voting_balance: Option = try_from_options_optional(&options, "prefundedVotingBalance")?; @@ -78,6 +92,19 @@ impl DocumentCreateTransitionWasm { let action_fee_agreement: Option = try_from_options_optional(&options, "actionFeeAgreement")?; + // The id a document carries before its nonce is known is a + // placeholder: derive the one consensus will recompute, on the + // caller's own object so `document.id` matches `transition.base.id`, + // as `DocumentCreateTransitionV0::from_document` does in dpp. + // + // Every other property is read above, before this borrow: a JS + // getter on the options bag could re-enter the same `Document`, and + // wasm-bindgen reports a second borrow of a mutably borrowed object + // as an unrecoverable runtime error, not as a result. From here on + // nothing calls back into JavaScript. + let mut document = try_from_options_mut::(&options, "document", "Document")?; + document.set_id_for_creation(identity_contract_nonce, &platform_version)?; + let rs_create_transition = generate_create_transition( &document, identity_contract_nonce, diff --git a/packages/wasm-dpp2/src/state_transitions/proof_result/convert.rs b/packages/wasm-dpp2/src/state_transitions/proof_result/convert.rs index d93d490b5c3..774761e38c2 100644 --- a/packages/wasm-dpp2/src/state_transitions/proof_result/convert.rs +++ b/packages/wasm-dpp2/src/state_transitions/proof_result/convert.rs @@ -337,6 +337,7 @@ pub fn convert_proof_result( let mut ban_reason = None; let mut suspended_until = None; let mut suspension_reason = None; + let mut warnings = None; for status in statuses.0 { match status { ContractModerationListStatus::Banlist { ban } => { @@ -351,6 +352,12 @@ pub fn convert_proof_result( suspension_reason = Some(suspension.reason); } } + ContractModerationListStatus::Warnings { + warnings: proved_warnings, + } => { + lists.push("warnings".to_string()); + warnings = Some(proved_warnings); + } } } VerifiedContractModerationListStatusesWasm { @@ -361,6 +368,7 @@ pub fn convert_proof_result( ban_reason, suspended_until, suspension_reason, + warnings, } .into() } @@ -401,6 +409,15 @@ pub fn convert_proof_result( moderator_id: removal.moderator_id.into(), reason: removal.reason, removed_at: removal.removed_at, + document_hash: removal.document_hash, + restored_by: removal + .restoration + .as_ref() + .map(|restoration| restoration.moderator_id.into()), + restored_at: removal + .restoration + .as_ref() + .map(|restoration| restoration.restored_at), } .into(), }; diff --git a/packages/wasm-dpp2/src/state_transitions/proof_result/data_contract.rs b/packages/wasm-dpp2/src/state_transitions/proof_result/data_contract.rs index c1f34c4cf01..2a469b1a2ac 100644 --- a/packages/wasm-dpp2/src/state_transitions/proof_result/data_contract.rs +++ b/packages/wasm-dpp2/src/state_transitions/proof_result/data_contract.rs @@ -5,12 +5,13 @@ use crate::DataContractWasm; use crate::IdentifierWasm; use crate::PlatformVersionLikeJs; use crate::data_contract::{ - ContractModerationReasonJs, DataContractJSONJs, DataContractObjectJs, moderation_reason_to_js, + ContractModerationReasonJs, ContractWarningsJs, DataContractJSONJs, DataContractObjectJs, + moderation_reason_to_js, moderation_warnings_to_js, }; use crate::error::{WasmDppError, WasmDppResult}; use crate::impl_wasm_type_info; use crate::serialization::conversions::normalize_js_value_for_json; -use dpp::data_contract::config::moderation::ContractModerationReason; +use dpp::data_contract::config::moderation::{ContractModerationReason, ContractWarning}; use js_sys::{BigInt, Map}; use wasm_bindgen::JsCast; use wasm_bindgen::JsValue; @@ -77,9 +78,9 @@ impl VerifiedDataContractWasm { impl_wasm_type_info!(VerifiedDataContractWasm, VerifiedDataContract); /// `VerifiedContractModerationListStatuses` proof-result wrapper: the target identity's status -/// on the lists a moderation transition touched (both for a ban, the edited one otherwise). -/// A list the proof does not cover is unknown: `banned` is undefined unless `lists` includes -/// `banlist`. +/// on the lists a moderation transition touched (every barring list for a ban, the edited one +/// otherwise). A list the proof does not cover is unknown: `banned` is undefined unless +/// `lists` includes `banlist`, and `warnings` unless it includes `warnings`. #[wasm_bindgen(js_name = "VerifiedContractModerationListStatuses")] #[derive(Clone)] pub struct VerifiedContractModerationListStatusesWasm { @@ -87,7 +88,7 @@ pub struct VerifiedContractModerationListStatusesWasm { pub contract_id: IdentifierWasm, #[wasm_bindgen(getter_with_clone, js_name = "identityId")] pub identity_id: IdentifierWasm, - /// The lists the proof covers: `banlist`, `suspensions`, or both + /// The lists the proof covers: `banlist` and `suspensions` for a ban, else the one edited #[wasm_bindgen(getter_with_clone)] pub lists: Vec, /// When `lists` includes `banlist`: the identity is on the banlist @@ -100,9 +101,20 @@ pub struct VerifiedContractModerationListStatusesWasm { pub suspended_until: Option, #[wasm_bindgen(skip)] pub suspension_reason: Option, + /// When `lists` includes `warnings`: the identity's warnings, oldest first, empty when it + /// carries none + #[wasm_bindgen(skip)] + pub warnings: Option>, } impl VerifiedContractModerationListStatusesWasm { + fn warnings_or_undefined(&self, as_json: bool) -> JsValue { + self.warnings + .as_deref() + .map(|warnings| moderation_warnings_to_js(warnings, as_json)) + .unwrap_or(JsValue::UNDEFINED) + } + fn reason_or_undefined(reason: &Option) -> JsValue { reason .as_ref() @@ -129,6 +141,14 @@ impl VerifiedContractModerationListStatusesWasm { .map(|reason| moderation_reason_to_js(reason).into()) } + /// When `lists` includes `warnings`: the identity's warnings, oldest first + #[wasm_bindgen(getter = "warnings")] + pub fn warnings(&self) -> Option { + self.warnings + .as_deref() + .map(|warnings| moderation_warnings_to_js(warnings, false).into()) + } + #[wasm_bindgen(js_name = toObject)] pub fn to_object(&self) -> WasmDppResult { Ok(js_obj(&[ @@ -159,6 +179,7 @@ impl VerifiedContractModerationListStatusesWasm { "suspensionReason", Self::reason_or_undefined(&self.suspension_reason), ), + ("warnings", self.warnings_or_undefined(false)), ])) } @@ -198,6 +219,7 @@ impl VerifiedContractModerationListStatusesWasm { "suspensionReason", Self::reason_or_undefined(&self.suspension_reason), ), + ("warnings", self.warnings_or_undefined(true)), ])) } } @@ -324,8 +346,10 @@ fn json_safe_credits(credits: u64) -> JsValue { impl_wasm_type_info!(VerifiedContractFeeClaimWasm, VerifiedContractFeeClaim); /// `VerifiedContractDocumentRemoval` proof-result wrapper: the record a moderator's document -/// deletion left under the contract. The document itself is gone; the record says whose it -/// was, who removed it, why and when. +/// deletion left under the contract, as a deletion or a restore leaves it. After a deletion +/// the document is gone and the record says whose it was, who removed it, why, when and what +/// it was (its hash); after a restore the document is live again and the record also says who +/// brought it back and when. #[wasm_bindgen(js_name = "VerifiedContractDocumentRemoval")] #[derive(Clone)] pub struct VerifiedContractDocumentRemovalWasm { @@ -346,6 +370,16 @@ pub struct VerifiedContractDocumentRemovalWasm { /// The time of the block that removed it, in milliseconds #[wasm_bindgen(js_name = "removedAt")] pub removed_at: u64, + #[wasm_bindgen(skip)] + pub document_hash: [u8; 32], + /// The contract owner or moderator that restored the document, undefined while the + /// removal stands + #[wasm_bindgen(getter_with_clone, js_name = "restoredBy")] + pub restored_by: Option, + /// The time of the block that restored it, in milliseconds, undefined while the removal + /// stands + #[wasm_bindgen(js_name = "restoredAt")] + pub restored_at: Option, } #[wasm_bindgen(js_class = VerifiedContractDocumentRemoval)] @@ -356,6 +390,13 @@ impl VerifiedContractDocumentRemovalWasm { moderation_reason_to_js(&self.reason).into() } + /// A double SHA-256 of the document as it was serialized under its type when it was + /// removed: what a restore must bring back byte for byte, as 64 hex characters + #[wasm_bindgen(getter = "documentHash")] + pub fn document_hash(&self) -> String { + hex::encode(self.document_hash) + } + #[wasm_bindgen(js_name = toObject)] pub fn to_object(&self) -> WasmDppResult { Ok(js_obj(&[ @@ -372,6 +413,21 @@ impl VerifiedContractDocumentRemovalWasm { "removedAt", JsValue::from(js_sys::BigInt::from(self.removed_at)), ), + ( + "documentHash", + JsValue::from_str(&hex::encode(self.document_hash)), + ), + ( + "restoredBy", + self.restored_by + .map_or(JsValue::UNDEFINED, |restored_by| restored_by.into()), + ), + ( + "restoredAt", + self.restored_at.map_or(JsValue::UNDEFINED, |restored_at| { + JsValue::from(js_sys::BigInt::from(restored_at)) + }), + ), ])) } @@ -400,6 +456,22 @@ impl VerifiedContractDocumentRemovalWasm { ), ("reason", moderation_reason_to_js(&self.reason)), ("removedAt", JsValue::from_f64(self.removed_at as f64)), + ( + "documentHash", + JsValue::from_str(&hex::encode(self.document_hash)), + ), + ( + "restoredBy", + self.restored_by.map_or(JsValue::UNDEFINED, |restored_by| { + JsValue::from_str(&restored_by.to_base58()) + }), + ), + ( + "restoredAt", + self.restored_at.map_or(JsValue::UNDEFINED, |restored_at| { + JsValue::from_f64(restored_at as f64) + }), + ), ])) } } diff --git a/packages/wasm-dpp2/src/utils.rs b/packages/wasm-dpp2/src/utils.rs index 472a5d2b70b..b55e68631bf 100644 --- a/packages/wasm-dpp2/src/utils.rs +++ b/packages/wasm-dpp2/src/utils.rs @@ -1,12 +1,10 @@ use crate::error::{WasmDppError, WasmDppResult}; -use dpp::identifier::Identifier; use dpp::platform_value::Value; -use dpp::util::hash::hash_double_to_vec; use js_sys::{Error as JsError, Map, Object}; use serde_json::Value as JsonValue; use std::collections::BTreeMap; use std::convert::TryInto; -use wasm_bindgen::convert::RefFromWasmAbi; +use wasm_bindgen::convert::{RefFromWasmAbi, RefMutFromWasmAbi}; use wasm_bindgen::{JsCast, JsValue}; /// Extension trait for extracting error messages from JsValue @@ -115,6 +113,13 @@ pub fn generic_of_js_val>( js_value: &JsValue, class_name: &str, ) -> WasmDppResult { + let ptr = wasm_ptr_of_js_val(js_value, class_name)?; + Ok(unsafe { T::ref_from_abi(ptr) }) +} + +/// The internal wasm pointer of a JS object, once it is confirmed to be an +/// instance of the expected wasm class. +fn wasm_ptr_of_js_val(js_value: &JsValue, class_name: &str) -> WasmDppResult { if !js_value.is_object() { return Err(WasmDppError::invalid_argument(format!( "Value supplied as {} is not an object", @@ -124,28 +129,36 @@ pub fn generic_of_js_val>( let ctor_name = get_class_type(js_value)?; - if ctor_name == class_name { - let ptr = - js_sys::Reflect::get(js_value, &JsValue::from_str("__wbg_ptr")).map_err(|err| { - let message = err.error_message(); - WasmDppError::generic(format!( - "failed to read internal pointer from JS object '{}': {}", - class_name, message - )) - })?; - let ptr_u32: u32 = ptr - .as_f64() - .ok_or_else(|| WasmDppError::invalid_argument("Invalid JS object pointer"))? - as u32; - let reference = unsafe { T::ref_from_abi(ptr_u32) }; - Ok(reference) - } else { - let error_string = format!( + if ctor_name != class_name { + return Err(WasmDppError::invalid_argument(format!( "JS object constructor name mismatch. Expected {}, provided {}.", class_name, ctor_name - ); - Err(WasmDppError::invalid_argument(error_string)) + ))); + } + + let ptr = js_sys::Reflect::get(js_value, &JsValue::from_str("__wbg_ptr")).map_err(|err| { + let message = err.error_message(); + WasmDppError::generic(format!( + "failed to read internal pointer from JS object '{}': {}", + class_name, message + )) + })?; + + let ptr = ptr + .as_f64() + .ok_or_else(|| WasmDppError::invalid_argument("Invalid JS object pointer"))? + as u32; + + // wasm-bindgen writes 0 into `__wbg_ptr` when JS frees the object (or + // moves it into wasm); dereferencing it would trap instead of erroring. + if ptr == 0 { + return Err(WasmDppError::invalid_argument(format!( + "JS object '{}' has already been freed", + class_name + ))); } + + Ok(ptr) } /// Get the `__type` property from a JsValue (used for WASM class identification) @@ -161,6 +174,31 @@ pub fn get_class_type(value: &JsValue) -> WasmDppResult { Ok(class_type.as_string().unwrap_or_default()) } +/// The raw value of a property of a JS options object. +fn read_property(options: &JsValue, property_name: &str) -> WasmDppResult { + js_sys::Reflect::get(options, &JsValue::from_str(property_name)).map_err(|err| { + let message = err.error_message(); + WasmDppError::generic(format!( + "failed to read '{}' from options: {}", + property_name, message + )) + }) +} + +/// The value of a required property, rejected when it is undefined or null. +fn read_required_property(options: &JsValue, property_name: &str) -> WasmDppResult { + let value = read_property(options, property_name)?; + + if value.is_undefined() || value.is_null() { + return Err(WasmDppError::invalid_argument(format!( + "'{}' is required", + property_name + ))); + } + + Ok(value) +} + /// Extract a required property from a JS value/object and convert it using TryFrom. /// /// Returns `Err` if the property is undefined, null, or conversion fails. @@ -177,21 +215,7 @@ where for<'a> T: TryFrom<&'a JsValue>, for<'a> >::Error: Into, { - let value = - js_sys::Reflect::get(options, &JsValue::from_str(property_name)).map_err(|err| { - let message = err.error_message(); - WasmDppError::generic(format!( - "failed to read '{}' from options: {}", - property_name, message - )) - })?; - - if value.is_undefined() || value.is_null() { - return Err(WasmDppError::invalid_argument(format!( - "'{}' is required", - property_name - ))); - } + let value = read_required_property(options, property_name)?; T::try_from(&value).map_err(Into::into) } @@ -217,25 +241,37 @@ pub fn try_from_options_with( where F: FnOnce(&JsValue) -> WasmDppResult, { - let value = - js_sys::Reflect::get(options, &JsValue::from_str(property_name)).map_err(|err| { - let message = err.error_message(); - WasmDppError::generic(format!( - "failed to read '{}' from options: {}", - property_name, message - )) - })?; - - if value.is_undefined() || value.is_null() { - return Err(WasmDppError::invalid_argument(format!( - "'{}' is required", - property_name - ))); - } + let value = read_required_property(options, property_name)?; converter(&value) } +/// Borrow a required wasm-class property of a JS object mutably, so that a +/// change made through it lands on the caller's own object rather than on a +/// clone. +/// +/// Returns `Err` if the property is undefined or null, or is not an instance +/// of the wasm class named `class_name`. The borrow panics, as any +/// wasm-bindgen method on the object would, if the object is already +/// borrowed: drop it before handing the object back to JS. +/// +/// # Example +/// +/// ```ignore +/// let mut document = try_from_options_mut::(&options, "document", "Document")?; +/// document.set_id(id); +/// ``` +pub fn try_from_options_mut>( + options: &JsValue, + property_name: &str, + class_name: &str, +) -> WasmDppResult { + let value = read_required_property(options, property_name)?; + + let ptr = wasm_ptr_of_js_val(&value, class_name)?; + Ok(unsafe { T::ref_mut_from_abi(ptr) }) +} + /// Extract an optional property from a JS object and convert it using TryFrom. /// /// Returns `Ok(None)` if the property is undefined or null. @@ -257,14 +293,7 @@ where for<'a> T: TryFrom<&'a JsValue>, for<'a> >::Error: Into, { - let value = - js_sys::Reflect::get(options, &JsValue::from_str(property_name)).map_err(|err| { - let message = err.error_message(); - WasmDppError::generic(format!( - "failed to read '{}' from options: {}", - property_name, message - )) - })?; + let value = read_property(options, property_name)?; if value.is_undefined() || value.is_null() { Ok(None) @@ -296,14 +325,7 @@ pub fn try_from_options_optional_with( where F: FnOnce(&JsValue) -> WasmDppResult, { - let value = - js_sys::Reflect::get(options, &JsValue::from_str(property_name)).map_err(|err| { - let message = err.error_message(); - WasmDppError::generic(format!( - "failed to read '{}' from options: {}", - property_name, message - )) - })?; + let value = read_property(options, property_name)?; if value.is_undefined() || value.is_null() { Ok(None) @@ -701,24 +723,6 @@ pub fn try_to_u16(value: &JsValue, field_name: &str) -> WasmDppResult { } } -/// Generate a document ID using the v0 algorithm -pub fn generate_document_id_v0( - contract_id: &Identifier, - owner_id: &Identifier, - document_type_name: &str, - entropy: &[u8], -) -> WasmDppResult { - let mut buf: Vec = vec![]; - - buf.extend_from_slice(&contract_id.to_buffer()); - buf.extend_from_slice(&owner_id.to_buffer()); - buf.extend_from_slice(document_type_name.as_bytes()); - buf.extend_from_slice(entropy); - - Identifier::from_bytes(&hash_double_to_vec(&buf)) - .map_err(|err| WasmDppError::invalid_argument(err.to_string())) -} - /// Macro to implement `TryFrom<&JsValue>` for WASM wrapper types using `IntoWasm`. /// /// This is for complex types that can only be instantiated from their WASM class objects diff --git a/packages/wasm-dpp2/tests/unit/ContractUserModerationTransition.spec.ts b/packages/wasm-dpp2/tests/unit/ContractUserModerationTransition.spec.ts index 88bdc1d14ca..831bf2dc411 100644 --- a/packages/wasm-dpp2/tests/unit/ContractUserModerationTransition.spec.ts +++ b/packages/wasm-dpp2/tests/unit/ContractUserModerationTransition.spec.ts @@ -12,41 +12,51 @@ const DOCUMENT_ID = 'GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec'; const DOCUMENT_TYPE_NAME = 'post'; interface ModerationOptions { - action?: 'ban' | 'unban' | 'suspend' | 'unsuspend' | 'deleteDocument'; - /** `null` leaves the identity out; left undefined, every action but a deleteDocument gets `TARGET_ID` */ + action?: 'ban' | 'unban' | 'suspend' | 'unsuspend' | 'warn' | 'clearWarnings' | 'deleteDocument' | 'restoreDocument'; + /** `null` leaves the identity out; left undefined, every action but a deleteDocument or a restoreDocument gets `TARGET_ID` */ identityId?: string | null; - /** `null` leaves it out; left undefined, a deleteDocument gets `DOCUMENT_TYPE_NAME` */ + /** `null` leaves it out; left undefined, a deleteDocument or a restoreDocument gets `DOCUMENT_TYPE_NAME` */ documentTypeName?: string | null; /** `null` leaves it out; left undefined, a deleteDocument gets `DOCUMENT_ID` */ documentId?: string | null; + /** `null` leaves it out; left undefined, a restoreDocument gets `DOCUMENT_BYTES` */ + document?: Uint8Array | null; until?: bigint; - /** `null` leaves the reason out; left undefined, a ban and a suspend get `REASON` */ - reason?: { code?: number; text: string } | null; + /** `null` leaves the reason out; left undefined, a ban, a suspend and a warn get `REASON` */ + reason?: { code?: number; text: string; documents?: { documentTypeName: string; documentId: string }[] } | null; identityContractNonce?: bigint; userFeeIncrease?: number; } const REASON = { text: 'spam' }; +/** What a restore carries: the document as it was serialized when it was deleted */ +const DOCUMENT_BYTES = new Uint8Array(70).fill(7); function createTransition(options: ModerationOptions = {}) { const action = options.action ?? 'ban'; - const addsAnEntry = action === 'ban' || action === 'suspend'; + const addsAnEntry = action === 'ban' || action === 'suspend' || action === 'warn'; let { reason } = options; if (reason === undefined && addsAnEntry) { reason = REASON; } - // A deletion names a document and every other action an identity. + // A deletion and a restore name a document and every other action an identity. const deletesADocument = action === 'deleteDocument'; - let { identityId, documentTypeName, documentId } = options; - if (identityId === undefined && !deletesADocument) { + const restoresADocument = action === 'restoreDocument'; + let { + identityId, documentTypeName, documentId, document, + } = options; + if (identityId === undefined && !deletesADocument && !restoresADocument) { identityId = TARGET_ID; } - if (documentTypeName === undefined && deletesADocument) { + if (documentTypeName === undefined && (deletesADocument || restoresADocument)) { documentTypeName = DOCUMENT_TYPE_NAME; } if (documentId === undefined && deletesADocument) { documentId = DOCUMENT_ID; } + if (document === undefined && restoresADocument) { + document = DOCUMENT_BYTES; + } return new wasm.ContractUserModeration({ ownerId: OWNER_ID, @@ -56,6 +66,7 @@ function createTransition(options: ModerationOptions = {}) { identityId: identityId ?? undefined, documentTypeName: documentTypeName ?? undefined, documentId: documentId ?? undefined, + document: document ?? undefined, until: options.until, reason: reason ?? undefined, userFeeIncrease: options.userFeeIncrease, @@ -114,10 +125,57 @@ describe('ContractUserModeration', () => { expect(() => createTransition({ action: 'deleteDocument', until: BigInt(5) })).to.throw(); }); + it('should create a document restore, which carries the document and names no identity', () => { + const transition = createTransition({ action: 'restoreDocument' }); + + expect(transition.action).to.equal('restoreDocument'); + expect(transition.documentTypeName).to.equal(DOCUMENT_TYPE_NAME); + expect(transition.document).to.deep.equal(DOCUMENT_BYTES); + expect(transition.documentId).to.equal(undefined); + expect(transition.identityId).to.equal(undefined); + expect(transition.until).to.equal(undefined); + expect(transition.reason).to.equal(undefined); + }); + + it('should refuse a document restore without its document type or its document', () => { + expect(() => createTransition({ action: 'restoreDocument', documentTypeName: null })).to.throw(); + expect(() => createTransition({ action: 'restoreDocument', document: null })).to.throw(); + }); + + it('should refuse on a document restore what it does not carry', () => { + expect(() => createTransition({ action: 'restoreDocument', identityId: TARGET_ID })).to.throw(); + expect(() => createTransition({ action: 'restoreDocument', documentId: DOCUMENT_ID })).to.throw(); + expect(() => createTransition({ action: 'restoreDocument', reason: REASON })).to.throw(); + expect(() => createTransition({ action: 'restoreDocument', until: BigInt(5) })).to.throw(); + expect(() => createTransition({ action: 'deleteDocument', document: DOCUMENT_BYTES })).to.throw(); + }); + + it('should create a warning and its clearing, which name an identity', () => { + const warn = createTransition({ action: 'warn', reason: { code: 1, text: 'first strike' } }); + + expect(warn.action).to.equal('warn'); + expect(warn.identityId?.toString()).to.equal(TARGET_ID); + expect(warn.until).to.equal(undefined); + expect(warn.reason).to.deep.equal({ code: 1, text: 'first strike' }); + + const clear = createTransition({ action: 'clearWarnings' }); + + expect(clear.action).to.equal('clearWarnings'); + expect(clear.identityId?.toString()).to.equal(TARGET_ID); + expect(clear.reason).to.equal(undefined); + expect(clear.toJSON().action).to.deep.equal({ $type: 'clearWarnings', identityId: TARGET_ID }); + }); + + it('should refuse a warning without a reason, and a reason on a clearing', () => { + expect(() => createTransition({ action: 'warn', reason: null })).to.throw(); + expect(() => createTransition({ action: 'clearWarnings', reason: REASON })).to.throw(); + }); + it('should refuse a document on an action that targets an identity', () => { - (['ban', 'unban', 'unsuspend'] as const).forEach((action) => { + (['ban', 'unban', 'unsuspend', 'warn', 'clearWarnings'] as const).forEach((action) => { expect(() => createTransition({ action, documentTypeName: DOCUMENT_TYPE_NAME })).to.throw(); expect(() => createTransition({ action, documentId: DOCUMENT_ID })).to.throw(); + expect(() => createTransition({ action, document: DOCUMENT_BYTES })).to.throw(); }); expect(() => createTransition({ action: 'suspend', @@ -128,12 +186,30 @@ describe('ContractUserModeration', () => { }); it('should refuse an action on an identity without the identity', () => { - (['ban', 'unban', 'unsuspend'] as const).forEach((action) => { + (['ban', 'unban', 'unsuspend', 'warn', 'clearWarnings'] as const).forEach((action) => { expect(() => createTransition({ action, identityId: null })).to.throw(); }); expect(() => createTransition({ action: 'suspend', until: BigInt(5), identityId: null })).to.throw(); }); + it('should cite the documents a reason is about, and leave them out when none', () => { + const documents = [ + { documentTypeName: 'post', documentId: DOCUMENT_ID }, + { documentTypeName: 'reply', documentId: TARGET_ID }, + ]; + const transition = createTransition({ action: 'warn', reason: { text: 'spam', documents } }); + + expect(transition.reason).to.deep.equal({ code: null, text: 'spam', documents }); + expect(transition.toJSON().action.reason).to.deep.equal({ code: null, text: 'spam', documents }); + expect(wasm.ContractUserModeration.fromBytes(transition.toBytes()).reason).to.deep.equal({ + code: null, + text: 'spam', + documents, + }); + // A reason about no document carries no `documents`, as before. + expect(createTransition().reason).to.deep.equal({ code: null, text: 'spam' }); + }); + it('should keep a reason code as written, and an empty text', () => { const transition = createTransition({ reason: { code: 65535, text: '' } }); @@ -169,7 +245,7 @@ describe('ContractUserModeration', () => { it('should refuse an end on anything but a suspension', () => { // Dropped, a caller asking for a timed ban would sign a permanent one. - (['ban', 'unban', 'unsuspend'] as const).forEach((action) => { + (['ban', 'unban', 'unsuspend', 'warn', 'clearWarnings'] as const).forEach((action) => { expect(() => createTransition({ action, until: BigInt(1800000000000) })).to.throw(); }); }); @@ -192,6 +268,8 @@ describe('ContractUserModeration', () => { createTransition({ action: 'unban' }), createTransition({ action: 'suspend', until: BigInt(5) }), createTransition({ action: 'unsuspend', userFeeIncrease: 3 }), + createTransition({ action: 'warn', reason: { code: 9, text: 'first strike' } }), + createTransition({ action: 'clearWarnings' }), createTransition({ action: 'deleteDocument', reason: { code: 9, text: 'spam' } }), createTransition({ action: 'deleteDocument' }), ]) { diff --git a/packages/wasm-dpp2/tests/unit/DataContract.spec.ts b/packages/wasm-dpp2/tests/unit/DataContract.spec.ts index 83541780e40..39dffc53456 100644 --- a/packages/wasm-dpp2/tests/unit/DataContract.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DataContract.spec.ts @@ -246,7 +246,8 @@ describe('DataContract', () => { dataContract.setConfig({ ...dataContract.config, moderation }, new PlatformVersion(14)); - expect(dataContract.config.moderation).to.deep.equal(moderation); + // A declaration that says nothing about the warning list keeps none. + expect(dataContract.config.moderation).to.deep.equal({ ...moderation, warnings: false }); }); it('should leave an unmoderated config without the key', () => { diff --git a/packages/wasm-dpp2/tests/unit/Document.spec.ts b/packages/wasm-dpp2/tests/unit/Document.spec.ts index 82b5d3a88e9..135d94f6a4c 100644 --- a/packages/wasm-dpp2/tests/unit/Document.spec.ts +++ b/packages/wasm-dpp2/tests/unit/Document.spec.ts @@ -11,7 +11,7 @@ import { document2, documentBytes, } from './mocks/Document/index.js'; -import { fromHexString } from './utils/hex.ts'; +import { fromHexString, toHexString } from './utils/hex.ts'; let PlatformVersion: typeof wasm.PlatformVersion; @@ -76,6 +76,83 @@ describe('Document', () => { expect(documentInstance).to.be.an.instanceof(wasm.Document); }); + + it('should derive the entropy-only placeholder id when no nonce is given', () => { + const documentInstance = createDocument({ entropy: fixedEntropy }); + + // the pre-14 derivation, which is all that can be computed without the + // nonce of the create transition + const placeholder = wasm.Document.generateId( + documentTypeName, + ownerId, + dataContractId, + fixedEntropy, + undefined, + 13, + ); + + expect(documentInstance.id.toBytes()).to.deep.equal(placeholder); + }); + + it('should derive the final id when the identity contract nonce is given', () => { + const documentInstance = new wasm.Document({ + properties: document, + documentTypeName, + dataContractId, + ownerId, + entropy: fixedEntropy, + identityContractNonce: BigInt(5), + }); + + const expected = wasm.Document.generateId( + documentTypeName, + ownerId, + dataContractId, + fixedEntropy, + BigInt(5), + ); + + expect(documentInstance.id.toBytes()).to.deep.equal(expected); + expect(documentInstance.id.toBytes()).to.not.deep.equal( + createDocument({ entropy: fixedEntropy }).id.toBytes(), + ); + }); + + it('should accept an explicit id that equals the one derived from the nonce', () => { + const derived = wasm.Document.generateId( + documentTypeName, + ownerId, + dataContractId, + fixedEntropy, + BigInt(5), + ); + + const documentInstance = new wasm.Document({ + properties: document, + documentTypeName, + dataContractId, + ownerId, + entropy: fixedEntropy, + id: derived, + identityContractNonce: BigInt(5), + }); + + expect(documentInstance.id.toBytes()).to.deep.equal(derived); + }); + + it('should reject an explicit id that differs from the one derived from the nonce', () => { + // the nonce fixes the id; an explicit id that disagrees could be + // referenced by another document but never created + expect(() => new wasm.Document({ + properties: document, + documentTypeName, + dataContractId, + ownerId, + entropy: fixedEntropy, + id, + identityContractNonce: BigInt(5), + })).to.throw(/does not match the id .* derived .* identityContractNonce 5/); + }); }); describe('toBytes()', () => { @@ -195,11 +272,121 @@ describe('Document', () => { }); describe('generateId()', () => { + // The vector `Document::generate_document_id_v1` pins in rs-dpp: contract + // [1; 32], owner [2; 32], type "note", entropy [7; 32], nonce 1. Every + // client derives this id on its own, so the layout is consensus. + const pinnedContractId = new Uint8Array(32).fill(1); + const pinnedOwnerId = new Uint8Array(32).fill(2); + const pinnedEntropy = new Uint8Array(32).fill(7); + const pinnedId = 'e574ae73396611a517691d1f89275b6e99642cb9c176ce8cf879b1665c50f15f'; + it('should generate id', () => { - const generatedId = wasm.Document.generateId('note', ownerId, dataContractId); + const generatedId = wasm.Document.generateId('note', ownerId, dataContractId, undefined, BigInt(1)); expect(Array.from(generatedId).length).to.equal(32); }); + + it('should reproduce the pinned nonce-derived id', () => { + const generatedId = wasm.Document.generateId( + 'note', + pinnedOwnerId, + pinnedContractId, + pinnedEntropy, + BigInt(1), + ); + + expect(toHexString(generatedId)).to.equal(pinnedId); + }); + + it('should reproduce the pinned id at an explicit latest platform version', () => { + const generatedId = wasm.Document.generateId( + 'note', + pinnedOwnerId, + pinnedContractId, + pinnedEntropy, + BigInt(1), + PlatformVersion.latest(), + ); + + expect(toHexString(generatedId)).to.equal(pinnedId); + }); + + it('should derive a different id for every nonce', () => { + const first = wasm.Document.generateId('note', pinnedOwnerId, pinnedContractId, pinnedEntropy, BigInt(1)); + const second = wasm.Document.generateId('note', pinnedOwnerId, pinnedContractId, pinnedEntropy, BigInt(2)); + + expect(toHexString(first)).to.not.equal(toHexString(second)); + }); + + it('should keep the entropy in the id', () => { + const first = wasm.Document.generateId('note', pinnedOwnerId, pinnedContractId, pinnedEntropy, BigInt(1)); + const second = wasm.Document.generateId( + 'note', + pinnedOwnerId, + pinnedContractId, + new Uint8Array(32).fill(8), + BigInt(1), + ); + + expect(toHexString(first)).to.not.equal(toHexString(second)); + }); + + it('should require the nonce from protocol version 14', () => { + expect(() => wasm.Document.generateId('note', pinnedOwnerId, pinnedContractId, pinnedEntropy)) + .to.throw(/identityContractNonce/); + }); + + it('should ignore the nonce before protocol version 14', () => { + const withoutNonce = wasm.Document.generateId( + 'note', + pinnedOwnerId, + pinnedContractId, + pinnedEntropy, + undefined, + 13, + ); + const withNonce = wasm.Document.generateId( + 'note', + pinnedOwnerId, + pinnedContractId, + pinnedEntropy, + BigInt(1), + new PlatformVersion(13), + ); + + expect(toHexString(withoutNonce)).to.equal(toHexString(withNonce)); + expect(toHexString(withoutNonce)).to.not.equal(pinnedId); + }); + }); + + describe('setIdForCreation()', () => { + it('should give the document the id its create transition will carry', () => { + const documentInstance = createDocument({ id, entropy: fixedEntropy }); + + documentInstance.setIdForCreation(BigInt(3)); + + const expected = wasm.Document.generateId( + documentTypeName, + ownerId, + dataContractId, + fixedEntropy, + BigInt(3), + ); + expect(documentInstance.id.toBytes()).to.deep.equal(expected); + expect(documentInstance.id.toBase58()).to.not.equal(id); + }); + + it('should throw for a document without entropy', () => { + const dataContract = wasm.DataContract.fromJSON(dataContractValue, false); + const documentInstance = wasm.Document.fromBytes( + fromHexString(documentBytes), + dataContract, + 'note', + new PlatformVersion(1), + ); + + expect(() => documentInstance.setIdForCreation(BigInt(1))).to.throw(/entropy/); + }); }); describe('id', () => { diff --git a/packages/wasm-dpp2/tests/unit/DocumentsTransitions.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentsTransitions.spec.ts index f740eae14b5..807b3f4663e 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentsTransitions.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentsTransitions.spec.ts @@ -101,6 +101,81 @@ describe('DocumentsTransitions', () => { expect(documentInstance).to.be.an.instanceof(wasm.Document); expect(createTransition).to.be.an.instanceof(wasm.DocumentCreateTransition); }); + + it('should derive the id from the entropy and the nonce and mirror it onto the document', () => { + // the document is built with an id that is not the one its create + // transition must carry: the constructor replaces it + const documentInstance = createDocument(); + expect(documentInstance.id.toBase58()).to.equal(id); + + const createTransition = new wasm.DocumentCreateTransition({ + document: documentInstance, + identityContractNonce: BigInt(7), + }); + + const derived = wasm.Document.generateId( + documentTypeName, + ownerId, + dataContractId, + documentInstance.entropy, + BigInt(7), + ); + + expect(createTransition.base.id.toBytes()).to.deep.equal(derived); + expect(documentInstance.id.toBytes()).to.deep.equal(derived); + expect(documentInstance.id.toBase58()).to.not.equal(id); + }); + + it('should derive a different id for another nonce', () => { + const documentInstance = createDocument(); + const { entropy } = documentInstance; + + const first = new wasm.DocumentCreateTransition({ + document: documentInstance, + identityContractNonce: BigInt(1), + }); + const second = new wasm.DocumentCreateTransition({ + document: documentInstance, + identityContractNonce: BigInt(2), + }); + + expect(first.entropy).to.deep.equal(entropy); + expect(second.entropy).to.deep.equal(entropy); + expect(first.base.id.toBase58()).to.not.equal(second.base.id.toBase58()); + // the document follows the transition it was last built into + expect(documentInstance.id.toBase58()).to.equal(second.base.id.toBase58()); + }); + + it('should keep the entropy-only id before protocol version 14', () => { + const documentInstance = new wasm.Document({ + properties: document, + documentTypeName, + dataContractId, + ownerId, + revision: BigInt(revision), + }); + const placeholder = documentInstance.id.toBase58(); + + const createTransition = new wasm.DocumentCreateTransition({ + document: documentInstance, + identityContractNonce: BigInt(1), + platformVersion: 13, + }); + + expect(createTransition.base.id.toBase58()).to.equal(placeholder); + expect(documentInstance.id.toBase58()).to.equal(placeholder); + }); + + it('should refuse a document without entropy', () => { + // a document read back from Platform carries none: it exists already + const documentInstance = createDocument(); + documentInstance.entropy = undefined; + + expect(() => new wasm.DocumentCreateTransition({ + document: documentInstance, + identityContractNonce: BigInt(1), + })).to.throw(/entropy/); + }); }); describe('toDocumentTransition()', () => { diff --git a/packages/wasm-sdk/src/queries/contract_moderation.rs b/packages/wasm-sdk/src/queries/contract_moderation.rs index e76cc3f3b01..7efb9580545 100644 --- a/packages/wasm-sdk/src/queries/contract_moderation.rs +++ b/packages/wasm-sdk/src/queries/contract_moderation.rs @@ -1,7 +1,7 @@ //! Contract moderation queries: one identity's status on a moderated contract -//! (`getContractModerationStatus`), one page of a contract's banlist or suspension list -//! (`getContractModerationEntries`) and the records of the documents its moderators deleted -//! (`getContractDocumentRemovals`). +//! (`getContractModerationStatus`), one page of a contract's banlist, suspension list or +//! warning list (`getContractModerationEntries`) and the records of the documents its +//! moderators deleted (`getContractDocumentRemovals`). use crate::error::WasmSdkError; use crate::queries::utils::deserialize_required_query; @@ -21,13 +21,13 @@ use js_sys::Array; use serde::Deserialize; use wasm_bindgen::prelude::wasm_bindgen; use wasm_bindgen::JsValue; -use wasm_dpp2::data_contract::moderation_reason_to_js; +use wasm_dpp2::data_contract::{moderation_reason_to_js, moderation_warnings_to_js}; use wasm_dpp2::identifier::IdentifierWasm; #[wasm_bindgen(typescript_custom_section)] const CONTRACT_MODERATION_QUERY_TS: &'static str = r#" -/** One of the two moderation lists a moderated contract may keep. */ -export type ContractModerationListKind = 'banlist' | 'suspensions'; +/** One of the moderation lists a moderated contract may keep. */ +export type ContractModerationListKind = 'banlist' | 'suspensions' | 'warnings'; /** * Query parameters for one identity's status on a moderated contract @@ -65,11 +65,16 @@ export interface ContractModerationStatus { suspendedUntil?: bigint; /** When the identity is suspended: why, as the moderator wrote it. */ suspensionReason?: ContractModerationReason; + /** + * When `lists` includes `warnings`: the identity's warnings, oldest first, an empty array + * when it carries none. Warnings bar nothing and stay until a moderator clears them. + */ + warnings?: ContractWarning[]; } /** - * Query parameters for one page of a moderated contract's banlist or suspension list - * (`getContractModerationEntries`). + * Query parameters for one page of a moderated contract's banlist, suspension list or + * warning list (`getContractModerationEntries`). */ export interface ContractModerationEntriesQuery { /** The moderated contract. */ @@ -90,8 +95,13 @@ export interface ContractModerationEntry { identityId: string; /** For a suspension list entry: the block time, in milliseconds, at which it lapses. */ until?: bigint; - /** Why the identity is on the list, as the moderator wrote it. */ + /** + * Why the identity is on the list, as the moderator wrote it: the ban's reason, the + * suspension's, or for a warning list entry the latest warning's. + */ reason: ContractModerationReason; + /** For a warning list entry: every warning the identity carries, oldest first. */ + warnings?: ContractWarning[]; } /** @@ -129,8 +139,10 @@ export interface ContractDocumentRemovalsQuery { } /** - * The record a contract keeps of one document a moderator deleted. It is final: a document id - * is produced at most once, so the removed id can not be created again. + * The record a contract keeps of one document a moderator deleted. A document id is produced + * at most once, so the removed id can not be created again; what can bring the document back + * is a moderator's restore within a week of the deletion, which marks the record restored and + * leaves it in place. A restored document deleted again gets a fresh record. */ export interface ContractDocumentRemovalEntry { documentId: string; @@ -142,6 +154,15 @@ export interface ContractDocumentRemovalEntry { reason: ContractModerationReason; /** The time of the block that removed it, in milliseconds. */ removedAt: bigint; + /** + * A double SHA-256 of the document as it was serialized under its type when it was + * removed, as 64 hex characters: what a restore must bring back byte for byte. + */ + documentHash: string; + /** The contract owner or moderator that restored the document; absent while the removal stands. */ + restoredBy?: string; + /** The time of the block that restored it, in milliseconds; absent while the removal stands. */ + restoredAt?: bigint; } /** @@ -172,6 +193,7 @@ extern "C" { enum ContractModerationListInput { Banlist, Suspensions, + Warnings, } impl From for ContractModerationList { @@ -179,6 +201,7 @@ impl From for ContractModerationList { match list { ContractModerationListInput::Banlist => ContractModerationList::Banlist, ContractModerationListInput::Suspensions => ContractModerationList::Suspensions, + ContractModerationListInput::Warnings => ContractModerationList::Warnings, } } } @@ -330,11 +353,11 @@ fn parse_removals_query( Ok(page_query) } -/// Sets `lists`, and `banned` and `suspendedUntil` for the lists read, each with the reason of -/// the entry found, on `target`. Only the -/// lists read are reported: the field of a list that was not read stays undefined (unknown) -/// rather than reading as "not banned" or "not suspended". The status query and the moderation -/// result share the shape, so they share this. +/// Sets `lists`, and `banned`, `suspendedUntil` and `warnings` for the lists read, each with +/// the reason of the entry found, on `target`. Only the lists read are reported: the field of +/// a list that was not read stays undefined (unknown) rather than reading as "not banned", +/// "not suspended" or "never warned". The status query and the moderation result share the +/// shape, so they share this. pub(crate) fn set_status_fields( target: &js_sys::Object, statuses: &ContractModerationListStatuses, @@ -367,6 +390,10 @@ pub(crate) fn set_status_fields( )?; } } + ContractModerationListStatus::Warnings { warnings } => { + lists.push(&"warnings".into()); + set("warnings", moderation_warnings_to_js(warnings, false))?; + } } } set("lists", lists.into()) @@ -399,6 +426,13 @@ fn entries_to_js( set(&js_entry, "until", js_sys::BigInt::from(until).into())?; } set(&js_entry, "reason", moderation_reason_to_js(&entry.reason))?; + if !entry.warnings.is_empty() { + set( + &js_entry, + "warnings", + moderation_warnings_to_js(&entry.warnings, false), + )?; + } entries.push(&js_entry); } set(&result, "entries", entries.into())?; @@ -413,10 +447,12 @@ fn entries_to_js( Ok(result.into()) } -/// Sets `documentOwnerId`, `moderatorId`, `reason` and `removedAt` on `target`. The removals -/// query and the result of a deletion carry the same record, so they share this. Each writes -/// identifiers its own way, which `id_to_js` decides: base58 strings in a query answer, as the -/// entries of a moderation list are, and `Identifier`s in the result of a transition. +/// Sets `documentOwnerId`, `moderatorId`, `reason`, `removedAt` and `documentHash` on +/// `target`, and `restoredBy` and `restoredAt` when the document was restored. The removals +/// query and the result of a deletion or a restore carry the same record, so they share this. +/// Each writes identifiers its own way, which `id_to_js` decides: base58 strings in a query +/// answer, as the entries of a moderation list are, and `Identifier`s in the result of a +/// transition. The hash is 64 hex characters either way. pub(crate) fn set_removal_fields( target: &js_sys::Object, removal: &ContractDocumentRemoval, @@ -430,7 +466,19 @@ pub(crate) fn set_removal_fields( set("documentOwnerId", id_to_js(removal.document_owner_id))?; set("moderatorId", id_to_js(removal.moderator_id))?; set("reason", moderation_reason_to_js(&removal.reason))?; - set("removedAt", js_sys::BigInt::from(removal.removed_at).into()) + set("removedAt", js_sys::BigInt::from(removal.removed_at).into())?; + set( + "documentHash", + JsValue::from_str(&hex::encode(removal.document_hash)), + )?; + if let Some(restoration) = &removal.restoration { + set("restoredBy", id_to_js(restoration.moderator_id))?; + set( + "restoredAt", + js_sys::BigInt::from(restoration.restored_at).into(), + )?; + } + Ok(()) } fn removals_to_js( @@ -467,8 +515,8 @@ fn removals_to_js( #[wasm_bindgen] impl WasmSdk { - /// One identity's status on a moderated contract: whether it is banned, and until when it - /// is suspended, on the lists read. Every list the query names must be one the contract + /// One identity's status on a moderated contract: whether it is banned, until when it is + /// suspended, and the warnings it carries, on the lists read. Every list the query names must be one the contract /// keeps; without `lists`, every list the contract keeps is read. /// /// # Example @@ -515,9 +563,9 @@ impl WasmSdk { )) } - /// One page of a moderated contract's banlist or suspension list, in identity id order. - /// Pass the page's `nextStartAfter` as the next query's `startAfter`; a page without one - /// is the last. + /// One page of a moderated contract's banlist, suspension list or warning list, in + /// identity id order. Pass the page's `nextStartAfter` as the next query's `startAfter`; + /// a page without one is the last. /// /// # Example /// ```javascript diff --git a/packages/wasm-sdk/src/state_transitions/contract.rs b/packages/wasm-sdk/src/state_transitions/contract.rs index 802b41d8781..fead990339e 100644 --- a/packages/wasm-sdk/src/state_transitions/contract.rs +++ b/packages/wasm-sdk/src/state_transitions/contract.rs @@ -8,8 +8,8 @@ use crate::queries::utils::deserialize_required_query; use crate::sdk::WasmSdk; use crate::settings::{get_user_fee_increase, PutSettingsInput}; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; -use dash_sdk::dpp::data_contract::config::moderation::ContractModerationReason; use dash_sdk::dpp::data_contract::DataContract; +use dash_sdk::dpp::document::{Document, DocumentV0Getters}; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::identity::Identity; use dash_sdk::dpp::identity::IdentityPublicKey; @@ -22,8 +22,10 @@ use dash_sdk::platform::transition::contract_user_moderation::ModerateContractUs use dash_sdk::platform::transition::put_contract::PutContract; use std::collections::BTreeMap; use wasm_bindgen::prelude::*; +use wasm_dpp2::data_contract::document::DocumentWasm; use wasm_dpp2::data_contract::{ - moderation_action_from_parts, ContractUserModerationActionParts, DataContractWasm, + moderation_action_from_parts, ContractModerationReasonInput, ContractUserModerationActionParts, + DataContractWasm, }; use wasm_dpp2::identity::{IdentityPublicKeyWasm, IdentityWasm}; use wasm_dpp2::utils::try_from_options_optional; @@ -255,9 +257,10 @@ impl WasmSdk { const CONTRACT_MODERATION_OPTIONS_TS: &'static str = r#" /** * Options for moderating one identity on a moderated data contract (protocol version 14): all - * an unban and an unsuspend take, and the base of the options of a ban and a suspend. The signer - * must hold a CRITICAL authentication key without contract bounds of the moderating identity: - * the contract owner, or a moderator the contract's config names. + * an unban, an unsuspend and a clearWarnings take, and the base of the options of a ban, a + * suspend and a warn. The signer must hold a CRITICAL authentication key without contract + * bounds of the moderating identity: the contract owner, or a moderator the contract's config + * names. */ export interface ContractModerationOptions { /** The moderating identity: the contract owner or a named moderator */ @@ -286,12 +289,18 @@ export interface ContractSuspendOptions extends ContractModerationOptions { reason: ContractModerationReason; } +/** Options for warning an identity: a warning needs a reason. */ +export interface ContractWarnOptions extends ContractModerationOptions { + /** Why. Stored with the warning, so anyone reading the list reads it. */ + reason: ContractModerationReason; +} + /** * The moderated identity's status on the lists the moderation touched, as its proof shows it. - * A ban proves every list the contract keeps (it removes a suspension too); an unban, a - * suspend and an unsuspend prove the one list they edit and say nothing about the other, so - * after an unsuspend `banned` is undefined (unknown), not false. Use - * `getContractModerationStatus` for the identity's whole status. + * A ban proves every barring list the contract keeps (it removes a suspension too); an unban, + * a suspend, an unsuspend, a warn and a clearWarnings prove the one list they edit and say + * nothing about the others, so after an unsuspend `banned` is undefined (unknown), not false. + * Use `getContractModerationStatus` for the identity's whole status. */ export interface ContractModerationResult { contractId: Identifier; @@ -309,6 +318,11 @@ export interface ContractModerationResult { suspendedUntil?: bigint; /** When the identity is suspended: why */ suspensionReason?: ContractModerationReason; + /** + * When `lists` includes `warnings`: the identity's warnings, oldest first, an empty array + * after a clearWarnings + */ + warnings?: ContractWarning[]; } /** @@ -341,8 +355,10 @@ export interface ContractDeleteDocumentOptions { } /** - * The record a document deletion left under the contract, as its proof shows it. The document - * itself is gone, its owner gets no storage refund, and nothing ever deletes the record. Use + * The record a document deletion left under the contract, as its proof shows it, or the same + * record as a restore marked it. After a deletion the document is gone, its owner gets no + * storage refund, and nothing ever deletes the record; the record holds a hash of the + * document, so keep the document if the deletion may have to be undone. Use * `getContractDocumentRemovals` to read the records later. */ export interface ContractDocumentRemovalResult { @@ -357,6 +373,45 @@ export interface ContractDocumentRemovalResult { reason: ContractModerationReason; /** The time of the block that removed the document, in milliseconds */ removedAt: bigint; + /** + * A double SHA-256 of the document as it was serialized under its type when it was removed, + * as 64 hex characters: what a restore must bring back byte for byte + */ + documentHash: string; + /** The contract owner or moderator that restored the document; absent while the removal stands */ + restoredBy?: Identifier; + /** The time of the block that restored it, in milliseconds; absent while the removal stands */ + restoredAt?: bigint; +} + +/** + * Options for restoring, as a moderator, one document a moderator deleted (protocol version + * 14): the document as it was, within a week of its deletion. Any current moderator or the + * contract owner may restore, whoever deleted. The document goes back through an ordinary + * insert, so a unique index value another document took meanwhile refuses it (40105); a + * document with no removal record (41119), one restored already (41122), a restore past the + * week (41120) or a document that is not the one deleted (41121) are refused too. The signer + * pays for the document's storage; the refund of a later deletion stays its owner's. As for + * the other moderations, the signer must hold a CRITICAL authentication key without contract + * bounds of the moderating identity. + */ +export interface ContractRestoreDocumentOptions { + /** The moderating identity: the contract owner or a named moderator */ + identity: Identity; + /** The moderated contract */ + contractId: IdentifierLike; + /** The document type of the document */ + documentTypeName: string; + /** + * The document to bring back, as it was when it was deleted: the document as fetched before + * the deletion. It is serialized under the contract's current document type and must hash + * to what its removal record holds. + */ + document: Document; + /** Signer holding a CRITICAL authentication key without contract bounds of the identity */ + signer: IdentitySigner; + /** Optional broadcast settings */ + settings?: PutSettings; } "#; @@ -371,6 +426,9 @@ extern "C" { #[wasm_bindgen(typescript_type = "ContractSuspendOptions")] pub type ContractSuspendOptionsJs; + #[wasm_bindgen(typescript_type = "ContractWarnOptions")] + pub type ContractWarnOptionsJs; + #[wasm_bindgen(typescript_type = "ContractModerationResult")] pub type ContractModerationResultJs; @@ -379,6 +437,9 @@ extern "C" { #[wasm_bindgen(typescript_type = "ContractDocumentRemovalResult")] pub type ContractDocumentRemovalResultJs; + + #[wasm_bindgen(typescript_type = "ContractRestoreDocumentOptions")] + pub type ContractRestoreDocumentOptionsJs; } #[derive(serde::Deserialize)] @@ -387,7 +448,7 @@ struct ContractModerationOptionsInput { #[serde(default)] until: Option, #[serde(default)] - reason: Option, + reason: Option, } #[derive(serde::Deserialize)] @@ -395,7 +456,13 @@ struct ContractModerationOptionsInput { struct ContractDeleteDocumentOptionsInput { document_type_name: String, #[serde(default)] - reason: Option, + reason: Option, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct ContractRestoreDocumentOptionsInput { + document_type_name: String, } impl WasmSdk { @@ -426,16 +493,16 @@ impl WasmSdk { ContractUserModerationActionParts { identity_id: Some(identity_id), until: parsed.until, - reason: parsed.reason, + reason: parsed.reason.map(Into::into), ..Default::default() }, ) .map_err(|e| WasmSdkError::invalid_argument(e.to_string()))?; - // The proof of a ban covers every list the contract keeps, which the verifier reads - // from the contract, so the contract is resolved and cached before anything is paid - // for; a cold cache would otherwise refuse a result the network already accepted. The - // other actions prove the one entry they edit and need no contract. + // The proof of a ban covers every barring list the contract keeps, which the verifier + // reads from the contract, so the contract is resolved and cached before anything is + // paid for; a cold cache would otherwise refuse a result the network already accepted. + // The other actions prove the one entry they edit and need no contract. if matches!(action, ContractUserModerationAction::Ban { .. }) { self.get_or_fetch_contract(contract_id).await?; } @@ -526,6 +593,34 @@ impl WasmSdk { self.moderate_contract_user(options, "unsuspend").await } + /// Warns an identity on a moderated contract: adds a warning, stamped with the block + /// time, to the warnings it carries. A warning bars nothing; the warnings accumulate, at + /// most 16 at a time, until they are cleared. + /// + /// @param options - The moderating identity, the contract, the target, the `reason` and the signer + /// @returns The target's warnings on the contract, proved + #[wasm_bindgen(js_name = "contractWarnUser")] + pub async fn contract_warn_user( + &self, + options: ContractWarnOptionsJs, + ) -> Result { + self.moderate_contract_user(options.unchecked_into(), "warn") + .await + } + + /// Takes an identity off a moderated contract's warning list: every warning it carries + /// goes. + /// + /// @param options - The moderating identity, the contract, the target and the signer + /// @returns The target's status on the contract, proved: no warnings + #[wasm_bindgen(js_name = "contractClearUserWarnings")] + pub async fn contract_clear_user_warnings( + &self, + options: ContractModerationOptionsJs, + ) -> Result { + self.moderate_contract_user(options, "clearWarnings").await + } + /// Deletes one document on a moderated contract as a moderator, whoever owns it, except /// the contract owner and the moderators. The document type must set /// `canBeDeletedByModerators`. The document's owner gets no storage refund, and a record @@ -564,7 +659,7 @@ impl WasmSdk { parsed.document_type_name.clone(), document_id, // Both parts of a deletion's reason are optional, and so is the reason itself. - parsed.reason.unwrap_or_default(), + parsed.reason.map(Into::into).unwrap_or_default(), None, signer, settings, @@ -587,6 +682,69 @@ impl WasmSdk { set_removal_fields(&result, &removal, |id| IdentifierWasm::from(id).into())?; Ok(JsValue::from(result).into()) } + + /// Restores, as a moderator, one document a moderator deleted: the document as it was, + /// within a week of its deletion. The document goes back through an ordinary insert, and + /// its removal record stays, marked restored. The signer pays for the document's storage; + /// the refund of a later deletion stays its owner's. + /// + /// @param options - The moderating identity, the contract, the document type, the document as it was, and the signer + /// @returns The record of the deletion, now marked restored, proved + #[wasm_bindgen(js_name = "contractRestoreDocument")] + pub async fn contract_restore_document( + &self, + options: ContractRestoreDocumentOptionsJs, + ) -> Result { + // Extract complex types first (borrows &options) + let identity: Identity = IdentityWasm::try_from_options(&options, "identity")?.into(); + let contract_id: Identifier = + IdentifierWasm::try_from_options(&options, "contractId")?.into(); + let document: Document = DocumentWasm::try_from_options(&options, "document")?.into(); + let signer = IdentitySignerWasm::try_from_options(&options, "signer")?; + let settings = + try_from_options_optional::(&options, "settings")?.map(Into::into); + + // Deserialize simple fields last (consumes options) + let parsed: ContractRestoreDocumentOptionsInput = deserialize_required_query( + options, + "Options object is required", + "contract document restore options", + )?; + + // The document is serialized under the contract's current document type, and the + // proof of the restore is verified by decoding it under the same, so the contract is + // fetched again, whatever copy is cached, before anything is paid for. The Rust SDK + // registers what it is given with its context provider, but that does not reach the + // trusted context of this SDK. + let contract = self.refresh_contract(contract_id).await?; + let document_id = document.id(); + let removal = identity + .restore_contract_document( + self.inner_sdk(), + &contract, + parsed.document_type_name.clone(), + &document, + None, + signer, + settings, + ) + .await?; + + let result = js_sys::Object::new(); + let set = |key: &str, value: JsValue| { + js_sys::Reflect::set(&result, &key.into(), &value).map_err(|_| { + WasmSdkError::generic(format!("failed to set `{key}` on the removal result")) + }) + }; + set("contractId", IdentifierWasm::from(contract_id).into())?; + set( + "documentTypeName", + JsValue::from_str(&parsed.document_type_name), + )?; + set("documentId", IdentifierWasm::from(document_id).into())?; + set_removal_fields(&result, &removal, |id| IdentifierWasm::from(id).into())?; + Ok(JsValue::from(result).into()) + } } // ============================================================================ diff --git a/packages/wasm-sdk/src/state_transitions/document.rs b/packages/wasm-sdk/src/state_transitions/document.rs index 97bab5102fb..28abdff81ed 100644 --- a/packages/wasm-sdk/src/state_transitions/document.rs +++ b/packages/wasm-sdk/src/state_transitions/document.rs @@ -7,10 +7,9 @@ use crate::sdk::WasmSdk; use crate::settings::PutSettingsInput; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::document_type::DocumentType; -use dash_sdk::dpp::document::{Document, DocumentV0Getters}; +use dash_sdk::dpp::document::{Document, DocumentV0Getters, DocumentV0Setters}; use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::identity::IdentityPublicKey; -use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::platform_value::Identifier; use dash_sdk::dpp::tokens::token_payment_info::TokenPaymentInfo; use dash_sdk::platform::documents::transitions::DocumentDeleteTransitionBuilder; @@ -28,8 +27,8 @@ use wasm_dpp2::state_transitions::batch::token_payment_info::{ TokenPaymentInfoOptionsJs, TokenPaymentInfoWasm, }; use wasm_dpp2::utils::{ - get_class_type, try_from_options_optional, try_from_options_with, try_to_string, try_to_u64, - IntoWasm, + get_class_type, try_from_options_mut, try_from_options_optional, try_from_options_with, + try_to_string, try_to_u64, IntoWasm, }; use wasm_dpp2::IdentitySignerWasm; @@ -229,13 +228,13 @@ impl WasmSdk { // that document too, so code that keeps using it (to replace, // transfer or delete what it just created) addresses the document // Platform stored. Best effort: the returned document is the - // authoritative one. - if let Ok(caller_document) = Reflect::get(&options, &JsValue::from_str("document")) { - let _ = Reflect::set( - &caller_document, - &JsValue::from_str("id"), - &JsValue::from_str(&confirmed_document.id().to_string(Encoding::Base58)), - ); + // authoritative one, and a caller that freed its document meanwhile + // is skipped. (A document still borrowed elsewhere would throw here + // rather than no-op; nothing re-enters wasm during the await.) + if let Ok(mut caller_document) = + try_from_options_mut::(&options, "document", "Document") + { + caller_document.inner_mut().set_id(confirmed_document.id()); } Ok(DocumentWasm::new( 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"