Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,12 @@ require (
github.com/vultisig/commondata v0.0.0-20250710214228-61d9ed8f7778
github.com/vultisig/go-wrappers v0.0.0-20250716071337-34a5c0f4d6e0
github.com/vultisig/mobile-tss-lib v0.0.0-20250316003201-2e7e570a4a74
github.com/vultisig/recipes v0.0.0-20251110144225-8d92cf257944
github.com/vultisig/recipes v0.0.0-20251112091748-7899bec2a8ec
github.com/vultisig/vultiserver v0.0.0-20250825042420-c6e6ac281110
github.com/vultisig/vultisig-go v0.0.0-20251004125942-60b3b1898d15
github.com/xyield/xrpl-go v0.0.0-20230914223425-9abe75c05830
golang.org/x/sync v0.14.0
golang.org/x/time v0.9.0
google.golang.org/protobuf v1.36.6
sigs.k8s.io/yaml v1.6.0
)
Expand Down Expand Up @@ -262,7 +263,6 @@ require (
golang.org/x/sys v0.33.0 // indirect
golang.org/x/term v0.32.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/time v0.9.0 // indirect
golang.org/x/tools v0.31.0 // indirect
google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect
Expand Down
26 changes: 2 additions & 24 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -1050,30 +1050,8 @@ github.com/vultisig/go-wrappers v0.0.0-20250716071337-34a5c0f4d6e0 h1:EdgQHZjzkY
github.com/vultisig/go-wrappers v0.0.0-20250716071337-34a5c0f4d6e0/go.mod h1:UfGCxUQW08kiwxyNBiHwXe+ePPuBmHVVS+BS51aU/Jg=
github.com/vultisig/mobile-tss-lib v0.0.0-20250316003201-2e7e570a4a74 h1:goqwk4nQ/NEVIb3OPP9SUx7/u9ZfsUIcd5fIN/e4DVU=
github.com/vultisig/mobile-tss-lib v0.0.0-20250316003201-2e7e570a4a74/go.mod h1:nOykk4nOy1L3yXtLSlYvVsgizBnCQ3tR2N5uwGPdvaM=
github.com/vultisig/recipes v0.0.0-20251024004947-6107c2ab0a64 h1:7gISCDyewsvTeasFVEvb/xaMVdtnxfObAK9aHqjK+jo=
github.com/vultisig/recipes v0.0.0-20251024004947-6107c2ab0a64/go.mod h1:gYRvnnDwj04CqNawzAZky0Do63Cmdf1RN9rNbcQqPUE=
github.com/vultisig/recipes v0.0.0-20251027203547-ac3242e4b91a h1:od+fGB0CmUHVflo0uKx1OiG09T4Hqi7+6gNI+2tP1o4=
github.com/vultisig/recipes v0.0.0-20251027203547-ac3242e4b91a/go.mod h1:gYRvnnDwj04CqNawzAZky0Do63Cmdf1RN9rNbcQqPUE=
github.com/vultisig/recipes v0.0.0-20251028124244-a61e31f3c2ee h1:j8KoUO6wZAP/D/G5F9a60u++ri0WIg3aZZhnF/e0bZE=
github.com/vultisig/recipes v0.0.0-20251028124244-a61e31f3c2ee/go.mod h1:gYRvnnDwj04CqNawzAZky0Do63Cmdf1RN9rNbcQqPUE=
github.com/vultisig/recipes v0.0.0-20251030210711-4957c1062a0a h1:G4/3gALaB0EtEGBQYxYxSh4kyy0lk1s8+gxbNfXo948=
github.com/vultisig/recipes v0.0.0-20251030210711-4957c1062a0a/go.mod h1:gYRvnnDwj04CqNawzAZky0Do63Cmdf1RN9rNbcQqPUE=
github.com/vultisig/recipes v0.0.0-20251030211813-309516ad5fee h1:kKHZG9yX4bhea7ASOxPB4P4Xlzci7nbbzAh0yW43JdE=
github.com/vultisig/recipes v0.0.0-20251030211813-309516ad5fee/go.mod h1:gYRvnnDwj04CqNawzAZky0Do63Cmdf1RN9rNbcQqPUE=
github.com/vultisig/recipes v0.0.0-20251104174445-41ecd01a2197 h1:LjdmbfSSQGLFkiNN29Vq7PCQCG/qmIchlvB6valk/rY=
github.com/vultisig/recipes v0.0.0-20251104174445-41ecd01a2197/go.mod h1:gYRvnnDwj04CqNawzAZky0Do63Cmdf1RN9rNbcQqPUE=
github.com/vultisig/recipes v0.0.0-20251104180304-7f653a2f5f86 h1:4d6SL2KaBSuo0Zv83LgJ91i0F8gvFWrzmNWdZw3X/NI=
github.com/vultisig/recipes v0.0.0-20251104180304-7f653a2f5f86/go.mod h1:gYRvnnDwj04CqNawzAZky0Do63Cmdf1RN9rNbcQqPUE=
github.com/vultisig/recipes v0.0.0-20251105141754-ad1c8df02c0d h1:f4VVcaY/HMh5Jhw7HSrMHU8Wymb7LYjm08Z2z++RBNo=
github.com/vultisig/recipes v0.0.0-20251105141754-ad1c8df02c0d/go.mod h1:gYRvnnDwj04CqNawzAZky0Do63Cmdf1RN9rNbcQqPUE=
github.com/vultisig/recipes v0.0.0-20251107185001-10c15ca5ac7a h1:FRxe+DMaMGBHa+SR0bZ5+rBWyVf3B9fiB0DbQ5/bAMo=
github.com/vultisig/recipes v0.0.0-20251107185001-10c15ca5ac7a/go.mod h1:gYRvnnDwj04CqNawzAZky0Do63Cmdf1RN9rNbcQqPUE=
github.com/vultisig/recipes v0.0.0-20251107191802-beb4afacdedb h1:uREmh5CqaB8HydoY521fxdtav/6aBfz6Pqjo6v+c+XU=
github.com/vultisig/recipes v0.0.0-20251107191802-beb4afacdedb/go.mod h1:gYRvnnDwj04CqNawzAZky0Do63Cmdf1RN9rNbcQqPUE=
github.com/vultisig/recipes v0.0.0-20251107192655-efa43da5d4e1 h1:9kbMsxFbnrkw8WgNsq7uCyA20H6hRT+nL97BRzimMPc=
github.com/vultisig/recipes v0.0.0-20251107192655-efa43da5d4e1/go.mod h1:gYRvnnDwj04CqNawzAZky0Do63Cmdf1RN9rNbcQqPUE=
github.com/vultisig/recipes v0.0.0-20251110144225-8d92cf257944 h1:lHaPUQNDJVY39zKkjsqaq5m6b4aqUIIezBGi7NxBpZg=
github.com/vultisig/recipes v0.0.0-20251110144225-8d92cf257944/go.mod h1:gYRvnnDwj04CqNawzAZky0Do63Cmdf1RN9rNbcQqPUE=
github.com/vultisig/recipes v0.0.0-20251112091748-7899bec2a8ec h1:9gEhm+cmAbq6Vb65lAI7EKvqM5qK0b/3hW7NLQvV8AU=
github.com/vultisig/recipes v0.0.0-20251112091748-7899bec2a8ec/go.mod h1:gYRvnnDwj04CqNawzAZky0Do63Cmdf1RN9rNbcQqPUE=
github.com/vultisig/vultiserver v0.0.0-20250825042420-c6e6ac281110 h1:7WDQ92FAdu08Byjgm3RNS8Sok49sK521PzPcbRpbzCE=
github.com/vultisig/vultiserver v0.0.0-20250825042420-c6e6ac281110/go.mod h1:HwP2IgW6Mcu/gX8paFuKvfibrGE9UmPgkOFTub6dskM=
github.com/vultisig/vultisig-go v0.0.0-20251004125942-60b3b1898d15 h1:wdRFnDMLdbaWXExUR/88WBAZ9sY9i9ldzurrYJWQeuw=
Expand Down
6 changes: 6 additions & 0 deletions internal/api/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,12 @@ func (s *Server) validateAndSign(c echo.Context, req *ptypes.PluginKeysignReques
return fmt.Errorf("failed to decode b64 proposed Solana tx: %w", er)
}
txBytesEvaluate = b
case firstKeysignMessage.Chain == common.THORChain:
b, er := base64.StdEncoding.DecodeString(req.Transaction)
if er != nil {
return fmt.Errorf("failed to decode b64 proposed THORChain tx: %w", er)
}
txBytesEvaluate = b
default:
return fmt.Errorf("failed to decode transaction, chain %s not supported", firstKeysignMessage.Chain)
}
Expand Down
13 changes: 13 additions & 0 deletions plugin/tx_indexer/chains_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"github.com/vultisig/recipes/sdk/btc"
"github.com/vultisig/recipes/sdk/solana"
"github.com/vultisig/recipes/sdk/thorchain"
"github.com/vultisig/recipes/sdk/xrpl"
"github.com/vultisig/verifier/plugin/tx_indexer/pkg/chain"
"github.com/vultisig/verifier/plugin/tx_indexer/pkg/config"
Expand Down Expand Up @@ -45,6 +46,14 @@ func Rpcs(ctx context.Context, cfg config.RpcConfig) (SupportedRpcs, error) {
rpcs[common.XRP] = xrpRpc
}

