@@ -13,6 +13,7 @@ use std::sync::Arc;
1313struct AppState {
1414 schema : graphql:: AtomicIpSchema ,
1515 query_client : Arc < graphql:: SorobanQueryClient > ,
16+ rpc_client : Arc < dyn graphql:: SorobanRpcClient > ,
1617 ws_broadcaster : Arc < websocket:: EventBroadcaster > ,
1718 sse_broadcaster : Arc < events:: EventBroadcaster > ,
1819 health_checker : Arc < health:: HealthChecker > ,
@@ -190,13 +191,14 @@ async fn main() {
190191 let rpc_client: Arc < dyn graphql:: SorobanRpcClient > = Arc :: new ( graphql:: MockSorobanRpcClient :: default ( ) ) ;
191192 let query_client = Arc :: new ( graphql:: SorobanQueryClient :: new ( rpc_client. clone ( ) ) ) ;
192193 let schema = graphql:: build_schema_with_broadcaster (
193- rpc_client,
194+ rpc_client. clone ( ) ,
194195 subscription_broadcaster. clone ( ) ,
195196 ) ;
196197
197198 let state = AppState {
198199 schema,
199200 query_client,
201+ rpc_client,
200202 ws_broadcaster : Arc :: new ( websocket:: EventBroadcaster :: new ( ) ) ,
201203 sse_broadcaster : Arc :: new ( events:: create_event_broadcaster ( ) . 0 ) ,
202204 health_checker : Arc :: new ( health:: HealthChecker :: new ( ) ) ,
@@ -235,12 +237,11 @@ async fn main() {
235237 . route ( "/ip/verify" , post ( handlers:: verify_commitment) )
236238 . route ( "/ip/owner/{owner}" , get ( handlers:: list_ip_by_owner) )
237239 . route ( "/ip/owner/{owner}/cursor" , get ( handlers:: list_ip_by_owner_cursor) )
238- . route ( "/ip/owner/{owner}/cursor" , get ( handlers:: list_ip_by_owner_cursor) )
239- . route ( "/swap/initiate" , post ( handlers:: initiate_swap) )
240+ . route ( "/swap/initiate" , post ( handlers:: initiate_swap) . layer ( signed. clone ( ) ) )
240241 . route ( "/swap/batch-initiate" , post ( handlers:: batch_initiate_swap) )
241- . route ( "/swap/{swap_id}/accept" , post ( handlers:: accept_swap) )
242- . route ( "/swap/{swap_id}/reveal" , post ( handlers:: reveal_key) )
243- . route ( "/swap/{swap_id}/cancel" , post ( handlers:: cancel_swap) )
242+ . route ( "/swap/{swap_id}/accept" , post ( handlers:: accept_swap) . layer ( signed . clone ( ) ) )
243+ . route ( "/swap/{swap_id}/reveal" , post ( handlers:: reveal_key) . layer ( signed . clone ( ) ) )
244+ . route ( "/swap/{swap_id}/cancel" , post ( handlers:: cancel_swap) . layer ( signed . clone ( ) ) )
244245 . route ( "/swap/{swap_id}/cancel-expired" , post ( handlers:: cancel_expired_swap) )
245246 . route ( "/swap/{swap_id}" , get ( handlers:: get_swap) )
246247 . route ( "/openapi.json" , get ( openapi_handler) )
@@ -328,6 +329,9 @@ fn build_app() -> Router {
328329 rpc_client,
329330 } ;
330331
332+ // #535: same signing enforcement as the production router, so the test
333+ // router exercises the identical middleware on the six write endpoints.
334+ let signed = middleware:: from_fn ( request_signing:: verify_request_signature) ;
331335 Router :: new ( )
332336 . route ( "/health" , get ( health:: health_handler) )
333337 . route ( "/health/detailed" , get ( health:: detailed_health_handler) )
@@ -339,19 +343,19 @@ fn build_app() -> Router {
339343 . route ( "/events" , get ( events_handler) )
340344 . route ( "/batch" , post ( batch:: batch_handler) )
341345 . route ( "/v1/graphql" , post ( graphql_handler) )
342- . route ( "/v1/ip/commit" , post ( handlers:: commit_ip) )
346+ . route ( "/v1/ip/commit" , post ( handlers:: commit_ip) . layer ( signed . clone ( ) ) )
343347 . route ( "/v1/ip/{ip_id}" , get ( handlers:: get_ip) )
344- . route ( "/v1/ip/transfer" , post ( handlers:: transfer_ip) )
348+ . route ( "/v1/ip/transfer" , post ( handlers:: transfer_ip) . layer ( signed . clone ( ) ) )
345349 . route ( "/v1/ip/verify" , post ( handlers:: verify_commitment) )
346350 . route ( "/v1/ip/owner/{owner}" , get ( handlers:: list_ip_by_owner) )
347351 . route ( "/v1/ip/owner/{owner}/cursor" , get ( handlers:: list_ip_by_owner_cursor) )
348352 . route ( "/v1/ip/owner/{owner}/cursor" , get ( handlers:: list_ip_by_owner_cursor) )
349353 . route ( "/v1/swap/initiate" , post ( handlers:: initiate_swap) )
350354 . route ( "/v1/swap/batch-initiate" , post ( handlers:: batch_initiate_swap) )
351355 . route ( "/v1/swap/bulk/initiate" , post ( handlers:: batch_initiate_swap) )
352- . route ( "/v1/swap/{swap_id}/accept" , post ( handlers:: accept_swap) )
353- . route ( "/v1/swap/{swap_id}/reveal" , post ( handlers:: reveal_key) )
354- . route ( "/v1/swap/{swap_id}/cancel" , post ( handlers:: cancel_swap) )
356+ . route ( "/v1/swap/{swap_id}/accept" , post ( handlers:: accept_swap) . layer ( signed . clone ( ) ) )
357+ . route ( "/v1/swap/{swap_id}/reveal" , post ( handlers:: reveal_key) . layer ( signed . clone ( ) ) )
358+ . route ( "/v1/swap/{swap_id}/cancel" , post ( handlers:: cancel_swap) . layer ( signed . clone ( ) ) )
355359 . route ( "/v1/swap/{swap_id}/cancel-expired" , post ( handlers:: cancel_expired_swap) )
356360 . route ( "/v1/swap/{swap_id}" , get ( handlers:: get_swap) )
357361 . route ( "/v1/webhooks" , post ( handlers:: register_webhook) )
@@ -377,6 +381,25 @@ mod tests {
377381 } ;
378382 use tower:: ServiceExt ;
379383
384+ /// Test router with an injectable RPC client, used by the swap read-path
385+ /// tests to exercise `GET /v1/swap/{id}` against a stub client.
386+ fn app_with_rpc_client ( rpc_client : Arc < dyn graphql:: SorobanRpcClient > ) -> Router {
387+ let query_client = Arc :: new ( graphql:: SorobanQueryClient :: new ( rpc_client. clone ( ) ) ) ;
388+ let schema = graphql:: build_schema ( ) ;
389+ let health_checker = Arc :: new ( health:: HealthChecker :: new ( ) ) ;
390+ let state = AppState {
391+ schema,
392+ query_client,
393+ rpc_client,
394+ ws_broadcaster : Arc :: new ( websocket:: EventBroadcaster :: new ( ) ) ,
395+ sse_broadcaster : Arc :: new ( events:: create_event_broadcaster ( ) . 0 ) ,
396+ health_checker,
397+ } ;
398+ Router :: new ( )
399+ . route ( "/v1/swap/{swap_id}" , get ( handlers:: get_swap) )
400+ . with_state ( state)
401+ }
402+
380403 #[ tokio:: test]
381404 async fn test_post_without_content_type_returns_415 ( ) {
382405 let app = build_app ( ) ;
@@ -1277,4 +1300,74 @@ mod tests {
12771300 . unwrap ( ) ;
12781301 assert_eq ! ( resp. headers( ) . get( "Content-Encoding" ) . unwrap( ) , "gzip" ) ;
12791302 }
1303+
1304+ // ── #535: Request-signing enforcement on Soroban write endpoints ──────────
1305+
1306+ /// Every write endpoint that submits a signed transaction to Soroban must
1307+ /// reject requests that carry no valid signature. The middleware runs
1308+ /// per-route on exactly these six endpoints — not on reads, verification,
1309+ /// or batch/bulk variants.
1310+ #[ tokio:: test]
1311+ async fn test_signed_write_endpoints_reject_unsigned_requests ( ) {
1312+ let app = build_app ( ) ;
1313+ let cases = [
1314+ ( "/v1/ip/commit" , r#"{"owner":"GADDR","commitment_hash":"abc"}"# ) ,
1315+ ( "/v1/ip/transfer" , r#"{"ip_id":1,"new_owner":"GBUYER"}"# ) ,
1316+ ( "/v1/swap/initiate" , r#"{"ip_registry_id":"C1","ip_id":1,"seller":"GSELLER","price":100,"buyer":"GBUYER","token":"CTOKEN"}"# ) ,
1317+ ( "/v1/swap/1/accept" , r#"{"buyer":"GBUYER"}"# ) ,
1318+ ( "/v1/swap/1/reveal" , r#"{"caller":"GSELLER","secret":"abcd","blinding_factor":"efgh"}"# ) ,
1319+ ( "/v1/swap/1/cancel" , r#"{"canceller":"GSELLER"}"# ) ,
1320+ ] ;
1321+ for ( path, body) in cases {
1322+ let resp = app
1323+ . clone ( )
1324+ . oneshot (
1325+ Request :: builder ( )
1326+ . method ( "POST" )
1327+ . uri ( path)
1328+ . header ( "content-type" , "application/json" )
1329+ . body ( Body :: from ( body. to_string ( ) ) )
1330+ . unwrap ( ) ,
1331+ )
1332+ . await
1333+ . unwrap ( ) ;
1334+ assert_eq ! (
1335+ resp. status( ) ,
1336+ StatusCode :: UNAUTHORIZED ,
1337+ "unsigned POST {path} must be rejected by request-signing middleware"
1338+ ) ;
1339+ }
1340+ }
1341+
1342+ #[ tokio:: test]
1343+ async fn test_signed_write_endpoint_accepts_valid_signature ( ) {
1344+ let app = build_app ( ) ;
1345+ let method = "POST" ;
1346+ let path = "/v1/ip/commit" ;
1347+ let body = r#"{"owner":"GADDR","commitment_hash":"abc"}"# ;
1348+ let now = std:: time:: SystemTime :: now ( )
1349+ . duration_since ( std:: time:: UNIX_EPOCH )
1350+ . unwrap ( )
1351+ . as_secs ( ) ;
1352+ let body_hash = request_signing:: hash_body ( body. as_bytes ( ) ) ;
1353+ let public_key = "GBRPYHIL2CI3WHZDTOOQFC6EB4KJJGUJJBBQ5ECVVF7C3XVQCRWGSGAX" ;
1354+ let signature = request_signing:: generate_signature ( method, path, now, & body_hash, public_key) ;
1355+ let resp = app
1356+ . oneshot (
1357+ Request :: builder ( )
1358+ . method ( method)
1359+ . uri ( path)
1360+ . header ( "content-type" , "application/json" )
1361+ . header ( "X-Signature" , signature)
1362+ . header ( "X-Timestamp" , now. to_string ( ) )
1363+ . header ( "X-Public-Key" , public_key)
1364+ . body ( Body :: from ( body. to_string ( ) ) )
1365+ . unwrap ( ) ,
1366+ )
1367+ . await
1368+ . unwrap ( ) ;
1369+ // Middleware verified the signature and let the request through; the
1370+ // commit_ip stub then returns 400 ("not yet implemented").
1371+ assert_eq ! ( resp. status( ) , StatusCode :: BAD_REQUEST ) ;
1372+ }
12801373}
0 commit comments