Skip to content

fix: prevent swap autoclaim race between service worker and UI#558

Open
Kukks wants to merge 2 commits intomasterfrom
fix/swap-autoclaim-race
Open

fix: prevent swap autoclaim race between service worker and UI#558
Kukks wants to merge 2 commits intomasterfrom
fix/swap-autoclaim-race

Conversation

@Kukks
Copy link
Copy Markdown
Contributor

@Kukks Kukks commented Apr 22, 2026

Summary

  • Queries SwapManager.isProcessing(swapId) on mount and after each status update to reactively track whether the service worker is already claiming/refunding the swap
  • Checks isProcessing again at button-press time as a final guard before calling claim/refund
  • Button shows "Claiming..." and disables when the SwapManager is processing
  • Fixes Windows lint script (single-quoted globs don't expand on Windows)

Problem

When the service worker's SwapManager auto-claims a swap (via executeAutonomousAction), the user can simultaneously tap "Complete swap" on the swap detail screen. Both paths call claimVHTLC concurrently, causing a double-claim race.

The SwapManager already tracks in-progress operations in its swapsInProgress Set and exposes isProcessing(swapId) via the service worker message protocol. This PR queries that source of truth from the UI rather than building a parallel tracking system in the main thread.

Test plan

  • Create a reverse swap (Lightning → Arkade) and verify the swap detail button shows "Claiming..." while SwapManager auto-claims
  • Tap "Complete swap" while auto-claim is in progress — button should be disabled, handler returns early
  • Verify the button re-enables after processing completes (status update triggers re-check)
  • Verify refund flows still work normally
  • Verify swap detail screen shows correct initial state when opened during an in-progress claim

Summary by CodeRabbit

  • Bug Fixes

    • Prevented duplicate claim/refund operations by validating processing state before user actions.
    • Improved claim flow UX: button shows persistent "Claiming…" and is disabled while processing.
  • Chores

    • Adjusted lint script quoting/escaping in project scripts for consistent command handling.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 22, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cff8e42d-e2c7-48c7-b64e-42962548244a

📥 Commits

Reviewing files that changed from the base of the PR and between 5995fc6 and 4a6ca3e.

📒 Files selected for processing (1)
  • package.json
✅ Files skipped from review due to trivial changes (1)
  • package.json

Walkthrough

Updates: change lint script quoting in package.json; add a swapManagerProcessing flag in the Boltz Swap UI to reflect SwapManager.isProcessing(), disable the primary action while processing, adjust button label to "Claiming...", and have the button handler early-exit when SwapManager reports processing.

Changes

Cohort / File(s) Summary
Build Configuration
package.json
Changed lint and lint:fix script glob quoting from single quotes to double quotes (no behavioral change).
Swap Component Enhancement
src/screens/Apps/Boltz/Swap.tsx
Added swapManagerProcessing state sourced from swapManager.isProcessing() and updated real-time updates to refresh it. Primary action button shows "Claiming..." and is disabled when processing. buttonHandler now checks isProcessing and returns early to prevent duplicate claim/refund flows.

Sequence Diagram(s)

sequenceDiagram
  participant User as User
  participant UI as Swap UI
  participant Manager as SwapManager
  participant Backend as Backend/Blockchain

  User->>UI: Click Claim/Refund button
  UI->>Manager: query isProcessing(swapId)
  alt already processing
    Manager-->>UI: true
    UI-->>User: button disabled / no-op (show "Claiming...")
  else not processing
    Manager-->>UI: false
    UI->>Manager: initiate claim/refund(swapId)
    Manager->>Backend: submit transaction
    Backend-->>Manager: tx result
    Manager-->>UI: update swap status
    UI-->>User: update UI (success/failure)
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • add buttons to swap page #186: Modifies the same src/screens/Apps/Boltz/Swap.tsx to manage swap processing state and guard the claim/refund button flow.

Suggested reviewers

  • louisinger
  • pietro909
🚥 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 title accurately describes the main change: preventing race conditions between service worker and UI during swap claiming, which aligns with the primary objective and the significant changes in Swap.tsx.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/swap-autoclaim-race

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.

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented Apr 22, 2026

Deploying wallet-bitcoin with  Cloudflare Pages  Cloudflare Pages

Latest commit: 4a6ca3e
Status: ✅  Deploy successful!
Preview URL: https://67c741cf.wallet-bitcoin.pages.dev
Branch Preview URL: https://fix-swap-autoclaim-race.wallet-bitcoin.pages.dev

View logs

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented Apr 22, 2026

Deploying wallet-mutinynet with  Cloudflare Pages  Cloudflare Pages

Latest commit: 4a6ca3e
Status: ✅  Deploy successful!
Preview URL: https://83f2f75b.arkade-wallet.pages.dev
Branch Preview URL: https://fix-swap-autoclaim-race.arkade-wallet.pages.dev

View logs

Copy link
Copy Markdown

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

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

Code Review — fix: prevent swap autoclaim race between service worker and UI

Good fix. The core approach — claimingRef + claimingSwapIds state with mark/unmark in finally — is the right pattern for this race. Three issues to address:


🔴 Bug: waitAndClaim / waitAndClaimArk missing idempotency guard

src/providers/swaps.tsx:261-275 (diff lines)

claimVHTLC, claimArk, and claimBtc all check claimingRef.current.has(swap.id) and bail early if already claiming. But waitAndClaim and waitAndClaimArk do NOT have this guard — they unconditionally markClaiming and proceed.

This matters because waitAndClaim is the long-lived poller. If React's useEffect in QrCode.tsx re-fires (StrictMode, dep changes, component remount), two concurrent arkadeSwaps.waitAndClaim() calls will race on the same swap — exactly the bug this PR is fixing.

// current (no guard):
const waitAndClaim = async (swap: BoltzReverseSwap): Promise<void> => {
    if (!arkadeSwaps) return
    markClaiming(swap.id)
    ...

// should be:
const waitAndClaim = async (swap: BoltzReverseSwap): Promise<void> => {
    if (!arkadeSwaps || claimingRef.current.has(swap.id)) return
    markClaiming(swap.id)
    ...

Same for waitAndClaimArk.


🟡 Incomplete: lint:fix script still uses single quotes

package.json:43

lint was fixed to double quotes for Windows, but lint:fix was not:

"lint:fix": "eslint 'src/**/*.{ts,tsx,js,jsx}' --fix",

Should be \"src/**/*.{ts,tsx,js,jsx}\" for consistency.


🟡 Test mocks not updated

src/test/screens/mocks.ts:87-114

mockSwapsContextValue is missing the three new context fields: waitAndClaim, waitAndClaimArk, and claimingSwapIds. This will cause TypeScript errors if any test references the full context type, and will break if tests for QrCode.tsx or Swap.tsx are added that exercise the new behavior.

Add:

waitAndClaim: async () => {},
waitAndClaimArk: async () => {},
claimingSwapIds: new Set<string>(),

Minor observations (no action needed)

  • The useEffect in QrCode.tsx (line ~208) has no cleanup for in-flight waitAndClaim/waitAndClaimArk promises. If the user changes amount, old pollers keep running. Pre-existing issue, not introduced here, but the idempotency guard above would at least prevent the new poller from double-claiming.
  • Context value object is recreated every render (no useMemo). Also pre-existing.

Verdict: Fix the missing idempotency guard on waitAndClaim/waitAndClaimArk — that's the same race this PR exists to solve. The rest is minor cleanup.

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented Apr 22, 2026

Deploying tmp-boltz-upstream-mainnet-arkade-wallet with  Cloudflare Pages  Cloudflare Pages

Latest commit: 4a6ca3e
Status: ✅  Deploy successful!
Preview URL: https://4c8f3654.tmp-boltz-upstream-mainnet-arkade-wallet.pages.dev
Branch Preview URL: https://fix-swap-autoclaim-race.tmp-boltz-upstream-mainnet-arkade-wallet.pages.dev

View logs

Use SwapManager.isProcessing() to check the service worker's actual
swapsInProgress state before allowing a manual claim from the UI.
The button reactively shows "Claiming..." and disables when the
SwapManager is already processing the swap (auto-claim, autonomous
action, etc).
@Kukks Kukks force-pushed the fix/swap-autoclaim-race branch from 3467fdd to 5995fc6 Compare April 23, 2026 08:27
Copy link
Copy Markdown

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

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

Code Review — fix: prevent swap autoclaim race (re-review after force-push)

The approach changed completely from the previous version (client-side claimingRef Set in SwapsProvider) to querying the service worker's SwapManager.swapsInProgress Set via isProcessing(). This is architecturally better — single source of truth in the service worker.

Overall: solid improvement, minor issues only. Approving.


🟡 TOCTOU race in buttonHandler (low practical risk)

src/screens/Apps/Boltz/Swap.tsx:164-167

if (swapManager) {
  const alreadyProcessing = await swapManager.isProcessing(swapInfo.id)
  if (alreadyProcessing) return
}
// ... gap where service worker can start processing ...
await claimVHTLC(swapInfo)

isProcessing() crosses the service worker boundary (async message), then there's a window before claimVHTLC executes where the SwapManager could start its own auto-claim via executeAutonomousAction. Both would call claimVHTLC concurrently.

Practical risk is low because:

  1. The button is already reactively disabled via swapManagerProcessing state — user needs precise timing
  2. Even if both fire, the on-chain VHTLC claim is atomic — double-claim yields a "VHTLC is already spent" error, not fund loss
  3. The error is caught and displayed cleanly (line 192-194)

No action required, just documenting the limitation. A proper fix would be an atomic tryStartProcessing(swapId) method on SwapManager that checks + locks in one call.


🟡 lint:fix script still uses single quotes

package.json:46

Same issue I flagged in the previous review — lint was fixed to double quotes for Windows but lint:fix was not:

"lint:fix": "eslint 'src/**/*.{ts,tsx,js,jsx}' --fix",

Should be:

"lint:fix": "eslint \"src/**/*.{ts,tsx,js,jsx}\" --fix",

✅ What looks good

  • Querying SwapManager.swapsInProgress (the service worker's canonical lock) instead of maintaining a parallel client-side Set — eliminates state drift between UI and worker
  • cancelled flag in useEffect cleanup prevents stale state updates
  • Button reactively shows "Claiming..." and disables during auto-claim
  • Swap update subscription re-checks isProcessing on each status change — keeps UI in sync
  • No protocol-critical changes — this is purely UI-layer race prevention; the actual claim logic in arkade-swaps.ts and swap-manager.ts is untouched

LGTM with the lint:fix nit. ✅

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@package.json`:
- Around line 45-46: The "lint:fix" npm script still uses single quotes which
breaks on Windows shells; update the "lint:fix" script (the "lint:fix" entry in
package.json) to use the same quoting as "lint" (use double quotes around the
glob or remove quotes entirely) so the eslint command eslint
"src/**/*.{ts,tsx,js,jsx}" --fix runs cross-platform; ensure you edit the
"lint:fix" value to match the Windows-safe form used by "lint".

In `@src/screens/Apps/Boltz/Swap.tsx`:
- Around line 159-167: The guard that calls swapManager.isProcessing should only
run for claim flows (when isClaimable is true) and when it detects
alreadyProcessing it must update swapManagerProcessing to keep the UI in sync
before returning; update the buttonHandler (and the analogous handler around the
isClaimable/refund branch at the other spot) to: 1) only call
swapManager.isProcessing when isClaimable is true, and 2) if alreadyProcessing
is true set swapManagerProcessing (the state/flag used to disable the UI)
appropriately (e.g., set to true) before returning so the button label/disabled
state reflects the ongoing manager processing.
- Around line 63-72: The two calls to swapManager.isProcessing (inside the
initial check and inside the subscribeToSwapUpdates callback) lack error
handling; extract a helper like safeCheckProcessing(swapId) that calls
swapManager.isProcessing(swapId).then(p => { if (!cancelled)
setSwapManagerProcessing(p) }).catch(err => { console.error('isProcessing
failed', err, swapId); if (!cancelled) setSwapManagerProcessing(false) }) and
replace both direct calls with safeCheckProcessing(swapInfo.id) so failures are
logged and swapManagerProcessing is reset to a safe default.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f84d8766-2905-4216-905d-d63fb140d4ac

📥 Commits

Reviewing files that changed from the base of the PR and between 6f08911 and 5995fc6.

📒 Files selected for processing (2)
  • package.json
  • src/screens/Apps/Boltz/Swap.tsx

Comment thread package.json Outdated
Comment on lines +63 to +72
swapManager.isProcessing(swapInfo.id).then((p) => {
if (!cancelled) setSwapManagerProcessing(p)
})

swapManager
.subscribeToSwapUpdates(swapInfo.id, (updatedSwap) => {
setSwapInfo(updatedSwap)
swapManager.isProcessing(updatedSwap.id).then((p) => {
if (!cancelled) setSwapManagerProcessing(p)
})
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

🧩 Analysis chain

🏁 Script executed:

# First, let's check the actual file and its imports
head -50 src/screens/Apps/Boltz/Swap.tsx

Repository: arkade-os/wallet

Length of output: 2284


🏁 Script executed:

# Read the specific lines mentioned in the review (63-72) and surrounding context
sed -n '50,85p' src/screens/Apps/Boltz/Swap.tsx

Repository: arkade-os/wallet

Length of output: 1106


🏁 Script executed:

# Check all imports in the file to verify consoleError is available
grep -n "^import" src/screens/Apps/Boltz/Swap.tsx | head -20

Repository: arkade-os/wallet

Length of output: 1187


🏁 Script executed:

# Search for other similar unhandled promise patterns in this file
rg -n "\.then\(" src/screens/Apps/Boltz/Swap.tsx | head -20

Repository: arkade-os/wallet

Length of output: 218


Handle failed processing-state checks with proper error containment.

Both isProcessing() calls (lines 63–65 and 70–72) lack .catch() handlers, which leaves unhandled promise rejections and stale swapManagerProcessing state if the swap-manager check fails. This can freeze the claim/refund button UI. Extract a helper function to centralize error handling, log the failure, and reset state to a safe default.

🛡️ Proposed fix
-    swapManager.isProcessing(swapInfo.id).then((p) => {
-      if (!cancelled) setSwapManagerProcessing(p)
-    })
+    const refreshSwapManagerProcessing = (id: string) => {
+      swapManager
+        .isProcessing(id)
+        .then((p) => {
+          if (!cancelled) setSwapManagerProcessing(p)
+        })
+        .catch((error) => {
+          if (!cancelled) {
+            consoleError(error, `Error checking swap processing state ${id}`)
+            setSwapManagerProcessing(false)
+          }
+        })
+    }
+
+    refreshSwapManagerProcessing(swapInfo.id)
 
     swapManager
       .subscribeToSwapUpdates(swapInfo.id, (updatedSwap) => {
         setSwapInfo(updatedSwap)
-        swapManager.isProcessing(updatedSwap.id).then((p) => {
-          if (!cancelled) setSwapManagerProcessing(p)
-        })
+        refreshSwapManagerProcessing(updatedSwap.id)
       })
📝 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
swapManager.isProcessing(swapInfo.id).then((p) => {
if (!cancelled) setSwapManagerProcessing(p)
})
swapManager
.subscribeToSwapUpdates(swapInfo.id, (updatedSwap) => {
setSwapInfo(updatedSwap)
swapManager.isProcessing(updatedSwap.id).then((p) => {
if (!cancelled) setSwapManagerProcessing(p)
})
const refreshSwapManagerProcessing = (id: string) => {
swapManager
.isProcessing(id)
.then((p) => {
if (!cancelled) setSwapManagerProcessing(p)
})
.catch((error) => {
if (!cancelled) {
consoleError(error, `Error checking swap processing state ${id}`)
setSwapManagerProcessing(false)
}
})
}
refreshSwapManagerProcessing(swapInfo.id)
swapManager
.subscribeToSwapUpdates(swapInfo.id, (updatedSwap) => {
setSwapInfo(updatedSwap)
refreshSwapManagerProcessing(updatedSwap.id)
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/screens/Apps/Boltz/Swap.tsx` around lines 63 - 72, The two calls to
swapManager.isProcessing (inside the initial check and inside the
subscribeToSwapUpdates callback) lack error handling; extract a helper like
safeCheckProcessing(swapId) that calls swapManager.isProcessing(swapId).then(p
=> { if (!cancelled) setSwapManagerProcessing(p) }).catch(err => {
console.error('isProcessing failed', err, swapId); if (!cancelled)
setSwapManagerProcessing(false) }) and replace both direct calls with
safeCheckProcessing(swapInfo.id) so failures are logged and
swapManagerProcessing is reset to a safe default.

Comment on lines +159 to +167
const buttonLabel = swapManagerProcessing ? 'Claiming...' : isClaimable ? 'Complete swap' : 'Refund swap'
const refunded = swapInfo.status === 'transaction.refunded'

const buttonHandler = async () => {
try {
if (swapManager) {
const alreadyProcessing = await swapManager.isProcessing(swapInfo.id)
if (alreadyProcessing) return
}
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

Scope the guard to claims and sync stale UI on early return.

The current swapManagerProcessing handling also disables/short-circuits refund flows, even though this guard is meant for auto-claim races. Also, when the handler discovers alreadyProcessing, it returns without setting swapManagerProcessing, so a stale UI can remain enabled.

🐛 Proposed fix
-  const buttonLabel = swapManagerProcessing ? 'Claiming...' : isClaimable ? 'Complete swap' : 'Refund swap'
+  const claimInProgress = swapManagerProcessing && !isRefundable
+  const buttonLabel = claimInProgress ? 'Claiming...' : isClaimable ? 'Complete swap' : 'Refund swap'
   const refunded = swapInfo.status === 'transaction.refunded'
 
   const buttonHandler = async () => {
     try {
-      if (swapManager) {
+      if (isClaimable && swapManager) {
         const alreadyProcessing = await swapManager.isProcessing(swapInfo.id)
-        if (alreadyProcessing) return
+        if (alreadyProcessing) {
+          setSwapManagerProcessing(true)
+          return
+        }
       }
-      {!success && (isRefundable || isClaimable || swapManagerProcessing) ? (
+      {!success && (isRefundable || isClaimable || claimInProgress) ? (
         <ButtonsOnBottom>
-          <Button onClick={buttonHandler} label={buttonLabel} disabled={processing || swapManagerProcessing} />
+          <Button onClick={buttonHandler} label={buttonLabel} disabled={processing || claimInProgress} />
         </ButtonsOnBottom>
       ) : null}

Also applies to: 233-235

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

In `@src/screens/Apps/Boltz/Swap.tsx` around lines 159 - 167, The guard that calls
swapManager.isProcessing should only run for claim flows (when isClaimable is
true) and when it detects alreadyProcessing it must update swapManagerProcessing
to keep the UI in sync before returning; update the buttonHandler (and the
analogous handler around the isClaimable/refund branch at the other spot) to: 1)
only call swapManager.isProcessing when isClaimable is true, and 2) if
alreadyProcessing is true set swapManagerProcessing (the state/flag used to
disable the UI) appropriately (e.g., set to true) before returning so the button
label/disabled state reflects the ongoing manager processing.

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: João Bordalo <bordalix@users.noreply.github.com>
Copy link
Copy Markdown

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

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

Re-review after 4a6ca3e

New commit applies the lint:fix quoting fix I flagged in my previous review. Single-line package.json change — 'src/**/*.{ts,tsx,js,jsx}'"src/**/*.{ts,tsx,js,jsx}". ✅

No other code changes. Previous approval stands. LGTM.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants