From f54727915e4282217dabe776afee013a5ea9f721 Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Thu, 1 May 2025 13:08:05 -0700 Subject: [PATCH 1/5] add reward errors --- api/spl/programs/reward_manager/errors.go | 60 +++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 api/spl/programs/reward_manager/errors.go diff --git a/api/spl/programs/reward_manager/errors.go b/api/spl/programs/reward_manager/errors.go new file mode 100644 index 00000000..6c7efe5c --- /dev/null +++ b/api/spl/programs/reward_manager/errors.go @@ -0,0 +1,60 @@ +package reward_manager + +// Custom errors returned by the RewardManager program. +// +// Caution: Some error values overlap with system program errors. +// +// See also: https://github.com/AudiusProject/audius-protocol/blob/2a37bcff1bb1a82efdf187d1723b3457dc0dcb9b/solana-programs/reward-manager/program/src/error.rs +type RewardManagerError int + +const ( + IncorrectOwner RewardManagerError = iota + SignCollision + WrongSigner + NotEnoughSigners + Secp256InstructionMissing + InstructionLoadError + RepeatedSenders + SignatureVerificationFailed + OperatorCollision + AlreadySent + IncorrectMessages + MessagesOverflow + MathOverflow + InvalidRecipient +) + +func (e RewardManagerError) String() string { + switch e { + case IncorrectOwner: + return "IncorrectOwner" + case SignCollision: + return "SignCollision" + case WrongSigner: + return "WrongSigner" + case NotEnoughSigners: + return "NotEnoughSigners" + case Secp256InstructionMissing: + return "Secp256InstructionMissing" + case InstructionLoadError: + return "InstructionLoadError" + case RepeatedSenders: + return "RepeatedSenders" + case SignatureVerificationFailed: + return "SignatureVerificationFailed" + case OperatorCollision: + return "OperatorCollision" + case AlreadySent: + return "AlreadySent" + case IncorrectMessages: + return "IncorrectMessages" + case MessagesOverflow: + return "MessagesOverflow" + case MathOverflow: + return "MathOverflow" + case InvalidRecipient: + return "InvalidRecipient" + default: + return "UnknownError" + } +} From 53c8e0ac8a4bfe847d51fe8c67c7ceebad5f29ad Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Thu, 1 May 2025 13:08:52 -0700 Subject: [PATCH 2/5] Add RewardManagerClient, use common.Address, add TransactionSender --- .../reward_manager/EvaluateAttestations.go | 26 +- .../EvaluateAttestations_test.go | 5 +- .../reward_manager/SubmitAttestation.go | 11 +- api/spl/programs/reward_manager/accounts.go | 13 +- api/spl/programs/reward_manager/client.go | 118 ++++++ .../secp256k1/Secp256k1Instruction.go | 30 +- .../secp256k1/Secp256k1Instruction_test.go | 8 +- api/spl/transaction_sender.go | 350 ++++++++++++++++++ 8 files changed, 521 insertions(+), 40 deletions(-) create mode 100644 api/spl/programs/reward_manager/client.go create mode 100644 api/spl/transaction_sender.go diff --git a/api/spl/programs/reward_manager/EvaluateAttestations.go b/api/spl/programs/reward_manager/EvaluateAttestations.go index 3613977e..dfc68771 100644 --- a/api/spl/programs/reward_manager/EvaluateAttestations.go +++ b/api/spl/programs/reward_manager/EvaluateAttestations.go @@ -1,9 +1,7 @@ package reward_manager import ( - "encoding/hex" - "strings" - + "github.com/ethereum/go-ethereum/common" bin "github.com/gagliardetto/binary" "github.com/gagliardetto/solana-go" ) @@ -12,14 +10,14 @@ type EvaluateAttestation struct { // Instruction Data Amount uint64 DisbursementID string - ReceipientEthAddress string + ReceipientEthAddress common.Address // Used for derivations RewardManagerState solana.PublicKey `bin:"-" borsh_skip:"true"` Payer solana.PublicKey `bin:"-" borsh_skip:"true"` DestinationUserBank solana.PublicKey `bin:"-" borsh_skip:"true"` TokenSource solana.PublicKey `bin:"-" borsh_skip:"true"` - AntiAbuseOracleEthAddress string `bin:"-" borsh_skip:"true"` + AntiAbuseOracleEthAddress common.Address `bin:"-" borsh_skip:"true"` // Accounts solana.AccountMetaSlice `bin:"-" borsh_skip:"true"` @@ -35,7 +33,7 @@ func (inst *EvaluateAttestation) SetDisbursementID(challengedId string, specifie return inst } -func (inst *EvaluateAttestation) SetRecipientEthAddress(recipientEthAddress string) *EvaluateAttestation { +func (inst *EvaluateAttestation) SetRecipientEthAddress(recipientEthAddress common.Address) *EvaluateAttestation { inst.ReceipientEthAddress = recipientEthAddress return inst } @@ -45,7 +43,7 @@ func (inst *EvaluateAttestation) SetAmount(amount uint64) *EvaluateAttestation { return inst } -func (inst *EvaluateAttestation) SetAntiAbuseOracleEthAddress(antiAbuseOracleAddress string) *EvaluateAttestation { +func (inst *EvaluateAttestation) SetAntiAbuseOracleEthAddress(antiAbuseOracleAddress common.Address) *EvaluateAttestation { inst.AntiAbuseOracleEthAddress = antiAbuseOracleAddress return inst } @@ -71,8 +69,8 @@ func (inst *EvaluateAttestation) SetPayer(payer solana.PublicKey) *EvaluateAttes } func (inst EvaluateAttestation) Build() *Instruction { - authority, _, _ := DeriveAuthorityAccount(ProgramID, inst.RewardManagerState) - attestations, _, _ := DeriveAttestationsAccount(ProgramID, authority, inst.DisbursementID) + authority, _, _ := deriveAuthorityAccount(ProgramID, inst.RewardManagerState) + attestations, _, _ := deriveAttestationsAccount(ProgramID, authority, inst.DisbursementID) disbursement, _, _ := deriveDisbursement(ProgramID, authority, inst.DisbursementID) antiAbuseOracle, _, _ := deriveSender(ProgramID, authority, inst.AntiAbuseOracleEthAddress) @@ -151,11 +149,7 @@ func (inst EvaluateAttestation) MarshalWithEncoder(encoder *bin.Encoder) error { return err } - address, err := hex.DecodeString(strings.TrimPrefix(inst.ReceipientEthAddress, "0x")) - if err != nil { - return err - } - return encoder.WriteBytes(address, false) + return encoder.WriteBytes(inst.ReceipientEthAddress.Bytes(), false) } func (inst *EvaluateAttestation) UnmarshalWithDecoder(decoder *bin.Decoder) error { @@ -165,9 +159,9 @@ func (inst *EvaluateAttestation) UnmarshalWithDecoder(decoder *bin.Decoder) erro func NewEvaluateAttestationInstruction( challengeId string, specifier string, - recipientEthAddress string, + recipientEthAddress common.Address, amount uint64, - antiAbuseOracleAddress string, + antiAbuseOracleAddress common.Address, rewardManagerState solana.PublicKey, tokenSource solana.PublicKey, destinationUserBank solana.PublicKey, diff --git a/api/spl/programs/reward_manager/EvaluateAttestations_test.go b/api/spl/programs/reward_manager/EvaluateAttestations_test.go index cd9f659d..2cfd117e 100644 --- a/api/spl/programs/reward_manager/EvaluateAttestations_test.go +++ b/api/spl/programs/reward_manager/EvaluateAttestations_test.go @@ -5,6 +5,7 @@ import ( "testing" "bridgerton.audius.co/api/spl/programs/reward_manager" + "github.com/ethereum/go-ethereum/common" "github.com/gagliardetto/solana-go" "github.com/stretchr/testify/require" ) @@ -13,9 +14,9 @@ func TestEvaluateAttestationsInstruction(t *testing.T) { // Test data challengeId := "ft" specifier := "37364e80" - recipientEthAddress := "0x3f6d9fcf0d4466dd5886e3b1def017adfb7916b4" + recipientEthAddress := common.HexToAddress("0x3f6d9fcf0d4466dd5886e3b1def017adfb7916b4") amount := uint64(200000000) - antiAbuseOracleEthAddress := "0x00b6462e955dA5841b6D9e1E2529B830F00f31Bf" + antiAbuseOracleEthAddress := common.HexToAddress("0x00b6462e955dA5841b6D9e1E2529B830F00f31Bf") // Expected Accounts // From successful stage transaction (signature 26gT9HVMhzBDzsKcsiKREYmGcXuZhjAJpCVUu9WFNhVMyKje8SdApYc4ev3HrumZB4LEXLUaPnKyriBPLmtzwrWp) diff --git a/api/spl/programs/reward_manager/SubmitAttestation.go b/api/spl/programs/reward_manager/SubmitAttestation.go index 7a7d78df..fe715549 100644 --- a/api/spl/programs/reward_manager/SubmitAttestation.go +++ b/api/spl/programs/reward_manager/SubmitAttestation.go @@ -1,6 +1,7 @@ package reward_manager import ( + "github.com/ethereum/go-ethereum/common" bin "github.com/gagliardetto/binary" "github.com/gagliardetto/solana-go" ) @@ -10,7 +11,7 @@ type SubmitAttestation struct { DisbursementID string // Used for derivations - SenderEthAddress string `bin:"-" borsh_skip:"true"` + SenderEthAddress common.Address `bin:"-" borsh_skip:"true"` RewardManagerState solana.PublicKey `bin:"-" borsh_skip:"true"` Payer solana.PublicKey `bin:"-" borsh_skip:"true"` @@ -28,7 +29,7 @@ func (inst *SubmitAttestation) SetDisbursementID(challengeId string, specifier s return inst } -func (inst *SubmitAttestation) SetSenderEthAddress(senderEthAddress string) *SubmitAttestation { +func (inst *SubmitAttestation) SetSenderEthAddress(senderEthAddress common.Address) *SubmitAttestation { inst.SenderEthAddress = senderEthAddress return inst } @@ -44,9 +45,9 @@ func (inst *SubmitAttestation) SetPayer(payer solana.PublicKey) *SubmitAttestati } func (inst SubmitAttestation) Build() *Instruction { - authority, _, _ := DeriveAuthorityAccount(ProgramID, inst.RewardManagerState) + authority, _, _ := deriveAuthorityAccount(ProgramID, inst.RewardManagerState) sender, _, _ := deriveSender(ProgramID, authority, inst.SenderEthAddress) - attestations, _, _ := DeriveAttestationsAccount(ProgramID, authority, inst.DisbursementID) + attestations, _, _ := deriveAttestationsAccount(ProgramID, authority, inst.DisbursementID) inst.AccountMetaSlice = []*solana.AccountMeta{ { @@ -108,7 +109,7 @@ func (inst *SubmitAttestation) UnmarshalWithDecoder(decoder *bin.Decoder) error func NewSubmitAttestationInstruction( challengeId string, specifier string, - senderEthAddress string, + senderEthAddress common.Address, rewardManagerState solana.PublicKey, payer solana.PublicKey, diff --git a/api/spl/programs/reward_manager/accounts.go b/api/spl/programs/reward_manager/accounts.go index 78471c3f..f9b7927e 100644 --- a/api/spl/programs/reward_manager/accounts.go +++ b/api/spl/programs/reward_manager/accounts.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/AudiusProject/audiusd/pkg/rewards" + "github.com/ethereum/go-ethereum/common" bin "github.com/gagliardetto/binary" "github.com/gagliardetto/solana-go" ) @@ -125,19 +126,15 @@ func (data *AttestationsAccountData) UnmarshalWithDecoder(decoder *bin.Decoder) return nil } -func DeriveAuthorityAccount(programId solana.PublicKey, state solana.PublicKey) (solana.PublicKey, uint8, error) { +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 deriveSender(programId solana.PublicKey, authority solana.PublicKey, ethAddress string) (solana.PublicKey, uint8, error) { +func deriveSender(programId solana.PublicKey, authority solana.PublicKey, ethAddress common.Address) (solana.PublicKey, uint8, error) { senderSeedPrefix := []byte(SenderSeedPrefix) - // Remove 0x and decode hex - decodedEthAddress, err := hex.DecodeString(strings.TrimPrefix(ethAddress, "0x")) - if err != nil { - return solana.PublicKey{}, 0, err - } + decodedEthAddress := ethAddress.Bytes() // Pad the eth address if necessary w/ leading 0 senderSeed := make([]byte, len(senderSeedPrefix)+EthAddressByteLength) copy(senderSeed, senderSeedPrefix) @@ -145,7 +142,7 @@ func deriveSender(programId solana.PublicKey, authority solana.PublicKey, ethAdd return solana.FindProgramAddress([][]byte{authority.Bytes()[0:32], senderSeed}, programId) } -func DeriveAttestationsAccount(programId solana.PublicKey, authority solana.PublicKey, disbursementId string) (solana.PublicKey, uint8, error) { +func deriveAttestationsAccount(programId solana.PublicKey, authority solana.PublicKey, disbursementId string) (solana.PublicKey, uint8, error) { attestationsSeed := make([]byte, len(AttestationsSeedPrefix)+len(disbursementId)) copy(attestationsSeed, []byte(AttestationsSeedPrefix)) copy(attestationsSeed[len([]byte(AttestationsSeedPrefix)):], disbursementId) diff --git a/api/spl/programs/reward_manager/client.go b/api/spl/programs/reward_manager/client.go new file mode 100644 index 00000000..1600eba5 --- /dev/null +++ b/api/spl/programs/reward_manager/client.go @@ -0,0 +1,118 @@ +package reward_manager + +import ( + "context" + + "bridgerton.audius.co/api/spl" + "github.com/AudiusProject/audiusd/pkg/rewards" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + "go.uber.org/zap" +) + +// A client for the Reward Manager Program that's immutably preconfigured to an +// instance of the program. +type RewardManagerClient struct { + client *rpc.Client + rewardManagerStateAccount solana.PublicKey + authority solana.PublicKey + rewardManagerState *RewardManagerState + lookupTableAccount solana.PublicKey + lookupTable *spl.AddressLookupTable + logger *zap.Logger +} + +// Creates a RewardManagerClient for the instance defined by the programId and +// state account. +func NewRewardManagerClient( + client *rpc.Client, + programId solana.PublicKey, + stateAccount solana.PublicKey, + lookupTable solana.PublicKey, + logger *zap.Logger, +) (*RewardManagerClient, error) { + authority, _, err := deriveAuthorityAccount(programId, stateAccount) + if err != nil { + return nil, err + } + return &RewardManagerClient{ + client: client, + rewardManagerStateAccount: stateAccount, + authority: authority, + lookupTableAccount: lookupTable, + rewardManagerState: nil, + logger: logger.Named("RewardManagerClient"), + }, nil +} + +// Gets the data of the rewardManagerState account, which has the configuration +// for this instance of the RewardManagerProgram on chain. +func (rc *RewardManagerClient) GetProgramState(ctx context.Context) (*RewardManagerState, error) { + if rc.rewardManagerState != nil { + return rc.rewardManagerState, nil + } + + err := rc.client.GetAccountDataBorshInto(ctx, rc.rewardManagerStateAccount, &rc.rewardManagerState) + if err != nil { + return nil, err + } + return rc.rewardManagerState, nil +} + +// Gets the public key of the rewardManagerState account for this instance. +func (rc *RewardManagerClient) GetProgramStateAccount() solana.PublicKey { + return rc.rewardManagerStateAccount +} + +// Gets the lookup table that has all the registered senders and other accounts +// used in most RewardManagerProgram instructions. +func (rc *RewardManagerClient) GetLookupTable(ctx context.Context) (*spl.AddressLookupTable, error) { + if rc.lookupTable != nil { + return rc.lookupTable, nil + } + + err := rc.client.GetAccountDataInto(ctx, rc.lookupTableAccount, &rc.lookupTable) + if err != nil { + return nil, err + } + return rc.lookupTable, nil +} + +// Gets the public key of the lookup table that has all the registered senders +// and other accounts used in most RewardManagerProgram instructions. +func (rc *RewardManagerClient) GetLookupTableAccount() solana.PublicKey { + return rc.lookupTableAccount +} + +// Gets the claims already submitted for a rewards claim from the account data. +func (rc *RewardManagerClient) GetSubmittedAttestations( + ctx context.Context, + claim rewards.RewardClaim, +) (*AttestationsAccountData, error) { + disbursementId := claim.RewardID + ":" + claim.Specifier + authority, _, err := deriveAuthorityAccount( + ProgramID, + rc.rewardManagerStateAccount, + ) + if err != nil { + return nil, err + } + attestationsAccountAddress, _, err := deriveAttestationsAccount( + ProgramID, + authority, + disbursementId, + ) + if err != nil { + return nil, err + } + attestationsData := AttestationsAccountData{} + err = rc.client.GetAccountDataInto( + ctx, + attestationsAccountAddress, + &attestationsData, + ) + if err != nil { + return nil, err + } + return &attestationsData, nil +} diff --git a/api/spl/programs/secp256k1/Secp256k1Instruction.go b/api/spl/programs/secp256k1/Secp256k1Instruction.go index 64a54ad8..34aa733d 100644 --- a/api/spl/programs/secp256k1/Secp256k1Instruction.go +++ b/api/spl/programs/secp256k1/Secp256k1Instruction.go @@ -1,6 +1,10 @@ package secp256k1 import ( + "errors" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" ag_binary "github.com/gagliardetto/binary" "github.com/gagliardetto/solana-go" ) @@ -29,7 +33,7 @@ type Secp256k1SignatureOffsets struct { } type Secp256k1SignatureData struct { - EthAddress []byte + EthAddress common.Address Message []byte Signature []byte InstructionIndex uint8 @@ -40,7 +44,7 @@ func NewSecp256k1InstructionBuilder() *Secp256k1Instruction { return nd } -func (inst *Secp256k1Instruction) AddSignatureData(ethAddress []byte, message []byte, signature []byte, instructionIndex uint8) *Secp256k1Instruction { +func (inst *Secp256k1Instruction) AddSignatureData(ethAddress common.Address, message []byte, signature []byte, instructionIndex uint8) *Secp256k1Instruction { inst.SignatureDatas = append(inst.SignatureDatas, Secp256k1SignatureData{ EthAddress: ethAddress, Message: message, @@ -58,6 +62,22 @@ func (slice Secp256k1Instruction) GetAccounts() (accounts []*solana.AccountMeta) return } +func (inst Secp256k1Instruction) Validate() error { + for _, sigData := range inst.SignatureDatas { + hash := crypto.Keccak256(sigData.Message) + recoveredPubkey, err := crypto.SigToPub(hash, sigData.Signature) + if err != nil { + return err + } + recoveredEthAddress := crypto.PubkeyToAddress(*recoveredPubkey) + same := recoveredEthAddress.Cmp(sigData.EthAddress) == 0 + if !same { + return errors.New("signature invalid") + } + } + return nil +} + func (inst Secp256k1Instruction) Build() *Instruction { return &Instruction{BaseVariant: ag_binary.BaseVariant{ Impl: inst, @@ -91,7 +111,7 @@ func (obj Secp256k1Instruction) MarshalWithEncoder(encoder *ag_binary.Encoder) ( return err } - err = encoder.WriteBytes(signatureData.EthAddress, false) + err = encoder.WriteBytes(signatureData.EthAddress.Bytes(), false) if err != nil { return err } @@ -137,11 +157,11 @@ func (obj *Secp256k1Instruction) UnmarshalWithDecoder(decoder *ag_binary.Decoder return err } - obj.AddSignatureData(ethAddress, message, signature, offsets.EthAddressInstructionIndex) + obj.AddSignatureData(common.BytesToAddress(ethAddress), message, signature, offsets.EthAddressInstructionIndex) } return nil } -func NewSecp256k1Instruction(ethAddress []byte, message []byte, signature []byte, instructionIndex uint8) *Secp256k1Instruction { +func NewSecp256k1Instruction(ethAddress common.Address, message []byte, signature []byte, instructionIndex uint8) *Secp256k1Instruction { return NewSecp256k1InstructionBuilder().AddSignatureData(ethAddress, message, signature, instructionIndex) } diff --git a/api/spl/programs/secp256k1/Secp256k1Instruction_test.go b/api/spl/programs/secp256k1/Secp256k1Instruction_test.go index 01e07b23..73c7c2d6 100644 --- a/api/spl/programs/secp256k1/Secp256k1Instruction_test.go +++ b/api/spl/programs/secp256k1/Secp256k1Instruction_test.go @@ -5,6 +5,7 @@ import ( "testing" "bridgerton.audius.co/api/spl/programs/secp256k1" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" bin "github.com/gagliardetto/binary" "github.com/gagliardetto/solana-go" @@ -13,8 +14,7 @@ import ( func TestSecp256k1Instruction(t *testing.T) { // Expected results - ethAddress, err := hex.DecodeString("8fcfa10bd3808570987dbb5b1ef4ab74400fbfda") - require.NoError(t, err) + ethAddress := common.HexToAddress("8fcfa10bd3808570987dbb5b1ef4ab74400fbfda") message, err := hex.DecodeString("68d5397bb16195ea47091010f3abb8fc6b5cdfa65f00e1f505000000005f623a33383639383d3e3530373431303135335f00b6462e955da5841b6d9e1e2529b830f00f31bf") require.NoError(t, err) signature, err := hex.DecodeString("f89b2e6f97f95f1306b468b10b1a18df9569b07d9d7b81b241d6fc99d9ec782e4e449f5c3c63836ed52c9344d3de5c3133fead711e421af545822f09bd78cb3900") @@ -51,7 +51,7 @@ func TestUnmarshal(t *testing.T) { ix.UnmarshalWithDecoder(decoder) require.Len(t, ix.SignatureDatas, 1) - require.Equal(t, ethAddress, ix.SignatureDatas[0].EthAddress) + require.Equal(t, ethAddress, ix.SignatureDatas[0].EthAddress.Bytes()) require.Equal(t, message, ix.SignatureDatas[0].Message) require.Equal(t, signature, ix.SignatureDatas[0].Signature) require.Equal(t, instrIndex, ix.SignatureDatas[0].InstructionIndex) @@ -72,7 +72,7 @@ func TestUnmarshalVerifySignature(t *testing.T) { require.NoError(t, err) require.Equal( t, - ix.SignatureDatas[0].EthAddress, + ix.SignatureDatas[0].EthAddress.Bytes(), crypto.PubkeyToAddress(*recoveredWallet).Bytes(), "signature recovers to declared signer eth address", ) diff --git a/api/spl/transaction_sender.go b/api/spl/transaction_sender.go new file mode 100644 index 00000000..03bc7a19 --- /dev/null +++ b/api/spl/transaction_sender.go @@ -0,0 +1,350 @@ +package spl + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "math/rand/v2" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/gagliardetto/solana-go" + computebudget "github.com/gagliardetto/solana-go/programs/compute-budget" + "github.com/gagliardetto/solana-go/rpc" + "github.com/gagliardetto/solana-go/rpc/ws" +) + +const ( + MAX_TRANSACTION_SIZE = 1232 +) + +var ( + // see https://github.com/solana-foundation/solana-web3.js/blob/maintenance/v1.x/src/utils/makeWebsocketUrl.ts + URL_RE = regexp.MustCompile(`(?i)^[^:]+:\/\/([^:[]+|\[[^\]]+\])(:\d+)?(.*)`) +) + +type TransactionSender struct { + feePayers []solana.Wallet + rpcUrls []string + client *rpc.Client +} + +func NewTransactionSender(feePayers []solana.Wallet, rpcProviders []string) *TransactionSender { + return &TransactionSender{ + feePayers: feePayers, + rpcUrls: rpcProviders, + client: rpc.New(rpcProviders[0]), + } +} + +type InstructionError struct { + Index int + Type string + Code int + EncodedTransaction string +} + +func (e *InstructionError) Error() string { + return fmt.Sprintf("instruction error. index: %d, type: %s, code: %d", e.Index, e.Type, e.Code) +} + +type AddComputeBudgetLimitParams struct { + Multiplier float64 + Padding uint32 +} + +func (ts *TransactionSender) AddComputeBudgetLimit(ctx context.Context, tx *solana.TransactionBuilder, params AddComputeBudgetLimitParams) error { + if tx == nil { + return errors.New("can't add compute budget limit to nil transaction") + } + + builtTx, err := tx.Build() + if err != nil { + return err + } + + // Dummy sign tx to satisfy tx format + _, err = builtTx.Sign(func(key solana.PublicKey) *solana.PrivateKey { + return &solana.NewWallet().PrivateKey + }) + + if err != nil { + return err + } + + simOpts := rpc.SimulateTransactionOpts{ + ReplaceRecentBlockhash: true, + Commitment: rpc.CommitmentConfirmed, + } + simResult, err := ts.client.SimulateTransactionWithOpts(ctx, builtTx, &simOpts) + if err != nil { + return err + } + + if simResult.Value.Err != nil { + str, err := json.Marshal(simResult.Value.Err) + if err != nil { + return fmt.Errorf("failed to set compute budget limit. simulation failed: %s", simResult.Value.Err) + } + instErr := InstructionError{} + err = json.Unmarshal(str, &instErr) + if err != nil { + return fmt.Errorf("failed to set compute budget limit. simulation failed: %s", str) + } + return fmt.Errorf("failed to set compute budget limit. simulation failed: %w", &instErr) + } + + if simResult.Value.UnitsConsumed == nil || *simResult.Value.UnitsConsumed == uint64(0) { + return errors.New("failed to set compute budget limit. simulation failed") + } + + computeUnits := uint32(float64(*simResult.Value.UnitsConsumed)*params.Multiplier) + params.Padding + computeBudgetLimitInstr := computebudget.NewSetComputeUnitLimitInstruction(computeUnits) + tx.AddInstruction(computeBudgetLimitInstr.Build()) + return nil +} + +type AddPriorityFeesParams struct { + Percentile int + Multiplier float64 + Minimum float64 + Maximum float64 +} + +func (ts *TransactionSender) AddPriorityFees(ctx context.Context, tx *solana.TransactionBuilder, params AddPriorityFeesParams) error { + if tx == nil { + return errors.New("can't add priority fees to nil transaction") + } + + recentFees, err := ts.client.GetRecentPrioritizationFees(ctx, solana.PublicKeySlice{}) + if err != nil { + return err + } + + sort.Slice(recentFees, func(i, j int) bool { + return recentFees[i].PrioritizationFee > recentFees[j].PrioritizationFee + }) + + percentileIndex := (len(recentFees) - 1) * params.Percentile / 100 + lamportsPerCu := math.Max(float64(recentFees[percentileIndex].PrioritizationFee)*params.Multiplier, params.Minimum) + if params.Maximum > 0 { + lamportsPerCu = math.Min(lamportsPerCu, params.Maximum) + } + computeBudgetPriorityFeesInstr := computebudget.NewSetComputeUnitPriceInstruction(uint64(lamportsPerCu)) + tx.AddInstruction(computeBudgetPriorityFeesInstr.Build()) + return nil +} + +func (ts *TransactionSender) GetFeePayer() solana.Wallet { + return ts.feePayers[rand.IntN(len(ts.feePayers))] +} + +func (ts *TransactionSender) SendTransactionWithRetries(ctx context.Context, txBuilder *solana.TransactionBuilder, commitment rpc.CommitmentType, opts rpc.TransactionOpts) (*solana.Signature, error) { + latestBlockhashRes, err := ts.client.GetLatestBlockhash(ctx, commitment) + if err != nil { + return nil, err + } + txBuilder.SetRecentBlockHash(latestBlockhashRes.Value.Blockhash) + + tx, err := txBuilder.Build() + if err != nil { + return nil, err + } + + _, err = tx.Sign(func(key solana.PublicKey) *solana.PrivateKey { + for _, feePayer := range ts.feePayers { + if key.Equals(feePayer.PublicKey()) { + return &feePayer.PrivateKey + } + } + return nil + }) + if err != nil { + return nil, err + } + + signature := tx.Signatures[0] + serializedTx, err := tx.MarshalBinary() + if err != nil { + return nil, err + } + + websocketUrl, err := makeWebsocketUrl(ts.rpcUrls[0]) + if err != nil { + return nil, err + } + + wsClient, err := ws.Connect(ctx, websocketUrl) + if err != nil { + return nil, err + } + defer wsClient.Close() + + subscription, err := wsClient.SignatureSubscribe(signature, commitment) + if err != nil { + return nil, err + } + defer subscription.Unsubscribe() + + subCtx, cancel := context.WithCancel(context.Background()) + resChan := make(chan *ws.SignatureResult) + errChan := make(chan error, 1) + go func() { + for { + select { + case <-subCtx.Done(): + return + default: + for _, rpcUrl := range ts.rpcUrls { + tempClient := rpc.New(rpcUrl) + go func() { + maxRetries := uint(0) + _, err := tempClient.SendRawTransactionWithOpts(subCtx, serializedTx, rpc.TransactionOpts{ + MaxRetries: &maxRetries, + SkipPreflight: true, + }) + if err != nil { + errChan <- err + return + } + }() + } + time.Sleep(time.Second * 5) + } + } + }() + + go func() { + got, err := subscription.Recv(subCtx) + if err != nil { + errChan <- err + return + } + + if got == nil { + errChan <- errors.New("failed to get transaction signature status") + return + } + + if got.Value.Err != nil { + str, err := json.Marshal(got.Value.Err) + if err != nil { + errChan <- errors.New("failed to confirm transaction") + } + + instErr := InstructionError{} + err = json.Unmarshal(str, &instErr) + if err != nil { + errChan <- fmt.Errorf("failed to confirm transaction: %s", str) + } + + errChan <- fmt.Errorf("failed to confirm transaction: %w", &instErr) + } + resChan <- got + }() + + go func() { + for { + select { + case <-subCtx.Done(): + return + default: + res, err := ts.client.GetBlockHeight(subCtx, rpc.CommitmentConfirmed) + if err != nil { + errChan <- err + return + } + if latestBlockhashRes.Value.LastValidBlockHeight < res { + errChan <- errors.New("failed to confirm transaction: TransactionExpiredBlockHeightExceeded") + return + } + time.Sleep(time.Second * 5) + } + } + }() + + select { + case err := <-errChan: + cancel() + return nil, err + case <-resChan: + cancel() + return &signature, nil + } +} + +func (e *InstructionError) UnmarshalJSON(b []byte) error { + var rawData map[string]any + if err := json.Unmarshal(b, &rawData); err != nil { + return fmt.Errorf("failed to unmarshal JSON: %w", err) + } + + parsedErr, ok := rawData["InstructionError"].([]any) + if !ok || len(parsedErr) != 2 { + return errors.New("invalid or missing InstructionError field") + } + + instrIndex, ok := parsedErr[0].(float64) + if !ok { + return errors.New("invalid instruction index") + } + + errStr, ok := parsedErr[1].(string) + if ok { + e.Index = int(instrIndex) + e.Type = errStr + return nil + } + + errObj, ok := parsedErr[1].(map[string]any) + if !ok { + return errors.New("invalid error object") + } + + customErr, ok := errObj["Custom"].(float64) + if !ok { + return errors.New("invalid custom error") + } + + e.Index = int(instrIndex) + e.Code = int(customErr) + e.Type = "Custom" + + return nil +} + +func makeWebsocketUrl(endpoint string) (string, error) { + match := URL_RE.FindStringSubmatch(endpoint) + + if len(match) < 4 { + return "", errors.New("bad rpc url") + } + hostish := match[1] + portWithColon := match[2] + rest := match[3] + + protocol := "ws:" + if strings.HasPrefix(endpoint, "https") { + protocol = "wss:" + } + + startPort := -1 + if portWithColon != "" { + parsedPort, err := strconv.ParseInt(portWithColon[1:], 10, 32) + if err != nil { + return "", err + } + startPort = int(parsedPort) + } + + websocketPort := "" + if startPort > 0 { + websocketPort = strconv.FormatInt(int64(startPort+1), 10) + } + return fmt.Sprintf("%s//%s%s%s", protocol, hostish, websocketPort, rest), nil +} From 1924094bfb00308a2ad382015b907081525855d5 Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Thu, 1 May 2025 13:12:15 -0700 Subject: [PATCH 3/5] changes to use TransactionSender, RewardManagerClient, and simplify attestation fetching/submitting --- api/server.go | 68 ++++-- api/v1_claim_rewards.go | 529 ++++++++++++++++++++-------------------- 2 files changed, 311 insertions(+), 286 deletions(-) diff --git a/api/server.go b/api/server.go index 446eb47c..c8226d0a 100644 --- a/api/server.go +++ b/api/server.go @@ -9,12 +9,15 @@ import ( "time" "bridgerton.audius.co/api/dbv1" + "bridgerton.audius.co/api/spl" + "bridgerton.audius.co/api/spl/programs/reward_manager" "bridgerton.audius.co/config" "bridgerton.audius.co/trashid" "github.com/AudiusProject/audiusd/pkg/rewards" adapter "github.com/axiomhq/axiom-go/adapters/zap" "github.com/axiomhq/axiom-go/axiom" "github.com/ethereum/go-ethereum/crypto" + "github.com/gagliardetto/solana-go/rpc" "github.com/gofiber/contrib/fiberzap/v2" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" @@ -117,23 +120,42 @@ func NewApiServer(config config.Config) *ApiServer { panic(err) } + solanaRpc := rpc.New(config.SolanaConfig.RpcProviders[0]) + rewardAttester := rewards.NewRewardAttester(privateKey, []rewards.Reward{}) + transactionSender := spl.NewTransactionSender( + config.SolanaConfig.FeePayers, + config.SolanaConfig.RpcProviders, + ) + rewardManagerClient, err := reward_manager.NewRewardManagerClient( + solanaRpc, + config.SolanaConfig.RewardManagerProgramID, + config.SolanaConfig.RewardManagerState, + config.SolanaConfig.RewardManagerLookupTable, + logger, + ) + if err != nil { + panic(err) + } + app := &ApiServer{ App: fiber.New(fiber.Config{ JSONEncoder: json.Marshal, JSONDecoder: json.Unmarshal, ErrorHandler: errorHandler(logger), }), - pool: pool, - queries: dbv1.New(pool), - logger: logger, - started: time.Now(), - resolveHandleCache: resolveHandleCache, - resolveWalletCache: resolveWalletCache, - resolveGrantCache: resolveGrantCache, - rewardAttester: *rewards.NewRewardAttester(privateKey, []rewards.Reward{}), - solanaConfig: config.SolanaConfig, - antiAbuseOracles: config.AntiAbuseOracles, - validators: config.Nodes, + pool: pool, + queries: dbv1.New(pool), + logger: logger, + started: time.Now(), + resolveHandleCache: resolveHandleCache, + resolveWalletCache: resolveWalletCache, + resolveGrantCache: resolveGrantCache, + rewardAttester: *rewardAttester, + transactionSender: *transactionSender, + rewardManagerClient: *rewardManagerClient, + solanaConfig: config.SolanaConfig, + antiAbuseOracles: config.AntiAbuseOracles, + validators: config.Nodes, } app.Use(recover.New(recover.Config{ @@ -273,17 +295,19 @@ func NewApiServer(config config.Config) *ApiServer { type ApiServer struct { *fiber.App - pool *pgxpool.Pool - queries *dbv1.Queries - logger *zap.Logger - started time.Time - resolveHandleCache otter.Cache[string, int32] - resolveWalletCache otter.Cache[string, int32] - resolveGrantCache otter.Cache[string, bool] - rewardAttester rewards.RewardAttester - solanaConfig config.SolanaConfig - antiAbuseOracles []string - validators []config.Node + pool *pgxpool.Pool + queries *dbv1.Queries + logger *zap.Logger + started time.Time + resolveHandleCache otter.Cache[string, int32] + resolveWalletCache otter.Cache[string, int32] + resolveGrantCache otter.Cache[string, bool] + rewardManagerClient reward_manager.RewardManagerClient + rewardAttester rewards.RewardAttester + transactionSender spl.TransactionSender + solanaConfig config.SolanaConfig + antiAbuseOracles []string + validators []config.Node } func (app *ApiServer) home(c *fiber.Ctx) error { diff --git a/api/v1_claim_rewards.go b/api/v1_claim_rewards.go index 61ec1a68..9f2b8cea 100644 --- a/api/v1_claim_rewards.go +++ b/api/v1_claim_rewards.go @@ -5,6 +5,8 @@ import ( "context" "encoding/hex" "encoding/json" + "errors" + "fmt" "io" "math/rand/v2" "net/http" @@ -23,15 +25,18 @@ import ( "bridgerton.audius.co/config" "bridgerton.audius.co/trashid" "github.com/AudiusProject/audiusd/pkg/rewards" + "github.com/ethereum/go-ethereum/common" "github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go/rpc" "github.com/gofiber/fiber/v2" + "go.uber.org/zap" "golang.org/x/sync/errgroup" ) type SenderAttestation struct { - Signature string - Address string + Message []byte + Signature []byte + EthAddress common.Address } const ( @@ -39,14 +44,10 @@ const ( antiAbuseOracleAttestationPath = "/attestation" ) -var rewardManagerStateData *reward_manager.RewardManagerState = nil -var rewardManagerAddressLookupTable *spl.AddressLookupTable var antiAbuseOracleMap map[string]string = make(map[string]string) type GetAntiAbuseOracleAttestationParams struct { - ChallengeID string - Specifier string - Amount uint64 + Claim rewards.RewardClaim Handle string AntiAbuseOracleEndpoint string } @@ -62,9 +63,9 @@ type AntiAbuseOracleAttestationResponseBody struct { // Gets a reward claim attestation from Anti Abuse Oracle func getAntiAbuseOracleAttestation(args GetAntiAbuseOracleAttestationParams) (*SenderAttestation, error) { attestationBody := AntiAbuseOracleAttestationRequestBody{ - ChallengeID: args.ChallengeID, - Specifier: args.Specifier, - Amount: args.Amount, + ChallengeID: args.Claim.RewardID, + Specifier: args.Claim.Specifier, + Amount: args.Claim.Amount, } reqBody, err := json.Marshal(attestationBody) @@ -92,21 +93,40 @@ func getAntiAbuseOracleAttestation(args GetAntiAbuseOracleAttestationParams) (*S } if resp.StatusCode != 200 { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get oracle attestation from "+args.AntiAbuseOracleEndpoint+". Error "+resp.Status+": "+string(body)) + return nil, fmt.Errorf("failed to get oracle attestation from %s. status %d: %s", + args.AntiAbuseOracleEndpoint, + resp.StatusCode, + body, + ) } - parsedBody := AntiAbuseOracleAttestationResponseBody{} - err = json.Unmarshal(body, &parsedBody) + respBody := AntiAbuseOracleAttestationResponseBody{} + err = json.Unmarshal(body, &respBody) if err != nil { return nil, err } address, exists := antiAbuseOracleMap[args.AntiAbuseOracleEndpoint] if !exists { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to find AAO address for "+args.AntiAbuseOracleEndpoint) + return nil, fmt.Errorf("failed to find AAO address for %s", args.AntiAbuseOracleEndpoint) + } + message, err := args.Claim.Compile() + if err != nil { + return nil, err + } + + // Pad the start if there's a missing leading zero + signature := respBody.Result + if len(signature)%2 == 1 { + signature = "0" + signature + } + signatureBytes, err := hex.DecodeString(strings.TrimPrefix(signature, "0x")) + if err != nil { + return nil, err } attestation := SenderAttestation{ - Address: address, - Signature: parsedBody.Result, + EthAddress: common.HexToAddress(address), + Message: message, + Signature: signatureBytes, } return &attestation, nil } @@ -119,10 +139,11 @@ func getValidators(validators []config.Node, count int, excludedOperators []stri shuffled[i], shuffled[j] = shuffled[j], shuffled[i] }) - selected := make([]string, 0) - for _, node := range shuffled { + selected := make([]string, count) + for i := range count { + node := shuffled[i] if !slices.Contains(excludedOperators, node.OwnerWallet) { - selected = append(selected, node.Endpoint) + selected[i] = node.Endpoint excludedOperators = append(excludedOperators, node.OwnerWallet) } } @@ -130,13 +151,10 @@ func getValidators(validators []config.Node, count int, excludedOperators []stri } type GetValidatorAttestationParams struct { - Validator string - ChallengeID string - Specifier string - Amount uint64 - UserEthAddress string - AntiAbuseOracleAddress string - Signature string + Validator string + Claim rewards.RewardClaim + UserEthAddress string + Signature string } type ValidatorAttestationResponseBody struct { @@ -147,11 +165,11 @@ type ValidatorAttestationResponseBody struct { // Gets a reward claim attestation from a validator. func getValidatorAttestation(args GetValidatorAttestationParams) (*SenderAttestation, error) { query := url.Values{} - query.Add("reward_id", args.ChallengeID) - query.Add("specifier", args.Specifier) - query.Add("eth_recipient_address", args.UserEthAddress) - query.Add("oracle_address", args.AntiAbuseOracleAddress) - query.Add("amount", strconv.FormatUint(args.Amount, 10)) + query.Add("reward_id", args.Claim.RewardID) + query.Add("specifier", args.Claim.Specifier) + query.Add("eth_recipient_address", args.Claim.RecipientEthAddress) + query.Add("oracle_address", args.Claim.AntiAbuseOracleEthAddress) + query.Add("amount", strconv.FormatUint(args.Claim.Amount, 10)) query.Add("signature", args.Signature) base, err := url.Parse(args.Validator) @@ -173,14 +191,37 @@ func getValidatorAttestation(args GetValidatorAttestationParams) (*SenderAttesta return nil, err } if resp.StatusCode != 200 { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get validator attestation from "+args.Validator+". Error "+resp.Status+": "+string(body)) + return nil, fmt.Errorf("failed to get validator attestation from %s. status %d: %s", + args.Validator, + resp.StatusCode, + body, + ) + } + respBody := ValidatorAttestationResponseBody{} + err = json.Unmarshal(body, &respBody) + if err != nil { + return nil, err } - attestation := ValidatorAttestationResponseBody{} - err = json.Unmarshal(body, &attestation) + + // Pad the start if there's a missing leading zero + signature := respBody.Attestation + if len(signature)%2 == 1 { + signature = "0" + signature + } + signatureBytes, err := hex.DecodeString(strings.TrimPrefix(signature, "0x")) if err != nil { return nil, err } - return &SenderAttestation{Address: attestation.Owner, Signature: attestation.Attestation}, nil + message, err := args.Claim.Compile() + if err != nil { + return nil, err + } + attestation := SenderAttestation{ + EthAddress: common.HexToAddress(respBody.Owner), + Message: message, + Signature: signatureBytes, + } + return &attestation, nil } // Gets reward claim attestations from AAO and Validators in parallel. @@ -189,13 +230,16 @@ func fetchAttestations( rewardClaim rewards.RewardClaim, handle string, validators []string, - antiAbuseOracleEndpoint string, - antiAbuseOracleAddress string, + antiAbuseOracle config.Node, signature string, hasAntiAbuseOracleAttestation bool, -) (*SenderAttestation, []SenderAttestation, error) { - var aaoAttestation *SenderAttestation - validatorAttestations := make([]SenderAttestation, len(validators)) +) ([]SenderAttestation, error) { + + offset := 0 + if !hasAntiAbuseOracleAttestation { + offset = 1 + } + attestations := make([]SenderAttestation, len(validators)+offset) ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() @@ -203,176 +247,159 @@ func fetchAttestations( if !hasAntiAbuseOracleAttestation { innerGroup.Go(func() error { + aaoClaim := rewardClaim + aaoClaim.AntiAbuseOracleEthAddress = "" getAntiAbuseAttestationParams := GetAntiAbuseOracleAttestationParams{ - ChallengeID: rewardClaim.RewardID, - Specifier: rewardClaim.Specifier, - Amount: rewardClaim.Amount, + Claim: aaoClaim, Handle: handle, - AntiAbuseOracleEndpoint: antiAbuseOracleEndpoint, + AntiAbuseOracleEndpoint: antiAbuseOracle.Endpoint, } - res, err := getAntiAbuseOracleAttestation(getAntiAbuseAttestationParams) - if err == nil { - aaoAttestation = res + aaoAttestation, err := getAntiAbuseOracleAttestation(getAntiAbuseAttestationParams) + if err != nil { + return err } - return err + attestations[0] = *aaoAttestation + return nil }) } for i, validator := range validators { innerGroup.Go(func() error { getValidatorAttestationParams := GetValidatorAttestationParams{ - Validator: validator, - ChallengeID: rewardClaim.RewardID, - Specifier: rewardClaim.Specifier, - Amount: rewardClaim.Amount, - UserEthAddress: rewardClaim.RecipientEthAddress, - AntiAbuseOracleAddress: antiAbuseOracleAddress, - Signature: signature, + Validator: validator, + Claim: rewardClaim, + UserEthAddress: rewardClaim.RecipientEthAddress, + Signature: signature, } validatorAttestation, err := getValidatorAttestation(getValidatorAttestationParams) - if err == nil { - validatorAttestations[i] = *validatorAttestation + + if err != nil { + return err } - return err + attestations[i+offset] = *validatorAttestation + return nil }) } err := innerGroup.Wait() if err != nil { - return nil, nil, err + return nil, err } - return aaoAttestation, validatorAttestations, nil + return attestations, nil } -// Builds a Solana transaction to claim a reward from the attestations. -func buildRewardClaimTransaction( +// Builds a Solana transaction to claim a reward from the attestations and sends it with retries. +func sendRewardClaimTransactions( + ctx context.Context, + userBank solana.PublicKey, + rewardManagerClient *reward_manager.RewardManagerClient, + transactionSender *spl.TransactionSender, rewardClaim rewards.RewardClaim, - aaoAttestation *SenderAttestation, - validatorAttestations []SenderAttestation, - solanaConfig config.SolanaConfig, - client *rpc.Client, -) (*solana.Transaction, error) { - feePayer := solanaConfig.FeePayers[rand.IntN(len(solanaConfig.FeePayers))] - - bankAccount, err := claimable_tokens.DeriveUserBankAccount(solanaConfig.MintAudio, rewardClaim.RecipientEthAddress) - if err != nil { - return nil, err - } - - // Build the transaction - tx := solana.TransactionBuilder{} - - if aaoAttestation != nil { - aaoAddressBytes, err := hex.DecodeString(strings.TrimPrefix(aaoAttestation.Address, "0x")) - if err != nil { - return nil, err - } + attestations []SenderAttestation, +) ([]solana.Signature, error) { + tx := solana.NewTransactionBuilder() - // Pad the start if there's a missing leading zero - if len(aaoAttestation.Signature)%2 == 1 { - aaoAttestation.Signature = "0" + aaoAttestation.Signature - } - aaoSignatureBytes, err := hex.DecodeString(aaoAttestation.Signature) - if err != nil { - return nil, err - } - - aaoClaim := rewardClaim - // AAO claims don't have the oracle appended - aaoClaim.AntiAbuseOracleEthAddress = "" - aaoAttestationBytes, err := aaoClaim.Compile() - if err != nil { - return nil, err - } + feePayer := transactionSender.GetFeePayer() + tx.SetFeePayer(feePayer.PublicKey()) - // Add AAO attestation instructions - aaoSubmitAttestationSecpInstruction := secp256k1.NewSecp256k1Instruction( - aaoAddressBytes, - aaoAttestationBytes, - aaoSignatureBytes, - 0, + for i, attestation := range attestations { + instructionIndex := uint8(i * 2) + submitAttestationSecpInstruction := secp256k1.NewSecp256k1Instruction( + attestation.EthAddress, + attestation.Message, + attestation.Signature, + instructionIndex, ).Build() - aaoSubmitAttestationInstruction := reward_manager.NewSubmitAttestationInstruction( + submitAttestationInstruction := reward_manager.NewSubmitAttestationInstruction( rewardClaim.RewardID, rewardClaim.Specifier, - aaoAttestation.Address, - solanaConfig.RewardManagerState, + attestation.EthAddress, + rewardManagerClient.GetProgramStateAccount(), feePayer.PublicKey(), ).Build() - tx.AddInstruction(aaoSubmitAttestationSecpInstruction) - tx.AddInstruction(aaoSubmitAttestationInstruction) + tx.AddInstruction(submitAttestationSecpInstruction) + tx.AddInstruction(submitAttestationInstruction) } - // Add Validator attestation instructions - attestationBytes, err := rewardClaim.Compile() + lookupTable, err := rewardManagerClient.GetLookupTable(ctx) if err != nil { return nil, err } - for i, attestation := range validatorAttestations { - instructionIndex := uint8(i*2 + 2) + addressLookupTables := map[solana.PublicKey]solana.PublicKeySlice{ + rewardManagerClient.GetLookupTableAccount(): lookupTable.Addresses, + } - senderEthAddressBytes, err := hex.DecodeString(strings.TrimPrefix(attestation.Address, "0x")) + txSignatures := make([]solana.Signature, 0) + + // If no attestations need to be submitted, don't need to split into two txs + if len(attestations) > 0 { + preTx := tx + preTx.WithOpt(solana.TransactionAddressTables(addressLookupTables)) + err = transactionSender.AddPriorityFees(ctx, preTx, spl.AddPriorityFeesParams{Percentile: 99, Multiplier: 1}) if err != nil { return nil, err } - - // Pad the start if there's a missing leading zero - if len(attestation.Signature)%2 == 1 { - attestation.Signature = "0" + attestation.Signature + err = transactionSender.AddComputeBudgetLimit(ctx, preTx, spl.AddComputeBudgetLimitParams{Padding: 1000, Multiplier: 1.2}) + if err != nil { + return nil, err } - signatureBytes, err := hex.DecodeString(strings.TrimPrefix(attestation.Signature, "0x")) + preTxBuilt, err := preTx.Build() if err != nil { return nil, err } - submitAttestationSecpInstruction := secp256k1.NewSecp256k1Instruction( - senderEthAddressBytes, - attestationBytes, - signatureBytes, - instructionIndex, - ).Build() - submitAttestationInstruction := reward_manager.NewSubmitAttestationInstruction( - rewardClaim.RewardID, - rewardClaim.Specifier, - attestation.Address, - solanaConfig.RewardManagerState, - feePayer.PublicKey(), - ).Build() - tx.AddInstruction(submitAttestationSecpInstruction) - tx.AddInstruction(submitAttestationInstruction) + preTxBinary, err := preTxBuilt.MarshalBinary() + if err != nil { + return nil, err + } + + // Check to see if there's room for the evaluate instruction. + // If not, send the attestations in a separate transaction. + estimatedEvaluateInstructionSize := 205 + threshold := spl.MAX_TRANSACTION_SIZE - estimatedEvaluateInstructionSize + if len(preTxBinary) > threshold { + sig, err := transactionSender.SendTransactionWithRetries(ctx, preTx, rpc.CommitmentConfirmed, rpc.TransactionOpts{}) + if err != nil { + return nil, err + } + txSignatures = append(txSignatures, *sig) + tx = solana.NewTransactionBuilder() + } } - // Add evaluate instructions + state, err := rewardManagerClient.GetProgramState(ctx) + if err != nil { + return nil, err + } evaluateAttestationInstruction := reward_manager.NewEvaluateAttestationInstruction( rewardClaim.RewardID, rewardClaim.Specifier, - rewardClaim.RecipientEthAddress, + common.HexToAddress(rewardClaim.RecipientEthAddress), rewardClaim.Amount*1e8, // Convert to wAUDIO wei - rewardClaim.AntiAbuseOracleEthAddress, - solanaConfig.RewardManagerState, - rewardManagerStateData.TokenAccount, - bankAccount, + common.HexToAddress(rewardClaim.AntiAbuseOracleEthAddress), + rewardManagerClient.GetProgramStateAccount(), + state.TokenAccount, + userBank, feePayer.PublicKey(), ).Build() tx.AddInstruction(evaluateAttestationInstruction) - tx.SetFeePayer(feePayer.PublicKey()) - addressLookupTables := map[solana.PublicKey]solana.PublicKeySlice{ - solanaConfig.RewardManagerLookupTable: rewardManagerAddressLookupTable.Addresses, - } tx.WithOpt(solana.TransactionAddressTables(addressLookupTables)) - - recent, err := client.GetLatestBlockhash(context.TODO(), rpc.CommitmentFinalized) + err = transactionSender.AddComputeBudgetLimit(ctx, tx, spl.AddComputeBudgetLimitParams{Padding: 1000, Multiplier: 1.2}) + if err != nil { + return nil, err + } + err = transactionSender.AddPriorityFees(ctx, tx, spl.AddPriorityFeesParams{Percentile: 99, Multiplier: 1}) if err != nil { return nil, err } - tx.SetRecentBlockHash(recent.Value.Blockhash) - transaction, err := tx.Build() + sig, err := transactionSender.SendTransactionWithRetries(ctx, tx, rpc.CommitmentConfirmed, rpc.TransactionOpts{}) if err != nil { return nil, err } - return transaction, nil + txSignatures = append(txSignatures, *sig) + return txSignatures, nil } type RelayTransactionRequest struct { @@ -382,91 +409,41 @@ type RelayTransactionResponse struct { Signature string `json:"signature"` } -// Relays transactions to the Solana Relay plugin. -// TODO: Move Solana Relay into Bridge -func relayTransaction(relay string, transaction *solana.Transaction) (string, error) { - encoded, err := transaction.ToBase64() - if err != nil { - return "", err - } - - jsonData, err := json.Marshal(RelayTransactionRequest{Transaction: encoded}) - if err != nil { - return "", err - } - - resp, err := http.Post(relay, "application/json", bytes.NewBuffer(jsonData)) - if err != nil { - return "", err - } - - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - - if resp.StatusCode != 200 { - return "", fiber.NewError(fiber.StatusInternalServerError, "Failed to relay transaction: "+encoded+". Error "+resp.Status+": "+string(body)) - } - - parsedResp := RelayTransactionResponse{} - err = json.Unmarshal(body, &parsedResp) - if err != nil { - return "", err - } - - return parsedResp.Signature, nil -} - // Claims an individual reward. -func claimReward(ctx context.Context, row dbv1.GetUndisbursedChallengesRow, antiAbuseOracleAddress string, antiAbuseOracleEndpoint string, rewardAttester *rewards.RewardAttester, solanaConfig config.SolanaConfig, validators []config.Node) (string, error) { - feePayer := solanaConfig.FeePayers[rand.IntN(len(solanaConfig.FeePayers))] - +func claimReward( + ctx context.Context, + rewardManagerClient *reward_manager.RewardManagerClient, + row dbv1.GetUndisbursedChallengesRow, + userBank solana.PublicKey, + antiAbuseOracle config.Node, + rewardAttester *rewards.RewardAttester, + transactionSender *spl.TransactionSender, + validators []config.Node, +) ([]solana.Signature, error) { rewardClaim := rewards.RewardClaim{ RewardID: row.ChallengeID, - Amount: uint64(5), // TODO: Change me! + Amount: uint64(1000), // TODO: Change me! Specifier: row.Specifier, RecipientEthAddress: row.Wallet.String, - AntiAbuseOracleEthAddress: antiAbuseOracleAddress, + AntiAbuseOracleEthAddress: antiAbuseOracle.DelegateOwnerWallet, } - handle := row.Handle.String - // Get the RewardManagerState - client := rpc.New(solanaConfig.RpcProviders[0]) - if rewardManagerStateData == nil { - rewardManagerStateData = &reward_manager.RewardManagerState{} - err := client.GetAccountDataBorshInto(ctx, solanaConfig.RewardManagerState, rewardManagerStateData) - if err != nil { - return "", err - } + rewardManagerStateData, err := rewardManagerClient.GetProgramState(ctx) + if err != nil { + return nil, err } - // Get the address lookup table - if rewardManagerAddressLookupTable == nil { - rewardManagerAddressLookupTable = &spl.AddressLookupTable{} - err := client.GetAccountDataInto(ctx, solanaConfig.RewardManagerLookupTable, rewardManagerAddressLookupTable) - if err != nil { - return "", err + attestationsData, err := rewardManagerClient.GetSubmittedAttestations(ctx, rewardClaim) + if err != nil { + // If not found, then it's empty. Use default values for the purpose + // of getting an empty list of messages + if err.Error() != "not found" { + return nil, err } + attestationsData = &reward_manager.AttestationsAccountData{} } - // Get current claim state to do minimum work to claim - disbursementId := rewardClaim.RewardID + ":" + rewardClaim.Specifier - authority, _, err := reward_manager.DeriveAuthorityAccount(reward_manager.ProgramID, solanaConfig.RewardManagerState) - if err != nil { - return "", err - } - attestationsAccountAddress, _, err := reward_manager.DeriveAttestationsAccount(reward_manager.ProgramID, authority, disbursementId) - if err != nil { - return "", err - } - attestationsData := reward_manager.AttestationsAccountData{} - err = client.GetAccountDataInto(ctx, attestationsAccountAddress, &attestationsData) - if err != nil { - return "", err - } hasAntiAbuseOracleAttestation := false existingValidatorOwners := make([]string, 0) for _, attestation := range attestationsData.Messages { @@ -480,52 +457,42 @@ func claimReward(ctx context.Context, row dbv1.GetUndisbursedChallengesRow, anti // Attest from Bridge to get authority signature _, signature, err := rewardAttester.Attest(rewardClaim) if err != nil { - return "", err + return nil, err } - // Fetch AAO and validator attestations - selectedValidators, err := getValidators(validators, int(rewardManagerStateData.MinVotes), existingValidatorOwners) + // Fetch AAO and validator attestations + validatorsNeeded := int(rewardManagerStateData.MinVotes) - len(existingValidatorOwners) + selectedValidators, err := getValidators(validators, validatorsNeeded, existingValidatorOwners) if err != nil { - return "", err + return nil, err } - aaoAttestation, validatorAttestations, err := fetchAttestations( + attestations, err := fetchAttestations( ctx, rewardClaim, handle, selectedValidators, - antiAbuseOracleEndpoint, - antiAbuseOracleAddress, + antiAbuseOracle, signature, hasAntiAbuseOracleAttestation, ) if err != nil { - return "", err + return nil, err } - // Build transaction - transaction, err := buildRewardClaimTransaction( + // Build and send solana transactions + signatures, err := sendRewardClaimTransactions( + ctx, + userBank, + rewardManagerClient, + transactionSender, rewardClaim, - aaoAttestation, - validatorAttestations, - solanaConfig, - client, + attestations, ) if err != nil { - return "", err - } - - transaction.Sign(func(key solana.PublicKey) *solana.PrivateKey { - return &feePayer.PrivateKey - }) - - // Send transaction - txSig, err := relayTransaction(solanaConfig.SolanaRelay, transaction) - - if err != nil { - return "", err + return nil, err } - return txSig, nil + return signatures, nil } type HealthCheckResponse struct { @@ -534,34 +501,40 @@ type HealthCheckResponse struct { // Selects a healthy Anti Abuse Oracle and gets its address. // TODO: Implement AAO in bridge and use config -func getAntiAbuseOracle(antiAbuseOracleEndpoints []string) (endpoint string, address string, err error) { +func getAntiAbuseOracle(antiAbuseOracleEndpoints []string) (node *config.Node, err error) { oracleEndpoint := antiAbuseOracleEndpoints[rand.IntN(len(antiAbuseOracleEndpoints))] if value, exists := antiAbuseOracleMap[oracleEndpoint]; exists { - return oracleEndpoint, value, nil + return &config.Node{ + DelegateOwnerWallet: value, + Endpoint: oracleEndpoint, + }, nil } resp, err := http.Get(oracleEndpoint + "/health_check") if err != nil { - return "", "", err + return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - return "", "", err + return nil, err } if resp.StatusCode != 200 { - return "", "", fiber.NewError(fiber.StatusInternalServerError, "Failed to get oracle from "+oracleEndpoint+". Error "+resp.Status+": "+string(body)) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get oracle from "+oracleEndpoint+". Error "+resp.Status+": "+string(body)) } health := &HealthCheckResponse{} err = json.Unmarshal(body, health) if err != nil { - return "", "", err + return nil, err } antiAbuseOracleMap[oracleEndpoint] = health.AntiAbuseWalletPubkey - return oracleEndpoint, health.AntiAbuseWalletPubkey, nil + return &config.Node{ + DelegateOwnerWallet: health.AntiAbuseWalletPubkey, + Endpoint: oracleEndpoint, + }, nil } // Claims all the filtered undisbursed rewards for a user. @@ -595,29 +568,57 @@ func (api *ApiServer) v1ClaimRewards(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "No rewards to claim") } - antiAbuseOracleEndpoint, antiAbuseOracleAddress, err := getAntiAbuseOracle(api.antiAbuseOracles) - + antiAbuseOracle, err := getAntiAbuseOracle(api.antiAbuseOracles) if err != nil { return err } - signatures := make([]string, len(undisbursedRows)) + signatures := make(map[string]map[string][]solana.Signature) ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() g, ctx := errgroup.WithContext(ctx) - for i, row := range undisbursedRows { + for _, row := range undisbursedRows { + bankAccount, err := claimable_tokens.DeriveUserBankAccount(api.solanaConfig.MintAudio, row.Wallet.String) + if err != nil { + return err + } g.Go(func() error { sig, err := claimReward( ctx, + &api.rewardManagerClient, row, - antiAbuseOracleAddress, - antiAbuseOracleEndpoint, + bankAccount, + *antiAbuseOracle, &api.rewardAttester, - api.solanaConfig, + &api.transactionSender, api.validators, ) - signatures[i] = sig - return err + if err != nil { + var instrErr *spl.InstructionError + if errors.As(err, &instrErr) { + api.logger.Error("failed to claim challenge reward. transaction failed to send.", + zap.String("handle", row.Handle.String), + zap.String("rewardId", row.ChallengeID), + zap.String("specifier", row.Specifier), + zap.String("transaction", instrErr.EncodedTransaction), + zap.String("customError", reward_manager.RewardManagerError(instrErr.Code).String()), + zap.Error(err), + ) + } else { + api.logger.Error("failed to claim challenge reward.", + zap.String("handle", row.Handle.String), + zap.String("rewardId", row.ChallengeID), + zap.String("specifier", row.Specifier), + zap.Error(err), + ) + } + return err + } + if signatures[row.ChallengeID] == nil { + signatures[row.ChallengeID] = make(map[string][]solana.Signature) + } + signatures[row.ChallengeID][row.Specifier] = sig + return nil }) } err = g.Wait() From 88b72b8beba4f86904430220dfedb6ad9c8b268f Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Thu, 1 May 2025 13:12:37 -0700 Subject: [PATCH 4/5] Update solana-go to support websockets --- go.mod | 5 ++++- go.sum | 8 ++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 374287bf..dec3bd98 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/axiomhq/axiom-go v0.23.0 github.com/ethereum/go-ethereum v1.15.8 github.com/gagliardetto/binary v0.8.0 - github.com/gagliardetto/solana-go v1.12.0 + github.com/gagliardetto/solana-go v1.12.1-0.20250314202648-ca3f5f643435 github.com/gofiber/contrib/fiberzap/v2 v2.1.5 github.com/gofiber/fiber/v2 v2.52.6 github.com/jackc/pgtype v1.14.4 @@ -31,6 +31,7 @@ require ( github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 // indirect github.com/andybalholm/brotli v1.1.1 // indirect github.com/blendle/zapdriver v1.3.1 // indirect + github.com/buger/jsonparser v1.1.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cometbft/cometbft v1.0.1 // indirect github.com/cometbft/cometbft/api v1.0.0 // indirect @@ -50,6 +51,8 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/rpc v1.2.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect github.com/holiman/uint256 v1.3.2 // indirect github.com/jackc/pgio v1.0.0 // indirect diff --git a/go.sum b/go.sum index 71701550..831b7aec 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,8 @@ github.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHf github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc= github.com/btcsuite/btcd/btcutil v1.1.6 h1:zFL2+c3Lb9gEgqKNzowKUPQNb8jV7v5Oaodi/AYFd6c= github.com/btcsuite/btcd/btcutil v1.1.6/go.mod h1:9dFymx8HpuLqBnsPELrImQeTQfKBQqzqGbbV3jK55aE= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= @@ -49,6 +51,8 @@ github.com/gagliardetto/binary v0.8.0 h1:U9ahc45v9HW0d15LoN++vIXSJyqR/pWw8DDlhd7 github.com/gagliardetto/binary v0.8.0/go.mod h1:2tfj51g5o9dnvsc+fL3Jxr22MuWzYXwx9wEoN0XQ7/c= github.com/gagliardetto/solana-go v1.12.0 h1:rzsbilDPj6p+/DOPXBMLhwMZeBgeRuXjm5zQFCoXgsg= github.com/gagliardetto/solana-go v1.12.0/go.mod h1:l/qqqIN6qJJPtxW/G1PF4JtcE3Zg2vD2EliZrr9Gn5k= +github.com/gagliardetto/solana-go v1.12.1-0.20250314202648-ca3f5f643435 h1:Ju6/BZbQ0aYVZggNJlyov8X9VIE7pbzTnU9uF9ffin4= +github.com/gagliardetto/solana-go v1.12.1-0.20250314202648-ca3f5f643435/go.mod h1:l/qqqIN6qJJPtxW/G1PF4JtcE3Zg2vD2EliZrr9Gn5k= github.com/gagliardetto/treeout v0.1.4 h1:ozeYerrLCmCubo1TcIjFiOWTTGteOOHND1twdFpgwaw= github.com/gagliardetto/treeout v0.1.4/go.mod h1:loUefvXTrlRG5rYmJmExNryyBRh8f89VZhmMOyCyqok= github.com/gammazero/deque v1.0.0 h1:LTmimT8H7bXkkCy6gZX7zNLtkbz4NdS2z8LZuor3j34= @@ -81,6 +85,10 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/ github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/rpc v1.2.0 h1:WvvdC2lNeT1SP32zrIce5l0ECBfbAlmrmSBsuc57wfk= +github.com/gorilla/rpc v1.2.0/go.mod h1:V4h9r+4sF5HnzqbwIez0fKSpANP0zlYd3qR7p36jkTQ= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= From a56f7300313646071410783519b1ff1fa61139dd Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Thu, 1 May 2025 13:48:21 -0700 Subject: [PATCH 5/5] change some config? --- api/server_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/api/server_test.go b/api/server_test.go index b2eaed1d..8c8bfc0c 100644 --- a/api/server_test.go +++ b/api/server_test.go @@ -47,6 +47,7 @@ func TestMain(m *testing.M) { Env: "test", DbUrl: "postgres://postgres:example@localhost:21300/test", DelegatePrivateKey: "0633fddb74e32b3cbc64382e405146319c11a1a52dc96598e557c5dbe2f31468", + SolanaConfig: config.SolanaConfig{RpcProviders: []string{""}}, }) // seed db