aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/ark.js58
-rw-r--r--src/features/zaps.js2
-rw-r--r--src/i18n.js2
-rw-r--r--tools/ark-ln-savings-small-test.js112
4 files changed, 160 insertions, 14 deletions
diff --git a/src/features/ark.js b/src/features/ark.js
index d1fa356..5d3b293 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -491,6 +491,23 @@ export function arkFeature(ctx) {
localStorage.setItem(ARK_INFO_KEY + url, JSON.stringify(tip ? { info, tip, at: Date.now() } : { info, tip: (prev && prev.tip) || 0, at: (prev && prev.at) || Date.now() }));
} catch {}
}
+ // Whether Savings can fund a move into Spending at all: the server's board
+ // minimum (1,000 sats on coinos) plus the board transaction's chain fee.
+ // A Savings balance under that — 546 sats, say — must not be offered a
+ // move the board panel then refuses with "Needs at least 1,000 sats"; the
+ // way into Spending for it is a Lightning receive, which lands there
+ // directly. The minimum comes from the live manager or the last copy of
+ // the server's info seen, so the answer is right before connect too.
+ function savingsBoardMin() {
+ const info = (ark && ark.info) || ((cachedArkInfo((getArkConfig() || {}).ark) || {}).info) || null;
+ return info ? (info.minBoardAmountSat || 0) : 1000;
+ }
+ function savingsCanBoard() {
+ if (!arkAvailable()) return false;
+ const min = savingsBoardMin();
+ if (min >= 2_100_000_000_000_000 || wallet.spendable < min) return false; // paused, or short
+ try { return previewBoardFunding(wallet, ark, min).fundingSat >= min; } catch { return false; } // the fee on top
+ }
// the tip, live or estimated from the last one seen (a block per ten minutes)
function arkTipNow(mgr) {
if (mgr && mgr._tipH) return mgr._tipH;
@@ -930,7 +947,7 @@ export function arkFeature(ctx) {
h('button', { class: 'btn-ghost', onClick: () => { ui.arkSend = null; ui.sendError = ''; render(); } }, t('back')),
ui.busy
? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner' }))
- : a.amountSat > (arkBalance()?.spendableSat || 0) && wallet.spendable > 0 && !wallet.watchOnly
+ : a.amountSat > (arkBalance()?.spendableSat || 0) && savingsCanBoard()
? h('button', { class: 'btn-primary grow', onClick: () => {
const need = a.amountSat;
ui.arkSend = null; ui.sendError = '';
@@ -1953,11 +1970,16 @@ export function arkFeature(ctx) {
}
// The ask itself: switch and continue, move Savings over first, or not.
+ // When Savings can't fund a move either (under the board minimum, as a
+ // 546-sat Savings against coinos's 1,000 is), the door offered is the one
+ // that works: a Lightning receive, which lands in Spending directly.
function arkLnFromSavingsView() {
const p = ui.arkLnFromSavings;
const have = arkBalance()?.spendableSat || 0;
const need = p.amountSat || 0;
const enough = !need || have >= need;
+ const canMove = savingsCanBoard();
+ const close = () => { ui.arkLnFromSavings = null; ui.sendError = ''; ui.send = blankSend(); };
const proceed = () => {
ui.arkLnFromSavings = null;
ctx.setAccount('spending', 'right');
@@ -1965,20 +1987,27 @@ export function arkFeature(ctx) {
if (startArkLnPay(p.invoice, p.meta)) return;
ui.arkLnFromSavingsOk = false;
// Spending can't carry it: the same offer the Spending side makes
- if (need && wallet.spendable > 1000 && ctx.hook('arkOfferBoard', need)) return;
+ if (need && canMove && ctx.hook('arkOfferBoard', need)) return;
ui.sendError = t('arkLnExceedsSpending', { need: fmtAmount(need) + ' ' + unitLabel(), have: fmtAmount(have) + ' ' + unitLabel() });
render();
};
+ const moveFirst = () => { ui.arkLnFromSavings = null; ctx.hook('arkOfferBoard', need || 0); };
+ const receiveInstead = () => { close(); ctx.setAccount('spending', 'right'); render(); };
+ const sats = (n) => fmtAmount(n) + ' ' + unitLabel();
return h('div', { class: 'card col', style: 'gap:12px' },
h('h3', { style: 'margin:0' }, t('arkLnFromSavingsTitle')),
- h('p', { class: 'small muted', style: 'margin:0' }, t('arkLnFromSavingsBody', { have: fmtAmount(have) + ' ' + unitLabel() })),
- need ? h('div', { class: 'row between' }, h('span', { class: 'small muted' }, t('lnPayAmount')), h('span', { class: 'small' }, fmtAmount(need) + ' ' + unitLabel())) : null,
- !enough ? h('div', { class: 'notice info small' }, t('arkLnFromSavingsShort')) : null,
- h('button', { class: 'btn-primary btn-block', onClick: proceed }, enough ? t('arkLnUseSpending') : t('arkLnMoveFirst')),
- enough && wallet.spendable > 1000
- ? h('button', { class: 'btn-block', onClick: () => { ui.arkLnFromSavings = null; ctx.hook('arkOfferBoard', need || 0); } }, t('arkLnMoveFirst'))
+ h('p', { class: 'small muted', style: 'margin:0' }, t('arkLnFromSavingsBody', { have: sats(have) })),
+ need ? h('div', { class: 'row between' }, h('span', { class: 'small muted' }, t('lnPayAmount')), h('span', { class: 'small' }, sats(need))) : null,
+ enough ? null
+ : canMove ? h('div', { class: 'notice info small' }, t('arkLnFromSavingsShort'))
+ : h('div', { class: 'notice info small' }, t('arkLnSavingsTooSmall', { have: sats(wallet.spendable || 0), min: sats(savingsBoardMin()) })),
+ enough ? h('button', { class: 'btn-primary btn-block', onClick: proceed }, t('arkLnUseSpending'))
+ : canMove ? h('button', { class: 'btn-primary btn-block', onClick: moveFirst }, t('arkLnMoveFirst'))
+ : h('button', { class: 'btn-primary btn-block', onClick: receiveInstead }, t('arkLnReceiveInstead')),
+ enough && canMove
+ ? h('button', { class: 'btn-block', onClick: moveFirst }, t('arkLnMoveFirst'))
: null,
- h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.arkLnFromSavings = null; ui.sendError = ''; ui.send = blankSend(); render(); } }, t('cancel')));
+ h('button', { class: 'btn-ghost btn-block', onClick: () => { close(); render(); } }, t('cancel')));
}
// shared by the ark and lightning zap flows (see zaps.js for the twin)
@@ -3666,7 +3695,7 @@ export function arkFeature(ctx) {
const boardsPaused = () => ((ark && ark.info && ark.info.minBoardAmountSat) || 0) >= 2_100_000_000_000_000;
function boardForm() {
if (boardsPaused()) return h('div', { class: 'notice small' }, t('arkBoardPaused'));
- const minBoard = (ark && ark.info && ark.info.minBoardAmountSat) || 0;
+ const minBoard = savingsBoardMin();
const canBoard = wallet.spendable >= minBoard;
return h('div', { class: 'col', style: 'width:100%;gap:8px' },
h('div', { class: 'input-group' },
@@ -4035,6 +4064,7 @@ export function arkFeature(ctx) {
return mgr.fetchBolt12(offerStr, amountSat);
},
lnSpendableSat() { const b = arkBalance(); return b ? b.spendableSat : 0; },
+ arkSavingsCanBoard() { return savingsCanBoard(); },
spendingRenewingSat() { return renewingSat(); },
// Max for any Lightning amount form: spendable minus estimated fees.
lnMaxSendSat() { return lnMaxSat(); },
@@ -4058,7 +4088,7 @@ export function arkFeature(ctx) {
const short = Math.max(0, Math.ceil(needSat * 1.01) + 2 - have);
let gross = short;
try { for (let i = 0; i < 4; i++) gross = short + boardFee(gross, ark.info.boardFees); } catch {}
- const minBoard = (ark && ark.info && ark.info.minBoardAmountSat) || 0;
+ const minBoard = savingsBoardMin(); // remembered info too: the offer can come before connect
ctx.goHome();
ui.arkMoveOpen = true;
ui.arkMoveDir = 'toSpending';
@@ -4209,11 +4239,13 @@ export function arkFeature(ctx) {
// paste. Own it: offer to board the shortfall from Savings when
// there is one, else say plainly what's short.
const need = (maybeLnInvoice(inv) || {}).amountSat || 0;
- if (need && wallet.spendable > 1000 && ctx.hook('arkOfferBoard', need)) return true;
+ if (need && savingsCanBoard() && ctx.hook('arkOfferBoard', need)) return true;
ui.sendError = t('arkLnExceedsSpending', {
need: fmtAmount(need) + ' ' + unitLabel(),
have: fmtAmount(arkBalance()?.spendableSat || 0) + ' ' + unitLabel(),
- });
+ }) + (wallet.spendable > 0 && !savingsCanBoard() // Savings has sats, just too few to move
+ ? ' ' + t('arkLnSavingsTooSmall', { have: fmtAmount(wallet.spendable) + ' ' + unitLabel(), min: fmtAmount(savingsBoardMin()) + ' ' + unitLabel() })
+ : '');
render();
return true;
}
diff --git a/src/features/zaps.js b/src/features/zaps.js
index 76296cf..fbdc2e9 100644
--- a/src/features/zaps.js
+++ b/src/features/zaps.js
@@ -487,7 +487,7 @@ export function zapsFeature(ctx) {
// what this payment needs (extra is one edit away).
const typedSats = parseAmount(z.amount, getUnit()) || 0;
const theirMaxSat = Math.floor(p.maxSendable / 1000);
- const boardable = !!hook('arkReady') && ours != null;
+ const boardable = !!hook('arkReady') && ours != null && !!hook('arkSavingsCanBoard');
const renewing = hook('spendingRenewingSat') || 0;
const waitingOnRenewal = renewing > 0 && broke && ours + renewing >= Math.max(min, p.fixedSat || typedSats);
const needsBoard = !waitingOnRenewal && boardable && (broke || (typedSats > 0 && typedSats > ours && typedSats <= theirMaxSat));
diff --git a/src/i18n.js b/src/i18n.js
index db54708..36beef0 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -1359,6 +1359,8 @@ const STR = {
arkLnFromSavingsShort: 'Spending can’t cover this one. Move funds over from Savings first. That takes an on-chain confirmation, so ask for a fresh invoice afterwards.',
arkLnUseSpending: 'Switch to Spending and continue',
arkLnMoveFirst: 'Move Savings into Spending first',
+ arkLnSavingsTooSmall: 'Savings holds {have}, and a move into Spending needs at least {min} plus the on-chain fee. Receive over Lightning to fund Spending directly, or add to Savings first.',
+ arkLnReceiveInstead: 'Receive over Lightning instead',
arkLnFee: 'Fee',
arkLnPaying: 'Paying invoice…',
arkLnRetrying: 'Route failed. Retrying with a bigger fee budget ({n}/{of})…',
diff --git a/tools/ark-ln-savings-small-test.js b/tools/ark-ln-savings-small-test.js
new file mode 100644
index 0000000..a674b42
--- /dev/null
+++ b/tools/ark-ln-savings-small-test.js
@@ -0,0 +1,112 @@
+// A Lightning invoice opened while Savings is selected, with Spending empty
+// and Savings under the server's board minimum (546 sats against coinos's
+// 1,000: "can you help me", 2026-11-03), used to offer "Move Savings into
+// Spending first" — a button whose board panel then said "Needs at least
+// 1,000 sats in Savings". The ask now says what Savings holds and what a move
+// needs, and offers the door that works: a Lightning receive into Spending.
+// With enough in Savings the move is offered as before, straight to the
+// board panel; the zap card asks the same question through a hook.
+// Run: bun tools/ark-ln-savings-small-test.js
+import { buildFeatures } from '../src/features/index.js';
+import { getArkConfig } from '../src/api.js';
+
+let fails = 0;
+const check = (name, ok, detail = '') => {
+ console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + String(detail).slice(0, 200) : ''}`);
+ if (!ok) fails++;
+};
+
+globalThis.fetch = async () => { throw new Error('offline (test)'); };
+globalThis.window = undefined;
+globalThis.localStorage = {
+ _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); },
+ removeItem(k) { delete this._d[k]; }, key(i) { return Object.keys(this._d)[i] ?? null; },
+ get length() { return Object.keys(this._d).length; },
+};
+// the server's info as the app remembers it between sessions
+const cfg = getArkConfig();
+localStorage.setItem('btc-wallet-arkinfo:' + cfg.ark, JSON.stringify({ info: { minBoardAmountSat: 1000, boardFees: {} }, tip: 1, at: Date.now() }));
+
+const CHAIN_FEE = 150;
+const store = {};
+const wallet = {
+ watchOnly: false, offline: false, spendable: 546, feeRates: { halfHourFee: 1 },
+ utxos: [], txs: [], receive: [], change: [],
+ account: () => null,
+ loadFeatureState: (n, fb) => JSON.parse(store[n] || 'null') || fb,
+ saveFeatureState: (n, st) => { store[n] = JSON.stringify(st); },
+ _cacheKey: () => 'test', saveCache: () => {},
+ nostrPubkey: () => 'ab'.repeat(32),
+ registerCacheExtension: () => {}, registerRealtimeHook: () => {}, registerLoadHook: () => {},
+ registerScanHook: () => {}, registerCacheSavedHook: () => {}, registerCoinLock: () => {},
+ loadArkState: () => null, saveArkState: () => {}, _arkNs: () => 'ns', _arkKey: () => 'k',
+ _arkHoldKey: () => 'h', _arkVtxoOwner: () => null, adoptArkState: () => null,
+ freshReceive: () => ({ address: 'bc1qtest' }), freshChange: () => ({ address: 'bc1qchange' }),
+ balance: () => ({ spendableSat: 0 }), emit: () => {}, on: () => {},
+ buildTx({ recipients }) {
+ const amount = recipients[0].amount;
+ if (amount + CHAIN_FEE > wallet.spendable) throw new Error('Insufficient funds for amount + fee.');
+ return { outputs: [{ amount }], fee: CHAIN_FEE };
+ },
+};
+const calls = [];
+const ui = { account: 'savings' };
+let features = [];
+const ctx = {
+ h: (tag, attrs, ...kids) => ({ tag, attrs: attrs || {}, kids: kids.flat().filter(Boolean) }),
+ ui, render: () => {}, wallet,
+ hook: (name, ...args) => { for (const f of features) if (f[name]) { const v = f[name](...args); if (v) return v; } return null; },
+ getAccount: () => ui.account, setAccount: (a, dir) => { calls.push(['setAccount', a]); ui.account = a; },
+ goHome: () => calls.push(['goHome']),
+ openMnemonic: async () => {},
+ fmtAmount: (n) => Number(n).toLocaleString('en-US'), unitLabel: () => 'sats', unitTag: () => 'sats', getUnit: () => 'sats',
+ parseAmount: (v) => parseInt(v, 10) || 0,
+ copyBtn: () => null, pasteBtn: () => null, toast: () => {}, blankSend: () => ({ recipients: [] }),
+ openExternal: () => {},
+};
+features = buildFeatures(ctx);
+const ark = features.find((f) => f.id === 'ark');
+
+const text = (n) => typeof n === 'string' ? n : n && n.kids ? n.kids.map(text).join(' ') : '';
+const buttons = (n, out = []) => { if (n && n.tag === 'button') out.push(n); if (n && n.kids) n.kids.forEach((k) => buttons(k, out)); return out; };
+const button = (tree, label) => buttons(tree).find((b) => text(b) === label);
+
+console.log('[546 sats in Savings, Spending empty, 175-sat invoice]');
+{
+ ui.arkLnFromSavings = { invoice: 'lnbc1750n1test', meta: null, amountSat: 175 };
+ const tree = ark.sendView();
+ const body = text(tree);
+ check('says what Savings holds and what a move needs', /Savings holds 546 sats, and a move into Spending needs at least 1,000 sats/.test(body), body);
+ check('no move button', !button(tree, 'Move Savings into Spending first'), buttons(tree).map(text).join(' | '));
+ check('hook answers: Savings cannot board', !ark.arkSavingsCanBoard());
+ const recv = button(tree, 'Receive over Lightning instead');
+ check('offers a Lightning receive', !!recv);
+ recv.attrs.onClick();
+ check('the receive door opens Spending (its Receive page) and closes the ask',
+ calls.some((c) => c[0] === 'setAccount' && c[1] === 'spending') && !ui.arkLnFromSavings && ui.account === 'spending', JSON.stringify(calls));
+}
+
+console.log('[1,000 sats in Savings: the minimum, but not the fee on top]');
+{
+ calls.length = 0; ui.account = 'savings'; wallet.spendable = 1000;
+ ui.arkLnFromSavings = { invoice: 'lnbc1750n1test', meta: null, amountSat: 175 };
+ const tree = ark.sendView();
+ check('still no move offered', !button(tree, 'Move Savings into Spending first') && !!button(tree, 'Receive over Lightning instead'), text(tree));
+}
+
+console.log('[5,000 sats in Savings]');
+{
+ calls.length = 0; ui.account = 'savings'; wallet.spendable = 5000;
+ ui.arkLnFromSavings = { invoice: 'lnbc1750n1test', meta: null, amountSat: 175 };
+ const tree = ark.sendView();
+ const body = text(tree);
+ check('the move is offered, with the confirmation note', /Move funds over from Savings first/.test(body) && !!button(tree, 'Move Savings into Spending first'), body);
+ check('no receive door', !button(tree, 'Receive over Lightning instead'));
+ check('hook answers: Savings can board', ark.arkSavingsCanBoard() === true);
+ button(tree, 'Move Savings into Spending first').attrs.onClick();
+ check('the move button opens the board panel toward Spending, prefilled at the minimum or more',
+ !ui.arkLnFromSavings && ui.arkMoveOpen && ui.arkMoveDir === 'toSpending' && parseInt(ui.arkBoardAmt, 10) >= 1000, `amt=${ui.arkBoardAmt} ${JSON.stringify(calls)}`);
+}
+
+console.log(fails ? `\n${fails} FAILED` : '\nall passed');
+process.exit(fails ? 1 : 0);