如何快速解析Solana链上DEX交易数据
本文总结一下链上数据的提取流程
在solana链上所有的交易都是一条条"指令(instruction)“,与evm不一样的是一个transaction 可以有多条指令, 每条指令又可以有多条inner instruciton. 当然一个transaction有最大空间限制所以不是无限条具体可以看下这篇介绍https://solana.com/docs/core/transactions
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,便于我们分析具体如何解析
-
代币创建
由于solana的program机制是在执行交易的时候需要把本次涉及到的所有account 都传入到指令中,所以我们分析交易行为和提取数据会非常的方便。
从上面这张图可以获得一些关键信息
pump.fun的合约地址是6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P
CA的账户是J8DsvUjD4xzjGL3ccDGmnPMWMG96iEcTPNvrwL1kpump
CA 的部署者是DJtXr8VPm9FbSwrSnbg3Go4Wbnc29H4o7qPnnQLeZ729
我们还想获得到 代币的symbol、logo、twitter url、bonding Curve等信息,如何获取呢?往下继续找
可以看到 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
}
上面的代码注释写的很清楚了, 但是有几个关键技术需要在解释下
-
如何获取到当前指令的执行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) }
-
如何解析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的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 相关数据中介绍