diff options
| author | Adam Soltys <[email protected]> | 2026-09-22 10:14:59 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-22 10:14:59 -0700 |
| commit | e59ed2d1106396750d1fdb15fe98a4b7ab2c80fc (patch) | |
| tree | 62def5369b7b0bc75075dcbd9d3ba82ff766e985 | |
| parent | b73d5b5db16bbd34385382574d2d454de0b5d505 (diff) | |
| download | coinosv3-e59ed2d1106396750d1fdb15fe98a4b7ab2c80fc.tar.gz coinosv3-e59ed2d1106396750d1fdb15fe98a4b7ab2c80fc.zip | |
Ark server is a setting again; an address on another server falls back to Lightning.
Settings → Advanced → Ark server: coinos, Second (ark.second.tech) or a
custom server, per network — the stored choice is honoured on mainnet
again. Ark state is kept per server, so switching hides but never loses
coins; the card says what the current server holds and asks before letting
go. After a switch the reusable address, the nostr advert and the payment
name's ark= instruction follow the new server.
Sends between servers can't be arkoor, so an Ark address that names a
different server (its first bytes hash the server key) is treated as no
Ark address at all in the pay ladder — a coinos name or npub then goes
over Lightning by itself — and a pasted one is refused with the reason.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tmfg8k5f3TbNJS34qiqHAp
| -rw-r--r-- | src/api.js | 6 | ||||
| -rw-r--r-- | src/app.js | 3 | ||||
| -rw-r--r-- | src/features/ark.js | 78 | ||||
| -rw-r--r-- | src/features/names.js | 2 | ||||
| -rw-r--r-- | src/i18n.js | 9 |
5 files changed, 89 insertions, 9 deletions
@@ -271,10 +271,8 @@ export function arkPresets(net = getNetwork()) { return ARK_PRESETS_BY_NET[net] || ARK_PRESETS_BY_NET.mainnet; } export function getArkProviderId(net = getNetwork()) { - // Mainnet always rides coinos — the provider picker was removed from - // Settings, so a stale stored choice must not silently win. Dev networks - // still honor a stored id (regtest/mutinynet testing needs it). - if (net === 'mainnet') return 'coinos'; + // A stored choice wins on every network (Settings → Advanced → Ark server + // offers coinos, Second, or a custom server); the default is coinos. try { const id = localStorage.getItem(nk(ARK_PROVIDER_KEY, net)); if (id && arkPresets(net).some((p) => p.id === id)) return id; @@ -2584,9 +2584,10 @@ function advancedSettingsView() { return h( 'div', { class: 'col', style: 'gap:16px' }, - // which network and which explorer: changed once, if ever — advanced + // which network, which explorer, which Ark server: changed once, if ever networkCard(), explorerCard(), + ...featureAll('advancedSettingsCards'), wallet.watchOnly ? null : h( diff --git a/src/features/ark.js b/src/features/ark.js index ed494b0..47f155e 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -10,12 +10,12 @@ 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 } from '../ark/proto.js'; +import { decodeVtxo, getVtxoStatus, VTXO_STATE_SPENT, concatBytes, vtxoBytesFromStr, vtxoBytesToHex, decodeAddress, arkIdFromServerPubkey } 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'; import { - getNetwork, setNetwork, getArkProviderId, getArkConfig, + getNetwork, setNetwork, getArkProviderId, getArkConfig, setArkProviderId, arkPresets, getArkCustom, setArkCustom, } from '../api.js'; import { t } from '../i18n.js'; import { resolveBip353, parsePaymentName, parseBip21 as parseBip21Uri } from '../bip353.js'; @@ -1635,7 +1635,7 @@ export function arkFeature(ctx) { const dec = parseBip21Uri(uri); if (!dec) continue; const arkAddr = dec.params && dec.params.ark; - if (arkAddr && isArkAddress(arkAddr)) { + if (arkAddr && isArkAddress(arkAddr) && !foreignArkAddr(arkAddr)) { Object.assign(z, { status: 'ready', address: arkAddr }); if (z.autoSat) return auto(); render(); @@ -1705,6 +1705,24 @@ export function arkFeature(ctx) { } }, t('back'))); } + // An Ark address names its server (the first bytes are a hash of the + // server key). One on a different server than ours can't take an arkoor + // send — the pay ladder falls through to Lightning for such a person, and + // a pasted one is refused with the reason rather than a gRPC error. + function myArkId() { + try { + if (ark && ark.serverPub) return hex.encode(arkIdFromServerPubkey(ark.serverPub)); + const st = arkStateNow(); + if (st && st.serverPubkey) return hex.encode(arkIdFromServerPubkey(hex.decode(st.serverPubkey))); + } catch {} + return null; + } + function foreignArkAddr(addr) { + try { + const mine = myArkId(); + return !!mine && decodeAddress(String(addr).trim()).arkId !== mine; + } catch { return false; } + } async function lookupArkZapTarget(pk) { const events = await wallet.nostrFetch({ kinds: [ARK_INFO_KIND], authors: [pk] }, 6000); const ev = (events || []).sort((a, b) => b.created_at - a.created_at)[0]; @@ -1713,6 +1731,7 @@ export function arkFeature(ctx) { const net = (ev.tags.find((t) => t[0] === 'network') || [])[1]; if (!addr) return { status: 'noark' }; if (net && net !== getNetwork()) return { status: 'wrongnet', net }; + if (foreignArkAddr(addr)) return { status: 'noark', foreign: true }; // their server isn't ours: Lightning it is return { status: 'ready', address: addr }; } @@ -2267,6 +2286,53 @@ export function arkFeature(ctx) { h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back'))); } + // 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. + function arkServerCard() { + if (wallet.watchOnly) return null; + const net = getNetwork(); + const presets = arkPresets(net); + const cur = getArkProviderId(net); + const custom = ui.arkCustomDraft || (ui.arkCustomDraft = getArkCustom(net)); + const held = arkBalance() ? (arkBalance().spendableSat || 0) + (arkBalance().pendingSat || 0) : 0; + const apply = (id) => { + if (id === cur && id !== 'custom') return; + if (id === 'custom' && !(custom.ark && custom.esplora)) { toast(t('arkServerCustomNeeded')); return; } + if (id === 'custom') setArkCustom(custom, net); + setArkProviderId(id, net); + ui.arkSwitchConfirm = null; + ui.arkRecvAddr = null; // the reusable address belongs to the old server + stopArk(); + initArk(); + // the payment name's ark= instruction and the nostr advert name the + // server's address: both follow the switch once the new one answers + connectArk().then((mgr) => { announceArkAddress(mgr); ctx.hook('namesRefresh'); }).catch(() => {}); + toast(t('arkServerSwitched')); + render(); + }; + const pick = (id) => { + if (id === cur) return; + if (held > 0 && id !== 'custom') { ui.arkSwitchConfirm = id; render(); return; } + if (id === 'custom') { ui.arkSwitchConfirm = 'custom'; render(); return; } + apply(id); + }; + const c = ui.arkSwitchConfirm; + return h('div', { class: 'card col', style: 'gap:8px' }, + h('h3', {}, t('arkServerTitle')), + 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))), + (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, + c ? h('div', { class: 'col', style: 'gap:8px' }, + held > 0 ? h('div', { class: 'notice info small' }, t('arkServerSwitchWarn', { amount: fmtSats(held) })) : null, + h('div', { class: 'row gap6' }, + h('button', { class: 'btn-primary grow', onClick: () => apply(c) }, t('arkServerSwitchGo')), + h('button', { class: 'btn-ghost grow', onClick: () => { ui.arkSwitchConfirm = null; render(); } }, t('cancel')))) : null); + } + function autoWithdrawCard() { if (!arkAvailable() || wallet.watchOnly) return null; const st = awState(); @@ -3278,6 +3344,7 @@ export function arkFeature(ctx) { // Max for any Lightning amount form: spendable minus estimated fees. lnMaxSendSat() { return lnMaxSat(); }, settingsCards() { return [autoWithdrawCard()]; }, + advancedSettingsCards() { return [arkServerCard()].filter(Boolean); }, // a wallet signing out takes its ark state with it, wherever it lives wipeCache(prefixes) { for (const p of prefixes) wallet._arkStore.removePrefix(p); }, // The onboarding wizard's top-up step borrows the board form wholesale. @@ -3371,7 +3438,10 @@ export function arkFeature(ctx) { // A raw paste of our own ark address means the same thing — start the // check now (the manager may need to connect); if it's ours the board // panel replaces the filled form a beat later. - if (isArkAddress(inv) && arkAvailable()) boardOwnAddress(inv, { connect: true }).catch(() => {}); + if (isArkAddress(inv) && arkAvailable()) { + if (foreignArkAddr(inv)) { ui.sendError = t('arkForeignServer'); render(); return true; } + boardOwnAddress(inv, { connect: true }).catch(() => {}); + } // In a Spending wallet an on-chain address means "exit ark to there": // the offboard happens right in the send flow, no Move money ceremony. if (ctx.getAccount() === 'spending' && wallet.isOnchainAddress(inv) && arkAvailable()) { diff --git a/src/features/names.js b/src/features/names.js index ed6cd99..f6afc95 100644 --- a/src/features/names.js +++ b/src/features/names.js @@ -947,6 +947,8 @@ export function namesFeature(ctx) { }, settingsCards() { return namesCards(); }, posSettingsCards() { return posSettingsCardsForName(); }, + // the Ark server changed: the name's ark= instruction must follow + namesRefresh() { refresh().catch(() => {}); return true; }, // Settings → Nostr: the CLINK zap code (a nostr offer, so it lives with // the other nostr settings rather than under the payment address). nostrSettingsCards() { diff --git a/src/i18n.js b/src/i18n.js index 903da37..6b89a9e 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -829,6 +829,15 @@ const STR = { arkOpsLnPay: '{n} Lightning payment(s) in flight', arkOpsLnRecv: '{n} Lightning invoice(s) outstanding', arkLnPayVia: 'Instant, from your Spending balance', + arkServerTitle: 'Ark server', + arkServerDesc: 'Spending runs on an Ark server. coinos runs one; Second runs another; or point at your own. Coins are held per server — switch back any time to reach what a server holds.', + arkServerCustomUrl: 'Ark server URL (https://…)', + arkServerCustomEsplora: 'Esplora API URL for that server', + 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', + 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', arkLnFromSavingsBody: 'Lightning payments leave your Spending balance, not Savings. Spending holds {have}.', 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.', |
