1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
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);
|