Skip to content

Commit b40f44c

Browse files
docs(sdk): document dash-platform-cxx and wire it into Rust CI
The README states the trust model (pushed quorum keys and ChainLock anchor, chain-id check, shell watermark, protocol-version signal, proven absence, refusal versus outage, panic containment), summarizes the bridge API by group, and fixes the threading and signing contract (blocking reads on the embedder's worker, builders on the calling thread, SignForKey over the signable preimage with the first-byte variant index, SignAssetLockSighash as the one digest path). CI runs the crate's tests with nextest, links tests/cxx_smoke.cc from the staged headers and archive, and asserts the embedder's trust closure: no trusted-context-provider, reqwest, openssl-sys or native-tls in the dependency tree and no seed-list or unproved entry points in the sources. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent ad7c6df commit b40f44c

2 files changed

Lines changed: 192 additions & 0 deletions

File tree

‎.github/workflows/tests-rs-workspace.yml‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,31 @@ jobs:
255255
done
256256
done
257257
258+
# The C++ embedder (Dash Core's platform GUI) links dash-platform-cxx
259+
# as a static archive whose trust anchor must be the embedder's own
260+
# LLMQ store and masternode list: no trusted-provider crate, no HTTP
261+
# client, no OpenSSL, no default seed list may enter its closure or
262+
# its sources. (It wraps the full dash-sdk, networking included, so
263+
# it is deliberately absent from the transport-free cuts above.)
264+
- name: Check the Platform CXX embedder's trust closure
265+
run: |
266+
for banned in rs-sdk-trusted-context-provider reqwest openssl-sys native-tls; do
267+
if cargo tree -p dash-platform-cxx -e normal -i "$banned" 2>/dev/null | grep -q .; then
268+
echo "::error::$banned leaked into dash-platform-cxx's dependency tree"
269+
exit 1
270+
fi
271+
done
272+
if grep -rnE 'new_mainnet|new_testnet|fetch_unproved|dash_network_seeds' \
273+
packages/rs-platform-cxx/src packages/rs-platform-cxx/include; then
274+
echo "::error::dash-platform-cxx must build its SDK from the embedder's endpoints only"
275+
exit 1
276+
fi
277+
278+
# The C++ embedding surface must link and run from the staged headers
279+
# and archive alone; the Rust tests cannot see a broken header layout.
280+
- name: Link the Platform CXX embedder from C++
281+
run: packages/rs-platform-cxx/scripts/cxx-smoke.sh
282+
258283
- name: Detect immutable structure changes
259284
if: github.event_name == 'pull_request'
260285
run: |
@@ -442,6 +467,7 @@ jobs:
442467
--package rs-sdk-ffi \
443468
--package platform-wallet-ffi \
444469
--package rs-dapi-client \
470+
--package dash-platform-cxx \
445471
--package platform-serialization \
446472
--package dapi-grpc \
447473
--package json-schema-compatibility-validator \

‎packages/rs-platform-cxx/README.md‎

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# dash-platform-cxx
2+
3+
Dash Platform for C++ embedders, as a thin [`cxx`](https://cxx.rs) shell over
4+
`dash-sdk`. The embedder supplies what only it knows: the evonode endpoints
5+
from its masternode list, the Platform quorum keys from its LLMQ store, its
6+
best ChainLock height, and signatures from its wallet. `dash-sdk` supplies
7+
everything else: query construction, transport, retries with address
8+
banning, proof verification, the signed-time window, protocol-version
9+
tracking, contact-request minting. Dash Core's platform GUI is the first
10+
consumer.
11+
12+
## Trust model
13+
14+
Every response byte comes from an untrusted node; nothing in the closure
15+
fetches from a trusted third-party service (no `rs-sdk-trusted-context-provider`,
16+
no HTTP client, no default seed list; CI asserts it). Trust rests on inputs
17+
the embedder pushes:
18+
19+
- **Quorum keys** (`set_quorum_keys`, the full active set, hashes in the
20+
embedder's internal byte order). The provider (`src/provider.rs`) hands the
21+
verifier a key only if the proof names the network's Platform LLMQ type and
22+
a pushed hash; anything else fails signature verification.
23+
- **ChainLock anchor** (`set_chainlock_height`, monotonic). Until the first
24+
push no proved read is dispatched (`Unavailable`). A proof whose signed
25+
core-chain-locked height trails the anchor by more than 288 blocks is
26+
refused as stale; there is no ceiling, since a node one ChainLock ahead of
27+
the embedder is honest.
28+
- **Chain id** (`Config.tenderdash_chain_id`). After the SDK verified the
29+
quorum signature and its signed-time window, the shell (`src/ops.rs`)
30+
compares the signed `chain_id` (`ChainIdMismatch`), then applies its own
31+
monotonic Platform-height watermark (tolerance 3 blocks, `Rejected`), then
32+
records the verified protocol version the builders use, then flags a
33+
`protocol_version` above what this build knows
34+
(`UnsupportedProtocolVersion`, the value is still returned). The SDK's own
35+
height watermark is off and the shell keys its watermark and its verified
36+
version after the chain-id check, so a validly signed proof from another
37+
chain moves nothing the shell decides on (the SDK's internal version
38+
ratchet, which runs inside verification, may move upward on it; nothing
39+
reads that for building).
40+
41+
Absence is proven, never inferred: `ProvenAbsent` comes only after the SDK
42+
verified a proof of it, and carries the same verified metadata as a value
43+
(also under an unsupported protocol version, which `meta` then shows).
44+
Every failure is classified by error variant into `Status.kind`, never by
45+
message text: a node that definitively refuses a request (a gRPC code the
46+
SDK would not retry, including a response above the 4 MiB decoding bound)
47+
is `Rejected`; no answer at all is `Unavailable`. Every bridge entry point runs under `catch_unwind`, so a panic
48+
on hostile input is a `rust::Error` or an `Internal` status, never an
49+
abort; the crate refuses to build with `panic = "abort"`.
50+
51+
## API
52+
53+
Namespace `platform_ffi`; the full declaration is `src/lib.rs`, the
54+
generated header `dash/platform/ffi.h`.
55+
56+
- **Lifecycle**: `new_platform_client(Config{network, tenderdash_chain_id,
57+
platform_llmq_type})`, `set_endpoints(&[String])` (`https://` only),
58+
`set_quorum_keys`, `set_chainlock_height`, `shutdown()` (aborts the
59+
in-flight request, stops the runtime; idempotent, also run on drop).
60+
The SDK keeps a pooled connection per evonode it has talked to, and only
61+
dropping the SDK closes them: an empty endpoint set drops it (no socket
62+
stays open while the embedder has disabled networking), a set that
63+
removes endpoints rebuilds it over the updated list (retained entries
64+
keep their ban state), a set that only adds updates it in place. The
65+
quorum keys, the ChainLock anchor, the height watermark and the verified
66+
protocol version belong to the client and survive every rebuild.
67+
- **Proved reads**, one SDK request each, returning `Verified*{status, meta,
68+
value | items + page}`: `get_identity`, `get_identity_by_pubkey_hash`,
69+
`get_identity_contract_nonce` (masked to the 40-bit value; `ProvenAbsent`
70+
= the identity has not used the contract, i.e. 0), `resolve_name`,
71+
`search_names` (prefix of 1 to 63 normalized characters, limit clamped to
72+
1..=100), `names_of_identity`, `get_profile`, `get_contact_requests` (to
73+
or from an identity, after a time, oldest first),
74+
`get_contested_vote_state`. Paged reads return one page of up to their
75+
limit; `page.has_more` is set on a page that fills it (which may still be
76+
the last one) and `page.next_start_after` is the cursor for the next call
77+
(all zero = from the start). Queries use this build's latest compiled-in
78+
contracts, which decode every older document; the SDK's tracked version
79+
only matters for building. At protocol version 13 Drive answers a
80+
continuation of `names_of_identity` with an empty page, so only its first
81+
100 names are reachable there and a continuation is `Unavailable` rather
82+
than a last page.
83+
- **Broadcast**: `broadcast(&[u8]) -> BroadcastResult{status}`, advisory and
84+
typed: `Ok`, `AlreadyExists`, `Consensus` with `consensus_code`, `Rejected`
85+
(a definitive non-consensus refusal), `Unavailable` (no answer). Every
86+
write is confirmed by a proved re-query.
87+
- **Builders**, no network, returning `Built{bytes, hash, object_id}`:
88+
`build_identity_create`, `build_dpns_preorder`, `build_dpns_domain` (the
89+
caller supplies and persists the salt), `build_profile` (create, or
90+
replace the `Profile` a `get_profile` returned at its revision + 1: a
91+
replace carries the whole document, so the avatar and payment-address
92+
fields another wallet set are carried over unedited),
93+
`build_contact_request` (minted by `Sdk::create_contact_request` with the
94+
embedder's ECDH secret). A create carries the document id
95+
`dash-sdk`'s `put_to_platform` would derive at the network's version
96+
(from the entropy alone up to protocol version 13, also from the identity
97+
contract nonce from 14), and a property the network's contract does not
98+
have yet (DashPay's payment addresses before 14) is refused before
99+
anything is signed. Transition rules change across protocol
100+
versions, so every builder (and `contested_vote_fund_credits`) needs the
101+
version a verified read has shown the network to run: before the first
102+
read the shell accepted they fail with a `rust::Error`, except on a
103+
devnet, whose floor is the latest version (regtest's is not: one read
104+
first). Once a read has shown a version this build does not know, they
105+
fail too, until the embedder is updated. The version only moves up, on
106+
each accepted read; the nonce read right before a build refreshes it.
107+
- **Pure helpers**: `normalize_label`, `is_valid_username`,
108+
`is_contested_username`, `credits_per_duff`, `system_contract_id`, and the
109+
DIP-15 pieces that need only 32-byte inputs: `dip15_decrypt_xpub`,
110+
`dip15_account_reference_from_mac`,
111+
`dip15_unmask_account_reference_from_mac`, `dip15_select_recipient_key`,
112+
`dip15_receive_keys_acceptable`. The infallible ones answer `false`, `0`
113+
or empty on a (contained) panic, which reads as a refusal.
114+
115+
`Status.kind` is one of `Ok, ProvenAbsent, AlreadyExists, Consensus,
116+
Unavailable, Rejected, ChainIdMismatch, UnsupportedProtocolVersion,
117+
Internal`; a `Verified*` value is meaningful only under `Ok` and
118+
`UnsupportedProtocolVersion`.
119+
120+
## Threading and signing
121+
122+
- Reads and the broadcast block the calling thread until the SDK request
123+
completes or `shutdown` aborts it; the embedder serializes them on its own
124+
worker. One tokio runtime (`src/runtime.rs`, two worker threads with
125+
16 MiB stacks for GroveDB proof replay) is owned per client. No single
126+
call issues more than one SDK request.
127+
- Builders run to completion on the calling thread: dpp's async builders are
128+
driven by a local executor, no runtime is entered, and the `WalletSigner`
129+
is only ever invoked on the thread that called the builder (tested). The
130+
embedder's `WalletSigner` (`include/dash/platform/signer.h`) must
131+
nonetheless be callable from any thread (take the wallet's own lock, no
132+
thread-local state); that contract, stated in the header, is what its
133+
`Send + Sync` impls rest on.
134+
- `SignForKey(key_id, signable)` receives the full signable preimage of the
135+
transition, so the wallet hashes it (double SHA256) itself and can check
136+
what it signs; the first byte is the `StateTransition` variant index
137+
(`test_data/state_transition_first_byte.json`: 2 = batch, 3 = identity
138+
create). It answers with a 65-byte compact recoverable ECDSA signature,
139+
exactly `dashcore::signer::sign` over the raw key.
140+
`SignAssetLockSighash(sighash)` is the one digest path: the asset lock's
141+
outpoint key signs the 32-byte double SHA256 the builder computed; the
142+
public key is recovered from the signature (compressed-key header,
143+
31 + recovery id; anything else is refused), so it is never exported.
144+
Private keys never cross the FFI; the shell never derives keys.
145+
146+
## Building and testing
147+
148+
An ordinary workspace member: `cargo build -p dash-platform-cxx --release`.
149+
`build.rs` stages `dash/platform/ffi.h`, `dash/platform/signer.h` and
150+
`rust/cxx.h` under `target/<profile>/include/`; install that tree and
151+
`libdash_platform_cxx.a`, link with `-lpthread -lm` (`-ldl` on Linux,
152+
`-framework Security -framework CoreFoundation` on macOS for the rustls
153+
trust store). `scripts/cxx-smoke.sh` compiles and runs `tests/cxx_smoke.cc`
154+
against exactly that interface.
155+
156+
`cargo test -p dash-platform-cxx` runs the unit tests, `tests/replay.rs`
157+
(a real Drive state proved and BLS-signed by a test quorum, replayed through
158+
`dash-sdk`'s mock transport so the SDK runs the full GroveDB replay and
159+
signature check: the freshness matrix, absence, paging, broadcast
160+
classification) and `tests/builders.rs` (every builder byte for byte against
161+
dpp's in-process private-key constructions, the document ids and data Drive
162+
validates, signer refusals, the no-reactor invariant, and
163+
`test_data/state_transition_first_byte.json` against the transitions it
164+
builds; `UPDATE_TEST_VECTORS=1` rewrites that file). Both suites run every
165+
test at protocol version 13 (what testnet and mainnet run) and at this
166+
build's latest, the fixture state generated at each.

0 commit comments

Comments
 (0)