mirror of
https://github.com/luxfi/bridge.git
synced 2026-09-24 00:26:46 +00:00
342 lines
9.4 KiB
Go
342 lines
9.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 StacksChainID uint64 = 4294967440
|
|
|
|
// StacksPlugin polls a Stacks Blockchain API node for deposit transactions
|
|
// from the bridge Clarity contract. Stacks uses secp256k1 signing (Bitcoin-derived).
|
|
type StacksPlugin struct {
|
|
apiURL string
|
|
bridgeAddr string // Clarity contract principal (SP...)
|
|
client *http.Client
|
|
}
|
|
|
|
func NewStacksPlugin(apiURL, bridgeAddr string) *StacksPlugin {
|
|
return &StacksPlugin{
|
|
apiURL: apiURL,
|
|
bridgeAddr: bridgeAddr,
|
|
client: &http.Client{Timeout: 15 * time.Second},
|
|
}
|
|
}
|
|
|
|
func (s *StacksPlugin) Name() string { return "stacks" }
|
|
func (s *StacksPlugin) ChainID() uint64 { return StacksChainID }
|
|
|
|
// PollDeposits fetches contract_call transactions to the bridge contract since
|
|
// the given block height and parses deposit events from the Clarity call args.
|
|
func (s *StacksPlugin) PollDeposits(ctx context.Context, fromBlock uint64) ([]DepositEvent, uint64, error) {
|
|
height, err := s.tipHeight(ctx)
|
|
if err != nil {
|
|
return nil, fromBlock, fmt.Errorf("stacks: tip height: %w", err)
|
|
}
|
|
if height <= fromBlock {
|
|
return nil, fromBlock, nil
|
|
}
|
|
|
|
txs, err := s.getContractTxs(ctx)
|
|
if err != nil {
|
|
return nil, fromBlock, fmt.Errorf("stacks: get txs: %w", err)
|
|
}
|
|
|
|
var events []DepositEvent
|
|
for _, tx := range txs {
|
|
if tx.TxStatus != "success" {
|
|
continue
|
|
}
|
|
if tx.TxType != "contract_call" {
|
|
continue
|
|
}
|
|
if tx.BlockHeight <= fromBlock {
|
|
continue
|
|
}
|
|
|
|
deposit, ok := parseStacksDeposit(tx)
|
|
if ok {
|
|
events = append(events, deposit)
|
|
}
|
|
}
|
|
|
|
return events, height, nil
|
|
}
|
|
|
|
// QueryBacking calls the read-only get-total-locked function on the bridge contract.
|
|
func (s *StacksPlugin) QueryBacking(ctx context.Context) (*big.Int, error) {
|
|
url := fmt.Sprintf("%s/v2/contracts/call-read/%s/get-total-locked",
|
|
s.apiURL, s.bridgeAddr)
|
|
|
|
body := map[string]interface{}{
|
|
"sender": "ST000000000000000000002AMW42H",
|
|
"arguments": []string{},
|
|
}
|
|
bodyBytes, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stacks: marshal call-read body: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stacks: new request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stacks: call-read: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stacks: read response: %w", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("stacks: call-read status %d: %s", resp.StatusCode, respBody)
|
|
}
|
|
|
|
var result struct {
|
|
Okay bool `json:"okay"`
|
|
Result string `json:"result"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
return nil, fmt.Errorf("stacks: parse call-read: %w", err)
|
|
}
|
|
if !result.Okay {
|
|
return nil, fmt.Errorf("stacks: call-read not okay: %s", result.Result)
|
|
}
|
|
|
|
// Clarity returns uint values as "(ok u12345)" or "0x0100000000000000000000000000003039".
|
|
// The hex representation is a Clarity serialized uint: type prefix 0x01 + 16-byte big-endian.
|
|
val, err := parseClarityUint(result.Result)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stacks: parse total_locked: %w", err)
|
|
}
|
|
return val, nil
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────
|
|
// Stacks API types and helpers
|
|
// ────────────────────────────────────────────────────────────────────
|
|
|
|
type stacksTx struct {
|
|
TxID string `json:"tx_id"`
|
|
TxType string `json:"tx_type"`
|
|
TxStatus string `json:"tx_status"`
|
|
BlockHeight uint64 `json:"block_height"`
|
|
ContractCall struct {
|
|
ContractID string `json:"contract_id"`
|
|
FunctionName string `json:"function_name"`
|
|
FunctionArgs []struct {
|
|
Hex string `json:"hex"`
|
|
Repr string `json:"repr"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
} `json:"function_args"`
|
|
} `json:"contract_call"`
|
|
}
|
|
|
|
func (s *StacksPlugin) tipHeight(ctx context.Context) (uint64, error) {
|
|
url := fmt.Sprintf("%s/v2/info", s.apiURL)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return 0, fmt.Errorf("status %d: %s", resp.StatusCode, body)
|
|
}
|
|
|
|
var info struct {
|
|
StacksTipHeight uint64 `json:"stacks_tip_height"`
|
|
}
|
|
if err := json.Unmarshal(body, &info); err != nil {
|
|
return 0, fmt.Errorf("parse info: %w", err)
|
|
}
|
|
return info.StacksTipHeight, nil
|
|
}
|
|
|
|
func (s *StacksPlugin) getContractTxs(ctx context.Context) ([]stacksTx, error) {
|
|
url := fmt.Sprintf("%s/extended/v1/address/%s/transactions?limit=50&offset=0&type=contract_call",
|
|
s.apiURL, s.bridgeAddr)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("status %d: %s", resp.StatusCode, body)
|
|
}
|
|
|
|
var result struct {
|
|
Results []stacksTx `json:"results"`
|
|
}
|
|
if err := json.Unmarshal(body, &result); err != nil {
|
|
return nil, fmt.Errorf("parse txs: %w", err)
|
|
}
|
|
return result.Results, nil
|
|
}
|
|
|
|
// parseStacksDeposit extracts a DepositEvent from a Stacks contract_call transaction.
|
|
// The bridge contract's deposit function has args: nonce (uint), recipient (buff 20), amount (uint).
|
|
func parseStacksDeposit(tx stacksTx) (DepositEvent, bool) {
|
|
if tx.ContractCall.FunctionName != "deposit" {
|
|
return DepositEvent{}, false
|
|
}
|
|
|
|
args := tx.ContractCall.FunctionArgs
|
|
if len(args) < 3 {
|
|
log.Printf("WARN: stacks: skip tx %s: expected 3+ args, got %d", tx.TxID, len(args))
|
|
return DepositEvent{}, false
|
|
}
|
|
|
|
// Build a name->arg map for reliable lookup.
|
|
argMap := make(map[string]struct {
|
|
Hex string
|
|
Repr string
|
|
}, len(args))
|
|
for _, a := range args {
|
|
argMap[a.Name] = struct {
|
|
Hex string
|
|
Repr string
|
|
}{a.Hex, a.Repr}
|
|
}
|
|
|
|
nonceArg, ok := argMap["nonce"]
|
|
if !ok {
|
|
log.Printf("WARN: stacks: skip tx %s: missing nonce arg", tx.TxID)
|
|
return DepositEvent{}, false
|
|
}
|
|
recipientArg, ok := argMap["recipient"]
|
|
if !ok {
|
|
log.Printf("WARN: stacks: skip tx %s: missing recipient arg", tx.TxID)
|
|
return DepositEvent{}, false
|
|
}
|
|
amountArg, ok := argMap["amount"]
|
|
if !ok {
|
|
log.Printf("WARN: stacks: skip tx %s: missing amount arg", tx.TxID)
|
|
return DepositEvent{}, false
|
|
}
|
|
|
|
nonce, err := parseClarityUint(nonceArg.Hex)
|
|
if err != nil {
|
|
log.Printf("WARN: stacks: skip tx %s: parse nonce: %v", tx.TxID, err)
|
|
return DepositEvent{}, false
|
|
}
|
|
|
|
amount, err := parseClarityUint(amountArg.Hex)
|
|
if err != nil {
|
|
log.Printf("WARN: stacks: skip tx %s: parse amount: %v", tx.TxID, err)
|
|
return DepositEvent{}, false
|
|
}
|
|
|
|
recipient, err := parseClarityBuff(recipientArg.Hex)
|
|
if err != nil || len(recipient) < 20 {
|
|
log.Printf("WARN: stacks: skip tx %s: parse recipient: %v (len=%d)", tx.TxID, err, len(recipient))
|
|
return DepositEvent{}, false
|
|
}
|
|
|
|
var recipientAddr [20]byte
|
|
copy(recipientAddr[:], recipient[:20])
|
|
|
|
return DepositEvent{
|
|
SrcChainID: StacksChainID,
|
|
Nonce: nonce.Uint64(),
|
|
Recipient: recipientAddr,
|
|
Amount: amount,
|
|
TxID: tx.TxID,
|
|
BlockHeight: tx.BlockHeight,
|
|
}, true
|
|
}
|
|
|
|
// parseClarityUint parses a Clarity serialized uint value.
|
|
// Hex format: 0x01 + 16-byte big-endian uint128.
|
|
// Repr format: "u12345" or "(ok u12345)".
|
|
func parseClarityUint(s string) (*big.Int, error) {
|
|
// Try hex format first: type prefix 0x01 + 16 bytes.
|
|
if len(s) >= 2 && s[:2] == "0x" {
|
|
data, err := hexToBytes(s)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(data) >= 17 && data[0] == 0x01 {
|
|
return new(big.Int).SetBytes(data[1:17]), nil
|
|
}
|
|
// Might be raw uint bytes.
|
|
if len(data) > 0 {
|
|
return new(big.Int).SetBytes(data), nil
|
|
}
|
|
}
|
|
|
|
// Try repr format: "u12345" or "(ok u12345)".
|
|
str := s
|
|
// Strip wrapping (ok ...) if present.
|
|
if len(str) > 4 && str[:4] == "(ok " && str[len(str)-1] == ')' {
|
|
str = str[4 : len(str)-1]
|
|
}
|
|
// Strip 'u' prefix.
|
|
if len(str) > 1 && str[0] == 'u' {
|
|
str = str[1:]
|
|
}
|
|
val, ok := new(big.Int).SetString(str, 10)
|
|
if !ok {
|
|
return nil, fmt.Errorf("cannot parse clarity uint: %q", s)
|
|
}
|
|
return val, nil
|
|
}
|
|
|
|
// parseClarityBuff parses a Clarity serialized buffer value.
|
|
// Hex format: 0x02 + 4-byte big-endian length + raw bytes.
|
|
func parseClarityBuff(s string) ([]byte, error) {
|
|
if len(s) >= 2 && s[:2] == "0x" {
|
|
data, err := hexToBytes(s)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(data) >= 5 && data[0] == 0x02 {
|
|
bufLen := int(data[1])<<24 | int(data[2])<<16 | int(data[3])<<8 | int(data[4])
|
|
if len(data) < 5+bufLen {
|
|
return nil, fmt.Errorf("clarity buff truncated: need %d, have %d", 5+bufLen, len(data))
|
|
}
|
|
return data[5 : 5+bufLen], nil
|
|
}
|
|
// Raw hex bytes (no type prefix).
|
|
return data, nil
|
|
}
|
|
return nil, fmt.Errorf("cannot parse clarity buff: %q", s)
|
|
}
|