diff --git a/CHANGELOG.md b/CHANGELOG.md index b242c47102..f7e080df8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. - SDK - Add read-only Go SDK for `doublezero-geolocation` program with state deserialization, PDA derivation, and RPC client for querying geoprobe configuration + - Add write-side geolocation Go SDK: instruction builders for all 7 program instructions, transaction executor with finalization polling, and client write methods - Client - Increase default route liveness probe interval (TxMin/RxMin) from 300ms to 1s and raise MaxTxCeil from 1s to 3s to preserve backoff headroom diff --git a/sdk/geolocation/go/add_parent_device.go b/sdk/geolocation/go/add_parent_device.go new file mode 100644 index 0000000000..532ddd0b84 --- /dev/null +++ b/sdk/geolocation/go/add_parent_device.go @@ -0,0 +1,69 @@ +package geolocation + +import ( + "fmt" + + "github.com/gagliardetto/solana-go" + "github.com/near/borsh-go" +) + +type AddParentDeviceInstructionConfig struct { + Payer solana.PublicKey + ProbePK solana.PublicKey + DevicePK solana.PublicKey + ServiceabilityGlobalStatePK solana.PublicKey +} + +func (c *AddParentDeviceInstructionConfig) Validate() error { + if c.Payer.IsZero() { + return fmt.Errorf("payer public key is required") + } + if c.ProbePK.IsZero() { + return fmt.Errorf("probe public key is required") + } + if c.DevicePK.IsZero() { + return fmt.Errorf("device public key is required") + } + if c.ServiceabilityGlobalStatePK.IsZero() { + return fmt.Errorf("serviceability global state public key is required") + } + return nil +} + +func BuildAddParentDeviceInstruction( + programID solana.PublicKey, + config AddParentDeviceInstructionConfig, +) (solana.Instruction, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("failed to validate config: %w", err) + } + + data, err := borsh.Serialize(struct { + Discriminator uint8 + }{ + Discriminator: uint8(AddParentDeviceInstructionIndex), + }) + if err != nil { + return nil, fmt.Errorf("failed to serialize args: %w", err) + } + + programConfigPDA, _, err := DeriveProgramConfigPDA(programID) + if err != nil { + return nil, fmt.Errorf("failed to derive program config PDA: %w", err) + } + + accounts := []*solana.AccountMeta{ + {PublicKey: config.ProbePK, IsSigner: false, IsWritable: true}, + {PublicKey: config.DevicePK, IsSigner: false, IsWritable: false}, + {PublicKey: programConfigPDA, IsSigner: false, IsWritable: false}, + {PublicKey: config.ServiceabilityGlobalStatePK, IsSigner: false, IsWritable: false}, + {PublicKey: config.Payer, 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/client.go b/sdk/geolocation/go/client.go index 4ff5b62cc8..0e4afec237 100644 --- a/sdk/geolocation/go/client.go +++ b/sdk/geolocation/go/client.go @@ -15,26 +15,36 @@ var ( ) type Client struct { - log *slog.Logger - rpc RPCClient - programID solana.PublicKey + log *slog.Logger + rpc RPCClient + executor *executor } -func New(log *slog.Logger, rpc RPCClient, programID solana.PublicKey) *Client { +func New(log *slog.Logger, rpc RPCClient, signer *solana.PrivateKey, programID solana.PublicKey) *Client { return &Client{ - log: log, - rpc: rpc, - programID: programID, + log: log, + rpc: rpc, + executor: NewExecutor(log, rpc, signer, programID), } } func (c *Client) ProgramID() solana.PublicKey { - return c.programID + if c.executor == nil { + return solana.PublicKey{} + } + return c.executor.programID +} + +func (c *Client) Signer() *solana.PrivateKey { + if c.executor == nil { + return nil + } + return c.executor.signer } // GetProgramConfig fetches the GeolocationProgramConfig account. func (c *Client) GetProgramConfig(ctx context.Context) (*GeolocationProgramConfig, error) { - pda, _, err := DeriveProgramConfigPDA(c.programID) + pda, _, err := DeriveProgramConfigPDA(c.executor.programID) if err != nil { return nil, fmt.Errorf("failed to derive PDA: %w", err) } @@ -59,7 +69,7 @@ func (c *Client) GetProgramConfig(ctx context.Context) (*GeolocationProgramConfi // GetGeoProbeByCode fetches a GeoProbe account by its code. func (c *Client) GetGeoProbeByCode(ctx context.Context, code string) (*GeoProbe, error) { - pda, _, err := DeriveGeoProbePDA(c.programID, code) + pda, _, err := DeriveGeoProbePDA(c.executor.programID, code) if err != nil { return nil, fmt.Errorf("failed to derive PDA: %w", err) } @@ -95,7 +105,7 @@ func (c *Client) GetGeoProbes(ctx context.Context) ([]GeoProbe, error) { }, } - accounts, err := c.rpc.GetProgramAccountsWithOpts(ctx, c.programID, opts) + accounts, err := c.rpc.GetProgramAccountsWithOpts(ctx, c.executor.programID, opts) if err != nil { return nil, fmt.Errorf("failed to get program accounts: %w", err) } @@ -111,3 +121,126 @@ func (c *Client) GetGeoProbes(ctx context.Context) ([]GeoProbe, error) { } return probes, nil } + +// InitProgramConfig initializes the geolocation program config. +func (c *Client) InitProgramConfig( + ctx context.Context, + config InitProgramConfigInstructionConfig, +) (solana.Signature, *solanarpc.GetTransactionResult, error) { + instruction, err := BuildInitProgramConfigInstruction(c.executor.programID, config) + if err != nil { + return solana.Signature{}, nil, fmt.Errorf("failed to build instruction: %w", err) + } + + sig, res, err := c.executor.ExecuteTransaction(ctx, instruction, &ExecuteTransactionOptions{ + SkipPreflight: true, + }) + if err != nil { + return solana.Signature{}, nil, fmt.Errorf("failed to execute instruction: %w", err) + } + return sig, res, nil +} + +// UpdateProgramConfig updates the geolocation program config. +func (c *Client) UpdateProgramConfig( + ctx context.Context, + config UpdateProgramConfigInstructionConfig, +) (solana.Signature, *solanarpc.GetTransactionResult, error) { + instruction, err := BuildUpdateProgramConfigInstruction(c.executor.programID, config) + if err != nil { + return solana.Signature{}, nil, fmt.Errorf("failed to build instruction: %w", err) + } + + sig, res, err := c.executor.ExecuteTransaction(ctx, instruction, nil) + if err != nil { + return solana.Signature{}, nil, fmt.Errorf("failed to execute instruction: %w", err) + } + return sig, res, nil +} + +// CreateGeoProbe creates a new GeoProbe account. +func (c *Client) CreateGeoProbe( + ctx context.Context, + config CreateGeoProbeInstructionConfig, +) (solana.Signature, *solanarpc.GetTransactionResult, error) { + instruction, err := BuildCreateGeoProbeInstruction(c.executor.programID, config) + if err != nil { + return solana.Signature{}, nil, fmt.Errorf("failed to build instruction: %w", err) + } + + sig, res, err := c.executor.ExecuteTransaction(ctx, instruction, &ExecuteTransactionOptions{ + SkipPreflight: true, + }) + if err != nil { + return solana.Signature{}, nil, fmt.Errorf("failed to execute instruction: %w", err) + } + return sig, res, nil +} + +// UpdateGeoProbe updates an existing GeoProbe account. +func (c *Client) UpdateGeoProbe( + ctx context.Context, + config UpdateGeoProbeInstructionConfig, +) (solana.Signature, *solanarpc.GetTransactionResult, error) { + instruction, err := BuildUpdateGeoProbeInstruction(c.executor.programID, config) + if err != nil { + return solana.Signature{}, nil, fmt.Errorf("failed to build instruction: %w", err) + } + + sig, res, err := c.executor.ExecuteTransaction(ctx, instruction, nil) + if err != nil { + return solana.Signature{}, nil, fmt.Errorf("failed to execute instruction: %w", err) + } + return sig, res, nil +} + +// DeleteGeoProbe deletes a GeoProbe account. +func (c *Client) DeleteGeoProbe( + ctx context.Context, + config DeleteGeoProbeInstructionConfig, +) (solana.Signature, *solanarpc.GetTransactionResult, error) { + instruction, err := BuildDeleteGeoProbeInstruction(c.executor.programID, config) + if err != nil { + return solana.Signature{}, nil, fmt.Errorf("failed to build instruction: %w", err) + } + + sig, res, err := c.executor.ExecuteTransaction(ctx, instruction, nil) + if err != nil { + return solana.Signature{}, nil, fmt.Errorf("failed to execute instruction: %w", err) + } + return sig, res, nil +} + +// AddParentDevice adds a parent device to a GeoProbe. +func (c *Client) AddParentDevice( + ctx context.Context, + config AddParentDeviceInstructionConfig, +) (solana.Signature, *solanarpc.GetTransactionResult, error) { + instruction, err := BuildAddParentDeviceInstruction(c.executor.programID, config) + if err != nil { + return solana.Signature{}, nil, fmt.Errorf("failed to build instruction: %w", err) + } + + sig, res, err := c.executor.ExecuteTransaction(ctx, instruction, nil) + if err != nil { + return solana.Signature{}, nil, fmt.Errorf("failed to execute instruction: %w", err) + } + return sig, res, nil +} + +// RemoveParentDevice removes a parent device from a GeoProbe. +func (c *Client) RemoveParentDevice( + ctx context.Context, + config RemoveParentDeviceInstructionConfig, +) (solana.Signature, *solanarpc.GetTransactionResult, error) { + instruction, err := BuildRemoveParentDeviceInstruction(c.executor.programID, config) + if err != nil { + return solana.Signature{}, nil, fmt.Errorf("failed to build instruction: %w", err) + } + + sig, res, err := c.executor.ExecuteTransaction(ctx, instruction, nil) + if err != nil { + return solana.Signature{}, nil, fmt.Errorf("failed to execute instruction: %w", err) + } + return sig, res, nil +} diff --git a/sdk/geolocation/go/client_test.go b/sdk/geolocation/go/client_test.go index 1d0cafdb34..24f1eeabb9 100644 --- a/sdk/geolocation/go/client_test.go +++ b/sdk/geolocation/go/client_test.go @@ -15,6 +15,7 @@ import ( func TestSDK_Geolocation_Client_GetProgramConfig_HappyPath(t *testing.T) { t.Parallel() + signer := solana.NewWallet().PrivateKey programID := solana.NewWallet().PublicKey() expected := &geolocation.GeolocationProgramConfig{ @@ -38,7 +39,7 @@ func TestSDK_Geolocation_Client_GetProgramConfig_HappyPath(t *testing.T) { }, } - client := geolocation.New(slog.Default(), mockRPC, programID) + client := geolocation.New(slog.Default(), mockRPC, &signer, programID) got, err := client.GetProgramConfig(context.Background()) require.NoError(t, err) require.Equal(t, expected.AccountType, got.AccountType) @@ -48,6 +49,7 @@ func TestSDK_Geolocation_Client_GetProgramConfig_HappyPath(t *testing.T) { func TestSDK_Geolocation_Client_GetProgramConfig_NotFound(t *testing.T) { t.Parallel() + signer := solana.NewWallet().PrivateKey programID := solana.NewWallet().PublicKey() mockRPC := &mockRPCClient{ @@ -56,7 +58,7 @@ func TestSDK_Geolocation_Client_GetProgramConfig_NotFound(t *testing.T) { }, } - client := geolocation.New(slog.Default(), mockRPC, programID) + client := geolocation.New(slog.Default(), mockRPC, &signer, programID) _, err := client.GetProgramConfig(context.Background()) require.ErrorIs(t, err, geolocation.ErrAccountNotFound) } @@ -64,6 +66,7 @@ func TestSDK_Geolocation_Client_GetProgramConfig_NotFound(t *testing.T) { func TestSDK_Geolocation_Client_GetGeoProbeByCode_HappyPath(t *testing.T) { t.Parallel() + signer := solana.NewWallet().PrivateKey programID := solana.NewWallet().PublicKey() expected := &geolocation.GeoProbe{ @@ -92,7 +95,7 @@ func TestSDK_Geolocation_Client_GetGeoProbeByCode_HappyPath(t *testing.T) { }, } - client := geolocation.New(slog.Default(), mockRPC, programID) + client := geolocation.New(slog.Default(), mockRPC, &signer, programID) got, err := client.GetGeoProbeByCode(context.Background(), "ams-probe-01") require.NoError(t, err) require.Equal(t, expected.Code, got.Code) @@ -102,6 +105,7 @@ func TestSDK_Geolocation_Client_GetGeoProbeByCode_HappyPath(t *testing.T) { func TestSDK_Geolocation_Client_GetGeoProbeByCode_NotFound(t *testing.T) { t.Parallel() + signer := solana.NewWallet().PrivateKey programID := solana.NewWallet().PublicKey() mockRPC := &mockRPCClient{ @@ -110,7 +114,7 @@ func TestSDK_Geolocation_Client_GetGeoProbeByCode_NotFound(t *testing.T) { }, } - client := geolocation.New(slog.Default(), mockRPC, programID) + client := geolocation.New(slog.Default(), mockRPC, &signer, programID) _, err := client.GetGeoProbeByCode(context.Background(), "nonexistent") require.ErrorIs(t, err, geolocation.ErrAccountNotFound) } @@ -118,6 +122,7 @@ func TestSDK_Geolocation_Client_GetGeoProbeByCode_NotFound(t *testing.T) { func TestSDK_Geolocation_Client_GetGeoProbes_HappyPath(t *testing.T) { t.Parallel() + signer := solana.NewWallet().PrivateKey programID := solana.NewWallet().PublicKey() probe1 := &geolocation.GeoProbe{ @@ -169,7 +174,7 @@ func TestSDK_Geolocation_Client_GetGeoProbes_HappyPath(t *testing.T) { }, } - client := geolocation.New(slog.Default(), mockRPC, programID) + client := geolocation.New(slog.Default(), mockRPC, &signer, programID) probes, err := client.GetGeoProbes(context.Background()) require.NoError(t, err) require.Len(t, probes, 2) @@ -180,6 +185,7 @@ func TestSDK_Geolocation_Client_GetGeoProbes_HappyPath(t *testing.T) { func TestSDK_Geolocation_Client_GetGeoProbes_Empty(t *testing.T) { t.Parallel() + signer := solana.NewWallet().PrivateKey programID := solana.NewWallet().PublicKey() mockRPC := &mockRPCClient{ @@ -188,7 +194,7 @@ func TestSDK_Geolocation_Client_GetGeoProbes_Empty(t *testing.T) { }, } - client := geolocation.New(slog.Default(), mockRPC, programID) + client := geolocation.New(slog.Default(), mockRPC, &signer, programID) probes, err := client.GetGeoProbes(context.Background()) require.NoError(t, err) require.Empty(t, probes) diff --git a/sdk/geolocation/go/constants.go b/sdk/geolocation/go/constants.go index ee9e432a06..37e92f51ac 100644 --- a/sdk/geolocation/go/constants.go +++ b/sdk/geolocation/go/constants.go @@ -1,5 +1,18 @@ package geolocation +// GeolocationInstructionType represents the type of geolocation instruction +type GeolocationInstructionType uint8 + +const ( + InitProgramConfigInstructionIndex GeolocationInstructionType = 0 + UpdateProgramConfigInstructionIndex GeolocationInstructionType = 1 + CreateGeoProbeInstructionIndex GeolocationInstructionType = 2 + UpdateGeoProbeInstructionIndex GeolocationInstructionType = 3 + DeleteGeoProbeInstructionIndex GeolocationInstructionType = 4 + AddParentDeviceInstructionIndex GeolocationInstructionType = 5 + RemoveParentDeviceInstructionIndex GeolocationInstructionType = 6 +) + // PDA seeds for geolocation program const ( SeedPrefix = "doublezero" diff --git a/sdk/geolocation/go/create_geo_probe.go b/sdk/geolocation/go/create_geo_probe.go new file mode 100644 index 0000000000..4c9c234adb --- /dev/null +++ b/sdk/geolocation/go/create_geo_probe.go @@ -0,0 +1,91 @@ +package geolocation + +import ( + "fmt" + + "github.com/gagliardetto/solana-go" + "github.com/near/borsh-go" +) + +type CreateGeoProbeInstructionConfig struct { + Payer solana.PublicKey + Code string + ExchangePK solana.PublicKey + ServiceabilityGlobalStatePK solana.PublicKey + PublicIP [4]uint8 + LocationOffsetPort uint16 + MetricsPublisherPK solana.PublicKey +} + +func (c *CreateGeoProbeInstructionConfig) Validate() error { + if c.Payer.IsZero() { + return fmt.Errorf("payer public key is required") + } + 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.ExchangePK.IsZero() { + return fmt.Errorf("exchange public key is required") + } + if c.ServiceabilityGlobalStatePK.IsZero() { + return fmt.Errorf("serviceability global state public key is required") + } + if c.MetricsPublisherPK.IsZero() { + return fmt.Errorf("metrics publisher public key is required") + } + return nil +} + +func BuildCreateGeoProbeInstruction( + programID solana.PublicKey, + config CreateGeoProbeInstructionConfig, +) (solana.Instruction, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("failed to validate config: %w", err) + } + + data, err := borsh.Serialize(struct { + Discriminator uint8 + Code string + PublicIP [4]uint8 + LocationOffsetPort uint16 + MetricsPublisherPK solana.PublicKey + }{ + Discriminator: uint8(CreateGeoProbeInstructionIndex), + Code: config.Code, + PublicIP: config.PublicIP, + LocationOffsetPort: config.LocationOffsetPort, + MetricsPublisherPK: config.MetricsPublisherPK, + }) + if err != nil { + return nil, fmt.Errorf("failed to serialize args: %w", err) + } + + probePDA, _, err := DeriveGeoProbePDA(programID, config.Code) + if err != nil { + return nil, fmt.Errorf("failed to derive geo probe PDA: %w", err) + } + + programConfigPDA, _, err := DeriveProgramConfigPDA(programID) + if err != nil { + return nil, fmt.Errorf("failed to derive program config PDA: %w", err) + } + + accounts := []*solana.AccountMeta{ + {PublicKey: probePDA, IsSigner: false, IsWritable: true}, + {PublicKey: config.ExchangePK, IsSigner: false, IsWritable: false}, + {PublicKey: programConfigPDA, IsSigner: false, IsWritable: false}, + {PublicKey: config.ServiceabilityGlobalStatePK, IsSigner: false, IsWritable: false}, + {PublicKey: config.Payer, 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_geo_probe.go b/sdk/geolocation/go/delete_geo_probe.go new file mode 100644 index 0000000000..ccf177648c --- /dev/null +++ b/sdk/geolocation/go/delete_geo_probe.go @@ -0,0 +1,63 @@ +package geolocation + +import ( + "fmt" + + "github.com/gagliardetto/solana-go" + "github.com/near/borsh-go" +) + +type DeleteGeoProbeInstructionConfig struct { + Payer solana.PublicKey + ProbePK solana.PublicKey + ServiceabilityGlobalStatePK solana.PublicKey +} + +func (c *DeleteGeoProbeInstructionConfig) Validate() error { + if c.Payer.IsZero() { + return fmt.Errorf("payer public key is required") + } + if c.ProbePK.IsZero() { + return fmt.Errorf("probe public key is required") + } + if c.ServiceabilityGlobalStatePK.IsZero() { + return fmt.Errorf("serviceability global state public key is required") + } + return nil +} + +func BuildDeleteGeoProbeInstruction( + programID solana.PublicKey, + config DeleteGeoProbeInstructionConfig, +) (solana.Instruction, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("failed to validate config: %w", err) + } + + data, err := borsh.Serialize(struct { + Discriminator uint8 + }{ + Discriminator: uint8(DeleteGeoProbeInstructionIndex), + }) + if err != nil { + return nil, fmt.Errorf("failed to serialize args: %w", err) + } + + programConfigPDA, _, err := DeriveProgramConfigPDA(programID) + if err != nil { + return nil, fmt.Errorf("failed to derive program config PDA: %w", err) + } + + accounts := []*solana.AccountMeta{ + {PublicKey: config.ProbePK, IsSigner: false, IsWritable: true}, + {PublicKey: programConfigPDA, IsSigner: false, IsWritable: false}, + {PublicKey: config.ServiceabilityGlobalStatePK, IsSigner: false, IsWritable: false}, + {PublicKey: config.Payer, IsSigner: true, IsWritable: true}, + } + + return &solana.GenericInstruction{ + ProgID: programID, + AccountValues: accounts, + DataBytes: data, + }, nil +} diff --git a/sdk/geolocation/go/executor.go b/sdk/geolocation/go/executor.go new file mode 100644 index 0000000000..b6e3cef036 --- /dev/null +++ b/sdk/geolocation/go/executor.go @@ -0,0 +1,184 @@ +package geolocation + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/gagliardetto/solana-go" + solanarpc "github.com/gagliardetto/solana-go/rpc" +) + +var ( + // ErrNoPrivateKey is returned when a transaction signing operation is attempted without a configured private key. + ErrNoPrivateKey = errors.New("no private key configured") + + // ErrNoProgramID is returned when a transaction signing operation is attempted without a configured program ID. + ErrNoProgramID = errors.New("no program ID configured") +) + +type executor struct { + log *slog.Logger + rpc RPCClient + 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 RPCClient, 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 + } + + // Get latest blockhash + blockhashResult, err := e.rpc.GetLatestBlockhash(ctx, solanarpc.CommitmentFinalized) + if err != nil { + return solana.Signature{}, nil, fmt.Errorf("failed to get latest blockhash: %w", err) + } + + // Build transaction + 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") + } + + // Sign transaction + _, 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 (likely missing signer): %w", err) + } + if len(tx.Signatures) == 0 { + return solana.Signature{}, nil, errors.New("signed transaction appears malformed") + } + + // Send transaction + 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) + } + + // Wait for the signature to be visible + err = e.waitForSignatureVisible(ctx, sig, e.waitForVisibleTimeout) + if err != nil { + if opts.SkipPreflight { + return solana.Signature{}, nil, fmt.Errorf("transaction dropped or rejected before cluster saw it. make sure you have sufficient funds for the transaction: %w", err) + } + return solana.Signature{}, nil, fmt.Errorf("transaction dropped or rejected before cluster saw it: %w", err) + } + + // Wait for the transaction to be finalized + 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 + } + time.Sleep(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..03324c5199 --- /dev/null +++ b/sdk/geolocation/go/executor_test.go @@ -0,0 +1,383 @@ +package geolocation_test + +import ( + "context" + "errors" + "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" +) + +func TestSDK_Geolocation_Executor_ExecuteTransaction(t *testing.T) { + t.Parallel() + + signer := solana.NewWallet().PrivateKey + programID := solana.NewWallet().PublicKey() + + var sig solana.Signature + copy(sig[:], []byte("fake-sig-0000000000000000000000000000000")[:]) + + blockhash := solana.MustHashFromBase58("5NzX7jrPWeTkGsDnVnszdEa7T3Yyr3nSgyc78z3CwjWQ") + + mockRPC := &mockRPCClient{ + GetLatestBlockhashFunc: func(_ context.Context, _ solanarpc.CommitmentType) (*solanarpc.GetLatestBlockhashResult, error) { + return &solanarpc.GetLatestBlockhashResult{ + Value: &solanarpc.LatestBlockhashResult{ + Blockhash: blockhash, + }, + }, 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.ConfirmationStatusFinalized}, + }, + }, nil + }, + GetTransactionFunc: func(_ context.Context, _ solana.Signature, _ *solanarpc.GetTransactionOpts) (*solanarpc.GetTransactionResult, error) { + return &solanarpc.GetTransactionResult{ + Meta: &solanarpc.TransactionMeta{}, + }, nil + }, + } + + exec := geolocation.NewExecutor(log, mockRPC, &signer, programID) + + instruction := solana.NewInstruction( + programID, + solana.AccountMetaSlice{}, + []byte{1, 2, 3}, + ) + + ctx := t.Context() + opts := &geolocation.ExecuteTransactionOptions{} + gotSig, res, err := exec.ExecuteTransaction(ctx, instruction, opts) + + require.NoError(t, err) + require.Equal(t, sig, gotSig) + require.NotNil(t, res) +} + +func TestSDK_Geolocation_Executor_MissingSigner(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + mockRPC := &mockRPCClient{} + + exec := geolocation.NewExecutor(log, mockRPC, nil, programID) + + instruction := solana.NewInstruction( + programID, + solana.AccountMetaSlice{}, + []byte{1, 2, 3}, + ) + + sig, res, err := exec.ExecuteTransaction(t.Context(), instruction, nil) + + require.ErrorIs(t, err, geolocation.ErrNoPrivateKey) + require.Empty(t, sig) + require.Nil(t, res) +} + +func TestSDK_Geolocation_Executor_MissingProgramID(t *testing.T) { + t.Parallel() + + signer := solana.NewWallet().PrivateKey + zeroProgramID := solana.PublicKey{} + mockRPC := &mockRPCClient{} + + exec := geolocation.NewExecutor(log, mockRPC, &signer, zeroProgramID) + + instruction := solana.NewInstruction( + solana.NewWallet().PublicKey(), + solana.AccountMetaSlice{}, + []byte{1, 2, 3}, + ) + + sig, res, err := exec.ExecuteTransaction(t.Context(), instruction, nil) + + require.ErrorIs(t, err, geolocation.ErrNoProgramID) + require.Empty(t, sig) + require.Nil(t, res) +} + +func TestSDK_Geolocation_Executor_GetLatestBlockhashError(t *testing.T) { + t.Parallel() + + signer := solana.NewWallet().PrivateKey + programID := solana.NewWallet().PublicKey() + + mockRPC := &mockRPCClient{ + GetLatestBlockhashFunc: func(_ context.Context, _ solanarpc.CommitmentType) (*solanarpc.GetLatestBlockhashResult, error) { + return nil, errors.New("rpc unavailable") + }, + } + + exec := geolocation.NewExecutor(log, mockRPC, &signer, programID) + + instruction := solana.NewInstruction( + programID, + solana.AccountMetaSlice{}, + []byte{1, 2, 3}, + ) + + sig, res, err := exec.ExecuteTransaction(t.Context(), instruction, nil) + + require.ErrorContains(t, err, "failed to get latest blockhash") + require.Empty(t, sig) + require.Nil(t, res) +} + +func TestSDK_Geolocation_Executor_SendFails(t *testing.T) { + t.Parallel() + + signer := solana.NewWallet().PrivateKey + programID := solana.NewWallet().PublicKey() + blockhash := solana.MustHashFromBase58("5NzX7jrPWeTkGsDnVnszdEa7T3Yyr3nSgyc78z3CwjWQ") + + mockRPC := &mockRPCClient{ + GetLatestBlockhashFunc: func(_ context.Context, _ solanarpc.CommitmentType) (*solanarpc.GetLatestBlockhashResult, error) { + return &solanarpc.GetLatestBlockhashResult{ + Value: &solanarpc.LatestBlockhashResult{Blockhash: blockhash}, + }, nil + }, + SendTransactionWithOptsFunc: func(_ context.Context, _ *solana.Transaction, _ solanarpc.TransactionOpts) (solana.Signature, error) { + return solana.Signature{}, errors.New("rpc send error") + }, + } + + exec := geolocation.NewExecutor(log, mockRPC, &signer, programID) + + instruction := solana.NewInstruction( + programID, + solana.AccountMetaSlice{ + {PublicKey: signer.PublicKey(), IsSigner: true, IsWritable: true}, + }, + []byte{1, 2, 3}, + ) + + sig, res, err := exec.ExecuteTransaction(t.Context(), instruction, nil) + + require.ErrorContains(t, err, "failed to send transaction") + require.Empty(t, sig) + require.Nil(t, res) +} + +func TestSDK_Geolocation_Executor_SignatureNeverVisible(t *testing.T) { + t.Parallel() + + signer := solana.NewWallet().PrivateKey + signerPub := signer.PublicKey() + programID := solana.NewWallet().PublicKey() + blockhash := solana.MustHashFromBase58("5NzX7jrPWeTkGsDnVnszdEa7T3Yyr3nSgyc78z3CwjWQ") + + var returnedSig solana.Signature + + mockRPC := &mockRPCClient{ + GetLatestBlockhashFunc: func(_ context.Context, _ solanarpc.CommitmentType) (*solanarpc.GetLatestBlockhashResult, error) { + return &solanarpc.GetLatestBlockhashResult{ + Value: &solanarpc.LatestBlockhashResult{Blockhash: blockhash}, + }, nil + }, + SendTransactionWithOptsFunc: func(_ context.Context, tx *solana.Transaction, _ solanarpc.TransactionOpts) (solana.Signature, error) { + if len(tx.Signatures) == 0 { + t.Fatal("transaction was not signed") + } + returnedSig = tx.Signatures[0] + return returnedSig, nil + }, + GetSignatureStatusesFunc: func(_ context.Context, _ bool, _ ...solana.Signature) (*solanarpc.GetSignatureStatusesResult, error) { + return &solanarpc.GetSignatureStatusesResult{ + Value: []*solanarpc.SignatureStatusesResult{nil}, + }, nil + }, + GetTransactionFunc: func(_ context.Context, _ solana.Signature, _ *solanarpc.GetTransactionOpts) (*solanarpc.GetTransactionResult, error) { + return nil, errors.New("not called") + }, + } + + exec := geolocation.NewExecutor(log, mockRPC, &signer, programID, geolocation.WithWaitForVisibleTimeout(500*time.Millisecond)) + + instruction := solana.NewInstruction( + programID, + solana.AccountMetaSlice{ + {PublicKey: signerPub, IsSigner: true, IsWritable: true}, + }, + []byte{1, 2, 3}, + ) + + ctx := t.Context() + opts := &geolocation.ExecuteTransactionOptions{SkipPreflight: false} + gotSig, res, err := exec.ExecuteTransaction(ctx, instruction, opts) + + require.ErrorContains(t, err, "transaction dropped or rejected before cluster saw it") + require.Equal(t, solana.Signature{}, gotSig, "executor returns zero sig on error (by design)") + require.NotEqual(t, solana.Signature{}, returnedSig, "the signed tx should still contain a real signature") + require.Nil(t, res) +} + +func TestSDK_Geolocation_Executor_TransactionNeverFinalized(t *testing.T) { + t.Parallel() + + signer := solana.NewWallet().PrivateKey + signerPub := signer.PublicKey() + programID := solana.NewWallet().PublicKey() + blockhash := solana.MustHashFromBase58("5NzX7jrPWeTkGsDnVnszdEa7T3Yyr3nSgyc78z3CwjWQ") + + mockRPC := &mockRPCClient{ + GetLatestBlockhashFunc: func(_ context.Context, _ solanarpc.CommitmentType) (*solanarpc.GetLatestBlockhashResult, error) { + return &solanarpc.GetLatestBlockhashResult{ + Value: &solanarpc.LatestBlockhashResult{Blockhash: blockhash}, + }, nil + }, + SendTransactionWithOptsFunc: func(_ context.Context, tx *solana.Transaction, _ solanarpc.TransactionOpts) (solana.Signature, error) { + if len(tx.Signatures) == 0 { + t.Fatal("tx.Signatures is empty") + } + return tx.Signatures[0], nil + }, + GetSignatureStatusesFunc: func(_ context.Context, _ bool, _ ...solana.Signature) (*solanarpc.GetSignatureStatusesResult, error) { + return &solanarpc.GetSignatureStatusesResult{ + Value: []*solanarpc.SignatureStatusesResult{ + {ConfirmationStatus: solanarpc.ConfirmationStatusConfirmed}, // never finalized + }, + }, nil + }, + GetTransactionFunc: func(_ context.Context, _ solana.Signature, _ *solanarpc.GetTransactionOpts) (*solanarpc.GetTransactionResult, error) { + t.Fatal("GetTransaction should not be called if not finalized") + return nil, nil + }, + } + + exec := geolocation.NewExecutor(log, mockRPC, &signer, programID) + + instruction := solana.NewInstruction( + programID, + solana.AccountMetaSlice{ + {PublicKey: signerPub, IsSigner: true, IsWritable: true}, + }, + []byte{1, 2, 3}, + ) + + ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond) + defer cancel() + + opts := &geolocation.ExecuteTransactionOptions{} + sig, res, err := exec.ExecuteTransaction(ctx, instruction, opts) + + require.Error(t, err) + require.Contains(t, err.Error(), "context deadline exceeded") + require.Equal(t, solana.Signature{}, sig) + require.Nil(t, res) +} + +func TestSDK_Geolocation_Executor_FinalizedButMissingTransactionMeta(t *testing.T) { + t.Parallel() + + signer := solana.NewWallet().PrivateKey + signerPub := signer.PublicKey() + programID := solana.NewWallet().PublicKey() + blockhash := solana.MustHashFromBase58("5NzX7jrPWeTkGsDnVnszdEa7T3Yyr3nSgyc78z3CwjWQ") + + mockRPC := &mockRPCClient{ + GetLatestBlockhashFunc: func(_ context.Context, _ solanarpc.CommitmentType) (*solanarpc.GetLatestBlockhashResult, error) { + return &solanarpc.GetLatestBlockhashResult{ + Value: &solanarpc.LatestBlockhashResult{Blockhash: blockhash}, + }, nil + }, + SendTransactionWithOptsFunc: func(_ context.Context, tx *solana.Transaction, _ solanarpc.TransactionOpts) (solana.Signature, error) { + if len(tx.Signatures) == 0 { + t.Fatal("tx.Signatures is empty") + } + return tx.Signatures[0], nil + }, + GetSignatureStatusesFunc: func(_ context.Context, _ bool, _ ...solana.Signature) (*solanarpc.GetSignatureStatusesResult, error) { + return &solanarpc.GetSignatureStatusesResult{ + Value: []*solanarpc.SignatureStatusesResult{ + {ConfirmationStatus: solanarpc.ConfirmationStatusFinalized}, + }, + }, nil + }, + GetTransactionFunc: func(_ context.Context, _ solana.Signature, _ *solanarpc.GetTransactionOpts) (*solanarpc.GetTransactionResult, error) { + return &solanarpc.GetTransactionResult{ + Meta: nil, + }, nil + }, + } + + exec := geolocation.NewExecutor(log, mockRPC, &signer, programID) + + instruction := solana.NewInstruction( + programID, + solana.AccountMetaSlice{ + {PublicKey: signerPub, IsSigner: true, IsWritable: true}, + }, + []byte{1, 2, 3}, + ) + + ctx := t.Context() + opts := &geolocation.ExecuteTransactionOptions{} + sig, res, err := exec.ExecuteTransaction(ctx, instruction, opts) + + require.ErrorContains(t, err, "transaction not found or missing metadata") + require.Equal(t, solana.Signature{}, sig) + require.Nil(t, res) +} + +func TestSDK_Geolocation_Executor_FinalizedButGetTransactionNil(t *testing.T) { + t.Parallel() + + signer := solana.NewWallet().PrivateKey + signerPub := signer.PublicKey() + programID := solana.NewWallet().PublicKey() + blockhash := solana.MustHashFromBase58("5NzX7jrPWeTkGsDnVnszdEa7T3Yyr3nSgyc78z3CwjWQ") + + mockRPC := &mockRPCClient{ + GetLatestBlockhashFunc: func(_ context.Context, _ solanarpc.CommitmentType) (*solanarpc.GetLatestBlockhashResult, error) { + return &solanarpc.GetLatestBlockhashResult{ + Value: &solanarpc.LatestBlockhashResult{Blockhash: blockhash}, + }, nil + }, + SendTransactionWithOptsFunc: func(_ context.Context, tx *solana.Transaction, _ solanarpc.TransactionOpts) (solana.Signature, error) { + if len(tx.Signatures) == 0 { + t.Fatal("tx.Signatures is empty") + } + return tx.Signatures[0], nil + }, + GetSignatureStatusesFunc: func(_ context.Context, _ bool, _ ...solana.Signature) (*solanarpc.GetSignatureStatusesResult, error) { + return &solanarpc.GetSignatureStatusesResult{ + Value: []*solanarpc.SignatureStatusesResult{ + {ConfirmationStatus: solanarpc.ConfirmationStatusFinalized}, + }, + }, nil + }, + GetTransactionFunc: func(_ context.Context, _ solana.Signature, _ *solanarpc.GetTransactionOpts) (*solanarpc.GetTransactionResult, error) { + return nil, nil // simulate node RPC dropping the data + }, + } + + exec := geolocation.NewExecutor(log, mockRPC, &signer, programID) + + instruction := solana.NewInstruction( + programID, + solana.AccountMetaSlice{ + {PublicKey: signerPub, IsSigner: true, IsWritable: true}, + }, + []byte("xyz"), + ) + + ctx := t.Context() + opts := &geolocation.ExecuteTransactionOptions{} + sig, res, err := exec.ExecuteTransaction(ctx, instruction, opts) + + require.ErrorContains(t, err, "transaction not found or missing metadata") + require.Equal(t, solana.Signature{}, sig) + require.Nil(t, res) +} diff --git a/sdk/geolocation/go/init_program_config.go b/sdk/geolocation/go/init_program_config.go new file mode 100644 index 0000000000..5c79ba4d1a --- /dev/null +++ b/sdk/geolocation/go/init_program_config.go @@ -0,0 +1,60 @@ +package geolocation + +import ( + "fmt" + + "github.com/gagliardetto/solana-go" + "github.com/near/borsh-go" +) + +type InitProgramConfigInstructionConfig struct { + Payer solana.PublicKey +} + +func (c *InitProgramConfigInstructionConfig) Validate() error { + if c.Payer.IsZero() { + return fmt.Errorf("payer public key is required") + } + return nil +} + +func BuildInitProgramConfigInstruction( + programID solana.PublicKey, + config InitProgramConfigInstructionConfig, +) (solana.Instruction, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("failed to validate config: %w", err) + } + + data, err := borsh.Serialize(struct { + Discriminator uint8 + }{ + Discriminator: uint8(InitProgramConfigInstructionIndex), + }) + if err != nil { + return nil, fmt.Errorf("failed to serialize args: %w", err) + } + + programConfigPDA, _, err := DeriveProgramConfigPDA(programID) + if err != nil { + return nil, fmt.Errorf("failed to derive program config PDA: %w", err) + } + + programDataPDA, _, err := DeriveProgramDataPDA(programID) + if err != nil { + return nil, fmt.Errorf("failed to derive program data PDA: %w", err) + } + + accounts := []*solana.AccountMeta{ + {PublicKey: programConfigPDA, IsSigner: false, IsWritable: true}, + {PublicKey: programDataPDA, IsSigner: false, IsWritable: false}, + {PublicKey: config.Payer, 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/instruction_test.go b/sdk/geolocation/go/instruction_test.go new file mode 100644 index 0000000000..71f41294d5 --- /dev/null +++ b/sdk/geolocation/go/instruction_test.go @@ -0,0 +1,551 @@ +package geolocation_test + +import ( + "strings" + "testing" + + "github.com/gagliardetto/solana-go" + geolocation "github.com/malbeclabs/doublezero/sdk/geolocation/go" + "github.com/stretchr/testify/require" +) + +// TestSDK_Geolocation_BuildInitProgramConfigInstruction_HappyPath tests successful instruction creation +func TestSDK_Geolocation_BuildInitProgramConfigInstruction_HappyPath(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + config := geolocation.InitProgramConfigInstructionConfig{ + Payer: solana.NewWallet().PublicKey(), + } + + instr, err := geolocation.BuildInitProgramConfigInstruction(programID, config) + require.NoError(t, err) + require.NotNil(t, instr) + + accounts := instr.Accounts() + require.Len(t, accounts, 4, "should have 4 accounts") + instrData, err := instr.Data() + require.NoError(t, err) + require.NotEmpty(t, instrData, "instruction data should not be empty") + require.Equal(t, programID, instr.ProgramID(), "program ID should match") +} + +// TestSDK_Geolocation_BuildInitProgramConfigInstruction_MissingRequiredFields tests validation errors +func TestSDK_Geolocation_BuildInitProgramConfigInstruction_MissingRequiredFields(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + + config := geolocation.InitProgramConfigInstructionConfig{ + Payer: solana.PublicKey{}, + } + + _, err := geolocation.BuildInitProgramConfigInstruction(programID, config) + require.Error(t, err) + require.Contains(t, err.Error(), "payer public key is required") +} + +// TestSDK_Geolocation_BuildUpdateProgramConfigInstruction_HappyPath tests successful instruction creation +func TestSDK_Geolocation_BuildUpdateProgramConfigInstruction_HappyPath(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + version := uint32(2) + minCompatibleVersion := uint32(1) + config := geolocation.UpdateProgramConfigInstructionConfig{ + Payer: solana.NewWallet().PublicKey(), + Version: &version, + MinCompatibleVersion: &minCompatibleVersion, + } + + instr, err := geolocation.BuildUpdateProgramConfigInstruction(programID, config) + require.NoError(t, err) + require.NotNil(t, instr) + + accounts := instr.Accounts() + require.Len(t, accounts, 4, "should have 4 accounts") + instrData, err := instr.Data() + require.NoError(t, err) + require.NotEmpty(t, instrData, "instruction data should not be empty") + require.Equal(t, programID, instr.ProgramID(), "program ID should match") +} + +// TestSDK_Geolocation_BuildUpdateProgramConfigInstruction_MissingRequiredFields tests validation errors +func TestSDK_Geolocation_BuildUpdateProgramConfigInstruction_MissingRequiredFields(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + + config := geolocation.UpdateProgramConfigInstructionConfig{ + Payer: solana.PublicKey{}, + Version: nil, + MinCompatibleVersion: nil, + } + + _, err := geolocation.BuildUpdateProgramConfigInstruction(programID, config) + require.Error(t, err) + require.Contains(t, err.Error(), "payer public key is required") +} + +// TestSDK_Geolocation_BuildCreateGeoProbeInstruction_HappyPath tests successful instruction creation +func TestSDK_Geolocation_BuildCreateGeoProbeInstruction_HappyPath(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + config := geolocation.CreateGeoProbeInstructionConfig{ + Payer: solana.NewWallet().PublicKey(), + Code: "ams-probe-01", + ExchangePK: solana.NewWallet().PublicKey(), + ServiceabilityGlobalStatePK: solana.NewWallet().PublicKey(), + PublicIP: [4]uint8{10, 0, 1, 42}, + LocationOffsetPort: 8923, + MetricsPublisherPK: solana.NewWallet().PublicKey(), + } + + instr, err := geolocation.BuildCreateGeoProbeInstruction(programID, config) + require.NoError(t, err) + require.NotNil(t, instr) + + accounts := instr.Accounts() + require.Len(t, accounts, 6, "should have 6 accounts") + instrData, err := instr.Data() + require.NoError(t, err) + require.NotEmpty(t, instrData, "instruction data should not be empty") + require.Equal(t, programID, instr.ProgramID(), "program ID should match") +} + +// TestSDK_Geolocation_BuildCreateGeoProbeInstruction_MissingRequiredFields tests validation errors +func TestSDK_Geolocation_BuildCreateGeoProbeInstruction_MissingRequiredFields(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + + tests := []struct { + name string + config geolocation.CreateGeoProbeInstructionConfig + errMsg string + }{ + { + name: "missing payer", + config: geolocation.CreateGeoProbeInstructionConfig{ + Payer: solana.PublicKey{}, + Code: "test-code", + ExchangePK: solana.NewWallet().PublicKey(), + ServiceabilityGlobalStatePK: solana.NewWallet().PublicKey(), + MetricsPublisherPK: solana.NewWallet().PublicKey(), + }, + errMsg: "payer public key is required", + }, + { + name: "missing code", + config: geolocation.CreateGeoProbeInstructionConfig{ + Payer: solana.NewWallet().PublicKey(), + Code: "", + ExchangePK: solana.NewWallet().PublicKey(), + ServiceabilityGlobalStatePK: solana.NewWallet().PublicKey(), + MetricsPublisherPK: solana.NewWallet().PublicKey(), + }, + errMsg: "code is required", + }, + { + name: "missing exchange public key", + config: geolocation.CreateGeoProbeInstructionConfig{ + Payer: solana.NewWallet().PublicKey(), + Code: "test-code", + ExchangePK: solana.PublicKey{}, + ServiceabilityGlobalStatePK: solana.NewWallet().PublicKey(), + MetricsPublisherPK: solana.NewWallet().PublicKey(), + }, + errMsg: "exchange public key is required", + }, + { + name: "missing serviceability global state public key", + config: geolocation.CreateGeoProbeInstructionConfig{ + Payer: solana.NewWallet().PublicKey(), + Code: "test-code", + ExchangePK: solana.NewWallet().PublicKey(), + ServiceabilityGlobalStatePK: solana.PublicKey{}, + MetricsPublisherPK: solana.NewWallet().PublicKey(), + }, + errMsg: "serviceability global state public key is required", + }, + { + name: "missing metrics publisher public key", + config: geolocation.CreateGeoProbeInstructionConfig{ + Payer: solana.NewWallet().PublicKey(), + Code: "test-code", + ExchangePK: solana.NewWallet().PublicKey(), + ServiceabilityGlobalStatePK: solana.NewWallet().PublicKey(), + MetricsPublisherPK: solana.PublicKey{}, + }, + errMsg: "metrics publisher public key is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := geolocation.BuildCreateGeoProbeInstruction(programID, tt.config) + require.Error(t, err) + require.Contains(t, err.Error(), tt.errMsg) + }) + } +} + +// TestSDK_Geolocation_BuildCreateGeoProbeInstruction_CodeTooLong tests code length validation +func TestSDK_Geolocation_BuildCreateGeoProbeInstruction_CodeTooLong(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + config := geolocation.CreateGeoProbeInstructionConfig{ + Payer: solana.NewWallet().PublicKey(), + Code: strings.Repeat("a", geolocation.MaxCodeLength+1), + ExchangePK: solana.NewWallet().PublicKey(), + ServiceabilityGlobalStatePK: solana.NewWallet().PublicKey(), + MetricsPublisherPK: solana.NewWallet().PublicKey(), + } + + _, err := geolocation.BuildCreateGeoProbeInstruction(programID, config) + require.Error(t, err) + require.Contains(t, err.Error(), "exceeds max") +} + +// TestSDK_Geolocation_BuildUpdateGeoProbeInstruction_HappyPath tests successful instruction creation +func TestSDK_Geolocation_BuildUpdateGeoProbeInstruction_HappyPath(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + publicIP := [4]uint8{192, 168, 1, 1} + port := uint16(9000) + metricsPublisher := solana.NewWallet().PublicKey() + + config := geolocation.UpdateGeoProbeInstructionConfig{ + Payer: solana.NewWallet().PublicKey(), + ProbePK: solana.NewWallet().PublicKey(), + ServiceabilityGlobalStatePK: solana.NewWallet().PublicKey(), + PublicIP: &publicIP, + LocationOffsetPort: &port, + MetricsPublisherPK: &metricsPublisher, + } + + instr, err := geolocation.BuildUpdateGeoProbeInstruction(programID, config) + require.NoError(t, err) + require.NotNil(t, instr) + + accounts := instr.Accounts() + require.Len(t, accounts, 4, "should have 4 accounts") + instrData, err := instr.Data() + require.NoError(t, err) + require.NotEmpty(t, instrData, "instruction data should not be empty") + require.Equal(t, programID, instr.ProgramID(), "program ID should match") +} + +// TestSDK_Geolocation_BuildUpdateGeoProbeInstruction_MissingRequiredFields tests validation errors +func TestSDK_Geolocation_BuildUpdateGeoProbeInstruction_MissingRequiredFields(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + + tests := []struct { + name string + config geolocation.UpdateGeoProbeInstructionConfig + errMsg string + }{ + { + name: "missing payer", + config: geolocation.UpdateGeoProbeInstructionConfig{ + Payer: solana.PublicKey{}, + ProbePK: solana.NewWallet().PublicKey(), + ServiceabilityGlobalStatePK: solana.NewWallet().PublicKey(), + }, + errMsg: "payer public key is required", + }, + { + name: "missing probe public key", + config: geolocation.UpdateGeoProbeInstructionConfig{ + Payer: solana.NewWallet().PublicKey(), + ProbePK: solana.PublicKey{}, + ServiceabilityGlobalStatePK: solana.NewWallet().PublicKey(), + }, + errMsg: "probe public key is required", + }, + { + name: "missing serviceability global state public key", + config: geolocation.UpdateGeoProbeInstructionConfig{ + Payer: solana.NewWallet().PublicKey(), + ProbePK: solana.NewWallet().PublicKey(), + ServiceabilityGlobalStatePK: solana.PublicKey{}, + }, + errMsg: "serviceability global state public key is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := geolocation.BuildUpdateGeoProbeInstruction(programID, tt.config) + require.Error(t, err) + require.Contains(t, err.Error(), tt.errMsg) + }) + } +} + +// TestSDK_Geolocation_BuildDeleteGeoProbeInstruction_HappyPath tests successful instruction creation +func TestSDK_Geolocation_BuildDeleteGeoProbeInstruction_HappyPath(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + config := geolocation.DeleteGeoProbeInstructionConfig{ + Payer: solana.NewWallet().PublicKey(), + ProbePK: solana.NewWallet().PublicKey(), + ServiceabilityGlobalStatePK: solana.NewWallet().PublicKey(), + } + + instr, err := geolocation.BuildDeleteGeoProbeInstruction(programID, config) + require.NoError(t, err) + require.NotNil(t, instr) + + accounts := instr.Accounts() + require.Len(t, accounts, 4, "should have 4 accounts") + instrData, err := instr.Data() + require.NoError(t, err) + require.NotEmpty(t, instrData, "instruction data should not be empty") + require.Equal(t, programID, instr.ProgramID(), "program ID should match") + // probe is writable (being closed); payer is writable (receives rent refund) + require.True(t, accounts[0].IsWritable, "probe account must be writable") + require.True(t, accounts[3].IsWritable, "payer must be writable to receive rent refund") + require.True(t, accounts[3].IsSigner, "payer must be signer") +} + +// TestSDK_Geolocation_BuildDeleteGeoProbeInstruction_MissingRequiredFields tests validation errors +func TestSDK_Geolocation_BuildDeleteGeoProbeInstruction_MissingRequiredFields(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + + tests := []struct { + name string + config geolocation.DeleteGeoProbeInstructionConfig + errMsg string + }{ + { + name: "missing payer", + config: geolocation.DeleteGeoProbeInstructionConfig{ + Payer: solana.PublicKey{}, + ProbePK: solana.NewWallet().PublicKey(), + ServiceabilityGlobalStatePK: solana.NewWallet().PublicKey(), + }, + errMsg: "payer public key is required", + }, + { + name: "missing probe public key", + config: geolocation.DeleteGeoProbeInstructionConfig{ + Payer: solana.NewWallet().PublicKey(), + ProbePK: solana.PublicKey{}, + ServiceabilityGlobalStatePK: solana.NewWallet().PublicKey(), + }, + errMsg: "probe public key is required", + }, + { + name: "missing serviceability global state public key", + config: geolocation.DeleteGeoProbeInstructionConfig{ + Payer: solana.NewWallet().PublicKey(), + ProbePK: solana.NewWallet().PublicKey(), + ServiceabilityGlobalStatePK: solana.PublicKey{}, + }, + errMsg: "serviceability global state public key is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := geolocation.BuildDeleteGeoProbeInstruction(programID, tt.config) + require.Error(t, err) + require.Contains(t, err.Error(), tt.errMsg) + }) + } +} + +// TestSDK_Geolocation_BuildAddParentDeviceInstruction_HappyPath tests successful instruction creation +func TestSDK_Geolocation_BuildAddParentDeviceInstruction_HappyPath(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + config := geolocation.AddParentDeviceInstructionConfig{ + Payer: solana.NewWallet().PublicKey(), + ProbePK: solana.NewWallet().PublicKey(), + DevicePK: solana.NewWallet().PublicKey(), + ServiceabilityGlobalStatePK: solana.NewWallet().PublicKey(), + } + + instr, err := geolocation.BuildAddParentDeviceInstruction(programID, config) + require.NoError(t, err) + require.NotNil(t, instr) + + accounts := instr.Accounts() + require.Len(t, accounts, 6, "should have 6 accounts") + instrData, err := instr.Data() + require.NoError(t, err) + require.NotEmpty(t, instrData, "instruction data should not be empty") + require.Equal(t, programID, instr.ProgramID(), "program ID should match") + // probe is writable (being modified); payer is writable (funds any realloc rent) + require.True(t, accounts[0].IsWritable, "probe account must be writable") + require.True(t, accounts[4].IsWritable, "payer must be writable to fund realloc") + require.True(t, accounts[4].IsSigner, "payer must be signer") +} + +// TestSDK_Geolocation_BuildAddParentDeviceInstruction_MissingRequiredFields tests validation errors +func TestSDK_Geolocation_BuildAddParentDeviceInstruction_MissingRequiredFields(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + + tests := []struct { + name string + config geolocation.AddParentDeviceInstructionConfig + errMsg string + }{ + { + name: "missing payer", + config: geolocation.AddParentDeviceInstructionConfig{ + Payer: solana.PublicKey{}, + ProbePK: solana.NewWallet().PublicKey(), + DevicePK: solana.NewWallet().PublicKey(), + ServiceabilityGlobalStatePK: solana.NewWallet().PublicKey(), + }, + errMsg: "payer public key is required", + }, + { + name: "missing probe public key", + config: geolocation.AddParentDeviceInstructionConfig{ + Payer: solana.NewWallet().PublicKey(), + ProbePK: solana.PublicKey{}, + DevicePK: solana.NewWallet().PublicKey(), + ServiceabilityGlobalStatePK: solana.NewWallet().PublicKey(), + }, + errMsg: "probe public key is required", + }, + { + name: "missing device public key", + config: geolocation.AddParentDeviceInstructionConfig{ + Payer: solana.NewWallet().PublicKey(), + ProbePK: solana.NewWallet().PublicKey(), + DevicePK: solana.PublicKey{}, + ServiceabilityGlobalStatePK: solana.NewWallet().PublicKey(), + }, + errMsg: "device public key is required", + }, + { + name: "missing serviceability global state public key", + config: geolocation.AddParentDeviceInstructionConfig{ + Payer: solana.NewWallet().PublicKey(), + ProbePK: solana.NewWallet().PublicKey(), + DevicePK: solana.NewWallet().PublicKey(), + ServiceabilityGlobalStatePK: solana.PublicKey{}, + }, + errMsg: "serviceability global state public key is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := geolocation.BuildAddParentDeviceInstruction(programID, tt.config) + require.Error(t, err) + require.Contains(t, err.Error(), tt.errMsg) + }) + } +} + +// TestSDK_Geolocation_BuildRemoveParentDeviceInstruction_HappyPath tests successful instruction creation +func TestSDK_Geolocation_BuildRemoveParentDeviceInstruction_HappyPath(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + config := geolocation.RemoveParentDeviceInstructionConfig{ + Payer: solana.NewWallet().PublicKey(), + ProbePK: solana.NewWallet().PublicKey(), + DevicePK: solana.NewWallet().PublicKey(), + ServiceabilityGlobalStatePK: solana.NewWallet().PublicKey(), + } + + instr, err := geolocation.BuildRemoveParentDeviceInstruction(programID, config) + require.NoError(t, err) + require.NotNil(t, instr) + + accounts := instr.Accounts() + require.Len(t, accounts, 5, "should have 5 accounts") + instrData, err := instr.Data() + require.NoError(t, err) + require.NotEmpty(t, instrData, "instruction data should not be empty") + require.Equal(t, programID, instr.ProgramID(), "program ID should match") +} + +// TestSDK_Geolocation_BuildRemoveParentDeviceInstruction_MissingRequiredFields tests validation errors +func TestSDK_Geolocation_BuildRemoveParentDeviceInstruction_MissingRequiredFields(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + + tests := []struct { + name string + config geolocation.RemoveParentDeviceInstructionConfig + errMsg string + }{ + { + name: "missing payer", + config: geolocation.RemoveParentDeviceInstructionConfig{ + Payer: solana.PublicKey{}, + ProbePK: solana.NewWallet().PublicKey(), + DevicePK: solana.NewWallet().PublicKey(), + ServiceabilityGlobalStatePK: solana.NewWallet().PublicKey(), + }, + errMsg: "payer public key is required", + }, + { + name: "missing probe public key", + config: geolocation.RemoveParentDeviceInstructionConfig{ + Payer: solana.NewWallet().PublicKey(), + ProbePK: solana.PublicKey{}, + DevicePK: solana.NewWallet().PublicKey(), + ServiceabilityGlobalStatePK: solana.NewWallet().PublicKey(), + }, + errMsg: "probe public key is required", + }, + { + name: "missing device public key", + config: geolocation.RemoveParentDeviceInstructionConfig{ + Payer: solana.NewWallet().PublicKey(), + ProbePK: solana.NewWallet().PublicKey(), + DevicePK: solana.PublicKey{}, + ServiceabilityGlobalStatePK: solana.NewWallet().PublicKey(), + }, + errMsg: "device public key is required", + }, + { + name: "missing serviceability global state public key", + config: geolocation.RemoveParentDeviceInstructionConfig{ + Payer: solana.NewWallet().PublicKey(), + ProbePK: solana.NewWallet().PublicKey(), + DevicePK: solana.NewWallet().PublicKey(), + ServiceabilityGlobalStatePK: solana.PublicKey{}, + }, + errMsg: "serviceability global state public key is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := geolocation.BuildRemoveParentDeviceInstruction(programID, tt.config) + require.Error(t, err) + require.Contains(t, err.Error(), tt.errMsg) + }) + } +} diff --git a/sdk/geolocation/go/main_test.go b/sdk/geolocation/go/main_test.go index 1bd08b50a5..98ec9565a8 100644 --- a/sdk/geolocation/go/main_test.go +++ b/sdk/geolocation/go/main_test.go @@ -41,10 +41,30 @@ func TestMain(m *testing.M) { type mockRPCClient struct { geolocation.RPCClient + 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) GetAccountInfoFunc func(context.Context, solana.PublicKey) (*solanarpc.GetAccountInfoResult, error) GetProgramAccountsWithOptsFunc func(context.Context, solana.PublicKey, *solanarpc.GetProgramAccountsOpts) (solanarpc.GetProgramAccountsResult, error) } +func (m *mockRPCClient) GetLatestBlockhash(ctx context.Context, ct solanarpc.CommitmentType) (*solanarpc.GetLatestBlockhashResult, error) { + return m.GetLatestBlockhashFunc(ctx, ct) +} + +func (m *mockRPCClient) SendTransactionWithOpts(ctx context.Context, tx *solana.Transaction, opts solanarpc.TransactionOpts) (solana.Signature, error) { + return m.SendTransactionWithOptsFunc(ctx, tx, opts) +} + +func (m *mockRPCClient) GetSignatureStatuses(ctx context.Context, search bool, sigs ...solana.Signature) (*solanarpc.GetSignatureStatusesResult, error) { + return m.GetSignatureStatusesFunc(ctx, search, sigs...) +} + +func (m *mockRPCClient) GetTransaction(ctx context.Context, sig solana.Signature, opts *solanarpc.GetTransactionOpts) (*solanarpc.GetTransactionResult, error) { + return m.GetTransactionFunc(ctx, sig, opts) +} + func (m *mockRPCClient) GetAccountInfo(ctx context.Context, account solana.PublicKey) (*solanarpc.GetAccountInfoResult, error) { return m.GetAccountInfoFunc(ctx, account) } diff --git a/sdk/geolocation/go/remove_parent_device.go b/sdk/geolocation/go/remove_parent_device.go new file mode 100644 index 0000000000..0ac08e7b69 --- /dev/null +++ b/sdk/geolocation/go/remove_parent_device.go @@ -0,0 +1,70 @@ +package geolocation + +import ( + "fmt" + + "github.com/gagliardetto/solana-go" + "github.com/near/borsh-go" +) + +type RemoveParentDeviceInstructionConfig struct { + Payer solana.PublicKey + ProbePK solana.PublicKey + DevicePK solana.PublicKey + ServiceabilityGlobalStatePK solana.PublicKey +} + +func (c *RemoveParentDeviceInstructionConfig) Validate() error { + if c.Payer.IsZero() { + return fmt.Errorf("payer public key is required") + } + if c.ProbePK.IsZero() { + return fmt.Errorf("probe public key is required") + } + if c.DevicePK.IsZero() { + return fmt.Errorf("device public key is required") + } + if c.ServiceabilityGlobalStatePK.IsZero() { + return fmt.Errorf("serviceability global state public key is required") + } + return nil +} + +func BuildRemoveParentDeviceInstruction( + programID solana.PublicKey, + config RemoveParentDeviceInstructionConfig, +) (solana.Instruction, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("failed to validate config: %w", err) + } + + data, err := borsh.Serialize(struct { + Discriminator uint8 + DevicePK solana.PublicKey + }{ + Discriminator: uint8(RemoveParentDeviceInstructionIndex), + DevicePK: config.DevicePK, + }) + if err != nil { + return nil, fmt.Errorf("failed to serialize args: %w", err) + } + + programConfigPDA, _, err := DeriveProgramConfigPDA(programID) + if err != nil { + return nil, fmt.Errorf("failed to derive program config PDA: %w", err) + } + + accounts := []*solana.AccountMeta{ + {PublicKey: config.ProbePK, IsSigner: false, IsWritable: true}, + {PublicKey: programConfigPDA, IsSigner: false, IsWritable: false}, + {PublicKey: config.ServiceabilityGlobalStatePK, IsSigner: false, IsWritable: false}, + {PublicKey: config.Payer, 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/rpc.go b/sdk/geolocation/go/rpc.go index 3fa2e03497..5193403b6a 100644 --- a/sdk/geolocation/go/rpc.go +++ b/sdk/geolocation/go/rpc.go @@ -7,8 +7,13 @@ import ( solanarpc "github.com/gagliardetto/solana-go/rpc" ) -// RPCClient is an interface for reading accounts from the Solana RPC server. +// RPCClient is an interface for interacting with the Solana RPC server. type RPCClient interface { + SendTransaction(context.Context, *solana.Transaction) (solana.Signature, error) + SendTransactionWithOpts(context.Context, *solana.Transaction, solanarpc.TransactionOpts) (solana.Signature, error) + GetLatestBlockhash(context.Context, solanarpc.CommitmentType) (*solanarpc.GetLatestBlockhashResult, 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) (*solanarpc.GetTransactionResult, error) 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) } diff --git a/sdk/geolocation/go/update_geo_probe.go b/sdk/geolocation/go/update_geo_probe.go new file mode 100644 index 0000000000..bc601e5675 --- /dev/null +++ b/sdk/geolocation/go/update_geo_probe.go @@ -0,0 +1,72 @@ +package geolocation + +import ( + "fmt" + + "github.com/gagliardetto/solana-go" + "github.com/near/borsh-go" +) + +type UpdateGeoProbeInstructionConfig struct { + Payer solana.PublicKey + ProbePK solana.PublicKey + ServiceabilityGlobalStatePK solana.PublicKey + PublicIP *[4]uint8 + LocationOffsetPort *uint16 + MetricsPublisherPK *solana.PublicKey +} + +func (c *UpdateGeoProbeInstructionConfig) Validate() error { + if c.Payer.IsZero() { + return fmt.Errorf("payer public key is required") + } + if c.ProbePK.IsZero() { + return fmt.Errorf("probe public key is required") + } + if c.ServiceabilityGlobalStatePK.IsZero() { + return fmt.Errorf("serviceability global state public key is required") + } + return nil +} + +func BuildUpdateGeoProbeInstruction( + programID solana.PublicKey, + config UpdateGeoProbeInstructionConfig, +) (solana.Instruction, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("failed to validate config: %w", err) + } + + data, err := borsh.Serialize(struct { + Discriminator uint8 + PublicIP *[4]uint8 `borsh_optional:"true"` + LocationOffsetPort *uint16 `borsh_optional:"true"` + MetricsPublisherPK *solana.PublicKey `borsh_optional:"true"` + }{ + Discriminator: uint8(UpdateGeoProbeInstructionIndex), + PublicIP: config.PublicIP, + LocationOffsetPort: config.LocationOffsetPort, + MetricsPublisherPK: config.MetricsPublisherPK, + }) + if err != nil { + return nil, fmt.Errorf("failed to serialize args: %w", err) + } + + programConfigPDA, _, err := DeriveProgramConfigPDA(programID) + if err != nil { + return nil, fmt.Errorf("failed to derive program config PDA: %w", err) + } + + accounts := []*solana.AccountMeta{ + {PublicKey: config.ProbePK, IsSigner: false, IsWritable: true}, + {PublicKey: programConfigPDA, IsSigner: false, IsWritable: false}, + {PublicKey: config.ServiceabilityGlobalStatePK, IsSigner: false, IsWritable: false}, + {PublicKey: config.Payer, IsSigner: true, IsWritable: true}, + } + + return &solana.GenericInstruction{ + ProgID: programID, + AccountValues: accounts, + DataBytes: data, + }, nil +} diff --git a/sdk/geolocation/go/update_program_config.go b/sdk/geolocation/go/update_program_config.go new file mode 100644 index 0000000000..fa79d91b12 --- /dev/null +++ b/sdk/geolocation/go/update_program_config.go @@ -0,0 +1,68 @@ +package geolocation + +import ( + "fmt" + + "github.com/gagliardetto/solana-go" + "github.com/near/borsh-go" +) + +type UpdateProgramConfigInstructionConfig struct { + Payer solana.PublicKey + Version *uint32 + MinCompatibleVersion *uint32 +} + +func (c *UpdateProgramConfigInstructionConfig) Validate() error { + if c.Payer.IsZero() { + return fmt.Errorf("payer public key is required") + } + return nil +} + +func BuildUpdateProgramConfigInstruction( + programID solana.PublicKey, + config UpdateProgramConfigInstructionConfig, +) (solana.Instruction, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("failed to validate config: %w", err) + } + + type updateArgs struct { + Discriminator uint8 + Version *uint32 `borsh_optional:"true"` + MinCompatibleVersion *uint32 `borsh_optional:"true"` + } + + data, err := borsh.Serialize(updateArgs{ + Discriminator: uint8(UpdateProgramConfigInstructionIndex), + Version: config.Version, + MinCompatibleVersion: config.MinCompatibleVersion, + }) + if err != nil { + return nil, fmt.Errorf("failed to serialize args: %w", err) + } + + programConfigPDA, _, err := DeriveProgramConfigPDA(programID) + if err != nil { + return nil, fmt.Errorf("failed to derive program config PDA: %w", err) + } + + programDataPDA, _, err := DeriveProgramDataPDA(programID) + if err != nil { + return nil, fmt.Errorf("failed to derive program data PDA: %w", err) + } + + accounts := []*solana.AccountMeta{ + {PublicKey: programConfigPDA, IsSigner: false, IsWritable: true}, + {PublicKey: programDataPDA, IsSigner: false, IsWritable: false}, + {PublicKey: config.Payer, IsSigner: true, IsWritable: true}, + {PublicKey: solana.SystemProgramID, IsSigner: false, IsWritable: false}, + } + + return &solana.GenericInstruction{ + ProgID: programID, + AccountValues: accounts, + DataBytes: data, + }, nil +}