From f546affac60d12541b98c25ad98f296d92681e97 Mon Sep 17 00:00:00 2001 From: Ben Blier Date: Wed, 22 Apr 2026 14:49:17 -0400 Subject: [PATCH 1/3] sdk/geolocation: add Go write SDK for add/remove target operations --- sdk/geolocation/go/add_target.go | 117 +++++++++++++++ sdk/geolocation/go/add_target_test.go | 172 ++++++++++++++++++++++ sdk/geolocation/go/executor.go | 176 +++++++++++++++++++++++ sdk/geolocation/go/executor_test.go | 78 ++++++++++ sdk/geolocation/go/instructions.go | 9 ++ sdk/geolocation/go/instructions_test.go | 107 ++++++++++++++ sdk/geolocation/go/remove_target.go | 88 ++++++++++++ sdk/geolocation/go/remove_target_test.go | 109 ++++++++++++++ sdk/geolocation/go/rpc.go | 8 ++ 9 files changed, 864 insertions(+) create mode 100644 sdk/geolocation/go/add_target.go create mode 100644 sdk/geolocation/go/add_target_test.go create mode 100644 sdk/geolocation/go/executor.go create mode 100644 sdk/geolocation/go/executor_test.go create mode 100644 sdk/geolocation/go/instructions.go create mode 100644 sdk/geolocation/go/instructions_test.go create mode 100644 sdk/geolocation/go/remove_target.go create mode 100644 sdk/geolocation/go/remove_target_test.go diff --git a/sdk/geolocation/go/add_target.go b/sdk/geolocation/go/add_target.go new file mode 100644 index 0000000000..569445def0 --- /dev/null +++ b/sdk/geolocation/go/add_target.go @@ -0,0 +1,117 @@ +package geolocation + +import ( + "fmt" + + "github.com/gagliardetto/solana-go" + "github.com/near/borsh-go" +) + +type AddTargetInstructionConfig struct { + Code string + ProbePK solana.PublicKey + TargetType GeoLocationTargetType + IPAddress [4]uint8 + LocationOffsetPort uint16 + TargetPK solana.PublicKey +} + +func (c *AddTargetInstructionConfig) Validate() error { + if c.Code == "" { + return fmt.Errorf("code is required") + } + if len(c.Code) > MaxCodeLength { + return fmt.Errorf("code length %d exceeds max %d", len(c.Code), MaxCodeLength) + } + if c.ProbePK.IsZero() { + return fmt.Errorf("probe public key is required") + } + + switch c.TargetType { + case GeoLocationTargetTypeOutbound, GeoLocationTargetTypeOutboundIcmp: + if err := validateNotPrivateIP(c.IPAddress); err != nil { + return err + } + case GeoLocationTargetTypeInbound: + if c.TargetPK.IsZero() { + return fmt.Errorf("target public key is required for inbound target type") + } + default: + return fmt.Errorf("unknown target type: %d", c.TargetType) + } + + return nil +} + +// validateNotPrivateIP checks that an IPv4 address is not in a private/reserved range. +func validateNotPrivateIP(ip [4]uint8) error { + // 10.0.0.0/8 + if ip[0] == 10 { + return fmt.Errorf("IP address %d.%d.%d.%d is in private range 10.0.0.0/8", ip[0], ip[1], ip[2], ip[3]) + } + // 172.16.0.0/12 + if ip[0] == 172 && ip[1] >= 16 && ip[1] <= 31 { + return fmt.Errorf("IP address %d.%d.%d.%d is in private range 172.16.0.0/12", ip[0], ip[1], ip[2], ip[3]) + } + // 192.168.0.0/16 + if ip[0] == 192 && ip[1] == 168 { + return fmt.Errorf("IP address %d.%d.%d.%d is in private range 192.168.0.0/16", ip[0], ip[1], ip[2], ip[3]) + } + // 127.0.0.0/8 + if ip[0] == 127 { + return fmt.Errorf("IP address %d.%d.%d.%d is in loopback range 127.0.0.0/8", ip[0], ip[1], ip[2], ip[3]) + } + // 0.0.0.0 + if ip[0] == 0 && ip[1] == 0 && ip[2] == 0 && ip[3] == 0 { + return fmt.Errorf("IP address 0.0.0.0 is not allowed") + } + return nil +} + +func BuildAddTargetInstruction( + programID solana.PublicKey, + signerPK solana.PublicKey, + config AddTargetInstructionConfig, +) (solana.Instruction, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("failed to validate config: %w", err) + } + + // Serialize the instruction data. + data, err := borsh.Serialize(struct { + Discriminator uint8 + TargetType uint8 + IPAddress [4]uint8 + LocationOffsetPort uint16 + TargetPK [32]byte + }{ + Discriminator: uint8(AddTargetInstructionIndex), + TargetType: uint8(config.TargetType), + IPAddress: config.IPAddress, + LocationOffsetPort: config.LocationOffsetPort, + TargetPK: config.TargetPK, + }) + if err != nil { + return nil, fmt.Errorf("failed to serialize args: %w", err) + } + + // Derive the user PDA. + userPDA, _, err := DeriveGeolocationUserPDA(programID, config.Code) + if err != nil { + return nil, fmt.Errorf("failed to derive user PDA: %w", err) + } + + // Build accounts. + accounts := []*solana.AccountMeta{ + {PublicKey: userPDA, IsSigner: false, IsWritable: true}, + {PublicKey: config.ProbePK, IsSigner: false, IsWritable: true}, + {PublicKey: signerPK, IsSigner: true, IsWritable: true}, + {PublicKey: solana.SystemProgramID, IsSigner: false, IsWritable: false}, + } + + return &solana.GenericInstruction{ + ProgID: programID, + AccountValues: accounts, + DataBytes: data, + }, nil +} diff --git a/sdk/geolocation/go/add_target_test.go b/sdk/geolocation/go/add_target_test.go new file mode 100644 index 0000000000..557e4c3227 --- /dev/null +++ b/sdk/geolocation/go/add_target_test.go @@ -0,0 +1,172 @@ +package geolocation_test + +import ( + "testing" + + "github.com/gagliardetto/solana-go" + geolocation "github.com/malbeclabs/doublezero/sdk/geolocation/go" + "github.com/stretchr/testify/require" +) + +func TestBuildAddTargetInstruction_Outbound(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + signerPK := solana.NewWallet().PublicKey() + probePK := solana.NewWallet().PublicKey() + + ix, err := geolocation.BuildAddTargetInstruction(programID, signerPK, geolocation.AddTargetInstructionConfig{ + Code: "test-user", + ProbePK: probePK, + TargetType: geolocation.GeoLocationTargetTypeOutbound, + IPAddress: [4]uint8{8, 8, 8, 8}, + LocationOffsetPort: 443, + TargetPK: solana.NewWallet().PublicKey(), + }) + require.NoError(t, err) + require.NotNil(t, ix) + + // Verify program ID. + require.Equal(t, programID, ix.ProgramID()) + + // Verify accounts: user_pda, probe_pk, signer, system_program. + accounts := ix.Accounts() + require.Len(t, accounts, 4, "expected 4 accounts: user_pda, probe_pk, signer, system_program") + + // Derive the expected user PDA. + expectedUserPDA, _, err := geolocation.DeriveGeolocationUserPDA(programID, "test-user") + require.NoError(t, err) + + // Account 0: user PDA (writable, not signer). + require.Equal(t, expectedUserPDA, accounts[0].PublicKey) + require.True(t, accounts[0].IsWritable) + require.False(t, accounts[0].IsSigner) + + // Account 1: probe PK (writable, not signer). + require.Equal(t, probePK, accounts[1].PublicKey) + require.True(t, accounts[1].IsWritable) + require.False(t, accounts[1].IsSigner) + + // Account 2: signer (writable, signer). + require.Equal(t, signerPK, accounts[2].PublicKey) + require.True(t, accounts[2].IsWritable) + require.True(t, accounts[2].IsSigner) + + // Account 3: system program (not writable, not signer). + require.Equal(t, solana.SystemProgramID, accounts[3].PublicKey) + require.False(t, accounts[3].IsWritable) + require.False(t, accounts[3].IsSigner) +} + +func TestBuildAddTargetInstruction_Inbound(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + signerPK := solana.NewWallet().PublicKey() + probePK := solana.NewWallet().PublicKey() + targetPK := solana.NewWallet().PublicKey() + + ix, err := geolocation.BuildAddTargetInstruction(programID, signerPK, geolocation.AddTargetInstructionConfig{ + Code: "test-user", + ProbePK: probePK, + TargetType: geolocation.GeoLocationTargetTypeInbound, + IPAddress: [4]uint8{1, 2, 3, 4}, + LocationOffsetPort: 8080, + TargetPK: targetPK, + }) + require.NoError(t, err) + require.NotNil(t, ix) + + // Verify the instruction data discriminator is AddTarget (10). + data, err := ix.Data() + require.NoError(t, err) + require.Equal(t, uint8(10), data[0]) + + // Verify inbound target type byte. + require.Equal(t, uint8(1), data[1], "target type should be Inbound (1)") + + // Verify accounts have 4 entries. + accounts := ix.Accounts() + require.Len(t, accounts, 4) +} + +func TestBuildAddTargetInstruction_OutboundIcmp(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + signerPK := solana.NewWallet().PublicKey() + probePK := solana.NewWallet().PublicKey() + + ix, err := geolocation.BuildAddTargetInstruction(programID, signerPK, geolocation.AddTargetInstructionConfig{ + Code: "test-user", + ProbePK: probePK, + TargetType: geolocation.GeoLocationTargetTypeOutboundIcmp, + IPAddress: [4]uint8{1, 1, 1, 1}, + LocationOffsetPort: 0, + TargetPK: solana.NewWallet().PublicKey(), + }) + require.NoError(t, err) + require.NotNil(t, ix) + + // Verify the instruction data discriminator is AddTarget (10). + data, err := ix.Data() + require.NoError(t, err) + require.Equal(t, uint8(10), data[0]) + + // Verify OutboundIcmp target type byte. + require.Equal(t, uint8(2), data[1], "target type should be OutboundIcmp (2)") +} + +func TestBuildAddTargetInstruction_PrivateIP(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + signerPK := solana.NewWallet().PublicKey() + probePK := solana.NewWallet().PublicKey() + + tests := []struct { + name string + ip [4]uint8 + }{ + {"10.x.x.x", [4]uint8{10, 0, 0, 1}}, + {"172.16.x.x", [4]uint8{172, 16, 0, 1}}, + {"192.168.x.x", [4]uint8{192, 168, 1, 1}}, + {"127.0.0.1", [4]uint8{127, 0, 0, 1}}, + {"0.0.0.0", [4]uint8{0, 0, 0, 0}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := geolocation.BuildAddTargetInstruction(programID, signerPK, geolocation.AddTargetInstructionConfig{ + Code: "test-user", + ProbePK: probePK, + TargetType: geolocation.GeoLocationTargetTypeOutbound, + IPAddress: tt.ip, + LocationOffsetPort: 443, + TargetPK: solana.NewWallet().PublicKey(), + }) + require.Error(t, err) + }) + } +} + +func TestBuildAddTargetInstruction_InboundDefaultTargetPK(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + signerPK := solana.NewWallet().PublicKey() + probePK := solana.NewWallet().PublicKey() + + _, err := geolocation.BuildAddTargetInstruction(programID, signerPK, geolocation.AddTargetInstructionConfig{ + Code: "test-user", + ProbePK: probePK, + TargetType: geolocation.GeoLocationTargetTypeInbound, + IPAddress: [4]uint8{1, 2, 3, 4}, + LocationOffsetPort: 8080, + TargetPK: solana.PublicKey{}, // zero value + }) + require.Error(t, err) + require.Contains(t, err.Error(), "target public key is required for inbound target type") +} diff --git a/sdk/geolocation/go/executor.go b/sdk/geolocation/go/executor.go new file mode 100644 index 0000000000..429d89cd8d --- /dev/null +++ b/sdk/geolocation/go/executor.go @@ -0,0 +1,176 @@ +package geolocation + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/gagliardetto/solana-go" + solanarpc "github.com/gagliardetto/solana-go/rpc" +) + +var ( + ErrNoPrivateKey = errors.New("no private key configured") + ErrNoProgramID = errors.New("no program ID configured") +) + +type executor struct { + log *slog.Logger + rpc ExecutorRPCClient + signer *solana.PrivateKey + programID solana.PublicKey + waitForVisibleTimeout time.Duration +} + +type ExecutorOption func(*executor) + +func WithWaitForVisibleTimeout(timeout time.Duration) ExecutorOption { + return func(e *executor) { + e.waitForVisibleTimeout = timeout + } +} + +func NewExecutor(log *slog.Logger, rpc ExecutorRPCClient, signer *solana.PrivateKey, programID solana.PublicKey, opts ...ExecutorOption) *executor { + e := &executor{ + log: log, + rpc: rpc, + signer: signer, + programID: programID, + waitForVisibleTimeout: 3 * time.Second, + } + for _, opt := range opts { + opt(e) + } + return e +} + +type ExecuteTransactionOptions struct { + SkipPreflight bool +} + +func (e *executor) ExecuteTransaction(ctx context.Context, instruction solana.Instruction, opts *ExecuteTransactionOptions) (solana.Signature, *solanarpc.GetTransactionResult, error) { + return e.ExecuteTransactions(ctx, []solana.Instruction{instruction}, opts) +} + +func (e *executor) ExecuteTransactions(ctx context.Context, instructions []solana.Instruction, opts *ExecuteTransactionOptions) (solana.Signature, *solanarpc.GetTransactionResult, error) { + if opts == nil { + opts = &ExecuteTransactionOptions{} + } + + if e.signer == nil { + return solana.Signature{}, nil, ErrNoPrivateKey + } + if e.programID.IsZero() { + return solana.Signature{}, nil, ErrNoProgramID + } + + blockhashResult, err := e.rpc.GetLatestBlockhash(ctx, solanarpc.CommitmentFinalized) + if err != nil { + return solana.Signature{}, nil, fmt.Errorf("failed to get latest blockhash: %w", err) + } + + tx, err := solana.NewTransaction( + instructions, + blockhashResult.Value.Blockhash, + solana.TransactionPayer(e.signer.PublicKey()), + ) + if err != nil { + return solana.Signature{}, nil, fmt.Errorf("failed to build transaction: %w", err) + } + if tx == nil { + return solana.Signature{}, nil, errors.New("transaction build failed: nil result") + } + + _, err = tx.Sign(func(key solana.PublicKey) *solana.PrivateKey { + if key.Equals(e.signer.PublicKey()) { + return e.signer + } + return nil + }) + if err != nil { + return solana.Signature{}, nil, fmt.Errorf("failed to sign transaction: %w", err) + } + if len(tx.Signatures) == 0 { + return solana.Signature{}, nil, errors.New("signed transaction appears malformed") + } + + sig, err := e.rpc.SendTransactionWithOpts(ctx, tx, solanarpc.TransactionOpts{ + SkipPreflight: opts.SkipPreflight, + }) + if err != nil { + return solana.Signature{}, nil, fmt.Errorf("failed to send transaction: %w", err) + } + + err = e.waitForSignatureVisible(ctx, sig, e.waitForVisibleTimeout) + if err != nil { + return solana.Signature{}, nil, fmt.Errorf("transaction not visible: %w", err) + } + + res, err := e.waitForTransactionFinalized(ctx, sig) + if err != nil { + return solana.Signature{}, nil, fmt.Errorf("failed to get transaction: %w", err) + } + + return sig, res, nil +} + +func (e *executor) waitForSignatureVisible(ctx context.Context, sig solana.Signature, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + + for time.Now().Before(deadline) { + resp, err := e.rpc.GetSignatureStatuses(ctx, true, sig) + if err != nil { + return err + } + if len(resp.Value) > 0 && resp.Value[0] != nil { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(250 * time.Millisecond): + } + } + return errors.New("signature not found after wait") +} + +func (e *executor) waitForTransactionFinalized(ctx context.Context, sig solana.Signature) (*solanarpc.GetTransactionResult, error) { + e.log.Debug("waiting for transaction to be finalized", "sig", sig) + start := time.Now() + for { + statusResp, err := e.rpc.GetSignatureStatuses(ctx, true, sig) + if err != nil { + return nil, err + } + if len(statusResp.Value) == 0 { + return nil, errors.New("transaction not found") + } + status := statusResp.Value[0] + if status != nil && status.ConfirmationStatus == solanarpc.ConfirmationStatusFinalized { + e.log.Debug("transaction finalized", "sig", sig, "duration", time.Since(start)) + break + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(1 * time.Second): + if time.Since(start)/time.Second%5 == 0 { + e.log.Debug("still waiting for transaction to be finalized", "sig", sig, "elapsed", time.Since(start)) + } + } + } + + tx, err := e.rpc.GetTransaction(ctx, sig, &solanarpc.GetTransactionOpts{ + Encoding: solana.EncodingBase64, + Commitment: solanarpc.CommitmentFinalized, + }) + if err != nil { + return nil, err + } + if tx == nil || tx.Meta == nil { + return nil, errors.New("transaction not found or missing metadata after finalization") + } + return tx, nil +} diff --git a/sdk/geolocation/go/executor_test.go b/sdk/geolocation/go/executor_test.go new file mode 100644 index 0000000000..00ff28130b --- /dev/null +++ b/sdk/geolocation/go/executor_test.go @@ -0,0 +1,78 @@ +package geolocation_test + +import ( + "context" + "log/slog" + "testing" + "time" + + "github.com/gagliardetto/solana-go" + geolocation "github.com/malbeclabs/doublezero/sdk/geolocation/go" + "github.com/stretchr/testify/require" +) + +func TestNewExecutor(t *testing.T) { + t.Parallel() + + signer := solana.NewWallet().PrivateKey + programID := solana.NewWallet().PublicKey() + + executor := geolocation.NewExecutor(slog.Default(), nil, &signer, programID) + require.NotNil(t, executor, "executor should not be nil") +} + +func TestNewExecutor_WithTimeout(t *testing.T) { + t.Parallel() + + signer := solana.NewWallet().PrivateKey + programID := solana.NewWallet().PublicKey() + + customTimeout := 10 * time.Second + executor := geolocation.NewExecutor(slog.Default(), nil, &signer, programID, geolocation.WithWaitForVisibleTimeout(customTimeout)) + require.NotNil(t, executor, "executor should not be nil with custom timeout") +} + +func TestExecuteTransaction_NoPrivateKey(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + + executor := geolocation.NewExecutor(slog.Default(), nil, nil, programID) + + // Build a dummy instruction to pass to ExecuteTransaction. + dummyIx, err := geolocation.BuildAddTargetInstruction(programID, solana.NewWallet().PublicKey(), geolocation.AddTargetInstructionConfig{ + Code: "test-user", + ProbePK: solana.NewWallet().PublicKey(), + TargetType: geolocation.GeoLocationTargetTypeOutbound, + IPAddress: [4]uint8{8, 8, 8, 8}, + LocationOffsetPort: 443, + }) + require.NoError(t, err) + + _, _, err = executor.ExecuteTransaction(context.Background(), dummyIx, nil) + require.ErrorIs(t, err, geolocation.ErrNoPrivateKey) +} + +func TestExecuteTransaction_NoProgramID(t *testing.T) { + t.Parallel() + + signer := solana.NewWallet().PrivateKey + zeroProgramID := solana.PublicKey{} // zero value + + executor := geolocation.NewExecutor(slog.Default(), nil, &signer, zeroProgramID) + + // Build a dummy instruction using a non-zero program ID (the builder needs it to derive PDAs). + // The executor checks its own programID field, not the instruction's. + validProgramID := solana.NewWallet().PublicKey() + dummyIx, err := geolocation.BuildAddTargetInstruction(validProgramID, solana.NewWallet().PublicKey(), geolocation.AddTargetInstructionConfig{ + Code: "test-user", + ProbePK: solana.NewWallet().PublicKey(), + TargetType: geolocation.GeoLocationTargetTypeOutbound, + IPAddress: [4]uint8{8, 8, 8, 8}, + LocationOffsetPort: 443, + }) + require.NoError(t, err) + + _, _, err = executor.ExecuteTransaction(context.Background(), dummyIx, nil) + require.ErrorIs(t, err, geolocation.ErrNoProgramID) +} diff --git a/sdk/geolocation/go/instructions.go b/sdk/geolocation/go/instructions.go new file mode 100644 index 0000000000..919e86d461 --- /dev/null +++ b/sdk/geolocation/go/instructions.go @@ -0,0 +1,9 @@ +package geolocation + +// Instruction discriminator indices for the GeolocationInstruction Borsh enum. +// Only target management operations are included; other user-facing and Foundation +// commands are added by follow-on changes. +const ( + AddTargetInstructionIndex = 10 + RemoveTargetInstructionIndex = 11 +) diff --git a/sdk/geolocation/go/instructions_test.go b/sdk/geolocation/go/instructions_test.go new file mode 100644 index 0000000000..c38b9b6df3 --- /dev/null +++ b/sdk/geolocation/go/instructions_test.go @@ -0,0 +1,107 @@ +package geolocation_test + +import ( + "encoding/binary" + "testing" + + "github.com/gagliardetto/solana-go" + geolocation "github.com/malbeclabs/doublezero/sdk/geolocation/go" + "github.com/stretchr/testify/require" +) + +func TestSDK_Geolocation_Instructions_AddTarget_Serialization(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + signerPK := solana.NewWallet().PublicKey() + probePK := solana.NewWallet().PublicKey() + targetPK := solana.NewWallet().PublicKey() + + ix, err := geolocation.BuildAddTargetInstruction(programID, signerPK, geolocation.AddTargetInstructionConfig{ + Code: "test-user", + ProbePK: probePK, + TargetType: geolocation.GeoLocationTargetTypeOutbound, + IPAddress: [4]uint8{8, 8, 8, 8}, + LocationOffsetPort: 12345, + TargetPK: targetPK, + }) + require.NoError(t, err) + + data, err := ix.Data() + require.NoError(t, err) + + // Byte 0: discriminator (10). + require.Equal(t, uint8(10), data[0], "discriminator should be AddTarget (10)") + + // Byte 1: target type (0 = Outbound). + require.Equal(t, uint8(0), data[1]) + + // Bytes 2-5: IP address. + require.Equal(t, [4]byte{8, 8, 8, 8}, [4]byte(data[2:6])) + + // Bytes 6-7: LocationOffsetPort (LE). + port := binary.LittleEndian.Uint16(data[6:8]) + require.Equal(t, uint16(12345), port) + + // Bytes 8-39: TargetPK. + var gotTargetPK [32]byte + copy(gotTargetPK[:], data[8:40]) + require.Equal(t, [32]byte(targetPK), gotTargetPK) +} + +func TestSDK_Geolocation_Instructions_RemoveTarget_Serialization(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + signerPK := solana.NewWallet().PublicKey() + probePK := solana.NewWallet().PublicKey() + targetPK := solana.NewWallet().PublicKey() + serviceabilityGS := solana.NewWallet().PublicKey() + + ix, err := geolocation.BuildRemoveTargetInstruction(programID, signerPK, geolocation.RemoveTargetInstructionConfig{ + Code: "test-user", + ProbePK: probePK, + TargetType: geolocation.GeoLocationTargetTypeInbound, + IPAddress: [4]uint8{1, 2, 3, 4}, + TargetPK: targetPK, + ServiceabilityGlobalState: serviceabilityGS, + }) + require.NoError(t, err) + + data, err := ix.Data() + require.NoError(t, err) + + // Byte 0: discriminator (11). + require.Equal(t, uint8(11), data[0], "discriminator should be RemoveTarget (11)") + + // Byte 1: target type (1 = Inbound). + require.Equal(t, uint8(1), data[1]) + + // Bytes 2-5: IP address. + require.Equal(t, [4]byte{1, 2, 3, 4}, [4]byte(data[2:6])) + + // Bytes 6-37: TargetPK. + var gotTargetPK [32]byte + copy(gotTargetPK[:], data[6:38]) + require.Equal(t, [32]byte(targetPK), gotTargetPK) +} + +func TestSDK_Geolocation_Instructions_AllDiscriminators(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + index int + expected uint8 + }{ + {"AddTarget", geolocation.AddTargetInstructionIndex, 10}, + {"RemoveTarget", geolocation.RemoveTargetInstructionIndex, 11}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, int(tt.expected), tt.index) + }) + } +} diff --git a/sdk/geolocation/go/remove_target.go b/sdk/geolocation/go/remove_target.go new file mode 100644 index 0000000000..d93d911e63 --- /dev/null +++ b/sdk/geolocation/go/remove_target.go @@ -0,0 +1,88 @@ +package geolocation + +import ( + "fmt" + + "github.com/gagliardetto/solana-go" + "github.com/near/borsh-go" +) + +type RemoveTargetInstructionConfig struct { + Code string + ProbePK solana.PublicKey + TargetType GeoLocationTargetType + IPAddress [4]uint8 + TargetPK solana.PublicKey + ServiceabilityGlobalState solana.PublicKey +} + +func (c *RemoveTargetInstructionConfig) Validate() error { + if c.Code == "" { + return fmt.Errorf("code is required") + } + if len(c.Code) > MaxCodeLength { + return fmt.Errorf("code length %d exceeds max %d", len(c.Code), MaxCodeLength) + } + if c.ProbePK.IsZero() { + return fmt.Errorf("probe public key is required") + } + if c.ServiceabilityGlobalState.IsZero() { + return fmt.Errorf("serviceability global state public key is required") + } + if c.TargetType > GeoLocationTargetTypeOutboundIcmp { + return fmt.Errorf("unknown target type: %d", c.TargetType) + } + return nil +} + +func BuildRemoveTargetInstruction( + programID solana.PublicKey, + signerPK solana.PublicKey, + config RemoveTargetInstructionConfig, +) (solana.Instruction, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("failed to validate config: %w", err) + } + + // Serialize the instruction data. + data, err := borsh.Serialize(struct { + Discriminator uint8 + TargetType uint8 + IPAddress [4]uint8 + TargetPK [32]byte + }{ + Discriminator: uint8(RemoveTargetInstructionIndex), + TargetType: uint8(config.TargetType), + IPAddress: config.IPAddress, + TargetPK: config.TargetPK, + }) + if err != nil { + return nil, fmt.Errorf("failed to serialize args: %w", err) + } + + // Derive PDAs. + userPDA, _, err := DeriveGeolocationUserPDA(programID, config.Code) + if err != nil { + return nil, fmt.Errorf("failed to derive user PDA: %w", err) + } + configPDA, _, err := DeriveProgramConfigPDA(programID) + if err != nil { + return nil, fmt.Errorf("failed to derive config PDA: %w", err) + } + + // Build accounts. + accounts := []*solana.AccountMeta{ + {PublicKey: userPDA, IsSigner: false, IsWritable: true}, + {PublicKey: config.ProbePK, IsSigner: false, IsWritable: true}, + {PublicKey: configPDA, IsSigner: false, IsWritable: false}, + {PublicKey: config.ServiceabilityGlobalState, IsSigner: false, IsWritable: false}, + {PublicKey: signerPK, IsSigner: true, IsWritable: true}, + {PublicKey: solana.SystemProgramID, IsSigner: false, IsWritable: false}, + } + + return &solana.GenericInstruction{ + ProgID: programID, + AccountValues: accounts, + DataBytes: data, + }, nil +} diff --git a/sdk/geolocation/go/remove_target_test.go b/sdk/geolocation/go/remove_target_test.go new file mode 100644 index 0000000000..db59def4d7 --- /dev/null +++ b/sdk/geolocation/go/remove_target_test.go @@ -0,0 +1,109 @@ +package geolocation_test + +import ( + "testing" + + "github.com/gagliardetto/solana-go" + geolocation "github.com/malbeclabs/doublezero/sdk/geolocation/go" + "github.com/stretchr/testify/require" +) + +func TestBuildRemoveTargetInstruction_Valid(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + signerPK := solana.NewWallet().PublicKey() + probePK := solana.NewWallet().PublicKey() + targetPK := solana.NewWallet().PublicKey() + serviceabilityGS := solana.NewWallet().PublicKey() + + ix, err := geolocation.BuildRemoveTargetInstruction(programID, signerPK, geolocation.RemoveTargetInstructionConfig{ + Code: "test-user", + ProbePK: probePK, + TargetType: geolocation.GeoLocationTargetTypeOutbound, + IPAddress: [4]uint8{8, 8, 4, 4}, + TargetPK: targetPK, + ServiceabilityGlobalState: serviceabilityGS, + }) + require.NoError(t, err) + require.NotNil(t, ix) + + // Verify program ID. + require.Equal(t, programID, ix.ProgramID()) + + // Verify accounts: user_pda, probe_pk, config_pda, serviceability_gs, signer, system_program. + accounts := ix.Accounts() + require.Len(t, accounts, 6, "expected 6 accounts: user_pda, probe_pk, config_pda, serviceability_gs, signer, system_program") + + // Derive expected PDAs. + expectedUserPDA, _, err := geolocation.DeriveGeolocationUserPDA(programID, "test-user") + require.NoError(t, err) + expectedConfigPDA, _, err := geolocation.DeriveProgramConfigPDA(programID) + require.NoError(t, err) + + // Account 0: user PDA (writable, not signer). + require.Equal(t, expectedUserPDA, accounts[0].PublicKey) + require.True(t, accounts[0].IsWritable) + require.False(t, accounts[0].IsSigner) + + // Account 1: probe PK (writable, not signer). + require.Equal(t, probePK, accounts[1].PublicKey) + require.True(t, accounts[1].IsWritable) + require.False(t, accounts[1].IsSigner) + + // Account 2: config PDA (not writable, not signer). + require.Equal(t, expectedConfigPDA, accounts[2].PublicKey) + require.False(t, accounts[2].IsWritable) + require.False(t, accounts[2].IsSigner) + + // Account 3: serviceability global state (not writable, not signer). + require.Equal(t, serviceabilityGS, accounts[3].PublicKey) + require.False(t, accounts[3].IsWritable) + require.False(t, accounts[3].IsSigner) + + // Account 4: signer (writable, signer). + require.Equal(t, signerPK, accounts[4].PublicKey) + require.True(t, accounts[4].IsWritable) + require.True(t, accounts[4].IsSigner) + + // Account 5: system program (not writable, not signer). + require.Equal(t, solana.SystemProgramID, accounts[5].PublicKey) + require.False(t, accounts[5].IsWritable) + require.False(t, accounts[5].IsSigner) +} + +func TestBuildRemoveTargetInstruction_EmptyCode(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + signerPK := solana.NewWallet().PublicKey() + + _, err := geolocation.BuildRemoveTargetInstruction(programID, signerPK, geolocation.RemoveTargetInstructionConfig{ + Code: "", + ProbePK: solana.NewWallet().PublicKey(), + TargetType: geolocation.GeoLocationTargetTypeOutbound, + IPAddress: [4]uint8{8, 8, 8, 8}, + TargetPK: solana.NewWallet().PublicKey(), + ServiceabilityGlobalState: solana.NewWallet().PublicKey(), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "code is required") +} + +func TestBuildRemoveTargetInstruction_ZeroProbePK(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + signerPK := solana.NewWallet().PublicKey() + + _, err := geolocation.BuildRemoveTargetInstruction(programID, signerPK, geolocation.RemoveTargetInstructionConfig{ + Code: "test-user", + ProbePK: solana.PublicKey{}, + TargetType: geolocation.GeoLocationTargetTypeOutbound, + IPAddress: [4]uint8{8, 8, 8, 8}, + TargetPK: solana.NewWallet().PublicKey(), + ServiceabilityGlobalState: solana.NewWallet().PublicKey(), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "probe public key is required") +} diff --git a/sdk/geolocation/go/rpc.go b/sdk/geolocation/go/rpc.go index 3fa2e03497..c93bf046f1 100644 --- a/sdk/geolocation/go/rpc.go +++ b/sdk/geolocation/go/rpc.go @@ -12,3 +12,11 @@ type RPCClient interface { GetAccountInfo(ctx context.Context, account solana.PublicKey) (out *solanarpc.GetAccountInfoResult, err error) GetProgramAccountsWithOpts(ctx context.Context, publicKey solana.PublicKey, opts *solanarpc.GetProgramAccountsOpts) (out solanarpc.GetProgramAccountsResult, err error) } + +// ExecutorRPCClient is an interface for write-path RPC operations used by the executor. +type ExecutorRPCClient interface { + GetLatestBlockhash(ctx context.Context, commitment solanarpc.CommitmentType) (out *solanarpc.GetLatestBlockhashResult, err error) + SendTransactionWithOpts(ctx context.Context, transaction *solana.Transaction, opts solanarpc.TransactionOpts) (sig solana.Signature, err error) + GetSignatureStatuses(ctx context.Context, searchTransactionHistory bool, transactionSignatures ...solana.Signature) (out *solanarpc.GetSignatureStatusesResult, err error) + GetTransaction(ctx context.Context, txSig solana.Signature, opts *solanarpc.GetTransactionOpts) (out *solanarpc.GetTransactionResult, err error) +} From e6d3de60c09aa7949814bc4e1baf2520ce1079c6 Mon Sep 17 00:00:00 2001 From: Ben Blier Date: Wed, 22 Apr 2026 15:56:02 -0400 Subject: [PATCH 2/3] sdk/geolocation: address review feedback --- sdk/geolocation/go/add_target.go | 27 +-- sdk/geolocation/go/add_target_test.go | 80 ++++++- sdk/geolocation/go/executor.go | 17 +- sdk/geolocation/go/executor_test.go | 293 +++++++++++++++++++++++ sdk/geolocation/go/main_test.go | 25 ++ sdk/geolocation/go/remove_target.go | 6 + sdk/geolocation/go/remove_target_test.go | 17 ++ sdk/geolocation/go/validation.go | 94 ++++++++ 8 files changed, 525 insertions(+), 34 deletions(-) create mode 100644 sdk/geolocation/go/validation.go diff --git a/sdk/geolocation/go/add_target.go b/sdk/geolocation/go/add_target.go index 569445def0..baf7caa793 100644 --- a/sdk/geolocation/go/add_target.go +++ b/sdk/geolocation/go/add_target.go @@ -29,7 +29,7 @@ func (c *AddTargetInstructionConfig) Validate() error { switch c.TargetType { case GeoLocationTargetTypeOutbound, GeoLocationTargetTypeOutboundIcmp: - if err := validateNotPrivateIP(c.IPAddress); err != nil { + if err := validatePublicIP(c.IPAddress); err != nil { return err } case GeoLocationTargetTypeInbound: @@ -43,31 +43,6 @@ func (c *AddTargetInstructionConfig) Validate() error { return nil } -// validateNotPrivateIP checks that an IPv4 address is not in a private/reserved range. -func validateNotPrivateIP(ip [4]uint8) error { - // 10.0.0.0/8 - if ip[0] == 10 { - return fmt.Errorf("IP address %d.%d.%d.%d is in private range 10.0.0.0/8", ip[0], ip[1], ip[2], ip[3]) - } - // 172.16.0.0/12 - if ip[0] == 172 && ip[1] >= 16 && ip[1] <= 31 { - return fmt.Errorf("IP address %d.%d.%d.%d is in private range 172.16.0.0/12", ip[0], ip[1], ip[2], ip[3]) - } - // 192.168.0.0/16 - if ip[0] == 192 && ip[1] == 168 { - return fmt.Errorf("IP address %d.%d.%d.%d is in private range 192.168.0.0/16", ip[0], ip[1], ip[2], ip[3]) - } - // 127.0.0.0/8 - if ip[0] == 127 { - return fmt.Errorf("IP address %d.%d.%d.%d is in loopback range 127.0.0.0/8", ip[0], ip[1], ip[2], ip[3]) - } - // 0.0.0.0 - if ip[0] == 0 && ip[1] == 0 && ip[2] == 0 && ip[3] == 0 { - return fmt.Errorf("IP address 0.0.0.0 is not allowed") - } - return nil -} - func BuildAddTargetInstruction( programID solana.PublicKey, signerPK solana.PublicKey, diff --git a/sdk/geolocation/go/add_target_test.go b/sdk/geolocation/go/add_target_test.go index 557e4c3227..d3a9ef8f00 100644 --- a/sdk/geolocation/go/add_target_test.go +++ b/sdk/geolocation/go/add_target_test.go @@ -117,22 +117,88 @@ func TestBuildAddTargetInstruction_OutboundIcmp(t *testing.T) { require.Equal(t, uint8(2), data[1], "target type should be OutboundIcmp (2)") } -func TestBuildAddTargetInstruction_PrivateIP(t *testing.T) { +func TestBuildAddTargetInstruction_NonPublicIP(t *testing.T) { t.Parallel() programID := solana.NewWallet().PublicKey() signerPK := solana.NewWallet().PublicKey() probePK := solana.NewWallet().PublicKey() + // Cases mirror doublezero-geolocation/src/validation.rs tests. Each IP must be + // rejected for both Outbound and OutboundIcmp target types. tests := []struct { name string ip [4]uint8 }{ - {"10.x.x.x", [4]uint8{10, 0, 0, 1}}, - {"172.16.x.x", [4]uint8{172, 16, 0, 1}}, - {"192.168.x.x", [4]uint8{192, 168, 1, 1}}, - {"127.0.0.1", [4]uint8{127, 0, 0, 1}}, - {"0.0.0.0", [4]uint8{0, 0, 0, 0}}, + {"unspecified", [4]uint8{0, 0, 0, 0}}, + {"this-network-0.x", [4]uint8{0, 1, 2, 3}}, + {"loopback", [4]uint8{127, 0, 0, 1}}, + {"private-10/8", [4]uint8{10, 0, 0, 1}}, + {"private-172.16/12-low", [4]uint8{172, 16, 0, 1}}, + {"private-172.16/12-high", [4]uint8{172, 31, 255, 254}}, + {"private-192.168/16", [4]uint8{192, 168, 1, 1}}, + {"cgnat-low", [4]uint8{100, 64, 0, 1}}, + {"cgnat-high", [4]uint8{100, 127, 255, 254}}, + {"link-local", [4]uint8{169, 254, 1, 1}}, + {"protocol-assignments", [4]uint8{192, 0, 0, 1}}, + {"test-net-1", [4]uint8{192, 0, 2, 1}}, + {"benchmarking-low", [4]uint8{198, 18, 0, 1}}, + {"benchmarking-high", [4]uint8{198, 19, 255, 254}}, + {"test-net-2", [4]uint8{198, 51, 100, 1}}, + {"test-net-3", [4]uint8{203, 0, 113, 1}}, + {"multicast-low", [4]uint8{224, 0, 0, 1}}, + {"multicast-high", [4]uint8{239, 255, 255, 255}}, + {"reserved", [4]uint8{240, 0, 0, 1}}, + {"broadcast", [4]uint8{255, 255, 255, 255}}, + } + + targetTypes := []struct { + name string + t geolocation.GeoLocationTargetType + }{ + {"outbound", geolocation.GeoLocationTargetTypeOutbound}, + {"outbound-icmp", geolocation.GeoLocationTargetTypeOutboundIcmp}, + } + + for _, tt := range tests { + for _, ttype := range targetTypes { + t.Run(tt.name+"/"+ttype.name, func(t *testing.T) { + t.Parallel() + + _, err := geolocation.BuildAddTargetInstruction(programID, signerPK, geolocation.AddTargetInstructionConfig{ + Code: "test-user", + ProbePK: probePK, + TargetType: ttype.t, + IPAddress: tt.ip, + LocationOffsetPort: 443, + TargetPK: solana.NewWallet().PublicKey(), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "not publicly routable") + }) + } + } +} + +// TestBuildAddTargetInstruction_PublicIPEdgeCases covers addresses adjacent to +// rejected ranges to confirm they still pass validation. +func TestBuildAddTargetInstruction_PublicIPEdgeCases(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + signerPK := solana.NewWallet().PublicKey() + probePK := solana.NewWallet().PublicKey() + + tests := []struct { + name string + ip [4]uint8 + }{ + {"public-8.8.8.8", [4]uint8{8, 8, 8, 8}}, + {"public-1.1.1.1", [4]uint8{1, 1, 1, 1}}, + {"just-below-cgnat", [4]uint8{100, 63, 255, 255}}, + {"just-above-cgnat", [4]uint8{100, 128, 0, 0}}, + {"just-below-benchmarking", [4]uint8{198, 17, 255, 255}}, + {"just-above-benchmarking", [4]uint8{198, 20, 0, 0}}, } for _, tt := range tests { @@ -147,7 +213,7 @@ func TestBuildAddTargetInstruction_PrivateIP(t *testing.T) { LocationOffsetPort: 443, TargetPK: solana.NewWallet().PublicKey(), }) - require.Error(t, err) + require.NoError(t, err) }) } } diff --git a/sdk/geolocation/go/executor.go b/sdk/geolocation/go/executor.go index 429d89cd8d..b6e33cd17a 100644 --- a/sdk/geolocation/go/executor.go +++ b/sdk/geolocation/go/executor.go @@ -22,6 +22,7 @@ type executor struct { signer *solana.PrivateKey programID solana.PublicKey waitForVisibleTimeout time.Duration + finalizationTimeout time.Duration } type ExecutorOption func(*executor) @@ -32,6 +33,15 @@ func WithWaitForVisibleTimeout(timeout time.Duration) ExecutorOption { } } +// WithFinalizationTimeout bounds how long ExecuteTransaction will poll for the +// transaction to reach Finalized commitment once it is visible on the cluster. +// Callers can also cancel the context to cut the wait short. +func WithFinalizationTimeout(timeout time.Duration) ExecutorOption { + return func(e *executor) { + e.finalizationTimeout = timeout + } +} + func NewExecutor(log *slog.Logger, rpc ExecutorRPCClient, signer *solana.PrivateKey, programID solana.PublicKey, opts ...ExecutorOption) *executor { e := &executor{ log: log, @@ -39,6 +49,7 @@ func NewExecutor(log *slog.Logger, rpc ExecutorRPCClient, signer *solana.Private signer: signer, programID: programID, waitForVisibleTimeout: 3 * time.Second, + finalizationTimeout: 120 * time.Second, } for _, opt := range opts { opt(e) @@ -137,8 +148,9 @@ func (e *executor) waitForSignatureVisible(ctx context.Context, sig solana.Signa } func (e *executor) waitForTransactionFinalized(ctx context.Context, sig solana.Signature) (*solanarpc.GetTransactionResult, error) { - e.log.Debug("waiting for transaction to be finalized", "sig", sig) + e.log.Debug("waiting for transaction to be finalized", "sig", sig, "timeout", e.finalizationTimeout) start := time.Now() + deadline := start.Add(e.finalizationTimeout) for { statusResp, err := e.rpc.GetSignatureStatuses(ctx, true, sig) if err != nil { @@ -152,6 +164,9 @@ func (e *executor) waitForTransactionFinalized(ctx context.Context, sig solana.S e.log.Debug("transaction finalized", "sig", sig, "duration", time.Since(start)) break } + if time.Now().After(deadline) { + return nil, fmt.Errorf("transaction not finalized within %s", e.finalizationTimeout) + } select { case <-ctx.Done(): return nil, ctx.Err() diff --git a/sdk/geolocation/go/executor_test.go b/sdk/geolocation/go/executor_test.go index 00ff28130b..d5f6047ac4 100644 --- a/sdk/geolocation/go/executor_test.go +++ b/sdk/geolocation/go/executor_test.go @@ -2,11 +2,14 @@ package geolocation_test import ( "context" + "errors" "log/slog" + "sync/atomic" "testing" "time" "github.com/gagliardetto/solana-go" + solanarpc "github.com/gagliardetto/solana-go/rpc" geolocation "github.com/malbeclabs/doublezero/sdk/geolocation/go" "github.com/stretchr/testify/require" ) @@ -76,3 +79,293 @@ func TestExecuteTransaction_NoProgramID(t *testing.T) { _, _, err = executor.ExecuteTransaction(context.Background(), dummyIx, nil) require.ErrorIs(t, err, geolocation.ErrNoProgramID) } + +// dummyInstructionFor returns a valid AddTarget instruction whose signer matches +// the given wallet — sufficient for tx.Sign to succeed inside ExecuteTransaction. +func dummyInstructionFor(t *testing.T, programID solana.PublicKey, signerPK solana.PublicKey) solana.Instruction { + t.Helper() + ix, err := geolocation.BuildAddTargetInstruction(programID, signerPK, geolocation.AddTargetInstructionConfig{ + Code: "test-user", + ProbePK: solana.NewWallet().PublicKey(), + TargetType: geolocation.GeoLocationTargetTypeOutbound, + IPAddress: [4]uint8{8, 8, 8, 8}, + LocationOffsetPort: 443, + }) + require.NoError(t, err) + return ix +} + +func finalizedStatusResult() *solanarpc.GetSignatureStatusesResult { + return &solanarpc.GetSignatureStatusesResult{ + Value: []*solanarpc.SignatureStatusesResult{ + {ConfirmationStatus: solanarpc.ConfirmationStatusFinalized}, + }, + } +} + +func finalizedTxResult() *solanarpc.GetTransactionResult { + return &solanarpc.GetTransactionResult{Meta: &solanarpc.TransactionMeta{}} +} + +func TestExecuteTransaction_HappyPath(t *testing.T) { + t.Parallel() + + signer := solana.NewWallet().PrivateKey + programID := solana.NewWallet().PublicKey() + sig := solana.Signature{1, 2, 3} + + rpc := &mockExecutorRPCClient{ + GetLatestBlockhashFunc: func(context.Context, solanarpc.CommitmentType) (*solanarpc.GetLatestBlockhashResult, error) { + return &solanarpc.GetLatestBlockhashResult{ + Value: &solanarpc.LatestBlockhashResult{Blockhash: solana.Hash{9}}, + }, nil + }, + SendTransactionWithOptsFunc: func(_ context.Context, _ *solana.Transaction, _ solanarpc.TransactionOpts) (solana.Signature, error) { + return sig, nil + }, + GetSignatureStatusesFunc: func(context.Context, bool, ...solana.Signature) (*solanarpc.GetSignatureStatusesResult, error) { + return finalizedStatusResult(), nil + }, + GetTransactionFunc: func(context.Context, solana.Signature, *solanarpc.GetTransactionOpts) (*solanarpc.GetTransactionResult, error) { + return finalizedTxResult(), nil + }, + } + + e := geolocation.NewExecutor(slog.Default(), rpc, &signer, programID) + ix := dummyInstructionFor(t, programID, signer.PublicKey()) + + gotSig, res, err := e.ExecuteTransaction(context.Background(), ix, nil) + require.NoError(t, err) + require.Equal(t, sig, gotSig) + require.NotNil(t, res) +} + +func TestExecuteTransaction_BlockhashError(t *testing.T) { + t.Parallel() + + signer := solana.NewWallet().PrivateKey + programID := solana.NewWallet().PublicKey() + wantErr := errors.New("blockhash unavailable") + + rpc := &mockExecutorRPCClient{ + GetLatestBlockhashFunc: func(context.Context, solanarpc.CommitmentType) (*solanarpc.GetLatestBlockhashResult, error) { + return nil, wantErr + }, + } + e := geolocation.NewExecutor(slog.Default(), rpc, &signer, programID) + ix := dummyInstructionFor(t, programID, signer.PublicKey()) + + _, _, err := e.ExecuteTransaction(context.Background(), ix, nil) + require.ErrorIs(t, err, wantErr) +} + +func TestExecuteTransaction_SendError(t *testing.T) { + t.Parallel() + + signer := solana.NewWallet().PrivateKey + programID := solana.NewWallet().PublicKey() + wantErr := errors.New("send failed") + + rpc := &mockExecutorRPCClient{ + GetLatestBlockhashFunc: func(context.Context, solanarpc.CommitmentType) (*solanarpc.GetLatestBlockhashResult, error) { + return &solanarpc.GetLatestBlockhashResult{ + Value: &solanarpc.LatestBlockhashResult{Blockhash: solana.Hash{9}}, + }, nil + }, + SendTransactionWithOptsFunc: func(context.Context, *solana.Transaction, solanarpc.TransactionOpts) (solana.Signature, error) { + return solana.Signature{}, wantErr + }, + } + e := geolocation.NewExecutor(slog.Default(), rpc, &signer, programID) + ix := dummyInstructionFor(t, programID, signer.PublicKey()) + + _, _, err := e.ExecuteTransaction(context.Background(), ix, nil) + require.ErrorIs(t, err, wantErr) +} + +func TestExecuteTransaction_ConfirmationTransitions(t *testing.T) { + t.Parallel() + + signer := solana.NewWallet().PrivateKey + programID := solana.NewWallet().PublicKey() + sig := solana.Signature{7} + + // Return Processed, then Confirmed, then Finalized on successive calls. + var call atomic.Int32 + rpc := &mockExecutorRPCClient{ + GetLatestBlockhashFunc: func(context.Context, solanarpc.CommitmentType) (*solanarpc.GetLatestBlockhashResult, error) { + return &solanarpc.GetLatestBlockhashResult{ + Value: &solanarpc.LatestBlockhashResult{Blockhash: solana.Hash{9}}, + }, nil + }, + SendTransactionWithOptsFunc: func(context.Context, *solana.Transaction, solanarpc.TransactionOpts) (solana.Signature, error) { + return sig, nil + }, + GetSignatureStatusesFunc: func(context.Context, bool, ...solana.Signature) (*solanarpc.GetSignatureStatusesResult, error) { + var status solanarpc.ConfirmationStatusType + switch call.Add(1) { + case 1: + status = solanarpc.ConfirmationStatusProcessed + case 2: + status = solanarpc.ConfirmationStatusConfirmed + default: + status = solanarpc.ConfirmationStatusFinalized + } + return &solanarpc.GetSignatureStatusesResult{ + Value: []*solanarpc.SignatureStatusesResult{ + {ConfirmationStatus: status}, + }, + }, nil + }, + GetTransactionFunc: func(context.Context, solana.Signature, *solanarpc.GetTransactionOpts) (*solanarpc.GetTransactionResult, error) { + return finalizedTxResult(), nil + }, + } + + e := geolocation.NewExecutor(slog.Default(), rpc, &signer, programID) + ix := dummyInstructionFor(t, programID, signer.PublicKey()) + + _, _, err := e.ExecuteTransaction(context.Background(), ix, nil) + require.NoError(t, err) + require.GreaterOrEqual(t, call.Load(), int32(3), "should have polled through Processed/Confirmed before Finalized") +} + +func TestExecuteTransaction_WaitForVisibleTimeout(t *testing.T) { + t.Parallel() + + signer := solana.NewWallet().PrivateKey + programID := solana.NewWallet().PublicKey() + sig := solana.Signature{5} + + rpc := &mockExecutorRPCClient{ + GetLatestBlockhashFunc: func(context.Context, solanarpc.CommitmentType) (*solanarpc.GetLatestBlockhashResult, error) { + return &solanarpc.GetLatestBlockhashResult{ + Value: &solanarpc.LatestBlockhashResult{Blockhash: solana.Hash{9}}, + }, nil + }, + SendTransactionWithOptsFunc: func(context.Context, *solana.Transaction, solanarpc.TransactionOpts) (solana.Signature, error) { + return sig, nil + }, + GetSignatureStatusesFunc: func(context.Context, bool, ...solana.Signature) (*solanarpc.GetSignatureStatusesResult, error) { + return &solanarpc.GetSignatureStatusesResult{Value: []*solanarpc.SignatureStatusesResult{nil}}, nil + }, + } + + e := geolocation.NewExecutor(slog.Default(), rpc, &signer, programID, + geolocation.WithWaitForVisibleTimeout(200*time.Millisecond), + ) + ix := dummyInstructionFor(t, programID, signer.PublicKey()) + + _, _, err := e.ExecuteTransaction(context.Background(), ix, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "not visible") +} + +func TestExecuteTransaction_ContextCancellationDuringFinalization(t *testing.T) { + t.Parallel() + + signer := solana.NewWallet().PrivateKey + programID := solana.NewWallet().PublicKey() + sig := solana.Signature{6} + + // Keep returning Confirmed (not Finalized) so the executor stays in the wait loop. + rpc := &mockExecutorRPCClient{ + GetLatestBlockhashFunc: func(context.Context, solanarpc.CommitmentType) (*solanarpc.GetLatestBlockhashResult, error) { + return &solanarpc.GetLatestBlockhashResult{ + Value: &solanarpc.LatestBlockhashResult{Blockhash: solana.Hash{9}}, + }, nil + }, + SendTransactionWithOptsFunc: func(context.Context, *solana.Transaction, solanarpc.TransactionOpts) (solana.Signature, error) { + return sig, nil + }, + GetSignatureStatusesFunc: func(context.Context, bool, ...solana.Signature) (*solanarpc.GetSignatureStatusesResult, error) { + return &solanarpc.GetSignatureStatusesResult{ + Value: []*solanarpc.SignatureStatusesResult{ + {ConfirmationStatus: solanarpc.ConfirmationStatusConfirmed}, + }, + }, nil + }, + } + + e := geolocation.NewExecutor(slog.Default(), rpc, &signer, programID) + ix := dummyInstructionFor(t, programID, signer.PublicKey()) + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(300 * time.Millisecond) + cancel() + }() + + _, _, err := e.ExecuteTransaction(ctx, ix, nil) + require.Error(t, err) + require.ErrorIs(t, err, context.Canceled) +} + +func TestExecuteTransaction_FinalizationTimeout(t *testing.T) { + t.Parallel() + + signer := solana.NewWallet().PrivateKey + programID := solana.NewWallet().PublicKey() + sig := solana.Signature{8} + + rpc := &mockExecutorRPCClient{ + GetLatestBlockhashFunc: func(context.Context, solanarpc.CommitmentType) (*solanarpc.GetLatestBlockhashResult, error) { + return &solanarpc.GetLatestBlockhashResult{ + Value: &solanarpc.LatestBlockhashResult{Blockhash: solana.Hash{9}}, + }, nil + }, + SendTransactionWithOptsFunc: func(context.Context, *solana.Transaction, solanarpc.TransactionOpts) (solana.Signature, error) { + return sig, nil + }, + GetSignatureStatusesFunc: func(context.Context, bool, ...solana.Signature) (*solanarpc.GetSignatureStatusesResult, error) { + return &solanarpc.GetSignatureStatusesResult{ + Value: []*solanarpc.SignatureStatusesResult{ + {ConfirmationStatus: solanarpc.ConfirmationStatusProcessed}, + }, + }, nil + }, + } + + e := geolocation.NewExecutor(slog.Default(), rpc, &signer, programID, + geolocation.WithFinalizationTimeout(500*time.Millisecond), + ) + ix := dummyInstructionFor(t, programID, signer.PublicKey()) + + _, _, err := e.ExecuteTransaction(context.Background(), ix, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "not finalized within") +} + +func TestExecuteTransaction_SkipPreflightPassthrough(t *testing.T) { + t.Parallel() + + signer := solana.NewWallet().PrivateKey + programID := solana.NewWallet().PublicKey() + sig := solana.Signature{4} + + var observed atomic.Bool + rpc := &mockExecutorRPCClient{ + GetLatestBlockhashFunc: func(context.Context, solanarpc.CommitmentType) (*solanarpc.GetLatestBlockhashResult, error) { + return &solanarpc.GetLatestBlockhashResult{ + Value: &solanarpc.LatestBlockhashResult{Blockhash: solana.Hash{9}}, + }, nil + }, + SendTransactionWithOptsFunc: func(_ context.Context, _ *solana.Transaction, opts solanarpc.TransactionOpts) (solana.Signature, error) { + observed.Store(opts.SkipPreflight) + return sig, nil + }, + GetSignatureStatusesFunc: func(context.Context, bool, ...solana.Signature) (*solanarpc.GetSignatureStatusesResult, error) { + return finalizedStatusResult(), nil + }, + GetTransactionFunc: func(context.Context, solana.Signature, *solanarpc.GetTransactionOpts) (*solanarpc.GetTransactionResult, error) { + return finalizedTxResult(), nil + }, + } + + e := geolocation.NewExecutor(slog.Default(), rpc, &signer, programID) + ix := dummyInstructionFor(t, programID, signer.PublicKey()) + + _, _, err := e.ExecuteTransaction(context.Background(), ix, &geolocation.ExecuteTransactionOptions{SkipPreflight: true}) + require.NoError(t, err) + require.True(t, observed.Load(), "mock should have received SkipPreflight=true") +} diff --git a/sdk/geolocation/go/main_test.go b/sdk/geolocation/go/main_test.go index 1bd08b50a5..c717b4b9d8 100644 --- a/sdk/geolocation/go/main_test.go +++ b/sdk/geolocation/go/main_test.go @@ -52,3 +52,28 @@ func (m *mockRPCClient) GetAccountInfo(ctx context.Context, account solana.Publi func (m *mockRPCClient) GetProgramAccountsWithOpts(ctx context.Context, publicKey solana.PublicKey, opts *solanarpc.GetProgramAccountsOpts) (solanarpc.GetProgramAccountsResult, error) { return m.GetProgramAccountsWithOptsFunc(ctx, publicKey, opts) } + +type mockExecutorRPCClient struct { + geolocation.ExecutorRPCClient + + GetLatestBlockhashFunc func(context.Context, solanarpc.CommitmentType) (*solanarpc.GetLatestBlockhashResult, error) + SendTransactionWithOptsFunc func(context.Context, *solana.Transaction, solanarpc.TransactionOpts) (solana.Signature, error) + GetSignatureStatusesFunc func(context.Context, bool, ...solana.Signature) (*solanarpc.GetSignatureStatusesResult, error) + GetTransactionFunc func(context.Context, solana.Signature, *solanarpc.GetTransactionOpts) (*solanarpc.GetTransactionResult, error) +} + +func (m *mockExecutorRPCClient) GetLatestBlockhash(ctx context.Context, commitment solanarpc.CommitmentType) (*solanarpc.GetLatestBlockhashResult, error) { + return m.GetLatestBlockhashFunc(ctx, commitment) +} + +func (m *mockExecutorRPCClient) SendTransactionWithOpts(ctx context.Context, tx *solana.Transaction, opts solanarpc.TransactionOpts) (solana.Signature, error) { + return m.SendTransactionWithOptsFunc(ctx, tx, opts) +} + +func (m *mockExecutorRPCClient) GetSignatureStatuses(ctx context.Context, searchTransactionHistory bool, sigs ...solana.Signature) (*solanarpc.GetSignatureStatusesResult, error) { + return m.GetSignatureStatusesFunc(ctx, searchTransactionHistory, sigs...) +} + +func (m *mockExecutorRPCClient) GetTransaction(ctx context.Context, sig solana.Signature, opts *solanarpc.GetTransactionOpts) (*solanarpc.GetTransactionResult, error) { + return m.GetTransactionFunc(ctx, sig, opts) +} diff --git a/sdk/geolocation/go/remove_target.go b/sdk/geolocation/go/remove_target.go index d93d911e63..cad31bacd6 100644 --- a/sdk/geolocation/go/remove_target.go +++ b/sdk/geolocation/go/remove_target.go @@ -32,6 +32,12 @@ func (c *RemoveTargetInstructionConfig) Validate() error { if c.TargetType > GeoLocationTargetTypeOutboundIcmp { return fmt.Errorf("unknown target type: %d", c.TargetType) } + // Inbound targets are matched onchain by target_pk alone, so a zero target_pk + // would always fail with TargetNotFound. Match the add-target precondition + // and reject it up front. + if c.TargetType == GeoLocationTargetTypeInbound && c.TargetPK.IsZero() { + return fmt.Errorf("target public key is required for inbound target type") + } return nil } diff --git a/sdk/geolocation/go/remove_target_test.go b/sdk/geolocation/go/remove_target_test.go index db59def4d7..444fc160ba 100644 --- a/sdk/geolocation/go/remove_target_test.go +++ b/sdk/geolocation/go/remove_target_test.go @@ -107,3 +107,20 @@ func TestBuildRemoveTargetInstruction_ZeroProbePK(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "probe public key is required") } + +func TestBuildRemoveTargetInstruction_InboundZeroTargetPK(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + signerPK := solana.NewWallet().PublicKey() + + _, err := geolocation.BuildRemoveTargetInstruction(programID, signerPK, geolocation.RemoveTargetInstructionConfig{ + Code: "test-user", + ProbePK: solana.NewWallet().PublicKey(), + TargetType: geolocation.GeoLocationTargetTypeInbound, + TargetPK: solana.PublicKey{}, + ServiceabilityGlobalState: solana.NewWallet().PublicKey(), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "target public key is required for inbound target type") +} diff --git a/sdk/geolocation/go/validation.go b/sdk/geolocation/go/validation.go new file mode 100644 index 0000000000..a190ba68d3 --- /dev/null +++ b/sdk/geolocation/go/validation.go @@ -0,0 +1,94 @@ +package geolocation + +import "fmt" + +// validatePublicIP mirrors doublezero-geolocation/src/validation.rs::validate_public_ip. +// It rejects any IPv4 address that is not globally routable: RFC 1918 private, +// loopback, multicast, broadcast, link-local, shared address space (RFC 6598), +// documentation/test ranges, benchmarking, protocol assignments, and reserved. +func validatePublicIP(ip [4]uint8) error { + reject := func(reason string) error { + return fmt.Errorf("IP address %d.%d.%d.%d is not publicly routable: %s", ip[0], ip[1], ip[2], ip[3], reason) + } + + if ip == [4]uint8{0, 0, 0, 0} { + return reject("unspecified") + } + + // 0.0.0.0/8 "This network" (RFC 791) + if ip[0] == 0 { + return reject("0.0.0.0/8 (this network, RFC 791)") + } + + // 127.0.0.0/8 loopback + if ip[0] == 127 { + return reject("127.0.0.0/8 (loopback)") + } + + // Private: 10.0.0.0/8 + if ip[0] == 10 { + return reject("10.0.0.0/8 (RFC 1918 private)") + } + + // Private: 172.16.0.0/12 + if ip[0] == 172 && ip[1] >= 16 && ip[1] <= 31 { + return reject("172.16.0.0/12 (RFC 1918 private)") + } + + // Private: 192.168.0.0/16 + if ip[0] == 192 && ip[1] == 168 { + return reject("192.168.0.0/16 (RFC 1918 private)") + } + + // Shared Address Space: 100.64.0.0/10 (RFC 6598) + if ip[0] == 100 && ip[1] >= 64 && ip[1] <= 127 { + return reject("100.64.0.0/10 (shared address space, RFC 6598)") + } + + // Link-local: 169.254.0.0/16 + if ip[0] == 169 && ip[1] == 254 { + return reject("169.254.0.0/16 (link-local)") + } + + // Protocol Assignments: 192.0.0.0/24 (RFC 6890) + if ip[0] == 192 && ip[1] == 0 && ip[2] == 0 { + return reject("192.0.0.0/24 (protocol assignments, RFC 6890)") + } + + // Documentation: 192.0.2.0/24 TEST-NET-1 (RFC 5737) + if ip[0] == 192 && ip[1] == 0 && ip[2] == 2 { + return reject("192.0.2.0/24 (TEST-NET-1, RFC 5737)") + } + + // Benchmarking: 198.18.0.0/15 (RFC 2544) + if ip[0] == 198 && (ip[1] == 18 || ip[1] == 19) { + return reject("198.18.0.0/15 (benchmarking, RFC 2544)") + } + + // Documentation: 198.51.100.0/24 TEST-NET-2 (RFC 5737) + if ip[0] == 198 && ip[1] == 51 && ip[2] == 100 { + return reject("198.51.100.0/24 (TEST-NET-2, RFC 5737)") + } + + // Documentation: 203.0.113.0/24 TEST-NET-3 (RFC 5737) + if ip[0] == 203 && ip[1] == 0 && ip[2] == 113 { + return reject("203.0.113.0/24 (TEST-NET-3, RFC 5737)") + } + + // Multicast: 224.0.0.0/4 + if ip[0] >= 224 && ip[0] <= 239 { + return reject("224.0.0.0/4 (multicast)") + } + + // Broadcast: 255.255.255.255 + if ip == [4]uint8{255, 255, 255, 255} { + return reject("255.255.255.255 (broadcast)") + } + + // Reserved: 240.0.0.0/4 (future use) + if ip[0] >= 240 { + return reject("240.0.0.0/4 (reserved for future use)") + } + + return nil +} From 8b51d23af81a3a4194d0d1ac80164c05e2a32633 Mon Sep 17 00:00:00 2001 From: Ben Blier Date: Wed, 22 Apr 2026 14:54:03 -0400 Subject: [PATCH 3/3] sdk/geolocation: add Go write SDK for user lifecycle and result destination --- sdk/geolocation/go/create_user.go | 66 +++++ sdk/geolocation/go/create_user_test.go | 80 ++++++ sdk/geolocation/go/delete_user.go | 71 +++++ sdk/geolocation/go/delete_user_test.go | 90 +++++++ sdk/geolocation/go/instructions.go | 11 +- sdk/geolocation/go/instructions_test.go | 147 ++++++++++ sdk/geolocation/go/main_test.go | 5 + sdk/geolocation/go/rpc.go | 5 +- sdk/geolocation/go/set_result_destination.go | 133 +++++++++ .../go/set_result_destination_test.go | 254 ++++++++++++++++++ sdk/geolocation/go/update_user.go | 73 +++++ sdk/geolocation/go/update_user_test.go | 64 +++++ 12 files changed, 994 insertions(+), 5 deletions(-) create mode 100644 sdk/geolocation/go/create_user.go create mode 100644 sdk/geolocation/go/create_user_test.go create mode 100644 sdk/geolocation/go/delete_user.go create mode 100644 sdk/geolocation/go/delete_user_test.go create mode 100644 sdk/geolocation/go/set_result_destination.go create mode 100644 sdk/geolocation/go/set_result_destination_test.go create mode 100644 sdk/geolocation/go/update_user.go create mode 100644 sdk/geolocation/go/update_user_test.go diff --git a/sdk/geolocation/go/create_user.go b/sdk/geolocation/go/create_user.go new file mode 100644 index 0000000000..5226f2add0 --- /dev/null +++ b/sdk/geolocation/go/create_user.go @@ -0,0 +1,66 @@ +package geolocation + +import ( + "fmt" + + "github.com/gagliardetto/solana-go" + "github.com/near/borsh-go" +) + +type CreateGeolocationUserInstructionConfig struct { + Code string + TokenAccount solana.PublicKey +} + +func (c *CreateGeolocationUserInstructionConfig) Validate() error { + if c.Code == "" { + return fmt.Errorf("code is required") + } + if len(c.Code) > MaxCodeLength { + return fmt.Errorf("code length %d exceeds max %d", len(c.Code), MaxCodeLength) + } + return nil +} + +func BuildCreateGeolocationUserInstruction( + programID solana.PublicKey, + signerPK solana.PublicKey, + config CreateGeolocationUserInstructionConfig, +) (solana.Instruction, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("failed to validate config: %w", err) + } + + // Serialize the instruction data. + data, err := borsh.Serialize(struct { + Discriminator uint8 + Code string + TokenAccount [32]byte + }{ + Discriminator: uint8(CreateGeolocationUserInstructionIndex), + Code: config.Code, + TokenAccount: config.TokenAccount, + }) + if err != nil { + return nil, fmt.Errorf("failed to serialize args: %w", err) + } + + // Derive the user PDA. + userPDA, _, err := DeriveGeolocationUserPDA(programID, config.Code) + if err != nil { + return nil, fmt.Errorf("failed to derive user PDA: %w", err) + } + + // Build accounts. + accounts := []*solana.AccountMeta{ + {PublicKey: userPDA, IsSigner: false, IsWritable: true}, + {PublicKey: signerPK, IsSigner: true, IsWritable: true}, + {PublicKey: solana.SystemProgramID, IsSigner: false, IsWritable: false}, + } + + return &solana.GenericInstruction{ + ProgID: programID, + AccountValues: accounts, + DataBytes: data, + }, nil +} diff --git a/sdk/geolocation/go/create_user_test.go b/sdk/geolocation/go/create_user_test.go new file mode 100644 index 0000000000..b969f51b4a --- /dev/null +++ b/sdk/geolocation/go/create_user_test.go @@ -0,0 +1,80 @@ +package geolocation_test + +import ( + "strings" + "testing" + + "github.com/gagliardetto/solana-go" + geolocation "github.com/malbeclabs/doublezero/sdk/geolocation/go" + "github.com/stretchr/testify/require" +) + +func TestBuildCreateGeolocationUserInstruction_Valid(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + signerPK := solana.NewWallet().PublicKey() + tokenAccount := solana.NewWallet().PublicKey() + + ix, err := geolocation.BuildCreateGeolocationUserInstruction(programID, signerPK, geolocation.CreateGeolocationUserInstructionConfig{ + Code: "test-user", + TokenAccount: tokenAccount, + }) + require.NoError(t, err) + require.NotNil(t, ix) + + // Verify program ID. + require.Equal(t, programID, ix.ProgramID()) + + // Verify accounts: user_pda, signer, system_program. + accounts := ix.Accounts() + require.Len(t, accounts, 3, "expected 3 accounts: user_pda, signer, system_program") + + // Derive the expected user PDA. + expectedUserPDA, _, err := geolocation.DeriveGeolocationUserPDA(programID, "test-user") + require.NoError(t, err) + + // Account 0: user PDA (writable, not signer). + require.Equal(t, expectedUserPDA, accounts[0].PublicKey) + require.True(t, accounts[0].IsWritable, "user PDA should be writable") + require.False(t, accounts[0].IsSigner, "user PDA should not be signer") + + // Account 1: signer (writable, signer). + require.Equal(t, signerPK, accounts[1].PublicKey) + require.True(t, accounts[1].IsWritable, "signer should be writable") + require.True(t, accounts[1].IsSigner, "signer should be signer") + + // Account 2: system program (not writable, not signer). + require.Equal(t, solana.SystemProgramID, accounts[2].PublicKey) + require.False(t, accounts[2].IsWritable, "system program should not be writable") + require.False(t, accounts[2].IsSigner, "system program should not be signer") +} + +func TestBuildCreateGeolocationUserInstruction_EmptyCode(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + signerPK := solana.NewWallet().PublicKey() + + _, err := geolocation.BuildCreateGeolocationUserInstruction(programID, signerPK, geolocation.CreateGeolocationUserInstructionConfig{ + Code: "", + TokenAccount: solana.NewWallet().PublicKey(), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "code is required") +} + +func TestBuildCreateGeolocationUserInstruction_CodeTooLong(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + signerPK := solana.NewWallet().PublicKey() + longCode := strings.Repeat("a", geolocation.MaxCodeLength+1) + + _, err := geolocation.BuildCreateGeolocationUserInstruction(programID, signerPK, geolocation.CreateGeolocationUserInstructionConfig{ + Code: longCode, + TokenAccount: solana.NewWallet().PublicKey(), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "exceeds max") +} diff --git a/sdk/geolocation/go/delete_user.go b/sdk/geolocation/go/delete_user.go new file mode 100644 index 0000000000..8abd0be9cc --- /dev/null +++ b/sdk/geolocation/go/delete_user.go @@ -0,0 +1,71 @@ +package geolocation + +import ( + "fmt" + + "github.com/gagliardetto/solana-go" + "github.com/near/borsh-go" +) + +type DeleteGeolocationUserInstructionConfig struct { + Code string + ServiceabilityGlobalState solana.PublicKey +} + +func (c *DeleteGeolocationUserInstructionConfig) Validate() error { + if c.Code == "" { + return fmt.Errorf("code is required") + } + if len(c.Code) > MaxCodeLength { + return fmt.Errorf("code length %d exceeds max %d", len(c.Code), MaxCodeLength) + } + if c.ServiceabilityGlobalState.IsZero() { + return fmt.Errorf("serviceability global state public key is required") + } + return nil +} + +func BuildDeleteGeolocationUserInstruction( + programID solana.PublicKey, + signerPK solana.PublicKey, + config DeleteGeolocationUserInstructionConfig, +) (solana.Instruction, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("failed to validate config: %w", err) + } + + // Serialize the instruction data. + data, err := borsh.Serialize(struct { + Discriminator uint8 + }{ + Discriminator: uint8(DeleteGeolocationUserInstructionIndex), + }) + if err != nil { + return nil, fmt.Errorf("failed to serialize args: %w", err) + } + + // Derive PDAs. + userPDA, _, err := DeriveGeolocationUserPDA(programID, config.Code) + if err != nil { + return nil, fmt.Errorf("failed to derive user PDA: %w", err) + } + configPDA, _, err := DeriveProgramConfigPDA(programID) + if err != nil { + return nil, fmt.Errorf("failed to derive config PDA: %w", err) + } + + // Build accounts. + accounts := []*solana.AccountMeta{ + {PublicKey: userPDA, IsSigner: false, IsWritable: true}, + {PublicKey: configPDA, IsSigner: false, IsWritable: false}, + {PublicKey: config.ServiceabilityGlobalState, IsSigner: false, IsWritable: false}, + {PublicKey: signerPK, IsSigner: true, IsWritable: true}, + {PublicKey: solana.SystemProgramID, IsSigner: false, IsWritable: false}, + } + + return &solana.GenericInstruction{ + ProgID: programID, + AccountValues: accounts, + DataBytes: data, + }, nil +} diff --git a/sdk/geolocation/go/delete_user_test.go b/sdk/geolocation/go/delete_user_test.go new file mode 100644 index 0000000000..163ab36021 --- /dev/null +++ b/sdk/geolocation/go/delete_user_test.go @@ -0,0 +1,90 @@ +package geolocation_test + +import ( + "testing" + + "github.com/gagliardetto/solana-go" + geolocation "github.com/malbeclabs/doublezero/sdk/geolocation/go" + "github.com/stretchr/testify/require" +) + +func TestBuildDeleteGeolocationUserInstruction_Valid(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + signerPK := solana.NewWallet().PublicKey() + serviceabilityGS := solana.NewWallet().PublicKey() + + ix, err := geolocation.BuildDeleteGeolocationUserInstruction(programID, signerPK, geolocation.DeleteGeolocationUserInstructionConfig{ + Code: "test-user", + ServiceabilityGlobalState: serviceabilityGS, + }) + require.NoError(t, err) + require.NotNil(t, ix) + + // Verify program ID. + require.Equal(t, programID, ix.ProgramID()) + + // Verify accounts: user_pda, config_pda, serviceability_gs, signer, system_program. + accounts := ix.Accounts() + require.Len(t, accounts, 5, "expected 5 accounts: user_pda, config_pda, serviceability_gs, signer, system_program") + + // Derive expected PDAs. + expectedUserPDA, _, err := geolocation.DeriveGeolocationUserPDA(programID, "test-user") + require.NoError(t, err) + expectedConfigPDA, _, err := geolocation.DeriveProgramConfigPDA(programID) + require.NoError(t, err) + + // Account 0: user PDA (writable, not signer). + require.Equal(t, expectedUserPDA, accounts[0].PublicKey) + require.True(t, accounts[0].IsWritable) + require.False(t, accounts[0].IsSigner) + + // Account 1: config PDA (not writable, not signer). + require.Equal(t, expectedConfigPDA, accounts[1].PublicKey) + require.False(t, accounts[1].IsWritable) + require.False(t, accounts[1].IsSigner) + + // Account 2: serviceability global state (not writable, not signer). + require.Equal(t, serviceabilityGS, accounts[2].PublicKey) + require.False(t, accounts[2].IsWritable) + require.False(t, accounts[2].IsSigner) + + // Account 3: signer (writable, signer). + require.Equal(t, signerPK, accounts[3].PublicKey) + require.True(t, accounts[3].IsWritable) + require.True(t, accounts[3].IsSigner) + + // Account 4: system program (not writable, not signer). + require.Equal(t, solana.SystemProgramID, accounts[4].PublicKey) + require.False(t, accounts[4].IsWritable) + require.False(t, accounts[4].IsSigner) +} + +func TestBuildDeleteGeolocationUserInstruction_EmptyCode(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + signerPK := solana.NewWallet().PublicKey() + + _, err := geolocation.BuildDeleteGeolocationUserInstruction(programID, signerPK, geolocation.DeleteGeolocationUserInstructionConfig{ + Code: "", + ServiceabilityGlobalState: solana.NewWallet().PublicKey(), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "code is required") +} + +func TestBuildDeleteGeolocationUserInstruction_ZeroServiceability(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + signerPK := solana.NewWallet().PublicKey() + + _, err := geolocation.BuildDeleteGeolocationUserInstruction(programID, signerPK, geolocation.DeleteGeolocationUserInstructionConfig{ + Code: "test-user", + ServiceabilityGlobalState: solana.PublicKey{}, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "serviceability global state public key is required") +} diff --git a/sdk/geolocation/go/instructions.go b/sdk/geolocation/go/instructions.go index 919e86d461..f931e2010a 100644 --- a/sdk/geolocation/go/instructions.go +++ b/sdk/geolocation/go/instructions.go @@ -1,9 +1,12 @@ package geolocation // Instruction discriminator indices for the GeolocationInstruction Borsh enum. -// Only target management operations are included; other user-facing and Foundation -// commands are added by follow-on changes. +// Only user-facing operations are included; Foundation commands are excluded. const ( - AddTargetInstructionIndex = 10 - RemoveTargetInstructionIndex = 11 + CreateGeolocationUserInstructionIndex = 7 + UpdateGeolocationUserInstructionIndex = 8 + DeleteGeolocationUserInstructionIndex = 9 + AddTargetInstructionIndex = 10 + RemoveTargetInstructionIndex = 11 + SetResultDestinationInstructionIndex = 13 ) diff --git a/sdk/geolocation/go/instructions_test.go b/sdk/geolocation/go/instructions_test.go index c38b9b6df3..8387181a9f 100644 --- a/sdk/geolocation/go/instructions_test.go +++ b/sdk/geolocation/go/instructions_test.go @@ -2,6 +2,7 @@ package geolocation_test import ( "encoding/binary" + "strings" "testing" "github.com/gagliardetto/solana-go" @@ -9,6 +10,84 @@ import ( "github.com/stretchr/testify/require" ) +func TestSDK_Geolocation_Instructions_CreateGeolocationUser_Serialization(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + signerPK := solana.NewWallet().PublicKey() + tokenAccount := solana.NewWallet().PublicKey() + + ix, err := geolocation.BuildCreateGeolocationUserInstruction(programID, signerPK, geolocation.CreateGeolocationUserInstructionConfig{ + Code: "test-user", + TokenAccount: tokenAccount, + }) + require.NoError(t, err) + + data, err := ix.Data() + require.NoError(t, err) + + // First byte is discriminator (7). + require.Equal(t, uint8(7), data[0], "discriminator should be CreateGeolocationUser (7)") + + // Next comes the Borsh string: 4-byte LE length prefix + UTF-8 bytes. + codeLen := binary.LittleEndian.Uint32(data[1:5]) + require.Equal(t, uint32(len("test-user")), codeLen) + require.Equal(t, "test-user", string(data[5:5+codeLen])) + + // After the code string: 32-byte token account public key. + offset := 5 + codeLen + var tokenAccountBytes [32]byte + copy(tokenAccountBytes[:], data[offset:offset+32]) + require.Equal(t, [32]byte(tokenAccount), tokenAccountBytes) +} + +func TestSDK_Geolocation_Instructions_UpdateGeolocationUser_Serialization(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + signerPK := solana.NewWallet().PublicKey() + tokenAccount := solana.NewWallet().PublicKey() + + ix, err := geolocation.BuildUpdateGeolocationUserInstruction(programID, signerPK, geolocation.UpdateGeolocationUserInstructionConfig{ + Code: "test-user", + TokenAccount: &tokenAccount, + }) + require.NoError(t, err) + + data, err := ix.Data() + require.NoError(t, err) + + // First byte is discriminator (8). + require.Equal(t, uint8(8), data[0], "discriminator should be UpdateGeolocationUser (8)") + + // Next: Borsh Option<[32]byte> — 1 byte for Some(1) + 32-byte pubkey. + require.Equal(t, uint8(1), data[1], "Option should be Some (1)") + var tokenAccountBytes [32]byte + copy(tokenAccountBytes[:], data[2:34]) + require.Equal(t, [32]byte(tokenAccount), tokenAccountBytes) +} + +func TestSDK_Geolocation_Instructions_DeleteGeolocationUser_Serialization(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + signerPK := solana.NewWallet().PublicKey() + serviceabilityGS := solana.NewWallet().PublicKey() + + ix, err := geolocation.BuildDeleteGeolocationUserInstruction(programID, signerPK, geolocation.DeleteGeolocationUserInstructionConfig{ + Code: "test-user", + ServiceabilityGlobalState: serviceabilityGS, + }) + require.NoError(t, err) + + data, err := ix.Data() + require.NoError(t, err) + + // Only the discriminator byte (9). + require.Len(t, data, 1) + require.Equal(t, uint8(9), data[0], "discriminator should be DeleteGeolocationUser (9)") +} + func TestSDK_Geolocation_Instructions_AddTarget_Serialization(t *testing.T) { t.Parallel() @@ -86,16 +165,69 @@ func TestSDK_Geolocation_Instructions_RemoveTarget_Serialization(t *testing.T) { require.Equal(t, [32]byte(targetPK), gotTargetPK) } +func TestSDK_Geolocation_Instructions_SetResultDestination_Serialization(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + signerPK := solana.NewWallet().PublicKey() + + ix, err := geolocation.BuildSetResultDestinationInstruction(programID, signerPK, geolocation.SetResultDestinationInstructionConfig{ + Code: "test-user", + Destination: "https://example.com/results", + }) + require.NoError(t, err) + + data, err := ix.Data() + require.NoError(t, err) + + // Byte 0: discriminator (13). + require.Equal(t, uint8(13), data[0], "discriminator should be SetResultDestination (13)") + + // Next: Borsh string: 4-byte LE length prefix + UTF-8 bytes. + destLen := binary.LittleEndian.Uint32(data[1:5]) + require.Equal(t, uint32(len("https://example.com/results")), destLen) + require.Equal(t, "https://example.com/results", string(data[5:5+destLen])) +} + +func TestSDK_Geolocation_Instructions_SetResultDestination_Clear_Serialization(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + signerPK := solana.NewWallet().PublicKey() + + ix, err := geolocation.BuildSetResultDestinationInstruction(programID, signerPK, geolocation.SetResultDestinationInstructionConfig{ + Code: "test-user", + Destination: "", + }) + require.NoError(t, err) + + data, err := ix.Data() + require.NoError(t, err) + + // Byte 0: discriminator (13). + require.Equal(t, uint8(13), data[0]) + + // Empty string: 4-byte LE zero length. + destLen := binary.LittleEndian.Uint32(data[1:5]) + require.Equal(t, uint32(0), destLen) + require.Len(t, data, 5, "no string bytes after zero-length prefix") +} + func TestSDK_Geolocation_Instructions_AllDiscriminators(t *testing.T) { t.Parallel() + // Verify discriminator constants match expected values. tests := []struct { name string index int expected uint8 }{ + {"CreateGeolocationUser", geolocation.CreateGeolocationUserInstructionIndex, 7}, + {"UpdateGeolocationUser", geolocation.UpdateGeolocationUserInstructionIndex, 8}, + {"DeleteGeolocationUser", geolocation.DeleteGeolocationUserInstructionIndex, 9}, {"AddTarget", geolocation.AddTargetInstructionIndex, 10}, {"RemoveTarget", geolocation.RemoveTargetInstructionIndex, 11}, + {"SetResultDestination", geolocation.SetResultDestinationInstructionIndex, 13}, } for _, tt := range tests { @@ -105,3 +237,18 @@ func TestSDK_Geolocation_Instructions_AllDiscriminators(t *testing.T) { }) } } + +func TestSDK_Geolocation_Instructions_CreateUser_CodeTooLong_NoSerialization(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + signerPK := solana.NewWallet().PublicKey() + longCode := strings.Repeat("x", geolocation.MaxCodeLength+1) + + _, err := geolocation.BuildCreateGeolocationUserInstruction(programID, signerPK, geolocation.CreateGeolocationUserInstructionConfig{ + Code: longCode, + TokenAccount: solana.NewWallet().PublicKey(), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "exceeds max") +} diff --git a/sdk/geolocation/go/main_test.go b/sdk/geolocation/go/main_test.go index c717b4b9d8..6bcd1cb76a 100644 --- a/sdk/geolocation/go/main_test.go +++ b/sdk/geolocation/go/main_test.go @@ -56,12 +56,17 @@ func (m *mockRPCClient) GetProgramAccountsWithOpts(ctx context.Context, publicKe type mockExecutorRPCClient struct { geolocation.ExecutorRPCClient + GetAccountInfoFunc func(context.Context, solana.PublicKey) (*solanarpc.GetAccountInfoResult, error) GetLatestBlockhashFunc func(context.Context, solanarpc.CommitmentType) (*solanarpc.GetLatestBlockhashResult, error) SendTransactionWithOptsFunc func(context.Context, *solana.Transaction, solanarpc.TransactionOpts) (solana.Signature, error) GetSignatureStatusesFunc func(context.Context, bool, ...solana.Signature) (*solanarpc.GetSignatureStatusesResult, error) GetTransactionFunc func(context.Context, solana.Signature, *solanarpc.GetTransactionOpts) (*solanarpc.GetTransactionResult, error) } +func (m *mockExecutorRPCClient) GetAccountInfo(ctx context.Context, account solana.PublicKey) (*solanarpc.GetAccountInfoResult, error) { + return m.GetAccountInfoFunc(ctx, account) +} + func (m *mockExecutorRPCClient) GetLatestBlockhash(ctx context.Context, commitment solanarpc.CommitmentType) (*solanarpc.GetLatestBlockhashResult, error) { return m.GetLatestBlockhashFunc(ctx, commitment) } diff --git a/sdk/geolocation/go/rpc.go b/sdk/geolocation/go/rpc.go index c93bf046f1..9ad01b3953 100644 --- a/sdk/geolocation/go/rpc.go +++ b/sdk/geolocation/go/rpc.go @@ -13,8 +13,11 @@ type RPCClient interface { GetProgramAccountsWithOpts(ctx context.Context, publicKey solana.PublicKey, opts *solanarpc.GetProgramAccountsOpts) (out solanarpc.GetProgramAccountsResult, err error) } -// ExecutorRPCClient is an interface for write-path RPC operations used by the executor. +// ExecutorRPCClient is an interface for RPC operations used by the executor. It includes +// GetAccountInfo so higher-level helpers can fetch onchain state needed to build +// instructions (e.g., the set of probe accounts referenced by a user's targets). type ExecutorRPCClient interface { + GetAccountInfo(ctx context.Context, account solana.PublicKey) (out *solanarpc.GetAccountInfoResult, err error) GetLatestBlockhash(ctx context.Context, commitment solanarpc.CommitmentType) (out *solanarpc.GetLatestBlockhashResult, err error) SendTransactionWithOpts(ctx context.Context, transaction *solana.Transaction, opts solanarpc.TransactionOpts) (sig solana.Signature, err error) GetSignatureStatuses(ctx context.Context, searchTransactionHistory bool, transactionSignatures ...solana.Signature) (out *solanarpc.GetSignatureStatusesResult, err error) diff --git a/sdk/geolocation/go/set_result_destination.go b/sdk/geolocation/go/set_result_destination.go new file mode 100644 index 0000000000..6cc575f28a --- /dev/null +++ b/sdk/geolocation/go/set_result_destination.go @@ -0,0 +1,133 @@ +package geolocation + +import ( + "context" + "errors" + "fmt" + + "github.com/gagliardetto/solana-go" + solanarpc "github.com/gagliardetto/solana-go/rpc" + "github.com/near/borsh-go" +) + +type SetResultDestinationInstructionConfig struct { + Code string + Destination string + ProbePKs []solana.PublicKey +} + +func (c *SetResultDestinationInstructionConfig) Validate() error { + if c.Code == "" { + return fmt.Errorf("code is required") + } + if len(c.Code) > MaxCodeLength { + return fmt.Errorf("code length %d exceeds max %d", len(c.Code), MaxCodeLength) + } + return nil +} + +func BuildSetResultDestinationInstruction( + programID solana.PublicKey, + signerPK solana.PublicKey, + config SetResultDestinationInstructionConfig, +) (solana.Instruction, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("failed to validate config: %w", err) + } + + // Serialize the instruction data. + data, err := borsh.Serialize(struct { + Discriminator uint8 + Destination string + }{ + Discriminator: uint8(SetResultDestinationInstructionIndex), + Destination: config.Destination, + }) + if err != nil { + return nil, fmt.Errorf("failed to serialize args: %w", err) + } + + // Derive the user PDA. + userPDA, _, err := DeriveGeolocationUserPDA(programID, config.Code) + if err != nil { + return nil, fmt.Errorf("failed to derive user PDA: %w", err) + } + + // Build accounts: user_pda, then each probe PK, then signer, then system program. + accounts := make([]*solana.AccountMeta, 0, 3+len(config.ProbePKs)) + accounts = append(accounts, &solana.AccountMeta{PublicKey: userPDA, IsSigner: false, IsWritable: true}) + for _, probePK := range config.ProbePKs { + accounts = append(accounts, &solana.AccountMeta{PublicKey: probePK, IsSigner: false, IsWritable: true}) + } + accounts = append(accounts, &solana.AccountMeta{PublicKey: signerPK, IsSigner: true, IsWritable: true}) + accounts = append(accounts, &solana.AccountMeta{PublicKey: solana.SystemProgramID, IsSigner: false, IsWritable: false}) + + return &solana.GenericInstruction{ + ProgID: programID, + AccountValues: accounts, + DataBytes: data, + }, nil +} + +// DeriveUniqueProbePKs returns the unique geoprobe public keys referenced by the +// given targets, preserving first-occurrence order. The onchain program requires +// the SetResultDestination instruction to carry exactly this set of probe accounts. +func DeriveUniqueProbePKs(targets []GeolocationTarget) []solana.PublicKey { + seen := make(map[solana.PublicKey]struct{}, len(targets)) + probes := make([]solana.PublicKey, 0, len(targets)) + for _, t := range targets { + if _, ok := seen[t.GeoProbePK]; ok { + continue + } + seen[t.GeoProbePK] = struct{}{} + probes = append(probes, t.GeoProbePK) + } + return probes +} + +// SetResultDestination fetches the GeolocationUser account, derives the set of unique +// probe accounts from its targets, and submits a SetResultDestination instruction. +// Pass an empty destination to clear. +func (e *executor) SetResultDestination(ctx context.Context, code, destination string, opts *ExecuteTransactionOptions) (solana.Signature, *solanarpc.GetTransactionResult, error) { + if e.signer == nil { + return solana.Signature{}, nil, ErrNoPrivateKey + } + if e.programID.IsZero() { + return solana.Signature{}, nil, ErrNoProgramID + } + + userPDA, _, err := DeriveGeolocationUserPDA(e.programID, code) + if err != nil { + return solana.Signature{}, nil, fmt.Errorf("failed to derive user PDA: %w", err) + } + + account, err := e.rpc.GetAccountInfo(ctx, userPDA) + if err != nil { + if errors.Is(err, solanarpc.ErrNotFound) { + return solana.Signature{}, nil, ErrAccountNotFound + } + return solana.Signature{}, nil, fmt.Errorf("failed to get user account: %w", err) + } + if account == nil || account.Value == nil { + return solana.Signature{}, nil, ErrAccountNotFound + } + if account.Value.Owner != e.programID { + return solana.Signature{}, nil, fmt.Errorf("%w: got %s, want %s", ErrOwnerMismatch, account.Value.Owner, e.programID) + } + + user, err := DeserializeGeolocationUser(account.Value.Data.GetBinary()) + if err != nil { + return solana.Signature{}, nil, fmt.Errorf("failed to deserialize user: %w", err) + } + + ix, err := BuildSetResultDestinationInstruction(e.programID, e.signer.PublicKey(), SetResultDestinationInstructionConfig{ + Code: code, + Destination: destination, + ProbePKs: DeriveUniqueProbePKs(user.Targets), + }) + if err != nil { + return solana.Signature{}, nil, err + } + + return e.ExecuteTransaction(ctx, ix, opts) +} diff --git a/sdk/geolocation/go/set_result_destination_test.go b/sdk/geolocation/go/set_result_destination_test.go new file mode 100644 index 0000000000..f4e78b6ae6 --- /dev/null +++ b/sdk/geolocation/go/set_result_destination_test.go @@ -0,0 +1,254 @@ +package geolocation_test + +import ( + "bytes" + "context" + "log/slog" + "testing" + + "github.com/gagliardetto/solana-go" + solanarpc "github.com/gagliardetto/solana-go/rpc" + geolocation "github.com/malbeclabs/doublezero/sdk/geolocation/go" + "github.com/stretchr/testify/require" +) + +func TestBuildSetResultDestinationInstruction_Set(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + signerPK := solana.NewWallet().PublicKey() + probePK1 := solana.NewWallet().PublicKey() + probePK2 := solana.NewWallet().PublicKey() + + ix, err := geolocation.BuildSetResultDestinationInstruction(programID, signerPK, geolocation.SetResultDestinationInstructionConfig{ + Code: "test-user", + Destination: "https://example.com/results", + ProbePKs: []solana.PublicKey{probePK1, probePK2}, + }) + require.NoError(t, err) + require.NotNil(t, ix) + + // Verify program ID. + require.Equal(t, programID, ix.ProgramID()) + + // Verify accounts: user_pda, probe1, probe2, signer, system_program. + accounts := ix.Accounts() + require.Len(t, accounts, 5, "expected 5 accounts: user_pda + 2 probes + signer + system_program") + + // Derive expected user PDA. + expectedUserPDA, _, err := geolocation.DeriveGeolocationUserPDA(programID, "test-user") + require.NoError(t, err) + + // Account 0: user PDA (writable, not signer). + require.Equal(t, expectedUserPDA, accounts[0].PublicKey) + require.True(t, accounts[0].IsWritable) + require.False(t, accounts[0].IsSigner) + + // Account 1: probe PK 1 (writable, not signer). + require.Equal(t, probePK1, accounts[1].PublicKey) + require.True(t, accounts[1].IsWritable) + require.False(t, accounts[1].IsSigner) + + // Account 2: probe PK 2 (writable, not signer). + require.Equal(t, probePK2, accounts[2].PublicKey) + require.True(t, accounts[2].IsWritable) + require.False(t, accounts[2].IsSigner) + + // Account 3: signer (writable, signer). + require.Equal(t, signerPK, accounts[3].PublicKey) + require.True(t, accounts[3].IsWritable) + require.True(t, accounts[3].IsSigner) + + // Account 4: system program (not writable, not signer). + require.Equal(t, solana.SystemProgramID, accounts[4].PublicKey) + require.False(t, accounts[4].IsWritable) + require.False(t, accounts[4].IsSigner) +} + +func TestBuildSetResultDestinationInstruction_Clear(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + signerPK := solana.NewWallet().PublicKey() + + ix, err := geolocation.BuildSetResultDestinationInstruction(programID, signerPK, geolocation.SetResultDestinationInstructionConfig{ + Code: "test-user", + Destination: "", + ProbePKs: nil, + }) + require.NoError(t, err) + require.NotNil(t, ix) + + // Verify accounts: user_pda, signer, system_program (no probes). + accounts := ix.Accounts() + require.Len(t, accounts, 3, "expected 3 accounts: user_pda + signer + system_program") + + // Derive expected user PDA. + expectedUserPDA, _, err := geolocation.DeriveGeolocationUserPDA(programID, "test-user") + require.NoError(t, err) + + // Account 0: user PDA. + require.Equal(t, expectedUserPDA, accounts[0].PublicKey) + require.True(t, accounts[0].IsWritable) + require.False(t, accounts[0].IsSigner) + + // Account 1: signer. + require.Equal(t, signerPK, accounts[1].PublicKey) + require.True(t, accounts[1].IsWritable) + require.True(t, accounts[1].IsSigner) + + // Account 2: system program. + require.Equal(t, solana.SystemProgramID, accounts[2].PublicKey) + require.False(t, accounts[2].IsWritable) + require.False(t, accounts[2].IsSigner) +} + +func TestBuildSetResultDestinationInstruction_EmptyCode(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + signerPK := solana.NewWallet().PublicKey() + + _, err := geolocation.BuildSetResultDestinationInstruction(programID, signerPK, geolocation.SetResultDestinationInstructionConfig{ + Code: "", + Destination: "https://example.com", + }) + require.Error(t, err) + require.Contains(t, err.Error(), "code is required") +} + +func TestDeriveUniqueProbePKs_Empty(t *testing.T) { + t.Parallel() + + pks := geolocation.DeriveUniqueProbePKs(nil) + require.Empty(t, pks) + + pks = geolocation.DeriveUniqueProbePKs([]geolocation.GeolocationTarget{}) + require.Empty(t, pks) +} + +func TestDeriveUniqueProbePKs_Unique(t *testing.T) { + t.Parallel() + + p1 := solana.NewWallet().PublicKey() + p2 := solana.NewWallet().PublicKey() + p3 := solana.NewWallet().PublicKey() + + targets := []geolocation.GeolocationTarget{ + {GeoProbePK: p1}, + {GeoProbePK: p2}, + {GeoProbePK: p3}, + } + require.Equal(t, []solana.PublicKey{p1, p2, p3}, geolocation.DeriveUniqueProbePKs(targets)) +} + +func TestDeriveUniqueProbePKs_DedupesPreservingFirstOccurrence(t *testing.T) { + t.Parallel() + + p1 := solana.NewWallet().PublicKey() + p2 := solana.NewWallet().PublicKey() + + targets := []geolocation.GeolocationTarget{ + {GeoProbePK: p1}, + {GeoProbePK: p2}, + {GeoProbePK: p1}, // duplicate + {GeoProbePK: p2}, // duplicate + } + require.Equal(t, []solana.PublicKey{p1, p2}, geolocation.DeriveUniqueProbePKs(targets)) +} + +// validUserAccountBytes returns a serialized GeolocationUser suitable for +// GetAccountInfo mocking. +func validUserAccountBytes(t *testing.T, owner solana.PublicKey, code string, targets []geolocation.GeolocationTarget) []byte { + t.Helper() + user := geolocation.GeolocationUser{ + AccountType: geolocation.AccountTypeGeolocationUser, + Owner: owner, + Code: code, + TokenAccount: solana.NewWallet().PublicKey(), + PaymentStatus: geolocation.GeolocationPaymentStatusPaid, + Billing: geolocation.GeolocationBillingConfig{ + Variant: geolocation.BillingConfigFlatPerEpoch, + FlatPerEpoch: geolocation.FlatPerEpochConfig{Rate: 1, LastDeductionDzEpoch: 1}, + }, + Status: geolocation.GeolocationUserStatusActivated, + Targets: targets, + ResultDestination: "", + } + var buf bytes.Buffer + require.NoError(t, user.Serialize(&buf)) + return buf.Bytes() +} + +func TestExecutorSetResultDestination_NoPrivateKey(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + executor := geolocation.NewExecutor(slog.Default(), nil, nil, programID) + + _, _, err := executor.SetResultDestination(context.Background(), "test-user", "8.8.8.8:9000", nil) + require.ErrorIs(t, err, geolocation.ErrNoPrivateKey) +} + +func TestExecutorSetResultDestination_NoProgramID(t *testing.T) { + t.Parallel() + + signer := solana.NewWallet().PrivateKey + executor := geolocation.NewExecutor(slog.Default(), nil, &signer, solana.PublicKey{}) + + _, _, err := executor.SetResultDestination(context.Background(), "test-user", "8.8.8.8:9000", nil) + require.ErrorIs(t, err, geolocation.ErrNoProgramID) +} + +func TestExecutorSetResultDestination_EmptyCode(t *testing.T) { + t.Parallel() + + signer := solana.NewWallet().PrivateKey + programID := solana.NewWallet().PublicKey() + executor := geolocation.NewExecutor(slog.Default(), nil, &signer, programID) + + _, _, err := executor.SetResultDestination(context.Background(), "", "8.8.8.8:9000", nil) + require.Error(t, err) + require.Contains(t, err.Error(), "code is required") +} + +func TestExecutorSetResultDestination_AccountNotFound(t *testing.T) { + t.Parallel() + + signer := solana.NewWallet().PrivateKey + programID := solana.NewWallet().PublicKey() + + rpc := &mockExecutorRPCClient{ + GetAccountInfoFunc: func(_ context.Context, _ solana.PublicKey) (*solanarpc.GetAccountInfoResult, error) { + return &solanarpc.GetAccountInfoResult{Value: nil}, nil + }, + } + executor := geolocation.NewExecutor(slog.Default(), rpc, &signer, programID) + + _, _, err := executor.SetResultDestination(context.Background(), "test-user", "8.8.8.8:9000", nil) + require.ErrorIs(t, err, geolocation.ErrAccountNotFound) +} + +func TestExecutorSetResultDestination_OwnerMismatch(t *testing.T) { + t.Parallel() + + signer := solana.NewWallet().PrivateKey + programID := solana.NewWallet().PublicKey() + wrongOwner := solana.NewWallet().PublicKey() + + rpc := &mockExecutorRPCClient{ + GetAccountInfoFunc: func(_ context.Context, _ solana.PublicKey) (*solanarpc.GetAccountInfoResult, error) { + data := validUserAccountBytes(t, signer.PublicKey(), "test-user", nil) + return &solanarpc.GetAccountInfoResult{ + Value: &solanarpc.Account{ + Owner: wrongOwner, + Data: solanarpc.DataBytesOrJSONFromBytes(data), + }, + }, nil + }, + } + executor := geolocation.NewExecutor(slog.Default(), rpc, &signer, programID) + + _, _, err := executor.SetResultDestination(context.Background(), "test-user", "8.8.8.8:9000", nil) + require.ErrorIs(t, err, geolocation.ErrOwnerMismatch) +} diff --git a/sdk/geolocation/go/update_user.go b/sdk/geolocation/go/update_user.go new file mode 100644 index 0000000000..500e55aa77 --- /dev/null +++ b/sdk/geolocation/go/update_user.go @@ -0,0 +1,73 @@ +package geolocation + +import ( + "fmt" + + "github.com/gagliardetto/solana-go" + "github.com/near/borsh-go" +) + +type UpdateGeolocationUserInstructionConfig struct { + Code string + TokenAccount *solana.PublicKey // optional +} + +func (c *UpdateGeolocationUserInstructionConfig) Validate() error { + if c.Code == "" { + return fmt.Errorf("code is required") + } + if len(c.Code) > MaxCodeLength { + return fmt.Errorf("code length %d exceeds max %d", len(c.Code), MaxCodeLength) + } + if c.TokenAccount == nil { + return fmt.Errorf("at least one field must be provided: TokenAccount is nil") + } + return nil +} + +func BuildUpdateGeolocationUserInstruction( + programID solana.PublicKey, + signerPK solana.PublicKey, + config UpdateGeolocationUserInstructionConfig, +) (solana.Instruction, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("failed to validate config: %w", err) + } + + // Serialize the instruction data with Option encoding. + var tokenAccountOpt *[32]byte + if config.TokenAccount != nil { + pk := [32]byte(*config.TokenAccount) + tokenAccountOpt = &pk + } + + data, err := borsh.Serialize(struct { + Discriminator uint8 + TokenAccount *[32]byte + }{ + Discriminator: uint8(UpdateGeolocationUserInstructionIndex), + TokenAccount: tokenAccountOpt, + }) + if err != nil { + return nil, fmt.Errorf("failed to serialize args: %w", err) + } + + // Derive the user PDA. + userPDA, _, err := DeriveGeolocationUserPDA(programID, config.Code) + if err != nil { + return nil, fmt.Errorf("failed to derive user PDA: %w", err) + } + + // Build accounts. + accounts := []*solana.AccountMeta{ + {PublicKey: userPDA, IsSigner: false, IsWritable: true}, + {PublicKey: signerPK, IsSigner: true, IsWritable: true}, + {PublicKey: solana.SystemProgramID, IsSigner: false, IsWritable: false}, + } + + return &solana.GenericInstruction{ + ProgID: programID, + AccountValues: accounts, + DataBytes: data, + }, nil +} diff --git a/sdk/geolocation/go/update_user_test.go b/sdk/geolocation/go/update_user_test.go new file mode 100644 index 0000000000..6f5c1c33c6 --- /dev/null +++ b/sdk/geolocation/go/update_user_test.go @@ -0,0 +1,64 @@ +package geolocation_test + +import ( + "testing" + + "github.com/gagliardetto/solana-go" + geolocation "github.com/malbeclabs/doublezero/sdk/geolocation/go" + "github.com/stretchr/testify/require" +) + +func TestBuildUpdateGeolocationUserInstruction_Valid(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + signerPK := solana.NewWallet().PublicKey() + tokenAccount := solana.NewWallet().PublicKey() + + ix, err := geolocation.BuildUpdateGeolocationUserInstruction(programID, signerPK, geolocation.UpdateGeolocationUserInstructionConfig{ + Code: "test-user", + TokenAccount: &tokenAccount, + }) + require.NoError(t, err) + require.NotNil(t, ix) + + // Verify program ID. + require.Equal(t, programID, ix.ProgramID()) + + // Verify accounts: user_pda, signer, system_program. + accounts := ix.Accounts() + require.Len(t, accounts, 3, "expected 3 accounts: user_pda, signer, system_program") + + // Derive the expected user PDA. + expectedUserPDA, _, err := geolocation.DeriveGeolocationUserPDA(programID, "test-user") + require.NoError(t, err) + + // Account 0: user PDA (writable, not signer). + require.Equal(t, expectedUserPDA, accounts[0].PublicKey) + require.True(t, accounts[0].IsWritable) + require.False(t, accounts[0].IsSigner) + + // Account 1: signer (writable, signer). + require.Equal(t, signerPK, accounts[1].PublicKey) + require.True(t, accounts[1].IsWritable) + require.True(t, accounts[1].IsSigner) + + // Account 2: system program (not writable, not signer). + require.Equal(t, solana.SystemProgramID, accounts[2].PublicKey) + require.False(t, accounts[2].IsWritable) + require.False(t, accounts[2].IsSigner) +} + +func TestBuildUpdateGeolocationUserInstruction_NilTokenAccount(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + signerPK := solana.NewWallet().PublicKey() + + _, err := geolocation.BuildUpdateGeolocationUserInstruction(programID, signerPK, geolocation.UpdateGeolocationUserInstructionConfig{ + Code: "test-user", + TokenAccount: nil, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "TokenAccount is nil") +}