diff options
| author | Adam Soltys <[email protected]> | 2026-06-28 19:14:30 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-28 19:14:30 -0700 |
| commit | 93631253030839509551d7e1e6a995ed60e31b96 (patch) | |
| tree | fa02596727bf3fb750cb5a88cbd04f63d54b19bb /src | |
| parent | 17f203ba3603c3f1ba9c8d14c53d9f10cccf3c81 (diff) | |
| download | coinosv3-93631253030839509551d7e1e6a995ed60e31b96.tar.gz coinosv3-93631253030839509551d7e1e6a995ed60e31b96.zip | |
Swaps: bound reverse amount by the provider's published limits, not a hardcoded 50k
doReverse rejected anything under a hardcoded 50,000 sats — which happens to be
the regtest provider's minimum, but mainnet boltz's real minimum is 25,000 (and
providers/networks differ), so valid 25k-50k mainnet swaps were refused and the
max was unchecked client-side.
Add SwapManager.reverseLimits() (GET /v2/swap/reverse, cached per API) and use it
in the swap UI: show the real min/max range under the amount field and validate
against it. Reset the cached limits when the network or provider changes.
Verified: regtest min/max 50,000/40,294,967; mainnet 25,000/25,000,000.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 18 | ||||
| -rw-r--r-- | src/swap.js | 16 |
2 files changed, 30 insertions, 4 deletions
@@ -1719,6 +1719,7 @@ function changeNetwork(net) { if (net === getNetwork()) return; setNetwork(net); swaps.network = net; + ui.swapLimits = null; // limits differ per network's provider const acc = accounts.find((a) => a.id === activeId); if (acc) activateAccount(acc); else render(); } @@ -1733,7 +1734,7 @@ function boltzProviderCard() { { class: 'card col' }, h('h3', {}, '⚡ Swap provider'), h('p', { class: 'small muted', style: 'margin:0' }, 'Boltz-compatible provider for Lightning swaps. Non-custodial: a provider can fail a swap but never take your funds.'), - h('select', { onChange: (e) => { setBoltzProviderId(e.target.value); render(); } }, + h('select', { onChange: (e) => { setBoltzProviderId(e.target.value); ui.swapLimits = null; render(); } }, BOLTZ_PRESETS.map((p) => h('option', { value: p.id, selected: p.id === id }, p.label))), id === 'custom' ? h('div', { class: 'col', style: 'gap:8px' }, @@ -1742,7 +1743,7 @@ function boltzProviderCard() { h('input', { type: 'text', class: 'mono-input', placeholder: 'https://api.example.com', autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: custom.api, - onChange: (e) => { setBoltzCustom({ api: e.target.value.trim(), ws: custom.ws }); render(); }, + onChange: (e) => { setBoltzCustom({ api: e.target.value.trim(), ws: custom.ws }); ui.swapLimits = null; render(); }, })), h('label', { class: 'field' }, h('span', { class: 'lab' }, 'WebSocket URL (optional)'), @@ -2536,7 +2537,8 @@ function balanceCard() { async function doReverse() { const amt = parseInt((ui.swapReverseAmt || '').trim(), 10); - if (!amt || amt < 50000) { ui.swapError = 'Enter at least 50,000 sats'; return render(); } + const lim = ui.swapLimits || (ui.swapLimits = await swaps.reverseLimits()); + if (!amt || amt < lim.min || amt > lim.max) { ui.swapError = `Enter ${lim.min.toLocaleString()}–${lim.max.toLocaleString()} sats`; return render(); } ui.swapError = ''; ui.swapBusy = true; render(); try { await swaps.startReverse(amt); ui.swapReverseAmt = ''; } catch (e) { ui.swapError = e.message; } @@ -2593,13 +2595,21 @@ function swapListView() { } function swapTab() { + // Pull the selected provider's real reverse-swap limits (min/max) once, to bound + // the amount instead of guessing — providers + networks differ. + if (!ui.swapLimits && !ui._swapLimitsLoading) { + ui._swapLimitsLoading = true; + swaps.reverseLimits().then((l) => { ui.swapLimits = l; ui._swapLimitsLoading = false; render(); }).catch(() => { ui._swapLimitsLoading = false; }); + } + const lim = ui.swapLimits; return h('div', { class: 'col', style: 'gap:12px' }, h('div', { class: 'card col' }, h('h3', {}, '⚡ Receive over Lightning'), h('p', { class: 'small muted', style: 'margin:0' }, 'Get a Lightning invoice; when paid, the sats are swapped on-chain into this wallet — non-custodial (you hold the preimage and claim on-chain).'), h('label', { class: 'field' }, h('span', { class: 'lab' }, 'Amount (sats)'), - h('input', { type: 'number', inputmode: 'numeric', placeholder: '100000', value: ui.swapReverseAmt || '', onInput: (e) => { ui.swapReverseAmt = e.target.value; } })), + h('input', { type: 'number', inputmode: 'numeric', placeholder: lim ? String(lim.min) : '100000', value: ui.swapReverseAmt || '', onInput: (e) => { ui.swapReverseAmt = e.target.value; } }), + lim ? h('span', { class: 'small muted' }, `Min ${lim.min.toLocaleString()} · max ${lim.max.toLocaleString()} sats`) : null), h('button', { disabled: !!ui.swapBusy, onClick: doReverse }, ui.swapBusy ? '…' : 'Create invoice')), wallet.watchOnly ? null : h('div', { class: 'card col' }, h('h3', {}, '⚡ Pay a Lightning invoice'), diff --git a/src/swap.js b/src/swap.js index 3d4cf54..de89214 100644 --- a/src/swap.js +++ b/src/swap.js @@ -250,6 +250,22 @@ export class SwapManager { _stop(id) { const t = this.timers.get(id); if (t) clearInterval(t); this.timers.delete(id); } _spendFee() { return Math.max(250, Math.ceil(170 * this.feeRate)); } // ~1-input taproot spend + // The selected provider's reverse-swap limits (sat), fetched + cached per API. + // Lets the UI bound the amount to what the provider actually accepts instead of + // a hardcoded guess (providers + networks differ). Falls back conservatively. + async reverseLimits() { + const api = this.getApi(); + if (this._revLim && this._revLimApi === api) return this._revLim; + let lim = { min: 25000, max: 25000000 }; + try { + const res = await boltzReq(api, '/v2/swap/reverse'); + const b = res && res.BTC && res.BTC.BTC && res.BTC.BTC.limits; + if (b && b.minimal) lim = { min: b.minimal, max: b.maximal }; + } catch {} + this._revLim = lim; this._revLimApi = api; + return lim; + } + // REVERSE: hal receives over Lightning. Returns a record whose `.invoice` // (bolt11) the user has someone pay; hal then claims the lockup on-chain. async startReverse(amountSat) { |
