diff --git a/CHANGELOG.md b/CHANGELOG.md index 1838817931..4920208fb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. ## Unreleased +### Breaking + +### Changes + +- SDK (Go) + - Add CreateUser / DeleteUser to the serviceability executor with cross-language wire-format fixtures and four new PDA helpers (GetUserPDA, GetAccessPassPDA, GetTunnelIdsPDA, GetDzPrefixBlockPDA) + ## [v0.25.0](https://github.com/malbeclabs/doublezero/compare/client/v0.24.0...client/v0.25.0) - 2026-05-29 ### Breaking diff --git a/sdk/serviceability/testdata/fixtures/generate-fixtures/Cargo.lock b/sdk/serviceability/testdata/fixtures/generate-fixtures/Cargo.lock index cb2af95041..9343abf199 100644 --- a/sdk/serviceability/testdata/fixtures/generate-fixtures/Cargo.lock +++ b/sdk/serviceability/testdata/fixtures/generate-fixtures/Cargo.lock @@ -346,7 +346,7 @@ dependencies = [ [[package]] name = "doublezero-program-common" -version = "0.23.0" +version = "0.24.0" dependencies = [ "borsh 1.6.0", "byteorder", @@ -358,7 +358,7 @@ dependencies = [ [[package]] name = "doublezero-serviceability" -version = "0.23.0" +version = "0.24.0" dependencies = [ "bitflags", "borsh 1.6.0", diff --git a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs index b8ed35df67..9823a5776c 100644 --- a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs +++ b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs @@ -17,6 +17,9 @@ use borsh::BorshSerialize; use doublezero_serviceability::id_allocator::IdAllocator; use doublezero_serviceability::ip_allocator::IpAllocator; +use doublezero_serviceability::processors::user::{ + create::UserCreateArgs, delete::UserDeleteArgs, +}; use doublezero_serviceability::programversion::ProgramVersion; use doublezero_serviceability::state::{ accesspass::{AccessPass, AccessPassStatus, AccessPassType}, @@ -95,11 +98,65 @@ fn main() { generate_tenant(&fixtures_dir); generate_resource_extension_id(&fixtures_dir); generate_resource_extension_ip(&fixtures_dir); + generate_user_create_args(&fixtures_dir); + generate_user_delete_args(&fixtures_dir); println!(" all fixtures generated in {}", fixtures_dir.display()); } +/// Borsh-encoded `UserCreateArgs` (the body of instruction variant 36, without the +/// 1-byte discriminant). Field order: user_type, cyoa_type, client_ip, tunnel_endpoint, +/// dz_prefix_count. Non-default IP octets make endianness mistakes detectable. +fn generate_user_create_args(dir: &Path) { + let val = UserCreateArgs { + user_type: UserType::IBRL, + cyoa_type: UserCYOA::GREOverDIA, + client_ip: Ipv4Addr::new(10, 11, 12, 13), + tunnel_endpoint: Ipv4Addr::new(192, 168, 1, 2), + dz_prefix_count: 2, + }; + + let data = borsh::to_vec(&val).unwrap(); + + let meta = FixtureMeta { + name: "UserCreateArgs".into(), + // Not an account; account_type=0 since this is an instruction-args fixture. + account_type: 0, + fields: vec![ + FieldValue { name: "UserType".into(), value: "0".into(), typ: "u8".into() }, + FieldValue { name: "CyoaType".into(), value: "1".into(), typ: "u8".into() }, + FieldValue { name: "ClientIp".into(), value: "10.11.12.13".into(), typ: "ipv4".into() }, + FieldValue { name: "TunnelEndpoint".into(), value: "192.168.1.2".into(), typ: "ipv4".into() }, + FieldValue { name: "DzPrefixCount".into(), value: "2".into(), typ: "u8".into() }, + ], + }; + + write_fixture(dir, "user_create_args", &data, &meta); +} + +/// Borsh-encoded `UserDeleteArgs` (the body of instruction variant 42, without the +/// 1-byte discriminant). Field order: dz_prefix_count, multicast_publisher_count. +fn generate_user_delete_args(dir: &Path) { + let val = UserDeleteArgs { + dz_prefix_count: 3, + multicast_publisher_count: 1, + }; + + let data = borsh::to_vec(&val).unwrap(); + + let meta = FixtureMeta { + name: "UserDeleteArgs".into(), + account_type: 0, + fields: vec![ + FieldValue { name: "DzPrefixCount".into(), value: "3".into(), typ: "u8".into() }, + FieldValue { name: "MulticastPublisherCount".into(), value: "1".into(), typ: "u8".into() }, + ], + }; + + write_fixture(dir, "user_delete_args", &data, &meta); +} + fn generate_global_state(dir: &Path) { let foundation_pk = pubkey_from_byte(0x01); let activator_pk = pubkey_from_byte(0x02); diff --git a/sdk/serviceability/testdata/fixtures/user_create_args.bin b/sdk/serviceability/testdata/fixtures/user_create_args.bin new file mode 100644 index 0000000000..8971a4e3a8 Binary files /dev/null and b/sdk/serviceability/testdata/fixtures/user_create_args.bin differ diff --git a/sdk/serviceability/testdata/fixtures/user_create_args.json b/sdk/serviceability/testdata/fixtures/user_create_args.json new file mode 100644 index 0000000000..93922f3343 --- /dev/null +++ b/sdk/serviceability/testdata/fixtures/user_create_args.json @@ -0,0 +1,31 @@ +{ + "name": "UserCreateArgs", + "account_type": 0, + "fields": [ + { + "name": "UserType", + "value": "0", + "typ": "u8" + }, + { + "name": "CyoaType", + "value": "1", + "typ": "u8" + }, + { + "name": "ClientIp", + "value": "10.11.12.13", + "typ": "ipv4" + }, + { + "name": "TunnelEndpoint", + "value": "192.168.1.2", + "typ": "ipv4" + }, + { + "name": "DzPrefixCount", + "value": "2", + "typ": "u8" + } + ] +} \ No newline at end of file diff --git a/sdk/serviceability/testdata/fixtures/user_delete_args.bin b/sdk/serviceability/testdata/fixtures/user_delete_args.bin new file mode 100644 index 0000000000..d8d3825962 --- /dev/null +++ b/sdk/serviceability/testdata/fixtures/user_delete_args.bin @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/sdk/serviceability/testdata/fixtures/user_delete_args.json b/sdk/serviceability/testdata/fixtures/user_delete_args.json new file mode 100644 index 0000000000..46d892aac6 --- /dev/null +++ b/sdk/serviceability/testdata/fixtures/user_delete_args.json @@ -0,0 +1,16 @@ +{ + "name": "UserDeleteArgs", + "account_type": 0, + "fields": [ + { + "name": "DzPrefixCount", + "value": "3", + "typ": "u8" + }, + { + "name": "MulticastPublisherCount", + "value": "1", + "typ": "u8" + } + ] +} \ No newline at end of file diff --git a/smartcontract/sdk/go/serviceability/executor.go b/smartcontract/sdk/go/serviceability/executor.go index eefffa5bd6..ccaf8a6255 100644 --- a/smartcontract/sdk/go/serviceability/executor.go +++ b/smartcontract/sdk/go/serviceability/executor.go @@ -16,6 +16,8 @@ import ( ) const ( + instructionCreateUser = 36 + instructionDeleteUser = 42 instructionSetDeviceHealth = 83 instructionSetLinkHealth = 84 instructionSetUserBGPStatus = 106 @@ -165,6 +167,252 @@ func (e *Executor) SetLinkHealthBatch(ctx context.Context, updates []LinkHealthU return lastSig, ErrAllUpdatesFailed } +// UserCreateArgs bundles every input the Go executor needs to submit a CreateUser +// instruction (variant 36). The first five fields are borsh-encoded into the +// instruction payload exactly matching Rust's `UserCreateArgs`; the trailing +// DevicePubkey/TenantPubkey are only used to derive AccountMeta entries. +type UserCreateArgs struct { + UserType UserUserType + CyoaType CyoaType + ClientIP [4]byte + TunnelEndpoint [4]byte + DzPrefixCount uint8 + + // DevicePubkey identifies the device the user attaches to; required. + DevicePubkey solana.PublicKey + // TenantPubkey is the optional tenant association; pass the zero pubkey to omit. + TenantPubkey solana.PublicKey +} + +// CreateUser submits a CreateUser instruction (variant 36) and waits for the user +// PDA to become visible on-chain. Returns the signature and derived user PDA so the +// caller can correlate (e.g., record t_activate against this user). +func (e *Executor) CreateUser(ctx context.Context, args UserCreateArgs) (solana.Signature, solana.PublicKey, error) { + if e.signer == nil { + return solana.Signature{}, solana.PublicKey{}, ErrNoPrivateKey + } + if e.programID.IsZero() { + return solana.Signature{}, solana.PublicKey{}, ErrNoProgramID + } + if args.DzPrefixCount == 0 { + return solana.Signature{}, solana.PublicKey{}, errors.New("UserCreateArgs.DzPrefixCount must be > 0") + } + if args.DevicePubkey.IsZero() { + return solana.Signature{}, solana.PublicKey{}, errors.New("UserCreateArgs.DevicePubkey is required") + } + + instr, userPDA, err := e.buildCreateUserInstruction(args) + if err != nil { + return solana.Signature{}, solana.PublicKey{}, fmt.Errorf("build CreateUser instruction: %w", err) + } + + sig, _, err := e.executeTransaction(ctx, []solana.Instruction{instr}) + if err != nil { + return sig, userPDA, err + } + + if err := e.waitForAccountVisible(ctx, userPDA, e.waitForVisibleTimeout); err != nil { + return sig, userPDA, fmt.Errorf("post-confirm visibility timeout for user PDA: %w", err) + } + return sig, userPDA, nil +} + +// DeleteUser submits a DeleteUser instruction (variant 42) and waits for the user +// PDA to disappear from chain. The function reads the user account first so it +// can derive the device-dependent PDAs and the multicast-publisher flag. +// NOTE: this function does not unsubscribe multicast groups first. That should be +// handled externally./ +func (e *Executor) DeleteUser(ctx context.Context, userPubkey solana.PublicKey) (solana.Signature, error) { + if e.signer == nil { + return solana.Signature{}, ErrNoPrivateKey + } + if e.programID.IsZero() { + return solana.Signature{}, ErrNoProgramID + } + + info, err := e.rpc.GetAccountInfo(ctx, userPubkey) + if err != nil { + return solana.Signature{}, fmt.Errorf("fetch user account %s: %w", userPubkey, err) + } + if info == nil || info.Value == nil { + return solana.Signature{}, fmt.Errorf("user account %s not found", userPubkey) + } + rawData := info.Value.Data.GetBinary() + if len(rawData) == 0 { + return solana.Signature{}, fmt.Errorf("user account %s has empty data", userPubkey) + } + var user User + DeserializeUser(NewByteReader(rawData), &user) + if user.AccountType != UserType { + return solana.Signature{}, fmt.Errorf("account %s is not a User (type=%d)", userPubkey, user.AccountType) + } + user.PubKey = userPubkey + + // The Rust SDK currently passes dz_prefix_count=1 / multicast_publisher_count=1 + // because all users are created with exactly one DzPrefixBlock. Stress-orchestrator + // users likewise use DzPrefixCount=1, so 1 is the correct value here. Diverging + // requires fetching the Device record — out of scope for the SDK primitive. + const dzPrefixCount uint8 = 1 + const multicastPublisherCount uint8 = 1 + + instr, err := e.buildDeleteUserInstruction(userPubkey, user, dzPrefixCount, multicastPublisherCount) + if err != nil { + return solana.Signature{}, fmt.Errorf("build DeleteUser instruction: %w", err) + } + + sig, _, err := e.executeTransaction(ctx, []solana.Instruction{instr}) + if err != nil { + return sig, err + } + + if err := e.waitForAccountGone(ctx, userPubkey, e.waitForVisibleTimeout); err != nil { + return sig, fmt.Errorf("post-confirm visibility timeout waiting for user PDA closure: %w", err) + } + return sig, nil +} + +// buildCreateUserInstruction packs the variant-36 payload and assembles the account +// list in the order the on-chain processor expects: +// +// [user_pda, device, accesspass, globalstate, +// 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) + 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 + + userPDA, _, err := GetUserPDA(e.programID, args.ClientIP, args.UserType) + if err != nil { + return nil, solana.PublicKey{}, fmt.Errorf("derive user PDA: %w", err) + } + accessPassPDA, _, err := GetAccessPassPDA(e.programID, args.ClientIP, e.signer.PublicKey()) + if err != nil { + return nil, userPDA, fmt.Errorf("derive accesspass PDA: %w", err) + } + globalStatePDA, _, err := GetGlobalStatePDA(e.programID) + if err != nil { + return nil, userPDA, fmt.Errorf("derive globalstate PDA: %w", err) + } + userTunnelBlockPDA, _, err := GetUserTunnelBlockPDA(e.programID) + if err != nil { + return nil, userPDA, fmt.Errorf("derive user tunnel block PDA: %w", err) + } + mcPublisherBlockPDA, _, err := GetMulticastPublisherBlockPDA(e.programID) + if err != nil { + return nil, userPDA, fmt.Errorf("derive multicast publisher block PDA: %w", err) + } + tunnelIdsPDA, _, err := GetTunnelIdsPDA(e.programID, args.DevicePubkey, 0) + if err != nil { + return nil, userPDA, fmt.Errorf("derive device tunnel ids PDA: %w", err) + } + + accounts := solana.AccountMetaSlice{ + solana.Meta(userPDA).WRITE(), + solana.Meta(args.DevicePubkey).WRITE(), + solana.Meta(accessPassPDA).WRITE(), + solana.Meta(globalStatePDA).WRITE(), + solana.Meta(userTunnelBlockPDA).WRITE(), + solana.Meta(mcPublisherBlockPDA).WRITE(), + solana.Meta(tunnelIdsPDA).WRITE(), + } + for i := uint64(0); i < uint64(args.DzPrefixCount); i++ { + dzPrefixPDA, _, err := GetDzPrefixBlockPDA(e.programID, args.DevicePubkey, i) + if err != nil { + return nil, userPDA, fmt.Errorf("derive dz_prefix_block[%d] PDA: %w", i, err) + } + accounts = append(accounts, solana.Meta(dzPrefixPDA).WRITE()) + } + if !args.TenantPubkey.IsZero() { + accounts = append(accounts, solana.Meta(args.TenantPubkey).WRITE()) + } + accounts = append(accounts, + solana.Meta(e.signer.PublicKey()).SIGNER().WRITE(), + solana.Meta(solana.SystemProgramID), + ) + + return &genericInstruction{ + programID: e.programID, + accounts: accounts, + data: data, + skipPermissionInject: true, + }, userPDA, nil +} + +// buildDeleteUserInstruction packs the variant-42 payload and assembles the account +// list in the order the on-chain processor expects: +// +// [user, accesspass, globalstate, device, +// user_tunnel_block, multicast_publisher_block, device_tunnel_ids, +// dz_prefix_block[0..N], optional_tenant, owner, payer, system] +// +// `multicastPublisherCount` mirrors the Rust SDK's behavior: the on-chain processor +// consumes the MulticastPublisherBlock slot unconditionally for the variant-42 +// layout, so DeleteUser's caller passes 1 even when the user was not created as a +// publisher. Exposed as a parameter so the byte-encoding can be tested independently. +func (e *Executor) buildDeleteUserInstruction(userPubkey solana.PublicKey, user User, dzPrefixCount, multicastPublisherCount uint8) (solana.Instruction, error) { + data := []byte{instructionDeleteUser, dzPrefixCount, multicastPublisherCount} + + accessPassPDA, _, err := GetAccessPassPDA(e.programID, user.ClientIp, user.Owner) + if err != nil { + return nil, fmt.Errorf("derive accesspass PDA: %w", err) + } + globalStatePDA, _, err := GetGlobalStatePDA(e.programID) + if err != nil { + return nil, fmt.Errorf("derive globalstate PDA: %w", err) + } + devicePubkey := solana.PublicKeyFromBytes(user.DevicePubKey[:]) + userTunnelBlockPDA, _, err := GetUserTunnelBlockPDA(e.programID) + if err != nil { + return nil, fmt.Errorf("derive user tunnel block PDA: %w", err) + } + mcPublisherBlockPDA, _, err := GetMulticastPublisherBlockPDA(e.programID) + if err != nil { + return nil, fmt.Errorf("derive multicast publisher block PDA: %w", err) + } + tunnelIdsPDA, _, err := GetTunnelIdsPDA(e.programID, devicePubkey, 0) + if err != nil { + return nil, fmt.Errorf("derive device tunnel ids PDA: %w", err) + } + + accounts := solana.AccountMetaSlice{ + solana.Meta(userPubkey).WRITE(), + solana.Meta(accessPassPDA).WRITE(), + solana.Meta(globalStatePDA).WRITE(), + solana.Meta(devicePubkey).WRITE(), + solana.Meta(userTunnelBlockPDA).WRITE(), + solana.Meta(mcPublisherBlockPDA).WRITE(), + solana.Meta(tunnelIdsPDA).WRITE(), + } + for i := uint64(0); i < uint64(dzPrefixCount); i++ { + dzPrefixPDA, _, err := GetDzPrefixBlockPDA(e.programID, devicePubkey, i) + if err != nil { + return nil, fmt.Errorf("derive dz_prefix_block[%d] PDA: %w", i, err) + } + accounts = append(accounts, solana.Meta(dzPrefixPDA).WRITE()) + } + var zeroPK [32]uint8 + if user.TenantPubKey != zeroPK { + accounts = append(accounts, solana.Meta(solana.PublicKeyFromBytes(user.TenantPubKey[:])).WRITE()) + } + accounts = append(accounts, + solana.Meta(solana.PublicKeyFromBytes(user.Owner[:])).WRITE(), + solana.Meta(e.signer.PublicKey()).SIGNER().WRITE(), + solana.Meta(solana.SystemProgramID), + ) + + return &genericInstruction{ + programID: e.programID, + accounts: accounts, + data: data, + skipPermissionInject: true, + }, nil +} + // UserBGPStatusUpdate holds the parameters for a single SetUserBGPStatus submission. type UserBGPStatusUpdate struct { UserPubkey solana.PublicKey @@ -231,6 +479,11 @@ type genericInstruction struct { programID solana.PublicKey accounts solana.AccountMetaSlice data []byte + // skipPermissionInject suppresses the executor's auto-appending of the Permission PDA. + // CreateUser/DeleteUser opt out because the on-chain processor uses accounts.len() + // to detect the optional tenant account; appending a trailing Permission shifts that + // count and would mis-classify accounts. + skipPermissionInject bool } func (i *genericInstruction) ProgramID() solana.PublicKey { @@ -278,7 +531,7 @@ func (e *Executor) executeTransaction(ctx context.Context, instructions []solana e.resolvePermissionPDA(ctx) if e.permissionPDA != nil { for _, instr := range instructions { - if gi, ok := instr.(*genericInstruction); ok { + if gi, ok := instr.(*genericInstruction); ok && !gi.skipPermissionInject { gi.accounts = append(gi.accounts, solana.Meta(*e.permissionPDA)) } } @@ -332,6 +585,53 @@ func (e *Executor) executeTransaction(ctx context.Context, instructions []solana return sig, res, nil } +// waitForAccountVisible polls GetAccountInfo until the given account is observable +// on-chain, or the deadline expires. Used post-CreateUser to give the caller a +// timestamp anchored to when the user PDA actually appears. +func (e *Executor) waitForAccountVisible(ctx context.Context, pubkey solana.PublicKey, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for { + info, err := e.rpc.GetAccountInfo(ctx, pubkey) + if err == nil && info != nil && info.Value != nil { + return nil + } + if time.Now().After(deadline) { + if err != nil { + return fmt.Errorf("account %s not visible: %w", pubkey, err) + } + return fmt.Errorf("account %s not visible before deadline", pubkey) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(250 * time.Millisecond): + } + } +} + +// waitForAccountGone polls GetAccountInfo until the given account no longer exists, +// or the deadline expires. Used post-DeleteUser to detect closure. +func (e *Executor) waitForAccountGone(ctx context.Context, pubkey solana.PublicKey, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for { + info, err := e.rpc.GetAccountInfo(ctx, pubkey) + if err == nil && (info == nil || info.Value == nil) { + return nil + } + if time.Now().After(deadline) { + if err != nil { + return fmt.Errorf("account %s still present: %w", pubkey, err) + } + return fmt.Errorf("account %s still present before deadline", pubkey) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(250 * time.Millisecond): + } + } +} + func (e *Executor) waitForSignatureVisible(ctx context.Context, sig solana.Signature, timeout time.Duration) error { deadline := time.Now().Add(timeout) diff --git a/smartcontract/sdk/go/serviceability/pda.go b/smartcontract/sdk/go/serviceability/pda.go index c147d3e103..39a2c39dbf 100644 --- a/smartcontract/sdk/go/serviceability/pda.go +++ b/smartcontract/sdk/go/serviceability/pda.go @@ -1,6 +1,10 @@ package serviceability -import "github.com/gagliardetto/solana-go" +import ( + "encoding/binary" + + "github.com/gagliardetto/solana-go" +) // PDA seeds matching Rust implementation in seeds.rs const ( @@ -16,6 +20,10 @@ const ( SeedMulticastPublisherBlock = "multicastpublisherblock" SeedTenant = "tenant" SeedPermission = "permission" + SeedUser = "user" + SeedAccessPass = "accesspass" + SeedTunnelIds = "tunnelids" + SeedDzPrefixBlock = "dzprefixblock" ) // DeriveGlobalStatePDA derives the PDA for the GlobalState account. @@ -123,3 +131,55 @@ func GetPermissionPDA(programID solana.PublicKey, userPayer solana.PublicKey) (s } return solana.FindProgramAddress(seeds, programID) } + +// GetUserPDA derives the PDA for a User account, keyed by (client_ip, user_type). +// Mirrors smartcontract/programs/doublezero-serviceability/src/pda.rs:get_user_pda. +func GetUserPDA(programID solana.PublicKey, clientIP [4]byte, userType UserUserType) (solana.PublicKey, uint8, error) { + seeds := [][]byte{ + []byte(SeedPrefix), + []byte(SeedUser), + clientIP[:], + {byte(userType)}, + } + return solana.FindProgramAddress(seeds, programID) +} + +// GetAccessPassPDA derives the PDA for an AccessPass account, keyed by (client_ip, user_payer). +// Mirrors smartcontract/programs/doublezero-serviceability/src/pda.rs:get_accesspass_pda. +func GetAccessPassPDA(programID solana.PublicKey, clientIP [4]byte, userPayer solana.PublicKey) (solana.PublicKey, uint8, error) { + seeds := [][]byte{ + []byte(SeedPrefix), + []byte(SeedAccessPass), + clientIP[:], + userPayer[:], + } + return solana.FindProgramAddress(seeds, programID) +} + +// GetTunnelIdsPDA derives the PDA for a per-device TunnelIds resource extension at the given index. +// Rust uses usize (8 bytes on 64-bit) little-endian for the index; we always encode 8 bytes. +func GetTunnelIdsPDA(programID solana.PublicKey, devicePK solana.PublicKey, index uint64) (solana.PublicKey, uint8, error) { + var idxBuf [8]byte + binary.LittleEndian.PutUint64(idxBuf[:], index) + seeds := [][]byte{ + []byte(SeedPrefix), + []byte(SeedTunnelIds), + devicePK[:], + idxBuf[:], + } + return solana.FindProgramAddress(seeds, programID) +} + +// GetDzPrefixBlockPDA derives the PDA for a per-device DzPrefixBlock resource extension at the given index. +// Rust uses usize (8 bytes on 64-bit) little-endian for the index; we always encode 8 bytes. +func GetDzPrefixBlockPDA(programID solana.PublicKey, devicePK solana.PublicKey, index uint64) (solana.PublicKey, uint8, error) { + var idxBuf [8]byte + binary.LittleEndian.PutUint64(idxBuf[:], index) + seeds := [][]byte{ + []byte(SeedPrefix), + []byte(SeedDzPrefixBlock), + devicePK[:], + idxBuf[:], + } + return solana.FindProgramAddress(seeds, programID) +} diff --git a/smartcontract/sdk/go/serviceability/pda_test.go b/smartcontract/sdk/go/serviceability/pda_test.go new file mode 100644 index 0000000000..614e243fb0 --- /dev/null +++ b/smartcontract/sdk/go/serviceability/pda_test.go @@ -0,0 +1,113 @@ +package serviceability_test + +import ( + "testing" + + "github.com/gagliardetto/solana-go" + "github.com/malbeclabs/doublezero/smartcontract/sdk/go/serviceability" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// PDAs are deterministic from (program_id, seeds), so we can cross-check the new +// helpers against an independent recomputation that mirrors the Rust seed bytes +// exactly. These tests catch typos in seed strings and width/endianness mistakes +// in the index encoding without requiring the Rust binary at test time. + +func recomputePDA(t *testing.T, programID solana.PublicKey, seeds [][]byte) solana.PublicKey { + t.Helper() + pda, _, err := solana.FindProgramAddress(seeds, programID) + require.NoError(t, err) + return pda +} + +func TestGetUserPDA_MatchesRustSeeds(t *testing.T) { + t.Parallel() + programID := solana.NewWallet().PublicKey() + ip := [4]byte{198, 51, 100, 7} + + got, _, err := serviceability.GetUserPDA(programID, ip, serviceability.UserTypeIBRLWithAllocatedIP) + require.NoError(t, err) + + want := recomputePDA(t, programID, [][]byte{ + []byte("doublezero"), + []byte("user"), + ip[:], + {byte(serviceability.UserTypeIBRLWithAllocatedIP)}, + }) + assert.Equal(t, want, got) +} + +func TestGetAccessPassPDA_MatchesRustSeeds(t *testing.T) { + t.Parallel() + programID := solana.NewWallet().PublicKey() + userPayer := solana.NewWallet().PublicKey() + ip := [4]byte{10, 0, 0, 5} + + got, _, err := serviceability.GetAccessPassPDA(programID, ip, userPayer) + require.NoError(t, err) + + want := recomputePDA(t, programID, [][]byte{ + []byte("doublezero"), + []byte("accesspass"), + ip[:], + userPayer[:], + }) + assert.Equal(t, want, got) +} + +func TestGetTunnelIdsPDA_IndexIsEightByteLE(t *testing.T) { + t.Parallel() + programID := solana.NewWallet().PublicKey() + device := solana.NewWallet().PublicKey() + + for _, idx := range []uint64{0, 1, 7, 256, 0xDEAD_BEEF} { + got, _, err := serviceability.GetTunnelIdsPDA(programID, device, idx) + require.NoError(t, err) + + // Build the index seed by hand: 8-byte little-endian. + idxBytes := []byte{ + byte(idx), byte(idx >> 8), byte(idx >> 16), byte(idx >> 24), + byte(idx >> 32), byte(idx >> 40), byte(idx >> 48), byte(idx >> 56), + } + want := recomputePDA(t, programID, [][]byte{ + []byte("doublezero"), + []byte("tunnelids"), + device[:], + idxBytes, + }) + assert.Equal(t, want, got, "idx=%d", idx) + } +} + +func TestGetDzPrefixBlockPDA_IndexIsEightByteLE(t *testing.T) { + t.Parallel() + programID := solana.NewWallet().PublicKey() + device := solana.NewWallet().PublicKey() + + idx := uint64(3) + got, _, err := serviceability.GetDzPrefixBlockPDA(programID, device, idx) + require.NoError(t, err) + want := recomputePDA(t, programID, [][]byte{ + []byte("doublezero"), + []byte("dzprefixblock"), + device[:], + {0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }) + assert.Equal(t, want, got) +} + +// TestUserPDA_DiffersByUserType guards against accidentally dropping the +// user_type byte from the seeds (which would collapse different user types onto +// the same PDA). +func TestUserPDA_DiffersByUserType(t *testing.T) { + t.Parallel() + programID := solana.NewWallet().PublicKey() + ip := [4]byte{10, 0, 0, 7} + + pdaIBRL, _, err := serviceability.GetUserPDA(programID, ip, serviceability.UserTypeIBRL) + require.NoError(t, err) + pdaMulticast, _, err := serviceability.GetUserPDA(programID, ip, serviceability.UserTypeMulticast) + require.NoError(t, err) + assert.NotEqual(t, pdaIBRL, pdaMulticast) +} diff --git a/smartcontract/sdk/go/serviceability/user_crud_test.go b/smartcontract/sdk/go/serviceability/user_crud_test.go new file mode 100644 index 0000000000..2bc77bd55c --- /dev/null +++ b/smartcontract/sdk/go/serviceability/user_crud_test.go @@ -0,0 +1,542 @@ +package serviceability + +import ( + "context" + "errors" + "log/slog" + "os" + "path/filepath" + "runtime" + "sync/atomic" + "testing" + "time" + + "github.com/gagliardetto/solana-go" + solanarpc "github.com/gagliardetto/solana-go/rpc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// loadArgsFixture loads a `.bin` payload from sdk/serviceability/testdata/fixtures/ +// for the cross-language wire-format check. +func loadArgsFixture(t *testing.T, name string) []byte { + t.Helper() + _, filename, _, _ := runtime.Caller(0) + dir := filepath.Join(filepath.Dir(filename), "..", "..", "..", "..", "sdk", "serviceability", "testdata", "fixtures") + bin, err := os.ReadFile(filepath.Join(dir, name+".bin")) + require.NoErrorf(t, err, "reading %s.bin", name) + return bin +} + +func TestBuildCreateUserInstruction(t *testing.T) { + t.Parallel() + + rpc := &mockRPCClient{} + executor, _ := newTestExecutor(t, rpc) + + args := UserCreateArgs{ + UserType: UserTypeIBRL, + CyoaType: CyoaTypeGREOverDIA, + ClientIP: [4]byte{10, 11, 12, 13}, + TunnelEndpoint: [4]byte{192, 168, 1, 2}, + DzPrefixCount: 2, + DevicePubkey: solana.NewWallet().PublicKey(), + } + + instr, userPDA, err := executor.buildCreateUserInstruction(args) + require.NoError(t, err) + + // Variant byte + 11-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") + assert.Equal(t, byte(instructionCreateUser), data[0]) + assert.Equal(t, loadArgsFixture(t, "user_create_args"), data[1:], + "borsh body must match Rust-generated user_create_args.bin") + + // User PDA derivation is deterministic from (program_id, client_ip, user_type). + expectedPDA, _, err := GetUserPDA(executor.programID, args.ClientIP, args.UserType) + require.NoError(t, err) + assert.Equal(t, expectedPDA, userPDA) + + // Account count = 7 fixed + DzPrefixCount + payer + system (no tenant). + accs := instr.Accounts() + require.Len(t, accs, 7+int(args.DzPrefixCount)+2) + assert.Equal(t, userPDA, accs[0].PublicKey) + assert.True(t, accs[0].IsWritable) + assert.False(t, accs[0].IsSigner) + assert.Equal(t, args.DevicePubkey, accs[1].PublicKey) + // Last two slots: signer + system program. + assert.Equal(t, executor.signer.PublicKey(), accs[len(accs)-2].PublicKey) + assert.True(t, accs[len(accs)-2].IsSigner) + assert.Equal(t, solana.SystemProgramID, accs[len(accs)-1].PublicKey) +} + +func TestBuildCreateUserInstruction_WithTenant(t *testing.T) { + t.Parallel() + + rpc := &mockRPCClient{} + executor, _ := newTestExecutor(t, rpc) + tenant := solana.NewWallet().PublicKey() + + args := UserCreateArgs{ + UserType: UserTypeIBRLWithAllocatedIP, + CyoaType: CyoaTypeGREOverFabric, + ClientIP: [4]byte{198, 51, 100, 7}, + TunnelEndpoint: [4]byte{0, 0, 0, 0}, + DzPrefixCount: 1, + DevicePubkey: solana.NewWallet().PublicKey(), + TenantPubkey: tenant, + } + instr, _, err := executor.buildCreateUserInstruction(args) + require.NoError(t, err) + + accs := instr.Accounts() + // Tenant slot sits between dz_prefix_block(s) and the payer/system tail. + tenantSlot := accs[len(accs)-3] + assert.Equal(t, tenant, tenantSlot.PublicKey) + assert.True(t, tenantSlot.IsWritable) +} + +func TestCreateUserInstruction_RejectsZeroDzPrefix(t *testing.T) { + t.Parallel() + + rpc := &mockRPCClient{} + executor, _ := newTestExecutor(t, rpc) + _, _, err := executor.CreateUser(context.Background(), UserCreateArgs{ + UserType: UserTypeIBRL, + CyoaType: CyoaTypeGREOverDIA, + DzPrefixCount: 0, + DevicePubkey: solana.NewWallet().PublicKey(), + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "DzPrefixCount must be > 0") +} + +func TestCreateUserValidation(t *testing.T) { + t.Parallel() + + validArgs := func() UserCreateArgs { + return UserCreateArgs{ + UserType: UserTypeIBRL, + CyoaType: CyoaTypeGREOverDIA, + ClientIP: [4]byte{10, 0, 0, 1}, + DzPrefixCount: 1, + DevicePubkey: solana.NewWallet().PublicKey(), + } + } + + t.Run("returns ErrNoPrivateKey when signer is nil", func(t *testing.T) { + t.Parallel() + programID := solana.NewWallet().PublicKey() + executor := NewExecutor(slog.Default(), &mockRPCClient{}, nil, programID) + + _, _, err := executor.CreateUser(context.Background(), validArgs()) + require.Error(t, err) + assert.ErrorIs(t, err, ErrNoPrivateKey) + }) + + t.Run("returns ErrNoProgramID when program ID is zero", func(t *testing.T) { + t.Parallel() + signer := solana.NewWallet().PrivateKey + executor := NewExecutor(slog.Default(), &mockRPCClient{}, &signer, solana.PublicKey{}) + + _, _, err := executor.CreateUser(context.Background(), validArgs()) + require.Error(t, err) + assert.ErrorIs(t, err, ErrNoProgramID) + }) + + t.Run("rejects zero DevicePubkey", func(t *testing.T) { + t.Parallel() + executor, _ := newTestExecutor(t, &mockRPCClient{}) + args := validArgs() + args.DevicePubkey = solana.PublicKey{} + + _, _, err := executor.CreateUser(context.Background(), args) + require.Error(t, err) + assert.Contains(t, err.Error(), "DevicePubkey is required") + }) +} + +func TestCreateUserReturnsPDAOnSendFailure(t *testing.T) { + t.Parallel() + + signer := solana.NewWallet().PrivateKey + programID := solana.NewWallet().PublicKey() + args := UserCreateArgs{ + UserType: UserTypeIBRL, + CyoaType: CyoaTypeGREOverDIA, + ClientIP: [4]byte{10, 0, 0, 9}, + DzPrefixCount: 1, + DevicePubkey: solana.NewWallet().PublicKey(), + } + expectedPDA, _, err := GetUserPDA(programID, args.ClientIP, args.UserType) + require.NoError(t, err) + + rpc := &mockRPCClient{ + sendTransactionFunc: func(ctx context.Context, transaction *solana.Transaction, opts solanarpc.TransactionOpts) (solana.Signature, error) { + return solana.Signature{}, errors.New("send failed") + }, + } + executor := NewExecutor(slog.Default(), rpc, &signer, programID) + + _, userPDA, err := executor.CreateUser(context.Background(), args) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to send transaction") + // PDA is still derived and returned so callers can correlate even on failure. + assert.Equal(t, expectedPDA, userPDA) +} + +func TestDeleteUserValidation(t *testing.T) { + t.Parallel() + + t.Run("returns ErrNoPrivateKey when signer is nil", func(t *testing.T) { + t.Parallel() + programID := solana.NewWallet().PublicKey() + executor := NewExecutor(slog.Default(), &mockRPCClient{}, nil, programID) + + _, err := executor.DeleteUser(context.Background(), solana.NewWallet().PublicKey()) + require.Error(t, err) + assert.ErrorIs(t, err, ErrNoPrivateKey) + }) + + t.Run("returns ErrNoProgramID when program ID is zero", func(t *testing.T) { + t.Parallel() + signer := solana.NewWallet().PrivateKey + executor := NewExecutor(slog.Default(), &mockRPCClient{}, &signer, solana.PublicKey{}) + + _, err := executor.DeleteUser(context.Background(), solana.NewWallet().PublicKey()) + require.Error(t, err) + assert.ErrorIs(t, err, ErrNoProgramID) + }) +} + +func TestDeleteUserRejectsBadAccount(t *testing.T) { + t.Parallel() + + userPubkey := solana.NewWallet().PublicKey() + + t.Run("propagates GetAccountInfo error", func(t *testing.T) { + t.Parallel() + signer := solana.NewWallet().PrivateKey + programID := solana.NewWallet().PublicKey() + rpc := &mockRPCClient{ + getAccountInfoFunc: func(ctx context.Context, account solana.PublicKey) (*solanarpc.GetAccountInfoResult, error) { + return nil, errors.New("rpc down") + }, + } + executor := NewExecutor(slog.Default(), rpc, &signer, programID) + + _, err := executor.DeleteUser(context.Background(), userPubkey) + require.Error(t, err) + assert.Contains(t, err.Error(), "fetch user account") + }) + + t.Run("rejects account with empty data", func(t *testing.T) { + t.Parallel() + signer := solana.NewWallet().PrivateKey + programID := solana.NewWallet().PublicKey() + rpc := &mockRPCClient{ + getAccountInfoFunc: func(ctx context.Context, account solana.PublicKey) (*solanarpc.GetAccountInfoResult, error) { + return &solanarpc.GetAccountInfoResult{ + Value: &solanarpc.Account{ + Owner: programID, + Data: solanarpc.DataBytesOrJSONFromBytes(nil), + }, + }, nil + }, + } + executor := NewExecutor(slog.Default(), rpc, &signer, programID) + + _, err := executor.DeleteUser(context.Background(), userPubkey) + require.Error(t, err) + assert.Contains(t, err.Error(), "empty data") + }) + + t.Run("rejects account that is not a User", func(t *testing.T) { + t.Parallel() + signer := solana.NewWallet().PrivateKey + programID := solana.NewWallet().PublicKey() + + // Same body layout as a User, but with a Device account-type discriminant. + notUser := makeMinimalUserBytes(solana.NewWallet().PublicKey(), solana.NewWallet().PublicKey(), [4]byte{10, 0, 0, 5}) + notUser[0] = byte(DeviceType) + rpc := &mockRPCClient{ + getAccountInfoFunc: func(ctx context.Context, account solana.PublicKey) (*solanarpc.GetAccountInfoResult, error) { + return &solanarpc.GetAccountInfoResult{ + Value: &solanarpc.Account{ + Owner: programID, + Data: solanarpc.DataBytesOrJSONFromBytes(notUser), + }, + }, nil + }, + } + executor := NewExecutor(slog.Default(), rpc, &signer, programID) + + _, err := executor.DeleteUser(context.Background(), userPubkey) + require.Error(t, err) + assert.Contains(t, err.Error(), "is not a User") + }) +} + +func TestBuildDeleteUserInstruction(t *testing.T) { + t.Parallel() + + rpc := &mockRPCClient{} + executor, _ := newTestExecutor(t, rpc) + + userPubkey := solana.NewWallet().PublicKey() + device := solana.NewWallet().PublicKey() + owner := solana.NewWallet().PublicKey() + user := User{ + AccountType: UserType, + Owner: owner, + UserType: UserTypeIBRL, + DevicePubKey: device, + ClientIp: [4]byte{10, 0, 0, 5}, + } + + // Use the fixture's (3, 1) values to exercise the borsh layout end-to-end + // against Rust output; production DeleteUser hard-codes (1, 1) — see the + // constant in DeleteUser itself. + instr, err := executor.buildDeleteUserInstruction(userPubkey, user, 3, 1) + require.NoError(t, err) + + data, err := instr.Data() + require.NoError(t, err) + require.Len(t, data, 3, "opcode (1) + borsh UserDeleteArgs (2) = 3 bytes") + assert.Equal(t, byte(instructionDeleteUser), data[0]) + assert.Equal(t, loadArgsFixture(t, "user_delete_args"), data[1:], + "borsh body must match Rust-generated user_delete_args.bin") + + accs := instr.Accounts() + // 7 fixed + 3 dz_prefix + owner + payer + system = 13 accounts (no tenant). + require.Len(t, accs, 13) + assert.Equal(t, userPubkey, accs[0].PublicKey) + assert.Equal(t, device, accs[3].PublicKey) + ownerSlot := accs[len(accs)-3] + assert.Equal(t, owner, ownerSlot.PublicKey) + assert.True(t, ownerSlot.IsWritable) + assert.Equal(t, executor.signer.PublicKey(), accs[len(accs)-2].PublicKey) + assert.True(t, accs[len(accs)-2].IsSigner) + assert.Equal(t, solana.SystemProgramID, accs[len(accs)-1].PublicKey) +} + +func TestBuildDeleteUserInstruction_WithTenant(t *testing.T) { + t.Parallel() + + rpc := &mockRPCClient{} + executor, _ := newTestExecutor(t, rpc) + + tenant := solana.NewWallet().PublicKey() + user := User{ + AccountType: UserType, + Owner: solana.NewWallet().PublicKey(), + TenantPubKey: tenant, + DevicePubKey: solana.NewWallet().PublicKey(), + UserType: UserTypeIBRL, + ClientIp: [4]byte{10, 0, 0, 5}, + } + + instr, err := executor.buildDeleteUserInstruction(solana.NewWallet().PublicKey(), user, 1, 1) + require.NoError(t, err) + + accs := instr.Accounts() + // Tenant sits before the owner/payer/system tail (3 trailing slots). + tenantSlot := accs[len(accs)-4] + assert.Equal(t, tenant, tenantSlot.PublicKey) + assert.True(t, tenantSlot.IsWritable) +} + +func TestCreateUserWaitsForAccountVisible(t *testing.T) { + t.Parallel() + + signer := solana.NewWallet().PrivateKey + programID := solana.NewWallet().PublicKey() + device := solana.NewWallet().PublicKey() + args := UserCreateArgs{ + UserType: UserTypeIBRL, + CyoaType: CyoaTypeGREOverDIA, + ClientIP: [4]byte{10, 0, 0, 1}, + DzPrefixCount: 1, + DevicePubkey: device, + } + expectedPDA, _, err := GetUserPDA(programID, args.ClientIP, args.UserType) + require.NoError(t, err) + + // First call (permission probe) returns nil; the user-PDA probe then returns + // a non-nil Value so the visibility wait completes immediately. + var lookups atomic.Int32 + rpc := &mockRPCClient{ + getAccountInfoFunc: func(ctx context.Context, account solana.PublicKey) (*solanarpc.GetAccountInfoResult, error) { + n := lookups.Add(1) + if account.Equals(expectedPDA) && n >= 2 { + return &solanarpc.GetAccountInfoResult{ + Value: &solanarpc.Account{Owner: programID}, + }, nil + } + return &solanarpc.GetAccountInfoResult{Value: nil}, nil + }, + } + executor := NewExecutor(slog.Default(), rpc, &signer, programID, WithWaitForVisibleTimeout(500*time.Millisecond)) + + sig, userPDA, err := executor.CreateUser(context.Background(), args) + require.NoError(t, err) + assert.NotEqual(t, solana.Signature{}, sig) + assert.Equal(t, expectedPDA, userPDA) + require.NotEmpty(t, rpc.sentTransactions) +} + +func TestCreateUserReportsVisibilityTimeout(t *testing.T) { + t.Parallel() + + signer := solana.NewWallet().PrivateKey + programID := solana.NewWallet().PublicKey() + rpc := &mockRPCClient{} // default: GetAccountInfo always returns nil + executor := NewExecutor(slog.Default(), rpc, &signer, programID, WithWaitForVisibleTimeout(50*time.Millisecond)) + + sig, userPDA, err := executor.CreateUser(context.Background(), UserCreateArgs{ + UserType: UserTypeIBRL, + CyoaType: CyoaTypeGREOverDIA, + ClientIP: [4]byte{10, 0, 0, 1}, + DzPrefixCount: 1, + DevicePubkey: solana.NewWallet().PublicKey(), + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "post-confirm visibility timeout") + // Signature and PDA are still returned so callers can correlate. + assert.NotEqual(t, solana.Signature{}, sig) + assert.NotEqual(t, solana.PublicKey{}, userPDA) +} + +func TestDeleteUserWaitsForAccountGone(t *testing.T) { + t.Parallel() + + signer := solana.NewWallet().PrivateKey + programID := solana.NewWallet().PublicKey() + userPubkey := solana.NewWallet().PublicKey() + + // Construct a borsh-serialized minimal User account body via DeserializeUser's + // inverse: we just write the fields by hand. + owner := solana.NewWallet().PublicKey() + device := solana.NewWallet().PublicKey() + userBytes := makeMinimalUserBytes(owner, device, [4]byte{10, 0, 0, 5}) + + // Sequence: GetAccountInfo returns user bytes once (initial DeleteUser read), nil + // thereafter (visibility wait sees account gone). Permission probe returns nil. + var lookups atomic.Int32 + rpc := &mockRPCClient{ + getAccountInfoFunc: func(ctx context.Context, account solana.PublicKey) (*solanarpc.GetAccountInfoResult, error) { + n := lookups.Add(1) + if account.Equals(userPubkey) && n == 1 { + return &solanarpc.GetAccountInfoResult{ + Value: &solanarpc.Account{ + Owner: programID, + Data: solanarpc.DataBytesOrJSONFromBytes(userBytes), + }, + }, nil + } + return &solanarpc.GetAccountInfoResult{Value: nil}, nil + }, + } + executor := NewExecutor(slog.Default(), rpc, &signer, programID, WithWaitForVisibleTimeout(500*time.Millisecond)) + + sig, err := executor.DeleteUser(context.Background(), userPubkey) + require.NoError(t, err) + assert.NotEqual(t, solana.Signature{}, sig) + require.NotEmpty(t, rpc.sentTransactions) + + // Verify the submitted transaction references the device pulled from the User. + tx := rpc.sentTransactions[0] + keys := tx.Message.AccountKeys + foundDevice := false + for _, k := range keys { + if k.Equals(device) { + foundDevice = true + break + } + } + assert.True(t, foundDevice, "device referenced by the user account must appear in the DeleteUser tx") +} + +func TestDeleteUserNotFound(t *testing.T) { + t.Parallel() + + signer := solana.NewWallet().PrivateKey + programID := solana.NewWallet().PublicKey() + rpc := &mockRPCClient{ + getAccountInfoFunc: func(ctx context.Context, account solana.PublicKey) (*solanarpc.GetAccountInfoResult, error) { + return &solanarpc.GetAccountInfoResult{Value: nil}, nil + }, + } + executor := NewExecutor(slog.Default(), rpc, &signer, programID) + + _, err := executor.DeleteUser(context.Background(), solana.NewWallet().PublicKey()) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestWaitForAccountVisible_TimeoutVsCancel(t *testing.T) { + t.Parallel() + + t.Run("returns nil when account appears", func(t *testing.T) { + var n atomic.Int32 + rpc := &mockRPCClient{ + getAccountInfoFunc: func(ctx context.Context, account solana.PublicKey) (*solanarpc.GetAccountInfoResult, error) { + if n.Add(1) >= 2 { + return &solanarpc.GetAccountInfoResult{Value: &solanarpc.Account{}}, nil + } + return &solanarpc.GetAccountInfoResult{Value: nil}, nil + }, + } + executor, _ := newTestExecutor(t, rpc) + require.NoError(t, executor.waitForAccountVisible(context.Background(), solana.NewWallet().PublicKey(), time.Second)) + }) + + t.Run("returns error past deadline", func(t *testing.T) { + rpc := &mockRPCClient{} + executor, _ := newTestExecutor(t, rpc) + err := executor.waitForAccountVisible(context.Background(), solana.NewWallet().PublicKey(), 50*time.Millisecond) + require.Error(t, err) + }) + + t.Run("returns context error on cancel", func(t *testing.T) { + rpc := &mockRPCClient{} + executor, _ := newTestExecutor(t, rpc) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := executor.waitForAccountVisible(ctx, solana.NewWallet().PublicKey(), time.Second) + require.Error(t, err) + assert.True(t, errors.Is(err, context.Canceled)) + }) +} + +// makeMinimalUserBytes hand-encodes a User account body matching DeserializeUser's +// field order. Most fields are zero — only AccountType, Owner, DevicePubKey, and +// ClientIp are populated, which is enough for buildDeleteUserInstruction. +func makeMinimalUserBytes(owner, device solana.PublicKey, clientIP [4]byte) []byte { + b := make([]byte, 0, 256) + b = append(b, byte(UserType)) // AccountType + b = append(b, owner[:]...) // Owner: 32 bytes + b = append(b, make([]byte, 16)...) // Index: u128 = 16 bytes + b = append(b, 0) // BumpSeed + b = append(b, byte(UserTypeIBRL)) // UserType + b = append(b, make([]byte, 32)...) // TenantPubKey (zero) + b = append(b, device[:]...) // DevicePubKey: 32 bytes + b = append(b, byte(CyoaTypeGREOverDIA)) // CyoaType + b = append(b, clientIP[:]...) // ClientIp: 4 bytes + b = append(b, make([]byte, 4)...) // DzIp: 4 bytes + b = append(b, 0, 0) // TunnelId: u16 + b = append(b, make([]byte, 5)...) // TunnelNet: 5 bytes + b = append(b, byte(UserStatusActivated)) + b = append(b, 0, 0, 0, 0) // Publishers: u32 len = 0 + b = append(b, 0, 0, 0, 0) // Subscribers: u32 len = 0 + b = append(b, make([]byte, 32)...) // ValidatorPubKey + b = append(b, make([]byte, 4)...) // TunnelEndpoint + b = append(b, 0) // TunnelFlags + b = append(b, 0) // BgpStatus + b = append(b, make([]byte, 8)...) // LastBgpUpAt + b = append(b, make([]byte, 8)...) // LastBgpReportedAt + b = append(b, make([]byte, 8)...) // BgpRttNs + return b +}