Skip to content

feat(platform): subscribe to committed state transitions matching document, address, identity, token and contract filters - #5283

Closed
PastaPastaPasta wants to merge 11 commits into
dashpay:v5.1-devfrom
PastaPastaPasta:feat/platform-subscriptions
Closed

PastaPastaPasta wants to merge 11 commits into
dashpay:v5.1-devfrom
PastaPastaPasta:feat/platform-subscriptions

Conversation

@PastaPastaPasta

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

Closes #5274.

Applications that react to Platform activity poll for it today, for example Dash Forge's inbox, CI runners and webhook relay. A wallet watching its addresses and a token payment gateway waiting for a transfer do the same. Polling cost grows with users times feeds, runs into the gateway's per-IP limit, and notices changes late.

This adds subscribeToStateTransitions, a server-streaming Platform RPC. A client describes what it wants to hear about, and DAPI streams each committed, successfully executed state transition that matches. It covers the requests "tell me when this address gets funds", "when a document appears on this contract/type", "when a document matching this query is created", "when this document changes", "when this identity receives credits or tokens" and "when this contract is updated". A client resumes from a checkpoint without gaps.

The closed #2795 was an earlier attempt: drive-abci events for committed blocks and transaction hashes, with no entity filters and no resume. Drive already had DriveDocumentQueryFilter for this purpose (#2761, #2781, #5114), but nothing used it.

What was done?

Filters (dash_platform_queries::subscriptions, shared by DAPI and the SDK)

A request carries 1–16 filters. A transition is delivered when it matches any filter, and every constraint within one filter must hold.

  • documents: one data contract, optionally narrowed by:
    • a document type;
    • actions (create / replace / delete / transfer / update price / purchase), each with clauses on the new data, typed like getDocuments V1 where clauses;
    • $id of the document before the transition;
    • the transfer recipient or the buyer;
    • the new price;
    • the batch owner.
  • addresses, up to 256 platform addresses, and identities, up to 64, each on a side: SENDER, RECIPIENT or ANY. The roles are classified for every StateTransition variant in one exhaustive match, so a new variant won't compile until it is classified.
  • tokens: by token id and/or party.
  • data contracts: creation, updates, moderation and fee claims.

Matching reads only what a transition says, so some filters are refused up front rather than accepted and then silently never matching. A clause on the document as it was before the transition is undecidable unless it is on $id. The exception is the delete of an indexOnly document, which carries the document's values. The endpoint doc lists the remaining limits: requested vs credited amounts, mints to the configured destination, shielded parties, and group actions that are proposed but not yet executed.

Drive filter fixes (rs-drive/src/query/filter.rs)

The filter had no consumer and two bugs that made valid filters silently never match:

  • Different encodings of the same value never compared equal. Two examples:

    • an identifier sent as bytes vs base58 vs Identifier;
    • a u8 field sent as U64.

    Clause values and the transition values a clause reads are now both brought to the form the schema stores them in. Long values still compare, because the per-type codec is used directly instead of the index-key path, which caps values at 255 bytes.

  • Clauses on $ system fields other than $id passed validate() but can never match, because transition data has no $ fields. They are now rejected.

DAPI (rs-dapi/.../subscribe_to_state_transitions)

  • Served from the Tenderdash block store, not drive-abci. Each subscription walks committed heights in order with its own cursor. History and new blocks are read the same way. New-block websocket events only wake the tip tracker, so a dropped event delays a block but cannot lose it. No consensus code changes; Drive answers the RPC with unimplemented.
  • Never skips a height:
    • A block stored before its results are saved is retried.
    • blockchain returns only the 20 highest heights of a range, so pages are requested 20 at a time and checked complete.
    • A transaction this build cannot decode, or an unknown protocol version, ends the stream at that height instead of advancing past it.
  • Stream contract:
    • The first message is a checkpoint at start − 1.
    • Matches are sent in block order, and a checkpoint follows every block with a match.
    • A checkpoint repeats every 10 s otherwise, which keeps quiet streams inside proxy idle timeouts.
    • A checkpoint at h means everything from the first scanned block through h has been sent; resume from h + 1.
  • Cost:
    • Reads are single-flight: concurrent misses on one block or metas page share one Tenderdash call, and a block is decoded once for all subscribers.
    • The block cache is bounded by bytes.
  • Limits:
    • 1024 subscriptions per node.
    • 16 per client address (the last X-Forwarded-For entry; IPv6 per /64).
    • 8 subscriptions catching up on history at once. The permit is held only while reading, so a client that stops reading cannot keep it.
    • Start at most 50k blocks behind or 1k ahead of the tip.
    • A client that does not read for 60 s is dropped with RESOURCE_EXHAUSTED.
  • Contract updates: a data contract update in the stream rebinds the document filters on that contract.

