mirror of
https://github.com/luxfi/bridge.git
synced 2026-09-24 00:26:46 +00:00
223 lines
6.5 KiB
Go
223 lines
6.5 KiB
Go
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"math/big"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
const AlgorandChainID uint64 = 0x414C474F // "ALGO" in Teleporter namespace
|
|
|
|
// AlgorandPlugin polls the Algod API for deposit transactions from the
|
|
// bridge application. Algorand uses ed25519 signing.
|
|
type AlgorandPlugin struct {
|
|
apiURL string
|
|
appID string // Application ID
|
|
apiKey string
|
|
client *http.Client
|
|
}
|
|
|
|
func NewAlgorandPlugin(apiURL, appID, apiKey string) *AlgorandPlugin {
|
|
return &AlgorandPlugin{
|
|
apiURL: apiURL,
|
|
appID: appID,
|
|
apiKey: apiKey,
|
|
client: &http.Client{Timeout: 15 * time.Second},
|
|
}
|
|
}
|
|
|
|
func (a *AlgorandPlugin) Name() string { return "algorand" }
|
|
func (a *AlgorandPlugin) ChainID() uint64 { return AlgorandChainID }
|
|
|
|
// PollDeposits fetches application call transactions to the bridge app from the Algod
|
|
// indexer starting at fromRound+1, then parses deposit parameters from application args.
|
|
func (a *AlgorandPlugin) PollDeposits(ctx context.Context, fromRound uint64) ([]DepositEvent, uint64, error) {
|
|
currentRound, err := a.getLastRound(ctx)
|
|
if err != nil {
|
|
return nil, fromRound, fmt.Errorf("algorand: get last round: %w", err)
|
|
}
|
|
if currentRound <= fromRound {
|
|
return nil, fromRound, nil
|
|
}
|
|
|
|
txs, err := a.getAppTransactions(ctx, fromRound+1)
|
|
if err != nil {
|
|
return nil, fromRound, fmt.Errorf("algorand: get app transactions: %w", err)
|
|
}
|
|
|
|
var events []DepositEvent
|
|
for _, tx := range txs {
|
|
if tx.AppCall == nil {
|
|
continue
|
|
}
|
|
deposit, ok := parseAlgorandDeposit(&tx)
|
|
if !ok {
|
|
continue
|
|
}
|
|
events = append(events, deposit)
|
|
}
|
|
|
|
return events, currentRound, nil
|
|
}
|
|
|
|
// QueryBacking reads the total_locked key from the bridge application's global state.
|
|
func (a *AlgorandPlugin) QueryBacking(ctx context.Context) (*big.Int, error) {
|
|
var appInfo struct {
|
|
Params struct {
|
|
GlobalState []algoStateKV `json:"global-state"`
|
|
} `json:"params"`
|
|
}
|
|
if err := a.apiGet(ctx, fmt.Sprintf("/v2/applications/%s", a.appID), &appInfo); err != nil {
|
|
return nil, fmt.Errorf("algorand: query backing: %w", err)
|
|
}
|
|
|
|
for _, kv := range appInfo.Params.GlobalState {
|
|
keyBytes, err := base64.StdEncoding.DecodeString(kv.Key)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if string(keyBytes) != "total_locked" {
|
|
continue
|
|
}
|
|
// Type 2 = uint, type 1 = bytes.
|
|
if kv.Value.Type == 2 {
|
|
return new(big.Int).SetUint64(kv.Value.Uint), nil
|
|
}
|
|
if kv.Value.Type == 1 {
|
|
valBytes, err := base64.StdEncoding.DecodeString(kv.Value.Bytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("algorand: decode total_locked bytes: %w", err)
|
|
}
|
|
return new(big.Int).SetBytes(valBytes), nil
|
|
}
|
|
}
|
|
return big.NewInt(0), nil
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────
|
|
// Algod API types and helpers
|
|
// ────────────────────────────────────────────────────────────────────
|
|
|
|
type algoStateKV struct {
|
|
Key string `json:"key"` // base64
|
|
Value struct {
|
|
Type uint64 `json:"type"` // 1=bytes, 2=uint
|
|
Bytes string `json:"bytes"` // base64 (when type=1)
|
|
Uint uint64 `json:"uint"` // (when type=2)
|
|
} `json:"value"`
|
|
}
|
|
|
|
type algoTransaction struct {
|
|
ID string `json:"id"`
|
|
ConfirmedRound uint64 `json:"confirmed-round"`
|
|
AppCall *algoAppCall `json:"application-transaction"`
|
|
}
|
|
|
|
type algoAppCall struct {
|
|
AppID uint64 `json:"application-id"`
|
|
AppArgs []string `json:"application-args"` // base64-encoded
|
|
OnCompletion string `json:"on-completion"`
|
|
}
|
|
|
|
// parseAlgorandDeposit extracts a DepositEvent from an Algorand application call transaction.
|
|
// The bridge app expects application args:
|
|
//
|
|
// [0] method selector ("deposit")
|
|
// [1] nonce (8 bytes big-endian)
|
|
// [2] recipient (20 bytes EVM address)
|
|
// [3] amount (8 bytes big-endian)
|
|
func parseAlgorandDeposit(tx *algoTransaction) (DepositEvent, bool) {
|
|
if tx.AppCall == nil || len(tx.AppCall.AppArgs) < 4 {
|
|
return DepositEvent{}, false
|
|
}
|
|
|
|
methodArg, err := base64.StdEncoding.DecodeString(tx.AppCall.AppArgs[0])
|
|
if err != nil || string(methodArg) != "deposit" {
|
|
return DepositEvent{}, false
|
|
}
|
|
|
|
nonceBytes, err := base64.StdEncoding.DecodeString(tx.AppCall.AppArgs[1])
|
|
if err != nil || len(nonceBytes) < 8 {
|
|
log.Printf("WARN: algorand: skip tx %s: invalid nonce arg", tx.ID)
|
|
return DepositEvent{}, false
|
|
}
|
|
nonce := be64(nonceBytes[:8])
|
|
|
|
recipBytes, err := base64.StdEncoding.DecodeString(tx.AppCall.AppArgs[2])
|
|
if err != nil || len(recipBytes) < 20 {
|
|
log.Printf("WARN: algorand: skip tx %s: invalid recipient arg", tx.ID)
|
|
return DepositEvent{}, false
|
|
}
|
|
var recipient [20]byte
|
|
copy(recipient[:], recipBytes[:20])
|
|
|
|
amountBytes, err := base64.StdEncoding.DecodeString(tx.AppCall.AppArgs[3])
|
|
if err != nil || len(amountBytes) < 8 {
|
|
log.Printf("WARN: algorand: skip tx %s: invalid amount arg", tx.ID)
|
|
return DepositEvent{}, false
|
|
}
|
|
amount := new(big.Int).SetBytes(amountBytes[:8])
|
|
|
|
return DepositEvent{
|
|
SrcChainID: AlgorandChainID,
|
|
Nonce: nonce,
|
|
Recipient: recipient,
|
|
Amount: amount,
|
|
TxID: tx.ID,
|
|
BlockHeight: tx.ConfirmedRound,
|
|
}, true
|
|
}
|
|
|
|
func (a *AlgorandPlugin) getLastRound(ctx context.Context) (uint64, error) {
|
|
var status struct {
|
|
LastRound uint64 `json:"last-round"`
|
|
}
|
|
if err := a.apiGet(ctx, "/v2/status", &status); err != nil {
|
|
return 0, err
|
|
}
|
|
return status.LastRound, nil
|
|
}
|
|
|
|
func (a *AlgorandPlugin) getAppTransactions(ctx context.Context, minRound uint64) ([]algoTransaction, error) {
|
|
path := fmt.Sprintf("/v2/transactions?application-id=%s&min-round=%d&type=appl", a.appID, minRound)
|
|
var resp struct {
|
|
Transactions []algoTransaction `json:"transactions"`
|
|
}
|
|
if err := a.apiGet(ctx, path, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return resp.Transactions, nil
|
|
}
|
|
|
|
func (a *AlgorandPlugin) apiGet(ctx context.Context, path string, dest interface{}) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.apiURL+path, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("X-Algo-API-Token", a.apiKey)
|
|
|
|
resp, err := a.client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("algod %s: %d: %s", path, resp.StatusCode, body)
|
|
}
|
|
return json.Unmarshal(body, dest)
|
|
}
|