diff --git a/api/handlers/signing.go b/api/handlers/signing.go index 135c50c3..523d46d3 100644 --- a/api/handlers/signing.go +++ b/api/handlers/signing.go @@ -20,6 +20,7 @@ const ( AcrossProtocol ProtocolType = "across" MayanProtocol ProtocolType = "mayan" RhinestoneProtocol ProtocolType = "rhinestone" + LifiProtocol ProtocolType = "lifi" ) type SigningBody struct { diff --git a/api/handlers/unlock.go b/api/handlers/unlock.go new file mode 100644 index 00000000..60bd0b2e --- /dev/null +++ b/api/handlers/unlock.go @@ -0,0 +1,124 @@ +package handlers + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "math/big" + "net/http" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/gorilla/mux" + evmMessage "github.com/sprintertech/sprinter-signing/chains/evm/message" + "github.com/sprintertech/sprinter-signing/tss/ecdsa/signing" + "github.com/sygmaprotocol/sygma-core/relayer/message" +) + +const SIGNATURE_TIMEOUT = time.Second * 15 + +type UnlockResponse struct { + Signature string `json:"signature"` + ID string `json:"id"` +} + +type UnlockBody struct { + ChainId uint64 + Protocol ProtocolType `json:"protocol"` + OrderID string `json:"orderId"` + Settler string `json:"settler"` +} + +type UnlockHandler struct { + chains map[uint64]struct{} + msgChan chan []*message.Message +} + +func NewUnlockHandler(msgChn chan []*message.Message, chains map[uint64]struct{}) *UnlockHandler { + return &UnlockHandler{ + chains: chains, + msgChan: msgChn, + } +} + +// HandleSigning sends a message to the across message handler and returns status code 202 +// if the deposit has been accepted for the signing process +func (h *UnlockHandler) HandleUnlock(w http.ResponseWriter, r *http.Request) { + b := &UnlockBody{} + d := json.NewDecoder(r.Body) + err := d.Decode(b) + if err != nil { + JSONError(w, fmt.Errorf("invalid request body: %s", err), http.StatusBadRequest) + return + } + + vars := mux.Vars(r) + err = h.validate(b, vars) + if err != nil { + JSONError(w, fmt.Errorf("invalid request body: %s", err), http.StatusBadRequest) + return + } + + sigChn := make(chan interface{}, 1) + var m *message.Message + switch b.Protocol { + case LifiProtocol: + { + m = evmMessage.NewLifiUnlockMessage(0, b.ChainId, &evmMessage.LifiUnlockData{ + Source: 0, + Destination: b.ChainId, + SigChn: sigChn, + OrderID: b.OrderID, + Settler: common.HexToAddress(b.Settler), + }) + } + default: + JSONError(w, fmt.Errorf("invalid protocol %s", b.Protocol), http.StatusBadRequest) + return + } + h.msgChan <- []*message.Message{m} + + for { + select { + case <-time.After(SIGNATURE_TIMEOUT): + JSONError(w, fmt.Errorf("timeout"), http.StatusInternalServerError) + return + case sig := <-sigChn: + { + sig, ok := sig.(signing.EcdsaSignature) + if !ok { + JSONError(w, fmt.Errorf("invalid signature"), http.StatusInternalServerError) + return + } + + data, _ := json.Marshal(UnlockResponse{ + Signature: hex.EncodeToString(sig.Signature), + ID: sig.ID, + }) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) + return + } + } + } +} + +func (h *UnlockHandler) validate(b *UnlockBody, vars map[string]string) error { + chainId, ok := new(big.Int).SetString(vars["chainId"], 10) + if !ok { + return fmt.Errorf("field 'chainId' invalid") + } + b.ChainId = chainId.Uint64() + + if b.ChainId == 0 { + return fmt.Errorf("missing field 'chainId'") + } + + _, ok = h.chains[b.ChainId] + if !ok { + return fmt.Errorf("chain '%d' not supported", b.ChainId) + } + + return nil +} diff --git a/api/handlers/unlock_test.go b/api/handlers/unlock_test.go new file mode 100644 index 00000000..76864a19 --- /dev/null +++ b/api/handlers/unlock_test.go @@ -0,0 +1,129 @@ +package handlers_test + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/sprintertech/sprinter-signing/api/handlers" + across "github.com/sprintertech/sprinter-signing/chains/evm/message" + "github.com/sprintertech/sprinter-signing/tss/ecdsa/signing" + "github.com/stretchr/testify/suite" + "github.com/sygmaprotocol/sygma-core/relayer/message" +) + +type UnlockHandlerTestSuite struct { + suite.Suite + + chains map[uint64]struct{} +} + +func TestRunUnlockHandlerTestSuite(t *testing.T) { + suite.Run(t, new(UnlockHandlerTestSuite)) +} + +func (s *UnlockHandlerTestSuite) SetupTest() { + chains := make(map[uint64]struct{}) + chains[1] = struct{}{} + s.chains = chains +} + +func (s *UnlockHandlerTestSuite) Test_HandleUnlock_InvalidRequest() { + msgChn := make(chan []*message.Message) + handler := handlers.NewUnlockHandler(msgChn, s.chains) + + input := handlers.UnlockBody{ + Protocol: "lifi", + OrderID: "id", + Settler: "settler", + } + body, _ := json.Marshal(input) + + req := httptest.NewRequest(http.MethodPost, "/v1/chains/1/unlocks", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + recorder := httptest.NewRecorder() + + go func() { + msg := <-msgChn + ad := msg[0].Data.(*across.LifiUnlockData) + ad.SigChn <- signing.EcdsaSignature{} + }() + + handler.HandleUnlock(recorder, req) + + s.Equal(http.StatusBadRequest, recorder.Code) +} + +func (s *UnlockHandlerTestSuite) Test_HandleUnlock_InvalidProtocol() { + msgChn := make(chan []*message.Message) + handler := handlers.NewUnlockHandler(msgChn, s.chains) + + input := handlers.UnlockBody{ + Protocol: "across", + OrderID: "id", + Settler: "settler", + } + body, _ := json.Marshal(input) + + req := httptest.NewRequest(http.MethodPost, "/v1/chains/1/unlocks", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = mux.SetURLVars(req, map[string]string{ + "chainId": "1", + }) + + recorder := httptest.NewRecorder() + + go func() { + msg := <-msgChn + ad := msg[0].Data.(*across.LifiUnlockData) + ad.SigChn <- signing.EcdsaSignature{} + }() + + handler.HandleUnlock(recorder, req) + + s.Equal(http.StatusBadRequest, recorder.Code) +} + +func (s *UnlockHandlerTestSuite) Test_HandleUnlock_ValidRequest() { + msgChn := make(chan []*message.Message) + handler := handlers.NewUnlockHandler(msgChn, s.chains) + + input := handlers.UnlockBody{ + Protocol: "lifi", + OrderID: "id", + Settler: "settler", + } + body, _ := json.Marshal(input) + + req := httptest.NewRequest(http.MethodPost, "/v1/chains/1/unlocks", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = mux.SetURLVars(req, map[string]string{ + "chainId": "1", + }) + + recorder := httptest.NewRecorder() + + sigBytes, _ := hex.DecodeString("abcd") + go func() { + msg := <-msgChn + ad := msg[0].Data.(*across.LifiUnlockData) + ad.SigChn <- signing.EcdsaSignature{ + Signature: sigBytes, + ID: "id", + } + }() + + handler.HandleUnlock(recorder, req) + + s.Equal(http.StatusOK, recorder.Code) + data, err := io.ReadAll(recorder.Body) + s.Nil(err) + + s.Equal(string(data), "{\"signature\":\"abcd\",\"id\":\"id\"}") +} diff --git a/api/server.go b/api/server.go index d27f4f76..6654bea8 100644 --- a/api/server.go +++ b/api/server.go @@ -14,10 +14,12 @@ func Serve( ctx context.Context, addr string, signingHandler *handlers.SigningHandler, + unlockHandler *handlers.UnlockHandler, statusHandler *handlers.StatusHandler, confirmationsHandler *handlers.ConfirmationsHandler, ) { r := mux.NewRouter() + r.HandleFunc("/v1/chains/{chainId:[0-9]+}/unlocks", unlockHandler.HandleUnlock).Methods("POST") r.HandleFunc("/v1/chains/{chainId:[0-9]+}/signatures", signingHandler.HandleSigning).Methods("POST") r.HandleFunc("/v1/chains/{chainId:[0-9]+}/signatures/{depositId}", statusHandler.HandleRequest).Methods("GET") r.HandleFunc("/v1/chains/{chainId:[0-9]+}/confirmations", confirmationsHandler.HandleRequest).Methods("GET") diff --git a/app/app.go b/app/app.go index df7fb51c..0f657c1b 100644 --- a/app/app.go +++ b/app/app.go @@ -269,6 +269,16 @@ func Run() error { confirmationsPerChain[*c.GeneralChainConfig.Id] = c.ConfirmationsByValue } + lifiUnlockMh := evmMessage.NewLifiUnlockHandler( + *c.GeneralChainConfig.Id, + repayerAddresses, + coordinator, + host, + communication, + keyshareStore, + ) + mh.RegisterMessageHandler(evmMessage.LifiUnlockMessage, lifiUnlockMh) + var startBlock *big.Int var listener *coreListener.EVMListener eventHandlers := make([]coreListener.EventHandler, 0) @@ -316,7 +326,14 @@ func Run() error { signingHandler := handlers.NewSigningHandler(msgChan, supportedChains) statusHandler := handlers.NewStatusHandler(signatureCache, supportedChains) confirmationsHandler := handlers.NewConfirmationsHandler(confirmationsPerChain) - go api.Serve(ctx, configuration.RelayerConfig.ApiAddr, signingHandler, statusHandler, confirmationsHandler) + unlockHandler := handlers.NewUnlockHandler(msgChan, supportedChains) + go api.Serve( + ctx, + configuration.RelayerConfig.ApiAddr, + signingHandler, + unlockHandler, + statusHandler, + confirmationsHandler) sig := <-sysErr log.Info().Msgf("terminating got ` [%v] signal", sig) diff --git a/chains/evm/message/message.go b/chains/evm/message/message.go index 751d3f7a..9f1bab34 100644 --- a/chains/evm/message/message.go +++ b/chains/evm/message/message.go @@ -14,8 +14,9 @@ import ( ) const ( - AcrossMessage = "AcrossMessage" - MayanMessage = "MayanMessage" + AcrossMessage = "AcrossMessage" + MayanMessage = "MayanMessage" + LifiUnlockMessage = "LifiUnlockMessage" DOMAIN_NAME = "LiquidityPool" VERSION = "1.0.0" @@ -95,6 +96,27 @@ func NewRhinestoneMessage(source, destination uint64, rhinestoneData *Rhinestone } } +type LifiUnlockData struct { + SigChn chan interface{} `json:"-"` + + OrderID string + Settler common.Address + + Coordinator peer.ID + Source uint64 + Destination uint64 +} + +func NewLifiUnlockMessage(source, destination uint64, lifiData *LifiUnlockData) *message.Message { + return &message.Message{ + Source: source, + Destination: destination, + Data: lifiData, + Type: LifiUnlockMessage, + Timestamp: time.Now(), + } +} + // unlockHash calculates the hash that has to signed and submitted on-chain to the liquidity // pool contract. func unlockHash( diff --git a/chains/evm/message/unlock.go b/chains/evm/message/unlock.go new file mode 100644 index 00000000..6a8d2e54 --- /dev/null +++ b/chains/evm/message/unlock.go @@ -0,0 +1,180 @@ +package message + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/math" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/signer/core/apitypes" + "github.com/libp2p/go-libp2p/core/host" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/rs/zerolog/log" + "github.com/sprintertech/sprinter-signing/comm" + "github.com/sprintertech/sprinter-signing/tss" + "github.com/sprintertech/sprinter-signing/tss/ecdsa/signing" + "github.com/sygmaprotocol/sygma-core/relayer/message" + "github.com/sygmaprotocol/sygma-core/relayer/proposal" +) + +type LifiUnlockHandler struct { + chainID uint64 + + repayers map[uint64]common.Address + + coordinator Coordinator + host host.Host + comm comm.Communication + fetcher signing.SaveDataFetcher +} + +func NewLifiUnlockHandler( + chainID uint64, + repayers map[uint64]common.Address, + coordinator Coordinator, + host host.Host, + comm comm.Communication, + fetcher signing.SaveDataFetcher, +) *LifiUnlockHandler { + return &LifiUnlockHandler{ + chainID: chainID, + repayers: repayers, + coordinator: coordinator, + host: host, + comm: comm, + fetcher: fetcher, + } +} + +// HandleMessage signs the unlock request to the address of the repayer. +func (h *LifiUnlockHandler) HandleMessage(m *message.Message) (*proposal.Proposal, error) { + data := m.Data.(*LifiUnlockData) + err := h.notify(data) + if err != nil { + log.Warn().Msgf("Failed to notify relayers because of %s", err) + } + + unlockHash, err := h.lifiUnlockHash(data) + if err != nil { + return nil, err + } + + sessionID := fmt.Sprintf("%d-%s", h.chainID, data.OrderID) + signing, err := signing.NewSigning( + new(big.Int).SetBytes(unlockHash), + sessionID, + sessionID, + h.host, + h.comm, + h.fetcher) + if err != nil { + return nil, err + } + + err = h.coordinator.Execute(context.Background(), []tss.TssProcess{signing}, data.SigChn, data.Coordinator) + if err != nil { + return nil, err + } + return nil, nil +} + +func (h *LifiUnlockHandler) Listen(ctx context.Context) { + msgChn := make(chan *comm.WrappedMessage) + subID := h.comm.Subscribe(fmt.Sprintf("%d-%s", h.chainID, comm.LifiUnlockSessionID), comm.LifiUnlockMsg, msgChn) + + for { + select { + case wMsg := <-msgChn: + { + d := &LifiUnlockData{} + err := json.Unmarshal(wMsg.Payload, d) + if err != nil { + log.Warn().Msgf("Failed unmarshaling across message: %s", err) + continue + } + d.SigChn = make(chan interface{}, 1) + + msg := NewLifiUnlockMessage(d.Source, d.Destination, d) + _, err = h.HandleMessage(msg) + if err != nil { + log.Err(err).Msgf("Failed handling across message %+v because of: %s", msg, err) + } + } + case <-ctx.Done(): + { + h.comm.UnSubscribe(subID) + return + } + } + } +} + +func (h *LifiUnlockHandler) notify(data *LifiUnlockData) error { + if data.Coordinator != peer.ID("") { + return nil + } + + data.Coordinator = h.host.ID() + msgBytes, err := json.Marshal(data) + if err != nil { + return err + } + + return h.comm.Broadcast( + h.host.Peerstore().Peers(), + msgBytes, comm.LifiUnlockMsg, + fmt.Sprintf("%d-%s", h.chainID, comm.LifiUnlockMsg)) +} + +func (h *LifiUnlockHandler) lifiUnlockHash(data *LifiUnlockData) ([]byte, error) { + repaymentAddress, ok := h.repayers[h.chainID] + if !ok { + return nil, fmt.Errorf("invalid repayment chain %d", h.chainID) + } + + msg := apitypes.TypedDataMessage{ + "orderId": common.HexToHash(data.OrderID), + "destination": common.HexToHash(repaymentAddress.Hex()), + "call": "0x", + } + chainId := math.HexOrDecimal256(*new(big.Int).SetUint64(h.chainID)) + typedData := apitypes.TypedData{ + Types: apitypes.Types{ + "EIP712Domain": []apitypes.Type{ + {Name: "name", Type: "string"}, + {Name: "version", Type: "string"}, + {Name: "chainId", Type: "uint256"}, + {Name: "verifyingContract", Type: "address"}, + }, + "AllowOpen": []apitypes.Type{ + {Name: "orderId", Type: "bytes32"}, + {Name: "destination", Type: "bytes32"}, + {Name: "call", Type: "bytes"}, + }, + }, + PrimaryType: "AllowOpen", + Domain: apitypes.TypedDataDomain{ + Name: DOMAIN_NAME, + ChainId: &chainId, + Version: VERSION, + VerifyingContract: data.Settler.Hex(), + }, + Message: msg, + } + + domainSeparator, err := typedData.HashStruct("EIP712Domain", typedData.Domain.Map()) + if err != nil { + return []byte{}, err + } + + messageHash, err := typedData.HashStruct(typedData.PrimaryType, typedData.Message) + if err != nil { + return []byte{}, err + } + + rawData := []byte(fmt.Sprintf("\x19\x01%s%s", string(domainSeparator), string(messageHash))) + return crypto.Keccak256(rawData), nil +} diff --git a/chains/evm/message/unlock_test.go b/chains/evm/message/unlock_test.go new file mode 100644 index 00000000..374de670 --- /dev/null +++ b/chains/evm/message/unlock_test.go @@ -0,0 +1,89 @@ +package message_test + +import ( + "fmt" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/libp2p/go-libp2p/p2p/host/peerstore/pstoremem" + "github.com/sprintertech/sprinter-signing/chains/evm/message" + mock_message "github.com/sprintertech/sprinter-signing/chains/evm/message/mock" + "github.com/sprintertech/sprinter-signing/comm" + mock_communication "github.com/sprintertech/sprinter-signing/comm/mock" + mock_host "github.com/sprintertech/sprinter-signing/comm/p2p/mock/host" + "github.com/sprintertech/sprinter-signing/keyshare" + mock_tss "github.com/sprintertech/sprinter-signing/tss/ecdsa/common/mock" + "github.com/stretchr/testify/suite" + coreMessage "github.com/sygmaprotocol/sygma-core/relayer/message" + "go.uber.org/mock/gomock" +) + +type LifiUnlockHandlerTestSuite struct { + suite.Suite + + mockCommunication *mock_communication.MockCommunication + mockCoordinator *mock_message.MockCoordinator + mockHost *mock_host.MockHost + mockFetcher *mock_tss.MockSaveDataFetcher + + handler *message.LifiUnlockHandler +} + +func TestRunLifiUnlockHandlerTestSuite(t *testing.T) { + suite.Run(t, new(LifiUnlockHandlerTestSuite)) +} + +func (s *LifiUnlockHandlerTestSuite) SetupTest() { + ctrl := gomock.NewController(s.T()) + + repayers := make(map[uint64]common.Address) + repayers[10] = common.HexToAddress("0x5c7BCd6E7De5423a257D81B442095A1a6ced35C6") + + s.mockCommunication = mock_communication.NewMockCommunication(ctrl) + s.mockCoordinator = mock_message.NewMockCoordinator(ctrl) + s.mockHost = mock_host.NewMockHost(ctrl) + s.mockHost.EXPECT().ID().Return(peer.ID("")).AnyTimes() + s.mockFetcher = mock_tss.NewMockSaveDataFetcher(ctrl) + s.mockFetcher.EXPECT().UnlockKeyshare().AnyTimes() + s.mockFetcher.EXPECT().LockKeyshare().AnyTimes() + s.mockFetcher.EXPECT().GetKeyshare().AnyTimes().Return(keyshare.ECDSAKeyshare{}, nil) + p, _ := pstoremem.NewPeerstore() + s.mockHost.EXPECT().Peerstore().Return(p) + s.mockCommunication.EXPECT().Broadcast( + gomock.Any(), + gomock.Any(), + comm.LifiUnlockMsg, + fmt.Sprintf("%d-%s", 10, comm.LifiUnlockMsg), + ).Return(nil) + + s.handler = message.NewLifiUnlockHandler( + 10, + repayers, + s.mockCoordinator, + s.mockHost, + s.mockCommunication, + s.mockFetcher, + ) +} + +func (s *LifiUnlockHandlerTestSuite) Test_HandleMessage_ValidMessage() { + sigChn := make(chan interface{}, 1) + ad := &message.LifiUnlockData{ + SigChn: sigChn, + OrderID: "id", + Settler: common.HexToAddress("abcd"), + } + s.mockCoordinator.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + + m := &coreMessage.Message{ + Data: ad, + Source: 0, + Destination: 10, + } + + prop, err := s.handler.HandleMessage(m) + + s.Nil(prop) + s.Nil(err) +} diff --git a/comm/messages.go b/comm/messages.go index 5678585a..9b64766b 100644 --- a/comm/messages.go +++ b/comm/messages.go @@ -41,6 +41,8 @@ const ( MayanMsg // Rhinestone message type is used for the process coordinator to share rhinestone data RhinestoneMsg + // LifiUnlockMsg message type is used for the process coordinator to share lifi unlock data + LifiUnlockMsg // Unknown message type Unknown ) @@ -50,6 +52,7 @@ const ( AcrossSessionID = "across" MayanSessionID = "mayan" RhinestoneSessionID = "rhinestone" + LifiUnlockSessionID = "lifi-unlock" ) // String implements fmt.Stringer @@ -87,6 +90,8 @@ func (msgType MessageType) String() string { return "MayanMsg" case RhinestoneMsg: return "RhinestoneMsg" + case LifiUnlockMsg: + return "LifiUnlockMsg" default: return "UnknownMsg" } diff --git a/go.sum b/go.sum index f4c47dd1..013b7b47 100644 --- a/go.sum +++ b/go.sum @@ -764,10 +764,6 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.9.0 h1:yR6EXjTp0y0cLN8OZg1CRZmOBdI88UcGkhgyJhu6nZk= github.com/spf13/viper v1.9.0/go.mod h1:+i6ajR7OX2XaiBkrcZJFK21htRk7eDeLg7+O6bhUPP4= -github.com/sprintertech/solver-config/go v0.0.0-20250513131040-3cde77de33bb h1:FUjoMHQu+thSACVV+RlCoIe5uc9/POdAOCjUrmkPVfk= -github.com/sprintertech/solver-config/go v0.0.0-20250513131040-3cde77de33bb/go.mod h1:Y0MjuSx4gxwYo9/rJcwB05DmNwDsDVcGDphh6hN0uX8= -github.com/sprintertech/solver-config/go v0.0.0-20250529124037-c285cf3b5a60 h1:QBjqQFS3SvchRtpUOIWfJG9S5cTz/6rHrCzXEOPAXqY= -github.com/sprintertech/solver-config/go v0.0.0-20250529124037-c285cf3b5a60/go.mod h1:MrIGW6M815PSYKtWSeOd1Z7eiSeOIk/uA/6E2PhlQVQ= github.com/sprintertech/solver-config/go v0.0.0-20250710125738-eb8eeea57100 h1:JSDdVqsmYpkOYW2cI6xc/yk03hJX6TYf8q8R0t1NviU= github.com/sprintertech/solver-config/go v0.0.0-20250710125738-eb8eeea57100/go.mod h1:MrIGW6M815PSYKtWSeOd1Z7eiSeOIk/uA/6E2PhlQVQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=