Skip to content

fix: SEP-10 verification, multi-sig fee control, matchmaking queue, ELO service#700

Merged
gabito1451 merged 2 commits into
OpenKnight-Foundation:mainfrom
Xuccessor:fix/issues-529-535-555-556
Apr 27, 2026
Merged

fix: SEP-10 verification, multi-sig fee control, matchmaking queue, ELO service#700
gabito1451 merged 2 commits into
OpenKnight-Foundation:mainfrom
Xuccessor:fix/issues-529-535-555-556

Conversation

@Xuccessor

@Xuccessor Xuccessor commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Resolves all four issues assigned to Xuccessor in a single PR.


#529 — Contract: SEP-10 Challenge Verification

Implements the SEP-10 Web Authentication challenge/response flow on-chain.

New contract methods:

  • issue_sep10_challenge(admin, account, nonce, expiry) — admin stores a 32-byte nonce with an expiry ledger sequence
  • verify_sep10_challenge(account, nonce, signature) — verifies ED25519 sig over SHA256(address || nonce || expiry_le8), consumes the nonce, marks account verified
  • is_sep10_verified(account) — query helper

Replay protection: nonce is deleted on successful verification; duplicate nonces are rejected at issue time.


#535 — Contract: Multi-Sig Control for Protocol Fee Parameters

Adds M-of-N multi-signature governance for fee_bips and treasury_address changes.

New contract methods:

  • configure_multisig(admin, signers, threshold) — admin sets the signer set and approval threshold
  • propose_fee_change(signer, new_fee_bips, new_treasury) — any signer proposes; proposer's approval is counted automatically
  • approve_fee_proposal(signer) — vote; fee change applied atomically when approvals ≥ threshold; returns true when executed
  • cancel_fee_proposal(signer) — any signer can cancel the pending proposal
  • get_fee_proposal() / get_approval_count() — query helpers

#555 — Backend: Redis-based Matchmaking Queue (frontend integration)

The Rust/Redis backend already implements the queue. This PR adds the frontend integration layer.

New files:

  • frontend/lib/matchmakingService.ts — typed fetch client for join/status/leave/accept-invite endpoints
  • frontend/hook/useMatchmakingQueue.ts — React hook with a state machine (idle → searching → matched | error), automatic polling every 3 s, and cleanup on unmount

#556 — Backend: ELO Rating Calculation Service (frontend)

Pure TypeScript Elo implementation mirroring backend/modules/matchmaking/elo.rs.

New files:

  • frontend/lib/eloService.tscalculateMatchRatings, calculateDrawRatings, kFactor, expectedScore
  • frontend/__tests__/eloService.test.ts — unit tests covering equal ratings, upsets, draws, floor clamping, and K-factor selection

Testing

  • Contract: 10 new unit tests in contracts/game_contract/src/test.rs covering SEP-10 and multi-sig flows
  • Frontend: frontend/__tests__/eloService.test.ts — all TypeScript files verified with tsc --transpileModule

Closes #529
Closes #535
Closes #555
Closes #556

Summary by CodeRabbit

  • New Features

    • Secure account verification via SEP-10 authentication
    • Multi-signature governance mechanism for protocol fee updates
    • Elo-based competitive rating system for player rankings
    • Matchmaking queue system for finding opponents
  • Tests

    • Added comprehensive test coverage for authentication, fee governance, and rating calculations

, OpenKnight-Foundation#555, OpenKnight-Foundation#556

- OpenKnight-Foundation#529 Contract: SEP-10 challenge verification
  - Add issue_sep10_challenge / verify_sep10_challenge / is_sep10_verified
  - Nonce-based replay protection; challenge consumed on successful verify
  - Payload: SHA256(address_bytes || nonce_bytes || expiry_le8)

- OpenKnight-Foundation#535 Contract: Multi-sig control for protocol fee parameters
  - Add configure_multisig (admin sets signers + M-of-N threshold)
  - Add propose_fee_change / approve_fee_proposal / cancel_fee_proposal
  - Fee change applied atomically when approvals reach threshold
  - Add get_fee_proposal / get_approval_count query helpers

- OpenKnight-Foundation#555 Backend: Redis-based matchmaking queue (frontend integration)
  - Add frontend/lib/matchmakingService.ts — typed API client for the
    existing Rust/Redis matchmaking backend (join, status, leave, invite)
  - Add frontend/hook/useMatchmakingQueue.ts — React hook with polling,
    state machine (idle → searching → matched | error), and cleanup

- OpenKnight-Foundation#556 Backend: ELO rating calculation service (frontend)
  - Add frontend/lib/eloService.ts — pure TS Elo implementation mirroring
    backend/modules/matchmaking/elo.rs (calculateMatchRatings,
    calculateDrawRatings, kFactor, expectedScore)
  - Add frontend/__tests__/eloService.test.ts — unit tests for all helpers

Closes OpenKnight-Foundation#529, OpenKnight-Foundation#535, OpenKnight-Foundation#555, OpenKnight-Foundation#556
@coderabbitai

coderabbitai Bot commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@Xuccessor has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 41 minutes and 46 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 92ecbf3d-b0c0-4f03-9996-23bb67c1fb92

📥 Commits

Reviewing files that changed from the base of the PR and between d9e47a9 and 2cd1588.

📒 Files selected for processing (1)
  • contracts/game_contract/src/test.rs
📝 Walkthrough

Walkthrough

This PR introduces three major feature areas: on-chain SEP-10 challenge verification with replay prevention via nonces, M-of-N multisig governance for fee parameter updates with threshold-based approvals, and frontend infrastructure for Elo rating calculations and Redis-backed matchmaking queue management with polling.

Changes

Cohort / File(s) Summary
Smart Contract: SEP-10 Challenge Verification
contracts/game_contract/src/lib.rs, contracts/game_contract/src/test.rs
Adds issue_sep10_challenge and verify_sep10_challenge methods to issue nonces and verify ED25519 signatures over SHA256 payloads (account + nonce + expiry). Records verified accounts in SEP10_VERIFIED and prevents replay via nonce removal. Includes new ContractError variants and comprehensive test coverage validating standard/edge cases.
Smart Contract: Multisig Fee Governance
contracts/game_contract/src/lib.rs, contracts/game_contract/src/test.rs
Introduces FeeProposal struct and methods (configure_multisig, propose_fee_change, approve_fee_proposal, cancel_fee_proposal) to enforce M-of-N threshold-based approval for fee updates. Signers auto-approve their own proposals; execution occurs when approvals meet threshold. Includes state queries (get_fee_proposal, get_approval_count) and comprehensive tests for threshold logic, cancellation, and invalid configurations.
Frontend: Elo Rating Service
frontend/lib/eloService.ts, frontend/__tests__/eloService.test.ts
Implements pure TypeScript Elo rating calculations with FIDE-style K-factor selection, expected-score formula, and rating-update logic clamping results to minimum 100. Exports types (GameResult, EloUpdateResult), functions for individual/match/draw updates, and comprehensive unit tests validating K-factor ranges, score expectations, and rating deltas across win/loss/draw scenarios.
Frontend: Matchmaking Queue & Client Service
frontend/lib/matchmakingService.ts, frontend/hook/useMatchmakingQueue.ts
Adds HTTP client (matchmakingService) for queue operations (join, poll status, leave, accept invite) with BASE_URL derived from NEXT_PUBLIC_BACKEND_URL. Introduces useMatchmakingQueue React hook managing polling intervals, state transitions (idlesearchingmatched / error), and best-effort queue cleanup on unmount/leave.

