From 632189a149bcc80f54089e10bb5eed8aeae1e0af Mon Sep 17 00:00:00 2001 From: Greg Mitchell Date: Fri, 28 Aug 2026 19:50:31 +0000 Subject: [PATCH 1/2] e2e: cover RFC-27 proof enforcement with the feature flag set require-ip-ownership-proof was never set in any e2e test, so the enforcement dimension of RFC-27 had no coverage at all and SetIPOwnershipProofFeatureFlag had no callers. Two tests with the flag set. The working path still reaches BGP. A client with no verifier to reach is refused by the program with IpOwnershipProofRequired, which is one of only two proof errors reachable end to end: the SDK pre-flights version, payer, client_ip, user_type and the signature before building a transaction, so those rejections never leave the client, and the epoch window needs a ledger epoch a devnet never advances past 0. The wildcard access pass is the case RFC-27 exists for and had no e2e coverage of any kind: all 72 access-pass call sites in e2e name a --client-ip. A pass at the 0.0.0.0 PDA authorizes its payer for any routable address, which is the shape the shred-oracle issues, so the proof is the only thing binding client_ip. Covered both ways: with a proof the user binds the observed address, without one the create is rejected. The sentinel exemption is covered too, because enforcement must not break the oracle path. The manager is the sentinel authority in a local devnet, so a manager-side user create still succeeds while a client-paid one does not. An address mismatch is asserted client-side, where the guard actually lives: connect binds its proof request to the address it provisions and refuses a proof for any other. ClientSpec.DaemonClientIP sets the daemon to an address the container does not own, which is what makes the two disagree. Everything that can share a devnet does, because a devnet is the expensive part of an e2e test and an extra client is one small container. The first four outcomes are subtests over one devnet with three clients; only the mismatch case needs a second, since DaemonClientIP is fixed when the container starts. --- CHANGELOG.md | 1 + e2e/docs/IP_VERIFIER_LOCAL_DEVNET.md | 24 +++ e2e/internal/devnet/client.go | 12 +- e2e/ip_ownership_proof_enforcement_test.go | 232 +++++++++++++++++++++ 4 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 e2e/ip_ownership_proof_enforcement_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index b89376c875..4f622b98be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to this project will be documented in this file. - shreds-e2e pins one heavy test to its own shard instead of three. `TestE2E_MultiUserInstantAllocationAndWithdrawal` and `TestE2E_DeviceScale` no longer exist in doublezero-shreds, so the pin validation failed every run and the matrix was never built. Only `TestE2E_FeedSubscriptionOracleExpiryTeardown` stays pinned, leaving 1 pinned + 2 round-robin shards. Dropping `shard-e2e (shard 4)` and `shard-e2e (shard 5)` from the required status checks in the main ruleset is a separate, manual step — until it happens those contexts are required but never reported. - `.cursor/BUGBOT.md` and `.github/copilot-instructions.md` now tell Bugbot and Copilot to read the nearest sibling, flag a path that skips a zero or a duplicate, and assert a specific error and the exact log line at the expected index. Onchain checks apply only when the repository has onchain code. The eight path-scoped files under `.github/instructions/` are removed so Copilot reads only the repo-wide file. (#4247) - E2E/QA + - New e2e coverage for RFC-27 proof enforcement with `require-ip-ownership-proof` set: the working path still reaches BGP, a client with no verifier to reach is rejected with `IpOwnershipProofRequired`, a wildcard (`0.0.0.0`) access pass binds `client_ip` only when a proof is attached, the sentinel authority stays exempt so the oracle path keeps working, and `connect` refuses a proof whose address disagrees with the one it provisions. - Remove `TestQA_MulticastSettlement`. It funded a seat through `doublezero-solana shreds pay`, which is going away. The agent seat-pay RPC now returns Unimplemented if something still calls it. Unused settlement helpers go with the test. (#4248) ## [v0.38.0](https://github.com/malbeclabs/doublezero/compare/client/v0.37.0...client/v0.38.0) - 2026-08-28 diff --git a/e2e/docs/IP_VERIFIER_LOCAL_DEVNET.md b/e2e/docs/IP_VERIFIER_LOCAL_DEVNET.md index 16d5544fce..2550cbe752 100644 --- a/e2e/docs/IP_VERIFIER_LOCAL_DEVNET.md +++ b/e2e/docs/IP_VERIFIER_LOCAL_DEVNET.md @@ -70,6 +70,30 @@ attaches a real proof. Two knobs cover the cases that need something else: `e2e/ip_ownership_proof_test.go` uses all three paths. +### Testing enforcement + +`devnet.SetIPOwnershipProofFeatureFlag(ctx, true)` sets `require-ip-ownership-proof`, which changes +exactly one thing in the program: whether a *missing* proof is an error. A supplied proof is +validated in full either way, so most proof failures are testable with the flag clear. + +Two things to know before writing a "flag on rejects everything" test: + +- **The manager is the sentinel authority** in a local devnet (`smartcontract_init.go` runs + `authority set --sentinel-authority me`), and the sentinel may create a user without a proof. So + `doublezero user create` from the manager still succeeds under enforcement — the rejection only + shows up on a create paid for by someone else, which is what `doublezero connect` on a client + does. `is_sentinel` compares the transaction payer. +- **Most bad-proof cases never reach the chain.** The Rust SDK pre-flights version, payer, + `client_ip`, `user_type` and the signature before building the transaction, and `connect` refuses + an address disagreement before that. Of the program's proof errors only + `IpOwnershipProofRequired` (105) and `IpProofEpochOutOfWindow` (110) are reachable end to end, + and 110 needs a ledger epoch the devnet never advances past 0. The rest have program-level + coverage in `user_ip_proof_test.rs`. + +`e2e/ip_ownership_proof_enforcement_test.go` covers the flag-set cases, including a wildcard access +pass — a pass created with no `--client-ip`, landing at the `0.0.0.0` PDA — which is the case +RFC-27 exists for. + ## Poking at it ```bash diff --git a/e2e/internal/devnet/client.go b/e2e/internal/devnet/client.go index 1e2448fdba..5eb63d00da 100644 --- a/e2e/internal/devnet/client.go +++ b/e2e/internal/devnet/client.go @@ -65,6 +65,12 @@ type ClientSpec struct { // RFC-27 proof even in a devnet running a verifier. NoIPVerifier bool + // DaemonClientIP overrides the address the daemon provisions, which is otherwise this client's + // CYOA address. Set it to an address the container does not own and the verifier observes the + // real CYOA source while `connect` binds the override, which is the disagreement `connect` + // refuses to provision through. + DaemonClientIP string + // EnableQAAgent starts the QA agent inside the client container for local QA testing. EnableQAAgent bool // QAAgentPort is the port the QA agent listens on inside the container (default: 7009). @@ -223,7 +229,11 @@ func (c *Client) Start(ctx context.Context) error { if c.Spec.LatencyProbeTunnelEndpoints { extraArgs = append(extraArgs, "-latency-probe-tunnel-endpoints") } - extraArgs = append(extraArgs, "-client-ip", clientCYOAIP) + daemonClientIP := clientCYOAIP + if c.Spec.DaemonClientIP != "" { + daemonClientIP = c.Spec.DaemonClientIP + } + extraArgs = append(extraArgs, "-client-ip", daemonClientIP) // Determine QA agent port if enabled. qaAgentPort := c.Spec.QAAgentPort diff --git a/e2e/ip_ownership_proof_enforcement_test.go b/e2e/ip_ownership_proof_enforcement_test.go new file mode 100644 index 0000000000..21c9244f56 --- /dev/null +++ b/e2e/ip_ownership_proof_enforcement_test.go @@ -0,0 +1,232 @@ +//go:build e2e + +package e2e_test + +import ( + "testing" + "time" + + "github.com/malbeclabs/doublezero/e2e/internal/devnet" + "github.com/stretchr/testify/require" +) + +// RFC-27 enforcement: the `require-ip-ownership-proof` feature flag set. +// +// The sibling file covers the flag-clear outcomes, where a missing proof is tolerated. Here the +// flag is on, which changes exactly one thing in the program: whether a *missing* proof is an +// error (`ip_proof.rs`, the `None` arm). A supplied proof is validated in full either way. +// +// What is worth testing at this level is narrow, and deliberately so. The serviceability program's +// own `user_ip_proof_test.rs` already covers every rejection condition against a program-test +// runtime, and the Rust SDK pre-flights version, payer, client_ip, user_type and the signature +// before it builds a transaction — so those rejections can never reach the chain through the real +// CLI. Of the program's proof errors only `IpOwnershipProofRequired` (105) and +// `IpProofEpochOutOfWindow` (110) are reachable end to end, and 110 needs a ledger epoch that a +// devnet never advances past 0. So 105 is the one onchain rejection these tests can assert, and +// the rest of the value here is in the integration: a real verifier, a real ledger, a real tunnel. +// +// Everything that can share a devnet does. A devnet is by far the expensive part of an e2e test — +// a ledger, a manager, a controller and a cEOS device — while an extra client is one small +// container, and whether a client has a verifier to reach is fixed at container start. So the +// outcomes below are subtests over one devnet with three clients, and only the client-IP mismatch, +// which needs a client whose daemon is pointed at an address it does not own, takes a second. + +// The enforced outcomes that can share a devnet: a proof accepted, a proof missing, and the +// sentinel exemption. Subtests run in order against distinct clients, so each one's onchain state +// is its own and a failure earlier does not invalidate what follows. +func TestE2E_IPOwnershipProof_Enforced(t *testing.T) { + t.Parallel() + + // Client with a verifier, for the ordinary path: an access pass naming its address. + dn, device, specific, log := setupIPProofDevnet(t, devnet.IPVerifierSpec{}, devnet.ClientSpec{ + CYOANetworkIPHostID: 100, + }) + + log.Info("==> Enabling require-ip-ownership-proof") + require.NoError(t, dn.SetIPOwnershipProofFeatureFlag(t.Context(), true)) + + // Client with a verifier, for the wildcard pass. + wildcard, err := dn.AddClient(t.Context(), devnet.ClientSpec{ + CYOANetworkIPHostID: 101, + }) + require.NoError(t, err) + + // Client with no verifier to reach, so `connect` attaches no proof at all. + unverified, err := dn.AddClient(t.Context(), devnet.ClientSpec{ + CYOANetworkIPHostID: 102, + NoIPVerifier: true, + }) + require.NoError(t, err) + + // A client picks its device from its own latency measurements; connecting before they exist + // fails on endpoint selection rather than on anything these tests are about. + for _, c := range []*devnet.Client{wildcard, unverified} { + require.NoError(t, c.WaitForLatencyResults(t.Context(), device.ID, 75*time.Second)) + log.Info("--> Client added", "clientIP", c.CYOANetworkIP, "pubkey", c.Pubkey) + } + + // The working path still works with enforcement on: proof obtained, attached, accepted, + // tunnel up. + t.Run("specific_pass_with_a_proof", func(t *testing.T) { + out := connectIBRLForProofTest(t, log, dn, specific) + + require.Contains(t, out, "IP ownership verified for "+specific.CYOANetworkIP) + require.Contains(t, out, "✅ User Provisioned") + + // The whole point of asserting BGP rather than stopping at account creation: enforcement + // must not disturb anything downstream of the proof. + require.NoError(t, specific.WaitForTunnelUp(t.Context(), 90*time.Second), + "a user created under enforcement must still reach BGP") + }) + + // The case RFC-27 exists for. + // + // A wildcard access pass — stored at the 0.0.0.0 PDA, which is the shape the shred-oracle + // issues — authorizes its payer for *any* routable address. Without a proof the program would + // let that payer squat the User PDA of an address they do not control and point device tunnel + // provisioning at a third party. For a specific-IP pass the issuing authority already chose + // the IP, so the proof is redundant there; here it is the only thing binding client_ip. + // + // The wildcard pass had no e2e coverage of any kind before this: all 72 access-pass call sites + // in e2e name a --client-ip. + t.Run("wildcard_pass_with_a_proof", func(t *testing.T) { + setWildcardAccessPass(t, dn, wildcard) + + log.Info("==> Connecting the verified client on a wildcard pass") + out, err := wildcard.Exec(t.Context(), []string{"bash", "-c", "doublezero connect ibrl 2>&1"}) + output := string(out) + log.Info("==> Connect output", "output", output) + require.NoError(t, err, "connect failed: %s", output) + + require.Contains(t, output, "IP ownership verified for "+wildcard.CYOANetworkIP) + require.Contains(t, output, "✅ User Provisioned") + + // The pass named no address, so the proof is what bound this one. + users, err := dn.Manager.Exec(t.Context(), []string{"bash", "-c", "doublezero user list"}) + require.NoError(t, err) + require.Contains(t, string(users), wildcard.CYOANetworkIP, + "the user must be bound to the address the verifier observed") + }) + + // The enforcement moment: with the flag on, a client that cannot reach a verifier is refused, + // and a wildcard pass does not rescue it. + // + // This is the one rejection in this file that is genuinely the program's. The CLI attaches no + // proof, so nothing is caught client-side, and `create_user` fails with + // `DoubleZeroError::IpOwnershipProofRequired` — custom program error 105 (0x69). + t.Run("wildcard_pass_without_a_proof", func(t *testing.T) { + setWildcardAccessPass(t, dn, unverified) + + log.Info("==> Connecting the unverified client on a wildcard pass") + out, err := unverified.Exec(t.Context(), []string{"bash", "-c", "doublezero connect ibrl 2>&1"}) + output := string(out) + log.Info("==> Connect output", "output", output) + + require.Error(t, err, "a wildcard pass must not admit an unproven address under enforcement") + // Assert the specific refusal, not merely that connect failed: a test that passes because + // connect broke for an unrelated reason would be worse than no test at all. + require.Contains(t, output, "An IP ownership proof is required to create a user", + "the refusal must be the program's IpOwnershipProofRequired, not an incidental failure") + require.NotContains(t, output, "✅ User Provisioned") + + requireNoUserForIP(t, dn, unverified.CYOANetworkIP) + }) + + // The sentinel exemption, which is what keeps enforcement from breaking the shred-oracle. + // + // The oracle provisions multicast publishers owned by validators, for addresses the + // verification service never sees a request from, so there is no proof it could obtain. The + // program waives the *requirement* for a creation paid for by + // `globalstate.sentinel_authority_pk`. + // + // In this devnet the manager is that authority (`smartcontract_init.go` runs + // `authority set --sentinel-authority me`), and `doublezero user create` never attaches a + // proof at all, so a manager-side create is the exemption in action. The contrast with + // wildcard_pass_without_a_proof is the transaction payer, which is what `is_sentinel` compares. + t.Run("sentinel_authority_is_exempt", func(t *testing.T) { + // An address the manager owns a pass for. It has no container behind it; this subtest is + // about whether the creation is admitted, not about tunnels. + const sentinelUserIP = "9.0.0.9" + + _, err := dn.Manager.Exec(t.Context(), []string{"bash", "-c", + "doublezero access-pass set --accesspass-type prepaid --epochs max --client-ip " + + sentinelUserIP + " --user-payer me"}) + require.NoError(t, err) + + log.Info("==> Creating a user as the sentinel authority, with no proof") + out, err := dn.Manager.Exec(t.Context(), []string{"bash", "-c", + "doublezero user create --device " + device.Spec.Code + " --client-ip " + sentinelUserIP + " 2>&1"}) + log.Info("==> User create output", "output", string(out)) + require.NoError(t, err, "the sentinel authority must be exempt from the proof requirement: %s", string(out)) + + users, err := dn.Manager.Exec(t.Context(), []string{"bash", "-c", "doublezero user list"}) + require.NoError(t, err) + require.Contains(t, string(users), sentinelUserIP) + }) +} + +// A proof for an address other than the one being provisioned is refused. +// +// The daemon is told to provision an address this container does not own, so the CLI cannot bind +// its proof request to it (`probe_source_binding`) and the request leaves from the real CYOA +// address. The verifier signs what it observes, and `connect` refuses rather than binding an +// address it has no proof for. +// +// This needs its own devnet: DaemonClientIP is fixed when the client container starts, so the +// disagreement cannot be arranged on a client any other case can use. +// +// This asserts the client-side guard on purpose. The equivalent onchain error +// (`IpProofClientIpMismatch`, 108) is unreachable through the SDK, which pre-flights the same +// comparison before building a transaction; the program-level case is covered by +// `test_proof_for_a_different_client_ip_is_rejected`. +func TestE2E_IPOwnershipProof_EnforcedClientIpMismatchRefused(t *testing.T) { + t.Parallel() + + // Routable, in the CYOA range, and not an address this container holds. + const unownedIP = "9.0.0.7" + + dn, _, client, log := setupIPProofDevnet(t, devnet.IPVerifierSpec{}, devnet.ClientSpec{ + CYOANetworkIPHostID: 100, + DaemonClientIP: unownedIP, + }) + + log.Info("==> Enabling require-ip-ownership-proof") + require.NoError(t, dn.SetIPOwnershipProofFeatureFlag(t.Context(), true)) + + // The pass has to cover the address the daemon reports, or connect stops on the pass instead. + _, err := dn.Manager.Exec(t.Context(), []string{"bash", "-c", + "doublezero access-pass set --accesspass-type prepaid --epochs max --client-ip " + + unownedIP + " --user-payer " + client.Pubkey}) + require.NoError(t, err) + + log.Info("==> Connecting with a daemon client IP this host does not own", + "provisioning", unownedIP, "observable", client.CYOANetworkIP) + out, err := client.Exec(t.Context(), []string{"bash", "-c", "doublezero connect ibrl 2>&1"}) + output := string(out) + log.Info("==> Connect output", "output", output) + + require.Error(t, err, "connect must refuse a proof for an address other than the one it binds") + require.Contains(t, output, "The verification service observed this host at", + "the refusal must be the address disagreement, not an incidental failure") + require.NotContains(t, output, "✅ User Provisioned") + + requireNoUserForIP(t, dn, unownedIP) +} + +// setWildcardAccessPass grants the client a prepaid pass with no --client-ip, which lands at the +// UNSPECIFIED (0.0.0.0) PDA and admits any routable address its payer can prove. +func setWildcardAccessPass(t *testing.T, dn *devnet.Devnet, client *devnet.Client) { + t.Helper() + _, err := dn.Manager.Exec(t.Context(), []string{"bash", "-c", + "doublezero access-pass set --accesspass-type prepaid --epochs max --user-payer " + client.Pubkey}) + require.NoError(t, err) +} + +// requireNoUserForIP asserts a rejected creation left nothing onchain. +func requireNoUserForIP(t *testing.T, dn *devnet.Devnet, clientIP string) { + t.Helper() + users, err := dn.Manager.Exec(t.Context(), []string{"bash", "-c", "doublezero user list"}) + require.NoError(t, err) + require.NotContains(t, string(users), clientIP, + "no user may exist for a client whose creation was rejected") +} From eb902c18cbbde15e393913361a02de83f6b72066 Mon Sep 17 00:00:00 2001 From: Greg Mitchell Date: Tue, 1 Sep 2026 13:29:06 +0000 Subject: [PATCH 2/2] sdk-go/stress: refuse CreateUser under RFC-27 enforcement and name the proof errors Executor.CreateUser hand-packs a 12-byte payload with no ip_proof discriminant, no Instructions sysvar account and no Ed25519 instruction. It works today only because UserCreateArgs is BorshDeserializeIncremental and a None proof is accepted while require-ip-ownership-proof is clear. The moment the flag is set for an environment every creation this path submits fails with IpOwnershipProofRequired (105); the payer is not the sentinel authority, so the exemption does not apply. Teaching the builder to carry a proof would not help. The verifier signs only the address it observes a request originate from, and the one production consumer is the device-stress orchestrator, whose ClientIPBase + idx addresses are synthetic. Binding an address the caller does not control is the exact thing RFC-27 exists to prevent, so the tool is gated instead: CreateUser reads the feature flag once, caches it, and refuses with a message naming the flag and what to do about it, rather than spending a transaction per user to collect the same rejection. A failure to read global state warns and proceeds -- the program is the authority on enforcement, and an unreadable account is not grounds for refusing to submit anything. The ip_proof discriminant is now emitted explicitly, so the Go payload equals the Rust fixture byte for byte and the test drops its "minus the discriminant" exception. Relying on incremental defaulting left the assertion unable to tell an intentionally absent proof from a truncated payload. The custom-error map stopped at 90, so every RFC-27 rejection rendered as "unknown error code 105". Codes 91-118 gained names, and ClassifyProgramError annotates a transaction error with a named ProgramError so callers can match it with errors.Is while the original RPC error stays reachable. Uint128.Lo64 isolates a pre-existing wart: ByteReader.ReadU128 fills .High from the first eight encoded bytes, which Borsh little-endian makes the low half, so the fields are named the wrong way round. Fixtures and existing tests bake in that convention, so the accessor documents it in one place rather than renaming fields here. Also corrects CLAUDE.md, which still described the Go SDK as read-only. --- CHANGELOG.md | 1 + CLAUDE.md | 2 +- smartcontract/sdk/go/serviceability/errors.go | 208 ++++++++------- .../sdk/go/serviceability/executor.go | 93 ++++++- .../sdk/go/serviceability/feature_flags.go | 64 +++++ .../sdk/go/serviceability/ip_proof_errors.go | 82 ++++++ .../go/serviceability/ip_proof_errors_test.go | 239 ++++++++++++++++++ .../sdk/go/serviceability/user_crud_test.go | 23 +- 8 files changed, 606 insertions(+), 106 deletions(-) create mode 100644 smartcontract/sdk/go/serviceability/feature_flags.go create mode 100644 smartcontract/sdk/go/serviceability/ip_proof_errors.go create mode 100644 smartcontract/sdk/go/serviceability/ip_proof_errors_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f622b98be..d8f4a63546 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. - SDK + - The Go serviceability `Executor.CreateUser` now emits the RFC-27 `ip_proof` Option discriminant explicitly, so its payload matches the Rust `user_create_args` fixture byte for byte instead of relying on `BorshDeserializeIncremental` to default the missing field. It also refuses to submit when an environment has `require-ip-ownership-proof` set, with a message saying why, rather than spending a transaction per user to collect `IpOwnershipProofRequired` (105): the verifier signs only the address it observes a request originate from, so the synthetic addresses the device-stress orchestrator names cannot be proven at all. Serviceability custom error codes 91-118 gained names, and the RFC-27 rejection classes (105-118) are matchable with `errors.Is` via `ClassifyProgramError`. (#4225) - The TypeScript and Python `GlobalState` deserializers expose `ip_verifier_authority_pk`, the RFC-27 trust root the Go SDK and the Rust state already carried, so those consumers can read which key signs IP ownership proofs. The field is appended, so an account written before the upgrade decodes it as the default pubkey rather than failing. (#4231) - CI - The e2e matrix runs 5 round-robin shards instead of 4. Shards are filled by test count, not by duration, so the heaviest one was carrying ~3735s of tests against a 15-minute job timeout and was cancelled mid-run; the extra shard brings the worst case back to ~3022s. Adding `e2e (shard 6)` to the required status checks in the main ruleset is a separate, manual step. diff --git a/CLAUDE.md b/CLAUDE.md index e8a7dd7a93..57c97f1a70 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ DoubleZero is a protocol for building and operating high-performance, permission | `controlplane/` | Go | Controller pushes configs to devices; agent runs on Arista EOS; funder, monitor, admin tools | | `telemetry/` | Go | Flow ingestion (NetFlow/IPFIX), gNMI writer, global monitor → ClickHouse/InfluxDB | | `api/` | Go | API server | -| `sdk/` | Go, Python, TypeScript | Read-only account deserialization for serviceability, telemetry, revenue distribution | +| `sdk/` | Go, Python, TypeScript | Account deserialization for serviceability, telemetry, revenue distribution. Python and TypeScript are read-only; the Go serviceability SDK also writes (`Executor` submits `CreateUser`, `DeleteUser`, health and BGP-status instructions) | | `e2e/` | Go | End-to-end tests using testcontainers-go with Arista cEOS devices | **Onchain state lifecycle:** Created in `Activated`; admin/operator instructions drive subsequent transitions. diff --git a/smartcontract/sdk/go/serviceability/errors.go b/smartcontract/sdk/go/serviceability/errors.go index 0a84dc9bf1..bbf83123ce 100644 --- a/smartcontract/sdk/go/serviceability/errors.go +++ b/smartcontract/sdk/go/serviceability/errors.go @@ -11,96 +11,124 @@ import ( // programErrors maps custom error codes from the doublezero-serviceability // program (DoubleZeroError enum) to their names. var programErrors = map[uint32]string{ - 1: "InvalidOwnerPubkey", - 2: "InvalidLocationPubkey", - 3: "InvalidExchangePubkey", - 4: "InvalidDeviceAPubkey", - 5: "InvalidDeviceZPubkey", - 6: "InvalidDevicePubkey", - 7: "InvalidStatus", - 8: "NotAllowed", - 9: "InvalidAccountType", - 10: "InvalidContributorPubkey", - 11: "InvalidInterfaceVersion", - 12: "InvalidInterfaceName", - 13: "ReferenceCountNotZero", - 14: "InvalidContributor", - 15: "InvalidInterfaceZForExternal", - 16: "InvalidIndex", - 17: "DeviceAlreadySet", - 18: "DeviceNotSet", - 19: "InvalidAccountCode", - 20: "MaxUsersExceeded", - 21: "InvalidLastAccessEpoch", - 22: "Unauthorized", - 23: "InvalidSolanaPubkey", - 24: "InterfaceNotFound", - 25: "AccessPassUnauthorized", - 26: "InvalidClientIp", - 27: "InvalidDzIp", - 28: "InvalidTunnelNet", - 29: "InvalidTunnelId", - 30: "InvalidTunnelIp", - 31: "InvalidBandwidth", - 32: "InvalidDelay", - 33: "InvalidJitter", - 34: "CodeTooLong", - 35: "NoDzPrefixes", - 36: "InvalidLocation", - 37: "InvalidExchange", - 38: "InvalidDzPrefix", - 39: "NameTooLong", - 40: "InvalidLatitude", - 41: "InvalidLongitude", - 42: "InvalidLocId", - 43: "InvalidCountryCode", - 44: "InvalidLocalAsn", - 45: "InvalidRemoteAsn", - 46: "InvalidMtu", - 47: "InvalidInterfaceIp", - 48: "InvalidInterfaceIpNet", - 49: "InvalidVlanId", - 50: "InvalidMaxBandwidth", - 51: "InvalidMulticastIp", - 52: "InvalidAccountOwner", - 53: "AccessPassNotFound", - 54: "UserAccountNotFound", - 55: "InvalidBgpCommunity", - 56: "InterfaceAlreadyExists", - 57: "InvalidInterfaceType", - 58: "InvalidLoopbackType", - 59: "InvalidMinCompatibleVersion", - 60: "InvalidActualLocation", - 61: "InvalidUserPubkey", - 62: "InvalidPublicIp", - 63: "AllocationFailed", - 64: "SerializationFailure", - 65: "InvalidArgument", - 66: "InvalidFoundationAllowlist", - 67: "Deprecated", - 68: "ImmutableField", - 69: "CyoaRequiresPhysical", - 70: "DeviceHasInterfaces", - 71: "MulticastGroupNotEmpty", - 72: "AccessPassInUse", - 73: "InvalidTenantPubkey", - 74: "InvalidVrfId", - 75: "VrfIdTooLong", - 76: "AdministratorAlreadyExists", - 77: "AdministratorNotFound", - 78: "InvalidPaymentStatus", - 79: "TenantNotInAccessPassAllowlist", - 80: "InvalidTunnelEndpoint", - 81: "MaxUnicastUsersExceeded", - 82: "MaxMulticastSubscribersExceeded", - 83: "InterfaceHasEdgeAssignment", - 84: "FeatureNotEnabled", - 85: "MaxMulticastPublishersExceeded", - 86: "ArithmeticOverflow", - 87: "InvalidName", - 88: "InvalidDeviceTunnelBlock", - 89: "AccessPassMaxUnicastUsersExceeded", - 90: "AccessPassMaxMulticastUsersExceeded", + 1: "InvalidOwnerPubkey", + 2: "InvalidLocationPubkey", + 3: "InvalidExchangePubkey", + 4: "InvalidDeviceAPubkey", + 5: "InvalidDeviceZPubkey", + 6: "InvalidDevicePubkey", + 7: "InvalidStatus", + 8: "NotAllowed", + 9: "InvalidAccountType", + 10: "InvalidContributorPubkey", + 11: "InvalidInterfaceVersion", + 12: "InvalidInterfaceName", + 13: "ReferenceCountNotZero", + 14: "InvalidContributor", + 15: "InvalidInterfaceZForExternal", + 16: "InvalidIndex", + 17: "DeviceAlreadySet", + 18: "DeviceNotSet", + 19: "InvalidAccountCode", + 20: "MaxUsersExceeded", + 21: "InvalidLastAccessEpoch", + 22: "Unauthorized", + 23: "InvalidSolanaPubkey", + 24: "InterfaceNotFound", + 25: "AccessPassUnauthorized", + 26: "InvalidClientIp", + 27: "InvalidDzIp", + 28: "InvalidTunnelNet", + 29: "InvalidTunnelId", + 30: "InvalidTunnelIp", + 31: "InvalidBandwidth", + 32: "InvalidDelay", + 33: "InvalidJitter", + 34: "CodeTooLong", + 35: "NoDzPrefixes", + 36: "InvalidLocation", + 37: "InvalidExchange", + 38: "InvalidDzPrefix", + 39: "NameTooLong", + 40: "InvalidLatitude", + 41: "InvalidLongitude", + 42: "InvalidLocId", + 43: "InvalidCountryCode", + 44: "InvalidLocalAsn", + 45: "InvalidRemoteAsn", + 46: "InvalidMtu", + 47: "InvalidInterfaceIp", + 48: "InvalidInterfaceIpNet", + 49: "InvalidVlanId", + 50: "InvalidMaxBandwidth", + 51: "InvalidMulticastIp", + 52: "InvalidAccountOwner", + 53: "AccessPassNotFound", + 54: "UserAccountNotFound", + 55: "InvalidBgpCommunity", + 56: "InterfaceAlreadyExists", + 57: "InvalidInterfaceType", + 58: "InvalidLoopbackType", + 59: "InvalidMinCompatibleVersion", + 60: "InvalidActualLocation", + 61: "InvalidUserPubkey", + 62: "InvalidPublicIp", + 63: "AllocationFailed", + 64: "SerializationFailure", + 65: "InvalidArgument", + 66: "InvalidFoundationAllowlist", + 67: "Deprecated", + 68: "ImmutableField", + 69: "CyoaRequiresPhysical", + 70: "DeviceHasInterfaces", + 71: "MulticastGroupNotEmpty", + 72: "AccessPassInUse", + 73: "InvalidTenantPubkey", + 74: "InvalidVrfId", + 75: "VrfIdTooLong", + 76: "AdministratorAlreadyExists", + 77: "AdministratorNotFound", + 78: "InvalidPaymentStatus", + 79: "TenantNotInAccessPassAllowlist", + 80: "InvalidTunnelEndpoint", + 81: "MaxUnicastUsersExceeded", + 82: "MaxMulticastSubscribersExceeded", + 83: "InterfaceHasEdgeAssignment", + 84: "FeatureNotEnabled", + 85: "MaxMulticastPublishersExceeded", + 86: "ArithmeticOverflow", + 87: "InvalidName", + 88: "InvalidDeviceTunnelBlock", + 89: "AccessPassMaxUnicastUsersExceeded", + 90: "AccessPassMaxMulticastUsersExceeded", + 91: "MetroMismatch", + 92: "FeedAccountRequired", + 93: "FeedNotOnAccessPass", + 94: "GroupNotInFeed", + 95: "FeedSeatFull", + 96: "FeedMaxUsersZero", + 97: "FeedMaxUsersBelowCurrentUsers", + 98: "FeedMaxFutureUsersBelowMaxUsers", + 99: "FeedInvalidAnniversaryDay", + 100: "FeedInvalidBillingWindow", + 101: "EdgeSeatRequired", + 102: "UserDeviceMismatch", + 103: "UserFeedLimitExceeded", + 104: "EdgeSeatIsMulticastOnly", + 105: "IpOwnershipProofRequired", + 106: "IpVerifierNotConfigured", + 107: "IpProofPayerMismatch", + 108: "IpProofClientIpMismatch", + 109: "IpProofUserTypeMismatch", + 110: "IpProofEpochOutOfWindow", + 111: "IpProofInstructionsSysvarMissing", + 112: "IpProofEd25519InstructionMissing", + 113: "IpProofEd25519OffsetsInvalid", + 114: "IpProofSignatureCountInvalid", + 115: "IpProofVerifierKeyMismatch", + 116: "IpProofSignatureMismatch", + 117: "IpProofMessageMismatch", + 118: "IpProofVersionUnsupported", } // ProgramErrorMessage returns a human-readable message for the given custom error code. diff --git a/smartcontract/sdk/go/serviceability/executor.go b/smartcontract/sdk/go/serviceability/executor.go index c0f2110254..37dd0d58fd 100644 --- a/smartcontract/sdk/go/serviceability/executor.go +++ b/smartcontract/sdk/go/serviceability/executor.go @@ -39,6 +39,12 @@ type Executor struct { permissionOnce sync.Once permissionPDA *solana.PublicKey // nil if no Permission account exists for this signer + + // Cached GlobalState feature flags, read once on the first user creation. + // See requireIPOwnershipProofEnabled. + featureFlagsOnce sync.Once + featureFlags Uint128 + featureFlagsErr error } type ExecutorRPCClient interface { @@ -206,9 +212,17 @@ func (e *Executor) CreateUser(ctx context.Context, args UserCreateArgs) (solana. return solana.Signature{}, solana.PublicKey{}, fmt.Errorf("build CreateUser instruction: %w", err) } + // RFC-27. This builder cannot carry an IpOwnershipProof, so once the + // environment enforces one every creation it submits would be rejected + // onchain with IpOwnershipProofRequired (105). Fail before spending a + // transaction, and say why. + if err := e.checkIPOwnershipProofNotRequired(ctx); err != nil { + return solana.Signature{}, userPDA, err + } + sig, _, err := e.executeTransaction(ctx, []solana.Instruction{instr}) if err != nil { - return sig, userPDA, err + return sig, userPDA, ClassifyProgramError(err) } if err := e.waitForAccountVisible(ctx, userPDA, e.waitForVisibleTimeout); err != nil { @@ -278,13 +292,24 @@ func (e *Executor) DeleteUser(ctx context.Context, userPubkey solana.PublicKey) // user_tunnel_block, multicast_publisher_block, device_tunnel_ids, // dz_prefix_block[0..N], optional_tenant, payer, system] func (e *Executor) buildCreateUserInstruction(args UserCreateArgs) (solana.Instruction, solana.PublicKey, error) { - data := make([]byte, 12) + // Layout: variant || user_type || cyoa_type || client_ip[4] || tunnel_endpoint[4] + // || dz_prefix_count || ip_proof discriminant. + // + // The trailing zero is Borsh's Option::None for the RFC-27 ip_proof field + // (see UserCreateArgs in processors/user/create.rs). It is emitted + // explicitly rather than left off: UserCreateArgs is + // BorshDeserializeIncremental, so a shorter payload would also decode as + // None, but relying on that makes the Go payload disagree with the Rust + // fixture and hides the omission. This builder cannot carry a real proof — + // see CreateUser for why. + data := make([]byte, 13) data[0] = instructionCreateUser data[1] = byte(args.UserType) data[2] = byte(args.CyoaType) copy(data[3:7], args.ClientIP[:]) copy(data[7:11], args.TunnelEndpoint[:]) data[11] = args.DzPrefixCount + data[12] = 0 userPDA, _, err := GetUserPDA(e.programID, args.ClientIP, args.UserType) if err != nil { @@ -519,6 +544,70 @@ func (e *Executor) resolvePermissionPDA(ctx context.Context) { }) } +// ErrIPOwnershipProofUnsupported is returned by CreateUser when the environment +// has FeatureRequireIPOwnershipProof set. This SDK's CreateUser builder cannot +// attach an IpOwnershipProof, and it deliberately does not try: the verifier +// signs only the address it observes a request originate from, so an address +// this executor merely names — a synthetic stress-test address in particular — +// cannot be proven at all. RFC-27 exists to stop exactly that binding. +// +// Run against an environment with the flag clear, or create the users with the +// doublezero CLI, which obtains a real proof during connect. +var ErrIPOwnershipProofUnsupported = errors.New( + "serviceability: require-ip-ownership-proof is set for this environment, but the Go SDK's " + + "CreateUser cannot attach an RFC-27 IpOwnershipProof (the verifier signs only the address it " + + "observes a request from); use an environment with the flag clear, or the doublezero CLI") + +// loadFeatureFlags reads GlobalState.feature_flags once and caches the result, +// including a failure, so a sweep does not re-read it per user. +func (e *Executor) loadFeatureFlags(ctx context.Context) (Uint128, error) { + e.featureFlagsOnce.Do(func() { + pda, _, err := GetGlobalStatePDA(e.programID) + if err != nil { + e.featureFlagsErr = fmt.Errorf("derive globalstate PDA: %w", err) + return + } + info, err := e.rpc.GetAccountInfo(ctx, pda) + if err != nil { + e.featureFlagsErr = fmt.Errorf("read globalstate account: %w", err) + return + } + if info == nil || info.Value == nil { + e.featureFlagsErr = fmt.Errorf("globalstate account %s not found", pda) + return + } + data := info.Value.Data.GetBinary() + if len(data) == 0 { + e.featureFlagsErr = fmt.Errorf("globalstate account %s is empty", pda) + return + } + var gs GlobalState + DeserializeGlobalState(NewByteReader(data), &gs) + e.featureFlags = gs.FeatureFlags + }) + return e.featureFlags, e.featureFlagsErr +} + +// checkIPOwnershipProofNotRequired returns ErrIPOwnershipProofUnsupported when +// the environment requires an RFC-27 proof that this SDK cannot supply. +// +// When global state cannot be read the check warns and allows the creation +// through: the program is the authority on enforcement, and a rejection now +// surfaces as a named ErrIPOwnershipProofRequired via ClassifyProgramError. An +// unreadable account is not grounds for refusing to submit anything. +func (e *Executor) checkIPOwnershipProofNotRequired(ctx context.Context) error { + flags, err := e.loadFeatureFlags(ctx) + if err != nil { + e.log.Warn("could not read feature flags; submitting without checking RFC-27 enforcement", + "flag", FeatureRequireIPOwnershipProof.String(), "error", err) + return nil + } + if flags.Lo64()&FeatureRequireIPOwnershipProof.Mask() != 0 { + return ErrIPOwnershipProofUnsupported + } + return nil +} + func (e *Executor) executeTransaction(ctx context.Context, instructions []solana.Instruction) (solana.Signature, *solanarpc.GetTransactionResult, error) { if e.signer == nil { return solana.Signature{}, nil, ErrNoPrivateKey diff --git a/smartcontract/sdk/go/serviceability/feature_flags.go b/smartcontract/sdk/go/serviceability/feature_flags.go new file mode 100644 index 0000000000..d52e687a19 --- /dev/null +++ b/smartcontract/sdk/go/serviceability/feature_flags.go @@ -0,0 +1,64 @@ +package serviceability + +// FeatureFlag mirrors the FeatureFlag enum in +// smartcontract/programs/doublezero-serviceability/src/state/feature_flags.rs. +// The values are bit positions in GlobalState.feature_flags (a u128), so they +// must stay in lockstep with the Rust enum. +type FeatureFlag uint8 + +const ( + // FeatureOnChainAllocationDeprecated is bit 0, formerly OnChainAllocation and + // now always-on. Reserved; never reuse it for a new flag. + FeatureOnChainAllocationDeprecated FeatureFlag = 0 + // FeatureRequirePermissionAccounts is bit 1. When set, authorization requires + // a Permission account and the GlobalState allowlist fallback is disabled. + FeatureRequirePermissionAccounts FeatureFlag = 1 + // FeatureRequireIPOwnershipProof is bit 2 (RFC-27). When set, user creation + // requires a valid IpOwnershipProof signed by + // GlobalState.ip_verifier_authority_pk. + FeatureRequireIPOwnershipProof FeatureFlag = 2 +) + +// String returns the flag's canonical name, matching the Rust Display impl and +// the string the CLI accepts. +func (f FeatureFlag) String() string { + switch f { + case FeatureOnChainAllocationDeprecated: + return "onchain-allocation-deprecated" + case FeatureRequirePermissionAccounts: + return "require-permission-accounts" + case FeatureRequireIPOwnershipProof: + return "require-ip-ownership-proof" + default: + return "unknown" + } +} + +// Mask returns the bitmask for the flag. +func (f FeatureFlag) Mask() uint64 { + return 1 << uint64(f) +} + +// Lo64 returns the low 64 bits of the u128. +// +// ByteReader.ReadU128 fills Uint128.High from the FIRST eight encoded bytes. +// Borsh writes a u128 little-endian, so those first eight bytes are the LOW +// half of the value — the two fields are named the wrong way round. Existing +// callers and fixtures bake in that convention (see client_test.go, which +// stores small account indexes in High), so this accessor isolates the wart in +// one place rather than renaming the fields. +// +// Do not "correct" this to u.Low without fixing ReadU128 and every caller +// together. +func (u Uint128) Lo64() uint64 { + return u.High +} + +// IsFeatureEnabled reports whether the given feature flag is set in global +// state. All defined flags live in the low 64 bits. +func (gs *GlobalState) IsFeatureEnabled(f FeatureFlag) bool { + if gs == nil { + return false + } + return gs.FeatureFlags.Lo64()&f.Mask() != 0 +} diff --git a/smartcontract/sdk/go/serviceability/ip_proof_errors.go b/smartcontract/sdk/go/serviceability/ip_proof_errors.go new file mode 100644 index 0000000000..f3d55ee1b8 --- /dev/null +++ b/smartcontract/sdk/go/serviceability/ip_proof_errors.go @@ -0,0 +1,82 @@ +package serviceability + +import ( + "errors" + "fmt" +) + +// ProgramError is a doublezero-serviceability custom program error, identified by +// the code the program returns through `InstructionError: {Custom: N}`. +// +// It is a comparable value type, so the named sentinels below match with +// errors.Is once a transaction error has been annotated by +// ClassifyProgramError. +type ProgramError struct { + Code uint32 +} + +func (e ProgramError) Error() string { + return fmt.Sprintf("%s (custom program error %d)", ProgramErrorMessage(e.Code), e.Code) +} + +// The RFC-27 IP-ownership-proof rejection classes (codes 105-118), named so a +// caller can tell a missing proof from a stale one from a rotated verifier key +// instead of reading a bare error code out of an RPC blob. +// +// See rfcs/rfc27-ip-verification.md and the DoubleZeroError enum in +// smartcontract/programs/doublezero-serviceability/src/error.rs. +var ( + ErrIPOwnershipProofRequired = ProgramError{Code: 105} + ErrIPVerifierNotConfigured = ProgramError{Code: 106} + ErrIPProofPayerMismatch = ProgramError{Code: 107} + ErrIPProofClientIPMismatch = ProgramError{Code: 108} + ErrIPProofUserTypeMismatch = ProgramError{Code: 109} + ErrIPProofEpochOutOfWindow = ProgramError{Code: 110} + ErrIPProofInstructionsSysvarMissing = ProgramError{Code: 111} + ErrIPProofEd25519InstructionMissing = ProgramError{Code: 112} + ErrIPProofEd25519OffsetsInvalid = ProgramError{Code: 113} + ErrIPProofSignatureCountInvalid = ProgramError{Code: 114} + ErrIPProofVerifierKeyMismatch = ProgramError{Code: 115} + ErrIPProofSignatureMismatch = ProgramError{Code: 116} + ErrIPProofMessageMismatch = ProgramError{Code: 117} + ErrIPProofVersionUnsupported = ProgramError{Code: 118} +) + +// classifiedProgramError carries the named ProgramError alongside the original +// transaction error, so errors.Is matches either one and the RPC detail is not +// thrown away. +type classifiedProgramError struct { + named ProgramError + cause error +} + +func (e *classifiedProgramError) Error() string { return formatRPCError(e.cause) } + +func (e *classifiedProgramError) Unwrap() []error { return []error{e.named, e.cause} } + +// ClassifyProgramError annotates a transaction error with the named +// ProgramError for its custom error code, so a caller can match it with +// errors.Is (e.g. against ErrIPOwnershipProofRequired) and an operator reading +// the message sees the error name rather than a raw JSON-RPC blob. +// +// Returns err unchanged when it carries no serviceability custom error code. +func ClassifyProgramError(err error) error { + if err == nil { + return nil + } + code, ok := parseCustomErrorCode(err) + if !ok { + return err + } + return &classifiedProgramError{named: ProgramError{Code: code}, cause: err} +} + +// AsProgramError reports the serviceability custom error code carried by err, +// if any. +func AsProgramError(err error) (ProgramError, bool) { + var pe ProgramError + if errors.As(err, &pe) { + return pe, true + } + return ProgramError{}, false +} diff --git a/smartcontract/sdk/go/serviceability/ip_proof_errors_test.go b/smartcontract/sdk/go/serviceability/ip_proof_errors_test.go new file mode 100644 index 0000000000..9dd231049b --- /dev/null +++ b/smartcontract/sdk/go/serviceability/ip_proof_errors_test.go @@ -0,0 +1,239 @@ +package serviceability + +import ( + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "log/slog" + "testing" + + "github.com/gagliardetto/solana-go" + solanarpc "github.com/gagliardetto/solana-go/rpc" + "github.com/gagliardetto/solana-go/rpc/jsonrpc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// customErr builds the RPC error shape a serviceability program rejection +// arrives in: InstructionError: [idx, {Custom: code}]. +func customErr(code uint32) error { + return &jsonrpc.RPCError{ + Code: -32002, + Message: "Transaction simulation failed", + Data: map[string]any{ + "err": map[string]any{ + "InstructionError": []any{ + json.Number("0"), + map[string]any{"Custom": json.Number(fmt.Sprint(code))}, + }, + }, + }, + } +} + +func TestProgramErrorNamesCoverRFC27(t *testing.T) { + t.Parallel() + + // Every RFC-27 rejection class must have a name, or an operator sees a bare + // code. Codes come from DoubleZeroError in + // smartcontract/programs/doublezero-serviceability/src/error.rs. + want := map[uint32]string{ + 105: "IpOwnershipProofRequired", + 106: "IpVerifierNotConfigured", + 107: "IpProofPayerMismatch", + 108: "IpProofClientIpMismatch", + 109: "IpProofUserTypeMismatch", + 110: "IpProofEpochOutOfWindow", + 111: "IpProofInstructionsSysvarMissing", + 112: "IpProofEd25519InstructionMissing", + 113: "IpProofEd25519OffsetsInvalid", + 114: "IpProofSignatureCountInvalid", + 115: "IpProofVerifierKeyMismatch", + 116: "IpProofSignatureMismatch", + 117: "IpProofMessageMismatch", + 118: "IpProofVersionUnsupported", + } + for code, name := range want { + assert.Equal(t, name, ProgramErrorMessage(code), "code %d", code) + } +} + +func TestClassifyProgramErrorMapsIPOwnershipProofRequired(t *testing.T) { + t.Parallel() + + err := ClassifyProgramError(customErr(105)) + require.Error(t, err) + + // The named sentinel matches... + assert.ErrorIs(t, err, ErrIPOwnershipProofRequired) + // ...and only that one. + assert.NotErrorIs(t, err, ErrIPProofEpochOutOfWindow) + + // The original RPC error stays reachable. + var rpcErr *jsonrpc.RPCError + assert.ErrorAs(t, err, &rpcErr) + + // The message names the error rather than dumping the RPC blob. + assert.Contains(t, err.Error(), "IpOwnershipProofRequired") + + pe, ok := AsProgramError(err) + require.True(t, ok) + assert.Equal(t, uint32(105), pe.Code) +} + +func TestClassifyProgramErrorPassesThroughUnrelatedErrors(t *testing.T) { + t.Parallel() + + assert.NoError(t, ClassifyProgramError(nil)) + + plain := errors.New("connection refused") + assert.Equal(t, plain, ClassifyProgramError(plain)) + + _, ok := AsProgramError(plain) + assert.False(t, ok) +} + +// globalStateBytes encodes a GlobalState account with the given feature flags, +// in the field order DeserializeGlobalState reads. +func globalStateBytes(featureFlags uint64) []byte { + var b []byte + b = append(b, 1, 254) // account_type, bump_seed + b = append(b, make([]byte, 16)...) // account_index u128 + b = append(b, 0, 0, 0, 0) // foundation_allowlist len + b = append(b, 0, 0, 0, 0) // deprecated device_allowlist len + b = append(b, 0, 0, 0, 0) // deprecated user_allowlist len + b = append(b, make([]byte, 32)...) // activator_authority_pk + b = append(b, make([]byte, 32)...) // sentinel_authority_pk + b = append(b, make([]byte, 8)...) // contributor_airdrop_lamports + b = append(b, make([]byte, 8)...) // user_airdrop_lamports + b = append(b, make([]byte, 32)...) // health_oracle_pk + b = append(b, 0, 0, 0, 0) // qa_allowlist len + flags := make([]byte, 16) // feature_flags u128, little-endian + binary.LittleEndian.PutUint64(flags[:8], featureFlags) + b = append(b, flags...) + b = append(b, make([]byte, 32)...) // feed_authority_pk + b = append(b, make([]byte, 32)...) // ip_verifier_authority_pk + return b +} + +func TestGlobalStateIsFeatureEnabled(t *testing.T) { + t.Parallel() + + // Round-trip through the real deserializer so the Lo64 wart is exercised + // rather than assumed. + parse := func(flags uint64) *GlobalState { + var gs GlobalState + DeserializeGlobalState(NewByteReader(globalStateBytes(flags)), &gs) + return &gs + } + + none := parse(0) + assert.False(t, none.IsFeatureEnabled(FeatureRequireIPOwnershipProof)) + assert.False(t, none.IsFeatureEnabled(FeatureRequirePermissionAccounts)) + + // Bit 2 only. + ipOnly := parse(1 << 2) + assert.True(t, ipOnly.IsFeatureEnabled(FeatureRequireIPOwnershipProof)) + assert.False(t, ipOnly.IsFeatureEnabled(FeatureRequirePermissionAccounts)) + assert.False(t, ipOnly.IsFeatureEnabled(FeatureOnChainAllocationDeprecated)) + + // Bit 1 must not be mistaken for bit 2. + permOnly := parse(1 << 1) + assert.False(t, permOnly.IsFeatureEnabled(FeatureRequireIPOwnershipProof)) + assert.True(t, permOnly.IsFeatureEnabled(FeatureRequirePermissionAccounts)) + + var nilGS *GlobalState + assert.False(t, nilGS.IsFeatureEnabled(FeatureRequireIPOwnershipProof)) + + assert.Equal(t, "require-ip-ownership-proof", FeatureRequireIPOwnershipProof.String()) +} + +// globalStateRPC serves a GlobalState account carrying the given flags for the +// program's global-state PDA, and reports how many times it was read. +func globalStateRPC(t *testing.T, programID solana.PublicKey, flags uint64, reads *int) *mockRPCClient { + t.Helper() + pda, _, err := GetGlobalStatePDA(programID) + require.NoError(t, err) + return &mockRPCClient{ + getAccountInfoFunc: func(_ context.Context, account solana.PublicKey) (*solanarpc.GetAccountInfoResult, error) { + if account.Equals(pda) { + *reads++ + return &solanarpc.GetAccountInfoResult{ + Value: &solanarpc.Account{ + Data: solanarpc.DataBytesOrJSONFromBytes(globalStateBytes(flags)), + }, + }, nil + } + return nil, solanarpc.ErrNotFound + }, + } +} + +func TestCreateUserRefusesWhenProofRequired(t *testing.T) { + t.Parallel() + + signer := solana.NewWallet().PrivateKey + programID := solana.NewWallet().PublicKey() + + reads := 0 + rpc := globalStateRPC(t, programID, 1<<2, &reads) + executor := NewExecutor(slog.Default(), rpc, &signer, programID) + + args := UserCreateArgs{ + UserType: UserTypeIBRL, + CyoaType: CyoaTypeGREOverDIA, + ClientIP: [4]byte{10, 11, 12, 13}, + TunnelEndpoint: [4]byte{192, 168, 1, 2}, + DzPrefixCount: 1, + DevicePubkey: solana.NewWallet().PublicKey(), + } + + sig, userPDA, err := executor.CreateUser(context.Background(), args) + require.ErrorIs(t, err, ErrIPOwnershipProofUnsupported) + + // Nothing was submitted: the point is to fail before spending a transaction. + assert.Empty(t, rpc.sentTransactions) + assert.True(t, sig.IsZero()) + + // The PDA is still returned so a caller can correlate the refusal. + expectedPDA, _, derr := GetUserPDA(programID, args.ClientIP, args.UserType) + require.NoError(t, derr) + assert.Equal(t, expectedPDA, userPDA) + + // The message has to tell an operator what to do about it. + assert.Contains(t, err.Error(), "require-ip-ownership-proof") + + // A second attempt reuses the cached flags rather than re-reading. + _, _, err = executor.CreateUser(context.Background(), args) + require.ErrorIs(t, err, ErrIPOwnershipProofUnsupported) + assert.Equal(t, 1, reads, "feature flags must be read once and cached") +} + +func TestCreateUserProceedsWhenProofNotRequired(t *testing.T) { + t.Parallel() + + signer := solana.NewWallet().PrivateKey + programID := solana.NewWallet().PublicKey() + + reads := 0 + rpc := globalStateRPC(t, programID, 0, &reads) + executor := NewExecutor(slog.Default(), rpc, &signer, programID) + + args := UserCreateArgs{ + UserType: UserTypeIBRL, + CyoaType: CyoaTypeGREOverDIA, + ClientIP: [4]byte{10, 11, 12, 14}, + TunnelEndpoint: [4]byte{192, 168, 1, 2}, + DzPrefixCount: 1, + DevicePubkey: solana.NewWallet().PublicKey(), + } + + // The user PDA never becomes visible against this mock, so the call fails at + // the visibility wait — after the transaction was submitted, which is what + // this test cares about. + _, _, err := executor.CreateUser(context.Background(), args) + assert.NotErrorIs(t, err, ErrIPOwnershipProofUnsupported) + assert.Len(t, rpc.sentTransactions, 1, "the flag is clear, so the create must be submitted") +} diff --git a/smartcontract/sdk/go/serviceability/user_crud_test.go b/smartcontract/sdk/go/serviceability/user_crud_test.go index 59ea8ed616..fce1512939 100644 --- a/smartcontract/sdk/go/serviceability/user_crud_test.go +++ b/smartcontract/sdk/go/serviceability/user_crud_test.go @@ -46,24 +46,21 @@ func TestBuildCreateUserInstruction(t *testing.T) { instr, userPDA, err := executor.buildCreateUserInstruction(args) require.NoError(t, err) - // Variant byte + 11-byte borsh body matching Rust UserCreateArgs. + // Variant byte + 12-byte borsh body matching Rust UserCreateArgs. data, err := instr.Data() require.NoError(t, err) - require.Len(t, data, 12, "opcode (1) + borsh UserCreateArgs (11) = 12 bytes") + require.Len(t, data, 13, "opcode (1) + borsh UserCreateArgs (12) = 13 bytes") assert.Equal(t, byte(instructionCreateUser), data[0]) - // Rust's UserCreateArgs gained a trailing RFC-27 ip_proof (issue #4197), so the - // fixture is one byte longer: an Option discriminant of 0 for "no proof". This - // builder does not carry a proof yet (issue #4200), and the program decodes the - // shorter payload as None via BorshDeserializeIncremental, so what the builder - // emits must equal the fixture with that discriminant removed. Both halves are - // asserted, so neither the shared fields nor the assumption of an absent proof - // can drift unnoticed. + // The borsh body must equal the Rust-generated fixture byte for byte, + // trailing RFC-27 ip_proof discriminant included. The builder emits that + // discriminant explicitly (issue #4225): BorshDeserializeIncremental would + // also decode a shorter payload as None, but then this assertion could not + // tell an intentionally absent proof from a truncated payload. fixture := loadArgsFixture(t, "user_create_args") - require.Len(t, fixture, 12, "fixture is the borsh body including the ip_proof discriminant") - assert.Equal(t, byte(0), fixture[len(fixture)-1], "fixture must encode ip_proof as absent") - assert.Equal(t, fixture[:len(fixture)-1], data[1:], - "borsh body must match Rust-generated user_create_args.bin up to ip_proof") + assert.Equal(t, fixture, data[1:], + "borsh body must match Rust-generated user_create_args.bin exactly") + assert.Equal(t, byte(0), data[len(data)-1], "ip_proof must be encoded as absent") // User PDA derivation is deterministic from (program_id, client_ip, user_type). expectedPDA, _, err := GetUserPDA(executor.programID, args.ClientIP, args.UserType)