aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-31 15:51:03 -0700
committerAdam Soltys <[email protected]>2026-07-31 15:51:03 -0700
commit37960a580acd2ab18d53e30e97dd1c46d12679f2 (patch)
treefd611aa47d577418763bdff092b609a8ad77b650
parent52a88f58dba97926c57aad4765c0a67be9b6c9a5 (diff)
downloadcoinosv3-37960a580acd2ab18d53e30e97dd1c46d12679f2.tar.gz
coinosv3-37960a580acd2ab18d53e30e97dd1c46d12679f2.zip
Settings: coinos ASP presets + swap-bridge provider
- ark presets gain coinos entries (ark.coinos.io on mainnet, ark-staging.coinos.io on mutinynet) alongside Second's - new per-network swap-bridge setting (preset/custom URL + access token) with its own Settings card When a bridge is configured, a bolt11 quote asks it first and shows its fee; paying locks an HTLC only the bridge can claim, and only by revealing the invoice preimage. Any failure — unreachable bridge, refused swap, unpayable invoice — refunds and falls back to the ASP's own lightning-send, so the payment never dead-ends. Hanging swaps are refunded on reconnect. Co-Authored-By: Claude Fable 5 <[email protected]>
-rw-r--r--src/api.js66
-rw-r--r--src/features/ark.js181
-rw-r--r--src/i18n.js7
3 files changed, 251 insertions, 3 deletions
diff --git a/src/api.js b/src/api.js
index e56992d..3c94192 100644
--- a/src/api.js
+++ b/src/api.js
@@ -210,6 +210,7 @@ const ARK_PRESETS_BY_NET = {
mainnet: [
{ id: 'off', label: 'Off', ark: '', esplora: '' },
{ id: 'second', label: 'Second (ark.second.tech)', ark: 'https://ark.second.tech', esplora: 'https://mempool.second.tech/api' },
+ { id: 'coinos', label: 'coinos (ark.coinos.io)', ark: 'https://ark.coinos.io', esplora: 'https://mempool.space/api' },
{ id: 'custom', label: 'Custom…', ark: '', esplora: '' },
],
// No public ASP on these networks (Second runs signet + mainnet only) —
@@ -225,6 +226,7 @@ const ARK_PRESETS_BY_NET = {
],
mutinynet: [
{ id: 'off', label: 'Off', ark: '', esplora: '' },
+ { id: 'coinos', label: 'coinos (ark-staging.coinos.io)', ark: 'https://ark-staging.coinos.io', esplora: 'https://mutinynet.com/api' },
{ id: 'custom', label: 'Custom…', ark: '', esplora: '' },
],
regtest: [
@@ -268,6 +270,70 @@ export function getArkConfig(net = getNetwork()) {
return cfg.ark && cfg.esplora ? cfg : null;
}
+// Ark->Lightning swap bridge — an optional third-party provider that pays
+// bolt11 invoices out of your Ark balance for less than the ASP charges. The
+// swap is trustless (funds are locked in an HTLC only the bridge can claim,
+// and only by revealing the invoice preimage), but it requires an ASP that
+// supports third-party HTLC vtxos. Off means bolt11 payments use the ASP's
+// own — pricier — lightning-send path.
+const BRIDGE_PRESETS_BY_NET = {
+ mainnet: [
+ { id: 'off', label: 'Off (use the ASP)', url: '' },
+ { id: 'coinos', label: 'coinos (arkswap.coinos.io)', url: 'https://arkswap.coinos.io' },
+ { id: 'custom', label: 'Custom…', url: '' },
+ ],
+ testnet: [{ id: 'off', label: 'Off (use the ASP)', url: '' }, { id: 'custom', label: 'Custom…', url: '' }],
+ signet: [{ id: 'off', label: 'Off (use the ASP)', url: '' }, { id: 'custom', label: 'Custom…', url: '' }],
+ mutinynet: [
+ { id: 'off', label: 'Off (use the ASP)', url: '' },
+ { id: 'coinos', label: 'coinos (arkswap-staging.coinos.io)', url: 'https://arkswap-staging.coinos.io' },
+ { id: 'custom', label: 'Custom…', url: '' },
+ ],
+ regtest: [
+ { id: 'off', label: 'Off (use the ASP)', url: '' },
+ { id: 'local', label: 'Local (regtest)', url: 'http://localhost:8790' },
+ { id: 'custom', label: 'Custom…', url: '' },
+ ],
+};
+const BRIDGE_DEFAULT = { mainnet: 'off', testnet: 'off', signet: 'off', mutinynet: 'off', regtest: 'off' };
+const BRIDGE_KEY = 'btc-wallet-ark-bridge'; // selected preset id, per network
+const BRIDGE_CUSTOM_KEY = 'btc-wallet-ark-bridge-custom'; // custom url, per network
+const BRIDGE_TOKEN_KEY = 'btc-wallet-ark-bridge-token'; // access token, per network
+
+export function bridgePresets(net = getNetwork()) {
+ return BRIDGE_PRESETS_BY_NET[net] || BRIDGE_PRESETS_BY_NET.mainnet;
+}
+export function getBridgeProviderId(net = getNetwork()) {
+ try {
+ const id = localStorage.getItem(nk(BRIDGE_KEY, net));
+ if (id && bridgePresets(net).some((p) => p.id === id)) return id;
+ } catch {}
+ return BRIDGE_DEFAULT[net] || 'off';
+}
+export function setBridgeProviderId(id, net = getNetwork()) {
+ try { localStorage.setItem(nk(BRIDGE_KEY, net), id); } catch {}
+}
+export function getBridgeCustom(net = getNetwork()) {
+ try { return localStorage.getItem(nk(BRIDGE_CUSTOM_KEY, net)) || ''; } catch { return ''; }
+}
+export function setBridgeCustom(url, net = getNetwork()) {
+ try { localStorage.setItem(nk(BRIDGE_CUSTOM_KEY, net), (url || '').trim()); } catch {}
+}
+export function getBridgeToken(net = getNetwork()) {
+ try { return localStorage.getItem(nk(BRIDGE_TOKEN_KEY, net)) || ''; } catch { return ''; }
+}
+export function setBridgeToken(tok, net = getNetwork()) {
+ try { localStorage.setItem(nk(BRIDGE_TOKEN_KEY, net), (tok || '').trim()); } catch {}
+}
+// { url, token } when a bridge is configured, else null.
+export function getBridgeConfig(net = getNetwork()) {
+ const id = getBridgeProviderId(net);
+ if (id === 'off') return null;
+ const p = bridgePresets(net).find((x) => x.id === id);
+ const url = id === 'custom' ? getBridgeCustom(net) : p.url;
+ return url ? { url: url.replace(/\/$/, ''), token: getBridgeToken(net) } : null;
+}
+
// Silent-payment (BIP-352) tweak indexer — a separate, optional endpoint used
// only for SP receiving. It serves public tweak/UTXO data (the scan key never
// leaves the device), but a public one can observe which blocks hold your
diff --git a/src/features/ark.js b/src/features/ark.js
index 143bec4..58254cd 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -12,7 +12,12 @@ import { maybeBolt11, lnSendFee } from '../ark/lightning.js';
import { decodeVtxo, getVtxoStatus, VTXO_STATE_SPENT, concatBytes } from '../ark/proto.js';
import { signedExitTxs, buildBumpChild, buildExitClaim, submitPackage } from '../ark/exit.js';
import { utxoId } from '../wallet.js';
-import { getNetwork, setNetwork, arkPresets, getArkProviderId, setArkProviderId, getArkCustom, setArkCustom, getArkConfig } from '../api.js';
+import {
+ getNetwork, setNetwork, arkPresets, getArkProviderId, setArkProviderId,
+ getArkCustom, setArkCustom, getArkConfig,
+ bridgePresets, getBridgeProviderId, setBridgeProviderId, getBridgeCustom,
+ setBridgeCustom, getBridgeToken, setBridgeToken, getBridgeConfig,
+} from '../api.js';
import { t } from '../i18n.js';
import { qrSvg } from '../qr.js';
import { shortAddr, shortTxid, timeAgo, ARK_ICON, ARK_MARK } from '../format.js';
@@ -217,6 +222,7 @@ export function arkFeature(ctx) {
// against the server, so a stale spendable is caught here rather than
// only at send time.
mgr.reconcile().catch(() => {});
+ resumeBridgeSwaps(mgr).catch(() => {}); // refund any HTLC a bridge left hanging
// Receives arrive in real time over the mailbox stream; the poll is the
// fallback and what drives in-flight boards/refreshes forward.
mgr.startMailboxStream();
@@ -334,6 +340,108 @@ export function arkFeature(ctx) {
}, 2500));
}
+ // ---- third-party swap bridge (cheaper bolt11 sends) ---------------------
+ // When a bridge is configured we lock the amount in an HTLC only it can
+ // claim — and only by revealing the invoice preimage. Trustless: if it
+ // never pays we get a cosigned refund, or exit through the refund leaf.
+ // Requires an ASP supporting HTLC vtxos, so any failure falls back to the
+ // ASP's own (pricier) lightning-send.
+ async function bridgeQuote(invoice) {
+ const cfg = getBridgeConfig();
+ if (!cfg) return null;
+ const r = await fetch(`${cfg.url}/quote?invoice=${encodeURIComponent(invoice)}`,
+ { headers: cfg.token ? { authorization: `Bearer ${cfg.token}` } : {} });
+ const q = await r.json();
+ if (!r.ok || q.error) throw new Error(q.error || `bridge quote failed (${r.status})`);
+ return { ...q, bridgeUrl: cfg.url, token: cfg.token };
+ }
+
+ // Lock the HTLC, hand it to the bridge, and record the swap so a crash or
+ // a failed payment can still be refunded later.
+ async function bridgePay(quote, invoice) {
+ const mgr = await connectArk();
+ const lock = await mgr.htlcLock({
+ amountSat: quote.totalSat,
+ claimPubkey: quote.claimPubkey,
+ paymentHash: quote.paymentHash,
+ htlcExpiry: quote.htlcExpiry,
+ });
+ // persist before handing it over: the HTLC exists on the server now
+ const swaps = (mgr.state.bridgeSwaps = mgr.state.bridgeSwaps || []);
+ const rec = {
+ paymentHash: quote.paymentHash, invoice, bridgeUrl: quote.bridgeUrl,
+ amountSat: quote.amountSat, feeSat: quote.feeSat,
+ htlcVtxo: lock.htlcVtxos[0], refundIndex: lock.refundIndex,
+ htlcExpiry: quote.htlcExpiry, step: 'submitted', ts: Date.now(),
+ };
+ swaps.push(rec);
+ mgr._save();
+
+ let res;
+ try {
+ const r = await fetch(`${quote.bridgeUrl}/swap`, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ ...(quote.token ? { authorization: `Bearer ${quote.token}` } : {}),
+ },
+ body: JSON.stringify({ invoice, htlcVtxo: lock.htlcVtxos[0] }),
+ });
+ res = await r.json();
+ if (!r.ok || res.error) throw new Error(res.error || `bridge error (${r.status})`);
+ } catch (e) {
+ rec.step = 'failed';
+ rec.error = e.message;
+ mgr._save();
+ await tryBridgeRefund(rec).catch(() => {});
+ throw e;
+ }
+ if (res.step !== 'done') {
+ rec.step = 'unpaid';
+ mgr._save();
+ await tryBridgeRefund(rec).catch(() => {});
+ throw new Error(res.error || t('arkLnRefunded'));
+ }
+ // proof of payment: the preimage must hash to the invoice's payment hash
+ if (!res.preimage || hex.encode(sha256(hex.decode(res.preimage))) !== quote.paymentHash) {
+ rec.step = 'suspect';
+ mgr._save();
+ throw new Error('bridge returned an invalid preimage');
+ }
+ rec.step = 'done';
+ rec.preimage = res.preimage;
+ mgr._save();
+ mgr._movement({
+ type: 'ln-send', amountSat: quote.amountSat, status: 'complete',
+ detail: `via bridge · fee ${quote.feeSat} sat`, invoice, preimage: res.preimage,
+ });
+ mgr._save();
+ return res;
+ }
+
+ // Ask the bridge to hand an unpaid HTLC back, and take ownership of it.
+ async function tryBridgeRefund(rec) {
+ const mgr = await connectArk();
+ const r = await fetch(`${rec.bridgeUrl}/refund`, {
+ method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ paymentHash: rec.paymentHash }),
+ });
+ const out = await r.json();
+ if (!r.ok || out.error) throw new Error(out.error || 'refund failed');
+ const got = await mgr.acceptHtlcRefund({
+ vtxoBytesList: out.refundVtxos, refundIndex: rec.refundIndex,
+ });
+ rec.step = 'refunded';
+ mgr._save();
+ return got;
+ }
+
+ // Retry refunds for any bridge swap left hanging (called on connect).
+ async function resumeBridgeSwaps(mgr) {
+ const open = (mgr.state.bridgeSwaps || []).filter((r) => ['unpaid', 'failed'].includes(r.step));
+ for (const rec of open) await tryBridgeRefund(rec).catch(() => {});
+ }
+
// A bolt11 lands in Send (via the swaps feature's delegation hook, or
// directly when swaps is absent): take over when the ark balance can
// plausibly cover it, else decline so the Boltz path handles it.
@@ -364,6 +472,23 @@ export function arkFeature(ctx) {
const p = ui.arkLnPay;
if (!p || p.invoice !== invoice) return;
if (!p.amountSat) { p.status = 'ready'; render(); return; }
+ // A configured bridge is cheaper than the ASP; quote it first and fall
+ // back silently if it's unreachable or won't take the swap.
+ try {
+ const q = await bridgeQuote(invoice);
+ if (q && ui.arkLnPay === p) {
+ const covers = mgr.vtxos().some((v) => v.state === 'spendable' && v.amountSat >= q.totalSat);
+ if (covers) {
+ p.bridge = q;
+ p.feeSat = q.feeSat;
+ p.status = 'ready';
+ render();
+ return;
+ }
+ }
+ } catch (e) {
+ console.warn('bridge quote failed, using the ASP:', e.message);
+ }
const tip = await mgr.chain.tipHeight();
const candidates = mgr.vtxos().filter((v) => v.state === 'spendable')
.sort((a, b) => a.amountSat - b.amountSat);
@@ -401,6 +526,21 @@ export function arkFeature(ctx) {
ui.busy = true; ui.sendError = ''; render();
try {
const mgr = await connectArk();
+ if (p.bridge) {
+ p.status = 'paying'; render();
+ try {
+ await bridgePay(p.bridge, p.invoice);
+ ui.arkLnPaid = { amountSat: p.amountSat, meta: p.meta };
+ ui.arkLnPay = null;
+ ui.busy = false; render();
+ return;
+ } catch (e) {
+ // the bridge couldn't do it (and has refunded us) — fall back to
+ // the ASP rather than dead-ending the payment
+ console.warn('bridge swap failed, falling back to the ASP:', e.message);
+ p.bridge = null; p.status = 'ready';
+ }
+ }
const id = await mgr.payLnInvoice(p.invoice, { amountSat: sats });
const settle = (a) => {
if (a.step === 'done') {
@@ -461,7 +601,7 @@ export function arkFeature(ctx) {
h('div', { style: 'display:flex;align-items:center' }, unitTag()))
: null,
p.amountSat != null ? row(t('lnPayAmount'), p.amountSat) : null,
- p.feeSat != null && p.feeSat > 0 ? row(t('arkLnFee'), p.feeSat) : null,
+ p.feeSat != null && p.feeSat > 0 ? row(p.bridge ? t('arkLnBridgeFee') : t('arkLnFee'), p.feeSat) : null,
p.amountSat != null && p.feeSat != null
? [h('div', { style: 'border-top:1px solid var(--line, #ddd);margin:2px 0' }), row(t('lnPayTotal'), total, true)]
: null),
@@ -669,6 +809,41 @@ export function arkFeature(ctx) {
);
}
+ // Swap-bridge settings: an optional cheaper route for bolt11 sends.
+ function bridgeCard() {
+ const net = getNetwork();
+ const id = getBridgeProviderId(net);
+ const presets = bridgePresets(net);
+ if (presets.length <= 1 || !arkAvailable()) return null;
+ return h('div', { class: 'card col' },
+ h('h3', { class: 'row gap6', style: 'align-items:center' }, '⚡', t('arkBridgeTitle')),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('arkBridgeDesc')),
+ h('select', {
+ onChange: (e) => { setBridgeProviderId(e.target.value, net); render(); },
+ }, presets.map((p) => h('option', { value: p.id, selected: p.id === id }, p.label))),
+ id === 'custom'
+ ? h('label', { class: 'field' },
+ h('span', { class: 'lab' }, t('arkBridgeUrl')),
+ h('input', {
+ type: 'text', class: 'mono-input', placeholder: 'https://swap.example.com',
+ autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false',
+ value: getBridgeCustom(net),
+ onChange: (e) => { setBridgeCustom(e.target.value, net); render(); },
+ }))
+ : null,
+ id !== 'off'
+ ? h('label', { class: 'field' },
+ h('span', { class: 'lab' }, t('arkBridgeToken')),
+ h('input', {
+ type: 'password', class: 'mono-input', placeholder: t('arkBridgeTokenPh'),
+ autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false',
+ value: getBridgeToken(net),
+ onChange: (e) => { setBridgeToken(e.target.value, net); render(); },
+ }))
+ : null,
+ id !== 'off' ? h('div', { class: 'small faint' }, t('arkBridgeTrust')) : null);
+ }
+
async function doArkBoard() {
const sats = parseInt((ui.arkBoardAmt || '').trim(), 10);
if (!sats) return;
@@ -1420,7 +1595,7 @@ export function arkFeature(ctx) {
: null,
done ? null : h('div', { class: 'small muted', style: 'margin-top:2px' }, t('arkBoardedNote')));
},
- settingsCards() { return [arkCard()]; },
+ settingsCards() { return [arkCard(), bridgeCard()]; },
// ---- ark-gift hooks (called by the gifts feature via ctx.hook) ----
arkGiftInfo() {
diff --git a/src/i18n.js b/src/i18n.js
index 9b39503..91fef4b 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -435,6 +435,13 @@ const STR = {
arkLnPayFailed: 'Payment failed',
arkLnRefunded: 'Payment failed — funds returned to your Ark balance.',
arkLnPaidTitle: 'Paid!',
+ arkBridgeTitle: 'Lightning swap bridge',
+ arkBridgeDesc: 'Optional third-party service that pays Lightning invoices from your Ark balance, usually cheaper than the Ark server itself. Trustless: your sats are locked in a hash contract the bridge can only take by actually paying your invoice.',
+ arkBridgeUrl: 'Bridge URL',
+ arkBridgeToken: 'Access token',
+ arkBridgeTokenPh: 'Leave blank if the bridge is open',
+ arkBridgeTrust: 'If the bridge fails to pay, your sats come straight back — it can never take them without settling your invoice.',
+ arkLnBridgeFee: 'Bridge fee',
arkLnFundsPending: 'Your Ark funds are settling from a previous payment — retrying…',
lnZapOffer: 'Zap {addr}',
arkLnInvoiceIntro: 'Or receive over Lightning, straight into your Ark balance.',