beyondのblog

如何快速解析Solana链上DEX交易数据

本文总结一下链上数据的提取流程

在solana链上所有的交易都是一条条"指令(instruction)“,与evm不一样的是一个transaction 可以有多条指令, 每条指令又可以有多条inner instruciton. 当然一个transaction有最大空间限制所以不是无限条具体可以看下这篇介绍https://solana.com/docs/core/transactions

SOL Transfer

Transaction 的实际结构

下面是任意获取的一笔Transaction,为了方便阅读我精简了一些内容,下面来介绍下具体每个字段的含意

{
    "slot": 313260647,
    "blockTime": 1736582313,
    "transaction": [
        "AaIhW6A2M8DD88WylJhRKc8Do6Ug6k3HuW+oVZsTxnEqH7zSgBXfo80qShXG6U75zt/I2CMHhShtjmqwuGnATQKAAQAIC/IlPLKcGu5mLOINQ6rlWlO979iOhixnmws+sTB/ISXRVe3ON2GuQVor9ZXyM0O/QUuYjkejwy1W/Y0wxcR76293841lAK6Ro/l9pk4BiUPA64m6P1H327XvzYkL5+4JmgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAAAEedVb8jHAbu50xW7OaBUH/bGy3qP0jlECsc2iVrwTjwabiFf+q4GE+2h/Y0YYwDXaxDncGus7VZig8AAAAAABBt324ddloZPZy+FGzut5rBy0he1fWzeROoz1hX7/AKmMlyWPTiSJ8bs9ECkUjg2DC1oTmdr/EIQEjnvY2+n4WbQ/+if11/ZKdMCbHylYed5LCas238ndUUsyGqezjOXo3cRwRFfjcsWHIlalRMXxolPZyNvJQQHI3W4oWrQe6b9wZhPkTTwzClMMENgiooX55CfbfRLYY7muj35FD6ca6wcEAAUCrKoBAAgGAAIABgMHAQEDAgACDAIAAAAA4fUFAAAAAAcBAgERCAYAAQAKAwcBAQUbBwACAQUKBQkFDwcLDgsNDAsLCwsLCwsLAgEAI+UXy5d6460qAQAAAAdkAAEA4fUFAAAAAF4zmSQAAAAA+gAABwMCAAABCQEqimLdZp9XJM4tjpUdejtF2+JiEUVagWtFAa7RJzx9sAMFAwcCAgY=",
        "base64"
    ],
    "meta": {
        "err": null,
        "fee": 5000,
        "preBalances": [
            1000010000,
            0,
            0,
            1,
            1,
            1141440,
            788933756007,
            934087680,
            731913600,
            0,
            22502253405,
            6124801,
            2039280,
            4906415516450,
            14035088759,
            1141440
        ],
        "postBalances": [
            897965720,
            2039280,
            0,
            1,
            1,
            1141440,
            788933756007,
            934087680,
            731913600,
            0,
            22502253405,
            6124801,
            2039280,
            4906515516450,
            14035088759,
            1141440
        ],
        "innerInstructions": [
            {
                "index": 1,
                "instructions": [
                    {
                        "programIdIndex": 7,
                        "accounts": [
                            6
                        ],
                        "data": "84eT"
                    },
                    {
                        "programIdIndex": 3,
                        "accounts": [
                            0,
                            2
                        ],
                        "data": "11119os1e9qSs2u7TsThXqkBSRVFxhmYaFKFZ1waB2X7armDmvK3p5GmLdUxYdg3h7QSrL"
                    },
                    {
                        "programIdIndex": 7,
                        "accounts": [
                            2
                        ],
                        "data": "P"
                    },
                    {
                        "programIdIndex": 7,
                        "accounts": [
                            2,
                            6
                        ],
                        "data": "6dRwGUbFW67pwDDpjAPEhmKQzMWGSk5TY9Cgv4ZYR8eAU"
                    }
                ]
            },
            {
                "index": 4,
                "instructions": [
                    {
                        "programIdIndex": 7,
                        "accounts": [
                            10
                        ],
                        "data": "84eT"
                    },
                    {
                        "programIdIndex": 3,
                        "accounts": [
                            0,
                            1
                        ],
                        "data": "11119os1e9qSs2u7TsThXqkBSRVFxhmYaFKFZ1waB2X7armDmvK3p5GmLdUxYdg3h7QSrL"
                    },
                    {
                        "programIdIndex": 7,
                        "accounts": [
                            1
                        ],
                        "data": "P"
                    },
                    {
                        "programIdIndex": 7,
                        "accounts": [
                            1,
                            10
                        ],
                        "data": "6dRwGUbFW67pwDDpjAPEhmKQzMWGSk5TY9Cgv4ZYR8eAU"
                    }
                ]
            },
            {
                "index": 5,
                "instructions": [
                    {
                        "programIdIndex": 15,
                        "accounts": [
                            7,
                            11,
                            14,
                            11,
                            13,
                            12,
                            11,
                            11,
                            11,
                            11,
                            11,
                            11,
                            11,
                            11,
                            2,
                            1,
                            0
                        ],
                        "data": "5ucmhStLiAKrHueiRPZaPeX"
                    },
                    {
                        "programIdIndex": 7,
                        "accounts": [
                            2,
                            13,
                            0
                        ],
                        "data": "3Dc8EpW7Kr3R"
                    },
                    {
                        "programIdIndex": 7,
                        "accounts": [
                            12,
                            1,
                            14
                        ],
                        "data": "3x1R8aoLjRFu"
                    },
                    {
                        "programIdIndex": 5,
                        "accounts": [
                            9
                        ],
                        "data": "QMqFu4fYGGeUEysFnenhAvR83g86EDDNxzUskfkWKYCBPWe1hqgD6jgKAXr6aYoEQaxoqYMTvWgPVk2AHWGHjdbNiNtoaPfZA4znu6cRUSWSeJGBkZunVEvoYenVXbq4Tge3nrNuuvC6G4k8pkFasDAwMz8bFBpKqfFBg3pHojUCfiw"
                    }
                ]
            }
        ],
        "preTokenBalances": [
            {
                "accountIndex": 12,
                "owner": "5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1",
                "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
                "mint": "FvgqHMfL9yn39V79huDPy3YUNDoYJpuLWng2JfmQpump",
                "uiTokenAmount": {
                    "amount": "30190180808651",
                    "decimals": 6,
                    "uiAmount": 30190180.808651,
                    "uiAmountString": "30190180.808651"
                }
            }
        ],
        "postTokenBalances": [
            {
                "accountIndex": 1,
                "owner": "HJEbYPihoGZ6wjjD5E3NHLybrcgdLLBqSHX4ZyrbqVjW",
                "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
                "mint": "FvgqHMfL9yn39V79huDPy3YUNDoYJpuLWng2JfmQpump",
                "uiTokenAmount": {
                    "amount": "613769982",
                    "decimals": 6,
                    "uiAmount": 613.769982,
                    "uiAmountString": "613.769982"
                }
            }
        ],
        "logMessages": [],
        "status": {
            "Ok": null
        },
        "rewards": [],
        "loadedAddresses": {
            "readonly": [
                "5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1",
                "675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8"
            ],
            "writable": [
                "3hsdbMFsiCh3YCsXoFjgx4TVpxECsUE9nRMgvaoyveQT",
                "A67nie3cYJy58EB3uQvLGrUJaH5FVPdGmX2Wo7DLxJco",
                "AcsiBWpfYJnDpg4MRUJTwCbueCRTNeLBV27v5doA9RDv"
            ]
        },
        "returnData": {
            "programId": "11111111111111111111111111111111",
            "data": [
                "",
                ""
            ]
        },
        "computeUnitsConsumed": 88031
    },
    "version": 0
}

