Skip to content

feat: read L1 through Electrum instead of Esplora - #1101

Merged
Kukks merged 5 commits into
masterfrom
feat/electrum-onchain-provider
Oct 28, 2026
Merged

Kukks merged 5 commits into
masterfrom
feat/electrum-onchain-provider

Conversation

@Kukks

@Kukks Kukks commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

arkade-os/ts-sdk#1110 shipped in @arkade-os/sdk 0.5.0-rc.27 and @arkade-os/swap 0.1.0-rc.30. The vendored build is gone, and this branch now runs on master's pins (sdk rc.28, swap rc.31), which include it.

Summary

Switches the wallet's L1 reads and broadcasts from Esplora REST to Electrum over WebSocket, at the SDK's per-network defaults (ELECTRUM_WS_URL):

  • Worker wallet: boarding UTXOs, onchain balance, chain tip and unilateral-exit broadcasts, via MessageBus({ onchainProvider: electrumProvider }).
  • Exit history: dating unilateral exits with getTxStatus.
  • Swap onchain corridor: the HTLC watch (corridors.onchain.chain: { provider }) and the trader's L1 claim (chainSourceFrom).
  • src/lib/electrum.ts keeps one ElectrumOnchainProvider per network for the life of the page or worker and never closes it. ws-electrumx-client shares one options object across every ElectrumWS, so closing one would stop the others reconnecting.
  • getRestApiExplorerURL and the explorer api URLs had no other reader, so they go.

Not changed

  • Claim fee rate still comes from mempool's REST API (/v1/fees/recommended at ESPLORA_URL[network]). Electrum's estimatefee(1) answers -1 on regtest, which would make the claim refuse to reveal the preimage, and it is not the fastestFee tier the claim uses on purpose.
  • Explorer links (mempool.space web) are unchanged.

Endpoints

Probed on 2026-10-25 with blockchain.headers.subscribe:

Network URL Server
bitcoin wss://electrum.arkade.sh Fulcrum 2.1.2
mutinynet wss://electrum.mutinynet.arkade.sh Fulcrum 2.1.0
signet wss://electrum.signet.arkade.sh Fulcrum 2.1.0
regtest ws://localhost:50003 arkade-regtest's Fulcrum; the submodule pin maps the port

testnet's default (wss://electrum.blockstream.info:60004) timed out. The wallet doesn't ship testnet.

Test plan

  • format:check, lint, tsc --noEmit and build are clean, as on master.
  • test:unit: master 105 files / 1045 passed, branch 106 / 1046. The exit-history and swap-claim tests now stand in for the Electrum seam instead of EsploraProvider; electrum.test.ts pins one connection per network across worker re-inits.
  • The service-worker bundle carries the Electrum client and no Node ws import.
  • CI E2E on regtest: boarding, onchain receive and swaps now go through Fulcrum's WebSocket.
  • Mutinynet preview (Cloudflare 7d1d542f): created a wallet and attached CDP to the service worker. Its socket carries Electrum JSON-RPC (scripthash.listunspent, get_history, scripthash.subscribe, headers.subscribe at height 3533793, then 11 block.header reads for MTP), and its HTTP goes only to the operator, indexer and delegator. No Esplora. The page opens its own socket to wss://electrum.mutinynet.arkade.sh.
  • Manual on mutinynet with funds: a boarding deposit shows up, the onchain balance is right, a settle completes, and an exit gets dated.

Summary by CodeRabbit

  • New Features
    • On-chain claims and exit-history lookups now use Electrum connections for supported networks.
    • Connections are reused for repeated requests on the same network, while separate networks use their own connections.
    • On-chain corridor configuration now uses the network’s Electrum connection.
  • Documentation
    • Added guidance for maintaining and updating temporary SDK builds.

Kukks added 2 commits October 25, 2026 11:06
ts-sdk#1110 adds the two seams the Electrum switch needs: a `MessageBus`
`onchainProvider` factory for the worker wallet, and `{ provider }` for the
onchain corridor's chain source. Until a release ships it, both packages
are built from b90b356 and vendored.

The swap tarball asks for `@arkade-os/sdk@0.5.0-rc.25`, which exists on npm
without those seams, so the override forces every `@arkade-os/sdk` in the
tree to the vendored build. The lockfile holds a single instance.

`ws-electrumx-client` 1.0.5, the version the SDK already pins, becomes a
direct dependency: the wallet now opens the `ElectrumWS` itself.
The worker wallet (boarding UTXOs, onchain balance, chain tip, exit
broadcasts), exit dating, and the swap onchain corridor (its HTLC watch and
the trader's claim) now read L1 through `ElectrumOnchainProvider` at the
SDK's `ELECTRUM_WS_URL[network]`.

`electrum.ts` keeps one provider per network for the life of the page or
worker and never closes it: ws-electrumx-client shares one options object
across every `ElectrumWS`, so closing one would stop the others
reconnecting.

The claim fee rate still comes from mempool's REST API at
`ESPLORA_URL[network]`. Electrum's `estimatefee` answers -1 on regtest,
where the claim would then refuse to reveal the preimage, and it is not the
`fastestFee` tier the claim deliberately uses.

`getRestApiExplorerURL` and the explorer `api` URLs had no other reader, so
they go.
@coderabbitai

coderabbitai Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Important

Review skipped

We couldn't safely recover the incremental review. No full review was started, and the last reviewed checkpoint was preserved. Retry later, or explicitly request a full review by commenting @coderabbitai full review.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

No actionable comments were generated in the recent review.

Recent review info
Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 0026815e-f449-4aef-8eee-0edce3b1cdcf
Commits

Reviewing files that changed from the base of the PR and between 1127257 and 8b0b793.

Files ignored due to path filters: 1
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
Files selected for processing: 14
  • package.json
  • pnpm-workspace.yaml
  • src/lib/electrum.ts
  • src/lib/exitHistory.ts
  • src/lib/explorers.ts
  • src/lib/onchainPayout.ts
  • src/lib/swapClient.ts
  • src/test/lib/electrum.test.ts
  • src/test/lib/exitHistory.test.ts
  • src/test/lib/swapClientOnchainClaim.test.ts
  • src/wallet-service-worker.ts
  • vendor/README.md
  • vendor/arkade-os-sdk-0.5.0-rc.25-pr1110-b90b356.tgz
  • vendor/arkade-os-swap-0.1.0-rc.28-pr1110-b90b356.tgz
Files with no reviewable changes: 1
  • src/lib/explorers.ts

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

Walkthrough

The wallet now uses a cached Electrum provider for service-worker on-chain reads, exit-history lookups, and swap chain sources. Explorer configuration retains web URLs but no longer exposes REST API URLs. Vendored SDK and swap tarballs are configured as package dependencies.

Changes

Electrum provider migration

Layer / File(s) Summary
Configure and wire the Electrum provider
src/lib/electrum.ts, src/wallet-service-worker.ts, src/test/lib/electrum.test.ts, package.json, pnpm-workspace.yaml, vendor/README.md
The wallet creates and caches an Electrum provider per network and passes it to the MessageBus. Dependencies point to local vendor tarballs, and the vendor README documents their source and regeneration steps. Tests check provider caching and network-specific WebSocket URLs.
Use Electrum for exit-history lookups
src/lib/exitHistory.ts, src/lib/explorers.ts, src/lib/onchainPayout.ts, src/test/lib/exitHistory.test.ts
Exit-history lookups use the Electrum provider when a network has an Electrum endpoint. Explorer configuration keeps web URLs and removes REST API URL lookup. The shared provider type replaces EsploraProvider in exit-history code, and tests cover Electrum lookup outcomes. Comments in onchainPayout.ts distinguish L1 reads from fee estimates.
Use Electrum for swap chain sources
src/lib/swapClient.ts, src/test/lib/swapClientOnchainClaim.test.ts
On-chain claims and corridor configuration use the Electrum provider instead of EsploraProvider. Claim fee estimation continues to use onchainClaimEndpoint(network). Tests check the provider passed to the corridor and claim flow.

Priority: Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Refactor

Possibly related issues

  • arkade-os/ts-sdk issue 1105 concerns lockup-fate detection during on-chain swaps. This change switches the provider used for related on-chain reads, but the incorrect refund classification is not shown as fixed.

Suggested reviewers: pietro909, bordalix

Merge Risk: Minimal · previous review 8b0b7

No actionable merge-blocking issue was identified. Complete the planned Electrum and regtest checks before release.

Pre-merge checks: Passed 4
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title "feat: read L1 through Electrum instead of Esplora" matches the main change. The raw summary shows the Electrum provider replacing EsploraProvider in exitHistory.ts, swapClient.ts, and the m…
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.
Full details: Title check

Explanation

The title "feat: read L1 through Electrum instead of Esplora" matches the main change. The raw summary shows the Electrum provider replacing EsploraProvider in exitHistory.ts, swapClient.ts, and the message bus. The objectives confirm that L1 reads and broadcasts move to Electrum over WebSocket. The title is concise and single-sentence. It does not cover the vendored SDK tarballs or the removed explorer API helpers, but those details are secondary.

Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Deploying wallet-mutinynet with  Cloudflare Pages  Cloudflare Pages

Latest commit: 33765b4
Status: ✅  Deploy successful!
Preview URL: https://e80b154b.arkade-wallet.pages.dev
Branch Preview URL: https://feat-electrum-onchain-provid.arkade-wallet.pages.dev

View logs

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Deploying wallet-bitcoin with  Cloudflare Pages  Cloudflare Pages

Latest commit: 33765b4
Status: ✅  Deploy successful!
Preview URL: https://c6f4ffa5.wallet-bitcoin.pages.dev
Branch Preview URL: https://feat-electrum-onchain-provid.wallet-bitcoin.pages.dev

View logs

@Kukks

Kukks commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

The worker's `MessageBus` calls the provider factory on every init and
never closes what it returns, so a factory that opened a socket per call
would leak one per re-init. This pins the cache that prevents it.
@Kukks

Kukks commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@Kukks

Kukks commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@Kukks
Kukks marked this pull request as ready for review October 28, 2026 13:43
@Kukks
Kukks merged commit 263607e into master Oct 28, 2026
8 checks passed

@arkana-ai-bot arkana-ai-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.

PROTOCOL-CRITICAL: human review required.

Summary

Clean, focused migration from Esplora REST to Electrum WebSocket for all L1 reads. The SDK-level interface (OnchainProvider) is preserved, the fee-rate path intentionally stays on Esplora REST (with documented rationale), and the singleton-per-network design is sound for the service-worker context. Findings below in priority order.


1. blockTime: 0 edge case in ElectrumOnchainProvider.getTxStatus — low probability, wrong data persisted

src/lib/exitHistory.ts:68 (confirmedAt)

ElectrumOnchainProvider.getTxStatus (SDK chunk-LPEE2H2M.cjs:~20265) uses this flow:

let blockTime = 0
try {
  const header = await this.chain.fetchBlockHeader(merkle.blockHeight)
  blockTime = parseBlockHeader(header.hex).timestamp
} catch (err) {
  if (!isMissingHeightError(err)) throw err   // ← height errors are swallowed; blockTime stays 0
}
return { confirmed: true, blockHeight: merkle.blockHeight, blockTime }

If fetchBlockHeader throws an "unknown height" error (a reorg race or a freshly confirmed tx whose block the server hasn't indexed yet), blockTime is 0 and getTxStatus returns { confirmed: true, blockTime: 0 }.

confirmedAt in exitHistory.ts does:

return status?.confirmed ? status.blockTime : undefined

status.blockTime is 0 — not undefined — so it passes the undefined-guard in resolveExits and gets persisted via saveTransactionActivityMetadata(txid, { exitedAt: 0 }). The exit then appears dated at Unix epoch (1970-01-01), sorted to the top/bottom depending on direction, permanently — the record won't be revisited once stored.

Esplora's getTxStatus never returned blockTime: 0 for a confirmed tx; it returned a real block time from the REST response. This is a silent behavioral regression.

Fix: treat blockTime: 0 as undatable in confirmedAt:

return status?.confirmed && status.blockTime ? status.blockTime : undefined

2. electrumProvider passes ELECTRUM_WS_URL[network] unchecked for callers that skip the guard

src/lib/electrum.ts:10

exitHistory.ts correctly guards with name && ELECTRUM_WS_URL[name] before calling electrumProvider. swapClient.ts and wallet-service-worker.ts call it unconditionally. ELECTRUM_WS_URL is typed Record<NetworkName, string> so every valid NetworkName has an entry — no runtime hazard today. But electrumProvider's own parameter type is NetworkName, so the contract is enforced at compile time rather than at runtime. Worth a defensive check or an assertion, especially since wallet-service-worker.ts passes the factory directly as onchainProvider and the network is resolved later inside MessageBus.


3. ws-electrumx-client "shared options object" comment is imprecise

src/lib/electrum.ts:6-7

Looking at the ElectrumWS constructor: this.options = Object.assign({ reconnect: true }, options). Each instance gets its own object regardless of what options was passed in. The footgun only fires if the same external options object is passed to multiple ElectrumWS constructors. The wallet never does this — it calls new ElectrumWS(ELECTRUM_WS_URL[network]) with no options argument. The comment explains the right constraint (don't call close()) but the stated mechanism is misleading; a reader who checks the library source will doubt the comment. Consider tightening to: "closing any connection sets options.reconnect = false on the underlying options object; if the same options reference were shared across instances that would silence all reconnection, so we never close."


4. Coupling to swap SDK internals in swapClientOnchainClaim.test.ts

src/test/lib/swapClientOnchainClaim.test.ts:93

expect(chain).toEqual({ chain: [electrum, 'regtest'] })

This asserts the internal return shape of chainSourceFrom. If @arkade-os/swap/protocol ever changes the wrapper (e.g., wraps in a class instance rather than a plain object), this assertion will either silently pass (wrong) or need manual updating. The important invariant is that the Electrum provider is threaded through — consider asserting chain.chain[0] is the provider directly rather than the full structure.


5. Testnet Electrum endpoint is dead; any code reaching electrumProvider('testnet') queues forever

src/lib/electrum.ts:10

Per PR description: wss://electrum.blockstream.info:60004 timed out. ELECTRUM_WS_URL.testnet points there. The wallet doesn't ship testnet, and the LOOKUP_TIMEOUT_MS race in confirmedAt covers the exit-history path. But if makeSwapClient or the service worker were ever invoked with network 'testnet', requests would queue on a perpetually-reconnecting socket. Low risk now; worth a note in electrum.ts or a comment in onchainPayout.ts.


6. electrum.test.ts does not verify ElectrumOnchainProvider constructor argument order

src/test/lib/electrum.test.ts:12

The test checks that ElectrumWS was called with the right URLs but mocks ws-electrumx-client and never checks what the returned ElectrumOnchainProvider received. If electrum.ts accidentally swapped the ElectrumWS and network arguments to new ElectrumOnchainProvider(...), the test would still pass. The tsc --noEmit step catches this at compile time, but an explicit check that the provider for 'regtest' is constructed with the regtest network would strengthen the unit.


Minor / no action needed

  • Removal of getRestApiExplorerURL: no other consumers found in the repo or sibling SDK clones. Clean.
  • onchainClaimEndpoint still using ESPLORA_URL: intentional and documented. estimatefee(1) returning -1 on regtest is the right reason to keep this on REST.
  • ElectrumWS reconnect loop: requests block (not reject) while disconnected. The 5s timeout in confirmedAt handles exit-history correctly; service-worker L1 ops stall until reconnect, which matches the pre-existing behaviour of an unresponsive fetch.
  • Danger CI: passed.

Blocking on finding #1 (blockTime=0 persisted at epoch). Findings #2–#6 are non-blocking.

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.

3 participants