aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-02 08:32:13 -0700
committerAdam Soltys <[email protected]>2026-09-02 08:32:13 -0700
commitacd6ca748f1fc4b31648fb999e2f9342cbf5c8a1 (patch)
treebd1f3108ecf9cce7bd0bdd1f0ca849925b9404d7
parent21418741adfa8d16c53a20a5cc0d2d78a758ed89 (diff)
downloadcoinosv3-acd6ca748f1fc4b31648fb999e2f9342cbf5c8a1.tar.gz
coinosv3-acd6ca748f1fc4b31648fb999e2f9342cbf5c8a1.zip
The tester with 233 sats gets to spend them: invoice coverage judges the SUM of spendable coins (the single-coin gate silently declined anything over the largest coin, though multi-input pays gather 24), an uncoverable invoice now owns its paste — board the shortfall from Savings, or say what's short — instead of dropping the bolt11 into the address field like a dead click, and chain calls gain a last-resort relay through the registrar (/esplora passthrough, tip cached) for the network that blackholes every public esplora
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MrkLsNScXe9b6BviUSdmMr
-rw-r--r--names/server.js33
-rw-r--r--src/ark/manager.js6
-rw-r--r--src/features/ark.js32
-rw-r--r--src/i18n.js1
4 files changed, 65 insertions, 7 deletions
diff --git a/names/server.js b/names/server.js
index a56ed84..a8ffcd5 100644
--- a/names/server.js
+++ b/names/server.js
@@ -1051,6 +1051,39 @@ Bun.serve({
// Top up the float over Lightning: it mints an invoice against its own
// ark balance, which anyone (in practice, the operator's node) can pay.
// Guarded by a token from the config file — this endpoint is public.
+ // Chain relay for wallets whose networks can't reach any public esplora
+ // (one tester's ISP blackholes mempool.space AND blockstream — every
+ // chain-touching wallet feature died for it). A narrow passthrough to
+ // mempool.space: only the four paths the ark manager speaks, the tip
+ // briefly cached so polling wallets cost one upstream call per few
+ // seconds, not one each.
+ if (url.pathname.startsWith('/esplora/')) {
+ const sub = url.pathname.slice('/esplora'.length);
+ const okPath = sub === '/blocks/tip/height'
+ || /^\/tx\/[0-9a-f]{64}\/(hex|status)$/.test(sub)
+ || (sub === '/tx' && req.method === 'POST');
+ if (!okPath) return json({ error: 'not proxied' }, 404);
+ if (!rateOk(ip, 120)) return json({ error: 'rate limited' }, 429);
+ try {
+ if (sub === '/blocks/tip/height' && globalThis._tipCache && Date.now() - globalThis._tipCache.t < 5000) {
+ return new Response(globalThis._tipCache.v, { headers: { 'access-control-allow-origin': '*' } });
+ }
+ const r = await fetch('https://mempool.space/api' + sub,
+ req.method === 'POST' ? { method: 'POST', body: await req.text() } : undefined);
+ const body = await r.text();
+ if (sub === '/blocks/tip/height' && r.ok) globalThis._tipCache = { v: body, t: Date.now() };
+ return new Response(body, {
+ status: r.status,
+ headers: {
+ 'access-control-allow-origin': '*',
+ 'content-type': r.headers.get('content-type') || 'text/plain',
+ },
+ });
+ } catch {
+ return json({ error: 'upstream unreachable' }, 502);
+ }
+ }
+
// Legacy migration hand-off. coinos.io's migrate action (running with
// the OLD account's authenticated session — that session IS the proof of
// ownership) tells us which v3 identity may claim the released name.
diff --git a/src/ark/manager.js b/src/ark/manager.js
index e9f79b0..9731dd6 100644
--- a/src/ark/manager.js
+++ b/src/ark/manager.js
@@ -200,7 +200,11 @@ export class ArkManager {
async _esplora(path, init) {
const hosts = (this._esploraHosts ||= [
this.esploraUrl,
- ...(this.network === 'mainnet' ? ['https://blockstream.info/api', 'https://mempool.space/api'] : []),
+ // last resort: our own registrar relays chain calls — the one host a
+ // wallet that can reach coinos at all provably reaches
+ ...(this.network === 'mainnet'
+ ? ['https://blockstream.info/api', 'https://mempool.space/api', 'https://names.coinos.io/esplora']
+ : []),
].filter((h, i, a) => h && a.indexOf(h) === i));
const start = this._esploraIdx || 0;
let lastErr;
diff --git a/src/features/ark.js b/src/features/ark.js
index ba566ee..829cba9 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -728,12 +728,15 @@ export function arkFeature(ctx) {
const dec = maybeLnInvoice(invoice);
if (!dec || !arkAvailable() || wallet.watchOnly) return false;
const s = arkStateNow();
- const covers = (state, sats) =>
- !!s && (s.vtxos || []).some((v) => v.state === state && v.amountSat >= sats);
+ // Coverage by SUM: multi-input pays gather up to 24 coins, so judging by
+ // the single largest coin declined invoices the wallet could pay — a
+ // tester holding 204+29 sats pasted a ~220-sat invoice and nothing
+ // happened at all. Fees are the quote's business, not this gate's.
+ const sum = (state) =>
+ (s ? (s.vtxos || []) : []).filter((v) => v.state === state).reduce((n, v) => n + v.amountSat, 0);
// pending funds count for the take-over decision: an in-flight payment or
- // revocation frees them in seconds, and the Boltz fallback can't do small
- // amounts at all — better to wait for ark than bounce off Boltz's minimum
- if (!covers('spendable', dec.amountSat || 1) && !covers('pending', dec.amountSat || 1)) return false;
+ // revocation frees them in seconds
+ if (sum('spendable') < (dec.amountSat || 1) && sum('pending') < (dec.amountSat || 1)) return false;
ui.arkLnPay = { invoice, meta: meta || null, amountSat: dec.amountSat, amount: '', feeSat: null, status: 'quote' };
ui.sendError = '';
render();
@@ -2835,7 +2838,24 @@ export function arkFeature(ctx) {
return true;
}
}
- if (maybeLnInvoice(inv)) return startArkLnPay(inv);
+ if (maybeLnInvoice(inv)) {
+ if (startArkLnPay(inv)) return true;
+ if (arkAvailable() && !wallet.watchOnly) {
+ // An invoice Spending can't cover used to fall through SILENTLY —
+ // the bolt11 landed in the address field looking like a dead
+ // paste. Own it: offer to board the shortfall from Savings when
+ // there is one, else say plainly what's short.
+ const need = (maybeLnInvoice(inv) || {}).amountSat || 0;
+ if (need && wallet.spendable > 1000 && ctx.hook('arkOfferBoard', need)) return true;
+ ui.sendError = t('arkLnExceedsSpending', {
+ need: fmtAmount(need) + ' ' + unitLabel(),
+ have: fmtAmount(arkBalance()?.spendableSat || 0) + ' ' + unitLabel(),
+ });
+ render();
+ return true;
+ }
+ return false;
+ }
const pk = npubToHex(text);
if (!pk || !arkAvailable() || !wallet.nostrFetch) return false;
startNpubPay(pk, String(text).trim());
diff --git a/src/i18n.js b/src/i18n.js
index f9f8567..fc23c19 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -852,6 +852,7 @@ const STR = {
arkSendTitle: 'Send',
arkSendHint: 'Ark address — sent instantly off-chain, no mining fee.',
arkOwnAddrBoard: "That's your own Spending address — moving from Savings instead.",
+ arkLnExceedsSpending: 'That invoice asks for {need} but Spending holds {have}.',
arkPayFromSpending: 'Ark payments come from your Spending balance — switch to Spending to send this.',
arkPayTo: 'To',
arkNoFee: 'none — instant off-chain send',