上面的Transaction 使用的是Base64的Encoding格式,不同编码格式的结构会有一些差异但是字段都差不多.

Transaction 主要有两个重要的字段meta 和 transaction, meta 区域主要存放了本次交易涉及到的所有账户的余额变动情况(包括sol和spl token)、所有的inner instruction(注意是inner instruction不是instruction)、logs、还有地址表(loadedAddresses, 这个非常重要后面会介绍到)。transaction区域是一个编码的需要再一次parser才能得到, 里面主要是有 static account table(所有的账户索引),instruction(所有的指令)

Instruction 和 inner Instruction 的关系

每一个program的调用都是以一条 instruction的形式调用, inner instruction相当于是这个program 指令的内部交易,例如业务逻辑是swap 代币,那么里面肯定会有spl transfer 的inner instruction。相当于evm里面Transaction的Internal Tx.

由于本文主要是介绍解析dex的交易数据, 主要是考虑dex相关逻辑的program。应该仅解析instruction吗?不由于链上会有很多交易聚合工具例如 jup, 它会代理真正的dex trade instruction 调用为了保证数据完整性数据需要从 inner instruction中也解析一遍.

如何解析

简单的方案可以通过遍历solana 的slot,然后提取所有的transaction list 挨个去处理, 但是这样太慢了!

