Skip to content

Swaps: strip /v2 from the swap host before native claim and refund calls - #4944

Merged
kaloudis merged 3 commits into
ZeusLN:masterfrom
shubhamkmr04:fix/swap-native-endpoint
Oct 19, 2026
Merged

kaloudis merged 3 commits into
ZeusLN:masterfrom
shubhamkmr04:fix/swap-native-endpoint

Conversation

@shubhamkmr04

@shubhamkmr04 shubhamkmr04 commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Description

Relates to issue: #4937

Swap hosts are stored with a /v2 suffix, and boltz-client adds its own /v2 to every request. The submarine cooperative claim passed the host unchanged, so it requested /v2/v2/..., got a 404, and the host never received ZEUS's partial signature. The signing itself was fixed in ZeusLN/lnd#9 (#4938), which is already in the pinned v0.21.4-beta-zeus.1, so with this change the cooperative claim works end to end.

Adds nativeSwapEndpoint() to utils/SwapUtils.ts. It strips trailing slashes and a trailing /v2, anchored to the end of the string. The submarine claim in SwapDetails and both refund calls in Refund now use it. The refund calls used replace('/v2', ''), which removes the first /v2 anywhere in the URL.

The reverse claim is left unchanged: #5126 builds it without an endpoint, so it doesn't depend on a co-signing request that has no timeout.

This pull request is categorized as a:

  • New feature
  • Bug fix
  • Code refactor
  • Configuration change
  • Locales update
  • Quality assurance
  • Other

Checklist

  • I’ve run yarn run tsc and made sure my code compiles correctly
  • I’ve run yarn run lint and made sure my code didn’t contain any problematic patterns
  • I’ve run yarn run prettier and made sure my code is formatted correctly
  • I’ve run yarn run test and made sure all of the tests pass

Testing

If you modified or added testable logic, did you add new unit tests? For bug fixes, please include a test that would have caught the bug.

  • No, I’m a fool

  • Yes

  • N/A

  • utils/SwapUtils.test.ts: each default host, a custom host without /v2, trailing slashes (including //v2), and a /v2 that isn't at the end.

  • views/Swaps/SwapDetails.test.tsx: the submarine claim is built for the host without /v2, while the claim details still come from the full API path.

  • views/Swaps/Refund.test.tsx: the host without /v2 on the cooperative refund, the uncooperative retry, and the raw details.

Tested on regtest against a local Boltz stack: a submarine swap's cooperative claim posts ZEUS's partial signature to POST /v2/swap/submarine/<id>/claim (200), and the host claims the lockup on the key path.

I have tested this PR on the following platforms (please specify OS version and phone model/VM):

  • Android (Android 14, emulator)
  • iOS

I have tested this PR with the following types of nodes (please specify node version and API version where appropriate):

On-device

  • LDK Node
  • Embedded LND

Remote

  • LND (REST) (v0.21.1-beta, regtest)
  • LND (Lightning Node Connect)
  • Core Lightning (CLNRest)
  • Nostr Wallet Connect
  • LndHub

Locales

  • I’ve added new locale text that requires translations
  • I’m aware that new translations should be made on the ZEUS Transifex page and not directly to this repo

Third Party Dependencies and Packages

  • Contributors will need to run yarn after this PR is merged in
  • 3rd party dependencies have been modified:
    • verify that package.json and yarn.lock have been properly updated
    • verify that dependencies are installed for both iOS and Android platforms

Other

  • Changes were made that require an update to the README
  • Changes were made that require an update to onboarding

@athena-zeusln athena-zeusln Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Athena Review

Pinned commit: 57b22a5

anthropic:claude-opus-5-5: completed openai:gpt-6-sol: completed

0 validated findings. This is a nonblocking COMMENT review, not an approval. No findings does not establish correctness.

Limitations:

  • The implementations of SubmarineClaimTransaction.build and the reverse-swap claim builder in SwapDetails.tsx were not provided. Both previously received the raw endpoint. I could not confirm that they forward it to boltz-client, which appends /v2 itself, rather than to code that expects /v2 already in the host. If it is the latter, these two call sites would break.
  • The source and format of the endpoint variable in SwapDetails.tsx are not shown. I could not verify whether it is always a defined string that still includes /v2. If it can be undefined, the new helper would throw where the old code passed the value through unchanged.
  • No evidence was supplied about how boltz-client or the native module builds request paths. The claim that it produces /v2/v2 could not be independently confirmed.
  • The native swap library’s URL construction and supported custom endpoint formats were not supplied, so compatibility with those endpoints could not be independently verified.

@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Test coverage

Metric Coverage Covered/Total vs master
Statements 39.28% 15661/39867 +0.13%
Branches 30.74% 9273/30161 +0.08%
Functions 28.83% 2586/8968 +0.09%
Lines 39.22% 14892/37964 +0.13%
Coverage by folder

Change vs master in parentheses when nonzero.

Folder Files Statements Branches Functions Lines
(root) 3 1.76% 0% 0% 1.83%
backends 9 58.18% 35.5% 41.08% 60.15%
cashu-cdk 3 1.11% 0% 0% 1.11%
components 114 29.39% 27.6% 24.04% 30.17%
hooks 1 0% 0% 0% 0%
ldknode 2 0% 0% 0% 0%
lndmobile 12 45% 35% 26.66% 45.09%
models 32 52.21% (+0.23) 44.32% (+0.28) 42.37% (+0.30) 52.66% (+0.25)
storage 1 77.27% 62.5% 77.77% 78.57%
stores 31 52.02% 43.75% 44.21% 52.22%
utils 89 70.13% (+0.01) 69.02% 64.09% (+0.03) 71.67% (+0.01)
views 238 14.11% (+0.32) 12.99% (+0.15) 9.19% (+0.16) 14.11% (+0.31)

Commit: 20fa168 | Test run | Baseline: master@f1c4a88de

@kaloudis kaloudis 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.

The fix is correct, but merging it opens a path to losing a swap's funds that has never run before, so I'd hold it until that path is covered.

What I verified

  • The endpoint passes unchanged from SwapDetails/Refund through ClaimTransaction.build, lndmobile/swaps.ts and LncModule into boltz.Api{URL: endpoint} (mobile/swaps.go:67,84,214 at v0.21.4-beta-zeus). boltz-client v2.9.0 adds /v2 itself (api.go:785,795), so the /v2/v2 404 described in #4937 is real.
  • These four calls are the only native swap call sites, and the PR covers all of them.
  • The anchored regex is right for every default host, including swaps.zeuslsp.com/api/v2. The old replace('/v2', '') in Refund would have broken a host like v2.example.
  • On Athena's two open points: ClaimTransaction.build passes the endpoint through to the native call unchanged, and an undefined endpoint would already throw in swapWebSocketUrl (and in Refund's old .replace).
  • The diff merges cleanly with #4894.

