From 97c6a5b71b71be107650d1e36924f0bed2b6e17d Mon Sep 17 00:00:00 2001 From: Branimir Tomeljak Date: Fri, 17 Jul 2026 15:45:34 +0200 Subject: [PATCH 1/3] key across signature cache by the borrow digest fields --- api/handlers/signing.go | 19 ++++++++++- api/handlers/signing_test.go | 37 +++++++++++++++++++-- chains/evm/message/across.go | 4 ++- chains/evm/signature/session.go | 18 +++++++++++ chains/evm/signature/session_test.go | 48 ++++++++++++++++++++++++++++ 5 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 chains/evm/signature/session.go create mode 100644 chains/evm/signature/session_test.go diff --git a/api/handlers/signing.go b/api/handlers/signing.go index ba617f47..4d828b8a 100644 --- a/api/handlers/signing.go +++ b/api/handlers/signing.go @@ -7,10 +7,12 @@ import ( "fmt" "math/big" "net/http" + "strconv" "github.com/ethereum/go-ethereum/common" "github.com/gorilla/mux" evmMessage "github.com/sprintertech/sprinter-signing/chains/evm/message" + "github.com/sprintertech/sprinter-signing/chains/evm/signature" lighterMessage "github.com/sprintertech/sprinter-signing/chains/lighter/message" "github.com/sygmaprotocol/sygma-core/relayer/message" ) @@ -234,7 +236,7 @@ func (h *StatusHandler) HandleRequest(w http.ResponseWriter, r *http.Request) { ctx := r.Context() sigChn := make(chan []byte, 1) - h.cache.Subscribe(ctx, fmt.Sprintf("%d-%s", chainId, depositId), sigChn) + h.cache.Subscribe(ctx, subscribeKey(r, chainId.Uint64(), depositId), sigChn) for { select { case <-r.Context().Done(): @@ -249,6 +251,21 @@ func (h *StatusHandler) HandleRequest(w http.ResponseWriter, r *http.Request) { } } +// subscribeKey uses the composite key when the caller supplies the digest fields, else the legacy key. +func subscribeKey(r *http.Request, chainID uint64, depositID string) string { + q := r.URL.Query() + deadline, errD := strconv.ParseUint(q.Get("deadline"), 10, 64) + caller := q.Get("caller") + borrowAmount, okB := new(big.Int).SetString(q.Get("borrowAmount"), 10) + liquidityPool := q.Get("liquidityPool") + repaymentChainID, errR := strconv.ParseUint(q.Get("repaymentChainId"), 10, 64) + if errD != nil || caller == "" || !okB || liquidityPool == "" || errR != nil { + return fmt.Sprintf("%d-%s", chainID, depositID) + } + return signature.BorrowSessionID(chainID, depositID, deadline, common.HexToAddress(caller), + borrowAmount, common.HexToAddress(liquidityPool), repaymentChainID) +} + func (h *StatusHandler) setheaders(w http.ResponseWriter) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") diff --git a/api/handlers/signing_test.go b/api/handlers/signing_test.go index 820a46a9..762f8fc9 100644 --- a/api/handlers/signing_test.go +++ b/api/handlers/signing_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/ethereum/go-ethereum/common" "github.com/gorilla/mux" "github.com/sprintertech/sprinter-signing/api/handlers" mock_handlers "github.com/sprintertech/sprinter-signing/api/handlers/mock" @@ -472,7 +473,10 @@ func (s *StatusHandlerTestSuite) Test_HandleRequest_ChainNotSupported() { } func (s *StatusHandlerTestSuite) Test_HandleRequest_ValidSignature() { - req := httptest.NewRequest(http.MethodGet, "/v1/chains/1/signatures/id", nil) + caller := "0x1111111111111111111111111111111111111111" + pool := "0x3333333333333333333333333333333333333333" + query := "?deadline=1000&caller=" + caller + "&borrowAmount=500&liquidityPool=" + pool + "&repaymentChainId=10" + req := httptest.NewRequest(http.MethodGet, "/v1/chains/1/signatures/id"+query, nil) req = mux.SetURLVars(req, map[string]string{ "chainId": "1", "depositId": "id", @@ -480,9 +484,10 @@ func (s *StatusHandlerTestSuite) Test_HandleRequest_ValidSignature() { recorder := httptest.NewRecorder() + expectedKey := "1-id-1000-" + common.HexToAddress(caller).Hex() + "-500-" + common.HexToAddress(pool).Hex() + "-10" expectedSignature := []byte{0x01, 0x02, 0x03} s.mockSignatureCacher.EXPECT(). - Subscribe(gomock.Any(), "1-id", gomock.Any()). + Subscribe(gomock.Any(), expectedKey, gomock.Any()). Do(func(ctx context.Context, id string, sigChannel chan []byte) { go func() { sigChannel <- expectedSignature @@ -491,7 +496,7 @@ func (s *StatusHandlerTestSuite) Test_HandleRequest_ValidSignature() { go s.handler.HandleRequest(recorder, req) - time.Sleep(100 * time.Millisecond) // Give some time for the goroutine to execute + time.Sleep(50 * time.Millisecond) s.Equal(http.StatusOK, recorder.Code) s.Equal("text/event-stream", recorder.Header().Get("Content-Type")) @@ -500,3 +505,29 @@ func (s *StatusHandlerTestSuite) Test_HandleRequest_ValidSignature() { s.Equal("*", recorder.Header().Get("Access-Control-Allow-Origin")) s.Equal("data: "+hex.EncodeToString(expectedSignature)+"\n\n", recorder.Body.String()) } + +func (s *StatusHandlerTestSuite) Test_HandleRequest_LegacyKeyWithoutParams() { + req := httptest.NewRequest(http.MethodGet, "/v1/chains/1/signatures/id", nil) + req = mux.SetURLVars(req, map[string]string{ + "chainId": "1", + "depositId": "id", + }) + + recorder := httptest.NewRecorder() + + expectedSignature := []byte{0x01, 0x02, 0x03} + s.mockSignatureCacher.EXPECT(). + Subscribe(gomock.Any(), "1-id", gomock.Any()). + Do(func(ctx context.Context, id string, sigChannel chan []byte) { + go func() { + sigChannel <- expectedSignature + }() + }) + + go s.handler.HandleRequest(recorder, req) + + time.Sleep(100 * time.Millisecond) // Give some time for the goroutine to execute + + s.Equal(http.StatusOK, recorder.Code) + s.Equal("data: "+hex.EncodeToString(expectedSignature)+"\n\n", recorder.Body.String()) +} diff --git a/chains/evm/message/across.go b/chains/evm/message/across.go index 806818eb..d98a6f20 100644 --- a/chains/evm/message/across.go +++ b/chains/evm/message/across.go @@ -197,7 +197,9 @@ func (h *AcrossMessageHandler) HandleMessage(m *message.Message) (*proposal.Prop return nil, err } - sessionID := fmt.Sprintf("%d-%s", sourceChainID, data.DepositId) + sessionID := signature.BorrowSessionID( + sourceChainID, data.DepositId.String(), data.Deadline, data.Caller, + data.BorrowAmount, data.LiquidityPool, data.RepaymentChainID) signing, err := signing.NewSigning( new(big.Int).SetBytes(unlockHash), sessionID, diff --git a/chains/evm/signature/session.go b/chains/evm/signature/session.go new file mode 100644 index 00000000..3f9f8268 --- /dev/null +++ b/chains/evm/signature/session.go @@ -0,0 +1,18 @@ +package signature + +import ( + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" +) + +// BorrowSessionID keys the signing session and cache by the digest fields not already fixed by the deposit. +func BorrowSessionID( + chainID uint64, depositID string, deadline uint64, caller common.Address, + borrowAmount *big.Int, liquidityPool common.Address, repaymentChainID uint64, +) string { + return fmt.Sprintf("%d-%s-%d-%s-%s-%s-%d", + chainID, depositID, deadline, caller.Hex(), + borrowAmount.String(), liquidityPool.Hex(), repaymentChainID) +} diff --git a/chains/evm/signature/session_test.go b/chains/evm/signature/session_test.go new file mode 100644 index 00000000..4032b88c --- /dev/null +++ b/chains/evm/signature/session_test.go @@ -0,0 +1,48 @@ +package signature_test + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/sprintertech/sprinter-signing/chains/evm/signature" +) + +func TestBorrowSessionID(t *testing.T) { + caller := common.HexToAddress("0x1111111111111111111111111111111111111111") + other := common.HexToAddress("0x2222222222222222222222222222222222222222") + pool := common.HexToAddress("0x3333333333333333333333333333333333333333") + otherPool := common.HexToAddress("0x4444444444444444444444444444444444444444") + id := func(chainID uint64, deposit string, deadline uint64, c common.Address, + amount *big.Int, lp common.Address, repay uint64) string { + return signature.BorrowSessionID(chainID, deposit, deadline, c, amount, lp, repay) + } + base := id(1, "42", 1000, caller, big.NewInt(500), pool, 10) + + tests := []struct { + name string + got string + equal bool + }{ + {"identical inputs match", id(1, "42", 1000, caller, big.NewInt(500), pool, 10), true}, + {"different chain differs", id(2, "42", 1000, caller, big.NewInt(500), pool, 10), false}, + {"different deposit differs", id(1, "43", 1000, caller, big.NewInt(500), pool, 10), false}, + {"different deadline differs", id(1, "42", 1001, caller, big.NewInt(500), pool, 10), false}, + {"different caller differs", id(1, "42", 1000, other, big.NewInt(500), pool, 10), false}, + {"different borrow amount differs", id(1, "42", 1000, caller, big.NewInt(501), pool, 10), false}, + {"different liquidity pool differs", id(1, "42", 1000, caller, big.NewInt(500), otherPool, 10), false}, + {"different repayment chain differs", id(1, "42", 1000, caller, big.NewInt(500), pool, 11), false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if (tt.got == base) != tt.equal { + t.Fatalf("equal=%v, base=%q got=%q", tt.equal, base, tt.got) + } + }) + } + + want := "1-42-1000-" + caller.Hex() + "-500-" + pool.Hex() + "-10" + if base != want { + t.Fatalf("format: want %q got %q", want, base) + } +} From 58f0257b391102ee80c99b22788561e2f8ec8bb2 Mon Sep 17 00:00:00 2001 From: Branimir Tomeljak Date: Mon, 20 Jul 2026 12:58:58 +0200 Subject: [PATCH 2/3] optimizations --- api/handlers/signing.go | 28 +++++- api/handlers/signing_test.go | 190 ++++++++++++++++++----------------- 2 files changed, 119 insertions(+), 99 deletions(-) diff --git a/api/handlers/signing.go b/api/handlers/signing.go index 4d828b8a..a48972e5 100644 --- a/api/handlers/signing.go +++ b/api/handlers/signing.go @@ -230,13 +230,19 @@ func (h *StatusHandler) HandleRequest(w http.ResponseWriter, r *http.Request) { return } + key, err := subscribeKey(r, chainId.Uint64(), depositId) + if err != nil { + JSONError(w, err, http.StatusBadRequest) + return + } + h.setheaders(w) w.WriteHeader(http.StatusOK) w.(http.Flusher).Flush() ctx := r.Context() sigChn := make(chan []byte, 1) - h.cache.Subscribe(ctx, subscribeKey(r, chainId.Uint64(), depositId), sigChn) + h.cache.Subscribe(ctx, key, sigChn) for { select { case <-r.Context().Done(): @@ -251,19 +257,31 @@ func (h *StatusHandler) HandleRequest(w http.ResponseWriter, r *http.Request) { } } -// subscribeKey uses the composite key when the caller supplies the digest fields, else the legacy key. -func subscribeKey(r *http.Request, chainID uint64, depositID string) string { +// subscribeKey builds the cache key the status stream subscribes to, which must match the publish-side key. +func subscribeKey(r *http.Request, chainID uint64, depositID string) (string, error) { q := r.URL.Query() + // No digest fields supplied: legacy key used by protocols other than Across. + if !q.Has("deadline") && !q.Has("caller") && !q.Has("borrowAmount") && + !q.Has("liquidityPool") && !q.Has("repaymentChainId") { + return fmt.Sprintf("%d-%s", chainID, depositID), nil + } + deadline, errD := strconv.ParseUint(q.Get("deadline"), 10, 64) caller := q.Get("caller") borrowAmount, okB := new(big.Int).SetString(q.Get("borrowAmount"), 10) liquidityPool := q.Get("liquidityPool") repaymentChainID, errR := strconv.ParseUint(q.Get("repaymentChainId"), 10, 64) if errD != nil || caller == "" || !okB || liquidityPool == "" || errR != nil { - return fmt.Sprintf("%d-%s", chainID, depositID) + return "", fmt.Errorf("incomplete borrow parameters for composite signature key") + } + + // Normalize like the publish side (big.Int) so a non-canonical decimal cannot desync the keys. + if id, ok := new(big.Int).SetString(depositID, 10); ok { + depositID = id.String() } + // Composite Across key with all fields required, so a partial set errors above rather than silently mismatching. return signature.BorrowSessionID(chainID, depositID, deadline, common.HexToAddress(caller), - borrowAmount, common.HexToAddress(liquidityPool), repaymentChainID) + borrowAmount, common.HexToAddress(liquidityPool), repaymentChainID), nil } func (h *StatusHandler) setheaders(w http.ResponseWriter) { diff --git a/api/handlers/signing_test.go b/api/handlers/signing_test.go index 762f8fc9..2bb3c41a 100644 --- a/api/handlers/signing_test.go +++ b/api/handlers/signing_test.go @@ -17,6 +17,7 @@ import ( "github.com/sprintertech/sprinter-signing/api/handlers" mock_handlers "github.com/sprintertech/sprinter-signing/api/handlers/mock" across "github.com/sprintertech/sprinter-signing/chains/evm/message" + "github.com/sprintertech/sprinter-signing/chains/evm/signature" lighter "github.com/sprintertech/sprinter-signing/chains/lighter/message" "github.com/stretchr/testify/suite" "github.com/sygmaprotocol/sygma-core/relayer/message" @@ -430,104 +431,105 @@ func (s *StatusHandlerTestSuite) SetupTest() { s.handler = handlers.NewStatusHandler(s.mockSignatureCacher, chains) } -func (s *StatusHandlerTestSuite) Test_HandleRequest_MissingDepositID() { - req := httptest.NewRequest(http.MethodGet, "/v1/chains/1/signatures", nil) - req.Header.Set("Content-Type", "application/json") - req = mux.SetURLVars(req, map[string]string{ - "chainId": "1", - }) - - recorder := httptest.NewRecorder() - - s.handler.HandleRequest(recorder, req) - - s.Equal(http.StatusBadRequest, recorder.Code) -} - -func (s *StatusHandlerTestSuite) Test_HandleRequest_InvalidChainID() { - req := httptest.NewRequest(http.MethodGet, "/v1/chains/invalid/signatures", nil) - req = mux.SetURLVars(req, map[string]string{ - "chainId": "invalid", - "depositId": "id", - }) - - recorder := httptest.NewRecorder() - - s.handler.HandleRequest(recorder, req) - - s.Equal(http.StatusBadRequest, recorder.Code) -} - -func (s *StatusHandlerTestSuite) Test_HandleRequest_ChainNotSupported() { - req := httptest.NewRequest(http.MethodGet, "/v1/chains/3/signatures", nil) - req = mux.SetURLVars(req, map[string]string{ - "chainId": "3", - "depositId": "id", - }) - - recorder := httptest.NewRecorder() - - s.handler.HandleRequest(recorder, req) - - s.Equal(http.StatusNotFound, recorder.Code) -} - -func (s *StatusHandlerTestSuite) Test_HandleRequest_ValidSignature() { - caller := "0x1111111111111111111111111111111111111111" - pool := "0x3333333333333333333333333333333333333333" - query := "?deadline=1000&caller=" + caller + "&borrowAmount=500&liquidityPool=" + pool + "&repaymentChainId=10" - req := httptest.NewRequest(http.MethodGet, "/v1/chains/1/signatures/id"+query, nil) - req = mux.SetURLVars(req, map[string]string{ - "chainId": "1", - "depositId": "id", - }) +func (s *StatusHandlerTestSuite) Test_HandleRequest_Errors() { + tests := []struct { + name string + vars map[string]string + query string + wantCode int + }{ + { + name: "missing deposit id", + vars: map[string]string{"chainId": "1"}, + wantCode: http.StatusBadRequest, + }, + { + name: "invalid chain id", + vars: map[string]string{"chainId": "invalid", "depositId": "id"}, + wantCode: http.StatusBadRequest, + }, + { + name: "chain not supported", + vars: map[string]string{"chainId": "3", "depositId": "id"}, + wantCode: http.StatusNotFound, + }, + { + // A partial composite param set must fail fast, not fall back to the legacy key and hang. + name: "partial composite params", + vars: map[string]string{"chainId": "1", "depositId": "id"}, + query: "?deadline=1000&caller=0x1111111111111111111111111111111111111111", + wantCode: http.StatusBadRequest, + }, + } + for _, tt := range tests { + s.Run(tt.name, func() { + req := httptest.NewRequest(http.MethodGet, "/v1/chains/1/signatures/id"+tt.query, nil) + req = mux.SetURLVars(req, tt.vars) + recorder := httptest.NewRecorder() - recorder := httptest.NewRecorder() + s.handler.HandleRequest(recorder, req) - expectedKey := "1-id-1000-" + common.HexToAddress(caller).Hex() + "-500-" + common.HexToAddress(pool).Hex() + "-10" - expectedSignature := []byte{0x01, 0x02, 0x03} - s.mockSignatureCacher.EXPECT(). - Subscribe(gomock.Any(), expectedKey, gomock.Any()). - Do(func(ctx context.Context, id string, sigChannel chan []byte) { - go func() { - sigChannel <- expectedSignature - }() + s.Equal(tt.wantCode, recorder.Code) }) - - go s.handler.HandleRequest(recorder, req) - - time.Sleep(50 * time.Millisecond) - - s.Equal(http.StatusOK, recorder.Code) - s.Equal("text/event-stream", recorder.Header().Get("Content-Type")) - s.Equal("no-cache", recorder.Header().Get("Cache-Control")) - s.Equal("keep-alive", recorder.Header().Get("Connection")) - s.Equal("*", recorder.Header().Get("Access-Control-Allow-Origin")) - s.Equal("data: "+hex.EncodeToString(expectedSignature)+"\n\n", recorder.Body.String()) + } } -func (s *StatusHandlerTestSuite) Test_HandleRequest_LegacyKeyWithoutParams() { - req := httptest.NewRequest(http.MethodGet, "/v1/chains/1/signatures/id", nil) - req = mux.SetURLVars(req, map[string]string{ - "chainId": "1", - "depositId": "id", - }) - - recorder := httptest.NewRecorder() - - expectedSignature := []byte{0x01, 0x02, 0x03} - s.mockSignatureCacher.EXPECT(). - Subscribe(gomock.Any(), "1-id", gomock.Any()). - Do(func(ctx context.Context, id string, sigChannel chan []byte) { - go func() { - sigChannel <- expectedSignature - }() +func (s *StatusHandlerTestSuite) Test_HandleRequest_SubscribeKey() { + caller := common.HexToAddress("0x1111111111111111111111111111111111111111") + pool := common.HexToAddress("0x3333333333333333333333333333333333333333") + lowerCaller := common.HexToAddress("0xabcdef0123456789abcdef0123456789abcdef01") + lowerPool := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678") + + tests := []struct { + name string + depositId string + query string + wantKey string + }{ + { + name: "legacy key without params", + depositId: "id", + wantKey: "1-id", + }, + { + name: "composite key from params", + depositId: "id", + query: "?deadline=1000&caller=" + caller.Hex() + "&borrowAmount=500&liquidityPool=" + pool.Hex() + "&repaymentChainId=10", + wantKey: signature.BorrowSessionID(1, "id", 1000, caller, big.NewInt(500), pool, 10), + }, + { + // Lowercase addresses and a leading-zero deposit id must still map to the canonical publish key. + name: "composite key normalizes address casing and deposit id", + depositId: "0042", + query: "?deadline=1000&caller=0xabcdef0123456789abcdef0123456789abcdef01&borrowAmount=500&liquidityPool=0x1234567890abcdef1234567890abcdef12345678&repaymentChainId=10", + wantKey: signature.BorrowSessionID(1, "42", 1000, lowerCaller, big.NewInt(500), lowerPool, 10), + }, + } + for _, tt := range tests { + s.Run(tt.name, func() { + req := httptest.NewRequest(http.MethodGet, "/v1/chains/1/signatures/"+tt.depositId+tt.query, nil) + req = mux.SetURLVars(req, map[string]string{"chainId": "1", "depositId": tt.depositId}) + recorder := httptest.NewRecorder() + + expectedSignature := []byte{0x01, 0x02, 0x03} + s.mockSignatureCacher.EXPECT(). + Subscribe(gomock.Any(), tt.wantKey, gomock.Any()). + Do(func(ctx context.Context, id string, sigChannel chan []byte) { + go func() { + sigChannel <- expectedSignature + }() + }) + + go s.handler.HandleRequest(recorder, req) + + time.Sleep(50 * time.Millisecond) + + s.Equal(http.StatusOK, recorder.Code) + s.Equal("text/event-stream", recorder.Header().Get("Content-Type")) + s.Equal("no-cache", recorder.Header().Get("Cache-Control")) + s.Equal("keep-alive", recorder.Header().Get("Connection")) + s.Equal("*", recorder.Header().Get("Access-Control-Allow-Origin")) + s.Equal("data: "+hex.EncodeToString(expectedSignature)+"\n\n", recorder.Body.String()) }) - - go s.handler.HandleRequest(recorder, req) - - time.Sleep(100 * time.Millisecond) // Give some time for the goroutine to execute - - s.Equal(http.StatusOK, recorder.Code) - s.Equal("data: "+hex.EncodeToString(expectedSignature)+"\n\n", recorder.Body.String()) + } } From b19f8977877d22ddcff801a55580720b1a5dd076 Mon Sep 17 00:00:00 2001 From: Branimir Tomeljak Date: Mon, 20 Jul 2026 13:00:30 +0200 Subject: [PATCH 3/3] comment --- api/handlers/signing.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/handlers/signing.go b/api/handlers/signing.go index a48972e5..20b7e7d4 100644 --- a/api/handlers/signing.go +++ b/api/handlers/signing.go @@ -257,10 +257,10 @@ func (h *StatusHandler) HandleRequest(w http.ResponseWriter, r *http.Request) { } } -// subscribeKey builds the cache key the status stream subscribes to, which must match the publish-side key. +// subscribeKey builds the cache key the status stream subscribes to. func subscribeKey(r *http.Request, chainID uint64, depositID string) (string, error) { q := r.URL.Query() - // No digest fields supplied: legacy key used by protocols other than Across. + // No digest fields supplied: key used by protocols other than Across. if !q.Has("deadline") && !q.Has("caller") && !q.Has("borrowAmount") && !q.Has("liquidityPool") && !q.Has("repaymentChainId") { return fmt.Sprintf("%d-%s", chainID, depositID), nil @@ -279,7 +279,7 @@ func subscribeKey(r *http.Request, chainID uint64, depositID string) (string, er if id, ok := new(big.Int).SetString(depositID, 10); ok { depositID = id.String() } - // Composite Across key with all fields required, so a partial set errors above rather than silently mismatching. + return signature.BorrowSessionID(chainID, depositID, deadline, common.HexToAddress(caller), borrowAmount, common.HexToAddress(liquidityPool), repaymentChainID), nil }