diff --git a/cmd/rewards_cli/cmd/register.go b/cmd/rewards_cli/cmd/register.go new file mode 100644 index 00000000..fe079cda --- /dev/null +++ b/cmd/rewards_cli/cmd/register.go @@ -0,0 +1,119 @@ +package cmd + +import ( + "fmt" + + "api.audius.co/config" + "api.audius.co/logging" + "api.audius.co/solana/spl" + "api.audius.co/solana/spl/programs/reward_manager" + "github.com/ethereum/go-ethereum/common" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + "github.com/spf13/cobra" + "go.uber.org/zap" +) + +var registerCmd = &cobra.Command{ + Use: "register [address] [operator]", + Short: "Register a single Ethereum address as a sender in the rewards program", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + // Get flags + rpcOverride, _ := cmd.Flags().GetString("rpc") + openAudioURLOverride, _ := cmd.Flags().GetString("openAudioURL") + keypairPath, _ := cmd.Flags().GetString("keypair") + dryRun, _ := cmd.Flags().GetBool("dry-run") + + if dryRun { + return fmt.Errorf("dry-run mode not supported for register command") + } + + // Initialize logger + cfg := config.Cfg + logger := logging.NewZapLogger(cfg) + + // Initialize config based on environment + if rpcOverride != "" { + cfg.SolanaConfig.RpcProviders = []string{rpcOverride} + } + if openAudioURLOverride != "" { + cfg.OpenAudioURLs = []string{openAudioURLOverride} + } + if keypairPath != "" { + privKey, err := solana.PrivateKeyFromSolanaKeygenFile(keypairPath) + if err != nil { + return fmt.Errorf("failed to load keypair from %s: %w", keypairPath, err) + } + payer, err := solana.WalletFromPrivateKeyBase58(privKey.String()) + if err != nil { + return fmt.Errorf("failed to create wallet from private key: %w", err) + } + cfg.SolanaConfig.FeePayers = []solana.Wallet{*payer} + } + + address := args[0] + operator := args[1] + + ctx := cmd.Context() + payer := cfg.SolanaConfig.FeePayers[0] + validators := cfg.ArtistCoinRewardsStaticSenders + transactionSender := spl.NewTransactionSender(cfg.SolanaConfig.FeePayers, cfg.SolanaConfig.RpcProviders) + + logger.Debug("Getting attestations...") + attestations, err := getSenderAttestations(ctx, validators, address, cfg.SolanaConfig.RewardManagerState, logger) + if err != nil { + return fmt.Errorf("failed to get sender attestations: %w", err) + } + logger.Debug("Got attestations", zap.Int("count", len(attestations))) + + for _, a := range attestations { + sender, _, err := reward_manager.DeriveSenderAccount(reward_manager.ProgramID, cfg.SolanaConfig.RewardManagerState, common.HexToAddress(a.Owner)) + if err != nil { + return fmt.Errorf("failed to derive sender account: %w", err) + } + logger.Debug("Attestation", + zap.String("attester", a.Owner), + zap.String("senderAccount", sender.String())) + } + + logger.Debug("Building create sender transaction...") + tx, err := buildCreateSenderPublicTransaction( + cfg.SolanaConfig.RewardManagerState, + payer.PublicKey(), + &config.Node{DelegateWallet: address, Owner: operator}, + attestations, + ) + if err != nil { + return fmt.Errorf("failed to build create sender transaction: %w", err) + } + + logger.Debug("Adding priority fees to transaction...") + err = transactionSender.AddPriorityFees(ctx, tx, spl.AddPriorityFeesParams{}) + if err != nil { + return fmt.Errorf("failed to add priority fees: %w", err) + } + logger.Debug("Priority fees added") + + tx.SetFeePayer(payer.PublicKey()) + txBuilt, err := tx.Build() + if err != nil { + return fmt.Errorf("failed to build transaction: %w", err) + } + fmt.Println(txBuilt.String()) + + logger.Info("Sending create sender transaction...") + sig, err := transactionSender.SendTransactionWithRetries( + ctx, tx, rpc.CommitmentConfirmed, rpc.TransactionOpts{}) + if err != nil { + return fmt.Errorf("failed to send create sender transaction: %w", err) + } + logger.Info("Successfully registered sender", zap.String("signature", sig.String())) + + return nil + }, +} + +func init() { + rootCmd.AddCommand(registerCmd) +} diff --git a/cmd/rewards_cli/cmd/root.go b/cmd/rewards_cli/cmd/root.go new file mode 100644 index 00000000..7f83513b --- /dev/null +++ b/cmd/rewards_cli/cmd/root.go @@ -0,0 +1,26 @@ +package cmd + +import ( + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "rewards_cli", + Short: "Manages the Reward Manager program's registered senders and related lookup table", +} + +func init() { + rootCmd.PersistentFlags().StringP("rpc", "r", "", "Solana RPC endpoint (overrides env default)") + rootCmd.PersistentFlags().StringP("openAudioURL", "o", "", "OpenAudio SDK URL (overrides env default)") + rootCmd.PersistentFlags().StringP("keypair", "k", "", "Fee payer keypair file (overrides env default)") + rootCmd.PersistentFlags().Bool("dry-run", false, "Dry run mode - check accounts without registering") +} + +func Execute() { + err := rootCmd.Execute() + if err != nil { + os.Exit(1) + } +} diff --git a/cmd/rewards_cli/cmd/sync.go b/cmd/rewards_cli/cmd/sync.go new file mode 100644 index 00000000..0d7ec85d --- /dev/null +++ b/cmd/rewards_cli/cmd/sync.go @@ -0,0 +1,483 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "slices" + "sync" + + "api.audius.co/api" + "api.audius.co/config" + "api.audius.co/logging" + "api.audius.co/solana/spl" + "api.audius.co/solana/spl/programs/reward_manager" + "api.audius.co/solana/spl/programs/secp256k1" + "connectrpc.com/connect" + "github.com/AlecAivazis/survey/v2" + corev1 "github.com/OpenAudio/go-openaudio/pkg/api/core/v1" + ethv1 "github.com/OpenAudio/go-openaudio/pkg/api/eth/v1" + "github.com/OpenAudio/go-openaudio/pkg/sdk" + "github.com/ethereum/go-ethereum/common" + bin "github.com/gagliardetto/binary" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + "github.com/spf13/cobra" + "go.uber.org/zap" +) + +// ValidatorSenderInfo contains information about a validator and its sender account +type ValidatorSenderInfo struct { + Validator *config.Node + Sender *SenderWithPubkey +} + +var syncCmd = &cobra.Command{ + Use: "sync", + Short: "Register all senders for validators", + RunE: func(cmd *cobra.Command, args []string) error { + // Get flags + rpcOverride, _ := cmd.Flags().GetString("rpc") + openAudioURLOverride, _ := cmd.Flags().GetString("openAudioURL") + dryRun, _ := cmd.Flags().GetBool("dry-run") + keypairPath, _ := cmd.Flags().GetString("keypair") + + // Initialize logger + cfg := config.Cfg + logger := logging.NewZapLogger(cfg) + logger.Info("Starting register_senders", + zap.Bool("dryRun", dryRun), + ) + + // Initialize config based on environment + if rpcOverride != "" { + cfg.SolanaConfig.RpcProviders = []string{rpcOverride} + } + if openAudioURLOverride != "" { + cfg.OpenAudioURLs = []string{openAudioURLOverride} + } + if keypairPath != "" { + privKey, err := solana.PrivateKeyFromSolanaKeygenFile(keypairPath) + if err != nil { + return fmt.Errorf("failed to load keypair from %s: %w", keypairPath, err) + } + payer, err := solana.WalletFromPrivateKeyBase58(privKey.String()) + if err != nil { + return fmt.Errorf("failed to create wallet from private key: %w", err) + } + cfg.SolanaConfig.FeePayers = []solana.Wallet{*payer} + } + return RunRegisterSenders(cmd.Context(), cfg, dryRun, logger) + }, +} + +func init() { + rootCmd.AddCommand(syncCmd) +} + +func RunRegisterSenders(ctx context.Context, cfg config.Config, dryRun bool, logger *zap.Logger) error { + logger.Debug("Initializing clients...") + if len(cfg.OpenAudioURLs) == 0 { + return fmt.Errorf("no OpenAudio URLs configured") + } + openAudioSDK := sdk.NewOpenAudioSDK(cfg.OpenAudioURLs[0]) + + if len(cfg.SolanaConfig.RpcProviders) == 0 { + return fmt.Errorf("no Solana RPC providers configured") + } + rpcClient := rpc.New(cfg.SolanaConfig.RpcProviders[0]) + + // Ensure we use the right program ID for the environment + reward_manager.SetProgramID(cfg.SolanaConfig.RewardManagerProgramID) + + if len(cfg.SolanaConfig.FeePayers) == 0 { + return fmt.Errorf("no fee payer wallets configured") + } + payer := cfg.SolanaConfig.FeePayers[0] + transactionSender := spl.NewTransactionSender(cfg.SolanaConfig.FeePayers, cfg.SolanaConfig.RpcProviders) + + logger.Debug("Fetching oracles...") + oracles, err := getAntiAbuseOracles(cfg.AntiAbuseOracles) + if err != nil { + logger.Warn("Failed to get anti-abuse oracles", zap.Error(err)) + if !dryRun { + var continueWithout bool + prompt := &survey.Confirm{ + Message: fmt.Sprintf("Failed to get some anti-abuse oracles: %v. Continue without them?", err), + Default: false, + } + if err := survey.AskOne(prompt, &continueWithout); err != nil || !continueWithout { + return fmt.Errorf("failed to get anti-abuse oracles: %w", err) + } + } + logger.Info("Continuing without missing anti-abuse oracles. This may result in attempting to register/deregister oracle senders.") + } + logger.Debug("Found oracles", zap.Int("count", len(oracles))) + + // Get list of validators and senders and diff them + logger.Debug("Fetching validators...") + validators, err := getValidators(ctx, openAudioSDK) + if err != nil { + return fmt.Errorf("failed to get validators: %w", err) + } + logger.Debug("Found validators", zap.Int("count", len(validators))) + + logger.Debug("Fetching senders...") + senders, err := getSenders(ctx, rpcClient, reward_manager.ProgramID, cfg.SolanaConfig.RewardManagerState) + if err != nil { + return fmt.Errorf("failed to get senders: %w", err) + } + logger.Debug("Found senders", zap.Int("count", len(senders))) + + // Map validators and senders by delegate wallet / eth address + mapped := make(map[string]ValidatorSenderInfo) + for _, v := range validators { + mapped[v.DelegateWallet] = ValidatorSenderInfo{ + Validator: &v, + } + } + for _, s := range senders { + if existing, ok := mapped[s.SenderEthAddress.String()]; ok { + mapped[s.SenderEthAddress.String()] = ValidatorSenderInfo{ + Validator: existing.Validator, + Sender: &s, + } + } else { + mapped[s.SenderEthAddress.String()] = ValidatorSenderInfo{ + Sender: &s, + } + } + } + + toDeregister := make([]ValidatorSenderInfo, 0) + toRegister := make([]ValidatorSenderInfo, 0) + validSenders := make([]ValidatorSenderInfo, 0) + for _, info := range mapped { + if info.Validator == nil { + if slices.ContainsFunc(oracles, func(n config.Node) bool { + return n.DelegateWallet == info.Sender.SenderEthAddress.String() + }) { + logger.Debug("Skipping deregistration of oracle sender", + zap.String("sender", info.Sender.Pubkey.String()), + zap.String("owner", info.Sender.OwnerEthAddress.String()), + zap.String("ethAddress", info.Sender.SenderEthAddress.String()), + ) + validSenders = append(validSenders, info) + continue + } + toDeregister = append(toDeregister, info) + } else if info.Sender == nil { + toRegister = append(toRegister, info) + } else { + validSenders = append(validSenders, info) + } + } + + registered := 0 + for _, info := range toRegister { + logger := logger.With( + zap.String("owner", info.Validator.Owner), + zap.String("ethAddress", info.Validator.DelegateWallet), + zap.String("endpoint", info.Validator.Endpoint), + ) + + if dryRun { + logger.Info("Would register sender for validator") + continue + } + + logger.Debug("Registering sender for validator") + + attestations, err := getSenderAttestations(ctx, cfg.ArtistCoinRewardsStaticSenders, info.Validator.DelegateWallet, cfg.SolanaConfig.RewardManagerState, logger) + if err != nil { + logger.Error("Failed to get sender attestations", zap.Error(err)) + continue + } + + tx, err := buildCreateSenderPublicTransaction( + cfg.SolanaConfig.RewardManagerState, payer.PublicKey(), info.Validator, attestations, + ) + if err != nil { + logger.Error("Failed to build CreateSenderPublic transaction", zap.Error(err)) + continue + } + + err = transactionSender.AddPriorityFees(ctx, tx, spl.AddPriorityFeesParams{}) + if err != nil { + logger.Error("Failed to add priority fees to transaction", zap.Error(err)) + continue + } + tx.SetFeePayer(payer.PublicKey()) + + sig, err := transactionSender.SendTransactionWithRetries( + ctx, tx, rpc.CommitmentConfirmed, rpc.TransactionOpts{}) + if err != nil { + logger.Error("Failed to send CreateSenderPublic transaction", zap.Error(err)) + continue + } + logger.Info("Successfully registered sender for validator", zap.String("signature", sig.String())) + registered++ + } + + deregistered := 0 + for _, info := range toDeregister { + logger := logger.With( + zap.String("sender", info.Sender.Pubkey.String()), + zap.String("owner", info.Sender.OwnerEthAddress.String()), + zap.String("ethAddress", info.Sender.SenderEthAddress.String()), + ) + + if dryRun { + logger.Info("Would deregister sender for validator") + continue + } + + logger.Warn("Not deregistering sender for validator - deregistration not yet implemented") + } + + logger.Info("Summary", + zap.Int("totalValidators", len(mapped)), + zap.Int("neededRegistration", len(toRegister)), + zap.Int("registered", registered), + zap.Int("neededDeregistration", len(toDeregister)), + zap.Int("deregistered", deregistered), + zap.Int("noChange", len(validSenders)), + ) + + return nil +} + +func getValidators(ctx context.Context, sdk *sdk.OpenAudioSDK) ([]config.Node, error) { + resp, err := sdk.Eth.GetRegisteredEndpoints(ctx, connect.NewRequest(ðv1.GetRegisteredEndpointsRequest{})) + if err != nil { + return nil, fmt.Errorf("failed to get registered endpoints: %w", err) + } + if resp == nil || resp.Msg == nil { + return nil, fmt.Errorf("GetRegisteredEndpoints returned nil response") + } + + var nodes []config.Node + for _, node := range resp.Msg.Endpoints { + nodes = append(nodes, config.Node{ + Id: fmt.Sprintf("%d", node.Id), + Endpoint: node.Endpoint, + DelegateWallet: node.DelegateWallet, + Owner: node.Owner, + ServiceType: node.ServiceType, + }) + } + + return nodes, nil +} + +type SenderWithPubkey struct { + Pubkey solana.PublicKey + reward_manager.SenderAccountData +} + +func getSenders( + ctx context.Context, + rpcClient *rpc.Client, + programId solana.PublicKey, + rewardManagerState solana.PublicKey, +) ([]SenderWithPubkey, error) { + res, err := rpcClient.GetProgramAccountsWithOpts(ctx, programId, &rpc.GetProgramAccountsOpts{ + Encoding: solana.EncodingBase64, + Filters: []rpc.RPCFilter{ + { + DataSize: 73, // Size of sender account + }, + { + Memcmp: &rpc.RPCFilterMemcmp{ + Offset: 1, // Offset of is_validator field + Bytes: rewardManagerState[:], + }, + }, + }, + }) + if err != nil { + return nil, err + } + + out := make([]SenderWithPubkey, 0, len(res)) + for _, acc := range res { + var data reward_manager.SenderAccountData + err := bin.NewBinDecoder(acc.Account.Data.GetBinary()).Decode(&data) + if err != nil { + return nil, err + } + out = append(out, SenderWithPubkey{ + Pubkey: acc.Pubkey, + SenderAccountData: data, + }) + } + return out, nil +} + +func getAntiAbuseOracles(antiAbuseOracleEndpoints []string) ([]config.Node, error) { + oracles := make([]config.Node, len(antiAbuseOracleEndpoints)) + var wg sync.WaitGroup + var mu sync.Mutex + var errs []error + + for i, endpoint := range antiAbuseOracleEndpoints { + wg.Go(func() { + resp, err := http.Get(endpoint + "/health_check") + if err != nil { + mu.Lock() + errs = append(errs, fmt.Errorf("failed to fetch from %s: %w", endpoint, err)) + mu.Unlock() + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + mu.Lock() + errs = append(errs, fmt.Errorf("failed to read response from %s: %w", endpoint, err)) + mu.Unlock() + return + } + + if resp.StatusCode != 200 { + mu.Lock() + errs = append(errs, fmt.Errorf("failed to get oracle from %s: %s: %s", endpoint, resp.Status, string(body))) + mu.Unlock() + return + } + + health := &api.HealthCheckResponse{} + err = json.Unmarshal(body, health) + if err != nil { + mu.Lock() + errs = append(errs, fmt.Errorf("failed to unmarshal response from %s: %w", endpoint, err)) + mu.Unlock() + return + } + + oracles[i] = config.Node{ + Endpoint: endpoint, + DelegateWallet: health.AntiAbuseWalletPubkey, + Owner: health.AntiAbuseWalletPubkey, + ServiceType: "anti_abuse_oracle", + } + }) + } + + wg.Wait() + + if len(errs) > 0 { + return oracles, fmt.Errorf("encountered %d errors: %v", len(errs), errs) + } + + return oracles, nil +} + +func getSenderAttestation(ctx context.Context, node config.Node, senderEthAddress string, rewardManagerState solana.PublicKey) (*corev1.GetRewardSenderAttestationResponse, error) { + openAudioSdk := sdk.NewOpenAudioSDK(node.Endpoint) + res, err := openAudioSdk.Core.GetRewardSenderAttestation(ctx, connect.NewRequest(&corev1.GetRewardSenderAttestationRequest{ + Address: senderEthAddress, + RewardsManagerPubkey: rewardManagerState.String(), + })) + if err != nil { + return nil, fmt.Errorf("failed to get reward sender attestation from OpenAudio: %w", err) + } + if res == nil || res.Msg == nil { + return nil, fmt.Errorf("GetRewardSenderAttestation returned nil response") + } + return res.Msg, nil +} + +func getSenderAttestations(ctx context.Context, nodes []config.Node, senderEthAddress string, rewardManagerState solana.PublicKey, logger *zap.Logger) (map[string]*corev1.GetRewardSenderAttestationResponse, error) { + attestations := make(map[string]*corev1.GetRewardSenderAttestationResponse) + mapped := make(map[string][]config.Node) + for _, node := range nodes { + mapped[node.Owner] = append(mapped[node.Owner], node) + } + batchSize := 3 + maxAttempts := 10 + var wg sync.WaitGroup + var mu sync.Mutex + for j := 0; j < maxAttempts && len(attestations) < 3 && len(mapped) > 0; j++ { + i := 0 + for _, nodeList := range mapped { + node := nodeList[0] + nl := nodeList + i++ + wg.Go(func() { + attestation, err := getSenderAttestation(ctx, node, senderEthAddress, rewardManagerState) + if err != nil { + logger.Warn("Failed to get attestation", zap.String("from", node.Endpoint), zap.Error(err)) + return + } + + mu.Lock() + + attestations[node.DelegateWallet] = attestation + + if len(nl) > 1 { + mapped[node.Owner] = nl[1:] + } else { + delete(mapped, node.Owner) + } + + mu.Unlock() + }) + + if i >= batchSize { + wg.Wait() + i = 0 + } + + if len(attestations) >= 3 { + break + } + } + wg.Wait() + } + if len(attestations) < 3 { + return nil, fmt.Errorf("failed to get enough attestations for sender %s", senderEthAddress) + } + return attestations, nil +} + +func buildCreateSenderPublicTransaction( + rewardManagerState solana.PublicKey, + payer solana.PublicKey, + validator *config.Node, + attestations map[string]*corev1.GetRewardSenderAttestationResponse, +) (*solana.TransactionBuilder, error) { + tx := solana.NewTransactionBuilder() + + ethAddress := common.HexToAddress(validator.DelegateWallet) + operatorEthAddress := common.HexToAddress(validator.Owner) + + // Build Secp256k1 instructions + var b bytes.Buffer + b.WriteString("add") + b.Write(rewardManagerState[:]) + b.Write(ethAddress[:]) + message := b.Bytes() + i := uint8(0) + for _, attestation := range attestations { + sig := common.Hex2Bytes(attestation.Attestation) + inst := secp256k1.NewSecp256k1Instruction(common.HexToAddress(attestation.Owner), message, sig, i) + tx.AddInstruction(inst.Build()) + i++ + } + + attesterEthAddresses := make([]common.Address, 0, len(attestations)) + for ethAddr := range attestations { + attesterEthAddresses = append(attesterEthAddresses, common.HexToAddress(ethAddr)) + } + createSenderPublicInst, err := reward_manager.NewCreateSenderPublicInstruction(ethAddress, operatorEthAddress, rewardManagerState, payer, attesterEthAddresses...) + if err != nil { + return nil, fmt.Errorf("failed to build CreateSenderPublic instruction: %w", err) + } + tx.AddInstruction(createSenderPublicInst.Build()) + + return tx, nil +} diff --git a/cmd/rewards_cli/cmd/update_lookup.go b/cmd/rewards_cli/cmd/update_lookup.go new file mode 100644 index 00000000..9015adee --- /dev/null +++ b/cmd/rewards_cli/cmd/update_lookup.go @@ -0,0 +1,257 @@ +package cmd + +import ( + "context" + "fmt" + + "api.audius.co/config" + "api.audius.co/logging" + "api.audius.co/solana/spl" + "api.audius.co/solana/spl/programs/address_lookup_table" + "api.audius.co/solana/spl/programs/reward_manager" + "github.com/OpenAudio/go-openaudio/pkg/sdk" + "github.com/ethereum/go-ethereum/common" + bin "github.com/gagliardetto/binary" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + "github.com/spf13/cobra" + "go.uber.org/zap" +) + +var updateLookupCmd = &cobra.Command{ + Use: "update-lookup", + Short: "Update the address lookup table with new sender accounts", + Long: `Updates the reward manager's address lookup table by adding sender accounts +for any newly registered validators or anti-abuse oracles that are missing.`, + RunE: func(cmd *cobra.Command, args []string) error { + // Get flags + rpcOverride, _ := cmd.Flags().GetString("rpc") + openAudioURLOverride, _ := cmd.Flags().GetString("openAudioURL") + keypairPath, _ := cmd.Flags().GetString("keypair") + dryRun, _ := cmd.Flags().GetBool("dry-run") + + // Initialize logger + cfg := config.Cfg + logger := logging.NewZapLogger(cfg) + + // Initialize config based on environment + if rpcOverride != "" { + cfg.SolanaConfig.RpcProviders = []string{rpcOverride} + } + if openAudioURLOverride != "" { + cfg.OpenAudioURLs = []string{openAudioURLOverride} + } + if keypairPath != "" { + privKey, err := solana.PrivateKeyFromSolanaKeygenFile(keypairPath) + if err != nil { + return fmt.Errorf("failed to load keypair from %s: %w", keypairPath, err) + } + payer, err := solana.WalletFromPrivateKeyBase58(privKey.String()) + if err != nil { + return fmt.Errorf("failed to create wallet from private key: %w", err) + } + cfg.SolanaConfig.FeePayers = []solana.Wallet{*payer} + } + + if len(cfg.SolanaConfig.FeePayers) == 0 { + return fmt.Errorf("no fee payer configured") + } + + ctx := cmd.Context() + return runUpdateLookup(ctx, cfg, dryRun, logger) + }, +} + +func init() { + updateLookupCmd.Flags().Bool("dry-run", false, "Show what would be added without making changes") + rootCmd.AddCommand(updateLookupCmd) +} + +func runUpdateLookup(ctx context.Context, cfg config.Config, dryRun bool, logger *zap.Logger) error { + // Initialize clients + if len(cfg.OpenAudioURLs) == 0 { + return fmt.Errorf("no OpenAudio URLs configured") + } + openAudioSDK := sdk.NewOpenAudioSDK(cfg.OpenAudioURLs[0]) + + if len(cfg.SolanaConfig.RpcProviders) == 0 { + return fmt.Errorf("no Solana RPC providers configured") + } + rpcClient := rpc.New(cfg.SolanaConfig.RpcProviders[0]) + + // Initialize reward manager + reward_manager.SetProgramID(cfg.SolanaConfig.RewardManagerProgramID) + rewardManagerClient, err := reward_manager.NewRewardManagerClient( + rpcClient, + cfg.SolanaConfig.RewardManagerProgramID, + cfg.SolanaConfig.RewardManagerState, + cfg.SolanaConfig.RewardManagerLookupTable, + logger, + ) + if err != nil { + return fmt.Errorf("failed to initialize reward manager client: %w", err) + } + + lookupTableAddress := cfg.SolanaConfig.RewardManagerLookupTable + logger.Info("Fetching lookup table", zap.String("address", lookupTableAddress.String())) + + // Get current lookup table contents + lookupTableAccount, err := rpcClient.GetAccountInfoWithOpts(ctx, lookupTableAddress, &rpc.GetAccountInfoOpts{ + Encoding: solana.EncodingBase64, + }) + if err != nil { + return fmt.Errorf("failed to get lookup table account: %w", err) + } + + if lookupTableAccount == nil || lookupTableAccount.Value == nil { + return fmt.Errorf("lookup table does not exist at %s", lookupTableAddress.String()) + } + + var lookupTable spl.AddressLookupTable + err = lookupTable.UnmarshalWithDecoder(bin.NewBinDecoder(lookupTableAccount.Value.Data.GetBinary())) + if err != nil { + return fmt.Errorf("failed to decode lookup table: %w", err) + } + + logger.Info("Found lookup table", + zap.Int("existingAccounts", len(lookupTable.Addresses)), + ) + + // Build map of existing accounts for quick lookup + existingAccounts := make(map[string]bool) + for _, addr := range lookupTable.Addresses { + existingAccounts[addr.String()] = true + } + + logger.Info("Fetching registered validators...") + validators, err := getValidators(ctx, openAudioSDK) + if err != nil { + return fmt.Errorf("failed to get registered validators: %w", err) + } + logger.Info("Found validators", zap.Int("count", len(validators))) + + // Derive sender addresses for validators + authority := rewardManagerClient.GetAuthority() + programID := cfg.SolanaConfig.RewardManagerProgramID + + var validatorSendersToAdd []solana.PublicKey + for _, validator := range validators { + addr := common.HexToAddress(validator.DelegateWallet) + senderAddr, _, err := reward_manager.DeriveSenderAccount(programID, authority, addr) + if err != nil { + logger.Warn("Failed to derive sender account", + zap.String("owner", validator.Owner), + zap.String("ethAddress", validator.DelegateWallet), + zap.String("endpoint", validator.Endpoint), + zap.Error(err), + ) + continue + } + + if !existingAccounts[senderAddr.String()] { + validatorSendersToAdd = append(validatorSendersToAdd, senderAddr) + logger.Info("New validator sender to add", + zap.String("owner", validator.Owner), + zap.String("ethAddress", validator.DelegateWallet), + zap.String("endpoint", validator.Endpoint), + zap.String("sender", senderAddr.String()), + ) + } + } + + // Derive sender addresses for anti-abuse oracles + logger.Debug("Fetching anti-abuse oracles...") + oracles, err := getAntiAbuseOracles(cfg.AntiAbuseOracles) + if err != nil { + return fmt.Errorf("failed to get anti-abuse oracles: %w", err) + } + logger.Debug("Found anti-abuse oracles", zap.Int("count", len(oracles))) + + var oracleSendersToAdd []solana.PublicKey + for _, oracleNode := range oracles { + ownerAddr := common.HexToAddress(oracleNode.Owner) + senderAddr, _, err := reward_manager.DeriveSenderAccount(programID, authority, ownerAddr) + if err != nil { + logger.Warn("Failed to derive oracle sender account", + zap.String("owner", oracleNode.Owner), + zap.Error(err), + ) + continue + } + + if !existingAccounts[senderAddr.String()] { + oracleSendersToAdd = append(oracleSendersToAdd, senderAddr) + logger.Info("New oracle sender to add", + zap.String("owner", oracleNode.Owner), + zap.String("sender", senderAddr.String()), + ) + } + } + + logger.Info("Summary", + zap.Int("newValidatorSenders", len(validatorSendersToAdd)), + zap.Int("newOracleSenders", len(oracleSendersToAdd)), + ) + + allAddressesToAdd := append(oracleSendersToAdd, validatorSendersToAdd...) + + if len(allAddressesToAdd) == 0 { + logger.Info("No new senders to add. Lookup table is up to date.") + return nil + } + + if dryRun { + logger.Info("Dry run mode - skipping lookup table update") + return nil + } + + // Extend lookup table in batches + payer := cfg.SolanaConfig.FeePayers[0] + transactionSender := spl.NewTransactionSender(cfg.SolanaConfig.FeePayers, cfg.SolanaConfig.RpcProviders) + + batchSize := 20 + for len(allAddressesToAdd) > 0 { + batchEnd := batchSize + if batchEnd > len(allAddressesToAdd) { + batchEnd = len(allAddressesToAdd) + } + batch := allAddressesToAdd[:batchEnd] + allAddressesToAdd = allAddressesToAdd[batchEnd:] + + logger.Info("Extending lookup table", + zap.String("table", lookupTableAddress.String()), + zap.Int("batchSize", len(batch)), + zap.Int("remaining", len(allAddressesToAdd)), + ) + + extendInstruction := address_lookup_table.NewExtendLookupTableInstruction( + payer.PublicKey(), + lookupTableAddress, + payer.PublicKey(), + batch, + ).Build() + + tx := solana.NewTransactionBuilder() + tx.SetFeePayer(payer.PublicKey()) + tx.AddInstruction(extendInstruction) + + err = transactionSender.AddPriorityFees(ctx, tx, spl.AddPriorityFeesParams{ + Percentile: 99, + Multiplier: 1, + }) + if err != nil { + return fmt.Errorf("failed to add priority fees: %w", err) + } + + logger.Info("Sending transaction...") + signature, err := transactionSender.SendTransactionWithRetries(ctx, tx, rpc.CommitmentConfirmed, rpc.TransactionOpts{}) + if err != nil { + return fmt.Errorf("failed to send transaction: %w", err) + } + + logger.Info("Transaction confirmed", zap.String("signature", signature.String())) + } + + logger.Info("Lookup table update complete") + return nil +} diff --git a/cmd/rewards_cli/main.go b/cmd/rewards_cli/main.go new file mode 100644 index 00000000..0f35581c --- /dev/null +++ b/cmd/rewards_cli/main.go @@ -0,0 +1,9 @@ +package main + +import ( + "api.audius.co/cmd/rewards_cli/cmd" +) + +func main() { + cmd.Execute() +} diff --git a/go.mod b/go.mod index ea25e267..a49b92da 100644 --- a/go.mod +++ b/go.mod @@ -149,6 +149,7 @@ require ( github.com/jmhodges/levigo v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/jsternberg/zap-logfmt v1.3.0 github.com/kamstrup/intmap v0.5.1 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/klauspost/compress v1.18.0 // indirect diff --git a/go.sum b/go.sum index 12737ebb..e7f4342b 100644 --- a/go.sum +++ b/go.sum @@ -389,6 +389,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jsternberg/zap-logfmt v1.3.0 h1:z1n1AOHVVydOOVuyphbOKyR4NICDQFiJMn1IK5hVQ5Y= +github.com/jsternberg/zap-logfmt v1.3.0/go.mod h1:N3DENp9WNmCZxvkBD/eReWwz1149BK6jEN9cQ4fNwZE= github.com/kamstrup/intmap v0.5.1 h1:ENGAowczZA+PJPYYlreoqJvWgQVtAmX1l899WfYFVK0= github.com/kamstrup/intmap v0.5.1/go.mod h1:gWUVWHKzWj8xpJVFf5GC0O26bWmv3GqdnIX/LMT6Aq4= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= diff --git a/solana/spl/programs/address_lookup_table/CloseLookupTable.go b/solana/spl/programs/address_lookup_table/CloseLookupTable.go new file mode 100644 index 00000000..4f9ae0ed --- /dev/null +++ b/solana/spl/programs/address_lookup_table/CloseLookupTable.go @@ -0,0 +1,119 @@ +package address_lookup_table + +import ( + "errors" + + bin "github.com/gagliardetto/binary" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/text" + "github.com/gagliardetto/solana-go/text/format" + "github.com/gagliardetto/treeout" +) + +// CloseLookupTable closes a deactivated lookup table and reclaims rent +type CloseLookupTable struct { + Accounts solana.AccountMetaSlice `bin:"-" borsh_skip:"true"` +} + +var ( + _ solana.AccountsGettable = (*CloseLookupTable)(nil) + _ solana.AccountsSettable = (*CloseLookupTable)(nil) + _ text.EncodableToTree = (*CloseLookupTable)(nil) +) + +func NewCloseLookupTableInstructionBuilder() *CloseLookupTable { + return &CloseLookupTable{ + Accounts: make(solana.AccountMetaSlice, 3), + } +} + +func (inst *CloseLookupTable) SetLookupTableAccount(lookupTable solana.PublicKey) *CloseLookupTable { + inst.Accounts[0] = solana.Meta(lookupTable).WRITE() + return inst +} + +func (inst *CloseLookupTable) LookupTableAccount() *solana.AccountMeta { + return inst.Accounts.Get(0) +} + +func (inst *CloseLookupTable) SetAuthorityAccount(authority solana.PublicKey) *CloseLookupTable { + inst.Accounts[1] = solana.Meta(authority).SIGNER() + return inst +} + +func (inst *CloseLookupTable) AuthorityAccount() *solana.AccountMeta { + return inst.Accounts.Get(1) +} + +func (inst *CloseLookupTable) SetRecipientAccount(recipient solana.PublicKey) *CloseLookupTable { + inst.Accounts[2] = solana.Meta(recipient).WRITE() + return inst +} + +func (inst *CloseLookupTable) RecipientAccount() *solana.AccountMeta { + return inst.Accounts.Get(2) +} + +func (inst *CloseLookupTable) Validate() error { + if inst.LookupTableAccount() == nil { + return errors.New("lookupTable account not set") + } + if inst.AuthorityAccount() == nil { + return errors.New("authority account not set") + } + if inst.RecipientAccount() == nil { + return errors.New("recipient account not set") + } + return nil +} + +func (inst *CloseLookupTable) Build() *solana.GenericInstruction { + return &solana.GenericInstruction{ + ProgID: ProgramID, + AccountValues: inst.Accounts, + DataBytes: inst.data(), + } +} + +func (inst *CloseLookupTable) data() []byte { + data := make([]byte, 4) + bin.LE.PutUint32(data[0:4], Instruction_CloseLookupTable) + return data +} + +func (inst *CloseLookupTable) SetAccounts(accounts []*solana.AccountMeta) error { + return inst.Accounts.SetAccounts(accounts) +} + +func (inst CloseLookupTable) GetAccounts() []*solana.AccountMeta { + return inst.Accounts +} + +func (inst *CloseLookupTable) EncodeToTree(parent treeout.Branches) { + parent.Child(format.Program("CloseLookupTable", ProgramID)). + ParentFunc(func(programBranch treeout.Branches) { + programBranch.Child("Accounts").ParentFunc(func(accountsBranch treeout.Branches) { + if inst.LookupTableAccount() != nil { + accountsBranch.Child(format.Meta("lookupTable", inst.LookupTableAccount())) + } + if inst.AuthorityAccount() != nil { + accountsBranch.Child(format.Meta("authority", inst.AuthorityAccount())) + } + if inst.RecipientAccount() != nil { + accountsBranch.Child(format.Meta("recipient", inst.RecipientAccount())) + } + }) + }) +} + +// NewCloseLookupTableInstruction creates a new CloseLookupTable instruction +func NewCloseLookupTableInstruction( + lookupTable solana.PublicKey, + authority solana.PublicKey, + recipient solana.PublicKey, +) *CloseLookupTable { + return NewCloseLookupTableInstructionBuilder(). + SetLookupTableAccount(lookupTable). + SetAuthorityAccount(authority). + SetRecipientAccount(recipient) +} diff --git a/solana/spl/programs/address_lookup_table/CreateLookupTable.go b/solana/spl/programs/address_lookup_table/CreateLookupTable.go new file mode 100644 index 00000000..661495bb --- /dev/null +++ b/solana/spl/programs/address_lookup_table/CreateLookupTable.go @@ -0,0 +1,149 @@ +package address_lookup_table + +import ( + "errors" + + bin "github.com/gagliardetto/binary" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/text" + "github.com/gagliardetto/solana-go/text/format" + "github.com/gagliardetto/treeout" +) + +// CreateLookupTable creates a new address lookup table +type CreateLookupTable struct { + RecentSlot uint64 + + Accounts solana.AccountMetaSlice `bin:"-" borsh_skip:"true"` +} + +var ( + _ solana.AccountsGettable = (*CreateLookupTable)(nil) + _ solana.AccountsSettable = (*CreateLookupTable)(nil) + _ text.EncodableToTree = (*CreateLookupTable)(nil) +) + +func NewCreateLookupTableInstructionBuilder() *CreateLookupTable { + return &CreateLookupTable{ + Accounts: make(solana.AccountMetaSlice, 4), + } +} + +func (inst *CreateLookupTable) SetRecentSlot(recentSlot uint64) *CreateLookupTable { + inst.RecentSlot = recentSlot + return inst +} + +func (inst *CreateLookupTable) SetLookupTableAccount(lookupTable solana.PublicKey) *CreateLookupTable { + inst.Accounts[0] = solana.Meta(lookupTable).WRITE() + return inst +} + +func (inst *CreateLookupTable) LookupTableAccount() *solana.AccountMeta { + return inst.Accounts.Get(0) +} + +func (inst *CreateLookupTable) SetAuthorityAccount(authority solana.PublicKey) *CreateLookupTable { + inst.Accounts[1] = solana.Meta(authority).SIGNER() + return inst +} + +func (inst *CreateLookupTable) AuthorityAccount() *solana.AccountMeta { + return inst.Accounts.Get(1) +} + +func (inst *CreateLookupTable) SetPayerAccount(payer solana.PublicKey) *CreateLookupTable { + inst.Accounts[2] = solana.Meta(payer).SIGNER().WRITE() + return inst +} + +func (inst *CreateLookupTable) PayerAccount() *solana.AccountMeta { + return inst.Accounts.Get(2) +} + +func (inst *CreateLookupTable) SetSystemProgramAccount(systemProgram solana.PublicKey) *CreateLookupTable { + inst.Accounts[3] = solana.Meta(systemProgram) + return inst +} + +func (inst *CreateLookupTable) SystemProgramAccount() *solana.AccountMeta { + return inst.Accounts.Get(3) +} + +func (inst *CreateLookupTable) Validate() error { + if inst.RecentSlot == 0 { + return errors.New("recentSlot not set") + } + if inst.LookupTableAccount() == nil { + return errors.New("lookupTable account not set") + } + if inst.AuthorityAccount() == nil { + return errors.New("authority account not set") + } + if inst.PayerAccount() == nil { + return errors.New("payer account not set") + } + if inst.SystemProgramAccount() == nil { + return errors.New("systemProgram account not set") + } + return nil +} + +func (inst *CreateLookupTable) Build() *solana.GenericInstruction { + return &solana.GenericInstruction{ + ProgID: ProgramID, + AccountValues: inst.Accounts, + DataBytes: inst.data(), + } +} + +func (inst *CreateLookupTable) data() []byte { + data := make([]byte, 12) + bin.LE.PutUint32(data[0:4], Instruction_CreateLookupTable) + bin.LE.PutUint64(data[4:12], inst.RecentSlot) + return data +} + +func (inst *CreateLookupTable) SetAccounts(accounts []*solana.AccountMeta) error { + return inst.Accounts.SetAccounts(accounts) +} + +func (inst CreateLookupTable) GetAccounts() []*solana.AccountMeta { + return inst.Accounts +} + +func (inst *CreateLookupTable) EncodeToTree(parent treeout.Branches) { + parent.Child(format.Program("CreateLookupTable", ProgramID)). + ParentFunc(func(programBranch treeout.Branches) { + programBranch.Child(format.Param("RecentSlot", inst.RecentSlot)) + programBranch.Child("Accounts").ParentFunc(func(accountsBranch treeout.Branches) { + if inst.LookupTableAccount() != nil { + accountsBranch.Child(format.Meta("lookupTable", inst.LookupTableAccount())) + } + if inst.AuthorityAccount() != nil { + accountsBranch.Child(format.Meta("authority", inst.AuthorityAccount())) + } + if inst.PayerAccount() != nil { + accountsBranch.Child(format.Meta("payer", inst.PayerAccount())) + } + if inst.SystemProgramAccount() != nil { + accountsBranch.Child(format.Meta("systemProgram", inst.SystemProgramAccount())) + } + }) + }) +} + +// NewCreateLookupTableInstruction creates a new CreateLookupTable instruction +func NewCreateLookupTableInstruction( + recentSlot uint64, + lookupTable solana.PublicKey, + authority solana.PublicKey, + payer solana.PublicKey, +) *CreateLookupTable { + return NewCreateLookupTableInstructionBuilder(). + SetRecentSlot(recentSlot). + SetLookupTableAccount(lookupTable). + SetAuthorityAccount(authority). + SetPayerAccount(payer). + SetSystemProgramAccount(solana.SystemProgramID) +} diff --git a/solana/spl/programs/address_lookup_table/DeactivateLookupTable.go b/solana/spl/programs/address_lookup_table/DeactivateLookupTable.go new file mode 100644 index 00000000..4d9cd2fd --- /dev/null +++ b/solana/spl/programs/address_lookup_table/DeactivateLookupTable.go @@ -0,0 +1,102 @@ +package address_lookup_table + +import ( + "errors" + + bin "github.com/gagliardetto/binary" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/text" + "github.com/gagliardetto/solana-go/text/format" + "github.com/gagliardetto/treeout" +) + +// DeactivateLookupTable deactivates a lookup table so it can be closed +type DeactivateLookupTable struct { + Accounts solana.AccountMetaSlice `bin:"-" borsh_skip:"true"` +} + +var ( + _ solana.AccountsGettable = (*DeactivateLookupTable)(nil) + _ solana.AccountsSettable = (*DeactivateLookupTable)(nil) + _ text.EncodableToTree = (*DeactivateLookupTable)(nil) +) + +func NewDeactivateLookupTableInstructionBuilder() *DeactivateLookupTable { + return &DeactivateLookupTable{ + Accounts: make(solana.AccountMetaSlice, 2), + } +} + +func (inst *DeactivateLookupTable) SetLookupTableAccount(lookupTable solana.PublicKey) *DeactivateLookupTable { + inst.Accounts[0] = solana.Meta(lookupTable).WRITE() + return inst +} + +func (inst *DeactivateLookupTable) LookupTableAccount() *solana.AccountMeta { + return inst.Accounts.Get(0) +} + +func (inst *DeactivateLookupTable) SetAuthorityAccount(authority solana.PublicKey) *DeactivateLookupTable { + inst.Accounts[1] = solana.Meta(authority).SIGNER() + return inst +} + +func (inst *DeactivateLookupTable) AuthorityAccount() *solana.AccountMeta { + return inst.Accounts.Get(1) +} + +func (inst *DeactivateLookupTable) Validate() error { + if inst.LookupTableAccount() == nil { + return errors.New("lookupTable account not set") + } + if inst.AuthorityAccount() == nil { + return errors.New("authority account not set") + } + return nil +} + +func (inst *DeactivateLookupTable) Build() *solana.GenericInstruction { + return &solana.GenericInstruction{ + ProgID: ProgramID, + AccountValues: inst.Accounts, + DataBytes: inst.data(), + } +} + +func (inst *DeactivateLookupTable) data() []byte { + data := make([]byte, 4) + bin.LE.PutUint32(data[0:4], Instruction_DeactivateLookupTable) + return data +} + +func (inst *DeactivateLookupTable) SetAccounts(accounts []*solana.AccountMeta) error { + return inst.Accounts.SetAccounts(accounts) +} + +func (inst DeactivateLookupTable) GetAccounts() []*solana.AccountMeta { + return inst.Accounts +} + +func (inst *DeactivateLookupTable) EncodeToTree(parent treeout.Branches) { + parent.Child(format.Program("DeactivateLookupTable", ProgramID)). + ParentFunc(func(programBranch treeout.Branches) { + programBranch.Child("Accounts").ParentFunc(func(accountsBranch treeout.Branches) { + if inst.LookupTableAccount() != nil { + accountsBranch.Child(format.Meta("lookupTable", inst.LookupTableAccount())) + } + if inst.AuthorityAccount() != nil { + accountsBranch.Child(format.Meta("authority", inst.AuthorityAccount())) + } + }) + }) +} + +// NewDeactivateLookupTableInstruction creates a new DeactivateLookupTable instruction +func NewDeactivateLookupTableInstruction( + lookupTable solana.PublicKey, + authority solana.PublicKey, +) *DeactivateLookupTable { + return NewDeactivateLookupTableInstructionBuilder(). + SetLookupTableAccount(lookupTable). + SetAuthorityAccount(authority) +} diff --git a/solana/spl/programs/address_lookup_table/ExtendLookupTable.go b/solana/spl/programs/address_lookup_table/ExtendLookupTable.go new file mode 100644 index 00000000..717c90f8 --- /dev/null +++ b/solana/spl/programs/address_lookup_table/ExtendLookupTable.go @@ -0,0 +1,159 @@ +package address_lookup_table + +import ( + "errors" + + bin "github.com/gagliardetto/binary" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/text" + "github.com/gagliardetto/solana-go/text/format" + "github.com/gagliardetto/treeout" +) + +// ExtendLookupTable extends an existing lookup table with new addresses +type ExtendLookupTable struct { + NewAddresses []solana.PublicKey + + Accounts solana.AccountMetaSlice `bin:"-" borsh_skip:"true"` +} + +var ( + _ solana.AccountsGettable = (*ExtendLookupTable)(nil) + _ solana.AccountsSettable = (*ExtendLookupTable)(nil) + _ text.EncodableToTree = (*ExtendLookupTable)(nil) +) + +func NewExtendLookupTableInstructionBuilder() *ExtendLookupTable { + return &ExtendLookupTable{ + Accounts: make(solana.AccountMetaSlice, 4), + } +} + +func (inst *ExtendLookupTable) SetNewAddresses(addresses []solana.PublicKey) *ExtendLookupTable { + inst.NewAddresses = addresses + return inst +} + +func (inst *ExtendLookupTable) SetLookupTableAccount(lookupTable solana.PublicKey) *ExtendLookupTable { + inst.Accounts[0] = solana.Meta(lookupTable).WRITE() + return inst +} + +func (inst *ExtendLookupTable) LookupTableAccount() *solana.AccountMeta { + return inst.Accounts.Get(0) +} + +func (inst *ExtendLookupTable) SetAuthorityAccount(authority solana.PublicKey) *ExtendLookupTable { + inst.Accounts[1] = solana.Meta(authority).SIGNER() + return inst +} + +func (inst *ExtendLookupTable) AuthorityAccount() *solana.AccountMeta { + return inst.Accounts.Get(1) +} + +func (inst *ExtendLookupTable) SetPayerAccount(payer solana.PublicKey) *ExtendLookupTable { + inst.Accounts[2] = solana.Meta(payer).SIGNER().WRITE() + return inst +} + +func (inst *ExtendLookupTable) PayerAccount() *solana.AccountMeta { + return inst.Accounts.Get(2) +} + +func (inst *ExtendLookupTable) SetSystemProgramAccount(systemProgram solana.PublicKey) *ExtendLookupTable { + inst.Accounts[3] = solana.Meta(systemProgram) + return inst +} + +func (inst *ExtendLookupTable) SystemProgramAccount() *solana.AccountMeta { + return inst.Accounts.Get(3) +} + +func (inst *ExtendLookupTable) Validate() error { + if len(inst.NewAddresses) == 0 { + return errors.New("newAddresses not set") + } + if inst.LookupTableAccount() == nil { + return errors.New("lookupTable account not set") + } + if inst.AuthorityAccount() == nil { + return errors.New("authority account not set") + } + if inst.PayerAccount() == nil { + return errors.New("payer account not set") + } + if inst.SystemProgramAccount() == nil { + return errors.New("systemProgram account not set") + } + return nil +} + +func (inst *ExtendLookupTable) Build() *solana.GenericInstruction { + return &solana.GenericInstruction{ + ProgID: ProgramID, + AccountValues: inst.Accounts, + DataBytes: inst.data(), + } +} + +func (inst *ExtendLookupTable) data() []byte { + // Instruction discriminator (4 bytes) + num addresses (8 bytes) + addresses + numAddresses := uint64(len(inst.NewAddresses)) + data := make([]byte, 4+8+(numAddresses*32)) + + bin.LE.PutUint32(data[0:4], Instruction_ExtendLookupTable) + bin.LE.PutUint64(data[4:12], numAddresses) + + offset := 12 + for _, addr := range inst.NewAddresses { + copy(data[offset:offset+32], addr[:]) + offset += 32 + } + + return data +} + +func (inst *ExtendLookupTable) SetAccounts(accounts []*solana.AccountMeta) error { + return inst.Accounts.SetAccounts(accounts) +} + +func (inst ExtendLookupTable) GetAccounts() []*solana.AccountMeta { + return inst.Accounts +} + +func (inst *ExtendLookupTable) EncodeToTree(parent treeout.Branches) { + parent.Child(format.Program("ExtendLookupTable", ProgramID)). + ParentFunc(func(programBranch treeout.Branches) { + programBranch.Child(format.Param("NumAddresses", len(inst.NewAddresses))) + programBranch.Child("Accounts").ParentFunc(func(accountsBranch treeout.Branches) { + if inst.LookupTableAccount() != nil { + accountsBranch.Child(format.Meta("lookupTable", inst.LookupTableAccount())) + } + if inst.AuthorityAccount() != nil { + accountsBranch.Child(format.Meta("authority", inst.AuthorityAccount())) + } + if inst.PayerAccount() != nil { + accountsBranch.Child(format.Meta("payer", inst.PayerAccount())) + } + if inst.SystemProgramAccount() != nil { + accountsBranch.Child(format.Meta("systemProgram", inst.SystemProgramAccount())) + } + }) + }) +} + +// NewExtendLookupTableInstruction creates a new ExtendLookupTable instruction +func NewExtendLookupTableInstruction( + authority solana.PublicKey, + lookupTable solana.PublicKey, + payer solana.PublicKey, + newAddresses []solana.PublicKey, +) *ExtendLookupTable { + return NewExtendLookupTableInstructionBuilder(). + SetNewAddresses(newAddresses). + SetLookupTableAccount(lookupTable). + SetAuthorityAccount(authority). + SetPayerAccount(payer). + SetSystemProgramAccount(solana.SystemProgramID) +} diff --git a/solana/spl/programs/address_lookup_table/FreezeLookupTable.go b/solana/spl/programs/address_lookup_table/FreezeLookupTable.go new file mode 100644 index 00000000..ee22e18f --- /dev/null +++ b/solana/spl/programs/address_lookup_table/FreezeLookupTable.go @@ -0,0 +1,102 @@ +package address_lookup_table + +import ( + "errors" + + bin "github.com/gagliardetto/binary" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/text" + "github.com/gagliardetto/solana-go/text/format" + "github.com/gagliardetto/treeout" +) + +// FreezeLookupTable freezes a lookup table making it immutable +type FreezeLookupTable struct { + Accounts solana.AccountMetaSlice `bin:"-" borsh_skip:"true"` +} + +var ( + _ solana.AccountsGettable = (*FreezeLookupTable)(nil) + _ solana.AccountsSettable = (*FreezeLookupTable)(nil) + _ text.EncodableToTree = (*FreezeLookupTable)(nil) +) + +func NewFreezeLookupTableInstructionBuilder() *FreezeLookupTable { + return &FreezeLookupTable{ + Accounts: make(solana.AccountMetaSlice, 2), + } +} + +func (inst *FreezeLookupTable) SetLookupTableAccount(lookupTable solana.PublicKey) *FreezeLookupTable { + inst.Accounts[0] = solana.Meta(lookupTable).WRITE() + return inst +} + +func (inst *FreezeLookupTable) LookupTableAccount() *solana.AccountMeta { + return inst.Accounts.Get(0) +} + +func (inst *FreezeLookupTable) SetAuthorityAccount(authority solana.PublicKey) *FreezeLookupTable { + inst.Accounts[1] = solana.Meta(authority).SIGNER() + return inst +} + +func (inst *FreezeLookupTable) AuthorityAccount() *solana.AccountMeta { + return inst.Accounts.Get(1) +} + +func (inst *FreezeLookupTable) Validate() error { + if inst.LookupTableAccount() == nil { + return errors.New("lookupTable account not set") + } + if inst.AuthorityAccount() == nil { + return errors.New("authority account not set") + } + return nil +} + +func (inst *FreezeLookupTable) Build() *solana.GenericInstruction { + return &solana.GenericInstruction{ + ProgID: ProgramID, + AccountValues: inst.Accounts, + DataBytes: inst.data(), + } +} + +func (inst *FreezeLookupTable) data() []byte { + data := make([]byte, 4) + bin.LE.PutUint32(data[0:4], Instruction_FreezeLookupTable) + return data +} + +func (inst *FreezeLookupTable) SetAccounts(accounts []*solana.AccountMeta) error { + return inst.Accounts.SetAccounts(accounts) +} + +func (inst FreezeLookupTable) GetAccounts() []*solana.AccountMeta { + return inst.Accounts +} + +func (inst *FreezeLookupTable) EncodeToTree(parent treeout.Branches) { + parent.Child(format.Program("FreezeLookupTable", ProgramID)). + ParentFunc(func(programBranch treeout.Branches) { + programBranch.Child("Accounts").ParentFunc(func(accountsBranch treeout.Branches) { + if inst.LookupTableAccount() != nil { + accountsBranch.Child(format.Meta("lookupTable", inst.LookupTableAccount())) + } + if inst.AuthorityAccount() != nil { + accountsBranch.Child(format.Meta("authority", inst.AuthorityAccount())) + } + }) + }) +} + +// NewFreezeLookupTableInstruction creates a new FreezeLookupTable instruction +func NewFreezeLookupTableInstruction( + lookupTable solana.PublicKey, + authority solana.PublicKey, +) *FreezeLookupTable { + return NewFreezeLookupTableInstructionBuilder(). + SetLookupTableAccount(lookupTable). + SetAuthorityAccount(authority) +} diff --git a/solana/spl/programs/address_lookup_table/instruction.go b/solana/spl/programs/address_lookup_table/instruction.go new file mode 100644 index 00000000..f7f8cf7a --- /dev/null +++ b/solana/spl/programs/address_lookup_table/instruction.go @@ -0,0 +1,16 @@ +package address_lookup_table + +import ( +"github.com/gagliardetto/solana-go" +) + +// ProgramID is the address of the Address Lookup Table program +var ProgramID = solana.MustPublicKeyFromBase58("AddressLookupTab1e1111111111111111111111111") + +const ( +Instruction_CreateLookupTable uint32 = iota +Instruction_FreezeLookupTable +Instruction_ExtendLookupTable +Instruction_DeactivateLookupTable +Instruction_CloseLookupTable +) diff --git a/solana/spl/programs/reward_manager/CreateSenderPublic.go b/solana/spl/programs/reward_manager/CreateSenderPublic.go index 5ebed8dc..48b34742 100644 --- a/solana/spl/programs/reward_manager/CreateSenderPublic.go +++ b/solana/spl/programs/reward_manager/CreateSenderPublic.go @@ -1,3 +1,215 @@ package reward_manager -type CreateSenderPublic struct{} +import ( + "errors" + "fmt" + "strconv" + + "github.com/ethereum/go-ethereum/common" + bin "github.com/gagliardetto/binary" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/text" + "github.com/gagliardetto/solana-go/text/format" + "github.com/gagliardetto/treeout" +) + +type CreateSenderPublic struct { + EthAddress common.Address + OperatorEthAddress common.Address + + Attesters []common.Address `bin:"-" borsh_skip:"true"` + + Accounts solana.AccountMetaSlice `bin:"-" borsh_skip:"true"` +} + +var ( + _ solana.AccountsGettable = (*CreateSenderPublic)(nil) + _ solana.AccountsSettable = (*CreateSenderPublic)(nil) + _ text.EncodableToTree = (*CreateSenderPublic)(nil) +) + +func NewCreateSenderPublicInstructionBuilder() *CreateSenderPublic { + inst := &CreateSenderPublic{ + Accounts: make(solana.AccountMetaSlice, 7), + } + inst.Accounts[4] = solana.Meta(solana.SysVarInstructionsPubkey) + inst.Accounts[5] = solana.Meta(solana.SysVarRentPubkey) + inst.Accounts[6] = solana.Meta(solana.SystemProgramID) + return inst +} + +func (inst *CreateSenderPublic) SetEthAddress(ethAddress common.Address) *CreateSenderPublic { + inst.EthAddress = ethAddress + return inst +} + +func (inst *CreateSenderPublic) SetOperatorEthAddress(operatorEthAddress common.Address) *CreateSenderPublic { + inst.OperatorEthAddress = operatorEthAddress + return inst +} + +func (inst *CreateSenderPublic) SetRewardManagerStateAccount(state solana.PublicKey) *CreateSenderPublic { + inst.Accounts[0] = solana.Meta(state) + return inst +} + +func (inst *CreateSenderPublic) RewardManagerStateAccount() *solana.AccountMeta { + return inst.Accounts.Get(0) +} + +func (inst *CreateSenderPublic) SetAuthorityAccount(authority solana.PublicKey) *CreateSenderPublic { + inst.Accounts[1] = solana.Meta(authority) + return inst +} + +func (inst *CreateSenderPublic) AuthorityAccount() *solana.AccountMeta { + return inst.Accounts.Get(1) +} + +func (inst *CreateSenderPublic) SetPayerAccount(payer solana.PublicKey) *CreateSenderPublic { + inst.Accounts[2] = solana.Meta(payer).SIGNER().WRITE() + return inst +} + +func (inst *CreateSenderPublic) PayerAccount() *solana.AccountMeta { + return inst.Accounts.Get(2) +} + +func (inst *CreateSenderPublic) SetSenderAccount(sender solana.PublicKey) *CreateSenderPublic { + inst.Accounts[3] = solana.Meta(sender).WRITE() + return inst +} + +func (inst *CreateSenderPublic) SenderAccount() *solana.AccountMeta { + return inst.Accounts.Get(3) +} + +func (inst *CreateSenderPublic) AddAttester(attester solana.PublicKey) *CreateSenderPublic { + inst.Accounts = append(inst.Accounts, solana.Meta(attester)) + return inst +} + +func (inst *CreateSenderPublic) Validate() error { + if inst.EthAddress == (common.Address{}) { + return errors.New("ethAddress not set") + } + if inst.OperatorEthAddress == (common.Address{}) { + return errors.New("operatorEthAddress not set") + } + if inst.SenderAccount() == nil { + return errors.New("sender account not set") + } + if inst.RewardManagerStateAccount() == nil { + return errors.New("rewardManagerState account not set") + } + if inst.PayerAccount() == nil { + return errors.New("payer account not set") + } + + _, _, err := deriveAuthorityAccount(ProgramID, inst.RewardManagerStateAccount().PublicKey) + if err != nil { + return fmt.Errorf("failed to derive authority account: %w", err) + } + + _, _, err = DeriveSenderAccount(ProgramID, inst.AuthorityAccount().PublicKey, inst.EthAddress) + if err != nil { + return fmt.Errorf("failed to derive sender account: %w", err) + } + + for _, addr := range inst.Attesters { + _, _, err = DeriveSenderAccount(ProgramID, inst.AuthorityAccount().PublicKey, addr) + if err != nil { + return fmt.Errorf("failed to derive sender account for attester %s: %w", addr.Hex(), err) + } + } + + return nil +} + +// Build builds the instruction +func (inst CreateSenderPublic) Build() *Instruction { + + authority, _, _ := deriveAuthorityAccount(ProgramID, inst.RewardManagerStateAccount().PublicKey) + inst.SetAuthorityAccount(authority) + newSender, _, _ := DeriveSenderAccount(ProgramID, authority, inst.EthAddress) + inst.SetSenderAccount(newSender) + + for _, addr := range inst.Attesters { + sender, _, _ := DeriveSenderAccount(ProgramID, authority, addr) + inst.AddAttester(sender) + } + + return &Instruction{BaseVariant: bin.BaseVariant{ + Impl: inst, + TypeID: bin.TypeIDFromUint8(Instruction_CreateSenderPublic), + }} +} + +// ValidateAndBuild validates and builds the instruction +func (inst *CreateSenderPublic) ValidateAndBuild() (*Instruction, error) { + if err := inst.Validate(); err != nil { + return nil, err + } + return inst.Build(), nil +} + +// ----- solana.AccountsSettable Implementation ----- + +func (inst *CreateSenderPublic) SetAccounts(accounts []*solana.AccountMeta) error { + return inst.Accounts.SetAccounts(accounts) +} + +// ----- solana.AccountsGettable Implementation ----- + +func (inst CreateSenderPublic) GetAccounts() []*solana.AccountMeta { + return inst.Accounts +} + +// ----- text.EncodableToTree Implementation ----- + +func (inst *CreateSenderPublic) EncodeToTree(parent treeout.Branches) { + parent.Child(format.Program("CreateSenderPublic", ProgramID)). + ParentFunc(func(programBranch treeout.Branches) { + programBranch.Child(format.Param("EthAddress", inst.EthAddress.Hex())) + programBranch.Child("Accounts").ParentFunc(func(accountsBranch treeout.Branches) { + if inst.SenderAccount() != nil { + accountsBranch.Child(format.Meta("sender", inst.SenderAccount())) + } + if inst.RewardManagerStateAccount() != nil { + accountsBranch.Child(format.Meta("rewardManagerState", inst.RewardManagerStateAccount())) + } + if inst.AuthorityAccount() != nil { + accountsBranch.Child(format.Meta("authority", inst.AuthorityAccount())) + } + if inst.PayerAccount() != nil { + accountsBranch.Child(format.Meta("payer", inst.PayerAccount())) + } + for i, acct := range inst.Accounts[7:] { + accountsBranch.Child(format.Meta( + "attester_"+strconv.Itoa(i), + acct, + )) + } + }) + }) +} + +// NewCreateSenderPublicInstruction creates a new CreateSenderPublic instruction +func NewCreateSenderPublicInstruction( + ethAddress common.Address, + operatorEthAddress common.Address, + rewardManagerState solana.PublicKey, + payer solana.PublicKey, + attesterEthAddresses ...common.Address, +) (*CreateSenderPublic, error) { + + inst := NewCreateSenderPublicInstructionBuilder(). + SetEthAddress(ethAddress). + SetOperatorEthAddress(operatorEthAddress). + SetRewardManagerStateAccount(rewardManagerState). + SetPayerAccount(payer) + + inst.Attesters = append(inst.Attesters, attesterEthAddresses...) + + return inst, nil +} diff --git a/solana/spl/programs/reward_manager/EvaluateAttestations.go b/solana/spl/programs/reward_manager/EvaluateAttestations.go index 17f322d9..da8e9514 100644 --- a/solana/spl/programs/reward_manager/EvaluateAttestations.go +++ b/solana/spl/programs/reward_manager/EvaluateAttestations.go @@ -236,7 +236,7 @@ func NewEvaluateAttestationInstruction( if err != nil { return nil, err } - antiAbuseOracle, _, err := deriveSenderAccount(ProgramID, authority, antiAbuseOracleAddress) + antiAbuseOracle, _, err := DeriveSenderAccount(ProgramID, authority, antiAbuseOracleAddress) if err != nil { return nil, err } diff --git a/solana/spl/programs/reward_manager/SubmitAttestation.go b/solana/spl/programs/reward_manager/SubmitAttestation.go index 5c80cd27..2f166288 100644 --- a/solana/spl/programs/reward_manager/SubmitAttestation.go +++ b/solana/spl/programs/reward_manager/SubmitAttestation.go @@ -165,7 +165,7 @@ func NewSubmitAttestationInstruction( if err != nil { return nil, err } - sender, _, err := deriveSenderAccount(ProgramID, authority, senderEthAddress) + sender, _, err := DeriveSenderAccount(ProgramID, authority, senderEthAddress) if err != nil { return nil, err } diff --git a/solana/spl/programs/reward_manager/accounts.go b/solana/spl/programs/reward_manager/accounts.go index 5c2d0e78..3b7bba58 100644 --- a/solana/spl/programs/reward_manager/accounts.go +++ b/solana/spl/programs/reward_manager/accounts.go @@ -126,13 +126,20 @@ func (data *AttestationsAccountData) UnmarshalWithDecoder(decoder *bin.Decoder) return nil } +type SenderAccountData struct { + Version uint8 + RewardManagerState solana.PublicKey + SenderEthAddress common.Address + OwnerEthAddress common.Address +} + func deriveAuthorityAccount(programId solana.PublicKey, state solana.PublicKey) (solana.PublicKey, uint8, error) { seeds := make([][]byte, 1) seeds[0] = state.Bytes()[0:32] return solana.FindProgramAddress(seeds, programId) } -func deriveSenderAccount(programId solana.PublicKey, authority solana.PublicKey, ethAddress common.Address) (solana.PublicKey, uint8, error) { +func DeriveSenderAccount(programId solana.PublicKey, authority solana.PublicKey, ethAddress common.Address) (solana.PublicKey, uint8, error) { senderSeedPrefix := []byte(SenderSeedPrefix) decodedEthAddress := ethAddress.Bytes() // Pad the eth address if necessary w/ leading 0 diff --git a/solana/spl/programs/reward_manager/client.go b/solana/spl/programs/reward_manager/client.go index e97211bc..8f68d4fe 100644 --- a/solana/spl/programs/reward_manager/client.go +++ b/solana/spl/programs/reward_manager/client.go @@ -84,6 +84,11 @@ func (rc *RewardManagerClient) GetLookupTableAccount() solana.PublicKey { return rc.lookupTableAccount } +// GetAuthority returns the authority account for this reward manager instance +func (rc *RewardManagerClient) GetAuthority() solana.PublicKey { + return rc.authority +} + // Gets the claims already submitted for a rewards claim from the account data. func (rc *RewardManagerClient) GetSubmittedAttestations( ctx context.Context,