Skip to content

cli: obtain an IP ownership proof during connect and attach it - #4227

Draft
elitegreg wants to merge 3 commits into
mainfrom
gm/ip-proof-cli-connect
Draft

cli: obtain an IP ownership proof during connect and attach it#4227
elitegreg wants to merge 3 commits into
mainfrom
gm/ip-proof-cli-connect

Conversation

@elitegreg

@elitegreg elitegreg commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

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

Stacked on #4224 (gm/ip-proof-instruction-sdk), which adds the SDK's ip_proof field this branch sets. Review that one first; the base will move to main once it merges.

Summary of Changes

  • doublezero connect obtains an RFC-27 IpOwnershipProof from 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.
  • The service decides the address, not the host. The verifier signs the source address it observes and refuses a caller-supplied one — its request body has no client_ip field at all. So the returned address is authoritative, and the daemon's own discovery (ifconfig.me, inside doublezerod) stays a convenience for display and the AccessPass pre-flight.
  • Multi-homed hosts. The request is bound to the address the tunnel will use, so a machine with several egress paths proves the one it will actually originate from. On a NATed host that address is not on any local interface and the bind fails; that is expected, not an error, so the request falls back to the default egress, where the service observes the NAT's public address — the same one the daemon discovered. Both paths are logged, because a silent fallback on a multi-homed host would prove the wrong address.
  • One hard failure. When the service's observed address disagrees with the one being provisioned, connect stops and names both. Attaching the proof would guarantee an onchain IpProofClientIpMismatch; dropping it would bind an address nobody proved. Guessing between them is worse than stopping.
  • Everything else is reported and non-fatal. Unconfigured, unreachable, and declined (not_globally_routable for a CGNAT source, rate_limited, …) each print their own 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 — the behavior wanted during rollout. Breaking connect here would take every host in a not-yet-enforcing environment offline the moment a verifier went down.
  • One proof per invocation: a single connect only ever creates users of one user_type, and the proof binds user_type, so the proof is fetched once and threaded to whichever creation path the mode selects.
  • NetworkConfig gains ip_verifier_url, overridable by DZ_IP_VERIFIER_URL and by a new --ip-verifier-url on connect.

Notes for reviewers

--client-ip disagreement. The issue asks to fail when an explicit --client-ip disagrees with what the service observed. That flag is already deprecated and ignored on the CLI — connect warns 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-ip on 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/dzctl will run (#4204). Mainnet-beta, testnet, and devnet have no verifier deployed yet — that is #4199 — so their ip_verifier_url is None, which is a documented state rather than a placeholder hostname that would be wrong on arrival. --ip-verifier-url and DZ_IP_VERIFIER_URL cover the gap in the meantime. #4199 should fill these in.

A new dependency. doublezero-daemon-cli gains reqwest (blocking, already a workspace dep used by two other crates). Blocking is deliberate: it sits beside LedgerClient'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_address is what makes the source binding above possible.

Diff Breakdown

Category Files Lines (+/-) Net
Core logic 4 +816 / -12 +804
Config/build 1 +6 / -0 +6
Generated 1 +5 / -0 +5
Docs 1 +2 / -0 +2
Scaffolding 1 +1 / -0 +1
Total 8 +830 / -12 +818

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 through connect.

Key files (click to expand)

Testing Verification

  • Five connect tests 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 to Multicast on the multicast path and to IBRL on the unicast one, so a proof for the wrong user_type would fail the test rather than the chain; an unreachable service continuing without a proof; a not_globally_routable refusal surfacing the service's own reason; and the address disagreement failing with both addresses in the message and no ledger call at all.
  • The fallback test reuses the existing expect_create_user_with_tenant helper, which pins ip_proof: None, so if the warn-and-continue path ever starts attaching something the test fails.
  • The other 45 connect tests 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 their Connect literals.
  • Wire-form tests for the proof response: a round trip, a signature that is not base58, a payer that is not a pubkey, and the unconfigured-client case.
  • 189 tests pass in 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.

…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
elitegreg force-pushed the gm/ip-proof-instruction-sdk branch 2 times, most recently from f0226ab to b57e469 Compare August 25, 2026 02:06
@elitegreg
elitegreg requested a lite review from Copilot August 25, 2026 02:22

Copilot AI 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.

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 DoubleZeroClient interface 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.

Comment thread CHANGELOG.md
- 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)
Comment thread CHANGELOG.md
- `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 thread config/src/env.rs
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))
Base automatically changed from gm/ip-proof-instruction-sdk to main August 25, 2026 02:31
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.

2 participants