serviceability-instruction/sdk-rs: carry the IP ownership proof and Ed25519 instruction - #4224
serviceability-instruction/sdk-rs: carry the IP ownership proof and Ed25519 instruction#4224elitegreg wants to merge 2 commits into
Conversation
…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.
juan-malbeclabs
left a comment
There was a problem hiding this comment.
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()) |
There was a problem hiding this comment.
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.
| 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 | ||
| ); | ||
| } |
There was a problem hiding this comment.
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).
Resolves #4200. Part of RFC-27 (rfcs/rfc27-ip-verification.md); tracker #4194.
Summary of Changes
IpOwnershipProofas 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 nativeEd25519SigVerifyinstruction the program introspects the Instructions sysvar to find, and the Rust SDK plumbing to send it alongside the creation it authorizes.ip_proofmodule indoublezero-serviceability-instruction:ed25519_verification_instructionlays out the precompile instruction for a proof, andwith_ed25519_verificationpairs it ahead of the create instruction. The offset layout comes fromsolana_ed25519_programrather 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.Option<IpOwnershipProof>, but serviceability: validate IpOwnershipProof via the Ed25519 precompile #4211 already putip_proofinUserCreateArgs/UserCreateSubscribeArgsand 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.DoubleZeroClientgainssend_instructionsfor a transaction needing more than one instruction.send_transactionis unchanged, so its 223 call sites are untouched.CreateUserCommandandCreateSubscribeUserCommandtake an optionalip_proof. A shared helper resolves the verifier key fromGlobalState.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.--owneroverride path the proof must name that owner, not the payer.create_user_corebinds the proof to the user's effective owner, which differs from the payer on the foundation-allowlist path.Noneproduces the pre-RFC-27 transaction byte for byte. Nothing setsip_proofyet; the CLI obtaining a proof duringconnectis 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,CreateUserfits 10dz_prefix_blockaccounts andCreateSubscribeUser8, against 21 and 19 without one — the proof costs about eleven slots: the 111-byteOption<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
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)
crates/doublezero-serviceability-instruction/src/ip_proof.rs— new module: the Ed25519 precompile instruction for a proof, and the ordered pairsmartcontract/sdk/rs/src/commands/user/mod.rs— the sharedinstructions_with_ip_proof: verifier-key lookup plus the three field checks the program also makes, in one place so the two commands cannot driftsmartcontract/sdk/rs/src/commands/user/create.rs—ip_prooffield, and the two-instruction send when one is suppliedsmartcontract/sdk/rs/src/commands/user/create_subscribe.rs— the same, binding the proof toaccesspass_payer(the effective owner) rather than the payersmartcontract/sdk/rs/src/client.rs— the inner send path now takes aVec<Instruction>smartcontract/sdk/rs/src/doublezeroclient.rs—send_instructionson the traitcrates/doublezero-serviceability-instruction/src/user.rs— the two transaction-size headroom testssmartcontract/programs/doublezero-serviceability/tests/rfc26_builders_test.rs— end-to-end acceptance against the real programTesting Verification
test_builder_user_creation_with_ip_proofruns both builders against the in-process program withrequire-ip-ownership-proofset 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 endActivated, 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.test_builder_create_subscribe_useris theNonecase end to end, and builder-level tests assert that aNoneproof leaves both account lists byte-identical to the pre-RFC-27 layout.u16::MAXsentinel, 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.--ownerpath — a proof bound to the payer instead of the owner.dz_prefix_countwith and without a proof, for both instructions.user_ip_proof_test(34),doublezero-daemon-cli(180), anddoublezero-serviceability-cli(419) all still pass.make generate-fixturesproduces no.bin/.jsonchange, confirming the no-proof wire shape is untouched.