if cfg.THORChain.URL != "" {
thorchainRpc, err := rpc.NewTHORChain(cfg.THORChain.URL)
if err != nil {
return nil, fmt.Errorf("failed to create THORChain RPC client: %w", err)
}
rpcs[common.THORChain] = thorchainRpc
}

evmChains := map[common.Chain]config.RpcItem{
common.Ethereum: cfg.Ethereum,
common.Avalanche: cfg.Avalanche,
Expand Down Expand Up @@ -82,6 +91,10 @@ func Chains() (SupportedChains, error) {
nil,
))

chains[common.THORChain] = chain.NewTHORChainIndexer(thorchain.NewSDK(
nil,
))

chains[common.XRP] = chain.NewXRPIndexer(xrpl.NewSDK(
nil,
))
Expand Down
35 changes: 35 additions & 0 deletions plugin/tx_indexer/pkg/chain/thorchain.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package chain

import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"

"github.com/vultisig/mobile-tss-lib/tss"
"github.com/vultisig/recipes/sdk/thorchain"
)

type THORChainIndexer struct {
sdk *thorchain.SDK
}

func NewTHORChainIndexer(sdk *thorchain.SDK) *THORChainIndexer {
return &THORChainIndexer{
sdk: sdk,
}
}

func (t *THORChainIndexer) ComputeTxHash(proposedTx []byte, sigs map[string]tss.KeysignResponse) (string, error) {
signed, err := t.sdk.Sign(proposedTx, sigs)
if err != nil {
return "", fmt.Errorf("failed to sign: %w", err)
}

// For Cosmos-based chains like THORChain, the transaction hash is computed
// according to CometBFT/Tendermint standards: SHA256 hash of the signed transaction bytes,
// but this needs to match exactly what the blockchain network expects.
// The SDK Sign() method returns the final serialized transaction that will be broadcast.
hash := sha256.Sum256(signed)
return strings.ToUpper(hex.EncodeToString(hash[:])), nil
}
1 change: 1 addition & 0 deletions plugin/tx_indexer/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type DatabaseConfig struct {
type RpcConfig struct {
Bitcoin RpcItem `mapstructure:"bitcoin" json:"bitcoin,omitempty"`
Solana RpcItem `mapstructure:"solana" json:"solana,omitempty"`
THORChain RpcItem `mapstructure:"thorchain" json:"thorchain,omitempty"`
XRP RpcItem `mapstructure:"xrp" json:"xrp,omitempty"`
Ethereum RpcItem `mapstructure:"ethereum" json:"ethereum,omitempty"`
Avalanche RpcItem `mapstructure:"avalanche" json:"avalanche,omitempty"`
Expand Down
191 changes: 191 additions & 0 deletions plugin/tx_indexer/pkg/rpc/thorchain.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
package rpc

import (
"context"
"encoding/json"
"fmt"
"io"
"math"
"math/rand"
"net/http"
"strconv"
"strings"
"time"

"github.com/sirupsen/logrus"
"golang.org/x/time/rate"
)

type THORChain struct {
client *http.Client
baseURL string
limiter *rate.Limiter
}

func NewTHORChain(rpcURL string) (*THORChain, error) {
client := &http.Client{
Timeout: 30 * time.Second,
}

// Clean up URL
baseURL := strings.TrimSuffix(rpcURL, "/")

// Rate limiter: 1 req/s with burst of 3 (more lenient)
limiter := rate.NewLimiter(rate.Limit(1), 3)

return &THORChain{
client: client,
baseURL: baseURL,
limiter: limiter,
}, nil
}

// THORChain Tendermint RPC transaction response structure
type thorchainTxResponse struct {
Result struct {
Hash string `json:"hash"`
Height string `json:"height"`
TxResult struct {
Code int `json:"code"`
} `json:"tx_result"`
} `json:"result"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
}

func (t *THORChain) GetTxStatus(ctx context.Context, txHash string) (TxOnChainStatus, error) {
// Use Tendermint RPC format: prefix with 0x and ensure uppercase
formattedHash := strings.ToUpper(strings.TrimPrefix(txHash, "0x"))
url := fmt.Sprintf("%s/tx?hash=0x%s", t.baseURL, formattedHash)

return t.makeRequestWithRetry(ctx, url)
}

func (t *THORChain) makeRequestWithRetry(ctx context.Context, url string) (TxOnChainStatus, error) {
maxRetries := 3
baseDelay := 2 * time.Second

for attempt := 0; attempt <= maxRetries; attempt++ {
// Rate limiting
if err := t.limiter.Wait(ctx); err != nil {
return TxOnChainFail, fmt.Errorf("rate limiter context error: %w", err)
}

req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return TxOnChainFail, fmt.Errorf("failed to create request: %w", err)
}

resp, err := t.client.Do(req)
if err != nil {
if attempt == maxRetries {
return TxOnChainFail, fmt.Errorf("failed to make request after %d attempts: %w", maxRetries+1, err)
}
delay := t.calculateBackoff(attempt, baseDelay)
logrus.WithFields(logrus.Fields{
"attempt": attempt + 1,
"max_attempts": maxRetries + 1,
"delay_seconds": delay.Seconds(),
"error": err.Error(),
}).Info("THORChain RPC request failed, retrying")
t.sleep(ctx, delay)
continue
}

body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return TxOnChainFail, fmt.Errorf("failed to read response: %w", err)
}

// Handle rate limiting (429) - don't fail, just return pending
if resp.StatusCode == http.StatusTooManyRequests {
if attempt == maxRetries {
logrus.WithFields(logrus.Fields{
"attempt": attempt + 1,
"max_attempts": maxRetries + 1,
}).Warn("THORChain RPC rate limited after max retries, returning pending")
return TxOnChainPending, nil
}

delay := t.getRetryAfterDelay(resp, t.calculateBackoff(attempt, baseDelay))
t.sleep(ctx, delay)
continue
}

if resp.StatusCode != http.StatusOK {
return TxOnChainPending, fmt.Errorf("HTTP error: %d, body: %s", resp.StatusCode, string(body))
}

var txResp thorchainTxResponse
if err := json.Unmarshal(body, &txResp); err != nil {
return TxOnChainPending, fmt.Errorf("failed to unmarshal response: %w", err)
}

// Check for RPC error response
if txResp.Error != nil {
// Transaction not found - still pending
return TxOnChainPending, nil
}

// Check if transaction exists and has a height (confirmed)
if txResp.Result.Hash != "" && txResp.Result.Height != "" {
// Tendermint RPC: code 0 = success, non-zero = failure
if txResp.Result.TxResult.Code == 0 {
return TxOnChainSuccess, nil
}
return TxOnChainFail, nil
}

return TxOnChainPending, nil
}

// Don't crash on max retries - just return pending
return TxOnChainPending, nil
}

func (t *THORChain) calculateBackoff(attempt int, baseDelay time.Duration) time.Duration {
// Exponential backoff: 1s → 2s → 4s → 8s, max 30s
delay := baseDelay * time.Duration(math.Pow(2, float64(attempt)))
maxDelay := 30 * time.Second
if delay > maxDelay {
delay = maxDelay
}

// Add jitter (±25%)
jitter := time.Duration(rand.Float64()*0.5-0.25) * delay
return delay + jitter
}

func (t *THORChain) getRetryAfterDelay(resp *http.Response, fallback time.Duration) time.Duration {
retryAfter := resp.Header.Get("Retry-After")
if retryAfter == "" {
return fallback
}

// Try parsing as seconds
if seconds, err := strconv.Atoi(retryAfter); err == nil {
delay := time.Duration(seconds) * time.Second
maxDelay := 30 * time.Second
if delay > maxDelay {
delay = maxDelay
}
return delay
}

return fallback
}

func (t *THORChain) sleep(ctx context.Context, duration time.Duration) {
timer := time.NewTimer(duration)
defer timer.Stop()

select {
case <-ctx.Done():
return
case <-timer.C:
return
}
}
3 changes: 3 additions & 0 deletions tx_indexer.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
"xrp": {
"url": "https://s1.ripple.com:51234"
},
"thorchain": {
"url": "https://rpc.ninerealms.com"
},
"ethereum": {
"url": "https://ethereum-rpc.publicnode.com"
},
Expand Down