diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/ark.js | 55 | ||||
| -rw-r--r-- | src/i18n.js | 23 | ||||
| -rw-r--r-- | src/style.css | 6 |
3 files changed, 83 insertions, 1 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index 47f155e..79895be 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -10,7 +10,7 @@ import { ArkManager } from '../ark/manager.js'; import { loadBg, saveBg, buildBg, disarmSiblingRecords } from '../nwc-bg.js'; import { boardFee, p2trAddress } from '../ark/board.js'; import { maybeBolt11, maybeLnInvoice, lnSendFee } from '../ark/lightning.js'; -import { decodeVtxo, getVtxoStatus, VTXO_STATE_SPENT, concatBytes, vtxoBytesFromStr, vtxoBytesToHex, decodeAddress, arkIdFromServerPubkey } from '../ark/proto.js'; +import { decodeVtxo, getVtxoStatus, VTXO_STATE_SPENT, concatBytes, vtxoBytesFromStr, vtxoBytesToHex, decodeAddress, arkIdFromServerPubkey, getArkInfo } from '../ark/proto.js'; import { arkStore } from '../ark/store.js'; import { signedExitTxs, exitTxVsizes, buildBumpChild, buildExitClaim, submitPackage } from '../ark/exit.js'; import { utxoId } from '../wallet.js'; @@ -2286,6 +2286,57 @@ export function arkFeature(ctx) { h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back'))); } + // What each server charges and promises, from its own published info — + // laid side by side under the switcher so the choice is an informed one. + const serverInfos = new Map(); // url -> info | null (failed) | undefined (loading) + function serverInfo(url) { + if (!url) return null; + if (serverInfos.has(url)) return serverInfos.get(url); + serverInfos.set(url, undefined); + getArkInfo(url).then((i) => { serverInfos.set(url, i); render(); }).catch(() => { serverInfos.set(url, null); render(); }); + return undefined; + } + const pct = (ppm) => (ppm / 10000).toLocaleString(undefined, { maximumFractionDigits: 3 }) + '%'; + const blocksSpan = (b) => (b >= 144 ? Math.round(b / 144) + 'd' : Math.max(1, Math.round(b / 6)) + 'h'); + // an expiry-graded fee: free within N of expiry, else a range + const gradedFee = (tbl, base = 0, min = 0) => { + const rows = [...(tbl || [])].sort((a, b) => a.thresholdBlocks - b.thresholdBlocks); + const paid = rows.filter((r) => r.ppm > 0); + const parts = []; + if (base) parts.push(fmtSats(base) + ' sats'); + if (!paid.length) parts.push(t('arkCmpFree')); + else { + const lo = Math.min(...paid.map((r) => r.ppm)), hi = Math.max(...paid.map((r) => r.ppm)); + const freeUnder = rows[0].ppm === 0 && paid[0] ? paid[0].thresholdBlocks : 0; + parts.push((lo === hi ? pct(lo) : pct(lo) + '–' + pct(hi)) + (freeUnder ? ' · ' + t('arkCmpFreeNear', { span: blocksSpan(freeUnder) }) : '')); + } + if (min) parts.push(t('arkCmpMin', { n: fmtSats(min) })); + return parts.join(', '); + }; + function arkServerCompare(presets) { + const cols = presets.filter((p) => p.ark && p.id !== 'custom'); + if (cols.length < 2) return null; + const infos = cols.map((p) => serverInfo(p.ark)); + const cell = (i, f) => (i === undefined ? '…' : i === null ? '—' : f(i)); + const rows = [ + [t('arkCmpRunBy'), cols.map((p) => t(p.id === 'coinos' ? 'arkCmpRunByCoinos' : p.id === 'second' ? 'arkCmpRunBySecond' : 'arkCmpRunByOther'))], + [t('arkCmpLifetime'), infos.map((i) => cell(i, (x) => blocksSpan(x.vtxoExpiryDelta || 0)))], + [t('arkCmpRounds'), infos.map((i) => cell(i, (x) => t('arkCmpEvery', { n: Math.max(1, Math.round((x.roundIntervalSecs || 0) / 60)) })))], + [t('arkCmpRenew'), infos.map((i) => cell(i, (x) => gradedFee((x.refreshFees || {}).ppmExpiryTable, (x.refreshFees || {}).baseFeeSat)))], + [t('arkCmpLnSend'), infos.map((i) => cell(i, (x) => gradedFee((x.lnSendFees || {}).ppmExpiryTable, (x.lnSendFees || {}).baseFeeSat, (x.lnSendFees || {}).minFeeSat)))], + [t('arkCmpLnRecv'), infos.map((i) => cell(i, (x) => ((x.lnReceiveFees || {}).ppm || (x.lnReceiveFees || {}).baseFeeSat) ? gradedFee([{ thresholdBlocks: 0, ppm: x.lnReceiveFees.ppm || 0 }], x.lnReceiveFees.baseFeeSat) : t('arkCmpFree')))], + [t('arkCmpExit'), infos.map((i) => cell(i, (x) => gradedFee((x.offboardFees || {}).ppmExpiryTable, (x.offboardFees || {}).baseFeeSat)))], + [t('arkCmpBoard'), infos.map((i) => cell(i, (x) => t('arkCmpBoardVal', { min: fmtSats(x.minBoardAmountSat || 0), n: x.requiredBoardConfirmations || 0 })))], + [t('arkCmpMaxCoin'), infos.map((i) => cell(i, (x) => fmtSats(x.maxVtxoAmountSat || 0) + ' sats'))], + [t('arkCmpBackstop'), cols.map((p) => t(p.id === 'coinos' ? 'arkCmpYes' : 'arkCmpNo'))], + ]; + return h('div', { class: 'col', style: 'gap:6px' }, + h('table', { class: 'cmp-table' }, + h('thead', {}, h('tr', {}, h('th', {}), ...cols.map((p) => h('th', {}, p.label.replace(/ \(.*\)$/, ''))))), + h('tbody', {}, ...rows.map(([k, vals]) => h('tr', {}, h('td', { class: 'k' }, k), ...vals.map((v) => h('td', {}, v)))))), + h('div', { class: 'small faint' }, t('arkCmpNote'))); + } + // Settings → Advanced: which Ark server. The state is kept per server, so // coins held with the old one aren't lost by switching — but they can't be // spent from the new one; the card says what's held before it lets go. @@ -2323,6 +2374,8 @@ export function arkFeature(ctx) { h('p', { class: 'small muted', style: 'margin:0' }, t('arkServerDesc')), h('select', { onChange: (e) => pick(e.target.value) }, ...presets.map((p) => h('option', { value: p.id, selected: p.id === (c || cur) }, p.label))), + h('button', { class: 'linklike small', style: 'align-self:flex-start', onClick: () => { ui.arkCmpOpen = !ui.arkCmpOpen; render(); } }, ui.arkCmpOpen ? t('arkCmpHide') : t('arkCmpShow')), + ui.arkCmpOpen ? arkServerCompare(presets) : null, (c === 'custom' || cur === 'custom') ? h('div', { class: 'col', style: 'gap:6px' }, h('input', { type: 'text', placeholder: t('arkServerCustomUrl'), value: custom.ark, autocapitalize: 'none', onInput: (e) => { custom.ark = e.target.value; } }), h('input', { type: 'text', placeholder: t('arkServerCustomEsplora'), value: custom.esplora, autocapitalize: 'none', onInput: (e) => { custom.esplora = e.target.value; } })) : null, diff --git a/src/i18n.js b/src/i18n.js index 6b89a9e..ff67fc1 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -836,6 +836,29 @@ const STR = { arkServerCustomNeeded: 'Enter both the Ark server and its Esplora URL first', arkServerSwitchWarn: '{amount} sats are held with the current server. They stay there — switch back to reach them — but they can’t be spent from the new one. To move them, send or exit them first.', arkServerSwitchGo: 'Switch server', + arkCmpShow: 'Compare the servers', + arkCmpHide: 'Hide the comparison', + arkCmpRunBy: 'Run by', + arkCmpRunByCoinos: 'coinos', + arkCmpRunBySecond: 'Second, the makers of Ark', + arkCmpRunByOther: 'someone else', + arkCmpLifetime: 'Coin lifetime before renewal', + arkCmpRounds: 'Rounds', + arkCmpEvery: 'every {n} min', + arkCmpRenew: 'Renewal fee', + arkCmpLnSend: 'Lightning send fee', + arkCmpLnRecv: 'Lightning receive fee', + arkCmpExit: 'Cooperative exit fee', + arkCmpBoard: 'Boarding', + arkCmpBoardVal: 'min {min} sats, {n} confirmations', + arkCmpMaxCoin: 'Largest coin', + arkCmpBackstop: 'Renews for you while the app is closed', + arkCmpYes: 'yes', + arkCmpNo: 'no', + arkCmpFree: 'free', + arkCmpFreeNear: 'free within {span} of expiry', + arkCmpMin: 'min {n} sats', + arkCmpNote: 'Fees graded by expiry cost more the earlier a coin is renewed or exited. Figures come from each server’s own published info, read just now. Either server pays Lightning invoices and takes Lightning payments to your coinos name.', arkServerSwitched: 'Ark server switched', arkForeignServer: 'That Ark address belongs to a different Ark server, so it can’t be paid from here directly. Ask for a Lightning invoice, or pay their coinos name or npub — those fall back to Lightning by themselves.', arkLnFromSavingsTitle: 'Lightning pays from Spending', diff --git a/src/style.css b/src/style.css index 1588956..2a0a4ba 100644 --- a/src/style.css +++ b/src/style.css @@ -601,6 +601,12 @@ hr.divider { margin: 16px 0; } post's full width so each is a comfortable target, quiet until one carries a count or you've used it. */ .note-acts { margin-top: 8px; justify-content: space-between; gap: 4px; max-width: 340px; } +/* server comparison table (Settings → Advanced → Ark server) */ +.cmp-table { width: 100%; border-collapse: collapse; font-size: 12.5px; } +.cmp-table th { text-align: left; font-weight: 600; padding: 4px 6px; border-bottom: 1px solid var(--line); } +.cmp-table td { padding: 5px 6px; border-bottom: 1px solid var(--line); vertical-align: top; } +.cmp-table td.k { color: var(--muted); width: 38%; } +.cmp-table tr:last-child td { border-bottom: 0; } /* point of sale */ .input-group input.pos-amount, input.pos-amount { font-size: 40px; font-weight: 700; text-align: right; letter-spacing: -.5px; padding: 8px 12px; height: auto; line-height: 1.2; } .pos-big { font-size: 34px; font-weight: 700; line-height: 1.1; letter-spacing: -.5px; } |
