mirror of
https://github.com/luxfi/bridge.git
synced 2026-09-24 00:26:46 +00:00
381 lines
11 KiB
Go
381 lines
11 KiB
Go
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package plugins
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"math/big"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
const SolanaChainID uint64 = 1399811149 // "SOLN" in Teleporter namespace
|
|
|
|
// SolanaPlugin polls a Solana RPC node for LockEvent logs from the Lux bridge program
|
|
// and converts them to canonical DepositEvent format.
|
|
type SolanaPlugin struct {
|
|
rpcURL string
|
|
programID string // Base58 bridge program address
|
|
vaultAddress string // Base58 bridge config PDA (precomputed, not derived at runtime)
|
|
client *http.Client
|
|
}
|
|
|
|
func NewSolanaPlugin(rpcURL, programID, vaultAddress string) *SolanaPlugin {
|
|
return &SolanaPlugin{
|
|
rpcURL: rpcURL,
|
|
programID: programID,
|
|
vaultAddress: vaultAddress,
|
|
client: &http.Client{Timeout: 15 * time.Second},
|
|
}
|
|
}
|
|
|
|
func (s *SolanaPlugin) Name() string { return "solana" }
|
|
func (s *SolanaPlugin) ChainID() uint64 { return SolanaChainID }
|
|
|
|
// PollDeposits fetches confirmed transaction signatures for the bridge program
|
|
// starting from the given slot, then parses LockEvent data from each transaction's logs.
|
|
func (s *SolanaPlugin) PollDeposits(ctx context.Context, fromSlot uint64) ([]DepositEvent, uint64, error) {
|
|
// Get current slot to bound the search.
|
|
currentSlot, err := s.getSlot(ctx)
|
|
if err != nil {
|
|
return nil, fromSlot, fmt.Errorf("solana: get slot: %w", err)
|
|
}
|
|
if currentSlot <= fromSlot {
|
|
return nil, fromSlot, nil
|
|
}
|
|
|
|
// Fetch signatures for the bridge program in the slot range.
|
|
sigs, err := s.getSignaturesForAddress(ctx, fromSlot, currentSlot)
|
|
if err != nil {
|
|
return nil, fromSlot, fmt.Errorf("solana: get signatures: %w", err)
|
|
}
|
|
|
|
var events []DepositEvent
|
|
for _, sig := range sigs {
|
|
tx, err := s.getTransaction(ctx, sig.Signature)
|
|
if err != nil {
|
|
return events, sig.Slot, fmt.Errorf("solana: get tx %s: %w", sig.Signature, err)
|
|
}
|
|
|
|
deposit, ok := parseLockEventFromLogs(tx.Meta.LogMessages, sig.Slot, sig.Signature)
|
|
if ok {
|
|
events = append(events, deposit)
|
|
}
|
|
}
|
|
|
|
return events, currentSlot, nil
|
|
}
|
|
|
|
// QueryBacking calls the bridge program's total_locked account data via getAccountInfo.
|
|
func (s *SolanaPlugin) QueryBacking(ctx context.Context) (*big.Int, error) {
|
|
// The BridgeConfig PDA stores total locked value.
|
|
// PDA seeds: ["bridge_config"] — derived deterministically from the program ID.
|
|
// For now we read the account and parse the total_locked field from its data.
|
|
configPDA, err := s.deriveBridgeConfigPDA()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("solana: derive config PDA: %w", err)
|
|
}
|
|
|
|
info, err := s.getAccountInfo(ctx, configPDA)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("solana: get account info: %w", err)
|
|
}
|
|
|
|
data, err := base64.StdEncoding.DecodeString(info.Data[0])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("solana: decode account data: %w", err)
|
|
}
|
|
|
|
// BridgeConfig layout (after 8-byte discriminator):
|
|
// admin: 32 bytes (offset 8)
|
|
// mpc_signers: 96 bytes (offset 40)
|
|
// threshold: 1 byte (offset 136)
|
|
// fee_bps: 2 bytes (offset 137)
|
|
// fee_collector: 32 bytes (offset 139)
|
|
// paused: 1 byte (offset 171)
|
|
// outbound_nonce: 8 bytes (offset 172)
|
|
// chain_id: 8 bytes (offset 180)
|
|
// bump: 1 byte (offset 188)
|
|
//
|
|
// Total locked is tracked in the token vault accounts, not in BridgeConfig.
|
|
// Sum vault token balances via getTokenAccountBalance for each registered vault.
|
|
//
|
|
// For the backing attestation, we query vault balance directly.
|
|
_ = data
|
|
return s.queryVaultBalance(ctx)
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────
|
|
// Solana RPC helpers
|
|
// ────────────────────────────────────────────────────────────────────
|
|
|
|
type solanaSignature struct {
|
|
Signature string `json:"signature"`
|
|
Slot uint64 `json:"slot"`
|
|
}
|
|
|
|
type solanaTx struct {
|
|
Meta struct {
|
|
LogMessages []string `json:"logMessages"`
|
|
} `json:"meta"`
|
|
}
|
|
|
|
type solanaAccountInfo struct {
|
|
Data []string `json:"data"` // [base64_data, encoding]
|
|
}
|
|
|
|
func (s *SolanaPlugin) getSlot(ctx context.Context) (uint64, error) {
|
|
result, err := s.rpcCall(ctx, "getSlot", []interface{}{
|
|
map[string]string{"commitment": "confirmed"},
|
|
})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
var slot uint64
|
|
if err := json.Unmarshal(result, &slot); err != nil {
|
|
return 0, fmt.Errorf("parse slot: %w", err)
|
|
}
|
|
return slot, nil
|
|
}
|
|
|
|
func (s *SolanaPlugin) getSignaturesForAddress(ctx context.Context, minSlot, maxSlot uint64) ([]solanaSignature, error) {
|
|
params := []interface{}{
|
|
s.programID,
|
|
map[string]interface{}{
|
|
"commitment": "confirmed",
|
|
"minContext": map[string]uint64{"slot": minSlot},
|
|
},
|
|
}
|
|
result, err := s.rpcCall(ctx, "getSignaturesForAddress", params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var sigs []solanaSignature
|
|
if err := json.Unmarshal(result, &sigs); err != nil {
|
|
return nil, fmt.Errorf("parse signatures: %w", err)
|
|
}
|
|
// Filter to slot range.
|
|
var filtered []solanaSignature
|
|
for _, sig := range sigs {
|
|
if sig.Slot > minSlot && sig.Slot <= maxSlot {
|
|
filtered = append(filtered, sig)
|
|
}
|
|
}
|
|
return filtered, nil
|
|
}
|
|
|
|
func (s *SolanaPlugin) getTransaction(ctx context.Context, sig string) (*solanaTx, error) {
|
|
params := []interface{}{
|
|
sig,
|
|
map[string]interface{}{
|
|
"encoding": "json",
|
|
"commitment": "confirmed",
|
|
"maxSupportedTransactionVersion": 0,
|
|
},
|
|
}
|
|
result, err := s.rpcCall(ctx, "getTransaction", params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var tx solanaTx
|
|
if err := json.Unmarshal(result, &tx); err != nil {
|
|
return nil, fmt.Errorf("parse tx: %w", err)
|
|
}
|
|
return &tx, nil
|
|
}
|
|
|
|
func (s *SolanaPlugin) getAccountInfo(ctx context.Context, address string) (*solanaAccountInfo, error) {
|
|
params := []interface{}{
|
|
address,
|
|
map[string]string{"encoding": "base64", "commitment": "confirmed"},
|
|
}
|
|
result, err := s.rpcCall(ctx, "getAccountInfo", params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var wrapper struct {
|
|
Value *solanaAccountInfo `json:"value"`
|
|
}
|
|
if err := json.Unmarshal(result, &wrapper); err != nil {
|
|
return nil, fmt.Errorf("parse account info: %w", err)
|
|
}
|
|
if wrapper.Value == nil {
|
|
return nil, fmt.Errorf("account not found: %s", address)
|
|
}
|
|
return wrapper.Value, nil
|
|
}
|
|
|
|
func (s *SolanaPlugin) queryVaultBalance(ctx context.Context) (*big.Int, error) {
|
|
// Query program token accounts owned by the vault PDA.
|
|
// getTokenAccountsByOwner returns all SPL token accounts.
|
|
// Sum their balances for total backing.
|
|
params := []interface{}{
|
|
s.programID,
|
|
map[string]string{"programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"},
|
|
map[string]string{"encoding": "jsonParsed"},
|
|
}
|
|
result, err := s.rpcCall(ctx, "getTokenAccountsByOwner", params)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get token accounts: %w", err)
|
|
}
|
|
|
|
var wrapper struct {
|
|
Value []struct {
|
|
Account struct {
|
|
Data struct {
|
|
Parsed struct {
|
|
Info struct {
|
|
TokenAmount struct {
|
|
Amount string `json:"amount"`
|
|
} `json:"tokenAmount"`
|
|
} `json:"info"`
|
|
} `json:"parsed"`
|
|
} `json:"data"`
|
|
} `json:"account"`
|
|
} `json:"value"`
|
|
}
|
|
if err := json.Unmarshal(result, &wrapper); err != nil {
|
|
return nil, fmt.Errorf("parse token accounts: %w", err)
|
|
}
|
|
|
|
total := new(big.Int)
|
|
for _, acct := range wrapper.Value {
|
|
bal, ok := new(big.Int).SetString(acct.Account.Data.Parsed.Info.TokenAmount.Amount, 10)
|
|
if ok {
|
|
total.Add(total, bal)
|
|
}
|
|
}
|
|
return total, nil
|
|
}
|
|
|
|
func (s *SolanaPlugin) deriveBridgeConfigPDA() (string, error) {
|
|
if s.vaultAddress == "" {
|
|
return "", fmt.Errorf("solana: vaultAddress not configured (set to precomputed findProgramAddress([\"bridge_config\"], programID))")
|
|
}
|
|
return s.vaultAddress, nil
|
|
}
|
|
|
|
// parseLockEventFromLogs extracts a DepositEvent from Anchor program log messages.
|
|
// Anchor emits events as base64-encoded borsh in "Program data: <base64>" log lines.
|
|
func parseLockEventFromLogs(logs []string, slot uint64, txSig string) (DepositEvent, bool) {
|
|
// Anchor event discriminator for LockEvent: SHA256("event:LockEvent")[:8]
|
|
lockDiscriminator := []byte{0x65, 0xc4, 0x2b, 0x6f, 0x39, 0x48, 0x3e, 0x09}
|
|
|
|
for _, line := range logs {
|
|
if len(line) < 15 {
|
|
continue
|
|
}
|
|
// Anchor logs: "Program data: <base64>"
|
|
const prefix = "Program data: "
|
|
idx := 0
|
|
for i := 0; i <= len(line)-len(prefix); i++ {
|
|
if line[i:i+len(prefix)] == prefix {
|
|
idx = i + len(prefix)
|
|
break
|
|
}
|
|
}
|
|
if idx == 0 {
|
|
continue
|
|
}
|
|
|
|
data, err := base64.StdEncoding.DecodeString(line[idx:])
|
|
if err != nil || len(data) < 8 {
|
|
continue
|
|
}
|
|
|
|
if !bytes.Equal(data[:8], lockDiscriminator) {
|
|
continue
|
|
}
|
|
|
|
// LockEvent borsh layout (after 8-byte discriminator):
|
|
// source_chain: u64 (8 bytes)
|
|
// dest_chain: u64 (8 bytes)
|
|
// nonce: u64 (8 bytes)
|
|
// token: [32]u8
|
|
// sender: [32]u8
|
|
// recipient: [32]u8 (EVM address zero-padded to 32 bytes)
|
|
// amount: u64 (8 bytes)
|
|
// fee: u64 (8 bytes)
|
|
// timestamp: i64 (8 bytes)
|
|
const eventLen = 8 + 8 + 8 + 32 + 32 + 32 + 8 + 8 + 8 // 144
|
|
if len(data) < 8+eventLen {
|
|
continue
|
|
}
|
|
d := data[8:]
|
|
|
|
nonce := le64(d[16:24])
|
|
amount := new(big.Int).SetUint64(le64(d[120:128])) // amount at offset 120 (after 8+8+8+32+32+32)
|
|
|
|
var recipient [20]byte
|
|
// EVM address is in the last 20 bytes of the 32-byte recipient field at offset 88
|
|
copy(recipient[:], d[100:120]) // bytes 12-31 of the 32-byte field at offset 88
|
|
|
|
return DepositEvent{
|
|
SrcChainID: SolanaChainID,
|
|
Nonce: nonce,
|
|
Recipient: recipient,
|
|
Amount: amount,
|
|
TxID: txSig,
|
|
BlockHeight: slot,
|
|
}, true
|
|
}
|
|
return DepositEvent{}, false
|
|
}
|
|
|
|
func le64(b []byte) uint64 {
|
|
_ = b[7]
|
|
return uint64(b[0]) | uint64(b[1])<<8 | uint64(b[2])<<16 | uint64(b[3])<<24 |
|
|
uint64(b[4])<<32 | uint64(b[5])<<40 | uint64(b[6])<<48 | uint64(b[7])<<56
|
|
}
|
|
|
|
func (s *SolanaPlugin) rpcCall(ctx context.Context, method string, params interface{}) (json.RawMessage, error) {
|
|
reqBody := map[string]interface{}{
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": method,
|
|
"params": params,
|
|
}
|
|
body, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.rpcURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var rpcResp struct {
|
|
Result json.RawMessage `json:"result"`
|
|
Error *struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &rpcResp); err != nil {
|
|
return nil, fmt.Errorf("rpc decode: %w", err)
|
|
}
|
|
if rpcResp.Error != nil {
|
|
return nil, fmt.Errorf("rpc error %d: %s", rpcResp.Error.Code, rpcResp.Error.Message)
|
|
}
|
|
return rpcResp.Result, nil
|
|
}
|