SDK (rs-sdk/src/platform/subscriptions.rs)

Sdk::subscribe_to_state_transitions(filters, from_block_height) returns a subscription with next() / into_stream().

  • Checks what the node sends. It verifies each transition's hash and re-matches it against the filters, bound to data contracts fetched with proofs. Transitions that don't match are skipped and logged.
  • Resumes by itself from the last checkpoint, possibly on another node, with backoff. Delivery is at least once.
  • Follows updates of the contracts it filters on, so it matches against the same contract version as the node.

Wiring

  • Proto and regenerated clients.
  • build.rs and the metrics allowlist.
  • An Envoy route with the Core streams' timeouts. It replaces the route for subscribePlatformEvents, which has no RPC.
  • The endpoint doc packages/dapi/doc/endpoints/streams/subscribeToStateTransitions.md.

Trust model (stated in the docs)

A client can check every delivered transition: its hash, and that it matches its filters against proved contracts. A client cannot check completeness: a node may leave a match out, and block heights and times are the node's word. Act on an event by reading the changed state with a proved query, and catch up from a proved read's metadata.height + 1.

Design process

  • Four codebase explorations, then two rounds of design review by three independent reviewers. The reviewers were two Claude agents, one checking protocol correctness against the Tenderdash source and one checking codebase fit, plus a Codex GPT-6.1 reviewer for API and product.
  • An independent code review afterwards. Every finding above MINOR was fixed. Two findings in that category:
    • one client could hold the catch-up capacity;
    • every subscriber issued its own Tenderdash calls per block.

Not in this PR

  • wasm-sdk / js-evo-sdk bindings, an async iterator for browsers. The matcher and the Rust SDK already build for wasm32, and rs-dapi-client's grpc-web transport supports server streaming. The JS surface is a follow-up.
  • Firehose filters, all transitions of a kind. Explorers are better served by running indexers.

How Has This Been Tested?

  • cargo test -p drive --lib query::filter: 67 passed. New tests cover the identifier encodings, long values, rejected system fields and recipients given as bytes.
  • cargo test -p dash-platform-queries --lib subscriptions: 12 passed. They cover:
    • roles for identities and addresses;
    • batch attribution across document and token transitions;
    • $id original-document matching;
    • rejected undecidable clauses;
    • limits;
    • the wire round-trip;
    • rebinding.
  • cargo test -p rs-dapi --lib: 344 passed. The 21 new tests run the full scan loop against an in-memory chain:
    • replay across pages, then live blocks, without gaps;
    • starting live;
    • checkpoints while idle;
    • start bounds;
    • per-address limits and IPv6 /64 bucketing;
    • a block whose results arrive late;
    • a client that stops reading must not starve another's catch-up;
    • an undecodable transaction ends the stream;
    • meta page validation, including heights below the store base and header-only metas.
  • cargo test -p dash-sdk --lib subscriptions. The new network-only test tests/fetch/state_transition_subscriptions.rs compiles under network-testing but has not been run against a live network.
  • cargo clippy is clean for every touched crate. dash-sdk checks for wasm32-unknown-unknown. The dashmate Envoy template test passes. check-grpc-coverage.py passes.
  • Not done: an end-to-end run on a local dashmate devnet.

Breaking Changes

None. The RPC and messages are new. The drive filter changes affect only DriveDocumentQueryFilter, which nothing else uses and which is not consensus code; no shipped generation changes behaviour.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed
  • If I added or changed GroveDB structure, I described it in the area's structure.rs, regenerated grovedb-structure.json, and checked the structure viewer link posted on this pull request

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Generated with Claude Code

PastaPastaPasta and others added 11 commits October 4, 2026 23:57
…system-field clauses

`DriveDocumentQueryFilter` compared clause values and transition data with
`Value`'s derived equality, so the same value in two accepted encodings never
matched: an identifier given as bytes or base58 text against
`Value::Identifier`, a `u8` property sent as `U64`. Values a clause reads
are now brought to the form the document type stores before comparing, and
`canonicalize_clause_values` does the same once for the clause operands
(element-wise for IN and BETWEEN*, identifiers for the recipient/buyer
clause, `U64` for the price clause). Schema properties go through their
property type's codec directly, so values longer than an index key still
compare.

`validate()` now rejects clauses on `$`-prefixed system fields other than the
primary-key `$id` clauses: document data carries no such fields, so those
clauses could never match.

