Part of RFC-27 (tracker: #4194). Depends on #4197 (merged as #4211).
Why
The Go serviceability SDK is a writer, not just a reader. smartcontract/sdk/go/serviceability/executor.go submits CreateUser (variant 36) with a hand-packed 12-byte payload:
data := make([]byte, 12)
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
There is no Option<IpOwnershipProof> discriminant, no Instructions sysvar account, and no Ed25519 instruction. It works today for two reasons that both expire: UserCreateArgs is BorshDeserializeIncremental, so the program defaults the missing field to None; and while FeatureFlag::RequireIpOwnershipProof is clear, None is accepted.
The moment the flag is set for an environment, every user creation this path submits fails with IpOwnershipProofRequired (custom error 105). The payer is not the sentinel authority, so the exemption in validate_ip_ownership_proof does not apply.
smartcontract/sdk/go/serviceability/user_crud_test.go already carries a comment pointing at this, added in #4211; it asserts the builder's payload equals the Rust fixture minus the ip_proof discriminant, which is exactly the assumption that breaks.
Who is affected
Executor.CreateUser has one production consumer: the device-stress orchestrator. reconcile.PlanFor decides how many users to add or remove, pkg/sweep/sweep.go executes the plan, and pkg/exec/exec.go calls Executor.CreateUser per user.
tools/stress/device-orchestrator/pkg/reconcile/reconcile.go — pure planner
tools/stress/device-orchestrator/pkg/sweep/sweep.go:741
tools/stress/device-orchestrator/pkg/exec/exec.go:89
Executor does not implement CreateSubscribeUser (variant 59), so only the unicast path is in scope.
Note also that CLAUDE.md still describes the Go SDK as "read-only account deserialization". That is no longer true and should be corrected as part of this work.
Decision needed first
Whether the stress orchestrator must run against a proof-enforced environment at all. The two answers lead to very different amounts of work:
A. Teach the Go executor to carry a proof. Needs a Go port of the doublezero-ip-proof layout, an Ed25519 precompile instruction builder, a GlobalState.ip_verifier_authority_pk read, and an HTTP client for the verifier's POST /v1/proof. The verifier signs the address it observes the request originate from, so the orchestrator's synthetic ClientIPBase + idx addresses cannot be proven at all — it would need a different mechanism, which means this option is not simply "port the crate".
B. Gate the tool instead. Have Executor.CreateUser read the feature flag and fail fast with a clear message when enforcement is on, and run stress sweeps only against environments where the flag is clear. Cheap, honest, and does not invent a proof path for synthetic addresses.
Option B looks right on the evidence above — the tool binds addresses it demonstrably does not control, which is the exact thing RFC-27 exists to prevent. Confirm before implementing.
What (either way)
- Emit the
ip_proof discriminant explicitly rather than relying on incremental defaulting, so the Go payload and the Rust fixture agree byte for byte and TestBuildCreateUserInstruction can drop its "minus the discriminant" special case.
- Surface the RFC-27 rejection classes (
IpOwnershipProofRequired and friends, custom errors 105–118) as named Go errors, so an operator sees why a sweep stopped rather than a bare custom error code.
- Update
CLAUDE.md's description of the Go SDK.
- Whichever of A or B is chosen, implement it.
Not in scope
The read-side Go/TypeScript/Python SDK work for the new GlobalState field, which is #4203.
Acceptance
TestBuildCreateUserInstruction asserts the full Rust fixture with no trailing-byte exception.
- A test covering the behavior chosen above: either a Go-built transaction with a proof that the program accepts, or a fast, clearly-worded failure when the flag is set and no proof can be supplied.
- Named errors for the RFC-27 rejection classes, with a test mapping at least
IpOwnershipProofRequired.
Part of RFC-27 (tracker: #4194). Depends on #4197 (merged as #4211).
Why
The Go serviceability SDK is a writer, not just a reader.
smartcontract/sdk/go/serviceability/executor.gosubmitsCreateUser(variant 36) with a hand-packed 12-byte payload:There is no
Option<IpOwnershipProof>discriminant, no Instructions sysvar account, and no Ed25519 instruction. It works today for two reasons that both expire:UserCreateArgsisBorshDeserializeIncremental, so the program defaults the missing field toNone; and whileFeatureFlag::RequireIpOwnershipProofis clear,Noneis accepted.The moment the flag is set for an environment, every user creation this path submits fails with
IpOwnershipProofRequired(custom error 105). The payer is not the sentinel authority, so the exemption invalidate_ip_ownership_proofdoes not apply.smartcontract/sdk/go/serviceability/user_crud_test.goalready carries a comment pointing at this, added in #4211; it asserts the builder's payload equals the Rust fixture minus theip_proofdiscriminant, which is exactly the assumption that breaks.Who is affected
Executor.CreateUserhas one production consumer: the device-stress orchestrator.reconcile.PlanFordecides how many users to add or remove,pkg/sweep/sweep.goexecutes the plan, andpkg/exec/exec.gocallsExecutor.CreateUserper user.tools/stress/device-orchestrator/pkg/reconcile/reconcile.go— pure plannertools/stress/device-orchestrator/pkg/sweep/sweep.go:741tools/stress/device-orchestrator/pkg/exec/exec.go:89Executordoes not implementCreateSubscribeUser(variant 59), so only the unicast path is in scope.Note also that
CLAUDE.mdstill describes the Go SDK as "read-only account deserialization". That is no longer true and should be corrected as part of this work.Decision needed first
Whether the stress orchestrator must run against a proof-enforced environment at all. The two answers lead to very different amounts of work:
A. Teach the Go executor to carry a proof. Needs a Go port of the
doublezero-ip-prooflayout, an Ed25519 precompile instruction builder, aGlobalState.ip_verifier_authority_pkread, and an HTTP client for the verifier'sPOST /v1/proof. The verifier signs the address it observes the request originate from, so the orchestrator's syntheticClientIPBase + idxaddresses cannot be proven at all — it would need a different mechanism, which means this option is not simply "port the crate".B. Gate the tool instead. Have
Executor.CreateUserread the feature flag and fail fast with a clear message when enforcement is on, and run stress sweeps only against environments where the flag is clear. Cheap, honest, and does not invent a proof path for synthetic addresses.Option B looks right on the evidence above — the tool binds addresses it demonstrably does not control, which is the exact thing RFC-27 exists to prevent. Confirm before implementing.
What (either way)
ip_proofdiscriminant explicitly rather than relying on incremental defaulting, so the Go payload and the Rust fixture agree byte for byte andTestBuildCreateUserInstructioncan drop its "minus the discriminant" special case.IpOwnershipProofRequiredand friends, custom errors 105–118) as named Go errors, so an operator sees why a sweep stopped rather than a bare custom error code.CLAUDE.md's description of the Go SDK.Not in scope
The read-side Go/TypeScript/Python SDK work for the new
GlobalStatefield, which is #4203.Acceptance
TestBuildCreateUserInstructionasserts the full Rust fixture with no trailing-byte exception.IpOwnershipProofRequired.