Sequence Diagrams

sequenceDiagram
    participant Admin as Admin/Issuer
    participant Contract as Game Contract
    participant Storage as Contract Storage
    
    Admin->>Contract: issue_sep10_challenge(account)
    Contract->>Storage: Store (nonce, expiry)
    Contract-->>Admin: Return nonce
    
    rect rgba(100, 150, 200, 0.5)
    Note over Admin,Storage: Later: Verification
    end
    
    Admin->>Contract: verify_sep10_challenge(account, nonce, expiry, signature)
    Contract->>Contract: Verify ED25519 signature over (account + nonce + expiry)
    Contract->>Storage: Check nonce exists & not expired
    alt Signature valid & nonce fresh
        Contract->>Storage: Remove nonce (replay prevention)
        Contract->>Storage: Record account in SEP10_VERIFIED
        Contract-->>Admin: Success
    else Invalid or expired
        Contract-->>Admin: Error (InvalidSignature / NonceExpired)
    end
Loading
sequenceDiagram
    participant Signer1 as Signer 1
    participant Signer2 as Signer 2
    participant Contract as Game Contract
    participant Storage as Contract Storage
    
    Signer1->>Contract: configure_multisig(signers, threshold)
    Contract->>Storage: Store signers & threshold config
    
    Signer1->>Contract: propose_fee_change(new_fee, new_treasury)
    Contract->>Storage: Create FeeProposal, auto-approve Signer1
    Contract-->>Signer1: Proposal created
    
    Signer2->>Contract: approve_fee_proposal(proposal_id)
    Contract->>Storage: Increment approval count
    Contract->>Storage: Check approval_count vs threshold
    
    alt approval_count >= threshold
        Contract->>Storage: Update FEE_BIPS & TREASURY_ADDR
        Contract->>Storage: Clear pending proposal & approvals
        Contract-->>Signer2: Success (fee updated)
    else Approvals pending
        Contract-->>Signer2: Proposal still pending
    end
Loading
sequenceDiagram
    participant UI as Frontend UI
    participant Hook as useMatchmakingQueue Hook
    participant Service as matchmakingService
    participant API as Backend API
    participant Queue as Redis Queue
    
    UI->>Hook: join(matchRequest)
    Hook->>Service: joinQueue(request)
    Service->>API: POST /matchmaking/join
    API->>Queue: Add requestId
    API-->>Service: Return requestId & matchId
    Service-->>Hook: Success
    Hook->>Hook: Set state='searching'
    
    rect rgba(100, 200, 150, 0.5)
    Note over Hook,Queue: Polling Loop (every N ms)
    end
    
    loop Poll interval
        Hook->>Service: getQueueStatus(requestId)
        Service->>API: GET /matchmaking/status/:requestId
        alt requestId still in queue
            API-->>Service: QueueStatus
        else Not found (matched/expired)
            API-->>Service: 404 (null)
        end
        
        alt Not found
            Hook->>Hook: Set state='matched', record matchId
            loop Stop polling
            end
        end
    end
    
    UI->>Hook: leave()
    Hook->>Service: leaveQueue(requestId)
    Service->>API: DELETE /matchmaking/leave/:requestId
    API->>Queue: Remove requestId
    Hook->>Hook: Reset state='idle', clear identifiers
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~55 minutes

Possibly related PRs

Poem

