cli: obtain an IP ownership proof during connect and attach it - #4227
Draft
elitegreg wants to merge 3 commits into
Draft
cli: obtain an IP ownership proof during connect and attach it#4227elitegreg wants to merge 3 commits into
elitegreg wants to merge 3 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.
Resolves #4201. Part of RFC-27; tracker #4194. Stacked on #4224. The SDK can carry an RFC-27 proof as of #4200, but nothing obtained one. This makes `doublezero connect` ask the verification service for a proof and attach it to user creation. - config: NetworkConfig gains ip_verifier_url, overridable by DZ_IP_VERIFIER_URL and by a new --ip-verifier-url on connect. Only localnet has a built-in default, the verifier's own listen address; deployed URLs land with #4199, and until then those environments simply have no verifier. - New ip_proof module in doublezero-daemon-cli: an automock'd IpProofClient trait plus a blocking reqwest implementation. The request is bound to the address the tunnel will use, so a multi-homed host proves the address it actually originates from; on a NATed host that bind fails and the request falls back to the default egress, where the service observes the NAT address the daemon already discovered. - The service's observed address is authoritative. Where it disagrees with what the daemon discovered, connect stops and names both: attaching the proof would guarantee an onchain rejection, and dropping it would bind an address nobody proved. - Every other failure is reported and non-fatal — unconfigured, unreachable, or declined (a CGNAT source, a rate limit) each print the specific reason and continue without a proof. The program is the enforcement point, so this succeeds while require-ip-ownership-proof is clear and fails with a named error once it is set, which is the behavior wanted during rollout. - One proof per invocation: a single connect only ever creates users of one user_type, and the proof binds user_type. The issue asks to fail when an explicit --client-ip disagrees with the service. That flag is deprecated and ignored on the CLI, so the disagreement that can actually happen is between the daemon's discovered address and the service's observed one; that is what is checked.
elitegreg
force-pushed
the
gm/ip-proof-instruction-sdk
branch
2 times, most recently
from
August 25, 2026 02:06
f0226ab to
b57e469
Compare
Contributor
There was a problem hiding this comment.
Pull request overview
Adds RFC-27 IP ownership proof plumbing end-to-end: doublezero connect can fetch an IpOwnershipProof from the verifier service, thread it into user creation, and the Rust SDK/instruction builders can attach the required Ed25519 precompile instruction in the same transaction.
Changes:
- Add an IP-verifier HTTP client (source-address binding + classified refusal reasons) and integrate best-effort proof retrieval into
doublezero connect. - Extend Rust SDK user-creation commands and the
DoubleZeroClientinterface to support sending multi-instruction transactions (Ed25519 + create). - Add config surface for
ip_verifier_url(per-env +DZ_IP_VERIFIER_URL+--ip-verifier-url) and update changelog/docs.
Reviewed changes
Copilot reviewed 21 out of 23 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| smartcontract/sdk/rs/src/tests.rs | Test utilities updated to support configuring ip_verifier_authority_pk for RFC-27 paths. |
| smartcontract/sdk/rs/src/doublezeroclient.rs | Adds send_instructions to support multi-instruction transactions. |
| smartcontract/sdk/rs/src/commands/user/mod.rs | Adds shared helper to build [ed25519, create] using onchain verifier key from GlobalState. |
| smartcontract/sdk/rs/src/commands/user/create.rs | Threads optional ip_proof into CreateUser and uses send_instructions when present; adds tests. |
| smartcontract/sdk/rs/src/commands/user/create_subscribe.rs | Threads optional ip_proof into CreateSubscribeUser and uses send_instructions when present; adds tests. |
| smartcontract/sdk/rs/src/client.rs | Refactors inner send path to accept Vec<Instruction> and implements send_instructions. |
| smartcontract/sdk/rs/Cargo.toml | Adds doublezero-ip-proof dependency for proof struct forwarding. |
| smartcontract/programs/doublezero-serviceability/tests/rfc26_builders_test.rs | Adds end-to-end SVM test covering builder-produced RFC-27 user creations with proofs. |
| smartcontract/cli/src/user/create.rs | Updates CLI user-create wiring/tests to include ip_proof: None. |
| smartcontract/cli/src/user/create_subscribe.rs | Updates CLI create-subscribe wiring/tests to include ip_proof: None. |
| sdk/serviceability/testdata/fixtures/generate-fixtures/Cargo.lock | Fixture generator lockfile updates for new deps (Ed25519 program, ip-proof). |
| crates/doublezero-serviceability-instruction/src/user.rs | Adds tx-size headroom tests for proof-bearing user creation transactions. |
| crates/doublezero-serviceability-instruction/src/lib.rs | Exposes new ip_proof module. |
| crates/doublezero-serviceability-instruction/src/ip_proof.rs | Adds Ed25519 precompile instruction builder + helper to pair it with user creation. |
| crates/doublezero-serviceability-instruction/Cargo.toml | Adds doublezero-ip-proof and solana-ed25519-program deps. |
| crates/doublezero-daemon-cli/src/lib.rs | Exposes new ip_proof module. |
| crates/doublezero-daemon-cli/src/ip_proof.rs | Adds blocking verifier client + wire parsing into IpOwnershipProof. |
| crates/doublezero-daemon-cli/src/connect.rs | Integrates proof retrieval into connect flow and threads proof into all creation paths; adds tests. |
| crates/doublezero-daemon-cli/Cargo.toml | Adds doublezero-ip-proof, reqwest (blocking+json), and thiserror. |
| config/src/env.rs | Adds NetworkConfig::ip_verifier_url and DZ_IP_VERIFIER_URL override. |
| config/src/constants.rs | Adds localnet default verifier URL constant. |
| CHANGELOG.md | Documents new RFC-27 CLI + SDK + instruction support. |
| Cargo.lock | Workspace lockfile updates for new dependencies. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| - CLI | ||
| - `doublezero connect` obtains an RFC-27 IP ownership proof from the verification service and attaches it to user creation. The service signs the source address it observes, so that address — not the daemon's own discovery — is authoritative; where the two disagree, `connect` stops and names both rather than binding an address nobody proved. The request is bound to the address the tunnel will use where the host allows it, so a multi-homed machine proves the right one. A verifier that is unreachable, unconfigured, or that declines (a CGNAT source, for instance) is reported and the connect continues without a proof, which the program accepts until `require-ip-ownership-proof` is set for the environment. `--ip-verifier-url` or `DZ_IP_VERIFIER_URL` points at a verifier; only localnet has a built-in default until the deployment work lands. (#4201) | ||
| - Rust SDK | ||
| - `CreateUserCommand` and `CreateSubscribeUserCommand` take an optional RFC-27 `ip_proof`. Supplying one attaches the native `Ed25519SigVerify` instruction the program looks for and sends both as one transaction; the verifier key comes from `GlobalState.ip_verifier_authority_pk`, the same place the program reads it, so a caller cannot pair a proof with the wrong key. A proof naming a different owner, address, or user type is refused before the transaction is paid for. On the `--owner` override path the proof must name that owner, because the program binds it to the user's effective owner. Omitting it produces the pre-RFC-27 transaction unchanged. Nothing sets it yet; the CLI is #4201. (#4200) |
| - `CreateUserCommand` and `CreateSubscribeUserCommand` take an optional RFC-27 `ip_proof`. Supplying one attaches the native `Ed25519SigVerify` instruction the program looks for and sends both as one transaction; the verifier key comes from `GlobalState.ip_verifier_authority_pk`, the same place the program reads it, so a caller cannot pair a proof with the wrong key. A proof naming a different owner, address, or user type is refused before the transaction is paid for. On the `--owner` override path the proof must name that owner, because the program binds it to the user's effective owner. Omitting it produces the pre-RFC-27 transaction unchanged. Nothing sets it yet; the CLI is #4201. (#4200) | ||
| - `DoubleZeroClient` gains `send_instructions`, for a transaction that needs more than one instruction. `send_transaction` is unchanged. (#4200) | ||
| - Utility crates | ||
| - New `doublezero-ip-proof` crate defines the RFC-27 `IpOwnershipProof` and the exact bytes the verifier signs, in one place the serviceability program, the CLI, and the verification service all share. Nothing consumes it yet. (#4195, #4206) |
Comment on lines
+182
to
186
| fn send_transaction_inner(&self, ixs: Vec<Instruction>) -> eyre::Result<Signature> { | ||
| let payer = self | ||
| .payer | ||
| .as_ref() | ||
| .ok_or_eyre("No default signer found, run \"doublezero keygen\" to create a new one")?; |
Comment on lines
+113
to
+117
| // Set this to point at a verifier in an environment that has no deployed one yet, or at | ||
| // a local one while developing. `--ip-verifier-url` on `connect` overrides it in turn. | ||
| if std::env::var("DZ_IP_VERIFIER_URL").is_ok() { | ||
| config.ip_verifier_url = Some(std::env::var("DZ_IP_VERIFIER_URL").unwrap()); | ||
| } |
Comment on lines
+131
to
+135
| let client = reqwest::blocking::Client::builder() | ||
| .timeout(REQUEST_TIMEOUT) | ||
| .build() | ||
| .map_err(|e| IpProofError::Malformed(e.to_string()))?; | ||
| Ok((client, false)) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Resolves #4201. Part of RFC-27 (rfcs/rfc27-ip-verification.md); tracker #4194.
Summary of Changes
doublezero connectobtains an RFC-27IpOwnershipProoffrom the verification service and attaches it to user creation. serviceability-instruction/sdk-rs: carry the IP ownership proof and Ed25519 instruction #4200 gave the SDK somewhere to put a proof; nothing obtained one until now.client_ipfield at all. So the returned address is authoritative, and the daemon's own discovery (ifconfig.me, insidedoublezerod) stays a convenience for display and the AccessPass pre-flight.connectstops and names both. Attaching the proof would guarantee an onchainIpProofClientIpMismatch; dropping it would bind an address nobody proved. Guessing between them is worse than stopping.not_globally_routablefor a CGNAT source,rate_limited, …) each print their own reason and continue without a proof. The program is the enforcement point, so this succeeds whilerequire-ip-ownership-proofis clear and fails with a named error once it is set — the behavior wanted during rollout. Breakingconnecthere would take every host in a not-yet-enforcing environment offline the moment a verifier went down.connectonly ever creates users of oneuser_type, and the proof bindsuser_type, so the proof is fetched once and threaded to whichever creation path the mode selects.NetworkConfiggainsip_verifier_url, overridable byDZ_IP_VERIFIER_URLand by a new--ip-verifier-urlonconnect.Notes for reviewers
--client-ipdisagreement. The issue asks to fail when an explicit--client-ipdisagrees with what the service observed. That flag is already deprecated and ignored on the CLI —connectwarns and reads the address from the daemon instead. So the disagreement that can actually occur is daemon-discovered versus service-observed, and that is what is checked. The remedy the error suggests is--client-ipon the daemon, which is the flag that still does something.Per-environment URLs. Only localnet gets a default, the verifier's own listen address, which is what
dev/dzctlwill run (#4204). Mainnet-beta, testnet, and devnet have no verifier deployed yet — that is #4199 — so theirip_verifier_urlisNone, which is a documented state rather than a placeholder hostname that would be wrong on arrival.--ip-verifier-urlandDZ_IP_VERIFIER_URLcover the gap in the meantime. #4199 should fill these in.A new dependency.
doublezero-daemon-cligainsreqwest(blocking, already a workspace dep used by two other crates). Blocking is deliberate: it sits besideLedgerClient's blocking RPC in the same code path, and one short request per invocation does not justify a second async HTTP stack in this crate.local_addressis what makes the source binding above possible.Diff Breakdown
423 of the core-logic lines are inline
#[cfg(test)]tests in those same files, leaving about 390 lines of new logic — one new module and the threading of a single value throughconnect.Key files (click to expand)
crates/doublezero-daemon-cli/src/ip_proof.rs— new module: theIpProofClienttrait, its blocking HTTP implementation with source-address binding, and the classified refusal reasonscrates/doublezero-daemon-cli/src/connect.rs—obtain_ip_proof(the fetch, the address-agreement check, the warn-and-continue paths),--ip-verifier-url, and the proof threaded to all four creation call sitesconfig/src/env.rs—NetworkConfig::ip_verifier_urland itsDZ_IP_VERIFIER_URLoverrideconfig/src/constants.rs— the localnet verifier URL, and why the other environments have none yetTesting Verification
connecttests drive every branch against a mocked verifier: a proof attached and asserted field for field on the command that reaches the SDK; the proof bound toMulticaston the multicast path and toIBRLon the unicast one, so a proof for the wronguser_typewould fail the test rather than the chain; an unreachable service continuing without a proof; anot_globally_routablerefusal surfacing the service's own reason; and the address disagreement failing with both addresses in the message and no ledger call at all.expect_create_user_with_tenanthelper, which pinsip_proof: None, so if the warn-and-continue path ever starts attaching something the test fails.connecttests run against a verifier that reports "not configured", which is the pre-RFC-27 shape they were written to assert — they are unchanged apart from the new field in theirConnectliterals.doublezero-daemon-cli.Still outstanding from the issue's acceptance list: manual verification against local devnet end to end, which needs #4204 to run a verifier in
dev/dzctl. Worth doing before this merges.