Skip to content

serviceability-instruction/sdk-rs: carry the IP ownership proof and Ed25519 instruction - #4224

Open
elitegreg wants to merge 2 commits into
mainfrom
gm/ip-proof-instruction-sdk
Open

serviceability-instruction/sdk-rs: carry the IP ownership proof and Ed25519 instruction#4224
elitegreg wants to merge 2 commits into
mainfrom
gm/ip-proof-instruction-sdk

Conversation

@elitegreg

@elitegreg elitegreg commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Resolves #4200. Part of RFC-27 (rfcs/rfc27-ip-verification.md); tracker #4194.

Summary of Changes

  • The serviceability program validates an optional IpOwnershipProof as of serviceability: validate IpOwnershipProof via the Ed25519 precompile #4211, but nothing client-side could build a transaction carrying one. This adds the two missing pieces: the native Ed25519SigVerify instruction the program introspects the Instructions sysvar to find, and the Rust SDK plumbing to send it alongside the creation it authorizes.
  • New ip_proof module in doublezero-serviceability-instruction: ed25519_verification_instruction lays out the precompile instruction for a proof, and with_ed25519_verification pairs it ahead of the create instruction. The offset layout comes from solana_ed25519_program rather than being written out here — the program rejects any instruction whose offsets name a different instruction or run past the end of its data, so a hand-rolled header is a silent way to build a transaction that can never land.
  • The builders keep their signatures. Issue serviceability-instruction/sdk-rs: carry the IP ownership proof and Ed25519 instruction #4200 asks them to take Option<IpOwnershipProof>, but serviceability: validate IpOwnershipProof via the Ed25519 precompile #4211 already put ip_proof in UserCreateArgs / UserCreateSubscribeArgs and made both builders derive the Instructions sysvar append from it. Adding a positional parameter would give the proof two homes that can disagree, so it stays in the args.
  • DoubleZeroClient gains send_instructions for a transaction needing more than one instruction. send_transaction is unchanged, so its 223 call sites are untouched.
  • CreateUserCommand and CreateSubscribeUserCommand take an optional ip_proof. A shared helper resolves the verifier key from GlobalState.ip_verifier_authority_pk — the same place the program reads it — so a caller cannot pair a proof with the wrong key, and refuses a proof naming a different owner, address, or user type before the transaction is paid for. The epoch window is deliberately left to the program: the ledger's current epoch is its to judge.
  • On the --owner override path the proof must name that owner, not the payer. create_user_core binds the proof to the user's effective owner, which differs from the payer on the foundation-allowlist path.
  • Passing None produces the pre-RFC-27 transaction byte for byte. Nothing sets ip_proof yet; the CLI obtaining a proof during connect is cli: obtain an IP ownership proof during connect and attach it to user creation #4201.

Transaction size

The issue asks for the headroom at realistic dz_prefix_count, and two tests pin it rather than leaving it to a comment. With a proof attached, CreateUser fits 10 dz_prefix_block accounts and CreateSubscribeUser 8, against 21 and 19 without one — the proof costs about eleven slots: the 111-byte Option<IpOwnershipProof> in the args, a 169-byte Ed25519 instruction, and two more account keys (the Instructions sysvar and the Ed25519 program). Devices carry one or two prefixes, so the margin is large either way, but a future field cannot quietly eat the rest of it without failing these tests.

Diff Breakdown

Category Files Lines (+/-) Net
Core logic 6 +708 / -16 +692
Tests 3 +411 / -6 +405
Fixtures 1 +14 / -2 +12
Scaffolding 4 +13 / -0 +13
Config/build 2 +10 / -3 +7
Docs 1 +4 / -0 +4
Generated 1 +2 / -0 +2
Total 18 +1162 / -27 +1135

The core-logic files carry 507 lines of inline #[cfg(test)] tests of their own, leaving about 200 lines of new logic — two builders, one shared SDK helper, and one trait method.

Key files (click to expand)

Testing Verification

  • test_builder_user_creation_with_ip_proof runs both builders against the in-process program with require-ip-ownership-proof set and a real verifier key in global state, so each creation only lands if the builder-assembled Ed25519 instruction actually validates — the acceptance criterion. Both users end Activated, and the multicast one carries its subscription. This is the first test in the repo that submits a precompile instruction produced by production code rather than a test helper.
  • The pre-existing test_builder_create_subscribe_user is the None case end to end, and builder-level tests assert that a None proof leaves both account lists byte-identical to the pre-RFC-27 layout.
  • Ed25519 layout is asserted field by field against the offsets the program's own parser reads: one signature, all three instruction indices at the u16::MAX sentinel, and the key / signature / message slices at the declared offsets. A separate test bends the proof's epoch and asserts the covered message moves with it, so the builder can never sign for a message the program will not reconstruct.
  • SDK command tests cover the proof path on both commands and each local rejection: unset verifier key, a proof naming another payer, a proof for another address, a proof for another user type, and — on the --owner path — a proof bound to the payer instead of the owner.
  • Transaction-size tests assert the exact maximum dz_prefix_count with and without a proof, for both instructions.
  • user_ip_proof_test (34), doublezero-daemon-cli (180), and doublezero-serviceability-cli (419) all still pass.
  • make generate-fixtures produces no .bin / .json change, confirming the no-proof wire shape is untouched.

…d25519 instruction

Resolves #4200. Part of RFC-27; tracker #4194.

The program validates an optional IpOwnershipProof as of #4211, but nothing
client-side could produce a transaction carrying one. This adds the two pieces a
caller needs: the native Ed25519SigVerify instruction the program introspects the
Instructions sysvar to find, and the Rust SDK plumbing to send it alongside the
creation it authorizes.

