diff --git a/go.mod b/go.mod index fb99638c..63d6d0cc 100644 --- a/go.mod +++ b/go.mod @@ -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 ) @@ -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 diff --git a/go.sum b/go.sum index ee6c75e1..cde1d170 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/api/plugin.go b/internal/api/plugin.go index 8c01cbbc..a30f1154 100644 --- a/internal/api/plugin.go +++ b/internal/api/plugin.go @@ -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) } diff --git a/plugin/tx_indexer/chains_list.go b/plugin/tx_indexer/chains_list.go index 8ef775a5..cbc662b1 100644 --- a/plugin/tx_indexer/chains_list.go +++ b/plugin/tx_indexer/chains_list.go @@ -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" @@ -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, @@ -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, )) diff --git a/plugin/tx_indexer/pkg/chain/thorchain.go b/plugin/tx_indexer/pkg/chain/thorchain.go new file mode 100644 index 00000000..12d389a2 --- /dev/null +++ b/plugin/tx_indexer/pkg/chain/thorchain.go @@ -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 +} \ No newline at end of file diff --git a/plugin/tx_indexer/pkg/config/config.go b/plugin/tx_indexer/pkg/config/config.go index 115fbb64..a0750fb5 100644 --- a/plugin/tx_indexer/pkg/config/config.go +++ b/plugin/tx_indexer/pkg/config/config.go @@ -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"` diff --git a/plugin/tx_indexer/pkg/rpc/thorchain.go b/plugin/tx_indexer/pkg/rpc/thorchain.go new file mode 100644 index 00000000..0bb675d6 --- /dev/null +++ b/plugin/tx_indexer/pkg/rpc/thorchain.go @@ -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 + } +} diff --git a/tx_indexer.example.json b/tx_indexer.example.json index 0ea0368c..9ef5b634 100644 --- a/tx_indexer.example.json +++ b/tx_indexer.example.json @@ -12,6 +12,9 @@ "xrp": { "url": "https://s1.ripple.com:51234" }, + "thorchain": { + "url": "https://rpc.ninerealms.com" + }, "ethereum": { "url": "https://ethereum-rpc.publicnode.com" },