The filter has no consumer yet, so neither change affects consensus.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A server-streaming Platform RPC that delivers committed, successfully
executed state transitions matching any of a request's filters: document
transitions on a contract (narrowed by type, action, where clauses on the new
data, `$id` of the original, recipient/buyer, price, batch owner), platform
addresses, identities and tokens on a chosen side (sender/recipient/any),
and data contracts. Clauses reuse the getDocuments V1 typed WhereClause.

The stream sends each matching transition with its block height, time,
protocol version, position, hash and bytes, and checkpoints that tell the
client where to resume (`from_block_height`).

Drive answers it with `unimplemented`: DAPI serves it from the Tenderdash
block store. The Platform module now allows the camel-case stream type tonic
generates, as the Core module already does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`dash_platform_queries::subscriptions` holds the transport-free filter
model, its wire conversion and the matcher, so DAPI (which evaluates the
filters) and clients (which re-check what DAPI sends) run the same code:

- `StateTransitionFilter` and builders, `subscribe_request`;
- `ResolvedFilters::resolve` validates limits and binds document filters to
  their contracts through `DriveDocumentQueryFilter`, rejecting clauses that
  cannot be decided from a transition (original-document clauses other than
  `$id`, except an indexOnly delete);
- `ResolvedFilters::matches` reports the matched filters and, for batches,
  the matched inner transition positions;
- every `StateTransition` variant is classified by the parties it names, so
  a new variant does not compile until it is.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ck store

Each subscription walks committed blocks in height order with its own
cursor, reading history and new blocks the same way through a shared,
byte-bounded block cache; new-block websocket events only wake the tip
tracker, so a dropped event delays a block but cannot lose it.

- Heights are never skipped: a block whose results are not saved yet is
  retried, `blockchain` pages are requested 20 heights at a time and checked
  complete, and an undecodable transaction or unknown protocol version ends
  the stream at that height instead of advancing past it.
- A checkpoint follows every block with a match and repeats every 10s, so
  clients can resume and quiet streams outlive proxy idle timeouts.
- Limits: 1024 subscriptions per node, 16 per client address (last
  X-Forwarded-For entry; IPv6 per /64), 8 concurrent catch-ups, starts at
  most 50k blocks behind or 1k ahead of the tip, and a client that does not
  read for 60s is dropped with RESOURCE_EXHAUSTED.
- A data contract update in the stream rebinds the document filters on that
  contract.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`Sdk::subscribe_to_state_transitions(filters, from_block_height)` returns a
`StateTransitionSubscription` (`next()`, or `into_stream()`) yielding
matching transitions and checkpoints.

- Every transition the node sends is checked: its hash, and a re-match
  against the filters bound to data contracts fetched with proofs; a
  transition that does not match is skipped and logged.
- Broken streams resume from the last checkpoint, possibly on another node,
  with backoff; only progress clears the failure count, so a node failing
  right after every start is given up on after five attempts. Delivery is at
  least once.
- A data contract update in the stream rebinds the local filters, as on the
  node.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Gives the stream the Core streams' timeouts (300s idle, 600s lifetime) and
documents the endpoint. Replaces the route of `subscribePlatformEvents`, an
RPC that does not exist.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…by slow clients

- Reads are single-flight: concurrent misses on one block or one metas page
  share a Tenderdash call, short pages at the tip are cached by their exact
  range, and a block's transactions are decoded once for every subscriber.
- The replay permit covers only reads from Tenderdash and is released
  before anything is sent, so a client that stops reading cannot hold the
  node's catch-up capacity.
- A scan stops as soon as its client goes away, including while waiting for
  a block's results; start-height bounds use saturating arithmetic.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…suming

- The subscription also asks for updates of the contracts its document
  filters name and rebinds its local filters on them, as the node does, so
  the two never check against different contract versions; those updates are
  not reported unless the caller asked for them.
- A stream that ends cleanly counts as a failure with backoff, and only a
  stream that got past its opening checkpoint clears the failure count, so a
  node that keeps ending streams cannot cause a tight reconnect loop.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ResolvedFilters::follow replaces the identical rebinding the node and the
client each carried; drops unused len/is_empty.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@thepastaclaw

thepastaclaw commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

🕓 Review not started yet because this PR is a draft.

  • Request normal review — click when the PR is ready for review.
  • Request priority review — click to move this review to the front of the queue.

Commit 5681b07. Normal review starts when eligible; priority review starts as soon as a slot is available.

@PastaPastaPasta

Copy link
Copy Markdown
Member Author

Superseded by #5285: same branch and commits, opened from the dashpay/platform repository so CI runs. Closing this fork-based PR.


🤖 Posted autonomously by Codex on behalf of pasta.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants