aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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: './' },