🐰 A hop, a skip, through quantum tricks—
SEP-10 nonces seal the fix!
Multisig bunnies vote as one,
While matchmaking queues hop for fun. 🎮
Elo whispers through the air—
Come play, dear friend, if you dare!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title lists four distinct feature implementations (SEP-10, multi-sig, matchmaking, ELO) but uses 'fix' label which is imprecise for feature additions. The title is specific and covers the main changes but could mislead reviewers about the nature of work.
Linked Issues check ✅ Passed All four linked issues (#529, #535, #555, #556) have their coding requirements met: contract code implements SEP-10 and multi-sig governance with tests; frontend adds matchmaking service, React hook, and ELO service with unit tests. All changes follow established patterns and include documentation.
Out of Scope Changes check ✅ Passed All changes align with the four linked issues. Contract additions (SEP-10, multi-sig) and frontend additions (matchmaking, ELO) are scoped to their respective issues with no extraneous modifications.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@drips-wave

drips-wave Bot commented Apr 27, 2026

Copy link
Copy Markdown

@Xuccessor Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 17

🧹 Nitpick comments (4)
contracts/game_contract/src/lib.rs (3)

1454-1465: expect("Not initialized") will panic instead of returning a typed error.

issue_sep10_challenge and configure_multisig both expect() on CONTRACT_ADMIN, which means callers receive a host-level panic ("Not initialized") rather than a ContractError. Other admin-gated entry points in this file (e.g. set_max_stake line 911) follow the same pattern, so this is consistent — but for new methods returning Result<…, ContractError>, prefer a typed error so SDK clients can handle it deterministically.

-        let current_admin: Address = env
-            .storage()
-            .instance()
-            .get(&CONTRACT_ADMIN)
-            .expect("Not initialized");
+        let current_admin: Address = env
+            .storage()
+            .instance()
+            .get(&CONTRACT_ADMIN)
+            .ok_or(ContractError::Unauthorized)?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/game_contract/src/lib.rs` around lines 1454 - 1465, The code
currently calls .expect("Not initialized") when reading CONTRACT_ADMIN in
functions like issue_sep10_challenge (and similarly configure_multisig /
set_max_stake), which panics instead of returning a ContractError; change these
to retrieve CONTRACT_ADMIN with .get(...).ok_or(...) and return a typed
ContractError (e.g. Err(ContractError::NotInitialized) or a suitable existing
variant) so issue_sep10_challenge and other admin-gated entry points return
Err(...) instead of panicking; ensure you reference CONTRACT_ADMIN and the
ContractError enum when making the change.

1539-1543: Truncation hazard if account.to_string().len() ever exceeds 64 bytes.

copy_into_slice(&mut addr_buf[..str_len]) will panic if str_len > 64. Stellar G…/C… strkey addresses are 56 chars today, so this is fine in practice, but the same fixed-size buffer pattern is duplicated from claim_puzzle_reward and claim_win. Consider extracting an address_to_payload_bytes(&Env, &Address) -> Bytes helper to remove the magic 64 and prevent drift if Stellar adds longer address types in the future.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/game_contract/src/lib.rs` around lines 1539 - 1543, The current
code builds a fixed 64-byte buffer (addr_buf) and calls
account_str.copy_into_slice(&mut addr_buf[..str_len]) which will panic if
account_str.len() > 64; refactor by extracting a helper like
address_to_payload_bytes(env: &Env, account: &Address) -> Bytes that allocates
exactly the needed length (no fixed 64 magic), converts account.to_string() to
bytes, and returns Bytes::from_slice(&env, &account_bytes), then replace the
inline logic in this function and the duplicated sites claim_puzzle_reward and
claim_win with calls to address_to_payload_bytes to avoid truncation hazards and
remove duplicate code.

1771-1779: Auth check after storage read — minor ordering nit.

signer.require_auth() is invoked on line 1779 after both the signers-list lookup and the proposal-existence check. For consistency with propose_fee_change and approve_fee_proposal (which call require_auth after the membership check but before the bulk of the work), this is fine; however calling require_auth first would short-circuit unauthorized callers without doing extra storage reads. Optional — the current ordering does not produce incorrect behaviour because require_auth will still fail before any state change.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/game_contract/src/lib.rs` around lines 1771 - 1779, The auth check
is done after reading storage for PENDING_FEE_PROPOSAL; move the authentication
earlier so unauthorized callers are rejected before performing storage reads.
Specifically, in the function that currently calls
env.storage().instance().has(&PENDING_FEE_PROPOSAL) then signer.require_auth(),
call signer.require_auth() first (matching the pattern used by
propose_fee_change and approve_fee_proposal) and then perform the signers-list
lookup and the proposal-existence check.
frontend/hook/useMatchmakingQueue.ts (1)

128-132: Consider best-effort leaveQueue on unmount.

If a user navigates away while state === "searching", polling stops but the queue entry stays in Redis until it expires. A best-effort leaveQueue(requestIdRef.current) in the cleanup would avoid stale entries and ghost matches.

♻️ Proposed change
   useEffect(() => {
     return () => {
       stopPolling();
+      const id = requestIdRef.current;
+      if (id) {
+        // Best-effort; ignore failures on unmount.
+        void leaveQueue(id).catch(() => {});
+      }
     };
   }, [stopPolling]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/hook/useMatchmakingQueue.ts` around lines 128 - 132, In the
useEffect cleanup, add a best-effort call to leaveQueue(requestIdRef.current)
before/after stopPolling to avoid leaving stale Redis entries when state ===
"searching"; check that requestIdRef.current exists and state === "searching",
call leaveQueue in a fire-and-forget way (wrap in an immediately-invoked async
function or use void leaveQueue(...)) and swallow/log errors (try/catch) so
unmount is not blocked; keep stopPolling() as before and reference useEffect,
stopPolling, leaveQueue, requestIdRef, and state to locate the code to change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@contracts/game_contract/src/lib.rs`:
- Around line 1688-1757: approve_fee_proposal currently counts approvals via
approvals.len() which may include approvals from signers removed via
configure_multisig; to fix, modify configure_multisig to clear any in-flight
proposal and approvals by removing PENDING_FEE_PROPOSAL and
FEE_PROPOSAL_APPROVALS from storage (env.storage().instance().remove(...))
whenever MULTISIG_SIGNERS is updated, ensuring that a signer rotation always
forces a fresh proposal and prevents stale approvals from counting in
approve_fee_proposal.
- Around line 1636-1683: propose_fee_change currently overwrites any existing
PENDING_FEE_PROPOSAL and resets FEE_PROPOSAL_APPROVALS, enabling a single signer
to grief in-flight proposals; change propose_fee_change to first check
env.storage().instance().get(&PENDING_FEE_PROPOSAL) and return an error (e.g.,
ContractError::PendingProposalExists) if a proposal is present, so new proposals
are rejected until the existing one is explicitly cancelled; keep the existing
proposer.require_auth() and leave cancel_fee_proposal to handle removal
(follow-up: tighten cancel rights), and update the event/behavior accordingly.
- Around line 1605-1630: The configure_multisig function accepts duplicated
addresses in signers which breaks approval semantics (FEE_PROPOSAL_APPROVALS is
keyed by Address); fix by validating uniqueness before persisting: build a
temporary set from signers and if set.len() != signers.len() return an
appropriate ContractError (e.g., DuplicateSigner or add InvalidSigners), perform
this check prior to setting MULTISIG_SIGNERS and MULTISIG_THRESHOLD, and only
persist after the uniqueness and threshold validations pass.
- Around line 1454-1496: Store the intended account alongside the nonce when
issuing SEP-10 challenges and enforce that the verifier is the same account:
modify issue_sep10_challenge to save a mapping from nonce (BytesN<32>) to a
tuple/value containing (account: Address, expiry: u64) instead of only expiry in
SEP10_CHALLENGES, and update verify_sep10_challenge to load that stored tuple
and reject if the supplied account does not match the stored account; also call
account.require_auth() (or otherwise require auth on the verifying account) in
verify_sep10_challenge to ensure only the intended account can complete
verification.
- Around line 1511-1524: The code incorrectly maps a missing nonce lookup to
ContractError::ChallengeAlreadyUsed; change this to a distinct error (e.g.,
ContractError::ChallengeNotFound or reuse an appropriate existing variant like
ContractError::Unauthorized) so a non-existent nonce is not treated as a replay.
Update the get() -> ok_or(...) call on the Map<BytesN<32>, u64> named challenges
(the lookup using nonce.clone()) to return the new/appropriate error variant,
and add the new enum variant to ContractError if needed, keeping
ContractError::ChallengeAlreadyUsed reserved for the actual replay path where a
nonce was previously consumed.

In `@contracts/game_contract/src/test.rs`:
- Around line 472-477: The comment is off-by-one: propose_fee_change (which
auto-approves the proposer) means there is already one approval before calling
approve_fee_proposal(&signer2), so after signer2 approves the proposal reaches
the threshold and is executed; update the inline comment above the
approve_fee_proposal(&signer2) call to reflect that the proposer was
auto-approved by propose_fee_change and that this call provides the second
approval (making it 2-of-3 and triggering execution) — reference
propose_fee_change and approve_fee_proposal in the comment so readers know why
the approval count is different.
- Around line 398-403: The test incorrectly assumes env.mock_all_auths()
bypasses ed25519_verify; update the test to either (A) generate a real Ed25519
signature and public key like tests::setup in lib.rs using
ed25519_dalek::SigningKey and pass that valid signature to
verify_sep10_challenge (ensure admin_pubkey and digest_bytes match the signer),
or (B) avoid calling verify_sep10_challenge at all and instead assert the
storage-only behavior you want (e.g., use init_contract helper only for storage
checks); apply the same fix to test_sep10_challenge_cannot_be_reused so no
ed25519_verify call receives all-zero keys/signatures.

In `@frontend/__tests__/eloService.test.ts`:
- Around line 68-71: The test uses parameters where delta is zero so the 100
floor isn't exercised; update the test that calls calculateNewRating to use
inputs that produce a negative delta large enough to push rating + delta below
100 (for example use rating like 110 with a very high opponentRating and result
"loss" and K=40) and then assert that the returned newRating is clamped to 100;
reference the calculateNewRating function and its returned newRating/delta
values when changing the test data so it actually hits the floor branch.

In `@frontend/hook/useMatchmakingQueue.ts`:
- Around line 55-79: startPolling uses setInterval which allows overlapping
getQueueStatus calls and stopPolling only clears the interval (doesn't cancel
inflight requests), causing races and possible state updates after unmount;
change to a re-arming setTimeout loop in startPolling so each poll awaits
getQueueStatus before scheduling the next, and add cancellation via an
AbortController or an "active" flag referenced by
requestIdRef/abortControllerRef that stopPolling sets (and aborts) to prevent
any subsequent setQueueStatus/setState/setError after leave/unmount; ensure
stopPolling clears pollRef, aborts the current controller, and startPolling
creates a fresh AbortController whose signal is passed into getQueueStatus (or
whose active flag is checked) before performing any state updates.
- Around line 65-69: The hook assumes a removed queue entry means a match but
doesn't set matchId; update the StatusResponse type to include an optional
match_id (string|null) populated by the backend when a request is paired, and
change the polling flow in useMatchmakingQueue/getQueueStatus so the response
carries match_id when the entry is removed; in useMatchmakingQueue, when the
polling response indicates removal (previously status === null) read
response.match_id, call setMatchId(response.match_id) and only
setState("matched") when a non-null match_id is present (otherwise treat as
expired), and ensure stopPolling is still called once you have handled match_id
or expiry.

In `@frontend/lib/eloService.ts`:
- Around line 55-66: The calculateNewRating function returns delta that doesn't
reflect the floor applied to newRating; compute the raw delta as Math.round(k *
(score - expected)), compute newRating = Math.max(100, rating + rawDelta), then
set the returned delta to appliedDelta = newRating - rating (i.e., clamp delta
to the actually-applied change) so callers see a consistent invariant (reference
calculateNewRating, delta, newRating).

In `@frontend/lib/matchmakingService.ts`:
- Around line 38-58: The POST body sent by the join queue function currently
nests player and includes join_time which doesn't match the backend
JoinQueueRequest shape; update the fetch call in the join function
(matchmakingService.ts) to send a flat JSON object with keys wallet_address:
request.walletAddress, elo: request.elo, match_type: request.matchType,
invite_address: request.inviteAddress ?? null, and max_elo_diff:
request.maxEloDiff ?? null (remove the player wrapper and omit join_time so the
server-side Utc::now() is used).
- Around line 61-75: The getQueueStatus function currently treats the response
as a bare QueueStatus; instead fetch and parse the JSON as the StatusResponse
shape ({ status: string, queue_status: QueueStatus | null }), then if
response.status === 404 return null, else if res.ok read the StatusResponse and
return the inner queue_status (or null) — also remap the snake_case fields of
that inner object to the frontend QueueStatus shape (e.g. queue_status.position
-> position, queue_status.estimated_wait_time -> estimatedWaitTime) so consumers
receive the correct typed object; update getQueueStatus to return
Promise<QueueStatus | null> accordingly and preserve existing error handling for
non-ok responses.
- Around line 90-115: The request body sent by acceptPrivateInvite does not
match the server's AcceptInviteRequest (it wraps fields in accepting_player and
sends join_time), causing deserialization failures; update acceptPrivateInvite
to send a flat JSON object with keys inviter_request_id, wallet_address, and elo
(omit join_time and the accepting_player wrapper) so the payload matches the
server's expected AcceptInviteRequest shape before calling POST
/matchmaking/accept-invite.
- Around line 18-29: The backend returns snake_case JSON and Duration objects,
so update the response handling in joinQueue, getQueueStatus, and
acceptPrivateInvite to parse the raw JSON (const raw = await res.json()) and map
fields to the TS interfaces: map raw.match_id -> matchId (use ?? undefined),
raw.request_id -> requestId, raw.match_type -> matchType, raw.position ->
position, and convert raw.estimated_wait_time to estimatedWaitSeconds using
raw.estimated_wait_time.secs; keep raw.status -> status. Ensure these
transformations replace the current direct casts (res.json() as Promise<...>) so
response.matchId and response.requestId are populated and polling works.
- Around line 78-87: The leaveQueue function currently calls a non-existent
DELETE /matchmaking/leave/:id and suppresses 404s; change leaveQueue to POST to
/matchmaking/cancel with a JSON body { request_id: requestId }, set the
Content-Type: application/json header, and remove the special-case 404
suppression so that non-OK responses throw an Error (include res.statusText).
Locate the function leaveQueue in matchmakingService.ts and replace the fetch
URL/method/body/headers accordingly and ensure errors are propagated.

---

Nitpick comments:
In `@contracts/game_contract/src/lib.rs`:
- Around line 1454-1465: The code currently calls .expect("Not initialized")
when reading CONTRACT_ADMIN in functions like issue_sep10_challenge (and
similarly configure_multisig / set_max_stake), which panics instead of returning
a ContractError; change these to retrieve CONTRACT_ADMIN with
.get(...).ok_or(...) and return a typed ContractError (e.g.
Err(ContractError::NotInitialized) or a suitable existing variant) so
issue_sep10_challenge and other admin-gated entry points return Err(...) instead
of panicking; ensure you reference CONTRACT_ADMIN and the ContractError enum
when making the change.
- Around line 1539-1543: The current code builds a fixed 64-byte buffer
(addr_buf) and calls account_str.copy_into_slice(&mut addr_buf[..str_len]) which
will panic if account_str.len() > 64; refactor by extracting a helper like
address_to_payload_bytes(env: &Env, account: &Address) -> Bytes that allocates
exactly the needed length (no fixed 64 magic), converts account.to_string() to
bytes, and returns Bytes::from_slice(&env, &account_bytes), then replace the
inline logic in this function and the duplicated sites claim_puzzle_reward and
claim_win with calls to address_to_payload_bytes to avoid truncation hazards and
remove duplicate code.
- Around line 1771-1779: The auth check is done after reading storage for
PENDING_FEE_PROPOSAL; move the authentication earlier so unauthorized callers
are rejected before performing storage reads. Specifically, in the function that
currently calls env.storage().instance().has(&PENDING_FEE_PROPOSAL) then
signer.require_auth(), call signer.require_auth() first (matching the pattern
used by propose_fee_change and approve_fee_proposal) and then perform the
signers-list lookup and the proposal-existence check.

In `@frontend/hook/useMatchmakingQueue.ts`:
- Around line 128-132: In the useEffect cleanup, add a best-effort call to
leaveQueue(requestIdRef.current) before/after stopPolling to avoid leaving stale
Redis entries when state === "searching"; check that requestIdRef.current exists
and state === "searching", call leaveQueue in a fire-and-forget way (wrap in an
immediately-invoked async function or use void leaveQueue(...)) and swallow/log
errors (try/catch) so unmount is not blocked; keep stopPolling() as before and
reference useEffect, stopPolling, leaveQueue, requestIdRef, and state to locate
the code to change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 68da555b-ea0d-4745-96f0-94b4df9b080c

📥 Commits

Reviewing files that changed from the base of the PR and between 1b74a1c and d9e47a9.

📒 Files selected for processing (6)
  • contracts/game_contract/src/lib.rs
  • contracts/game_contract/src/test.rs
  • frontend/__tests__/eloService.test.ts
  • frontend/hook/useMatchmakingQueue.ts
  • frontend/lib/eloService.ts
  • frontend/lib/matchmakingService.ts

Comment on lines +111 to +119
// SEP-10 challenge verification (#529)
const SEP10_CHALLENGES: Symbol = symbol_short!("S10_CHAL"); // Map<BytesN<32>, u64> nonce → expiry
const SEP10_VERIFIED: Symbol = symbol_short!("S10_VER"); // Map<Address, bool>

// Multi-sig fee control (#535)
const MULTISIG_SIGNERS: Symbol = symbol_short!("MS_SIGN"); // Vec<Address>
const MULTISIG_THRESHOLD: Symbol = symbol_short!("MS_THRES"); // u32
const PENDING_FEE_PROPOSAL: Symbol = symbol_short!("MS_PROP"); // Option<FeeProposal>
const FEE_PROPOSAL_APPROVALS: Symbol = symbol_short!("MS_APPR"); // Map<Address, bool>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Soroban best practices for unbounded collections: instance storage vs persistent storage with TTL

💡 Result:

For unbounded collections in Soroban (e.g., vectors or maps that can grow indefinitely like user lists or balances), use persistent storage with variable DataKeys, not instance storage or fixed-key persistent storage. Instance storage is unsuitable due to its ~64-100KB size limit per ledger entry and because all instance data loads with every contract invocation, increasing costs and enabling DoS as the collection grows. Single persistent keys for collections also risk DoS by hitting the 64KB entry limit. Best practice: Use persistent storage where each collection element uses a unique, variable key (e.g., DataKey::UserBalance(user_id) or indexed keys like DataKey::ListItem(counter)). Maintain a separate counter for iteration if needed. This spreads data across multiple ledger entries, avoiding size limits and DoS, while economic costs prevent abuse (e.g., ~0.044 XLM per entry, limiting attackers). All storage requires TTL management via extend_ttl to prevent expiration/archival; persistent data can be restored but needs proactive extension in contract logic. Temporary storage is unsuitable for unbounded persistent needs as it deletes on expiry.

Citations:


🏁 Script executed:

# Search for usage of the symbols mentioned in the review
rg "SEP10_CHALLENGES|SEP10_VERIFIED|FEE_PROPOSAL_APPROVALS" contracts/game_contract/src/lib.rs -A 3 -B 1

Repository: NOVUS-X/XLMate

Length of output: 2987


🏁 Script executed:

# Check the context around lines 1476-1488 and 1562-1568
sed -n '1470,1495p' contracts/game_contract/src/lib.rs
sed -n '1556,1575p' contracts/game_contract/src/lib.rs

Repository: NOVUS-X/XLMate

Length of output: 1493


Move unbounded Maps from instance to persistent storage with per-item keys.

The three Maps—SEP10_CHALLENGES (nonce → expiry), SEP10_VERIFIED (address → bool), and FEE_PROPOSAL_APPROVALS (address → bool)—are stored as single values in instance storage. Since instance storage loads entirely on every invocation and is bounded by ~64-100KB, these maps will eventually exceed the contract entry size limit as they grow per nonce/user/signer, causing all contract calls to exceed budget and bricking the contract.

Use env.storage().persistent() with unique variable keys for each entry (e.g., (SEP10_CHALLENGES, nonce) and (SEP10_VERIFIED, address)) and set TTL on writes. This spreads entries across independent ledger entries with individual lifetimes, eliminating the read-entire-map-per-call cost. Apply the same pattern to FEE_PROPOSAL_APPROVALS; though it's smaller and cleared on execute/cancel, it remains vulnerable to the same issue.

Comment on lines +1454 to +1496
pub fn issue_sep10_challenge(
env: Env,
admin: Address,
account: Address,
nonce: BytesN<32>,
expiry: u64,
) -> Result<(), ContractError> {
let current_admin: Address = env
.storage()
.instance()
.get(&CONTRACT_ADMIN)
.expect("Not initialized");
current_admin.require_auth();
if admin != current_admin {
return Err(ContractError::Unauthorized);
}

let current_ledger = env.ledger().sequence() as u64;
if expiry <= current_ledger {
return Err(ContractError::ChallengeExpired);
}

let mut challenges: Map<BytesN<32>, u64> = env
.storage()
.instance()
.get(&SEP10_CHALLENGES)
.unwrap_or(Map::new(&env));

// Reject if nonce already exists (replay protection)
if challenges.get(nonce.clone()).is_some() {
return Err(ContractError::ChallengeAlreadyUsed);
}

challenges.set(nonce.clone(), expiry);
env.storage().instance().set(&SEP10_CHALLENGES, &challenges);

env.events().publish(
(symbol_short!("sep10"), symbol_short!("issued")),
(account, nonce),
);

Ok(())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

SEP-10 challenge isn't bound to the issued account.

issue_sep10_challenge accepts an account argument but only emits it in the event — it stores nothing more than nonce → expiry in SEP10_CHALLENGES. verify_sep10_challenge later builds the signed payload from whatever account the caller supplies (line 1539–1543). That means a single admin-signed (account, nonce, expiry) tuple is the only thing that prevents a different address from being marked verified, and a leaked/observed signature can be replayed against the contract from any sender (the function has no require_auth for account).

Bind the nonce to the intended account in storage and require auth for the verifier:

🛡️ Suggested binding
-        let mut challenges: Map<BytesN<32>, u64> = env
+        // Store (account, expiry) per nonce so verify can re-check the binding.
+        let mut challenges: Map<BytesN<32>, (Address, u64)> = env
             .storage()
             .instance()
             .get(&SEP10_CHALLENGES)
             .unwrap_or(Map::new(&env));

         // Reject if nonce already exists (replay protection)
         if challenges.get(nonce.clone()).is_some() {
             return Err(ContractError::ChallengeAlreadyUsed);
         }

-        challenges.set(nonce.clone(), expiry);
+        challenges.set(nonce.clone(), (account.clone(), expiry));
         env.storage().instance().set(&SEP10_CHALLENGES, &challenges);

…and in verify_sep10_challenge:

-        let expiry = challenges
+        let (issued_to, expiry) = challenges
             .get(nonce.clone())
             .ok_or(ContractError::ChallengeAlreadyUsed)?;
+        if issued_to != account {
+            return Err(ContractError::Unauthorized);
+        }
+        account.require_auth();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/game_contract/src/lib.rs` around lines 1454 - 1496, Store the
intended account alongside the nonce when issuing SEP-10 challenges and enforce
that the verifier is the same account: modify issue_sep10_challenge to save a
mapping from nonce (BytesN<32>) to a tuple/value containing (account: Address,
expiry: u64) instead of only expiry in SEP10_CHALLENGES, and update
verify_sep10_challenge to load that stored tuple and reject if the supplied
account does not match the stored account; also call account.require_auth() (or
otherwise require auth on the verifying account) in verify_sep10_challenge to
ensure only the intended account can complete verification.

Comment on lines +1511 to +1524
let mut challenges: Map<BytesN<32>, u64> = env
.storage()
.instance()
.get(&SEP10_CHALLENGES)
.unwrap_or(Map::new(&env));

let expiry = challenges
.get(nonce.clone())
.ok_or(ContractError::ChallengeAlreadyUsed)?;

let current_ledger = env.ledger().sequence() as u64;
if current_ledger > expiry {
return Err(ContractError::ChallengeExpired);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Wrong error variant when the nonce was never issued.

A missing nonce in SEP10_CHALLENGES is not "already used" — it could equally be a typo or a fabricated nonce. Returning ChallengeAlreadyUsed here is semantically incorrect and will confuse callers parsing error codes (and also shadows the genuine replay case which is indistinguishable). Add a dedicated error such as ChallengeNotFound (or reuse NotVerified/Unauthorized) and reserve ChallengeAlreadyUsed for the actual replay path.

🐛 Suggested change
         let expiry = challenges
             .get(nonce.clone())
-            .ok_or(ContractError::ChallengeAlreadyUsed)?;
+            .ok_or(ContractError::Unauthorized)?; // or a new ChallengeNotFound variant
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/game_contract/src/lib.rs` around lines 1511 - 1524, The code
incorrectly maps a missing nonce lookup to ContractError::ChallengeAlreadyUsed;
change this to a distinct error (e.g., ContractError::ChallengeNotFound or reuse
an appropriate existing variant like ContractError::Unauthorized) so a
non-existent nonce is not treated as a replay. Update the get() -> ok_or(...)
call on the Map<BytesN<32>, u64> named challenges (the lookup using
nonce.clone()) to return the new/appropriate error variant, and add the new enum
variant to ContractError if needed, keeping ContractError::ChallengeAlreadyUsed
reserved for the actual replay path where a nonce was previously consumed.

Comment on lines +1605 to +1630
pub fn configure_multisig(
env: Env,
admin: Address,
signers: Vec<Address>,
threshold: u32,
) -> Result<(), ContractError> {
let current_admin: Address = env
.storage()
.instance()
.get(&CONTRACT_ADMIN)
.expect("Not initialized");
current_admin.require_auth();
if admin != current_admin {
return Err(ContractError::Unauthorized);
}

let n = signers.len();
if threshold == 0 || threshold > n {
return Err(ContractError::InvalidThreshold);
}

env.storage().instance().set(&MULTISIG_SIGNERS, &signers);
env.storage().instance().set(&MULTISIG_THRESHOLD, &threshold);

Ok(())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

configure_multisig doesn't validate signer uniqueness.

Duplicate addresses in signers are accepted and counted by signers.len(), but the approval map (FEE_PROPOSAL_APPROVALS) is keyed by Address and dedupes implicitly. Net effect:

  • A threshold of n (= signers.len() with duplicates) becomes unreachable, so legitimate proposals can never execute.
  • A misconfiguration like [s1, s1, s2] with threshold 2 can be satisfied by s1 alone (auto-approval as proposer) — exactly the situation multisig is supposed to prevent.

Reject duplicates at configure time:

🛡️ Suggested validation
         let n = signers.len();
         if threshold == 0 || threshold > n {
             return Err(ContractError::InvalidThreshold);
         }
+        // Reject duplicate signers
+        for i in 0..n {
+            for j in (i + 1)..n {
+                if signers.get(i).unwrap() == signers.get(j).unwrap() {
+                    return Err(ContractError::InvalidThreshold);
+                }
+            }
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub fn configure_multisig(
env: Env,
admin: Address,
signers: Vec<Address>,
threshold: u32,
) -> Result<(), ContractError> {
let current_admin: Address = env
.storage()
.instance()
.get(&CONTRACT_ADMIN)
.expect("Not initialized");
current_admin.require_auth();
if admin != current_admin {
return Err(ContractError::Unauthorized);
}
let n = signers.len();
if threshold == 0 || threshold > n {
return Err(ContractError::InvalidThreshold);
}
env.storage().instance().set(&MULTISIG_SIGNERS, &signers);
env.storage().instance().set(&MULTISIG_THRESHOLD, &threshold);
Ok(())
}
pub fn configure_multisig(
env: Env,
admin: Address,
signers: Vec<Address>,
threshold: u32,
) -> Result<(), ContractError> {
let current_admin: Address = env
.storage()
.instance()
.get(&CONTRACT_ADMIN)
.expect("Not initialized");
current_admin.require_auth();
if admin != current_admin {
return Err(ContractError::Unauthorized);
}
let n = signers.len();
if threshold == 0 || threshold > n {
return Err(ContractError::InvalidThreshold);
}
// Reject duplicate signers
for i in 0..n {
for j in (i + 1)..n {
if signers.get(i).unwrap() == signers.get(j).unwrap() {
return Err(ContractError::InvalidThreshold);
}
}
}
env.storage().instance().set(&MULTISIG_SIGNERS, &signers);
env.storage().instance().set(&MULTISIG_THRESHOLD, &threshold);
Ok(())
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/game_contract/src/lib.rs` around lines 1605 - 1630, The
configure_multisig function accepts duplicated addresses in signers which breaks
approval semantics (FEE_PROPOSAL_APPROVALS is keyed by Address); fix by
validating uniqueness before persisting: build a temporary set from signers and
if set.len() != signers.len() return an appropriate ContractError (e.g.,
DuplicateSigner or add InvalidSigners), perform this check prior to setting
MULTISIG_SIGNERS and MULTISIG_THRESHOLD, and only persist after the uniqueness
and threshold validations pass.

Comment on lines +1636 to +1683
pub fn propose_fee_change(
env: Env,
proposer: Address,
new_fee_bips: u32,
new_treasury_address: Address,
) -> Result<(), ContractError> {
if new_fee_bips > 1000 {
return Err(ContractError::InvalidAmount);
}

let signers: Vec<Address> = env
.storage()
.instance()
.get(&MULTISIG_SIGNERS)
.ok_or(ContractError::Unauthorized)?;

// Verify proposer is a signer
if !signers.contains(&proposer) {
return Err(ContractError::NotASigner);
}

proposer.require_auth();

let proposal = FeeProposal {
new_fee_bips,
new_treasury_address,
proposed_at: env.ledger().sequence() as u64,
proposer: proposer.clone(),
};

env.storage()
.instance()
.set(&PENDING_FEE_PROPOSAL, &proposal);

// Reset approvals and auto-approve for proposer
let mut approvals: Map<Address, bool> = Map::new(&env);
approvals.set(proposer.clone(), true);
env.storage()
.instance()
.set(&FEE_PROPOSAL_APPROVALS, &approvals);

env.events().publish(
(symbol_short!("multisig"), symbol_short!("proposed")),
proposer,
);

Ok(())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

propose_fee_change silently overwrites an in-flight proposal — any signer can grief the queue.

A single signer (any one of N) can wipe an existing near-approved proposal by submitting a new one, resetting approval counts. The doc-comment ("Replaces any existing pending proposal") acknowledges this, but it weakens the M-of-N guarantee since one rogue or compromised signer can indefinitely block fee changes.

Consider rejecting if there's already a pending proposal and requiring an explicit cancel_fee_proposal first (which today is also any-signer, so consider tightening cancel to "only proposer or threshold-of-signers" as a follow-up).

🛡️ Suggested guard
     if new_fee_bips > 1000 {
         return Err(ContractError::InvalidAmount);
     }
+    if env.storage().instance().has(&PENDING_FEE_PROPOSAL) {
+        return Err(ContractError::AlreadyApproved); // or a dedicated ProposalPending variant
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/game_contract/src/lib.rs` around lines 1636 - 1683,
propose_fee_change currently overwrites any existing PENDING_FEE_PROPOSAL and
resets FEE_PROPOSAL_APPROVALS, enabling a single signer to grief in-flight
proposals; change propose_fee_change to first check
env.storage().instance().get(&PENDING_FEE_PROPOSAL) and return an error (e.g.,
ContractError::PendingProposalExists) if a proposal is present, so new proposals
are rejected until the existing one is explicitly cancelled; keep the existing
proposer.require_auth() and leave cancel_fee_proposal to handle removal
(follow-up: tighten cancel rights), and update the event/behavior accordingly.

Comment on lines +18 to +29
export interface MatchmakingResponse {
status: string;
matchId?: string;
requestId: string;
}

export interface QueueStatus {
requestId: string;
position: number;
estimatedWaitSeconds: number;
matchType: MatchType;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm backend serde does not rename to camelCase for these models.
rg -nP -C2 'rename_all\s*=\s*"camelCase"' --type=rust
rg -nP -C5 'struct\s+(MatchmakingResponse|QueueStatus|JoinQueueRequest|AcceptInviteRequest)\b' --type=rust

Repository: NOVUS-X/XLMate

Length of output: 2479


🏁 Script executed:

#!/bin/bash
# Check Duration serialization in the backend
rg -n "estimated_wait_time" backend/modules/matchmaking/ -A 2 -B 2

# Check if there's any custom serde configuration
rg -n "#\[serde" backend/modules/matchmaking/ --type=rust

# Look at the frontend matchmakingService.ts file
head -100 frontend/lib/matchmakingService.ts

# Check if there's a useMatchmakingQueue hook mentioned in the review
fd -e ts -e tsx | xargs rg -l "useMatchmakingQueue" 2>/dev/null | head -5

Repository: NOVUS-X/XLMate

Length of output: 3996


🏁 Script executed:

#!/bin/bash
# Check the useMatchmakingQueue hook
cat -n frontend/hook/useMatchmakingQueue.ts | head -150

# Also search for access patterns of matchId and requestId
rg -n "matchId|requestId|match_id|request_id" frontend/hook/useMatchmakingQueue.ts

Repository: NOVUS-X/XLMate

Length of output: 5144


🏁 Script executed:

#!/bin/bash
# Check how Rust serde serializes Duration by default
rg -n "use std::time::Duration\|use chrono::" backend/modules/matchmaking/models.rs

# Look for any Duration-specific serde configuration
rg -n "Duration" backend/modules/matchmaking/models.rs -B 2 -A 2

Repository: NOVUS-X/XLMate

Length of output: 347


Type field casing and serialization format do not match backend JSON.

Backend structs in backend/modules/matchmaking/models.rs use snake_case field names without custom serde renaming: match_id, request_id, estimated_wait_time, match_type. The frontend TypeScript types declare camelCase equivalents, and the res.json() as Promise<X> cast does not perform any runtime mapping.

This breaks the hook at two critical points:

  • Line 89 (if (response.matchId)) will always be false — backend sends match_id, so matchId is undefined
  • Line 96–97 (setRequestId(response.requestId)) stores undefined, blocking all polling logic (line 59–60)

Additionally, estimated_wait_time is a std::time::Duration, which serde serializes as { secs: number, nanos: number }, not a single number. The frontend QueueStatus.estimatedWaitSeconds: number cannot consume this format.

The request body correctly uses snake_case mapping (showing the pattern is known), but response handling needs the same treatment.

🔧 Solution: Add explicit mapping in each function

After calling res.json(), transform the response to match the frontend types:

// joinQueue
const raw = await res.json();
return {
  status: raw.status,
  matchId: raw.match_id ?? undefined,
  requestId: raw.request_id,
};

// getQueueStatus
const raw = await res.json();
return {
  requestId: raw.request_id,
  position: raw.position,
  estimatedWaitSeconds: raw.estimated_wait_time.secs,
  matchType: raw.match_type,
};

// acceptPrivateInvite
const raw = await res.json();
return {
  status: raw.status,
  matchId: raw.match_id ?? undefined,
  requestId: raw.request_id,
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/matchmakingService.ts` around lines 18 - 29, The backend returns
snake_case JSON and Duration objects, so update the response handling in
joinQueue, getQueueStatus, and acceptPrivateInvite to parse the raw JSON (const
raw = await res.json()) and map fields to the TS interfaces: map raw.match_id ->
matchId (use ?? undefined), raw.request_id -> requestId, raw.match_type ->
matchType, raw.position -> position, and convert raw.estimated_wait_time to
estimatedWaitSeconds using raw.estimated_wait_time.secs; keep raw.status ->
status. Ensure these transformations replace the current direct casts
(res.json() as Promise<...>) so response.matchId and response.requestId are
populated and polling works.

Comment on lines +38 to +58
const res = await fetch(`${BASE_URL}/matchmaking/join`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
player: {
wallet_address: request.walletAddress,
elo: request.elo,
join_time: new Date().toISOString(),
},
match_type: request.matchType,
invite_address: request.inviteAddress ?? null,
max_elo_diff: request.maxEloDiff ?? null,
}),
});

if (!res.ok) {
throw new Error(`Failed to join queue: ${res.statusText}`);
}

return res.json() as Promise<MatchmakingResponse>;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Request body shape does not match JoinQueueRequest.

Per backend/modules/matchmaking/routes.rs (lines 9–16), JoinQueueRequest is flat with wallet_address, elo, match_type, invite_address, max_elo_diff — there is no player wrapper and join_time is set server-side via Utc::now() (lines 51–55). Posting the wrapped body here will fail JSON deserialization and the call will return 503, so the queue can never be joined.

🔧 Proposed fix
   const res = await fetch(`${BASE_URL}/matchmaking/join`, {
     method: "POST",
     headers: { "Content-Type": "application/json" },
     body: JSON.stringify({
-      player: {
-        wallet_address: request.walletAddress,
-        elo: request.elo,
-        join_time: new Date().toISOString(),
-      },
+      wallet_address: request.walletAddress,
+      elo: request.elo,
       match_type: request.matchType,
       invite_address: request.inviteAddress ?? null,
       max_elo_diff: request.maxEloDiff ?? null,
     }),
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const res = await fetch(`${BASE_URL}/matchmaking/join`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
player: {
wallet_address: request.walletAddress,
elo: request.elo,
join_time: new Date().toISOString(),
},
match_type: request.matchType,
invite_address: request.inviteAddress ?? null,
max_elo_diff: request.maxEloDiff ?? null,
}),
});
if (!res.ok) {
throw new Error(`Failed to join queue: ${res.statusText}`);
}
return res.json() as Promise<MatchmakingResponse>;
}
const res = await fetch(`${BASE_URL}/matchmaking/join`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
wallet_address: request.walletAddress,
elo: request.elo,
match_type: request.matchType,
invite_address: request.inviteAddress ?? null,
max_elo_diff: request.maxEloDiff ?? null,
}),
});
if (!res.ok) {
throw new Error(`Failed to join queue: ${res.statusText}`);
}
return res.json() as Promise<MatchmakingResponse>;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/matchmakingService.ts` around lines 38 - 58, The POST body sent
by the join queue function currently nests player and includes join_time which
doesn't match the backend JoinQueueRequest shape; update the fetch call in the
join function (matchmakingService.ts) to send a flat JSON object with keys
wallet_address: request.walletAddress, elo: request.elo, match_type:
request.matchType, invite_address: request.inviteAddress ?? null, and
max_elo_diff: request.maxEloDiff ?? null (remove the player wrapper and omit
join_time so the server-side Utc::now() is used).

Comment on lines +61 to +75
export async function getQueueStatus(
requestId: string,
): Promise<QueueStatus | null> {
const res = await fetch(
`${BASE_URL}/matchmaking/status/${encodeURIComponent(requestId)}`,
);

if (res.status === 404) return null;

if (!res.ok) {
throw new Error(`Failed to get queue status: ${res.statusText}`);
}

return res.json() as Promise<QueueStatus>;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Status endpoint returns StatusResponse, not bare QueueStatus.

StatusResponse is { status: String, queue_status: Option<QueueStatus> } (routes.rs lines 31–34). Returning the raw JSON typed as QueueStatus will leave every consumer reading position/estimated_wait_time as undefined. Also, when queue_status is None/null, the hook treats a successful response as "still searching" instead of "matched/expired".

🔧 Proposed fix
 export async function getQueueStatus(
   requestId: string,
 ): Promise<QueueStatus | null> {
   const res = await fetch(
     `${BASE_URL}/matchmaking/status/${encodeURIComponent(requestId)}`,
   );

   if (res.status === 404) return null;

   if (!res.ok) {
     throw new Error(`Failed to get queue status: ${res.statusText}`);
   }

-  return res.json() as Promise<QueueStatus>;
+  const body = (await res.json()) as { status: string; queue_status: QueueStatus | null };
+  return body.queue_status;
 }

(Field-name remap of the inner queue_status object is still needed — see the type-casing comment above.)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/matchmakingService.ts` around lines 61 - 75, The getQueueStatus
function currently treats the response as a bare QueueStatus; instead fetch and
parse the JSON as the StatusResponse shape ({ status: string, queue_status:
QueueStatus | null }), then if response.status === 404 return null, else if
res.ok read the StatusResponse and return the inner queue_status (or null) —
also remap the snake_case fields of that inner object to the frontend
QueueStatus shape (e.g. queue_status.position -> position,
queue_status.estimated_wait_time -> estimatedWaitTime) so consumers receive the
correct typed object; update getQueueStatus to return Promise<QueueStatus |
null> accordingly and preserve existing error handling for non-ok responses.

