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