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

如何快速解析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,便于我们分析具体如何解析

代币创建

相关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 相关数据中介绍

相关推荐

editplus使用教程详细(新手editplus的配置和使用)
bt365体育在线官网

editplus使用教程详细(新手editplus的配置和使用)

📅 07-11 👁️ 6315
頂天的解释
www.28365-365.com

頂天的解释

📅 12-07 👁️ 659
有哪些蓝牙管理工具?蓝牙管理软件有哪些
www.28365-365.com

有哪些蓝牙管理工具?蓝牙管理软件有哪些

📅 09-23 👁️ 5678
阴阳师巫蛊师最多位置分享,挑战关卡一次性出现14只
遏的拼音、意思、组词
www.28365-365.com

遏的拼音、意思、组词

📅 07-17 👁️ 273
肉煮熟后为什么发红
bt365体育在线官网

肉煮熟后为什么发红

📅 10-23 👁️ 2657
wegame每次登录都要更新助手?原来是这个原因!解决方案来了!
摩托车电瓶该如何选择?读懂标识做到事半功倍
www.28365-365.com

摩托车电瓶该如何选择?读懂标识做到事半功倍

📅 07-25 👁️ 6518
韩国留学理发费用是多少?知乎上具体价格是多少?