Blocking: a failed broadcast now loses the swap

  1. With the fix, ConstructTransaction sends the preimage to the host before ZEUS broadcasts anything. The backend settles the hold invoice as soon as it gets a valid preimage, before it signs (boltz-backend MusigSigner.ts:264-265), and the swap status moves to invoice.settled.
  2. If all 11 attempts to post the claim to mempool.space fail, the user sees claimBroadcastFailed. Causes include mempool.space being down or blocked, or a network other than mainnet (#4939). The app being killed between the cooperative call and the broadcast has the same effect.
  3. When the user reopens the swap, the WebSocket reports invoice.settled. SwapDetails.tsx:551 logs "Swap successful" and never tries to claim again.
  4. Boltz has been paid, and nothing in the app claims the lockup before the timeout, so Boltz refunds it to itself. The user loses the swap amount.

Before this PR, the host only learned the preimage from the on-chain claim. A failed broadcast left the swap in transaction.mempool/transaction.confirmed, and reopening it retried the claim. Cooperative claims have never worked in ZEUS: git log -S shows SwapDetails never stripped /v2. So this path has never been exercised.

The PR body says #4939 should land first. That isn't enough on its own: a broadcast through the user's own explorer can still fail, and the app can still be killed mid-claim. The reverse handler needs a recovery step on invoice.settled. If no claim has been recorded for the swap, fetch the lockup transaction (GET /swap/reverse/{id}/transaction, since the invoice.settled update carries none) and run the normal claim path. Once #4894 lands, that path (claimIfLockupVerified) already checks the outspend, the refund deadline and the amount before revealing anything, so a spent lockup is skipped without a separate check. Boltz still co-signs in that state, since InvoiceSettled is in its eligible list (MusigSigner.ts:236-240).

Should-fix: tests

The helper tests are good, but nothing checks the call sites. Requested cases:

  • createReverseClaimTransaction and createClaimTransaction receive https://satsrouting.exchange when the route endpoint is https://satsrouting.exchange/v2. The SwapDetails.test.tsx setup in #4894 can be extended for this once it lands.
  • The Refund cooperative call and rawDetails both use the stripped host.
  • Once the recovery step exists: invoice.settled with an unspent lockup triggers a claim, and with a spent lockup it doesn't.

Not a regression

The submarine cooperative claim still fails until ZeusLN/lnd#9 (#4938) is in. Boltz then falls back to its own script-path claim, as it does today.

kaloudis added a commit to kaloudis/zeus that referenced this pull request Oct 8, 2026
The SwapDetails tests mocked SwapUtils with swapWebSocketUrl only, so
any helper added to it later is undefined there. Merged with ZeusLN#4944,
which adds nativeSwapEndpoint, every test in the file failed. Spread
the real module and override swapWebSocketUrl alone, and give the
route a stored host with its /v2 suffix, as the app does, since the
real helper needs one.
@kaloudis

kaloudis commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Update on the blocking point in my review: the invoice.settled recovery is now in #4949 (the Zeus side of #4939), so this PR can stay the four-call-site endpoint fix.

#4949 builds claims and refunds through the new BuildReverseClaimTransaction/BuildRefundTransaction (ZeusLN/lnd#10) and broadcasts them from the app. It stores the signed claim before the first broadcast and retries that same transaction. On invoice.settled with no recorded broadcast, it resends the stored claim, or fetches the lockup from the host and claims it through the #4894 checks.

What's still needed here:

@kaloudis kaloudis mentioned this pull request Oct 8, 2026
9 of 29 tasks
kaloudis added a commit to kaloudis/zeus that referenced this pull request Oct 8, 2026
The SwapDetails tests mocked SwapUtils with swapWebSocketUrl only, so
any helper added to it later is undefined there. Merged with ZeusLN#4944,
which adds nativeSwapEndpoint, every test in the file failed. Spread
the real module and override swapWebSocketUrl alone, and give the
route a stored host with its /v2 suffix, as the app does, since the
real helper needs one.
@kaloudis kaloudis modified the milestones: v13.2.3, v13.2.4 Oct 8, 2026
@shubhamkmr04
shubhamkmr04 force-pushed the fix/swap-native-endpoint branch from 57b22a5 to 9cdb107 Compare October 9, 2026 14:46
@shubhamkmr04

shubhamkmr04 commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor Author

@kaloudis Rebased onto master after #4942, and broadcastRefund() now uses nativeSwapEndpoint. Added call-site tests: both claims, the refund (cooperative and the uncooperative retry) and the raw details get the host without /v2. They fail without the fix. I'll rebase once more after #4949 for its build calls.

@kaloudis kaloudis 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.

The helper and the four call sites are right, and the call-site tests do what I asked for in my last review. I merged this branch with current master (it is 216 commits behind and merges cleanly), and the swap suites pass (149 tests). Reverting each call site on its own (reverse claim, submarine claim, the refund call, rawDetails) fails its new test. #4949 adds no new native call sites, so these four are still all of them.

Blocking: the reverse claim conflicts with #5126

#5126 builds the reverse claim with endpoint: '', so it always signs the script path. boltz-client's http.Client has no timeout, so a host that accepts the co-signing request and never answers leaves SwapDetails in verifyingLockup with nothing broadcast. On master that request 404s right away on /v2/v2. With this PR it reaches the host, so a stalled host becomes a new way for a reverse claim to hang. #5126's description already asks for this hunk to be dropped.

Please drop the reverse-claim change (SwapDetails.tsx:880) and its test (builds the claim for the host without its /v2 suffix). That limits this PR to the submarine claim and the refunds. It also means this PR no longer has to wait for #4949: without a cooperative reverse claim, the host does not get the preimage before ZEUS broadcasts, and that was what made the merge order matter. If we want cooperative reverse claims back later, they need a timeout on the co-signing request in ZeusLN/lnd mobile/swaps.go first, plus #4949's recovery.

Should-fix

  • Submarine cooperative claim test. With this PR the submarine cooperative claim reaches the host, and the pinned v0.21.4-beta-zeus.1 already includes ZeusLN/lnd#9. So this PR is what makes it work end to end, but only a reverse claim was tested on regtest. Please run a submarine swap on regtest and confirm the host gets ZEUS's partial signature (POST /v2/swap/submarine/<id>/claim returns 200) and claims on the key path. If it does, #4938 can be closed.
  • PR body. Update it to match: lnd#9 is already pinned, the merge-order note about #4939 is out of date, and the testing section should cover the call-site tests and the submarine run.

The nit is inline.

Comment thread views/Swaps/SwapDetails.tsx Outdated
destinationAddress: claimAddress
}),
endpoint,
endpoint: nativeSwapEndpoint(endpoint),

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.

