diff options
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/ark-ln-savings-small-test.js | 112 |
1 files changed, 112 insertions, 0 deletions
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); |
