aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-01 21:08:01 -0700
committerAdam Soltys <[email protected]>2026-08-01 21:08:01 -0700
commit161e0ad3b205c1423aaa709377c128980336d3c1 (patch)
treeec165e8211f644f0ec48115f4310ed64189ba3a0
parented1d996f9dbd6cedc18ec293daba0cfcd745ae2b (diff)
downloadcoinosv3-161e0ad3b205c1423aaa709377c128980336d3c1.tar.gz
coinosv3-161e0ad3b205c1423aaa709377c128980336d3c1.zip
Coinos v3: rebrand from Hal, payment addresses move to @coinos.io
The wallet is the next version of coinos, deployed at v3.coinos.io (same build as halwallet.app) ahead of an eventual migration of coinos.io itself. App name, manifest, notifications, and the NWC alias all say Coinos; the Hal Finney tribute stays. The registrar is multi-domain (per-domain Cloudflare zones; existing halwallet.app names keep working) and new claims default to @coinos.io. Names belonging to existing coinos users are reserved for them — the registrar checks the live coinos API before granting a claim — until the migration gives those users a way to claim their own. Co-Authored-By: Claude Fable 5 <[email protected]>
-rw-r--r--build.js10
-rw-r--r--names/server.js73
-rw-r--r--src/features/names.js16
-rw-r--r--src/features/nwc.js2
-rw-r--r--src/i18n.js10
-rw-r--r--src/nwc-respond.js4
-rw-r--r--src/sw-nwc.js2
7 files changed, 70 insertions, 47 deletions
diff --git a/build.js b/build.js
index 85cc74e..b940fe8 100644
--- a/build.js
+++ b/build.js
@@ -22,8 +22,8 @@ const STATIC = ['icon-192.png', 'icon-512.png', 'icon-maskable-512.png', 'icon.s
const MANIFEST = {
id: '/',
- name: 'Hal',
- short_name: 'Hal',
+ name: 'Coinos',
+ short_name: 'Coinos',
description: 'Self-custody Bitcoin wallet that runs entirely in your browser.',
start_url: './',
scope: './',
@@ -106,7 +106,7 @@ const PWA_HEAD = `<link rel="manifest" href="manifest.webmanifest">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
-<meta name="apple-mobile-web-app-title" content="Hal">
+<meta name="apple-mobile-web-app-title" content="Coinos">
<link rel="apple-touch-icon" href="icon-192.png">
`;
@@ -147,7 +147,7 @@ const SW_WAKE_ONLY = `self.addEventListener('push', (e) => {
return;
}
await self.registration.showNotification('Payment request', {
- body: 'An app is asking your wallet to pay. Open Hal to approve.',
+ body: 'An app is asking your wallet to pay. Open Coinos to approve.',
icon: 'icon-192.png', badge: 'icon-192.png',
tag: 'nwc-' + (data.servicePubkey || 'req'), renotify: false,
data: { url: './' },
@@ -229,7 +229,7 @@ export async function buildHtml({ minify = true, pwa = minify, features = proces
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<meta name="color-scheme" content="light dark">
-<title>Hal</title>
+<title>Coinos</title>
<link rel="icon" href="${FAVICON}">
<script>try{var t=localStorage.getItem('btc-wallet-theme');if(t!=='dark'&&t!=='light')t=matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';document.documentElement.dataset.theme=t;}catch(e){}</script>
${pwa ? PWA_HEAD : ''}<style>${css}</style>
diff --git a/names/server.js b/names/server.js
index d416d80..383a9ce 100644
--- a/names/server.js
+++ b/names/server.js
@@ -23,12 +23,13 @@ import { ArkManager } from '../src/ark/manager.js';
const CFG = JSON.parse(readFileSync(process.env.NAMES_CONFIG
|| join(import.meta.dir, 'config.json'), 'utf8'));
-// { port, domain, zoneId, cfEmail, cfKey, stateFile,
+// { port, domains: { '<domain>': { zoneId } }, domain (default), cfEmail, cfKey, stateFile,
// ln: { kind:'socket', path } — the ASP's CLN, for per-name bolt12 offers,
// forwarder: { mnemonic, ark, esplora, network } — a small float ark wallet
// that forwards settled offer payments to the name's ark address }
-const DOMAIN = CFG.domain || 'halwallet.app';
+const DOMAIN = CFG.domain || 'coinos.io'; // the default domain for claims
+const DOMAINS = CFG.domains || { [DOMAIN]: { zoneId: CFG.zoneId } };
const STATE = CFG.stateFile || join(import.meta.dir, 'data', 'names.json');
const AUTH_KIND = 21353; // arbitrary custom kind for signed claims
const MAX_SKEW_SEC = 600; // claim events must be fresh
@@ -62,25 +63,29 @@ const CF = 'https://api.cloudflare.com/client/v4';
const cfHeaders = {
'X-Auth-Email': CFG.cfEmail, 'X-Auth-Key': CFG.cfKey, 'content-type': 'application/json',
};
-const recordName = (name) => `${name}.user._bitcoin-payment.${DOMAIN}`;
+const recordName = (name, domain) => `${name}.user._bitcoin-payment.${domain}`;
-async function cfWrite(name, uri, existingId) {
+async function cfWrite(name, domain, uri, existingId) {
// TXT character-strings cap at 255 bytes; BIP-353 readers concatenate the
// chunks in order, so a long ark+lno URI just spans several quoted strings.
+ const zoneId = DOMAINS[domain]?.zoneId;
+ if (!zoneId) throw new Error('unknown domain');
const chunks = uri.match(/.{1,255}/g).map((c) => `"${c}"`).join(' ');
const body = JSON.stringify({
- type: 'TXT', name: recordName(name), content: chunks, ttl: TTL,
+ type: 'TXT', name: recordName(name, domain), content: chunks, ttl: TTL,
});
const url = existingId
- ? `${CF}/zones/${CFG.zoneId}/dns_records/${existingId}`
- : `${CF}/zones/${CFG.zoneId}/dns_records`;
+ ? `${CF}/zones/${zoneId}/dns_records/${existingId}`
+ : `${CF}/zones/${zoneId}/dns_records`;
const r = await fetch(url, { method: existingId ? 'PUT' : 'POST', headers: cfHeaders, body });
const j = await r.json();
if (!j.success) throw new Error('dns write failed: ' + JSON.stringify(j.errors).slice(0, 200));
return j.result.id;
}
-async function cfDelete(recordId) {
- const r = await fetch(`${CF}/zones/${CFG.zoneId}/dns_records/${recordId}`, {
+async function cfDelete(domain, recordId) {
+ const zoneId = DOMAINS[domain]?.zoneId;
+ if (!zoneId) throw new Error('unknown domain');
+ const r = await fetch(`${CF}/zones/${zoneId}/dns_records/${recordId}`, {
method: 'DELETE', headers: cfHeaders,
});
const j = await r.json();
@@ -112,9 +117,9 @@ if (CFG.forwarder?.mnemonic) {
log(`forwarder ark wallet ready — float ${fwd.balance().spendableSat} sat, receive ${fwd.address().slice(0, 24)}…`);
}
-async function makeOffer(name) {
+async function makeOffer(address) { // "name@domain"
if (!ln) return null;
- const o = await ln.call('offer', { amount: 'any', description: `${name}@${DOMAIN}` });
+ const o = await ln.call('offer', { amount: 'any', description: address });
return { offerId: o.offer_id, bolt12: o.bolt12 };
}
@@ -186,6 +191,16 @@ function checkAuth(auth) {
return null;
}
+// coinos.io names that belong to existing coinos users are reserved for
+// them until the migration gives those users a way to claim their own.
+async function takenByCoinosUser(domain, name) {
+ if (domain !== 'coinos.io') return false;
+ try {
+ const r = await fetch(`https://coinos.io/api/users/${encodeURIComponent(name)}`);
+ return r.status === 200;
+ } catch { return true; } // can't verify → refuse rather than squat
+}
+
const validUri = (u) => typeof u === 'string' && /^bitcoin:/i.test(u) && u.length <= 480
&& !/[\s"\\]/.test(u);
@@ -234,9 +249,11 @@ Bun.serve({
// Availability / lookup. Public data (it's DNS).
const m = url.pathname.match(/^\/name\/([a-z0-9._-]{1,30})$/);
if (m && req.method === 'GET') {
- const rec = state.names[m[1]];
- if (!rec) return json({ name: m[1], taken: false, reserved: RESERVED.has(m[1]) || !NAME_RE.test(m[1]) });
- return json({ name: m[1], taken: true, pubkey: rec.pubkey, uri: rec.uri });
+ const domain = (url.searchParams.get('domain') || DOMAIN).toLowerCase();
+ const rec = state.names[`${m[1]}@${domain}`];
+ if (rec) return json({ name: m[1], domain, taken: true, pubkey: rec.pubkey, uri: rec.uri });
+ const reserved = RESERVED.has(m[1]) || !NAME_RE.test(m[1]) || await takenByCoinosUser(domain, m[1]);
+ return json({ name: m[1], domain, taken: reserved, reserved });
}
if (url.pathname === '/register' && req.method === 'POST') {
@@ -248,29 +265,33 @@ Bun.serve({
let claim;
try { claim = JSON.parse(auth.content); } catch { return json({ error: 'bad claim content' }, 400); }
const name = String(claim.name || '').toLowerCase();
+ const domain = String(claim.domain || 'halwallet.app').toLowerCase();
+ if (!DOMAINS[domain]) return json({ error: 'unknown domain' }, 400);
if (!NAME_RE.test(name)) return json({ error: 'invalid name (a-z, 0-9, . _ -, max 30)' }, 400);
if (RESERVED.has(name)) return json({ error: 'name is reserved' }, 400);
if (claim.action !== 'register') return json({ error: 'wrong action' }, 400);
if (!validUri(claim.uri)) return json({ error: 'uri must be a bitcoin: URI' }, 400);
- const existing = state.names[name];
+ const key = `${name}@${domain}`;
+ const existing = state.names[key];
if (existing && existing.pubkey !== auth.pubkey) return json({ error: 'name is taken' }, 409);
+ if (!existing && await takenByCoinosUser(domain, name)) return json({ error: 'name is taken' }, 409);
if (/[?&]lno=/.test(claim.uri)) return json({ error: 'lno is added by the registrar' }, 400);
// Every name also gets a static Lightning offer on our node; payments
// to it are forwarded to the ark destination in the claim.
let offer = existing?.offerId ? { offerId: existing.offerId, bolt12: existing.bolt12 } : null;
- if (!offer) { try { offer = await makeOffer(name); } catch (e) { log('offer creation failed: ' + e.message); } }
+ if (!offer) { try { offer = await makeOffer(key); } catch (e) { log('offer creation failed: ' + e.message); } }
const published = offer ? `${claim.uri}${claim.uri.includes('?') ? '&' : '?'}lno=${offer.bolt12}` : claim.uri;
- const recordId = await cfWrite(name, published, existing?.recordId);
- state.names[name] = {
- pubkey: auth.pubkey, uri: published, recordId, updated: Date.now(),
+ const recordId = await cfWrite(name, domain, published, existing?.recordId);
+ state.names[key] = {
+ pubkey: auth.pubkey, uri: published, recordId, updated: Date.now(), domain,
offerId: offer?.offerId, bolt12: offer?.bolt12,
};
persist();
- log(`${existing ? 'updated' : 'registered'} ${name} for ${auth.pubkey.slice(0, 12)}`);
- return json({ ok: true, name, address: `${name}@${DOMAIN}`, record: recordName(name) });
+ log(`${existing ? 'updated' : 'registered'} ${key} for ${auth.pubkey.slice(0, 12)}`);
+ return json({ ok: true, name, address: key, record: recordName(name, domain) });
}
if (url.pathname === '/register' && req.method === 'DELETE') {
@@ -282,15 +303,17 @@ Bun.serve({
let claim;
try { claim = JSON.parse(auth.content); } catch { return json({ error: 'bad claim content' }, 400); }
const name = String(claim.name || '').toLowerCase();
+ const domain = String(claim.domain || 'halwallet.app').toLowerCase();
if (claim.action !== 'delete') return json({ error: 'wrong action' }, 400);
- const existing = state.names[name];
+ const key = `${name}@${domain}`;
+ const existing = state.names[key];
if (!existing) return json({ error: 'unknown name' }, 404);
if (existing.pubkey !== auth.pubkey) return json({ error: 'not your name' }, 403);
- await cfDelete(existing.recordId).catch(() => {});
+ await cfDelete(domain, existing.recordId).catch(() => {});
if (existing.offerId && ln) await ln.call('disableoffer', { offer_id: existing.offerId }).catch(() => {});
- delete state.names[name];
+ delete state.names[key];
persist();
- log(`released ${name}`);
+ log(`released ${key}`);
return json({ ok: true });
}
diff --git a/src/features/names.js b/src/features/names.js
index d932af7..f7efb89 100644
--- a/src/features/names.js
+++ b/src/features/names.js
@@ -16,8 +16,8 @@ import { isArkAddress } from './ark.js';
import { getNetwork } from '../api.js';
import { t } from '../i18n.js';
-const REGISTRAR = 'https://names.halwallet.app';
-const DOMAIN = 'halwallet.app';
+const REGISTRAR = 'https://names.coinos.io';
+const DOMAIN = 'coinos.io';
const AUTH_KIND = 21353;
export function namesFeature(ctx) {
@@ -39,7 +39,7 @@ export function namesFeature(ctx) {
if (!uri) throw new Error(t('namesNeedArk'));
const auth = wallet.nostrSign({
kind: AUTH_KIND, created_at: Math.floor(Date.now() / 1000), tags: [],
- content: JSON.stringify({ action: 'register', name, uri }),
+ content: JSON.stringify({ action: 'register', name, uri, domain: DOMAIN }),
});
if (!auth) throw new Error('wallet cannot sign');
const r = await fetch(`${REGISTRAR}/register`, {
@@ -48,7 +48,7 @@ export function namesFeature(ctx) {
});
const j = await r.json();
if (!r.ok || j.error) throw new Error(j.error || `registrar refused (${r.status})`);
- save({ name, uri, updated: Date.now() });
+ save({ name, domain: DOMAIN, uri, updated: Date.now() });
return j;
}
@@ -57,7 +57,7 @@ export function namesFeature(ctx) {
if (!st.name) return;
const auth = wallet.nostrSign({
kind: AUTH_KIND, created_at: Math.floor(Date.now() / 1000), tags: [],
- content: JSON.stringify({ action: 'delete', name: st.name }),
+ content: JSON.stringify({ action: 'delete', name: st.name, domain: st.domain || DOMAIN }),
});
await fetch(`${REGISTRAR}/register`, {
method: 'DELETE', headers: { 'content-type': 'application/json' },
@@ -126,12 +126,12 @@ export function namesFeature(ctx) {
if (!available()) return null;
const st = load();
if (st.name) {
- const addr = `${st.name}@${DOMAIN}`;
+ const addr = `${st.name}@${st.domain || DOMAIN}`;
return h('div', { class: 'card col' },
h('h3', {}, t('namesTitle')),
h('div', { class: 'addr-box break', style: 'font-size:14px' }, addr),
h('div', { class: 'row gap6' },
- copyBtn(`${st.name}@${DOMAIN}`, t('namesCopy')),
+ copyBtn(`${st.name}@${st.domain || DOMAIN}`, t('namesCopy')),
h('button', { class: 'btn-ghost btn-sm', onClick: async () => {
await release(); toast(t('namesReleased')); render();
} }, t('namesRelease'))),
@@ -147,7 +147,7 @@ export function namesFeature(ctx) {
h('summary', {}, t('namesOwnDomain')),
h('p', { style: 'margin:4px 0' }, t('namesOwnDomainHow')),
h('div', { class: 'addr-box break', style: 'font-size:11px' },
- `${st.name}.user._bitcoin-payment.yourdomain.com. CNAME ${st.name}.user._bitcoin-payment.${DOMAIN}.`)));
+ `${st.name}.user._bitcoin-payment.yourdomain.com. CNAME ${st.name}.user._bitcoin-payment.${st.domain || DOMAIN}.`)));
}
return h('div', { class: 'card col' },
h('h3', {}, t('namesTitle')),
diff --git a/src/features/nwc.js b/src/features/nwc.js
index 8ef9cb9..ec85463 100644
--- a/src/features/nwc.js
+++ b/src/features/nwc.js
@@ -326,7 +326,7 @@ export function nwcFeature(ctx) {
if (method === 'get_info') {
return {
result: {
- alias: 'Hal', color: '#f7931a', network: getNetwork() === 'mainnet' ? 'mainnet' : getNetwork(),
+ alias: 'Coinos', color: '#f7931a', network: getNetwork() === 'mainnet' ? 'mainnet' : getNetwork(),
block_height: 0, block_hash: '', methods: METHODS,
},
};
diff --git a/src/i18n.js b/src/i18n.js
index 62e7b07..14d5c8a 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -33,7 +33,7 @@ export const isRTL = (l) => RTL.has(l || lang);
const STR = {
en: {
- appTitle: 'Hal',
+ appTitle: 'Coinos',
home: 'Home',
logout: 'Logout',
accounts: 'Accounts',
@@ -139,7 +139,7 @@ const STR = {
hiwSp1: 'Your wallet also has a silent-payment address (sp1…) that never changes and is safe to share publicly — every payment to it lands at a fresh on-chain address nobody can link to you. Finding those payments uses an indexer service, configurable (or self-hostable) in Settings.',
hiwGiftsTitle: 'Gifts',
hiwGifts1: 'You can gift bitcoin with a link: whoever opens it claims the coins into their own wallet — no app, no signup. On-chain gifts lock the coins until claimed and can be cancelled or reclaimed; Ark gifts claim instantly and free, even into a brand-new empty wallet. A gift can also be locked to a specific nostr account so only they can claim it.',
- hiwTribute: 'The name Hal Wallet is a tribute to Hal Finney (1956–2014).',
+ hiwTribute: 'Built on the shoulders of Hal Finney (1956–2014).',
balance: 'Balance',
pending: 'Pending',
tabReceive: 'Receive',
@@ -340,7 +340,7 @@ const STR = {
autolock1h: 'After 1 hour',
autolock1d: 'After 1 day',
dataSource: 'Data source',
- dataSourceDesc: 'Where Hal reads the chain and how it learns about incoming payments.',
+ dataSourceDesc: 'Where Coinos reads the chain and how it learns about incoming payments.',
modeCoinos: 'coinos (instant)',
modeCoinosDesc: "The default. Chain data and instant payments come from coinos's own Bitcoin node, with public servers as automatic backup.",
modeExplorer: 'Block explorer',
@@ -429,7 +429,7 @@ const STR = {
arkLnRefunded: 'Payment failed — funds returned to your Ark balance.',
arkLnPaidTitle: 'Paid!',
namesTitle: 'Payment address',
- namesDesc: 'Claim your payment address — like [email protected]. People can pay you just by typing it, no codes to copy or QR to scan.',
+ namesDesc: 'Claim your payment address — like [email protected]. People can pay you just by typing it, no codes to copy or QR to scan.',
namesPlaceholder: 'username',
namesClaim: 'Claim',
namesClaimed: 'You are now {name}',
@@ -446,7 +446,7 @@ const STR = {
namesOwnDomainHow: 'Have your own domain? Add this line to its DNS records (DNSSEC must be enabled) and your address works there too:',
nwcTitle: 'Nostr Wallet Connect',
nwcDesc: 'Let another app pay from this wallet over nostr — it never sees your keys, only spends within the limits you set, and you can cut it off at any time. Payments go out over Ark.',
- nwcTabWarning: 'Only works while Hal is open in a tab — it is not an always-on service.',
+ nwcTabWarning: 'Works while Coinos is open — turn on background answering below to keep working when it isn’t.',
nwcAdd: 'Add a connection',
nwcBackground: 'Answer while closed',
nwcOn: 'On',
diff --git a/src/nwc-respond.js b/src/nwc-respond.js
index 83f23a0..6289b01 100644
--- a/src/nwc-respond.js
+++ b/src/nwc-respond.js
@@ -112,7 +112,7 @@ async function respondOffer(ev, rec, walletKey, { notifier, fetchFn, saveFn, log
await saveFn(walletKey, rec);
await publish({ bolt11: a.invoice });
log(`minted ${sat} sat offer invoice while closed`);
- return { notify: { title: 'Incoming payment', body: 'Someone is paying you — open Hal to receive it.' } };
+ return { notify: { title: 'Incoming payment', body: 'Someone is paying you — open Coinos to receive it.' } };
} catch (e) {
log('offer mint failed: ' + e.message);
await publish({ error: 'Temporary Failure', code: 2 }).catch(() => {});
@@ -181,7 +181,7 @@ export async function respondFromBg(data, {
await publish({
result_type: 'get_info',
result: {
- alias: 'Hal', color: '#f7931a',
+ alias: 'Coinos', color: '#f7931a',
network: rec.ark.network === 'mainnet' ? 'mainnet' : rec.ark.network,
block_height: 0, block_hash: '',
methods: ['get_info', 'get_balance', 'pay_invoice', 'list_transactions'],
diff --git a/src/sw-nwc.js b/src/sw-nwc.js
index 968be91..dcc0c9c 100644
--- a/src/sw-nwc.js
+++ b/src/sw-nwc.js
@@ -42,7 +42,7 @@ self.addEventListener('push', (e) => {
}
if (handled) return;
await self.registration.showNotification('Payment request', {
- body: 'An app is asking your wallet to pay. Open Hal to approve.',
+ body: 'An app is asking your wallet to pay. Open Coinos to approve.',
icon: 'icon-192.png', badge: 'icon-192.png',
tag: 'nwc-' + (data.servicePubkey || 'req'), renotify: false,
data: { url: './' },