Comment on lines +78 to +87
export async function leaveQueue(requestId: string): Promise<void> {
const res = await fetch(
`${BASE_URL}/matchmaking/leave/${encodeURIComponent(requestId)}`,
{ method: "DELETE" },
);

if (!res.ok && res.status !== 404) {
throw new Error(`Failed to leave queue: ${res.statusText}`);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm there is no `/leave` route and that `/cancel` is the canonical cancel endpoint.
rg -nP -C2 'matchmaking/(leave|cancel)' --type=rust
ast-grep --pattern 'web::scope("/matchmaking") $$$'

Repository: NOVUS-X/XLMate

Length of output: 231


🏁 Script executed:

# Get the full routes.rs file content around the matchmaking scope
cat backend/modules/matchmaking/routes.rs

Repository: NOVUS-X/XLMate

Length of output: 5015


🏁 Script executed:

# Search for leaveQueue usage in the frontend
rg -n "leaveQueue" frontend/ -t typescript -t javascript

Repository: NOVUS-X/XLMate

Length of output: 93


🏁 Script executed:

# Search for leaveQueue usage in the frontend without file type
rg -n "leaveQueue" frontend/

Repository: NOVUS-X/XLMate

Length of output: 290


🏁 Script executed:

# Get the context around the leaveQueue call
sed -n '100,125p' frontend/hook/useMatchmakingQueue.ts

Repository: NOVUS-X/XLMate

Length of output: 606


leaveQueue calls a non-existent route; users cannot leave the queue.

The backend exposes only /join, /status/{id}, /cancel, /accept-invite, and /match/{id}. There is no DELETE /matchmaking/leave/:id. The correct endpoint is POST /matchmaking/cancel expecting { request_id: Uuid }. Every call to leaveQueue receives a 404, which the res.status !== 404 guard silently suppresses, so queue entries are never removed and accumulate in Redis.

🔧 Fix
 export async function leaveQueue(requestId: string): Promise<void> {
-  const res = await fetch(
-    `${BASE_URL}/matchmaking/leave/${encodeURIComponent(requestId)}`,
-    { method: "DELETE" },
-  );
-
-  if (!res.ok && res.status !== 404) {
+  const res = await fetch(`${BASE_URL}/matchmaking/cancel`, {
+    method: "POST",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify({ request_id: requestId }),
+  });
+
+  if (!res.ok && res.status !== 404) {
     throw new Error(`Failed to leave queue: ${res.statusText}`);
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function leaveQueue(requestId: string): Promise<void> {
const res = await fetch(
`${BASE_URL}/matchmaking/leave/${encodeURIComponent(requestId)}`,
{ method: "DELETE" },
);
if (!res.ok && res.status !== 404) {
throw new Error(`Failed to leave queue: ${res.statusText}`);
}
}
export async function leaveQueue(requestId: string): Promise<void> {
const res = await fetch(`${BASE_URL}/matchmaking/cancel`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_id: requestId }),
});
if (!res.ok && res.status !== 404) {
throw new Error(`Failed to leave queue: ${res.statusText}`);
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/matchmakingService.ts` around lines 78 - 87, The leaveQueue
function currently calls a non-existent DELETE /matchmaking/leave/:id and
suppresses 404s; change leaveQueue to POST to /matchmaking/cancel with a JSON
body { request_id: requestId }, set the Content-Type: application/json header,
and remove the special-case 404 suppression so that non-OK responses throw an
Error (include res.statusText). Locate the function leaveQueue in
matchmakingService.ts and replace the fetch URL/method/body/headers accordingly
and ensure errors are propagated.

Comment on lines +90 to +115
export async function acceptPrivateInvite(
inviterRequestId: string,
walletAddress: string,
elo: number,
): Promise<MatchmakingResponse | null> {
const res = await fetch(`${BASE_URL}/matchmaking/accept-invite`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
inviter_request_id: inviterRequestId,
accepting_player: {
wallet_address: walletAddress,
elo,
join_time: new Date().toISOString(),
},
}),
});

if (res.status === 404) return null;

if (!res.ok) {
throw new Error(`Failed to accept invite: ${res.statusText}`);
}

return res.json() as Promise<MatchmakingResponse>;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Request body shape does not match AcceptInviteRequest.

AcceptInviteRequest is flat: wallet_address, elo, inviter_request_id (routes.rs lines 19–23); join_time is filled server-side. The accepting_player wrapper here will fail to deserialize and the endpoint will return 503, so private invites can never be accepted.

🔧 Proposed fix
   const res = await fetch(`${BASE_URL}/matchmaking/accept-invite`, {
     method: "POST",
     headers: { "Content-Type": "application/json" },
     body: JSON.stringify({
       inviter_request_id: inviterRequestId,
-      accepting_player: {
-        wallet_address: walletAddress,
-        elo,
-        join_time: new Date().toISOString(),
-      },
+      wallet_address: walletAddress,
+      elo,
     }),
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function acceptPrivateInvite(
inviterRequestId: string,
walletAddress: string,
elo: number,
): Promise<MatchmakingResponse | null> {
const res = await fetch(`${BASE_URL}/matchmaking/accept-invite`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
inviter_request_id: inviterRequestId,
accepting_player: {
wallet_address: walletAddress,
elo,
join_time: new Date().toISOString(),
},
}),
});
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(`Failed to accept invite: ${res.statusText}`);
}
return res.json() as Promise<MatchmakingResponse>;
}
export async function acceptPrivateInvite(
inviterRequestId: string,
walletAddress: string,
elo: number,
): Promise<MatchmakingResponse | null> {
const res = await fetch(`${BASE_URL}/matchmaking/accept-invite`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
inviter_request_id: inviterRequestId,
wallet_address: walletAddress,
elo,
}),
});
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(`Failed to accept invite: ${res.statusText}`);
}
return res.json() as Promise<MatchmakingResponse>;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/matchmakingService.ts` around lines 90 - 115, The request body
sent by acceptPrivateInvite does not match the server's AcceptInviteRequest (it
wraps fields in accepting_player and sends join_time), causing deserialization
failures; update acceptPrivateInvite to send a flat JSON object with keys
inviter_request_id, wallet_address, and elo (omit join_time and the
accepting_player wrapper) so the payload matches the server's expected
AcceptInviteRequest shape before calling POST /matchmaking/accept-invite.

@gabito1451 gabito1451 merged commit 3f8149f into OpenKnight-Foundation:main Apr 27, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants