aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-28 19:14:30 -0700
committerAdam Soltys <[email protected]>2026-06-28 19:14:30 -0700
commit93631253030839509551d7e1e6a995ed60e31b96 (patch)
treefa02596727bf3fb750cb5a88cbd04f63d54b19bb /src
parent17f203ba3603c3f1ba9c8d14c53d9f10cccf3c81 (diff)
downloadcoinosv3-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.js18
-rw-r--r--src/swap.js16
2 files changed, 30 insertions, 4 deletions
diff --git a/src/app.js b/src/app.js
index 41f2d5c..11fd88b 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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) {