aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-22 11:05:28 -0700
committerAdam Soltys <[email protected]>2026-09-22 11:05:28 -0700
commit7a1795f4205d3eec7f7446c341fd1278c90ed441 (patch)
treec322e02c6c12c5f699d166751b8947c5c21abc80 /src
parente59ed2d1106396750d1fdb15fe98a4b7ab2c80fc (diff)
downloadcoinosv3-7a1795f4205d3eec7f7446c341fd1278c90ed441.tar.gz
coinosv3-7a1795f4205d3eec7f7446c341fd1278c90ed441.zip
Ark server card: a side-by-side comparison of the servers, from their own published info.
Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Tmfg8k5f3TbNJS34qiqHAp
Diffstat (limited to 'src')
-rw-r--r--src/features/ark.js55
-rw-r--r--src/i18n.js23
-rw-r--r--src/style.css6
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; }