aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-22 10:14:59 -0700
committerAdam Soltys <[email protected]>2026-09-22 10:14:59 -0700
commite59ed2d1106396750d1fdb15fe98a4b7ab2c80fc (patch)
tree62def5369b7b0bc75075dcbd9d3ba82ff766e985
parentb73d5b5db16bbd34385382574d2d454de0b5d505 (diff)
downloadcoinosv3-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.js6
-rw-r--r--src/app.js3
-rw-r--r--src/features/ark.js78
-rw-r--r--src/features/names.js2
-rw-r--r--src/i18n.js9
5 files changed, 89 insertions, 9 deletions
diff --git a/src/api.js b/src/api.js
index ea63fdc..c0ecb4b 100644
--- a/src/api.js
+++ b/src/api.js
@@ -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;
diff --git a/src/app.js b/src/app.js
index b11aac2..6e1b6b4 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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.',