diff --git a/harnesses/bridge-monitor/cmd/monitor/executor.go b/harnesses/bridge-monitor/cmd/monitor/executor.go index add2a17a..7428375f 100644 --- a/harnesses/bridge-monitor/cmd/monitor/executor.go +++ b/harnesses/bridge-monitor/cmd/monitor/executor.go @@ -1,6 +1,7 @@ package main import ( + "crypto/ecdsa" "encoding/json" "fmt" "log" @@ -474,12 +475,18 @@ func (e *Executor) executeMobula(route TestRoute, amount float64, quoteStart tim log.Printf(" [mobula] Getting quote: %s → %s, amount: %.4f, sender: %s", route.FromChain, route.ToChain, amount, senderAddress[:8]+"...") - // Get quote with TX - quote, _, err := e.mobula.GetQuote( + // Two-step quote: for EVM origins the first response's deposit is a + // placeholder; only the signed re-quote's deposit is executable. See + // mobula_bridge.go GetSignedQuote for the flow. + var evmKey *ecdsa.PrivateKey + if e.txExecutor != nil { + evmKey = e.txExecutor.EVMPrivateKey() + } + quote, _, err := e.mobula.GetSignedQuote( route.FromChainAPI, route.FromToken, route.ToChainAPI, route.ToToken, senderAddress, receiverAddress, - amount, + amount, evmKey, ) if err != nil { log.Printf(" [mobula] ❌ Quote error: %v", err) diff --git a/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go b/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go index 4f1ab858..706c294a 100644 --- a/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go +++ b/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go @@ -1,6 +1,8 @@ package main import ( + "crypto/ecdsa" + "encoding/hex" "encoding/json" "fmt" "io" @@ -8,6 +10,10 @@ import ( "net/http" "strconv" "time" + + ethmath "github.com/ethereum/go-ethereum/common/math" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/signer/core/apitypes" ) type MobulaBridge struct { @@ -17,12 +23,22 @@ type MobulaBridge struct { type MobulaQuoteResponse struct { Data struct { + // Two-step flow fields: EVM origins return SignatureRequired=true + // and require a re-quote with the EIP-712 signature over TypedData + // before the deposit becomes executable (unsigned deposit stays + // unregistered server-side and the solver never picks it up). + IntentId string `json:"intentId"` + Deadline int64 `json:"deadline"` + SignatureRequired bool `json:"signatureRequired"` + TypedData json.RawMessage `json:"typedData"` + EstimatedAmountOut string `json:"estimatedAmountOut"` EstimatedAmountOutUsd string `json:"estimatedAmountOutUsd"` EstimatedTimeMs int64 `json:"estimatedTimeMs"` MaxTradeUsd int64 `json:"maxTradeUsd"` Fees struct { BridgeFeeBps int `json:"bridgeFeeBps"` + BridgeFeeUsd string `json:"bridgeFeeUsd"` GasFeeUsd string `json:"gasFeeUsd"` TotalFeeUsd string `json:"totalFeeUsd"` } `json:"fees"` @@ -87,7 +103,116 @@ func (m *MobulaBridge) APIKey() string { return m.apiKey } +// mobulaTypedData mirrors the typedData block of the quote response for +// EIP-712 signing. +type mobulaTypedData struct { + Domain struct { + Name string `json:"name"` + Version string `json:"version"` + ChainId int64 `json:"chainId"` + } `json:"domain"` + Types apitypes.Types `json:"types"` + PrimaryType string `json:"primaryType"` + Message apitypes.TypedDataMessage `json:"message"` +} + +// GetQuote fetches an unsigned quote. For EVM origins the returned deposit is +// a placeholder that the solver will not fill: use GetSignedQuote for real +// executions. func (m *MobulaBridge) GetQuote(originChain, originToken, destChain, destToken, senderAddress, walletAddress string, amount float64) (*MobulaQuoteResponse, time.Duration, error) { + return m.quote(originChain, originToken, destChain, destToken, senderAddress, walletAddress, amount, "") +} + +// GetSignedQuote runs the full two-step flow: unsigned quote → EIP-712 sign +// the returned typedData → re-quote echoing signature/intentId/deadline/ +// minAmountOut. Only the second response's deposit is executable. Solana +// origins skip the second call. evmKey==nil falls back to the unsigned quote +// (used by the read-only quote loop where no key is configured). +func (m *MobulaBridge) GetSignedQuote(originChain, originToken, destChain, destToken, senderAddress, walletAddress string, amount float64, evmKey *ecdsa.PrivateKey) (*MobulaQuoteResponse, time.Duration, error) { + quote, latency, err := m.GetQuote(originChain, originToken, destChain, destToken, senderAddress, walletAddress, amount) + if err != nil { + return nil, latency, err + } + if !quote.Data.SignatureRequired { + return quote, latency, nil + } + if evmKey == nil { + log.Printf(" [mobula] ⚠️ origin %s requires an EIP-712 signature but no EVM key is configured — returning unsigned quote (NOT executable)", originChain) + return quote, latency, nil + } + if len(quote.Data.TypedData) == 0 || quote.Data.IntentId == "" || quote.Data.Deadline == 0 { + return nil, latency, fmt.Errorf("signatureRequired but quote is missing typedData/intentId/deadline") + } + signature, minAmountOut, err := signBridgeIntent(quote.Data.TypedData, evmKey) + if err != nil { + return nil, latency, fmt.Errorf("failed to sign bridge intent: %w", err) + } + if minAmountOut == "" { + return nil, latency, fmt.Errorf("typedData message has no minAmountOut") + } + log.Printf(" [mobula] 🔏 Signed intent %s (deadline %d), fetching signed quote...", quote.Data.IntentId, quote.Data.Deadline) + extra := fmt.Sprintf("&signature=%s&intentId=%s&deadline=%d&minAmountOut=%s", signature, quote.Data.IntentId, quote.Data.Deadline, minAmountOut) + signed, signedLatency, err := m.quote(originChain, originToken, destChain, destToken, senderAddress, walletAddress, amount, extra) + if err != nil { + return nil, latency + signedLatency, fmt.Errorf("signed quote failed: %w", err) + } + return signed, latency + signedLatency, nil +} + +// signBridgeIntent hashes the EIP-712 typedData v4 and signs with evmKey. +// Returns the 0x-prefixed 65-byte signature and the minAmountOut extracted +// from the message (echoed back to the confirm endpoint). +func signBridgeIntent(typedDataJSON json.RawMessage, key *ecdsa.PrivateKey) (string, string, error) { + var raw mobulaTypedData + if err := json.Unmarshal(typedDataJSON, &raw); err != nil { + return "", "", fmt.Errorf("parse typedData: %w", err) + } + // Mobula's typedData omits the EIP712Domain type definition (only + // BridgeIntent is listed under types). go-ethereum requires it to hash + // the domain, so we synthesize one from the domain fields we actually + // received (name/version/chainId). + if _, ok := raw.Types["EIP712Domain"]; !ok { + raw.Types["EIP712Domain"] = []apitypes.Type{ + {Name: "name", Type: "string"}, + {Name: "version", Type: "string"}, + {Name: "chainId", Type: "uint256"}, + } + } + td := apitypes.TypedData{ + Types: raw.Types, + PrimaryType: raw.PrimaryType, + Domain: apitypes.TypedDataDomain{ + Name: raw.Domain.Name, + Version: raw.Domain.Version, + ChainId: ethmath.NewHexOrDecimal256(raw.Domain.ChainId), + }, + Message: raw.Message, + } + minAmountOut := "" + if v, ok := raw.Message["minAmountOut"]; ok { + minAmountOut = fmt.Sprintf("%v", v) + } + domainSep, err := td.HashStruct("EIP712Domain", td.Domain.Map()) + if err != nil { + return "", "", fmt.Errorf("hash domain: %w", err) + } + msgHash, err := td.HashStruct(td.PrimaryType, td.Message) + if err != nil { + return "", "", fmt.Errorf("hash message: %w", err) + } + digest := crypto.Keccak256([]byte("\x19\x01"), domainSep, msgHash) + sig, err := crypto.Sign(digest, key) + if err != nil { + return "", "", fmt.Errorf("sign digest: %w", err) + } + // canonical v is 27 or 28 (crypto.Sign returns 0/1) + if sig[64] < 27 { + sig[64] += 27 + } + return "0x" + hex.EncodeToString(sig), minAmountOut, nil +} + +func (m *MobulaBridge) quote(originChain, originToken, destChain, destToken, senderAddress, walletAddress string, amount float64, extraParams string) (*MobulaQuoteResponse, time.Duration, error) { start := time.Now() url := fmt.Sprintf( @@ -99,6 +224,7 @@ func (m *MobulaBridge) GetQuote(originChain, originToken, destChain, destToken, if senderAddress != "" { url += "&senderAddress=" + senderAddress } + url += extraParams req, err := http.NewRequest("GET", url, nil) if err != nil { diff --git a/harnesses/bridge-monitor/cmd/monitor/tx_executor.go b/harnesses/bridge-monitor/cmd/monitor/tx_executor.go index e9161f8f..a9d08380 100644 --- a/harnesses/bridge-monitor/cmd/monitor/tx_executor.go +++ b/harnesses/bridge-monitor/cmd/monitor/tx_executor.go @@ -120,6 +120,13 @@ func (tx *TxExecutor) CanExecute() bool { return tx.solanaPrivateKey != nil && tx.evmPrivateKey != nil && !tx.dryRun } +// EVMPrivateKey exposes the loaded EVM key so the Mobula client can EIP-712 +// sign the bridge intent on the confirm step. Returns nil in quote-only mode +// (no key configured), which GetSignedQuote handles by degrading to unsigned. +func (tx *TxExecutor) EVMPrivateKey() *ecdsa.PrivateKey { + return tx.evmPrivateKey +} + // ExecuteSolanaTransaction signs and broadcasts a Solana transaction func (tx *TxExecutor) ExecuteSolanaTransaction(serializedTxBase64 string) (string, error) { if tx.dryRun {