package pump_parser import ( "bytes" "context" "encoding/json" "os" "path/filepath" "testing" "github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go/rpc" "github.com/shopspring/decimal" ) func TestRawTxBinaryRoundTripRealFixture(t *testing.T) { original := mustLoadRawTxFixture(t, "testdata/rpc/4sj82GCLtgTDExq7B8YrBsrrqPcE4FqT5Y1gKWmE4cHMDxs7wkCV1hik73dSZ99gZm3K4wyBZQ6U8Nmf48rM9Jri.json") encoded, err := EncodeRawTxBinary(original) if err != nil { t.Fatalf("EncodeRawTxBinary() error = %v", err) } decoded, err := DecodeRawTxBinary(encoded) if err != nil { t.Fatalf("DecodeRawTxBinary() error = %v", err) } assertRawTxAccountAccess(t, decoded) if decoded.TxHash() != original.TxHash() { t.Fatalf("TxHash = %s, want %s", decoded.TxHash(), original.TxHash()) } if decoded.Slot != original.Slot { t.Fatalf("Slot = %d, want %d", decoded.Slot, original.Slot) } if decoded.IndexWithinBlock != original.IndexWithinBlock { t.Fatalf("IndexWithinBlock = %d, want %d", decoded.IndexWithinBlock, original.IndexWithinBlock) } if len(decoded.Meta.PostTokenBalances) != len(original.Meta.PostTokenBalances) { t.Fatalf("PostTokenBalances len = %d, want %d", len(decoded.Meta.PostTokenBalances), len(original.Meta.PostTokenBalances)) } if len(decoded.Meta.PostTokenBalances) > 0 { got := decoded.Meta.PostTokenBalances[0] want := original.Meta.PostTokenBalances[0] if got.AccountIndex != want.AccountIndex { t.Fatalf("token balance account index = %d, want %d", got.AccountIndex, want.AccountIndex) } wantMint := want.MintAccount if wantMint.IsZero() && want.Mint != "" { var err error wantMint, err = solana.PublicKeyFromBase58(want.Mint) if err != nil { t.Fatalf("parse want mint: %v", err) } } if got.MintAccount != wantMint { t.Fatalf("token balance mint = %s, want %s", got.MintAccount, wantMint) } if got.UITokenAmount.Decimals != want.UITokenAmount.Decimals { t.Fatalf("token balance decimals = %d, want %d", got.UITokenAmount.Decimals, want.UITokenAmount.Decimals) } if got.UITokenAmount.Amount != want.UITokenAmount.Amount { t.Fatalf("token balance amount = %s, want %s", got.UITokenAmount.Amount, want.UITokenAmount.Amount) } } } func TestRawTxsBinaryBatchAndStreamRoundTrip(t *testing.T) { tx1 := mustLoadRawTxFixture(t, "testdata/rpc/4sj82GCLtgTDExq7B8YrBsrrqPcE4FqT5Y1gKWmE4cHMDxs7wkCV1hik73dSZ99gZm3K4wyBZQ6U8Nmf48rM9Jri.json") tx2 := mustLoadRawTxFixture(t, "testdata/rpc/43EouSYkeVmLBZSKW1KptiQcAVvB6KX49wjztjgzkQ9iU38A6fF68k77bNy9Wn6fwjykqYsPorUKj8m6SFY7naf1.json") tx2.BlockTime = tx1.BlockTime original := []RawTx{*tx1, *tx2} encoded, err := EncodeRawTxsBinary(original) if err != nil { t.Fatalf("EncodeRawTxsBinary() error = %v", err) } decoded, err := DecodeRawTxsBinary(encoded) if err != nil { t.Fatalf("DecodeRawTxsBinary() error = %v", err) } if len(decoded) != len(original) { t.Fatalf("DecodeRawTxsBinary len = %d, want %d", len(decoded), len(original)) } for i := range decoded { assertRawTxAccountAccess(t, decoded[i]) if decoded[i].TxHash() != original[i].TxHash() { t.Fatalf("decoded[%d].TxHash = %s, want %s", i, decoded[i].TxHash(), original[i].TxHash()) } if decoded[i].BlockTime != original[i].BlockTime { t.Fatalf("decoded[%d].BlockTime = %d, want %d", i, decoded[i].BlockTime, original[i].BlockTime) } } var streamed int for decodedTx, err := range DecodeRawTxsBinaryReader(bytes.NewReader(encoded)) { if err != nil { t.Fatalf("DecodeRawTxsBinaryReader() error = %v", err) } assertRawTxAccountAccess(t, decodedTx) streamed++ } if streamed != len(original) { t.Fatalf("streamed tx count = %d, want %d", streamed, len(original)) } } func TestRawTxBlocksBinaryRoundTrip(t *testing.T) { tx1 := mustLoadRawTxFixture(t, "testdata/rpc/4sj82GCLtgTDExq7B8YrBsrrqPcE4FqT5Y1gKWmE4cHMDxs7wkCV1hik73dSZ99gZm3K4wyBZQ6U8Nmf48rM9Jri.json") tx2 := mustLoadRawTxFixture(t, "testdata/rpc/43EouSYkeVmLBZSKW1KptiQcAVvB6KX49wjztjgzkQ9iU38A6fF68k77bNy9Wn6fwjykqYsPorUKj8m6SFY7naf1.json") blocks := [][]RawTx{{*tx1}, {*tx2}} encoded, err := EncodeRawTxBlocksBinary(blocks) if err != nil { t.Fatalf("EncodeRawTxBlocksBinary() error = %v", err) } decoded, err := DecodeRawTxBlocksBinary(encoded) if err != nil { t.Fatalf("DecodeRawTxBlocksBinary() error = %v", err) } if len(decoded) != len(blocks) { t.Fatalf("block count = %d, want %d", len(decoded), len(blocks)) } for blockIndex := range decoded { if len(decoded[blockIndex]) != len(blocks[blockIndex]) { t.Fatalf("block[%d] tx count = %d, want %d", blockIndex, len(decoded[blockIndex]), len(blocks[blockIndex])) } for txIndex := range decoded[blockIndex] { assertRawTxAccountAccess(t, decoded[blockIndex][txIndex]) if decoded[blockIndex][txIndex].TxHash() != blocks[blockIndex][txIndex].TxHash() { t.Fatalf("block[%d].tx[%d] hash mismatch", blockIndex, txIndex) } if decoded[blockIndex][txIndex].BlockTime != blocks[blockIndex][txIndex].BlockTime { t.Fatalf("block[%d].tx[%d] block time = %d, want %d", blockIndex, txIndex, decoded[blockIndex][txIndex].BlockTime, blocks[blockIndex][txIndex].BlockTime) } } } } func TestRawTxBinaryPreservesAccountListHelperBehavior(t *testing.T) { owner := mustPubKey("SysvarRent111111111111111111111111111111111") mint := solana.WrappedSol tokenProgram := solana.TokenProgramID ata, _, err := solana.FindProgramAddress([][]byte{ owner[:], tokenProgram[:], mint[:], }, solana.SPLAssociatedTokenAccountProgramID) if err != nil { t.Fatalf("find ata: %v", err) } original := &RawTx{ accountList: []solana.PublicKey{owner, ata, mint, tokenProgram}, BlockTime: 1710000000, Slot: 123, IndexWithinBlock: 7, Transaction: Transaction{Signatures: []solana.Signature{{1, 2, 3}}}, Meta: Meta{ PreBalances: []uint64{2_000_000_000, 0, 0, 0}, PostBalances: []uint64{1_500_000_000, 0, 0, 0}, PreTokenBalances: []TokenBalance{{ AccountIndex: 1, MintAccount: mint, OwnerAccount: &owner, ProgramIDAccount: tokenProgram, UITokenAmount: UITokenAmount{ Amount: "100", Decimals: 9, UIAmount: 0.0000001, UIAmountString: "0.0000001", }, }}, PostTokenBalances: []TokenBalance{{ AccountIndex: 1, MintAccount: mint, OwnerAccount: &owner, ProgramIDAccount: tokenProgram, UITokenAmount: UITokenAmount{ Amount: "250", Decimals: 9, UIAmount: 0.00000025, UIAmountString: "0.00000025", }, }}, }, } binaryForm, err := NewRawTxBinary(original) if err != nil { t.Fatalf("NewRawTxBinary() error = %v", err) } if len(binaryForm.Meta.TokenBalances) != 1 { t.Fatalf("binary token balance count = %d, want 1", len(binaryForm.Meta.TokenBalances)) } if got := binaryForm.Meta.TokenBalances[0]; !got.HasPreAmount || !got.HasPostAmount || got.PreAmount != "100" || got.PostAmount != "250" { t.Fatalf("merged binary token balance = %+v", got) } encoded, err := EncodeRawTxBinary(original) if err != nil { t.Fatalf("EncodeRawTxBinary() error = %v", err) } decoded, err := DecodeRawTxBinary(encoded) if err != nil { t.Fatalf("DecodeRawTxBinary() error = %v", err) } for _, tx := range []*RawTx{original, decoded} { if got, err := GetSolAfterTx(tx, 0); err != nil || got != 1_500_000_000 { t.Fatalf("GetSolAfterTx() = %d, %v; want 1500000000, nil", got, err) } balance, err := getTokenBalanceAfterTx(tx, 1) if err != nil { t.Fatalf("getTokenBalanceAfterTx() error = %v", err) } if balance.UITokenAmount.Amount != "250" { t.Fatalf("getTokenBalanceAfterTx amount = %s, want 250", balance.UITokenAmount.Amount) } if got := getAccountBalanceAfterTx(tx, 1); !got.Equal(decimal.NewFromInt(250)) { t.Fatalf("getAccountBalanceAfterTx() = %s, want 250", got) } if got := GetTokenBalanceAfterTx(tx, 0, tokenProgram, mint); !got.Equal(decimal.NewFromInt(250)) { t.Fatalf("GetTokenBalanceAfterTx() = %s, want 250", got) } ataBalance, err := getAtaByOwner(tx, owner, mint) if err != nil { t.Fatalf("getAtaByOwner() error = %v", err) } if ataBalance.AccountIndex != 1 { t.Fatalf("getAtaByOwner account index = %d, want 1", ataBalance.AccountIndex) } ataIndex, err := getAtaIdxByOwner(tx, owner, mint) if err != nil { t.Fatalf("getAtaIdxByOwner() error = %v", err) } if ataIndex != 1 { t.Fatalf("getAtaIdxByOwner() = %d, want 1", ataIndex) } change, changeAtaIndex := tokenBalanceChange(tx, 0, tokenProgram, mint) if !change.Equal(decimal.NewFromInt(150)) || changeAtaIndex != 1 { t.Fatalf("tokenBalanceChange() = (%s, %d), want (150, 1)", change, changeAtaIndex) } } } func TestRawTxBinaryTokenBalanceAmountSupportsUint256(t *testing.T) { owner := mustPubKey("SysvarRent111111111111111111111111111111111") mint := solana.WrappedSol tokenProgram := solana.TokenProgramID amount := "340282366920938463463374607431768211455" original := &RawTx{ accountList: []solana.PublicKey{owner, mint, tokenProgram}, Transaction: Transaction{ Signatures: []solana.Signature{{1, 2, 3}}, }, Meta: Meta{ PreBalances: []uint64{1}, PostBalances: []uint64{1}, PostTokenBalances: []TokenBalance{{ AccountIndex: 0, MintAccount: mint, OwnerAccount: &owner, ProgramIDAccount: tokenProgram, UITokenAmount: UITokenAmount{ Amount: amount, Decimals: 9, }, }}, }, } encoded, err := EncodeRawTxBinary(original) if err != nil { t.Fatalf("EncodeRawTxBinary() error = %v", err) } decoded, err := DecodeRawTxBinary(encoded) if err != nil { t.Fatalf("DecodeRawTxBinary() error = %v", err) } got := decoded.Meta.PostTokenBalances[0].UITokenAmount if got.Amount != amount { t.Fatalf("Amount = %s, want %s", got.Amount, amount) } if got.UIAmountString != "340282366920938463463374607431.768211455" { t.Fatalf("UIAmountString = %s", got.UIAmountString) } } func TestRawTxBlocksBinarySaveSlots414696178To414696182(t *testing.T) { rpcURL := os.Getenv("RAWTX_BINARY_RPC_URL") if rpcURL == "" { t.Skip("set RAWTX_BINARY_RPC_URL to run RPC-backed rawtx-binary block save test") } const startSlot uint64 = 414696178 const endSlot uint64 = 414696182 client := rpc.New(rpcURL) rewards := false version := uint64(0) blocks := make([][]RawTx, 0, endSlot-startSlot+1) totalTx := 0 filteredVote := 0 for slot := startSlot; slot <= endSlot; slot++ { block, err := client.GetBlockWithOpts(context.Background(), slot, &rpc.GetBlockOpts{ TransactionDetails: rpc.TransactionDetailsFull, Rewards: &rewards, Commitment: rpc.CommitmentFinalized, Encoding: solana.EncodingBase64, MaxSupportedTransactionVersion: &version, }) if err != nil { t.Fatalf("get block %d: %v", slot, err) } var blockTime uint64 if block.BlockTime != nil { blockTime = uint64(*block.BlockTime) } rawTxs := make([]RawTx, 0, len(block.Transactions)) for i, tx := range block.Transactions { totalTx++ rawTx, err := FromRpcTransactionWithMeta(tx, &blockTime, slot, int64(i)) if err != nil { t.Fatalf("slot %d tx[%d] convert: %v", slot, i, err) } if rawTxBinaryIsVoteTx(rawTx) { filteredVote++ continue } rawTxs = append(rawTxs, *rawTx) } blocks = append(blocks, rawTxs) } encoded, err := EncodeRawTxBlocksBinary(blocks) if err != nil { t.Fatalf("EncodeRawTxBlocksBinary() error = %v", err) } outputPath := os.Getenv("RAWTX_BINARY_OUT") if outputPath == "" { outputPath = filepath.Join("testdata", "rawtx-binary", "rawtx-blocks-414696178-414696182.prbs") } if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { t.Fatalf("create rawtx binary output dir: %v", err) } if err := os.WriteFile(outputPath, encoded, 0o644); err != nil { t.Fatalf("write rawtx binary: %v", err) } decoded, err := DecodeRawTxBlocksBinary(encoded) if err != nil { t.Fatalf("DecodeRawTxBlocksBinary() error = %v", err) } if len(decoded) != len(blocks) { t.Fatalf("decoded block count = %d, want %d", len(decoded), len(blocks)) } savedTx := 0 for blockIndex := range decoded { if len(decoded[blockIndex]) != len(blocks[blockIndex]) { t.Fatalf("decoded block[%d] tx count = %d, want %d", blockIndex, len(decoded[blockIndex]), len(blocks[blockIndex])) } for txIndex := range decoded[blockIndex] { assertRawTxAccountAccess(t, decoded[blockIndex][txIndex]) if rawTxBinaryIsVoteTx(decoded[blockIndex][txIndex]) { t.Fatalf("decoded block[%d].tx[%d] is vote tx", blockIndex, txIndex) } savedTx++ } } if savedTx == 0 { t.Fatal("saved tx count is zero") } t.Logf("saved rawtx binary: path=%s bytes=%d total_tx=%d saved_tx=%d filtered_vote=%d", outputPath, len(encoded), totalTx, savedTx, filteredVote) } func mustLoadRawTxFixture(t *testing.T, path string) *RawTx { t.Helper() raw, err := os.ReadFile(path) if err != nil { t.Fatalf("read fixture: %v", err) } var response RPCResponse if err := json.Unmarshal(raw, &response); err != nil { t.Fatalf("unmarshal fixture: %v", err) } return &response.Result } func assertRawTxAccountAccess(t *testing.T, tx *RawTx) { t.Helper() accounts := tx.GetAccountList() if len(accounts) == 0 { t.Fatal("decoded account list is empty") } for _, instr := range tx.Transaction.Message.Instructions { if instr.ProgramIDIndex < 0 || instr.ProgramIDIndex >= len(accounts) { t.Fatalf("instruction program id index %d out of range %d", instr.ProgramIDIndex, len(accounts)) } for _, accountIndex := range instr.Accounts { if accountIndex < 0 || accountIndex >= len(accounts) { t.Fatalf("instruction account index %d out of range %d", accountIndex, len(accounts)) } } } for _, inner := range tx.Meta.InnerInstructions { for _, instr := range inner.Instructions { if instr.ProgramIDIndex < 0 || instr.ProgramIDIndex >= len(accounts) { t.Fatalf("inner instruction program id index %d out of range %d", instr.ProgramIDIndex, len(accounts)) } for _, accountIndex := range instr.Accounts { if accountIndex < 0 || accountIndex >= len(accounts) { t.Fatalf("inner instruction account index %d out of range %d", accountIndex, len(accounts)) } } } } } func rawTxBinaryIsVoteTx(tx *RawTx) bool { if tx == nil { return false } accountList := tx.GetAccountList() for _, instr := range tx.Transaction.Message.Instructions { if instr.ProgramIDIndex >= 0 && instr.ProgramIDIndex < len(accountList) && accountList[instr.ProgramIDIndex] == solana.VoteProgramID { return true } } return false }