From f40c548fc9746cb5045ed74b759927f1aa5b0cf6 Mon Sep 17 00:00:00 2001 From: thloyi Date: Fri, 17 Jul 2026 16:56:44 +0800 Subject: [PATCH] update amm virtual quotes reserves --- README.md | 4 +- parser.go | 22 +++-- pumpamm.go | 211 ++++++++++++++++++++++++++++++++++++---------- pumpamm_test.go | 127 ++++++++++++++++++++++++++++ tx.go | 10 ++- tx_binary.go | 102 +++++++++++++++++++--- tx_binary_test.go | 121 +++++++++++++++++++++++++- 7 files changed, 528 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index f985c17..504361d 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,8 @@ Each `Swap` describes one protocol-level swap, liquidity, migration, or pool ope | `BaseMintDecimals` / `QuoteMintDecimals` | Decimals used to interpret raw token amounts. | | `User` | User or effective owner account for the action. If the parsed user is not on-curve, the parser may fall back to the transaction signer. | | `BaseAmount` / `QuoteAmount` | Actual parsed base-side and quote-side amounts, stored as `decimal.Decimal`. | -| `BaseReserve` / `QuoteReserve` | Pool reserves when the protocol event or accounts expose them. | +| `BaseReserve` / `QuoteReserve` | Pool reserves when the protocol event or accounts expose them. For Pump AMM buy/sell events, `QuoteReserve` is the post-trade effective quote reserve. | +| `RealQuoteReserve` / `VirtualQuoteReserve` | Quote-reserve components. Pump AMM buy/sell events expose the post-trade real reserve and the signed virtual reserve separately. | | `UserBaseBalance` / `UserQuoteBalance` | User token balances after the transaction when available from token balance metadata. | | `AfterSOLBalance` | User or signer SOL balance after the transaction. | | `EntryContract` | Known router / entry contract account when detected. | @@ -179,6 +180,7 @@ Parsed transaction binary: - `EncodeTxsBinary` / `DecodeTxsBinary` for a batch of parsed `Tx`. - `DecodeTxsBinaryReader` for streaming `PTXS` reads. - `MergeTxsBinaryBytes` and `MergeTxsBinarySourcesToWriter` for merging `PTXS` batches. +- Schema v5 persists `RealQuoteReserve` and signed `VirtualQuoteReserve`; v3/v4 inputs remain readable and default these components to legacy `QuoteReserve` and zero. Raw transaction binary: diff --git a/parser.go b/parser.go index e2453d3..d37fe6c 100644 --- a/parser.go +++ b/parser.go @@ -268,10 +268,12 @@ func (tx *Tx) Parser() error { lastReserve := make(map[solana.PublicKey]reserveSnapshot, len(tx.Swaps)) for _, swap := range tx.Swaps { lastReserve[pairKey(swap)] = reserveSnapshot{ - baseMint: swap.BaseMint, - quoteMint: swap.QuoteMint, - baseReserve: swap.BaseReserve, - quoteReserve: swap.QuoteReserve, + baseMint: swap.BaseMint, + quoteMint: swap.QuoteMint, + baseReserve: swap.BaseReserve, + quoteReserve: swap.QuoteReserve, + realQuoteReserve: swap.RealQuoteReserve, + virtualQuoteReserve: swap.VirtualQuoteReserve, } } @@ -281,6 +283,8 @@ func (tx *Tx) Parser() error { if tx.Swaps[i].BaseMint == v.baseMint && tx.Swaps[i].QuoteMint == v.quoteMint { tx.Swaps[i].BaseReserve = v.baseReserve tx.Swaps[i].QuoteReserve = v.quoteReserve + tx.Swaps[i].RealQuoteReserve = v.realQuoteReserve + tx.Swaps[i].VirtualQuoteReserve = v.virtualQuoteReserve } else if tx.Swaps[i].BaseMint == v.quoteMint && tx.Swaps[i].QuoteMint == v.baseMint { tx.Swaps[i].BaseReserve = v.quoteReserve tx.Swaps[i].QuoteReserve = v.baseReserve @@ -297,10 +301,12 @@ func (tx *Tx) Parser() error { } type reserveSnapshot struct { - baseMint solana.PublicKey - quoteMint solana.PublicKey - baseReserve decimal.Decimal - quoteReserve decimal.Decimal + baseMint solana.PublicKey + quoteMint solana.PublicKey + baseReserve decimal.Decimal + quoteReserve decimal.Decimal + realQuoteReserve decimal.Decimal + virtualQuoteReserve decimal.Decimal } func cloneSwapPrograms(src map[solana.PublicKey]swapParser) map[solana.PublicKey]swapParser { diff --git a/pumpamm.go b/pumpamm.go index f3dfdaf..685fcf2 100644 --- a/pumpamm.go +++ b/pumpamm.go @@ -9,7 +9,7 @@ import ( "github.com/shopspring/decimal" ) -type ammBuyEvent struct { +type ammBuyEventPrefix struct { TimeStamp int64 BaseAmountOut uint64 MaxQuoteAmountIn uint64 @@ -45,6 +45,38 @@ type ammBuyEvent struct { Cashback uint64 } +type ammBuyEvent struct { + ammBuyEventPrefix + BuybackFeeBasisPoints uint64 + BuybackFee uint64 + VirtualQuoteReserves agbinary.Int128 + CanBoost bool + BaseSupply uint64 +} + +type ammTradeEventBuybackSuffix struct { + BuybackFeeBasisPoints uint64 + BuybackFee uint64 +} + +type ammTradeEventVirtualSuffix struct { + VirtualQuoteReserves agbinary.Int128 + CanBoost bool + BaseSupply uint64 +} + +func decodeAmmBuyEvent(data []byte) (ammBuyEvent, error) { + var event ammBuyEvent + decoder := agbinary.NewBorshDecoder(data) + if err := decoder.Decode(&event.ammBuyEventPrefix); err != nil { + return ammBuyEvent{}, err + } + if err := decodeAmmTradeEventSuffix(decoder, &event.BuybackFeeBasisPoints, &event.BuybackFee, &event.VirtualQuoteReserves, &event.CanBoost, &event.BaseSupply); err != nil { + return ammBuyEvent{}, err + } + return event, nil +} + type ammCreatePoolEvent struct { TimeStamp int64 Index uint16 @@ -90,7 +122,7 @@ type ammDepositEvent struct { UserPoolTokenAccount solana.PublicKey } -type ammSellEvent struct { +type ammSellEventPrefix struct { Timestamp int64 BaseAmountIn uint64 MinQuoteAmountOut uint64 @@ -119,6 +151,86 @@ type ammSellEvent struct { Cashback uint64 } +type ammSellEvent struct { + ammSellEventPrefix + BuybackFeeBasisPoints uint64 + BuybackFee uint64 + VirtualQuoteReserves agbinary.Int128 + CanBoost bool + BaseSupply uint64 +} + +func decodeAmmSellEvent(data []byte) (ammSellEvent, error) { + var event ammSellEvent + decoder := agbinary.NewBorshDecoder(data) + if err := decoder.Decode(&event.ammSellEventPrefix); err != nil { + return ammSellEvent{}, err + } + if err := decodeAmmTradeEventSuffix(decoder, &event.BuybackFeeBasisPoints, &event.BuybackFee, &event.VirtualQuoteReserves, &event.CanBoost, &event.BaseSupply); err != nil { + return ammSellEvent{}, err + } + return event, nil +} + +func decodeAmmTradeEventSuffix( + decoder *agbinary.Decoder, + buybackFeeBasisPoints *uint64, + buybackFee *uint64, + virtualQuoteReserves *agbinary.Int128, + canBoost *bool, + baseSupply *uint64, +) error { + remaining := decoder.Remaining() + if remaining == 0 { + return nil + } + if remaining < 16 { + return fmt.Errorf("pump amm trade event buyback suffix truncated: %d bytes", remaining) + } + + var buyback ammTradeEventBuybackSuffix + if err := decoder.Decode(&buyback); err != nil { + return err + } + *buybackFeeBasisPoints = buyback.BuybackFeeBasisPoints + *buybackFee = buyback.BuybackFee + + remaining = decoder.Remaining() + if remaining == 0 { + return nil + } + if remaining < 25 { + return fmt.Errorf("pump amm trade event virtual reserve suffix truncated: %d bytes", remaining) + } + + var virtual ammTradeEventVirtualSuffix + if err := decoder.Decode(&virtual); err != nil { + return err + } + *virtualQuoteReserves = virtual.VirtualQuoteReserves + *canBoost = virtual.CanBoost + *baseSupply = virtual.BaseSupply + return nil +} + +func pumpAmmVirtualQuoteReserve(value agbinary.Int128) decimal.Decimal { + return decimal.NewFromBigInt(value.BigInt(), 0) +} + +func pumpAmmBuyPostQuoteReserves(event ammBuyEvent) (real, effective decimal.Decimal) { + // The event reserve is the pre-trade real vault balance. Keep the + // parser's existing LP-fee treatment by applying QuoteAmountIn here. + real = decimal.NewFromUint64(event.PoolQuoteTokenReserve).Add(decimal.NewFromUint64(event.QuoteAmountIn)) + return real, real.Add(pumpAmmVirtualQuoteReserve(event.VirtualQuoteReserves)) +} + +func pumpAmmSellPostQuoteReserves(event ammSellEvent) (real, effective decimal.Decimal) { + // The event reserve is the pre-trade real vault balance. Keep the + // parser's existing LP-fee treatment by applying QuoteAmountOut here. + real = decimal.NewFromUint64(event.PoolQuoteTokenReserves).Sub(decimal.NewFromUint64(event.QuoteAmountOut)) + return real, real.Add(pumpAmmVirtualQuoteReserve(event.VirtualQuoteReserves)) +} + type ammWithdrawEvent struct { Timestamp int64 LpTokenAmountIn uint64 @@ -249,6 +361,7 @@ func ammCreatePoolParser(tx *Tx, instruction Instruction, innerInstructions Inne QuoteAmount: decimal.NewFromUint64(createEvent.QuoteAmountIn), BaseReserve: decimal.NewFromUint64(createEvent.PoolBaseAmount), QuoteReserve: decimal.NewFromUint64(createEvent.PoolQuoteAmount), + RealQuoteReserve: decimal.NewFromUint64(createEvent.PoolQuoteAmount), UserBaseBalance: decimal.Decimal{}, UserQuoteBalance: decimal.Decimal{}, EntryContract: entryContract, @@ -395,6 +508,7 @@ func failedTxAmmBuyParser(tx *Tx, instruction Instruction, innerInstructions Inn QuoteAmount: decimal.NewFromUint64(quoteAmount), BaseReserve: baseReserve, QuoteReserve: quoteReserve, + RealQuoteReserve: quoteReserve, Mayhem: isMayhemPump(result.accountList[instruction.Accounts[9]]), UserBaseBalance: userBase, UserQuoteBalance: userQuote, @@ -518,6 +632,7 @@ func failedTxAmmSellParser(tx *Tx, instruction Instruction, innerInstructions In QuoteAmount: decimal.NewFromUint64(quoteAmount), BaseReserve: baseReserve, QuoteReserve: quoteReserve, + RealQuoteReserve: quoteReserve, Mayhem: isMayhemPump(result.accountList[instruction.Accounts[9]]), UserBaseBalance: userBase, UserQuoteBalance: userQuote, @@ -557,7 +672,7 @@ func ammBuyParser(tx *Tx, instruction Instruction, innerInstructions InnerInstru if innerInstr.ProgramIDIndex == instruction.ProgramIDIndex && bytes.Equal(innerInstr.Data[:8], pumpAmmEventDiscriminator[:]) && bytes.Equal(innerInstr.Data[8:16], pumpAmmBuyEventDiscriminator[:]) { - err = agbinary.NewBorshDecoder(innerInstr.Data[16:]).Decode(&event) + event, err = decodeAmmBuyEvent(innerInstr.Data[16:]) if offset[1] == 0 { offset[0] += 1 } else { @@ -632,27 +747,30 @@ func ammBuyParser(tx *Tx, instruction Instruction, innerInstructions InnerInstru if event.IxName == "buy" { quoteAmount = decimal.NewFromUint64(event.QuoteAmountIn) } + realQuoteReserve, effectiveQuoteReserve := pumpAmmBuyPostQuoteReserves(event) swap := Swap{ - Program: SolProgramPumpAMM, - Event: "buy", - Pool: event.Pool, - BaseMint: baseMint, - QuoteMint: quoteMint, - BaseTokenProgram: baseTokenProgram, - QuoteTokenProgram: quoteTokenProgram, - Creator: event.CoinCreator, - BaseMintDecimals: baseMintDecimals, - QuoteMintDecimals: quoteMintDecimals, - User: eventUser, - BaseAmount: decimal.NewFromUint64(event.BaseAmountOut), - QuoteAmount: quoteAmount, - BaseReserve: decimal.NewFromUint64(event.PoolBaseTokenReserve - event.BaseAmountOut), - QuoteReserve: decimal.NewFromUint64(event.PoolQuoteTokenReserve + event.QuoteAmountIn), - Mayhem: isMayhemPump(result.accountList[instruction.Accounts[9]]), - Cashback: isCashbackCoin, - UserBaseBalance: userBase, - UserQuoteBalance: userQuote, - EntryContract: entryContract, + Program: SolProgramPumpAMM, + Event: "buy", + Pool: event.Pool, + BaseMint: baseMint, + QuoteMint: quoteMint, + BaseTokenProgram: baseTokenProgram, + QuoteTokenProgram: quoteTokenProgram, + Creator: event.CoinCreator, + BaseMintDecimals: baseMintDecimals, + QuoteMintDecimals: quoteMintDecimals, + User: eventUser, + BaseAmount: decimal.NewFromUint64(event.BaseAmountOut), + QuoteAmount: quoteAmount, + BaseReserve: decimal.NewFromUint64(event.PoolBaseTokenReserve - event.BaseAmountOut), + QuoteReserve: effectiveQuoteReserve, + RealQuoteReserve: realQuoteReserve, + VirtualQuoteReserve: pumpAmmVirtualQuoteReserve(event.VirtualQuoteReserves), + Mayhem: isMayhemPump(result.accountList[instruction.Accounts[9]]), + Cashback: isCashbackCoin, + UserBaseBalance: userBase, + UserQuoteBalance: userQuote, + EntryContract: entryContract, } if bytes.Equal(instruction.Data[:8], pumpAmmBuyV2Discriminator[:]) { swap.SetSwapAmountInfo( @@ -699,7 +817,7 @@ func ammSellParser(tx *Tx, instruction Instruction, innerInstructions InnerInstr if innerInstr.ProgramIDIndex == instruction.ProgramIDIndex && bytes.Equal(innerInstr.Data[:8], pumpAmmEventDiscriminator[:]) && bytes.Equal(innerInstr.Data[8:16], pumpAmmSellEventDiscriminator[:]) { - err = agbinary.NewBorshDecoder(innerInstr.Data[16:]).Decode(&event) + event, err = decodeAmmSellEvent(innerInstr.Data[16:]) if offset[1] == 0 { offset[0] += 1 } else { @@ -770,27 +888,30 @@ func ammSellParser(tx *Tx, instruction Instruction, innerInstructions InnerInstr userQuote = userQuote.Add(decimal.NewFromUint64(userBalance)) } isCashbackCoin := event.CashbackFeeBasisPoints > 0 || event.Cashback > 0 + realQuoteReserve, effectiveQuoteReserve := pumpAmmSellPostQuoteReserves(event) swap := Swap{ - Program: SolProgramPumpAMM, - Event: "sell", - Pool: event.Pool, - BaseMint: baseMint, - QuoteMint: quoteMint, - BaseTokenProgram: baseTokenProgram, - QuoteTokenProgram: quoteTokenProgram, - Creator: event.CoinCreator, - BaseMintDecimals: baseMintDecimals, - QuoteMintDecimals: quoteMintDecimals, - User: eventUser, - BaseAmount: decimal.NewFromUint64(event.BaseAmountIn), - QuoteAmount: decimal.NewFromUint64(event.UserQuoteAmountOut), - BaseReserve: decimal.NewFromUint64(event.PoolBaseTokenReserves + event.BaseAmountIn), - QuoteReserve: decimal.NewFromUint64(event.PoolQuoteTokenReserves - event.QuoteAmountOut), - Mayhem: isMayhemPump(result.accountList[instruction.Accounts[9]]), - Cashback: isCashbackCoin, - UserBaseBalance: userBase, - UserQuoteBalance: userQuote, - EntryContract: entryContract, + Program: SolProgramPumpAMM, + Event: "sell", + Pool: event.Pool, + BaseMint: baseMint, + QuoteMint: quoteMint, + BaseTokenProgram: baseTokenProgram, + QuoteTokenProgram: quoteTokenProgram, + Creator: event.CoinCreator, + BaseMintDecimals: baseMintDecimals, + QuoteMintDecimals: quoteMintDecimals, + User: eventUser, + BaseAmount: decimal.NewFromUint64(event.BaseAmountIn), + QuoteAmount: decimal.NewFromUint64(event.UserQuoteAmountOut), + BaseReserve: decimal.NewFromUint64(event.PoolBaseTokenReserves + event.BaseAmountIn), + QuoteReserve: effectiveQuoteReserve, + RealQuoteReserve: realQuoteReserve, + VirtualQuoteReserve: pumpAmmVirtualQuoteReserve(event.VirtualQuoteReserves), + Mayhem: isMayhemPump(result.accountList[instruction.Accounts[9]]), + Cashback: isCashbackCoin, + UserBaseBalance: userBase, + UserQuoteBalance: userQuote, + EntryContract: entryContract, } swap.SetSwapAmountInfo( SwapModeExactIn, @@ -893,6 +1014,7 @@ func depositParse(tx *Tx, instruction Instruction, innerInstructions InnerInstru QuoteAmount: decimal.NewFromUint64(event.QuoteAmountIn), BaseReserve: decimal.NewFromUint64(event.PoolBaseTokenReserves + event.BaseAmountIn), QuoteReserve: decimal.NewFromUint64(event.PoolQuoteTokenReserves + event.QuoteAmountIn), + RealQuoteReserve: decimal.NewFromUint64(event.PoolQuoteTokenReserves + event.QuoteAmountIn), //Mayhem: false, UserBaseBalance: decimal.NewFromUint64(event.UserBaseTokenReserves - event.BaseAmountIn), UserQuoteBalance: decimal.NewFromUint64(event.UserQuoteTokenReserves - event.QuoteAmountIn), @@ -994,6 +1116,7 @@ func withdrawParse(tx *Tx, instruction Instruction, innerInstructions InnerInstr QuoteAmount: decimal.NewFromUint64(event.QuoteAmountOut), BaseReserve: decimal.NewFromUint64(event.PoolBaseTokenReserves - event.BaseAmountOut), QuoteReserve: decimal.NewFromUint64(event.PoolQuoteTokenReserves - event.QuoteAmountOut), + RealQuoteReserve: decimal.NewFromUint64(event.PoolQuoteTokenReserves - event.QuoteAmountOut), //Mayhem: false, UserBaseBalance: decimal.NewFromUint64(event.UserBaseTokenReserves + event.BaseAmountOut), UserQuoteBalance: decimal.NewFromUint64(event.UserQuoteTokenReserves + event.QuoteAmountOut), diff --git a/pumpamm_test.go b/pumpamm_test.go index 6195662..ab3e1de 100644 --- a/pumpamm_test.go +++ b/pumpamm_test.go @@ -1,11 +1,13 @@ package pump_parser import ( + "bytes" "encoding/base64" "fmt" agbinary "github.com/gagliardetto/binary" "github.com/gagliardetto/solana-go" + "github.com/mr-tron/base58" "testing" ) @@ -40,3 +42,128 @@ func TestAmmBuyEvent(t *testing.T) { fmt.Println(pumpAmmBuyEventDiscriminator) fmt.Println(pumpGetFeesDiscriminator) } + +func TestDecodeAmmBuyEventSuffixCompatibility(t *testing.T) { + prefix := ammBuyEventPrefix{TimeStamp: 123, PoolQuoteTokenReserve: 456} + + legacy, err := decodeAmmBuyEvent(encodeAmmEventParts(t, prefix)) + if err != nil { + t.Fatalf("decodeAmmBuyEvent(legacy) error = %v", err) + } + if legacy.TimeStamp != prefix.TimeStamp || legacy.PoolQuoteTokenReserve != prefix.PoolQuoteTokenReserve { + t.Fatalf("legacy prefix mismatch: %+v", legacy.ammBuyEventPrefix) + } + if legacy.BuybackFeeBasisPoints != 0 || legacy.BuybackFee != 0 || legacy.CanBoost || legacy.BaseSupply != 0 || !pumpAmmVirtualQuoteReserve(legacy.VirtualQuoteReserves).IsZero() { + t.Fatalf("legacy suffix is not zero-valued: %+v", legacy) + } + + buyback := ammTradeEventBuybackSuffix{BuybackFeeBasisPoints: 1000, BuybackFee: 77} + withBuyback, err := decodeAmmBuyEvent(encodeAmmEventParts(t, prefix, buyback)) + if err != nil { + t.Fatalf("decodeAmmBuyEvent(buyback) error = %v", err) + } + if withBuyback.BuybackFeeBasisPoints != buyback.BuybackFeeBasisPoints || withBuyback.BuybackFee != buyback.BuybackFee { + t.Fatalf("buyback suffix mismatch: %+v", withBuyback) + } + if withBuyback.CanBoost || withBuyback.BaseSupply != 0 || !pumpAmmVirtualQuoteReserve(withBuyback.VirtualQuoteReserves).IsZero() { + t.Fatalf("pre-virtual suffix has unexpected virtual fields: %+v", withBuyback) + } + + negativeVirtual := agbinary.Int128(agbinary.Uint128{Lo: ^uint64(122), Hi: ^uint64(0)}) + virtual := ammTradeEventVirtualSuffix{ + VirtualQuoteReserves: negativeVirtual, + CanBoost: true, + BaseSupply: 1_000_000, + } + current, err := decodeAmmBuyEvent(encodeAmmEventParts(t, prefix, buyback, virtual)) + if err != nil { + t.Fatalf("decodeAmmBuyEvent(current) error = %v", err) + } + if got := pumpAmmVirtualQuoteReserve(current.VirtualQuoteReserves).String(); got != "-123" { + t.Fatalf("VirtualQuoteReserves = %s, want -123", got) + } + if !current.CanBoost || current.BaseSupply != virtual.BaseSupply { + t.Fatalf("current suffix mismatch: %+v", current) + } +} + +func TestDecodeAmmSellEventSuffixCompatibility(t *testing.T) { + prefix := ammSellEventPrefix{Timestamp: 321, PoolQuoteTokenReserves: 654, QuoteAmountOut: 54} + buyback := ammTradeEventBuybackSuffix{BuybackFeeBasisPoints: 900, BuybackFee: 88} + virtual := ammTradeEventVirtualSuffix{ + VirtualQuoteReserves: agbinary.Int128(agbinary.Uint128{Lo: 987}), + CanBoost: true, + BaseSupply: 2_000_000, + } + + event, err := decodeAmmSellEvent(encodeAmmEventParts(t, prefix, buyback, virtual)) + if err != nil { + t.Fatalf("decodeAmmSellEvent() error = %v", err) + } + if event.Timestamp != prefix.Timestamp || event.BuybackFee != buyback.BuybackFee || !event.CanBoost || event.BaseSupply != virtual.BaseSupply { + t.Fatalf("decoded sell event mismatch: %+v", event) + } + if got := pumpAmmVirtualQuoteReserve(event.VirtualQuoteReserves).String(); got != "987" { + t.Fatalf("VirtualQuoteReserves = %s, want 987", got) + } + real, effective := pumpAmmSellPostQuoteReserves(event) + if real.String() != "600" || effective.String() != "1587" { + t.Fatalf("post reserves = real %s effective %s", real, effective) + } +} + +func TestDecodeAmmTradeEventRejectsTruncatedSuffix(t *testing.T) { + prefix := encodeAmmEventParts(t, ammBuyEventPrefix{}) + for _, suffixLength := range []int{1, 15, 17, 40} { + data := append(append([]byte(nil), prefix...), make([]byte, suffixLength)...) + if _, err := decodeAmmBuyEvent(data); err == nil { + t.Fatalf("decodeAmmBuyEvent() error = nil for %d-byte suffix", suffixLength) + } + } +} + +func TestDecodeAmmBuyEventDevnetVirtualReserve(t *testing.T) { + const eventDataBase58 = "6MF1ykxMQW5eFmo1ErWgds8V2wfEyqJPjEsoz4yRoKQCktPze5U297neF7huAQQ9DhxEJqHPwezxUh9mcQEsfdmnwW84YVxcfEzDUduRqc1zty2dLQZcFGzFeFC6fmpX75tkvuMvTN89HekHa9TEikGRuXhtWkeacjNoVqfbxXUp3FQxGSFoyeBaSdAfPMvXJ7zN4ALTDtn3a5g7HxJCPPWeUtYGcJig6RpKrC3DzAQ3x5SiiJiVhK1zdocmkkRXuTezt5L5Svrf9pbM4QkkwgvxrYqfUs5YeaLNqWYtNZfimLtENhQJcfrhQh2svCosCJ2Ux5iyyLZwJu7VLZibD41CwxuF6ngmrhde8BmEegmQ9f3L4qYhqgW5RJSiu1JnKMFqmTckuwGydQfTiyA9RsCtFxiazaAVim9SMARU2fqe9zfAPdRjBetAvTGkBxrERmmzhWVhER6g448mV1E1eeQjFWYRhtR33hSUpNUUP6dUF8CWae2BuJxaKexpiY3pyqUwbH7osvexbff8jpfpZGubP5UWjqhwijtF8tdkoEdAnb2TgWXqXMu4by5VDsEzwbgNK1DbkGbxrcXYwFTNskqoUqoNLPRaAcfnjTMA9cVme62HAj5WvxuzpL9Gpw7ZzSE2LYnVvzTa6PnN2bbpuqJpZ3d" + data, err := base58.Decode(eventDataBase58) + if err != nil { + t.Fatalf("base58.Decode() error = %v", err) + } + if len(data) < 16 || !bytes.Equal(data[:8], pumpAmmEventDiscriminator[:]) || !bytes.Equal(data[8:16], pumpAmmBuyEventDiscriminator[:]) { + t.Fatalf("unexpected event discriminator: len=%d", len(data)) + } + + event, err := decodeAmmBuyEvent(data[16:]) + if err != nil { + t.Fatalf("decodeAmmBuyEvent(devnet) error = %v", err) + } + if event.TimeStamp != 1784271481 || event.PoolQuoteTokenReserve != 2_213_159_021 || event.QuoteAmountIn != 100_000_000 || event.QuoteAmountInWithLpFee != 98_785_184 { + t.Fatalf("devnet buy event core fields mismatch: %+v", event) + } + if event.BuybackFeeBasisPoints != 1000 || event.BuybackFee != 91_851 { + t.Fatalf("devnet buyback fields mismatch: bps=%d fee=%d", event.BuybackFeeBasisPoints, event.BuybackFee) + } + if got := pumpAmmVirtualQuoteReserve(event.VirtualQuoteReserves).String(); got != "583150126" { + t.Fatalf("VirtualQuoteReserves = %s, want 583150126", got) + } + if !event.CanBoost || event.BaseSupply != 1_000_000_000_000_000 { + t.Fatalf("devnet boost metadata mismatch: can_boost=%t base_supply=%d", event.CanBoost, event.BaseSupply) + } + + real, effective := pumpAmmBuyPostQuoteReserves(event) + if real.String() != "2313159021" || effective.String() != "2896309147" { + t.Fatalf("post reserves = real %s effective %s", real, effective) + } +} + +func encodeAmmEventParts(t *testing.T, parts ...any) []byte { + t.Helper() + + var buf bytes.Buffer + encoder := agbinary.NewBorshEncoder(&buf) + for _, part := range parts { + if err := encoder.Encode(part); err != nil { + t.Fatalf("borsh encode %T: %v", part, err) + } + } + return buf.Bytes() +} diff --git a/tx.go b/tx.go index e12902c..82adabc 100644 --- a/tx.go +++ b/tx.go @@ -43,10 +43,12 @@ type Swap struct { ActualLimitAmountSide SwapAmountSide SlippageBps decimal.Decimal - BaseReserve decimal.Decimal - QuoteReserve decimal.Decimal - Mayhem bool - Cashback bool + BaseReserve decimal.Decimal + QuoteReserve decimal.Decimal + RealQuoteReserve decimal.Decimal + VirtualQuoteReserve decimal.Decimal + Mayhem bool + Cashback bool UserBaseBalance decimal.Decimal UserQuoteBalance decimal.Decimal diff --git a/tx_binary.go b/tx_binary.go index cb737bc..c2b6c8b 100644 --- a/tx_binary.go +++ b/tx_binary.go @@ -8,6 +8,7 @@ import ( "io" "iter" "math" + "math/big" "sort" "strconv" @@ -18,7 +19,9 @@ import ( const ( txBinarySchemaVersionV3 uint16 = 3 - txBinarySchemaVersionCurrent uint16 = 4 + txBinarySchemaVersionV4 uint16 = 4 + txBinarySchemaVersionV5 uint16 = 5 + txBinarySchemaVersionCurrent = txBinarySchemaVersionV5 txBinaryEnumVersionV1 uint16 = 1 txBinarySOLScale int32 = 9 @@ -102,6 +105,9 @@ type SwapBinary struct { LpMint uint32 AfterSOLBalance float64 + + RealQuoteReserve uint64 + VirtualQuoteReserve [16]byte } type TxsBinary struct { @@ -659,7 +665,7 @@ func (tx *TxBinary) ToTx() (*Tx, error) { if len(tx.Swaps) > 0 { out.Swaps = make([]Swap, 0, len(tx.Swaps)) for i, swap := range tx.Swaps { - decodedSwap, err := swap.toSwap(tx.AddressTable, i) + decodedSwap, err := swap.toSwap(tx.AddressTable, i, tx.SchemaVersion) if err != nil { return nil, err } @@ -797,6 +803,12 @@ func newSwapBinary(swap Swap, index int, addressIndex *txBinaryAddressIndex) (Sw if out.QuoteReserve, err = txBinaryDecimalToFloat64Raw(swap.QuoteReserve, fmt.Sprintf("swap[%d].quote_reserve", index)); err != nil { return SwapBinary{}, err } + if out.RealQuoteReserve, err = txBinaryDecimalToUint64(swap.RealQuoteReserve, fmt.Sprintf("swap[%d].real_quote_reserve", index)); err != nil { + return SwapBinary{}, err + } + if out.VirtualQuoteReserve, err = txBinaryDecimalToInt128(swap.VirtualQuoteReserve, fmt.Sprintf("swap[%d].virtual_quote_reserve", index)); err != nil { + return SwapBinary{}, err + } if out.UserBaseBalance, err = txBinaryDecimalToUint64(swap.UserBaseBalance, fmt.Sprintf("swap[%d].user_base_balance", index)); err != nil { return SwapBinary{}, err } @@ -810,7 +822,7 @@ func newSwapBinary(swap Swap, index int, addressIndex *txBinaryAddressIndex) (Sw return out, nil } -func (swap SwapBinary) toSwap(addressTable []solana.PublicKey, index int) (Swap, error) { +func (swap SwapBinary) toSwap(addressTable []solana.PublicKey, index int, schemaVersion uint16) (Swap, error) { pool, err := txBinaryAddressAt(addressTable, swap.Pool, fmt.Sprintf("swap[%d].pool", index)) if err != nil { return Swap{}, err @@ -864,6 +876,20 @@ func (swap SwapBinary) toSwap(addressTable []solana.PublicKey, index int) (Swap, return Swap{}, err } + quoteReserve := txBinaryFloat64ToDecimalRaw(swap.QuoteReserve) + realQuoteReserve := quoteReserve + virtualQuoteReserve := decimal.Zero + if schemaVersion >= txBinarySchemaVersionV5 { + realQuoteReserve = decimal.NewFromUint64(swap.RealQuoteReserve) + virtualQuoteReserve = txBinaryInt128ToDecimal(swap.VirtualQuoteReserve) + // A v3/v4 batch upgraded by the streaming merger has no component + // bytes to carry forward. Preserve its legacy meaning after the + // merged output is written as v5. + if realQuoteReserve.IsZero() && virtualQuoteReserve.IsZero() && !quoteReserve.IsZero() { + realQuoteReserve = quoteReserve + } + } + return Swap{ Program: swap.Program, Event: swap.Event, @@ -893,7 +919,9 @@ func (swap SwapBinary) toSwap(addressTable []solana.PublicKey, index int) (Swap, ActualLimitAmountSide: swap.ActualLimitAmountSide, SlippageBps: decimal.NewFromUint64(swap.SlippageBps), BaseReserve: txBinaryFloat64ToDecimalRaw(swap.BaseReserve), - QuoteReserve: txBinaryFloat64ToDecimalRaw(swap.QuoteReserve), + QuoteReserve: quoteReserve, + RealQuoteReserve: realQuoteReserve, + VirtualQuoteReserve: virtualQuoteReserve, Mayhem: swap.Mayhem, Cashback: swap.Cashback, UserBaseBalance: decimal.NewFromUint64(swap.UserBaseBalance), @@ -1071,6 +1099,44 @@ func txBinaryDecimalToUint64(value decimal.Decimal, field string) (uint64, error return bigInt.Uint64(), nil } +func txBinaryDecimalToInt128(value decimal.Decimal, field string) ([16]byte, error) { + var out [16]byte + if !value.Equal(value.Truncate(0)) { + return out, fmt.Errorf("%s must be an integer, got %s", field, value.String()) + } + + integer := value.BigInt() + limit := new(big.Int).Lsh(big.NewInt(1), 127) + minimum := new(big.Int).Neg(new(big.Int).Set(limit)) + maximum := new(big.Int).Sub(new(big.Int).Set(limit), big.NewInt(1)) + if integer.Cmp(minimum) < 0 || integer.Cmp(maximum) > 0 { + return out, fmt.Errorf("%s overflows int128: %s", field, value.String()) + } + + unsigned := new(big.Int).Set(integer) + if unsigned.Sign() < 0 { + unsigned.Add(unsigned, new(big.Int).Lsh(big.NewInt(1), 128)) + } + var bigEndian [16]byte + unsigned.FillBytes(bigEndian[:]) + for i := range out { + out[i] = bigEndian[len(bigEndian)-1-i] + } + return out, nil +} + +func txBinaryInt128ToDecimal(raw [16]byte) decimal.Decimal { + var bigEndian [16]byte + for i := range raw { + bigEndian[i] = raw[len(raw)-1-i] + } + integer := new(big.Int).SetBytes(bigEndian[:]) + if raw[len(raw)-1]&0x80 != 0 { + integer.Sub(integer, new(big.Int).Lsh(big.NewInt(1), 128)) + } + return decimal.NewFromBigInt(integer, 0) +} + func txBinaryScaledDecimalToUint64(value decimal.Decimal, scale int32, field string) (uint64, error) { return txBinaryDecimalToUint64(value.Shift(scale), field) } @@ -1179,7 +1245,7 @@ func (enc *txBinaryEncoder) writeTxBinaryBody(tx *TxBinary, enumTable *txBinaryE enc.writeUint32(tx.Signer) enc.writeUint64(tx.Block) enc.writeUint64(tx.BlockIndex) - if tx.SchemaVersion >= txBinarySchemaVersionCurrent { + if tx.SchemaVersion >= txBinarySchemaVersionV4 { enc.writeUint64(uint64(tx.BlockAt)) } enc.writeBool(tx.TxHash != nil) @@ -1198,7 +1264,7 @@ func (enc *txBinaryEncoder) writeTxBinaryBody(tx *TxBinary, enumTable *txBinaryE if err := enc.writeMevAgentEntries(tx.MevAgent, enumTable); err != nil { return err } - if err := enc.writeSwaps(tx.Swaps, enumTable); err != nil { + if err := enc.writeSwaps(tx.Swaps, enumTable, tx.SchemaVersion); err != nil { return err } return nil @@ -1230,7 +1296,7 @@ func (enc *txBinaryEncoder) writeMevAgentEntries(entries []MevAgentBinary, enumT return nil } -func (enc *txBinaryEncoder) writeSwaps(swaps []SwapBinary, enumTable *txBinaryEnumTable) error { +func (enc *txBinaryEncoder) writeSwaps(swaps []SwapBinary, enumTable *txBinaryEnumTable, schemaVersion uint16) error { enc.writeUint32(uint32(len(swaps))) for i, swap := range swaps { programID, err := enumTable.programs.id(swap.Program) @@ -1280,6 +1346,10 @@ func (enc *txBinaryEncoder) writeSwaps(swaps []SwapBinary, enumTable *txBinaryEn enc.writeUint32(swap.MigrateTopProgram) enc.writeUint32(swap.LpMint) enc.writeFloat64(swap.AfterSOLBalance) + if schemaVersion >= txBinarySchemaVersionV5 { + enc.writeUint64(swap.RealQuoteReserve) + enc.writeBytes(swap.VirtualQuoteReserve[:]) + } } return nil } @@ -1395,7 +1465,7 @@ func (dec *txBinaryDecoder) readMevAgentEntries(enumTable *txBinaryEnumTable) ([ } func (dec *txBinaryDecoder) readSwaps(enumTable *txBinaryEnumTable, _ []solana.PublicKey) ([]SwapBinary, error) { - return txBinaryReadSwaps(dec, enumTable) + return txBinaryReadSwaps(dec, enumTable, txBinarySchemaVersionCurrent) } func (dec *txBinaryDecoder) readTxBinaryBody(tx *TxBinary, enumTable *txBinaryEnumTable, addressTable []solana.PublicKey) error { @@ -1651,7 +1721,7 @@ func txBinaryReadMevAgentEntries(dec txBinaryBodyReader, enumTable *txBinaryEnum return out, nil } -func txBinaryReadSwaps(dec txBinaryBodyReader, enumTable *txBinaryEnumTable) ([]SwapBinary, error) { +func txBinaryReadSwaps(dec txBinaryBodyReader, enumTable *txBinaryEnumTable, schemaVersion uint16) ([]SwapBinary, error) { count, err := dec.readUint32() if err != nil { return nil, err @@ -1798,6 +1868,16 @@ func txBinaryReadSwaps(dec txBinaryBodyReader, enumTable *txBinaryEnumTable) ([] if swap.AfterSOLBalance, err = dec.readFloat64(); err != nil { return nil, err } + if schemaVersion >= txBinarySchemaVersionV5 { + if swap.RealQuoteReserve, err = dec.readUint64(); err != nil { + return nil, err + } + rawVirtualQuoteReserve, err := dec.readN(len(swap.VirtualQuoteReserve)) + if err != nil { + return nil, err + } + copy(swap.VirtualQuoteReserve[:], rawVirtualQuoteReserve) + } out = append(out, swap) } return out, nil @@ -1815,7 +1895,7 @@ func txBinaryReadTxBody(dec txBinaryBodyReader, tx *TxBinary, enumTable *txBinar if tx.BlockIndex, err = dec.readUint64(); err != nil { return err } - if tx.SchemaVersion >= txBinarySchemaVersionCurrent { + if tx.SchemaVersion >= txBinarySchemaVersionV4 { blockAt, err := dec.readUint64() if err != nil { return err @@ -1863,7 +1943,7 @@ func txBinaryReadTxBody(dec txBinaryBodyReader, tx *TxBinary, enumTable *txBinar if tx.MevAgent, err = txBinaryReadMevAgentEntries(dec, enumTable); err != nil { return err } - if tx.Swaps, err = txBinaryReadSwaps(dec, enumTable); err != nil { + if tx.Swaps, err = txBinaryReadSwaps(dec, enumTable, tx.SchemaVersion); err != nil { return err } return nil diff --git a/tx_binary_test.go b/tx_binary_test.go index 8176e4e..42f9618 100644 --- a/tx_binary_test.go +++ b/tx_binary_test.go @@ -82,6 +82,8 @@ func TestTxBinaryRoundTrip(t *testing.T) { SlippageBps: decimal.RequireFromString("833.3333"), BaseReserve: decimal.NewFromInt(5555), QuoteReserve: decimal.NewFromInt(9999), + RealQuoteReserve: decimal.NewFromInt(10122), + VirtualQuoteReserve: decimal.NewFromInt(-123), Mayhem: true, Cashback: false, UserBaseBalance: decimal.NewFromInt(777), @@ -202,6 +204,12 @@ func TestTxBinaryRoundTrip(t *testing.T) { if !swap.QuoteReserve.Equal(original.Swaps[0].QuoteReserve) { t.Fatalf("swap.QuoteReserve = %s, want %s", swap.QuoteReserve, original.Swaps[0].QuoteReserve) } + if !swap.RealQuoteReserve.Equal(original.Swaps[0].RealQuoteReserve) { + t.Fatalf("swap.RealQuoteReserve = %s, want %s", swap.RealQuoteReserve, original.Swaps[0].RealQuoteReserve) + } + if !swap.VirtualQuoteReserve.Equal(original.Swaps[0].VirtualQuoteReserve) { + t.Fatalf("swap.VirtualQuoteReserve = %s, want %s", swap.VirtualQuoteReserve, original.Swaps[0].VirtualQuoteReserve) + } if !swap.UserBaseBalance.Equal(original.Swaps[0].UserBaseBalance) { t.Fatalf("swap.UserBaseBalance = %s, want %s", swap.UserBaseBalance, original.Swaps[0].UserBaseBalance) } @@ -1017,6 +1025,89 @@ func TestTxBinaryDecodeSchemaV3LeavesBlockAtZero(t *testing.T) { } } +func TestTxBinaryDecodeSchemaV4DefaultsQuoteReserveComponents(t *testing.T) { + original := &Tx{ + Signer: mustPubKey("So11111111111111111111111111111111111111112"), + Block: 42, + BlockIndex: 2, + BlockAt: 1710000042, + Swaps: []Swap{ + { + Program: SolProgramPumpAMM, + Event: TxEventBuy, + QuoteReserve: decimal.NewFromInt(123456789), + RealQuoteReserve: decimal.NewFromInt(123000000), + VirtualQuoteReserve: decimal.NewFromInt(456789), + }, + }, + } + + binaryTx, err := NewTxBinary(original) + if err != nil { + t.Fatalf("NewTxBinary() error = %v", err) + } + binaryTx.SchemaVersion = txBinarySchemaVersionV4 + encoded, err := binaryTx.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary(v4) error = %v", err) + } + decoded, err := DecodeTxBinary(encoded) + if err != nil { + t.Fatalf("DecodeTxBinary(v4) error = %v", err) + } + if decoded.BlockAt != original.BlockAt { + t.Fatalf("BlockAt = %d, want %d", decoded.BlockAt, original.BlockAt) + } + if len(decoded.Swaps) != 1 { + t.Fatalf("Swaps len = %d, want 1", len(decoded.Swaps)) + } + swap := decoded.Swaps[0] + if !swap.QuoteReserve.Equal(original.Swaps[0].QuoteReserve) { + t.Fatalf("QuoteReserve = %s, want %s", swap.QuoteReserve, original.Swaps[0].QuoteReserve) + } + if !swap.RealQuoteReserve.Equal(swap.QuoteReserve) { + t.Fatalf("RealQuoteReserve = %s, want legacy QuoteReserve %s", swap.RealQuoteReserve, swap.QuoteReserve) + } + if !swap.VirtualQuoteReserve.IsZero() { + t.Fatalf("VirtualQuoteReserve = %s, want 0", swap.VirtualQuoteReserve) + } +} + +func TestTxBinarySignedInt128RoundTrip(t *testing.T) { + values := []string{ + "-170141183460469231731687303715884105728", + "-123", + "-1", + "0", + "1", + "123", + "170141183460469231731687303715884105727", + } + for _, value := range values { + t.Run(value, func(t *testing.T) { + want := decimal.RequireFromString(value) + raw, err := txBinaryDecimalToInt128(want, "value") + if err != nil { + t.Fatalf("txBinaryDecimalToInt128() error = %v", err) + } + if got := txBinaryInt128ToDecimal(raw); !got.Equal(want) { + t.Fatalf("round trip = %s, want %s", got, want) + } + }) + } + + invalid := []string{ + "-170141183460469231731687303715884105729", + "170141183460469231731687303715884105728", + "1.5", + } + for _, value := range invalid { + if _, err := txBinaryDecimalToInt128(decimal.RequireFromString(value), "value"); err == nil { + t.Fatalf("txBinaryDecimalToInt128(%s) error = nil", value) + } + } +} + func TestMergeTxsBinaryBytesUpgradesSchemaV3AndPreservesV4BlockAt(t *testing.T) { legacyTx := Tx{ Signer: mustPubKey("So11111111111111111111111111111111111111112"), @@ -1029,11 +1120,18 @@ func TestMergeTxsBinaryBytesUpgradesSchemaV3AndPreservesV4BlockAt(t *testing.T) Block: 52, BlockIndex: 2, BlockAt: 1710000052, + Swaps: []Swap{ + { + Program: SolProgramPumpAMM, + Event: TxEventBuy, + QuoteReserve: decimal.NewFromInt(222), + }, + }, } merged, err := MergeTxsBinaryBytes([][]byte{ mustEncodeTxsBinaryV3(t, []Tx{legacyTx}), - mustEncodeTxsBinary(t, []Tx{currentTx}), + mustEncodeTxsBinaryV4(t, []Tx{currentTx}), }) if err != nil { t.Fatalf("MergeTxsBinaryBytes(v3,v4) error = %v", err) @@ -1060,6 +1158,9 @@ func TestMergeTxsBinaryBytesUpgradesSchemaV3AndPreservesV4BlockAt(t *testing.T) if decoded[1].BlockAt != currentTx.BlockAt { t.Fatalf("current BlockAt = %d, want %d", decoded[1].BlockAt, currentTx.BlockAt) } + if len(decoded[1].Swaps) != 1 || !decoded[1].Swaps[0].RealQuoteReserve.Equal(currentTx.Swaps[0].QuoteReserve) || !decoded[1].Swaps[0].VirtualQuoteReserve.IsZero() { + t.Fatalf("v4 quote reserve components were not preserved: %+v", decoded[1].Swaps) + } } func mustPubKey(value string) solana.PublicKey { @@ -1109,6 +1210,24 @@ func mustEncodeTxsBinaryV3(t *testing.T, txs []Tx) []byte { return encoded } +func mustEncodeTxsBinaryV4(t *testing.T, txs []Tx) []byte { + t.Helper() + + binaryTxs, err := NewTxsBinary(txs) + if err != nil { + t.Fatalf("NewTxsBinary() error = %v", err) + } + binaryTxs.SchemaVersion = txBinarySchemaVersionV4 + for i := range binaryTxs.Txs { + binaryTxs.Txs[i].SchemaVersion = txBinarySchemaVersionV4 + } + encoded, err := binaryTxs.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary(v4) error = %v", err) + } + return encoded +} + func mustTxBinary(t *testing.T, data []byte) *TxsBinary { t.Helper()