Please drop this call site and its test, see the review body: #5126 claims reverse swaps on the script path (endpoint: '') so the claim does not depend on a co-signing request that has no timeout.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Dropped in 20fa168, together with its test. The reverse claim is unchanged from master, so it stays with #5126. The PR now covers the submarine claim and the refunds.

Comment thread utils/SwapUtils.ts Outdated
* Anchored to the end so a `/v2` elsewhere in a custom host is kept.
*/
export const nativeSwapEndpoint = (endpoint: string): string =>
endpoint.replace(/\/+$/, '').replace(/\/v2$/, '');

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.

nit: a host with a doubled slash before v2 keeps a trailing slash, because trailing slashes are only stripped before /v2 is removed:

nativeSwapEndpoint('https://h//v2') // 'https://h/'

boltz-client then requests https://h//v2/.... Stripping slashes again after the /v2 removal fixes it:

export const nativeSwapEndpoint = (endpoint: string): string =>
    endpoint
        .replace(/\/+$/, '')
        .replace(/\/v2$/, '')
        .replace(/\/+$/, '');

and a row in the trailing-slash test covers it ('http://192.168.1.5:9001//v2' -> 'http://192.168.1.5:9001').

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: slashes are stripped again after the /v2 removal, and the trailing-slash test now has the //v2 row.

@shubhamkmr04
shubhamkmr04 force-pushed the fix/swap-native-endpoint branch from 8dccd3b to 20fa168 Compare October 19, 2026 14:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bug Something isn't working Swaps On-chain <-> LN

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Swaps: native claims call /v2/v2/... on the swap host, so cooperative claims fail

2 participants