为了追求最快的行情提取,可以使用websocket来实时订阅每一个区块,通过”推“的方式来获取数据。当然这种方案也过时了,现在最流行的方案是使用Geyser Yellowstone插件的方式来获取

什么是Yellowstone?

yellowstone 其实是一个solana 验证节点的插件,它通过grpc协议的方式从节点中把实时数据推给调用方。具体可以从这里了解更多 https://github.com/rpcpool/yellowstone-grpc

虽然它很快, 但是它非常昂贵. 目前要么通过自己部署专属节点来安装插件的形式去获得, 专属节点的价格不便宜, 以helius提供的服务为例子目前一个月要2000刀左右。另一种方法是使用第三方的共享yellowstone服务,目前了解到instantnodes和quicknode 都有提供, 这里推荐quicknode 只需要500刀/月 就能用上,但是每条数据的获取都要按条计费。所以如果是追求最佳性能专属节点是非常好的选择。

yellowstone 如何用?

插件的仓库有具体的例子, 后续本文会以golang 语言为主要开发语言举例. 首先来简单看下yellowstone的用法, 它提供一种filter 能力, 指定订阅某个账户的所有tx基于这个能力我们可以把一些dex program 都订阅上,这样只要有交易性能就一定能获得到tx事件,直接解析就行了。

yellowstone 的数据结构与 sol rpc 获取到的transaction结构有一些不同,可以通过查看proto看到具体的结构 例如

message SubscribeUpdateTransactionInfo {
  bytes signature = 1;
  bool is_vote = 2;
  solana.storage.ConfirmedBlock.Transaction transaction = 3;
  solana.storage.ConfirmedBlock.TransactionStatusMeta meta = 4;
  uint64 index = 5;
}

可以看到除了没有时间字段,主要也是 transaction和meta两类数据

那么监听的流程就是, 建立一个grpc的connect, 然后创建一个transactions_sub结构,将需要解析的program account 填入 account_include 中, 例如pump.fun、raydium 等等. 这样当任意一笔交易与这些账户有交互时都会推送相关transaction过来

解析 Pump.fun

接下来介绍下如何解析pump.fun中所有的买卖交易、池子发射、ca创建

首先需要找到pump.fun的program,通过solscan很容找到它. 我们需要分别找到每种类型的交易tx,便于我们分析具体如何解析

  • 代币创建

    相关tx

    pump_fun_create

    由于solana的program机制是在执行交易的时候需要把本次涉及到的所有account 都传入到指令中,所以我们分析交易行为和提取数据会非常的方便。

    从上面这张图可以获得一些关键信息

    pump.fun的合约地址是6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P

    CA的账户是J8DsvUjD4xzjGL3ccDGmnPMWMG96iEcTPNvrwL1kpump

    CA 的部署者是DJtXr8VPm9FbSwrSnbg3Go4Wbnc29H4o7qPnnQLeZ729

    我们还想获得到 代币的symbol、logo、twitter url、bonding Curve等信息,如何获取呢?往下继续找

    pump_fun_create_event

​ 可以看到 pump.fun的合约内部有打印一个log,这个log里面有我们想要的所有数据,也就是说只要解析到这个 event log 就能拿到代币的所有信息了。那么如何解析这个log呢,代码的逻辑可以是这样

由于event log 必然是inner instruction中产生的, 所以仅需要遍历 meta中的指令行了. 那么代码可能是下面这样

func (c *Client) parserPumpFun(subscribeUpdateTransaction *proto.SubscribeUpdate_Transaction) error {

	if subscribeUpdateTransaction.Transaction.Transaction.Meta.Err != nil {
		return nil
	}

	accountKey := subscribeUpdateTransaction.Transaction.Transaction.Transaction.Message.AccountKeys
	meta := subscribeUpdateTransaction.Transaction.Transaction.Meta
	signature := base58.Encode(subscribeUpdateTransaction.Transaction.Transaction.Signature)
	slot := int64(subscribeUpdateTransaction.Transaction.Slot)

	innerInstructions := subscribeUpdateTransaction.Transaction.Transaction.Meta.InnerInstructions
  // 遍历yellowstone推送过来的所有 inner instruction
	for _, innerInstruction := range innerInstructions {
		for j, instruction := range innerInstruction.Instructions {
      // 由于推送过来的program只有 account index, 所以需要解析下
			programAccount, _ := meta.ProgramAddress(accountKey, byte(instruction.ProgramIdIndex))
      // 判断当前的inner instruction 是不是pumpfun的
			if programAccount == consts.Pumpfun {
        // 尝试解析下log
				instructionData := base58.Encode(instruction.Data)
				createEvent, _ := pumpfun.ParseCreateEventInstruction(instruction.Data)
				if createEvent != nil {
          // 提取想要的数据
					newPool := helius.PumpNewPool{
						Name:             createEvent.Name, // mint代币 的名字
						Symbol:           createEvent.Symbol, // mint 代币的symbol
            Uri:              createEvent.Uri, // mint 代币的metadata uri (logo twitter相关的都在这里)
						Mint:             createEvent.Mint.String(), // mint 代币的地址
						BondingCurve:     createEvent.BondingCurve.String(), // mint 代币的交易池子地址
						User:             createEvent.User.String(), // mint 的dev 地址
						TxHash:           signature,
						Slot:             slot,
						InstructionIndex: fmt.Sprintf("%d_%d", innerInstruction.Index, j),
						Timestamp:        time.Now().Unix(),
					}
          // 进行落盘,可以存到kafka或者数仓
					_ = c.pumpNewPoolHandler(newPool)
				}
			}
		}
	}
	return nil
}

上面的代码注释写的很清楚了, 但是有几个关键技术需要在解释下

  1. 如何获取到当前指令的执行program 的account address?

    由于链上源数据的账户都是“索引”概念, 这里有一个非常坑的地方是需要根据loadedAddresses来解析数据,它的流程大概是下面这样

    首先检查索引是否在 staticAccountKeys 范围内,如果在则直接根据staticAccountKeys去获取 否则通过减去 staticAccountKeys 的长度来调整索引 检查调整后的索引是否在 writableAddresses 范围内,如果在则直接在writableAddresses中获取 否则通过减去 writableAddresses 的长度来进一步调整索引 最后检查 readonlyAddresses,如果在则直接获取,否则就返回错误

    相关的代码如下

    func (x *TransactionStatusMeta) ProgramAddress(staticAccountKeys [][]byte, programIDIndex byte) (string, error) {
    	index := int(programIDIndex)
    	if index < len(staticAccountKeys) {
    		return solana.PublicKeyFromBytes(staticAccountKeys[programIDIndex]).String(), nil
    	}
    
    	// 调整索引值,减去 staticAccountKeys 的长度
    	index -= len(staticAccountKeys)
    
    	// 检查是否在 writableAddresses 范围内
    	if index < len(x.LoadedWritableAddresses) {
    		return solana.PublicKeyFromBytes(x.LoadedWritableAddresses[index]).String(), nil
    	}
    
    	// 再次调整索引,减去 writableAddresses 的长度
    	index -= len(x.LoadedWritableAddresses)
    
    	// 最后检查 readonlyAddresses
    	if index < len(x.LoadedReadonlyAddresses) {
    		return solana.PublicKeyFromBytes(x.LoadedReadonlyAddresses[index]).String(), nil
    	}
    
    	return solana.PublicKey{}.String(), fmt.Errorf("programID index not found %d", programIDIndex)
    
    }
    
  2. 如何解析event log

    非常简单, pump.fun使用的是anchor编写的合约, event 是用的Borsh编码的, 只需要用Borsh反编码就出来了。golang的解析代码如下

    // 需要注意的是这个结构的每个字段的类型, 需要跟链上event匹配,否则会解析出错误的数据
    type CreateEvent struct {
    	Name         string
    	Symbol       string
    	Uri          string
    	Mint         solana.PublicKey
    	BondingCurve solana.PublicKey
    	User         solana.PublicKey
    }
    
    func ParseCreateEventInstruction(decodedBytes []byte) (*CreateEvent, error) {
    	if len(decodedBytes) < 16 {
    		return nil, fmt.Errorf("error decoding create instruction data: too short")
    	}
      // 跳过前面16个字节, 前16个字节是discriminator
    	decoder := ag_binary.NewBorshDecoder(decodedBytes[16:])
    	var create CreateEvent
    	if err := decoder.Decode(&create); err != nil {
    		return nil, fmt.Errorf("error unmarshaling TradeEvent: %s", err)
    	}
    	return &create, nil
    }
    

3.如何解析pump的所有交易

​ 和解析创建代币一样的逻辑, 只需要找到对应Buy的指令特征就行,例如下面的这个

pump_fun_trade_event

​ 可以看到实际上pump.fun的program每次trade之后也会创建一个event,只需要解析出这个event logs就行

​ 为了准确匹配出交易特征, 代码中可以根据instruction data 的前缀是否是指定的discriminator前缀就行, 代码如下

type TradeEvent struct {
	Mint                 solana.PublicKey // 代币ca
	SolAmount            uint64 // 交易的sol数量
	TokenAmount          uint64 // 交易的代币数量
	IsBuy                bool // 交易side
	User                 solana.PublicKey // 交易者
	Timestamp            int64 // 交易时间戳
	VirtualSolReserves   uint64 
	VirtualTokenReserves uint64
}

func ParseTradeEventInstruction2(decodedBytes []byte) (*TradeEvent, error) {
	decoder := ag_binary.NewBorshDecoder(decodedBytes[16:])

	if len(decodedBytes) < 16 {
		return nil, fmt.Errorf("error decoding trade instruction data: too short")
	}

	var trade TradeEvent
	if err := decoder.Decode(&trade); err != nil {
		return nil, fmt.Errorf("error unmarshaling TradeEvent: %s", err)
	}

	return &trade, nil
}

.....

// 2K7nL28P 就是trade event的discriminator前缀
if !strings.HasPrefix(instructionData, "2K7nL28P") {
  continue
}

tradeEvent, _ := pumpfun.ParseTradeEventInstruction2(instruction.Data)
if tradeEvent != nil {
  txType := enums.TxTypeBuy
  if !tradeEvent.IsBuy {
    txType = enums.TxTypeSell
  }
  // 由于pumpfun的decimal都是固定的,所以直接除就好
  token0Amount := decimal.NewFromUint64(tradeEvent.SolAmount).Div(decimal.NewFromInt(1_000_000_000))
  token1Amount := decimal.NewFromUint64(tradeEvent.TokenAmount).Div(decimal.NewFromInt(1_000_000))
  var token0UnitPrice decimal.Decimal
  if token1Amount.Cmp(decimal.Zero) > 0 {
    token0UnitPrice = token0Amount.Div(token1Amount).Round(18)
  } else {
    token0UnitPrice = decimal.Zero
  }
  trade := helius.PumpTrade{
    DexName:              "pump.fun",
    PoolAddress:          tradeEvent.Mint.String(),
    TxHash:               signature,
    TxType:               string(txType),
    Slot:                 int64(slot),
    InstructionIndex:     fmt.Sprintf("%d_%d", innerInstruction.Index, j),
    Timestamp:            tradeEvent.Timestamp,
    TraderAddress:        tradeEvent.User.String(),
    Token0Amount:         token0Amount.String(),
    Token1Amount:         token1Amount.String(),
    Token0UnitPrice:      token0UnitPrice.String(),
    Token0Address:        consts.WrappedSOLAddress, // pump 固定sol
    Token1Address:        tradeEvent.Mint.String(), // pump交易的代币
    VirtualSolReserves:   tradeEvent.VirtualSolReserves,
    VirtualTokenReserves: tradeEvent.VirtualTokenReserves,
  }
  tradeResult = append(tradeResult, trade)
}

以上就是如何通过yellowstone订阅指定program transaction, 然后解析pump.fun 的相关数据的逻辑, 至于如何解析发射池子, 后续会在解析raydium 相关数据中介绍