diff --git a/.run/[git] worker.run.xml b/.run/[git] worker.run.xml
index 7299015..afb12e9 100644
--- a/.run/[git] worker.run.xml
+++ b/.run/[git] worker.run.xml
@@ -32,6 +32,7 @@
+
diff --git a/cmd/worker/main.go b/cmd/worker/main.go
index e86dcba..c0abb53 100644
--- a/cmd/worker/main.go
+++ b/cmd/worker/main.go
@@ -22,9 +22,11 @@ import (
"github.com/vultisig/dca/internal/oneinch"
"github.com/vultisig/dca/internal/solana"
"github.com/vultisig/dca/internal/thorchain"
+ "github.com/vultisig/dca/internal/thorchain_native"
"github.com/vultisig/dca/internal/xrp"
btcsdk "github.com/vultisig/recipes/sdk/btc"
evmsdk "github.com/vultisig/recipes/sdk/evm"
+ thorchainSDK "github.com/vultisig/recipes/sdk/thorchain"
xrplsdk "github.com/vultisig/recipes/sdk/xrpl"
"github.com/vultisig/verifier/plugin"
plugin_config "github.com/vultisig/verifier/plugin/config"
@@ -230,6 +232,27 @@ func main() {
xrpClient,
)
+ // Initialize THORChain native network
+ thorchainNativeClient := thorchain_native.NewClient(cfg.ThorChain.TendermintURL, cfg.ThorChain.URL)
+
+ // Initialize THORChain SDK for signing and broadcasting
+ thorchainRpcClient, err := thorchainSDK.NewCometBFTRPCClient(cfg.ThorChain.TendermintURL)
+ if err != nil {
+ logger.Fatalf("failed to initialize THORChain RPC client: %v", err)
+ }
+ thorchainSDKInstance := thorchainSDK.NewSDK(thorchainRpcClient)
+
+ // Create THORChain native provider (uses THORChain API for quotes + native tx building)
+ thorchainNativeProvider := thorchain.NewProviderThorchainNative(thorchainClient, thorchainNativeClient)
+
+ // Create THORChain native network with SDK
+ thorchainNativeNetwork := thorchain_native.NewNetwork(
+ thorchain_native.NewSwapService([]thorchain_native.SwapProvider{thorchainNativeProvider}),
+ thorchain_native.NewSendService(thorchainNativeClient),
+ thorchain_native.NewSignerService(thorchainSDKInstance, signer, txIndexerService),
+ thorchainNativeClient,
+ )
+
jup, err := jupiter.NewProvider(cfg.Solana.JupiterAPIURL, solanarpc.New(cfg.Rpc.Solana.URL))
if err != nil {
logger.Fatalf("failed to initialize Jupiter provider: %v", err)
@@ -261,6 +284,7 @@ func main() {
),
solanaNetwork,
xrpNetwork,
+ thorchainNativeNetwork,
vaultStorage,
cfg.VaultService.EncryptionSecret,
)
@@ -303,7 +327,8 @@ type oneInchConfig struct {
}
type thorChainConfig struct {
- URL string
+ URL string `envconfig:"THORCHAIN_URL"` // THORChain application API (thornode.ninerealms.com) - for business logic, quotes, pools
+ TendermintURL string `envconfig:"THORCHAIN_TENDERMINTURL"` // Tendermint RPC endpoint (rpc.ninerealms.com) - for blockchain operations, broadcasting
}
type rpc struct {
@@ -318,6 +343,7 @@ type rpc struct {
BTC rpcItem
XRP rpcItem
Solana rpcItem
+ THORChain rpcItem
}
type rpcItem struct {
diff --git a/deploy/02_worker.yaml b/deploy/02_worker.yaml
index cbba8ee..854dfad 100644
--- a/deploy/02_worker.yaml
+++ b/deploy/02_worker.yaml
@@ -120,6 +120,11 @@ spec:
configMapKeyRef:
name: thorchain
key: url
+ - name: THORCHAIN_TENDERMINTURL
+ valueFrom:
+ configMapKeyRef:
+ name: thorchain
+ key: tendermint-url
- name: BTC_BLOCKCHAIRURL
valueFrom:
configMapKeyRef:
diff --git a/deploy/dev/01_thorchain.yaml b/deploy/dev/01_thorchain.yaml
index 7aa37a5..32c3701 100644
--- a/deploy/dev/01_thorchain.yaml
+++ b/deploy/dev/01_thorchain.yaml
@@ -4,3 +4,4 @@ metadata:
name: thorchain
data:
url: "https://thornode.ninerealms.com"
+ tendermint-url: "https://rpc.ninerealms.com"
diff --git a/deploy/prod/01_thorchain.yaml b/deploy/prod/01_thorchain.yaml
index 7aa37a5..32c3701 100644
--- a/deploy/prod/01_thorchain.yaml
+++ b/deploy/prod/01_thorchain.yaml
@@ -4,3 +4,4 @@ metadata:
name: thorchain
data:
url: "https://thornode.ninerealms.com"
+ tendermint-url: "https://rpc.ninerealms.com"
diff --git a/go.mod b/go.mod
index 6f28a8b..04fbffe 100644
--- a/go.mod
+++ b/go.mod
@@ -3,10 +3,12 @@ module github.com/vultisig/dca
go 1.24.2
require (
+ cosmossdk.io/math v1.5.3
github.com/btcsuite/btcd v0.24.2
github.com/btcsuite/btcd/btcutil v1.1.6
github.com/btcsuite/btcd/btcutil/psbt v1.1.10
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0
+ github.com/cosmos/cosmos-sdk v0.50.11
github.com/ethereum/go-ethereum v1.15.11
github.com/gagliardetto/solana-go v1.14.0
github.com/google/uuid v1.6.0
@@ -17,8 +19,8 @@ require (
github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.10.0
github.com/vultisig/mobile-tss-lib v0.0.0-20250316003201-2e7e570a4a74
- github.com/vultisig/recipes v0.0.0-20251028124244-a61e31f3c2ee
- github.com/vultisig/verifier v0.0.0-20251028124740-28151222d390
+ github.com/vultisig/recipes v0.0.0-20251112091748-7899bec2a8ec
+ github.com/vultisig/verifier v0.0.0-20251113095713-b811fd7525b0
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
@@ -32,7 +34,6 @@ require (
cosmossdk.io/depinject v1.2.1 // indirect
cosmossdk.io/errors v1.0.2 // indirect
cosmossdk.io/log v1.6.0 // indirect
- cosmossdk.io/math v1.5.3 // indirect
cosmossdk.io/schema v1.1.0 // indirect
cosmossdk.io/store v1.1.2 // indirect
cosmossdk.io/x/tx v0.14.0 // indirect
@@ -74,7 +75,6 @@ require (
github.com/cosmos/btcutil v1.0.5 // indirect
github.com/cosmos/cosmos-db v1.1.1 // indirect
github.com/cosmos/cosmos-proto v1.0.0-beta.5 // indirect
- github.com/cosmos/cosmos-sdk v0.50.11 // indirect
github.com/cosmos/go-bip39 v1.0.0 // indirect
github.com/cosmos/gogogateway v1.2.0 // indirect
github.com/cosmos/gogoproto v1.7.0 // indirect
diff --git a/go.sum b/go.sum
index 11f5763..19d7a1b 100644
--- a/go.sum
+++ b/go.sum
@@ -1013,10 +1013,10 @@ 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-20251028124244-a61e31f3c2ee h1:j8KoUO6wZAP/D/G5F9a60u++ri0WIg3aZZhnF/e0bZE=
-github.com/vultisig/recipes v0.0.0-20251028124244-a61e31f3c2ee/go.mod h1:gYRvnnDwj04CqNawzAZky0Do63Cmdf1RN9rNbcQqPUE=
-github.com/vultisig/verifier v0.0.0-20251028124740-28151222d390 h1:W2wAIG2TFfRNI89T4zgettXANCT+LFFrD52nJieZzUc=
-github.com/vultisig/verifier v0.0.0-20251028124740-28151222d390/go.mod h1:vAkhFF9OzhGfEoezrHShmVpgq5Jyw4UMzVytBmTfA30=
+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/verifier v0.0.0-20251113095713-b811fd7525b0 h1:gs6sv7WfEPDj4dq8dF6nBWR1OwcOQTq1odOaL/iXA80=
+github.com/vultisig/verifier v0.0.0-20251113095713-b811fd7525b0/go.mod h1:IApsss46kg0Oqv6jzfFQ5R9Cyfy6RTgRSyfI8pf2Wt0=
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/dca/consumer.go b/internal/dca/consumer.go
index aa4878b..a095304 100644
--- a/internal/dca/consumer.go
+++ b/internal/dca/consumer.go
@@ -19,6 +19,7 @@ import (
"github.com/vultisig/dca/internal/btc"
"github.com/vultisig/dca/internal/evm"
"github.com/vultisig/dca/internal/solana"
+ "github.com/vultisig/dca/internal/thorchain_native"
"github.com/vultisig/dca/internal/util"
"github.com/vultisig/dca/internal/xrp"
"github.com/vultisig/mobile-tss-lib/tss"
@@ -42,6 +43,7 @@ type Consumer struct {
btc *btc.Network
xrp *xrp.Network
solana *solana.Network
+ thorchain *thorchain_native.Network
vault vault.Storage
vaultSecret string
}
@@ -53,6 +55,7 @@ func NewConsumer(
btc *btc.Network,
solana *solana.Network,
xrp *xrp.Network,
+ thorchain *thorchain_native.Network,
vault vault.Storage,
vaultSecret string,
) *Consumer {
@@ -63,6 +66,7 @@ func NewConsumer(
btc: btc,
xrp: xrp,
solana: solana,
+ thorchain: thorchain,
vault: vault,
vaultSecret: vaultSecret,
}
@@ -188,6 +192,14 @@ func (c *Consumer) handle(ctx context.Context, t *asynq.Task) error {
return nil
}
+ if fromChainTyped == common.THORChain {
+ er := c.handleThorchainSwap(ctx, pol, toAssetMap, fromAmountStr, fromAssetTokenStr, toAssetTokenStr, toAddressStr)
+ if er != nil {
+ return fmt.Errorf("failed to handle THORChain swap: %w", er)
+ }
+ return nil
+ }
+
err = c.handleEvmSwap(
ctx,
pol,
@@ -777,3 +789,96 @@ func findSpender(chain common.Chain, rawRules []*rtypes.Rule) (ecommon.Address,
}
return ecommon.Address{}, fmt.Errorf("rule not found")
}
+
+func (c *Consumer) handleThorchainSwap(
+ ctx context.Context,
+ pol *types.PluginPolicy,
+ toAssetMap map[string]any,
+ fromAmountStr string,
+ fromAssetTokenStr string,
+ toAssetTokenStr string,
+ toAddressStr string,
+) error {
+ // Get THORChain address from policy public key
+ fromAddressStr, childPubKey, err := c.thorchainPubToAddress(pol.PublicKey)
+ if err != nil {
+ return fmt.Errorf("failed to get THORChain address from policy PublicKey: %w", err)
+ }
+
+ // Parse amount
+ fromAmountRune, err := parseUint64(fromAmountStr)
+ if err != nil {
+ return fmt.Errorf("failed to parse fromAmount: %w", err)
+ }
+
+ // Parse destination chain
+ toChainStr, ok := toAssetMap["chain"].(string)
+ if !ok {
+ return fmt.Errorf("failed to get toAsset.chain")
+ }
+
+ toChainTyped, err := common.FromString(toChainStr)
+ if err != nil {
+ return fmt.Errorf("failed to parse toAsset.chain: %w", err)
+ }
+
+ // Create THORChain native From and To structs
+ from := thorchain_native.From{
+ Address: fromAddressStr,
+ AssetID: fromAssetTokenStr,
+ Amount: fromAmountRune,
+ PubKey: childPubKey,
+ // Sequence will be auto-fetched by network
+ }
+
+ to := thorchain_native.To{
+ Chain: toChainTyped,
+ AssetID: toAssetTokenStr,
+ Address: toAddressStr,
+ }
+
+ c.logger.WithFields(logrus.Fields{
+ "policyID": pol.ID.String(),
+ "fromAddress": fromAddressStr,
+ "fromAmount": fromAmountRune,
+ "fromAsset": fromAssetTokenStr,
+ "toChain": toChainTyped.String(),
+ "toAsset": toAssetTokenStr,
+ "toAddress": toAddressStr,
+ }).Info("handling THORChain swap")
+
+ // Execute the swap using THORChain native network
+ txHash, err := c.thorchain.SwapAssets(ctx, *pol, from, to)
+ if err != nil {
+ return fmt.Errorf("failed to execute THORChain swap: %w", err)
+ }
+
+ c.logger.WithField("txHash", txHash).Info("THORChain swap signed & broadcasted successfully")
+ return nil
+}
+
+func (c *Consumer) thorchainPubToAddress(rootPub string) (string, string, error) {
+ vaultContent, err := c.vault.GetVault(common.GetVaultBackupFilename(rootPub, string(types.PluginVultisigDCA_0000)))
+ if err != nil {
+ return "", "", fmt.Errorf("failed to get vault content: %w", err)
+ }
+
+ vlt, err := common.DecryptVaultFromBackup(c.vaultSecret, vaultContent)
+ if err != nil {
+ return "", "", fmt.Errorf("failed to decrypt vault: %w", err)
+ }
+
+ // Get THORChain-specific child key derivation
+ childPub, err := tss.GetDerivedPubKey(rootPub, vlt.GetHexChainCode(), common.THORChain.GetDerivePath(), false)
+ if err != nil {
+ return "", "", fmt.Errorf("failed to get derived pubkey: %w", err)
+ }
+
+ // Convert child public key to THORChain address (bech32 format with "thor" prefix)
+ addr, err := address.GetBech32Address(childPub, "thor")
+ if err != nil {
+ return "", "", fmt.Errorf("failed to get THORChain address: %w", err)
+ }
+
+ return addr, childPub, nil
+}
diff --git a/internal/dca/spec.go b/internal/dca/spec.go
index dab031b..e851eea 100644
--- a/internal/dca/spec.go
+++ b/internal/dca/spec.go
@@ -28,6 +28,7 @@ var supportedChains = []common.Chain{
common.Bitcoin,
common.Solana,
common.XRP,
+ common.THORChain,
}
const (
diff --git a/internal/thorchain/const.go b/internal/thorchain/const.go
index d21c08a..9f03577 100644
--- a/internal/thorchain/const.go
+++ b/internal/thorchain/const.go
@@ -26,6 +26,8 @@ func parseThorNetwork(c common.Chain) (thorNetwork, error) {
return avax, nil
case common.XRP:
return xrp, nil
+ case common.THORChain:
+ return thor, nil
default:
return "", errors.New("unknown chain")
}
@@ -40,4 +42,5 @@ const (
base thorNetwork = "BASE"
avax thorNetwork = "AVAX"
xrp thorNetwork = "XRP"
+ thor thorNetwork = "THOR"
)
diff --git a/internal/thorchain/provider_thorchain_native.go b/internal/thorchain/provider_thorchain_native.go
new file mode 100644
index 0000000..ff90a9c
--- /dev/null
+++ b/internal/thorchain/provider_thorchain_native.go
@@ -0,0 +1,257 @@
+package thorchain
+
+import (
+ "context"
+ "encoding/hex"
+ "fmt"
+ "strconv"
+
+ "cosmossdk.io/math"
+ "github.com/cosmos/cosmos-sdk/codec"
+ codectypes "github.com/cosmos/cosmos-sdk/codec/types"
+ "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1"
+ sdk "github.com/cosmos/cosmos-sdk/types"
+ "github.com/cosmos/cosmos-sdk/types/tx"
+ "github.com/cosmos/cosmos-sdk/types/tx/signing"
+ thorchain_native "github.com/vultisig/dca/internal/thorchain_native"
+ recipestypes "github.com/vultisig/recipes/types"
+ thorchainSDK "github.com/vultisig/recipes/sdk/thorchain"
+ "github.com/vultisig/vultisig-go/common"
+)
+
+// ProviderThorchainNative implements thorchain_native.SwapProvider interface
+// It uses THORChain API for quotes and builds native THORChain transactions
+type ProviderThorchainNative struct {
+ client *Client
+ thorchainClient *thorchain_native.Client
+ codec codec.Codec
+}
+
+// Ensure ProviderThorchainNative implements thorchain_native.SwapProvider
+var _ thorchain_native.SwapProvider = (*ProviderThorchainNative)(nil)
+
+func NewProviderThorchainNative(client *Client, thorchainClient *thorchain_native.Client) *ProviderThorchainNative {
+ return &ProviderThorchainNative{
+ client: client,
+ thorchainClient: thorchainClient,
+ codec: thorchainSDK.MakeCodec(),
+ }
+}
+
+func (p *ProviderThorchainNative) validateThorchainNative(from thorchain_native.From, to thorchain_native.To) error {
+ if from.Address == "" {
+ return fmt.Errorf("[THORChain] from address cannot be empty")
+ }
+
+ if to.Address == "" {
+ return fmt.Errorf("[THORChain] to address cannot be empty")
+ }
+
+ // Only allow cross-chain swaps for now (reject same-chain THORChain swaps)
+ if to.Chain == common.THORChain {
+ return fmt.Errorf("[THORChain] same-chain swaps not supported - only cross-chain swaps allowed (THORChain to external chains)")
+ }
+
+ // Validate destination chain is supported
+ _, err := parseThorNetwork(to.Chain)
+ if err != nil {
+ return fmt.Errorf("[THORChain] unsupported destination chain: %w", err)
+ }
+
+ return nil
+}
+
+func (p *ProviderThorchainNative) MakeTransaction(
+ ctx context.Context,
+ from thorchain_native.From,
+ to thorchain_native.To,
+) ([]byte, uint64, error) {
+ if err := p.validateThorchainNative(from, to); err != nil {
+ return nil, 0, fmt.Errorf("[THORChain] invalid swap: %w", err)
+ }
+
+ // Convert assets to THORChain format
+ fromAsset, err := makeThorAsset(ctx, p.client, common.THORChain, from.AssetID)
+ if err != nil {
+ return nil, 0, fmt.Errorf("[THORChain] failed to convert from asset: %w", err)
+ }
+
+ toAsset, err := makeThorAsset(ctx, p.client, to.Chain, to.AssetID)
+ if err != nil {
+ return nil, 0, fmt.Errorf("[THORChain] failed to convert to asset: %w", err)
+ }
+
+ // Get quote from THORChain for the swap
+ quoteRequest := quoteSwapRequest{
+ FromAsset: fromAsset,
+ ToAsset: toAsset,
+ Amount: fmt.Sprintf("%d", from.Amount),
+ Destination: to.Address,
+ StreamingInterval: defaultStreamingInterval,
+ StreamingQuantity: defaultStreamingQuantity,
+ ToleranceBps: defaultToleranceBps,
+ }
+
+ quote, err := p.client.getQuote(ctx, quoteRequest)
+ if err != nil {
+ return nil, 0, fmt.Errorf("[THORChain] failed to get quote: %w", err)
+ }
+
+ // Parse expected amount out
+ expectedOut, err := strconv.ParseUint(quote.ExpectedAmountOut, 10, 64)
+ if err != nil {
+ return nil, 0, fmt.Errorf("[THORChain] failed to parse expected amount out: %w", err)
+ }
+
+ // Get dynamic THORChain network data
+ currentHeight, err := p.thorchainClient.GetLatestBlock(ctx)
+ if err != nil {
+ return nil, 0, fmt.Errorf("[THORChain] failed to get current block height: %w", err)
+ }
+
+ baseFee, err := p.thorchainClient.GetBaseFee(ctx)
+ if err != nil {
+ return nil, 0, fmt.Errorf("[THORChain] failed to get base fee: %w", err)
+ }
+
+ // Get complete account information (number and sequence) from the from address
+ accountInfo, err := p.thorchainClient.GetAccountInfo(ctx, from.Address)
+ if err != nil {
+ return nil, 0, fmt.Errorf("[THORChain] failed to get account info: %w", err)
+ }
+
+ // Build THORChain native swap transaction
+ txBytes, err := p.buildUnsignedThorchainSwapTx(
+ from,
+ quote,
+ accountInfo,
+ baseFee,
+ currentHeight+100, // 100 block buffer
+ )
+ if err != nil {
+ return nil, 0, fmt.Errorf("[THORChain] failed to build swap transaction: %w", err)
+ }
+
+ return txBytes, expectedOut, nil
+}
+
+// buildUnsignedThorchainSwapTx creates an unsigned THORChain swap transaction
+func (p *ProviderThorchainNative) buildUnsignedThorchainSwapTx(
+ from thorchain_native.From,
+ quote quoteSwapResponse,
+ accountInfo thorchain_native.AccountInfo, // Account number and sequence are needed for proper SignDoc
+ feeRune uint64, // Base fee in RUNE (from GetBaseFee)
+ _ uint64, // timeoutHeight - not needed for MsgDeposit
+) ([]byte, error) {
+ // Build THORChain memo for cross-chain swap
+ // Since we only support cross-chain swaps, use the quote memo directly
+ memo := quote.Memo
+
+ // Create the asset based on the from.AssetID
+ var asset *recipestypes.Asset
+ var decimals int64
+
+ if from.AssetID == "" {
+ // Native RUNE
+ asset = &recipestypes.Asset{
+ Chain: "THOR",
+ Symbol: "RUNE",
+ Ticker: "RUNE",
+ Synth: false,
+ Trade: false,
+ Secured: false,
+ }
+ decimals = 8 // RUNE has 8 decimals
+ } else {
+ // Other THORChain assets (e.g., tokens)
+ asset = &recipestypes.Asset{
+ Chain: "THOR",
+ Symbol: from.AssetID,
+ Ticker: from.AssetID,
+ Synth: false,
+ Trade: false,
+ Secured: false,
+ }
+ decimals = 8 // Default to 8 decimals for THORChain assets
+ }
+
+ // Create the deposit coin using the new Coin structure (no denom field)
+ coin := &recipestypes.Coin{
+ Asset: asset,
+ Amount: fmt.Sprintf("%d", from.Amount),
+ Decimals: decimals,
+ }
+
+ // Decode bech32 address to raw address bytes (not ASCII bytes)
+ signerBytes, err := sdk.GetFromBech32(from.Address, "thor")
+ if err != nil {
+ return nil, fmt.Errorf("failed to decode bech32 address %s: %w", from.Address, err)
+ }
+
+ // Build MsgDeposit with memo in the message (correct for cross-chain swaps)
+ msgDeposit := &recipestypes.MsgDeposit{
+ Coins: []*recipestypes.Coin{coin},
+ Memo: memo, // Memo goes in MsgDeposit for cross-chain swaps
+ Signer: signerBytes, // Signer as bytes instead of string
+ }
+
+ // Pack MsgDeposit into Any for Cosmos SDK transaction
+ msgAny, err := codectypes.NewAnyWithValue(msgDeposit)
+ if err != nil {
+ return nil, fmt.Errorf("failed to pack MsgDeposit into Any: %w", err)
+ }
+
+ // Convert public key hex string to secp256k1.PubKey (now properly registered)
+ pubKeyBytes, err := hex.DecodeString(from.PubKey)
+ if err != nil {
+ return nil, fmt.Errorf("failed to decode public key hex: %w", err)
+ }
+
+ pubKey := &secp256k1.PubKey{Key: pubKeyBytes}
+ pubKeyAny, err := codectypes.NewAnyWithValue(pubKey)
+ if err != nil {
+ return nil, fmt.Errorf("failed to pack public key into Any: %w", err)
+ }
+
+ // Create fee amount using dynamic fee from THORChain network
+ feeAmount := sdk.NewCoins(sdk.NewCoin("rune", math.NewInt(int64(feeRune))))
+ // TODO consider fetching dynamically
+ gasLimit := uint64(500000) // 500k gas limit
+
+ // Create complete Cosmos SDK transaction structure with proper gas and fees
+ // For MsgDeposit, memo goes in the message, not transaction body
+ txData := &tx.Tx{
+ Body: &tx.TxBody{
+ Messages: []*codectypes.Any{msgAny},
+ Memo: "", // Empty for MsgDeposit - memo is in the message
+ },
+ AuthInfo: &tx.AuthInfo{
+ SignerInfos: []*tx.SignerInfo{
+ {
+ PublicKey: pubKeyAny,
+ ModeInfo: &tx.ModeInfo{
+ Sum: &tx.ModeInfo_Single_{
+ Single: &tx.ModeInfo_Single{
+ Mode: signing.SignMode_SIGN_MODE_DIRECT,
+ },
+ },
+ },
+ Sequence: accountInfo.Sequence, // Use actual account sequence from chain
+ },
+ },
+ Fee: &tx.Fee{
+ Amount: feeAmount,
+ GasLimit: gasLimit,
+ },
+ },
+ Signatures: [][]byte{{}}, // Empty signature placeholder for unsigned transaction
+ }
+
+ // Marshal the complete transaction using provider's codec
+ txBytes, err := p.codec.Marshal(txData)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal complete transaction: %w", err)
+ }
+
+ return txBytes, nil
+}
diff --git a/internal/thorchain_native/client.go b/internal/thorchain_native/client.go
new file mode 100644
index 0000000..69c9e6b
--- /dev/null
+++ b/internal/thorchain_native/client.go
@@ -0,0 +1,156 @@
+package thorchain_native
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strconv"
+ "time"
+)
+
+// AccountInfo represents complete account information from THORChain
+type AccountInfo struct {
+ AccountNumber uint64
+ Sequence uint64
+}
+
+
+// Client provides THORChain account and network data access
+type Client struct {
+ tendermintRpcURL string // Tendermint RPC endpoint for blockchain operations (rpc.ninerealms.com)
+ thorchainApiURL string // THORChain API endpoint for account queries (thornode.ninerealms.com)
+ httpClient *http.Client
+}
+
+// NewClient creates a new THORChain client with both RPC endpoints
+func NewClient(tendermintRpcURL, thorchainApiURL string) *Client {
+ return &Client{
+ tendermintRpcURL: tendermintRpcURL,
+ thorchainApiURL: thorchainApiURL,
+ httpClient: &http.Client{
+ Timeout: 30 * time.Second,
+ },
+ }
+}
+
+
+// GetAccountInfo fetches account information (number and sequence) from THORChain
+func (c *Client) GetAccountInfo(ctx context.Context, address string) (AccountInfo, error) {
+ // Use Cosmos REST API endpoint for account info
+ // THORChain follows standard Cosmos SDK patterns
+ url := fmt.Sprintf("%s/cosmos/auth/v1beta1/accounts/%s", c.thorchainApiURL, address)
+
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return AccountInfo{}, fmt.Errorf("thorchain: failed to create account request: %w", err)
+ }
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return AccountInfo{}, fmt.Errorf("thorchain: failed to query account info: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return AccountInfo{}, fmt.Errorf("thorchain: unexpected status code: %d", resp.StatusCode)
+ }
+
+ var accountResp struct {
+ Account struct {
+ AccountNumber string `json:"account_number"`
+ Sequence string `json:"sequence"`
+ } `json:"account"`
+ }
+
+ if err := json.NewDecoder(resp.Body).Decode(&accountResp); err != nil {
+ return AccountInfo{}, fmt.Errorf("thorchain: failed to decode account response: %w", err)
+ }
+
+ accountNumber, err := strconv.ParseUint(accountResp.Account.AccountNumber, 10, 64)
+ if err != nil {
+ return AccountInfo{}, fmt.Errorf("thorchain: failed to parse account number: %w", err)
+ }
+
+ sequence, err := strconv.ParseUint(accountResp.Account.Sequence, 10, 64)
+ if err != nil {
+ return AccountInfo{}, fmt.Errorf("thorchain: failed to parse sequence: %w", err)
+ }
+
+ return AccountInfo{
+ AccountNumber: accountNumber,
+ Sequence: sequence,
+ }, nil
+}
+
+// GetLatestBlock fetches current block height from THORChain
+func (c *Client) GetLatestBlock(ctx context.Context) (uint64, error) {
+ // Use Tendermint RPC endpoint to get latest block height
+ url := fmt.Sprintf("%s/status", c.tendermintRpcURL)
+
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return 0, fmt.Errorf("thorchain: failed to create block request: %w", err)
+ }
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return 0, fmt.Errorf("thorchain: failed to get latest block: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return 0, fmt.Errorf("thorchain: unexpected status code: %d", resp.StatusCode)
+ }
+
+ var statusResp struct {
+ Result struct {
+ SyncInfo struct {
+ LatestBlockHeight string `json:"latest_block_height"`
+ } `json:"sync_info"`
+ } `json:"result"`
+ }
+
+ if err := json.NewDecoder(resp.Body).Decode(&statusResp); err != nil {
+ return 0, fmt.Errorf("thorchain: failed to decode status response: %w", err)
+ }
+
+ // Parse block height from string
+ height, err := strconv.ParseUint(statusResp.Result.SyncInfo.LatestBlockHeight, 10, 64)
+ if err != nil {
+ return 0, fmt.Errorf("thorchain: failed to parse block height: %w", err)
+ }
+
+ return height, nil
+}
+
+// GetBaseFee fetches current base fee from THORChain network
+func (c *Client) GetBaseFee(ctx context.Context) (uint64, error) {
+ // Query THORChain constants for current native transaction fee
+ url := fmt.Sprintf("%s/thorchain/constants", c.thorchainApiURL)
+
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return 0, fmt.Errorf("thorchain: failed to create constants request: %w", err)
+ }
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return 0, fmt.Errorf("thorchain: failed to query constants: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return 0, fmt.Errorf("thorchain: unexpected status code: %d", resp.StatusCode)
+ }
+
+ var constantsResp struct {
+ NativeTransactionFee uint64 `json:"NativeTransactionFee"`
+ }
+
+ if err := json.NewDecoder(resp.Body).Decode(&constantsResp); err != nil {
+ return 0, fmt.Errorf("thorchain: failed to decode constants response: %w", err)
+ }
+
+ return constantsResp.NativeTransactionFee, nil
+}
diff --git a/internal/thorchain_native/network.go b/internal/thorchain_native/network.go
new file mode 100644
index 0000000..9f2c1f1
--- /dev/null
+++ b/internal/thorchain_native/network.go
@@ -0,0 +1,83 @@
+package thorchain_native
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/vultisig/verifier/types"
+ "github.com/vultisig/vultisig-go/common"
+)
+
+type Network struct {
+ Swap *SwapService
+ Send *SendService
+ Signer *SignerService
+ client *Client
+}
+
+func NewNetwork(
+ swap *SwapService,
+ send *SendService,
+ signer *SignerService,
+ client *Client,
+) *Network {
+ return &Network{
+ Swap: swap,
+ Send: send,
+ Signer: signer,
+ client: client,
+ }
+}
+
+func (n *Network) SendPayment(ctx context.Context, policy types.PluginPolicy, fromAddress, toAddress string, amountRune uint64, pubKey string) (string, error) {
+ // Get account information for signing
+ accountInfo, err := n.client.GetAccountInfo(ctx, fromAddress)
+ if err != nil {
+ return "", fmt.Errorf("thorchain: failed to get account info: %w", err)
+ }
+
+ // Build payment transaction using send service
+ txData, err := n.Send.BuildPayment(ctx, fromAddress, toAddress, amountRune, pubKey)
+ if err != nil {
+ return "", fmt.Errorf("thorchain: failed to build payment: %w", err)
+ }
+
+ // Sign and broadcast transaction
+ txHash, err := n.Signer.SignAndBroadcast(ctx, policy, txData, accountInfo.AccountNumber, accountInfo.Sequence)
+ if err != nil {
+ return "", fmt.Errorf("thorchain: failed to sign and broadcast: %w", err)
+ }
+
+ return txHash, nil
+}
+
+func (n *Network) SwapAssets(ctx context.Context, policy types.PluginPolicy, from From, to To) (string, error) {
+ if to.Chain == common.THORChain && from.AssetID == to.AssetID {
+ return "", errors.New("thorchain: can't swap same asset on THORChain")
+ }
+
+ // Fetch dynamic THORChain network data (account number and sequence)
+ accountInfo, err := n.client.GetAccountInfo(ctx, from.Address)
+ if err != nil {
+ return "", fmt.Errorf("thorchain: failed to get account info: %w", err)
+ }
+
+ // Update from struct with fetched account info
+ from.Sequence = accountInfo.Sequence
+ from.AccountNumber = accountInfo.AccountNumber
+
+ // Find best swap route
+ txData, _, err := n.Swap.FindBestAmountOut(ctx, from, to)
+ if err != nil {
+ return "", fmt.Errorf("thorchain: failed to find best amount out: %w", err)
+ }
+
+ // Sign and broadcast transaction
+ txHash, err := n.Signer.SignAndBroadcast(ctx, policy, txData, from.AccountNumber, from.Sequence)
+ if err != nil {
+ return "", fmt.Errorf("thorchain: failed to sign and broadcast: %w", err)
+ }
+
+ return txHash, nil
+}
\ No newline at end of file
diff --git a/internal/thorchain_native/provider_thorchain_swap.go b/internal/thorchain_native/provider_thorchain_swap.go
new file mode 100644
index 0000000..fc247e9
--- /dev/null
+++ b/internal/thorchain_native/provider_thorchain_swap.go
@@ -0,0 +1,193 @@
+package thorchain_native
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/vultisig/vultisig-go/common"
+)
+
+type ProviderThorchainSwap struct {
+ client *Client
+}
+
+func NewProviderThorchainSwap(client *Client) *ProviderThorchainSwap {
+ return &ProviderThorchainSwap{
+ client: client,
+ }
+}
+
+func (p *ProviderThorchainSwap) validateThorchainSwap(from From, to To) error {
+ if to.Chain == common.THORChain && from.AssetID == to.AssetID {
+ return fmt.Errorf("thorchain: can't swap same asset on THORChain")
+ }
+
+ // Validate that we support the destination chain
+ supportedChains := []common.Chain{
+ common.Bitcoin,
+ common.Ethereum,
+ common.BscChain,
+ common.Base,
+ common.Avalanche,
+ common.XRP,
+ common.THORChain,
+ }
+
+ supported := false
+ for _, chain := range supportedChains {
+ if to.Chain == chain {
+ supported = true
+ break
+ }
+ }
+
+ if !supported {
+ return fmt.Errorf("thorchain: unsupported destination chain: %s", to.Chain)
+ }
+
+ return nil
+}
+
+func (p *ProviderThorchainSwap) MakeTransaction(
+ ctx context.Context,
+ from From,
+ to To,
+) ([]byte, uint64, error) {
+ if err := p.validateThorchainSwap(from, to); err != nil {
+ return nil, 0, fmt.Errorf("thorchain: invalid swap: %w", err)
+ }
+
+ // Get dynamic THORChain network data
+ currentHeight, err := p.client.GetLatestBlock(ctx)
+ if err != nil {
+ return nil, 0, fmt.Errorf("thorchain: failed to get current block height: %w", err)
+ }
+
+ baseFee, err := p.client.GetBaseFee(ctx)
+ if err != nil {
+ return nil, 0, fmt.Errorf("thorchain: failed to get base fee: %w", err)
+ }
+
+ // For THORChain native swaps, we need to construct a Cosmos SDK transaction
+ // that contains either:
+ // 1. A native swap message (if both assets are on THORChain)
+ // 2. An outbound transaction message (if swapping to external chain)
+
+ // Build the swap transaction
+ txBytes, expectedOut, err := p.buildThorchainSwapTx(
+ from,
+ to,
+ currentHeight,
+ baseFee,
+ )
+ if err != nil {
+ return nil, 0, fmt.Errorf("thorchain: failed to build swap transaction: %w", err)
+ }
+
+ return txBytes, expectedOut, nil
+}
+
+func (p *ProviderThorchainSwap) buildThorchainSwapTx(
+ from From,
+ to To,
+ currentHeight uint64,
+ baseFee uint64,
+) ([]byte, uint64, error) {
+ // Determine swap type and build appropriate transaction
+ var expectedOut uint64
+ var txData []byte
+ var err error
+
+ if to.Chain == common.THORChain {
+ // Native THORChain swap (e.g., RUNE to synthetic asset)
+ txData, expectedOut, err = p.buildNativeSwapTx(from, to, currentHeight, baseFee)
+ } else {
+ // Cross-chain swap (e.g., RUNE to external chain asset)
+ txData, expectedOut, err = p.buildCrossChainSwapTx(from, to, currentHeight, baseFee)
+ }
+
+ if err != nil {
+ return nil, 0, fmt.Errorf("thorchain: failed to build swap tx: %w", err)
+ }
+
+ return txData, expectedOut, nil
+}
+
+func (p *ProviderThorchainSwap) buildNativeSwapTx(
+ from From,
+ to To,
+ currentHeight uint64,
+ baseFee uint64,
+) ([]byte, uint64, error) {
+ // TODO: Implement native THORChain swap transaction building
+ // This would use THORChain's native swap messages
+
+ // For now, return a placeholder transaction
+ placeholder := fmt.Sprintf("thorchain-native-swap:from=%s:to=%s:amount=%d:height=%d:fee=%d:fromAsset=%s:toAsset=%s",
+ from.Address, to.Address, from.Amount, currentHeight, baseFee, from.AssetID, to.AssetID)
+
+ // Estimate output (simplified - real implementation would query THORChain pools)
+ expectedOut := from.Amount * 95 / 100 // 5% slippage estimate
+
+ return []byte(placeholder), expectedOut, nil
+}
+
+func (p *ProviderThorchainSwap) buildCrossChainSwapTx(
+ from From,
+ to To,
+ currentHeight uint64,
+ baseFee uint64,
+) ([]byte, uint64, error) {
+ // TODO: Implement cross-chain swap transaction building
+ // This would use THORChain's MsgSend to the appropriate vault address
+ // with a memo indicating the swap details
+
+ // Build swap memo in THORChain format: "=:CHAIN.ASSET:DEST_ADDR:LIM"
+ memo := p.buildSwapMemo(to.Chain, to.AssetID, to.Address)
+
+ placeholder := fmt.Sprintf("thorchain-crosschain-swap:from=%s:vault=%s:amount=%d:memo=%s:height=%d:fee=%d",
+ from.Address, "thorchain_vault_address", from.Amount, memo, currentHeight, baseFee)
+
+ // Estimate output (simplified - real implementation would query THORChain for quotes)
+ expectedOut := from.Amount * 90 / 100 // 10% slippage estimate for cross-chain
+
+ return []byte(placeholder), expectedOut, nil
+}
+
+func (p *ProviderThorchainSwap) buildSwapMemo(toChain common.Chain, toAsset, toAddress string) string {
+ // THORChain swap memo format: "=:CHAIN.ASSET:DEST_ADDR:LIM"
+ chainStr := p.chainToThorchainFormat(toChain)
+ assetStr := toAsset
+ if assetStr == "" {
+ // Native asset
+ nativeSymbol, err := toChain.NativeSymbol()
+ if err == nil {
+ assetStr = nativeSymbol
+ }
+ }
+
+ memo := fmt.Sprintf("=:%s.%s:%s", chainStr, assetStr, toAddress)
+ return memo
+}
+
+func (p *ProviderThorchainSwap) chainToThorchainFormat(chain common.Chain) string {
+ switch chain {
+ case common.Bitcoin:
+ return "BTC"
+ case common.Ethereum:
+ return "ETH"
+ case common.BscChain:
+ return "BSC"
+ case common.Base:
+ return "BASE"
+ case common.Avalanche:
+ return "AVAX"
+ case common.XRP:
+ return "XRP"
+ case common.THORChain:
+ return "THOR"
+ default:
+ return strings.ToUpper(chain.String())
+ }
+}
\ No newline at end of file
diff --git a/internal/thorchain_native/send_service.go b/internal/thorchain_native/send_service.go
new file mode 100644
index 0000000..5041491
--- /dev/null
+++ b/internal/thorchain_native/send_service.go
@@ -0,0 +1,80 @@
+package thorchain_native
+
+import (
+ "context"
+ "fmt"
+)
+
+type SendService struct {
+ client *Client
+}
+
+func NewSendService(client *Client) *SendService {
+ return &SendService{
+ client: client,
+ }
+}
+
+func (s *SendService) BuildPayment(
+ ctx context.Context,
+ from string,
+ to string,
+ amountRune uint64,
+ signingPubKey string,
+) ([]byte, error) {
+ // Get dynamic THORChain network data
+ accountInfo, err := s.client.GetAccountInfo(ctx, from)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get account info: %w", err)
+ }
+ sequence := accountInfo.Sequence
+
+ currentHeight, err := s.client.GetLatestBlock(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get current block height: %w", err)
+ }
+
+ baseFee, err := s.client.GetBaseFee(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get base fee: %w", err)
+ }
+
+ // Build Cosmos SDK bank send transaction
+ txData, err := buildUnsignedCosmosSDKSendTx(
+ from,
+ to,
+ amountRune,
+ sequence,
+ baseFee,
+ currentHeight+100, // 100 block buffer
+ signingPubKey,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to build send transaction: %w", err)
+ }
+
+ return txData, nil
+}
+
+// buildUnsignedCosmosSDKSendTx creates an unsigned Cosmos SDK bank send transaction
+func buildUnsignedCosmosSDKSendTx(
+ from, to string,
+ amountRune uint64,
+ sequence uint64,
+ feeRune uint64,
+ timeoutHeight uint64,
+ signingPubKey string,
+) ([]byte, error) {
+ // TODO: Implement Cosmos SDK transaction building
+ // This would typically involve:
+ // 1. Creating a MsgSend with from/to addresses and amount
+ // 2. Wrapping in a transaction with fee, sequence, timeout
+ // 3. Serializing to protobuf bytes for signing
+ // 4. Use cosmos-sdk-go or equivalent library
+
+ // For now, return a placeholder that indicates the structure needed
+ placeholder := fmt.Sprintf("cosmos-tx:bank-send:from=%s:to=%s:amount=%d:sequence=%d:fee=%d:timeout=%d:pubkey=%s",
+ from, to, amountRune, sequence, feeRune, timeoutHeight, signingPubKey)
+
+ return []byte(placeholder), nil
+}
diff --git a/internal/thorchain_native/signer_service.go b/internal/thorchain_native/signer_service.go
new file mode 100644
index 0000000..917f667
--- /dev/null
+++ b/internal/thorchain_native/signer_service.go
@@ -0,0 +1,139 @@
+package thorchain_native
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "fmt"
+ "strings"
+
+ "github.com/vultisig/mobile-tss-lib/tss"
+ "github.com/vultisig/verifier/plugin/keysign"
+ "github.com/vultisig/verifier/plugin/tx_indexer"
+ "github.com/vultisig/verifier/plugin/tx_indexer/pkg/storage"
+ "github.com/vultisig/verifier/types"
+ "github.com/vultisig/vultisig-go/common"
+)
+
+type SignerService struct {
+ sdk ThorchainSDK // THORChain Cosmos SDK wrapper interface
+ signer *keysign.Signer
+ txIndexer *tx_indexer.Service
+}
+
+// ThorchainSDK interface for THORChain Cosmos SDK operations
+type ThorchainSDK interface {
+ MessageHash(txData []byte, accountNumber uint64, sequence uint64) ([]byte, error) // Calculate proper Cosmos SDK SignDoc hash
+ Sign(txData []byte, signatures map[string]tss.KeysignResponse) ([]byte, error)
+ Broadcast(ctx context.Context, signedTxBytes []byte) error
+}
+
+func NewSignerService(
+ sdk ThorchainSDK,
+ signer *keysign.Signer,
+ txIndexer *tx_indexer.Service,
+) *SignerService {
+ return &SignerService{
+ sdk: sdk,
+ signer: signer,
+ txIndexer: txIndexer,
+ }
+}
+
+func (s *SignerService) SignAndBroadcast(
+ ctx context.Context,
+ policy types.PluginPolicy,
+ txData []byte,
+ accountNumber uint64,
+ sequence uint64,
+) (string, error) {
+ if s.sdk == nil {
+ return "", fmt.Errorf("thorchain: SDK not available - THORChain signing not yet implemented")
+ }
+
+ keysignRequest, err := s.buildKeysignRequest(ctx, policy, txData, accountNumber, sequence)
+ if err != nil {
+ return "", fmt.Errorf("thorchain: failed to build keysign request: %w", err)
+ }
+
+ signatures, err := s.signer.Sign(ctx, keysignRequest)
+ if err != nil {
+ return "", fmt.Errorf("thorchain: failed to get signature: %w", err)
+ }
+
+ // Sign the transaction using THORChain SDK
+ // The SDK handles public key extraction internally from the transaction
+ signedTxBytes, err := s.sdk.Sign(txData, signatures)
+ if err != nil {
+ return "", fmt.Errorf("thorchain: failed to sign transaction: %w", err)
+ }
+
+ err = s.sdk.Broadcast(ctx, signedTxBytes)
+ if err != nil {
+ return "", fmt.Errorf("thorchain: failed to broadcast transaction: %w", err)
+ }
+
+ // Extract transaction hash from signed transaction
+ txHash, err := s.extractTransactionHash(signedTxBytes)
+ if err != nil {
+ return "", fmt.Errorf("thorchain: failed to extract transaction hash: %w", err)
+ }
+
+ return txHash, nil
+}
+
+func (s *SignerService) buildKeysignRequest(
+ ctx context.Context,
+ policy types.PluginPolicy,
+ txData []byte,
+ accountNumber uint64,
+ sequence uint64,
+) (types.PluginKeysignRequest, error) {
+ hashToSign, err := s.sdk.MessageHash(txData, accountNumber, sequence)
+ if err != nil {
+ return types.PluginKeysignRequest{}, fmt.Errorf("thorchain: failed to calculate SignDoc hash: %w", err)
+ }
+
+ // Create tx tracking entry
+ txBase64 := base64.StdEncoding.EncodeToString(txData)
+ txToTrack, err := s.txIndexer.CreateTx(ctx, storage.CreateTxDto{
+ PluginID: policy.PluginID,
+ PolicyID: policy.ID,
+ ChainID: common.THORChain,
+ TokenID: "",
+ FromPublicKey: policy.PublicKey,
+ ToPublicKey: "",
+ ProposedTxHex: txBase64,
+ })
+ if err != nil {
+ return types.PluginKeysignRequest{}, fmt.Errorf("thorchain: failed to create tx: %w", err)
+ }
+
+ // Create keysign message - THORChain uses standard Cosmos SDK signing
+ hashToSignBase64 := base64.StdEncoding.EncodeToString(hashToSign)
+
+ msg := types.KeysignMessage{
+ TxIndexerID: txToTrack.ID.String(),
+ Message: hashToSignBase64,
+ Hash: hashToSignBase64,
+ HashFunction: types.HashFunction_SHA256,
+ Chain: common.THORChain,
+ }
+
+ return types.PluginKeysignRequest{
+ KeysignRequest: types.KeysignRequest{
+ PublicKey: policy.PublicKey,
+ Messages: []types.KeysignMessage{msg},
+ PolicyID: policy.ID,
+ PluginID: policy.PluginID.String(),
+ },
+ Transaction: txBase64,
+ }, nil
+}
+
+func (s *SignerService) extractTransactionHash(signedTxBytes []byte) (string, error) {
+ hash := sha256.Sum256(signedTxBytes)
+ txHash := hex.EncodeToString(hash[:])
+ return strings.ToUpper(txHash), nil
+}
diff --git a/internal/thorchain_native/swap_provider.go b/internal/thorchain_native/swap_provider.go
new file mode 100644
index 0000000..4e221a9
--- /dev/null
+++ b/internal/thorchain_native/swap_provider.go
@@ -0,0 +1,9 @@
+package thorchain_native
+
+import (
+ "context"
+)
+
+type SwapProvider interface {
+ MakeTransaction(ctx context.Context, from From, to To) (txData []byte, toAmount uint64, err error)
+}
\ No newline at end of file
diff --git a/internal/thorchain_native/swap_service.go b/internal/thorchain_native/swap_service.go
new file mode 100644
index 0000000..66c3db9
--- /dev/null
+++ b/internal/thorchain_native/swap_service.go
@@ -0,0 +1,37 @@
+package thorchain_native
+
+import (
+ "context"
+ "fmt"
+)
+
+type SwapService struct {
+ providers []SwapProvider
+}
+
+func NewSwapService(providers []SwapProvider) *SwapService {
+ return &SwapService{
+ providers: providers,
+ }
+}
+
+func (s *SwapService) FindBestAmountOut(
+ ctx context.Context,
+ from From,
+ to To,
+) ([]byte, uint64, error) {
+ if len(s.providers) == 0 {
+ return nil, 0, fmt.Errorf("no providers available")
+ }
+
+ // For now, just use the first provider
+ // TODO: Implement best route finding logic when we have multiple providers
+ provider := s.providers[0]
+
+ txData, toAmount, err := provider.MakeTransaction(ctx, from, to)
+ if err != nil {
+ return nil, 0, fmt.Errorf("provider failed: %w", err)
+ }
+
+ return txData, toAmount, nil
+}
\ No newline at end of file
diff --git a/internal/thorchain_native/types.go b/internal/thorchain_native/types.go
new file mode 100644
index 0000000..61f7d0a
--- /dev/null
+++ b/internal/thorchain_native/types.go
@@ -0,0 +1,21 @@
+package thorchain_native
+
+import (
+ "github.com/vultisig/vultisig-go/common"
+)
+
+type From struct {
+ Address string // THORChain address (thor1...)
+ AssetID string // Asset identifier ("" for native RUNE, or token symbol)
+ Amount uint64 // Amount in base units (1e8 for RUNE)
+ Sequence uint64 // Account sequence (Cosmos SDK style)
+ AccountNumber uint64 // Account number (Cosmos SDK style)
+ PubKey string // Child-derived public key for signing
+}
+
+// To destination could be any supported chain
+type To struct {
+ Chain common.Chain
+ AssetID string
+ Address string
+}
\ No newline at end of file