Files
zeekay 72dcfe29a3 feat: all 19 chain plugins fully implemented — zero stubs
Every chain plugin now has real PollDeposits + QueryBacking:

EVM (generic):     eth_getLogs + eth_call — 15 chains
OP_NET:            Bitcoin L1 via JSON-RPC block scanning
Solana:            Anchor event parsing from program logs
TON:               BOC cell parsing + VarUInteger16 amounts
Lux:               Warp-aware EVM plugin (eth_getLogs)
Sui:               suix_queryEvents + MoveEventType filter
NEAR:              query call_function view + block/chunk fallback
Cosmos:            Tendermint tx_search + abci_query
XRPL:              account_tx + Payment/DestinationTag parsing
Stellar:           Horizon REST operations + memo recipient
Cardano:           Blockfrost API + lovelace balance
Algorand:          Algod API + application global state
ICP:               HTTP gateway JSON query interface
Polkadot:          Substrate chain_getBlock + SCALE decoding
Aptos:             REST event API + BridgeStore resource
StarkNet:          starknet_getEvents + felt conversion
Stacks:            Stacks API + Clarity value parsing
Fuel:              GraphQL receipts + LOG_DATA parsing
Tezos:             Tezos RPC + Michelson parameter parsing

97 tests pass. No external dependencies added.
2026-04-06 23:56:25 -07:00

289 lines
7.4 KiB
Go

// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package plugins
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"math/big"
"net/http"
"time"
)
const FuelChainID uint64 = 4294967520
// FuelPlugin polls a Fuel GraphQL API for deposit receipts from the bridge
// Sway contract. Fuel uses secp256k1 signing.
type FuelPlugin struct {
apiURL string
bridgeAddr string // Fuel contract ID (0x-prefixed)
client *http.Client
}
func NewFuelPlugin(apiURL, bridgeAddr string) *FuelPlugin {
return &FuelPlugin{
apiURL: apiURL,
bridgeAddr: bridgeAddr,
client: &http.Client{Timeout: 15 * time.Second},
}
}
func (f *FuelPlugin) Name() string { return "fuel" }
func (f *FuelPlugin) ChainID() uint64 { return FuelChainID }
// PollDeposits fetches LOG_DATA receipts from the bridge contract via the
// Fuel GraphQL API and parses deposit events from the receipt data.
func (f *FuelPlugin) PollDeposits(ctx context.Context, fromBlock uint64) ([]DepositEvent, uint64, error) {
height, err := f.latestBlockHeight(ctx)
if err != nil {
return nil, fromBlock, fmt.Errorf("fuel: latest block: %w", err)
}
if height <= fromBlock {
return nil, fromBlock, nil
}
// Fetch receipts from the bridge contract.
// Use cursor-based pagination starting after fromBlock.
cursor := fmt.Sprintf("%d", fromBlock)
receipts, err := f.getDepositReceipts(ctx, cursor)
if err != nil {
return nil, fromBlock, fmt.Errorf("fuel: get receipts: %w", err)
}
var events []DepositEvent
for _, r := range receipts {
deposit, ok := parseFuelDepositReceipt(r)
if ok {
events = append(events, deposit)
}
}
return events, height, nil
}
// QueryBacking queries the bridge contract's balance for the base asset (ETH).
func (f *FuelPlugin) QueryBacking(ctx context.Context) (*big.Int, error) {
// Query contract balance for the zero asset ID (base asset).
const query = `query($contract: ContractId!, $asset: AssetId!) {
contractBalance(contract: $contract, assetId: $asset) {
amount
}
}`
vars := map[string]string{
"contract": f.bridgeAddr,
"asset": "0x0000000000000000000000000000000000000000000000000000000000000000",
}
result, err := f.graphql(ctx, query, vars)
if err != nil {
return nil, fmt.Errorf("fuel: query backing: %w", err)
}
var resp struct {
ContractBalance struct {
Amount string `json:"amount"`
} `json:"contractBalance"`
}
if err := json.Unmarshal(result, &resp); err != nil {
return nil, fmt.Errorf("fuel: parse backing: %w", err)
}
val, ok := new(big.Int).SetString(resp.ContractBalance.Amount, 10)
if !ok {
return nil, fmt.Errorf("fuel: parse amount: %q", resp.ContractBalance.Amount)
}
return val, nil
}
// ────────────────────────────────────────────────────────────────────
// Fuel GraphQL types and helpers
// ────────────────────────────────────────────────────────────────────
type fuelReceipt struct {
TxID string `json:"txId"`
BlockHeight uint64 `json:"blockHeight"`
Data string `json:"data"` // hex-encoded log data
}
func (f *FuelPlugin) latestBlockHeight(ctx context.Context) (uint64, error) {
const query = `query {
chain {
latestBlock {
header {
height
}
}
}
}`
result, err := f.graphql(ctx, query, nil)
if err != nil {
return 0, err
}
var resp struct {
Chain struct {
LatestBlock struct {
Header struct {
Height string `json:"height"`
} `json:"header"`
} `json:"latestBlock"`
} `json:"chain"`
}
if err := json.Unmarshal(result, &resp); err != nil {
return 0, fmt.Errorf("parse chain height: %w", err)
}
h, ok := new(big.Int).SetString(resp.Chain.LatestBlock.Header.Height, 10)
if !ok {
return 0, fmt.Errorf("parse height: %q", resp.Chain.LatestBlock.Header.Height)
}
return h.Uint64(), nil
}
func (f *FuelPlugin) getDepositReceipts(ctx context.Context, cursor string) ([]fuelReceipt, error) {
const query = `query($contract: ContractId!, $after: String) {
receipts(
filter: { contractId: $contract, receiptType: LOG_DATA }
first: 50
after: $after
) {
edges {
node {
txId
blockHeight
data
}
}
}
}`
vars := map[string]string{
"contract": f.bridgeAddr,
"after": cursor,
}
result, err := f.graphql(ctx, query, vars)
if err != nil {
return nil, err
}
var resp struct {
Receipts struct {
Edges []struct {
Node struct {
TxID string `json:"txId"`
BlockHeight string `json:"blockHeight"`
Data string `json:"data"`
} `json:"node"`
} `json:"edges"`
} `json:"receipts"`
}
if err := json.Unmarshal(result, &resp); err != nil {
return nil, fmt.Errorf("parse receipts: %w", err)
}
var receipts []fuelReceipt
for _, edge := range resp.Receipts.Edges {
h, _ := new(big.Int).SetString(edge.Node.BlockHeight, 10)
var height uint64
if h != nil {
height = h.Uint64()
}
receipts = append(receipts, fuelReceipt{
TxID: edge.Node.TxID,
BlockHeight: height,
Data: edge.Node.Data,
})
}
return receipts, nil
}
// parseFuelDepositReceipt parses a deposit event from Fuel LOG_DATA receipt data.
// Sway deposit event layout (big-endian):
//
// nonce: u64 (8 bytes)
// recipient: b256 (32 bytes, EVM address in last 20)
// amount: u64 (8 bytes)
//
// Total: 48 bytes minimum.
func parseFuelDepositReceipt(r fuelReceipt) (DepositEvent, bool) {
data, err := hexToBytes(r.Data)
if err != nil {
log.Printf("WARN: fuel: skip malformed receipt in tx %s: %v", r.TxID, err)
return DepositEvent{}, false
}
if len(data) < 48 {
log.Printf("WARN: fuel: skip short receipt (%d bytes) in tx %s", len(data), r.TxID)
return DepositEvent{}, false
}
// Fuel uses big-endian encoding for ABI types.
nonce := be64(data[0:8])
var recipient [20]byte
// EVM address in last 20 bytes of the 32-byte b256 field at offset 8.
copy(recipient[:], data[20:40])
amount := new(big.Int).SetUint64(be64(data[40:48]))
return DepositEvent{
SrcChainID: FuelChainID,
Nonce: nonce,
Recipient: recipient,
Amount: amount,
TxID: r.TxID,
BlockHeight: r.BlockHeight,
}, true
}
// graphql sends a GraphQL POST request and returns the "data" field.
func (f *FuelPlugin) graphql(ctx context.Context, query string, variables interface{}) (json.RawMessage, error) {
reqBody := map[string]interface{}{
"query": query,
}
if variables != nil {
reqBody["variables"] = variables
}
body, err := json.Marshal(reqBody)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, f.apiURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := f.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 gqlResp struct {
Data json.RawMessage `json:"data"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
}
if err := json.Unmarshal(respBody, &gqlResp); err != nil {
return nil, fmt.Errorf("graphql decode: %w", err)
}
if len(gqlResp.Errors) > 0 {
return nil, fmt.Errorf("graphql error: %s", gqlResp.Errors[0].Message)
}
return gqlResp.Data, nil
}