sdk: migrate serviceability commands to RFC-26 instruction builders (R10) - #4060
Conversation
2194abb to
24c23fe
Compare
70541a5 to
57fe622
Compare
24c23fe to
ad8b455
Compare
57fe622 to
7ab5da2
Compare
ad8b455 to
c0250bc
Compare
92e417d to
b0a7627
Compare
ef762c5 to
da6a150
Compare
b0a7627 to
ebe5e54
Compare
da6a150 to
44f5220
Compare
ben-dz
left a comment
There was a problem hiding this comment.
Two things should change before this merges.
The Permission-PDA drop needs release gating. With the builder-side append deferred and client.rs's resolve/cache/retry deleted, every migrated instruction takes the None branch of authorize(). A payer whose only grant is an activated Permission account — not on foundation_allowlist/qa_allowlist, not an authority key — starts getting NotAllowed on every gated command the moment this ships. Please confirm zero such grants exist on devnet/testnet/mainnet-beta, and note that doublezero permission audit does not answer this (it reports the strict-mode direction only). The entry also belongs in the ### Breaking section this Unreleased block already has, following the feed_pk → feed_pks precedent.
The composed cascade tests lost their exact-instruction assertions, opposite to what the Testing Verification section says: predicate::always() went 0 → 6 in user/delete.rs and 3 → 7 in tenant/delete.rs. That is the one path in this migration that is more than mechanical, and it is now unasserted.
Also worth addressing: send_transaction_quiet has no consumer left (the activator was deleted from the workspace), the clear_topology program test skips the writable path the builder's divergence exists for and its comment states the opposite, and three device commands traded an eyre error for a release-build panic on over-long input.
The mechanical migration itself holds up — tracing the risky layouts found no swapped pubkeys, dropped accounts, or writability drift beyond the documented clear_topology divergence.
Findings not anchored to the current diff:
rfcs/rfc26-rust-instruction-builder-library.md:176— low: "The permission cache (note_transaction_sent/invalidation) stays" describes a subsystem this PR deletes, and contradicts the Open Questions bullet updated at line 245. One-sentence fix.
|
Thanks — all seven findings held up under checking, including the two blocking ones. Addressed in d807db7 and a691c80, with details inline on each thread. The Permission-PDA gate — verified against live chain stateYou were right that
Same key on both: On your second point: What still needs an owner, not code: whether Everything else
Green: Two judgement calls, flagged rather than buried:
|
ben-dz
left a comment
There was a problem hiding this comment.
lgtm — all seven earlier findings are addressed, and the one open question is answered.
On whether EWFXDTBWhNxsmj4mbahZL7HH1XpmC6Ao3LZUhJGCyyPh loses authorization through the Rust SDK: it does not, for the documented candidate. The shred oracle — which PERMISSION.md:119-122 describes as the holder of that ACCESS_PASS_ADMIN-only grant shape — never touches the SDK send path. crates/shred-oracle depends on doublezero_sdk only for data types and PDA helpers (Device, User, Exchange, get_accesspass_pda, get_permission_pda, …); there is no DoubleZeroClient, no commands/* usage, and no execute_*/send_transaction call anywhere in that repo. It assembles its serviceability instructions itself in crates/shred-oracle/src/dz_ledger.rs and appends its own Permission AccountMeta per instruction via permission_account_meta() (:264-286, applied at nine sites covering SetAccessPass, CloseAccessPass, SetAccessPassFeeds, AddMulticastGroupSubAllowlist, RemoveMulticastGroupSubAllowlist, CreateSubscribeUser, UpdateMulticastGroupRoles, and DeleteUser — the exact set the deploy note names). Its doublezero_sdk dependency is pinned by git rev, so the type surface does not move on merge either.
So the oracle keeps authorizing via its own append, independent of the SDK. Residual exposure would need an unidentified Rust-SDK consumer holding that key on testnet or devnet; the in-repo consumers are all operator-driven tools and mainnet-beta has zero exposure. No deploy blocker.
One note for whenever the deferred builder-side Permission append resumes: the oracle solved the offline-existence problem locally, so the builder will face the same constraint documented at crates/doublezero-serviceability-instruction/src/common.rs:80-86.
|
Thanks for tracing the oracle — that closes the one thing I couldn't settle from this repo. I verified your findings before encoding them, since the deploy note now asserts them:
Two follow-ups pushed in 769573c:
Still green after both: |
elitegreg
left a comment
There was a problem hiding this comment.
Reviewed the full stack tip. The migration itself holds up well: I hand-compared the builder call against the old hand-assembled account list for every non-mechanical command (link/update including the side-A/side-Z preamble split and the conditional side_a/side_z + resource-extension appends, device/delete on both the legacy and atomic paths, user/create, user/delete, tenant/delete, exchange/setdevice two-send sequencing, topology/clear, topology/assign_node_segments) and found no drift. The _quiet removal checks out: nothing in the workspace calls the removed trait methods, and geolocation/'s execute_transaction is a separate trait, untouched.
Verified locally:
cargo test -p doublezero_sdk --lib— 167 passedcargo test -p doublezero-serviceability --test rfc26_builders_test— 5 passedcargo check --workspace --all-targets— clean, no new warnings (thedoublezero_geolocation::entrypointfeature-unification errors reproduce identically onmain)
Four things to address before merge.
1. This PR fixes #4078 but does not say so
The builder passes the clear_topology topology PDA writable, which is exactly the fix #4078 asks for. That issue is open and describes a user-facing break: the processor asserts topology_account.is_writable whenever cleared_count > 0, so on main topology clear reverts with "Topology Account is not writable" on a live topology, which is the mandatory precursor to topology delete. After this PR it works.
Right now the only mention is a comment inside rfc26_builders_test.rs. Please add a ### Fixed CHANGELOG entry under SDK and a Fixes #4078 line in the PR body so the issue closes on merge. As written, a real bug fix ships invisibly and #4078 stays open pointing at a file that no longer has the bug.
2. Four allowlist tests lost their instruction assertion
multicastgroup/allowlist/publisher/add.rs, publisher/remove.rs, subscriber/add.rs, subscriber/remove.rs now read:
client
.expect_send_transaction()
.with(predicate::always())
.returning(|_| Ok(Signature::new_unique()));On main the same tests pinned the instruction and relaxed only the accounts:
.with(
predicate::eq(DoubleZeroInstruction::AddMulticastGroupPubAllowlist(
AddMulticastGroupPubAllowlistArgs { client_ip: ..., user_payer: pubkey },
)),
predicate::always(),
)So client_ip and user_payer were asserted before and are not now. The PR body's "predicate::always() across sdk/rs/src/commands/ is 4, all pre-existing on main" is accurate as a file count but understates this: the assertion strength dropped in all four, and these are the only migrated tests that assert nothing about what gets sent. One-line fix each, using the predicate::eq(add_multicast_group_pub_allowlist(...)) pattern the other 73 migrations already use.
3. A Permission grant becomes a silent no-op with nothing telling the operator
The exposure analysis in the CHANGELOG is thorough and I am not disputing the conclusion. The gap is forward-looking: once this lands, doublezero permission set produces a grant that cannot work through the CLI or Rust SDK, and nothing at runtime says so. The CHANGELOG itself notes permission audit answers only the inverse (strict-mode) direction, so the natural place an operator would look does not surface it either.
Suggest a tracing::warn! on permission set / permission update, or an added line in permission audit, stating that the Rust SDK does not attach Permission accounts yet and gated commands still require a legacy allowlist or authority entry. It can be removed when the builder-side append is re-enabled. The chain-state check is point-in-time by nature; this makes the next grant self-documenting instead of a debugging session.
4. Please rebase onto origin/main
RF (#4059) merged as 9c2ceb76, but this branch still carries its three unsquashed commits, so the PR renders as 115 files / +4548 −4751 when only ~96 files are actually new. I diffed the branch's RF content against merged main: byte-identical apart from the two intended builder-crate additions (set_access_pass_flags, the build_with_permission doc note), so the rebase is content-neutral and makes the diff match what needs reviewing.
Non-blocking notes
- Several commands pass args fields the builder immediately overwrites:
use_onchain_allocation: falseinlink/update,resource_count: 0indevice/create,dz_prefix_count/multicast_publisher_countinuser/delete,user/create,user/requestban. Correct, and documented on the builder side, but at the call site it reads as if the caller controls them. A short comment at each site would help the next reader. - Roughly 60 commands drop the
GetGlobalStateCommandround-trip, which is a real latency win, but they also lose the fast-fail "Globalstate not initialized" message in favor of an onchain error on an uninitialized cluster. Probably the right trade; flagging it as deliberate rather than incidental. device/deletehas nou8guard onowners.len()wheredevice/createand the two interface verbs got one, so it relies on the builder's.expect(...). Unreachable given the transaction account budget, and the builder comment says as much, so this is consistency rather than a bug.- Worth stating in the RFC or the test module doc: the migrated command tests are structurally tautological with respect to account layout.
predicate::eq(builder(...))catches wrong arguments but cannot catch a builder layout regression, since both sides call the same builder. That is by design, with the golden fixtures and the program test as the real net, but it means around 70 builders currently have no program-level coverage. A note keeps a future reader from over-trusting the 167 passing tests.
…R10) Delegate every serviceability commands/* execute() to the pure doublezero-serviceability-instruction builders and a new DoubleZeroClient::send_transaction (compute-budget prelude + sign + send), replacing the four execute_*(instruction, accounts) methods and the client-side account assembly. Command execute() signatures/returns are unchanged, so CLI/sentinel/daemon consumers are unaffected. Behavior change: the Rust SDK no longer attaches the payer's Permission PDA to serviceability transactions; authorize() uses the legacy GlobalState allowlist path until the builder-side Permission append is re-enabled per instruction (must be sequenced with the permission-model rollout). Adds a solana-program-test safety net that runs the highest-cardinality builders (create_device, create_link, create_subscribe_user, atomic delete_device, clear_topology) against the real program to catch account-order drift.
…r_topology writable coverage - Restore exact-instruction assertions in the composed cascade tests. `user/delete` and `tenant/delete` had traded `predicate::eq(builder(...))` for `predicate::always()` on every send (0 -> 6 and 3 -> 7), leaving the one non-mechanical path in the migration unasserted. Both files now assert the exact DeleteUser / SetAccessPass / UpdateMulticastGroupRoles / DeleteTenant instruction at each step, and the dead `let (_pda, _)` bindings left behind are gone. - Restore the u8 pre-flight guards dropped from `device/create`, `device/interface/create` and `device/interface/update`. The builders write `resource_count`/`topology_count` with `.expect(...)`, which panics in release; the commands now reject over-long input as an eyre error before reaching the builder. Added coverage for both interface guards. - Cover the `clear_topology` writable divergence. The program test only exercised `cleared_count == 0`, which skips the `is_writable` assert the builder's writable-topology break exists for, and its comment claimed the builder passes the topology read-only. Fixed the comment and added a `cleared_count > 0` case against the topology the link is tagged into; verified it fails with "Topology Account is not writable" if the builder is flipped to read-only. - Drop `send_transaction_quiet` and its `SimulationError`/`SimulationTransactionError` types. Their only caller was the `activator/` crate, deleted in #3647. - Move the Permission-PDA behavior change to the CHANGELOG's Breaking section, with the live per-cluster exposure check: strict mode is off everywhere, mainnet-beta's Permission holders are all covered by `foundation_allowlist`, and testnet/devnet each carry one ACCESS_PASS_ADMIN grant no legacy authority covers. - Fix rfc26 line 176, which described the permission cache as staying when this stack removes it, contradicting the Open Questions bullet.
Both re-exported constants carried a duplicated doc claiming "Solana caps transactions at 32 accounts", and clear.rs additionally claimed CLEAR_BATCH_SIZE was "the same constant as backfill" (16 vs 4). The builder crate owns these constants and the real headroom math (64-account lock limit, ~1232-byte packet size), so point at it instead of restating it wrongly.
…nd precedent The CHANGELOG breaking note asked the reader to confirm, before shipping, that EWFXDTBW... does not drive the access-pass instructions through the Rust SDK. That is now answered: the key belongs to the shred oracle, which never touches DoubleZeroClient (it depends on doublezero_sdk for data types and PDA helpers only) and assembles its serviceability instructions itself, appending its own Permission account. No cluster is exposed, so the note records the result instead of asking for it. Also point build_with_permission's activation-precondition doc at the oracle's caller-side solution, since whoever resumes the deferred append will hit the same offline-existence constraint.
…ngelog entry, inert-grant warning
769573c to
cb9fa51
Compare
|
Thanks — all four held up under checking. Addressed in 1. One deviation worth flagging: you asked for a 2. Allowlist assertions. All four now pin the built instruction with Two things beyond the one-line fix. You were also right that the PR body understated this, and the real numbers cut harder than the file count suggested. 3. Permission grant no-op. Did both hooks you offered rather than picking one, since the audit is where an operator looks but
Small correction: there is no 4. Rebase. Done. A plain The PR renders 98 files rather than your predicted ~96 — the extra two are the Non-blocking notes. Taking the first two as follow-ups rather than growing this PR further: the call-site comments on builder-overwritten args ( Verified: |
Summary of Changes
commands/*execute()to the puredoublezero-serviceability-instructionbuilders (RFC-26), so account layout has a single source of truth in the builder crate rather than being hand-assembled per command.DoubleZeroClient::execute_*(instruction, accounts)trait methods and the client-side account assembly with a singlesend_transaction(Instruction)that just prepends the compute-budget prelude, signs, and sends. Commandexecute()signatures/returns are unchanged, so CLI/sentinel/daemon consumers are unaffected. The_quietvariant is not carried forward — its only caller was theactivator/crate, deleted in activator: delete activator/ crate from workspace #3647.doublezero topology clearreverted withTopology Account is not writableon a live topology — the SDK passed the topology PDA read-only while the processor asserts it writable whenever it actually clears a link reference (it decrementsreference_count). The command therefore only worked on the already-closed-topology path, blockingtopology delete, which requiresreference_count == 0. Theclear_topologybuilder passes the account writable; writable is a strict superset privilege, so no other path changes.### Breaking): the Rust SDK no longer attaches the payer's Permission PDA to serviceability transactions —authorize()uses the legacy GlobalState allowlist path until the builder-side Permission append is re-enabled per instruction (must be sequenced with the permission-model rollout). A payer whose only grant is an activated Permission account would lose access to gated commands.RequirePermissionAccountsis off everywhere, so the legacy fallback is live. mainnet-beta's 8 Permission accounts all grantFOUNDATIONto keys already infoundation_allowlist. The one grant on testnet/devnet that no legacy authority covers (ACCESS_PASS_ADMINtoEWFXDTBWhNxsmj4mbahZL7HH1XpmC6Ao3LZUhJGCyyPh) belongs to the shred oracle, which never usesDoubleZeroClient— it assembles its serviceability instructions itself and appends its own Permission account after its own RPC existence check (doublezero-shreds,crates/shred-oracle/src/dz_ledger.rs,permission_account_meta, 9 call sites). It is unaffected by this send path. No deploy blocker.permission setemits atracing::warn!whenever the resulting bitmask carries anAUTHORIZE_GATED_FLAGSbit, andpermission auditrenders a matching paragraph (plus asdk_attaches_permission_accountsfield in--json), stating that the CLI/Rust SDK do not attach Permission accounts yet so the key still needs a legacy allowlist or authority entry. The chain-state check above is point-in-time by nature; this covers grants made after it. Both are removed when the append is re-enabled.solana-program-testsafety net that runs the highest-cardinality builders (create_device,create_link,create_subscribe_user, atomicdelete_device,clear_topology) against the real program to catch account-order drift.client.rs(the append, when re-enabled, lands in the builder — derived offline — so the RPC-based path is not needed).This is the final PR of the RFC-26 stack; it carries the global changelog entry. See
rfcs/rfc26-rust-instruction-builder-library.md.Diff Breakdown
Net −697 lines: a mostly-mechanical migration (77 command delegations) on top of a genuine send-path/trait rewrite and one new program-test suite. This exceeds doublezero's ~500-line PR norm, but the migration + its program-test safety net are one logical unit and the net change is a reduction; splitting would land the safety net separately from the code it guards.
Key files (click to expand)
smartcontract/programs/doublezero-serviceability/tests/rfc26_builders_test.rs(+763) — new program-test safety net; builds via the RFC-26 builders and runs them against the real program.smartcontract/sdk/rs/src/client.rs(+70/-560) — reworkexecute_transaction_innerintosend_transaction_inner(ix); dropassemble_instructions, the permission-resolve/cache and stale-retry loop, and their tests.smartcontract/sdk/rs/src/commands/user/delete.rs(+153/-246) — composed unsubscribe +delete_userbuilder; keeps cascade orchestration.smartcontract/sdk/rs/src/commands/exchange/setdevice.rs(+117/-131) — two-send command migrated toset_device_exchangebuilder.smartcontract/sdk/rs/src/commands/tenant/delete.rs(+108/-116) —delete_tenantbuilder; keeps cascade + reference-count poll.smartcontract/sdk/rs/src/commands/link/update.rs(+77/-81) —update_linkbuilder withLinkUpdateAuthority; builder recomputesuse_onchain_allocation.smartcontract/sdk/rs/src/commands/device/delete.rs(+59/-87) — legacy/atomic paths viadelete_device+DeviceDeleteResources.smartcontract/sdk/rs/src/commands/topology/clear.rs(+57/-85) — batched sends viaclear_topology; carries the sdk: ClearTopologyCommand passes topology PDA read-only but the processor requires it writable #4078 writable-PDA fix.smartcontract/sdk/rs/src/doublezeroclient.rs(+7/-35) — trait: fourexecute_*methods → a singlesend_transaction.smartcontract/cli/src/permission/{set,audit}.rs(+92/-1) — operator-facing warning that Permission grants are inert until the builder-side append returns.Testing Verification
solana-program-testsuite (rfc26_builders_test.rs): 5 tests exercising the highest-cardinality builders against the real program —create_device,create_link+clear_topology,create_subscribe_user(atomic subscriber), and atomicdelete_device(owners read on-chain in processor order). All pass.clear_topologycovers both processor branches:cleared_count == 0(untagged topology, no write) andcleared_count > 0(the topology the link is tagged into), the latter exercising theis_writableassert andreference_countdecrement that sdk: ClearTopologyCommand passes topology PDA read-only but the processor requires it writable #4078 is about. Verified it is a real guard — flipping the builder tonew_readonlyfails withTopology Account is not writable.cargo test -p doublezero_sdk: 167 passed. Rewritten command tests assertpredicate::eq(builder(...))on the exact instruction each command sends. The composed cascades inuser/deleteandtenant/deletepin every step — the resolved access-pass PDA,dz_prefix_count, the optionaltenantaccount, the[current_tenant, new_tenant]reset pair, and the trailingowner(which differs from the payer on the foundation-key path).sdk/rs/src/commands/went up, not down:predicate::always()appears in 0 files here, down from 8 onmain(where it relaxed the accounts argument of the two-argexecute_*expectation). The fourmulticastgroup/allowlist/*tests now pin the built instruction end to end — onmainthey asserted theDoubleZeroInstructionbut not the accounts — withuser_payerdeliberately distinct from the group pubkey so an account-meta swap fails. Confirmed discriminating: perturbingclient_ipin the expected instruction fails withNo matching expectation found.permission set's new warning is gated onAUTHORIZE_GATED_FLAGS, unit-tested to fire for every gated flag and stay quiet forSENTINEL/QA/FEED_AUTHORITY(which no processor hands toauthorize()), so it cannot become background noise.u8bounds onresource_count/topology_countstay errors rather than builder panics:device/create,device/interface/createanddevice/interface/updateguard before building, with tests asserting the exact messages for both interface guards.exchange/setdevice,topology/clear,topology/assign_node_segments) preserve their per-instruction sequencing.