- crates/doublezero-serviceability-instruction gains an ip_proof module:
  ed25519_verification_instruction lays out the precompile instruction for a
  proof, and with_ed25519_verification pairs it ahead of the create instruction.
  The offset layout comes from solana_ed25519_program rather than being written
  out, because the program rejects any instruction whose offsets name another
  instruction or run past the end of its data.
- The builders keep their signatures: #4211 already put ip_proof in the args and
  made them append the Instructions sysvar from it, so the proof travels in one
  place rather than two that can disagree.
- DoubleZeroClient gains send_instructions for a transaction that needs more than
  one instruction. send_transaction is unchanged.
- CreateUserCommand and CreateSubscribeUserCommand take an optional ip_proof.
  A shared helper resolves the verifier key from
  GlobalState.ip_verifier_authority_pk, the same place the program reads it, so a
  caller cannot pair a proof with the wrong key, and refuses a proof naming a
  different owner, address, or user type before the transaction is paid for. On
  the owner-override path the proof must name that owner: the program binds it to
  the user's effective owner, not the payer. Omitting the proof produces the
  pre-RFC-27 transaction unchanged.
- Nothing sets ip_proof yet; the CLI is #4201.

Transaction headroom, pinned by tests: with a proof attached CreateUser fits 10
dz_prefix_block accounts and CreateSubscribeUser 8, against 21 and 19 without
one. The proof costs about eleven slots — the 111-byte Option<IpOwnershipProof>
in the args, a 169-byte Ed25519 instruction, and two more account keys. Devices
carry one or two prefixes.
@elitegreg
elitegreg marked this pull request as ready for review August 21, 2026 19:26
@elitegreg
elitegreg requested a review from a team August 21, 2026 19:26

@juan-malbeclabs juan-malbeclabs left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the RFC-27 client side. The mechanism is correct: I re-derived the Ed25519 precompile layout against solana-ed25519-program-3.0.0 (DATA_START=16, key→sig→msg order, the u16::MAX sentinel) and it agrees with check_ed25519_instruction's reconstruction; the sysvar scan is position-independent and bounds-checked; the epoch window reads correctly at epoch 0; and proof_owner lines up with create_user_core's effective_owner on both paths (CreateUser passes owner_override: None, so the payer is right; CreateSubscribeUser derives it from args.owner, so accesspass_payer is right). No wrong conditions, off-by-ones, dropped errors, or broken callers.

Two notes, both on the local pre-send gate this PR introduces, inline.

Unrelated to the diff but newly relevant: client.rs:244 maps InstructionError::Custom(n) to DoubleZeroError::from(n) and discards the instruction index. That was safe while every instruction was serviceability's — now that transactions carry a precompile instruction whose PrecompileError lives in the same Custom space, InvalidSignature (2) would print as InvalidExchangePubkey. Unreachable while skip_preflight is hardcoded true, so a comment or a cheap index guard is enough.

Verified locally: cargo test -p doublezero-serviceability-instruction (81 pass), cargo test -p doublezero_sdk (189 pass, including all 7 new RFC-27 command tests), rfc26_builders_test test_builder_user_creation_with_ip_proof, and cargo check --workspace --all-targets (only the pre-existing, unrelated doublezero-geolocation::entrypoint test-target failures).

);
}

Ok(with_ed25519_verification(&verifier, proof, create_instruction).to_vec())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The helper resolves the verifier key and builds the Ed25519 instruction with it, but never checks proof.signature against that key — and send_transaction_inner sends with skip_preflight: true (client.rs:200).

So if the verifier rotates its key and GlobalState.ip_verifier_authority_pk is updated while a client still holds a proof signed by the old one (same for any truncated or corrupted proof), all three field checks pass, the Ed25519 instruction is built with the new key over the old signature, and agave-precompiles rejects the transaction during precompile verification — which the RPC only runs when !skip_preflight. The transaction is dropped by the leader and never lands, so there is no TransactionError for parse_transaction_error to find: the caller blocks in send_and_confirm_transaction_with_spinner_and_config until blockhash expiry and gets "unable to confirm transaction", with no program logs and no DoubleZeroError.

Every other failure in this path lands onchain with a named error and logs; this is the first that can fail invisibly, which is exactly what the doc comment above says the local checks are here to avoid. The helper already holds both the proof and the key, and doublezero_ip_proof::verify(proof, &verifier) exists behind the crate's signer feature — so this is a one-line check plus features = ["signer"] on the sdk/rs dependency (safe for the BPF build: cargo build-sbf doesn't pull sdk/rs into the program's graph, and the program already enables the same feature in dev-dependencies).

Worth noting the test fixtures encode the current behavior — both proof_for helpers build a [5u8; 64] signature against a Pubkey::new_unique() verifier and their doc comments say the signature is never checked client-side — so the two happy-path tests would need real keypairs and doublezero_ip_proof::sign, the shape rfc26_builders_test.rs already uses. The mismatched-field tests bail before the signature check, so they are unaffected.

Comment on lines +52 to +57
if proof.user_type != user_type {
eyre::bail!(
"IP ownership proof was issued for user type {} but this user is created as {user_type}",
proof.user_type
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mirrors the program's payer/client_ip/user_type comparisons but leaves out its is_supported_version(proof.version) check (ip_proof.rs:125).

During a v2 rollout, where the verification service starts issuing v2 proofs before a given ledger's program is upgraded, signed_message() builds v2 bytes, the precompile verifies them fine, the transaction lands, and the program rejects it with IpProofVersionUnsupported — after the fee is paid. That's the exact outcome the doc comment above gives as the reason for doing the other three checks locally, so this reads as an oversight rather than a decision (unlike the epoch window, which is deliberately excluded and correctly so).

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.

serviceability-instruction/sdk-rs: carry the IP ownership proof and Ed25519 instruction

2 participants