Add Level-B for SideShift: the server creates a SideShift variable shift via the authenticated API and shows the deposit address in-wallet; BCH settles to the user's OWN address, so self-custody is preserved end-to-end (funds never touch the wallet operator). Other providers stay link-out. - src/onramp/sideshift-shift.ts: createVariableShift / getShiftStatus / verifySideShiftSecret (injected fetch, typed errors). Endpoints verified against docs.sideshift.ai; live secret check passes GET /v2/account. - web: OnrampContext.sideshiftSecret/affiliate from SIDESHIFT_SECRET / SIDESHIFT_AFFILIATE env; handlers + routes GET /api/onramp/config, POST /api/onramp/shift, GET /api/onramp/shift?id=. Secret stays server-side (a test asserts it never appears in the response). - ui: SideShift card gets "Swap in wallet" (deposit address + range + 6s status poll) when shiftEnabled, with an "Open site" link-out fallback. - guard: mainnet-only — a bchtest:/bchreg: settle address is rejected with a switch-to-mainnet message (SideShift settles mainnet BCH). - scripts/deviant-web.mjs auto-loads .env; .env.example documents the keys. - ADR-0036 amended: Tier-3 admitted narrowly for non-custodial API swap providers only. Full suite 478 pass / 0 fail. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
754 lines
34 KiB
JavaScript
754 lines
34 KiB
JavaScript
'use strict';
|
||
|
||
// ---- tiny fetch helpers ----
|
||
async function getJson(url) {
|
||
const r = await fetch(url);
|
||
const body = await r.json();
|
||
if (!r.ok) throw new Error(body.error || `HTTP ${r.status}`);
|
||
return body;
|
||
}
|
||
async function postJson(url, payload) {
|
||
const r = await fetch(url, {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
const body = await r.json();
|
||
if (!r.ok) throw new Error(body.error || `HTTP ${r.status}`);
|
||
return body;
|
||
}
|
||
const postCommand = (p) => postJson('/api/command', p);
|
||
const postRegister = (definition) => postJson('/api/register', { definition });
|
||
|
||
const $ = (sel) => document.querySelector(sel);
|
||
function escapeHtml(s) {
|
||
return String(s).replace(/[&<>"']/g, (c) =>
|
||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||
}
|
||
|
||
// ---- sats / BCH formatting ----
|
||
function groupSats(satsStr) { return String(satsStr).replace(/\B(?=(\d{3})+(?!\d))/g, ','); }
|
||
function bchOf(satsStr) {
|
||
const s = BigInt(satsStr); const neg = s < 0n; const a = neg ? -s : s;
|
||
const whole = a / 100000000n; const frac = (a % 100000000n).toString().padStart(8, '0');
|
||
return `${neg ? '-' : ''}${whole}.${frac}`;
|
||
}
|
||
function satsWithBch(satsStr) { return `${groupSats(satsStr)} sats (${bchOf(satsStr)} BCH)`; }
|
||
function wholeRate(rateSats, decimals) { return (BigInt(rateSats) * 10n ** BigInt(decimals || 0)).toString(); }
|
||
function badge(v) { return `<span class="badge ${v}">${v}</span>`; }
|
||
|
||
// Cached balance + wallet meta.
|
||
let cachedBalance = null;
|
||
let ownAddrs = null;
|
||
async function fetchBalance() { cachedBalance = await getJson('/api/balance'); return cachedBalance; }
|
||
async function ensureBalance() { if (!cachedBalance) await fetchBalance(); return cachedBalance; }
|
||
async function getOwnAddrs() { if (!ownAddrs) ownAddrs = await getJson('/api/wallet'); return ownAddrs; }
|
||
|
||
// ---- top-level view switching ----
|
||
function switchView(name) {
|
||
document.querySelectorAll('.navbtn').forEach((b) => b.classList.toggle('active', b.dataset.view === name));
|
||
document.querySelectorAll('.view').forEach((v) => { v.hidden = v.id !== `view-${name}`; });
|
||
if (name === 'home') loadHome();
|
||
if (name === 'assets') loadBalance();
|
||
if (name === 'asm') loadAsm();
|
||
if (name === 'buy') loadBuy();
|
||
}
|
||
|
||
async function loadWalletMeta() {
|
||
try {
|
||
const w = await getOwnAddrs();
|
||
$('#wallet-meta').textContent = `${w.network} · ${w.cashaddr}`;
|
||
} catch (e) {
|
||
$('#wallet-meta').textContent = 'wallet error: ' + e.message;
|
||
}
|
||
}
|
||
|
||
// ---- shared asset-row rendering ----
|
||
function assetMenu(acts) {
|
||
const btns = acts
|
||
.map((a) => `<button class="mini" data-act="${a.op}" data-ticker="${escapeHtml(a.ticker)}"` +
|
||
(a.units !== undefined ? ` data-units="${a.units}"` : '') + `>${a.label}</button>`)
|
||
.join('');
|
||
return `<div class="asset-menu">${btns}</div>`;
|
||
}
|
||
function starHtml(token) {
|
||
const on = token.pinned === true;
|
||
return `<button class="star${on ? ' on' : ''}" data-id="${escapeHtml(token.id)}" data-pinned="${on}" ` +
|
||
`title="${on ? 'Unfavorite' : 'Favorite'}">${on ? '★' : '☆'}</button>`;
|
||
}
|
||
function assetRowHtml(t, opts = {}) {
|
||
const star = opts.star ? starHtml(t.token) : '';
|
||
if (t.kind === 'tier_a') {
|
||
const tk = t.token.ticker;
|
||
const under = BigInt(t.unitsUnderbacked || '0') > 0n ? ` (+${t.unitsUnderbacked} underbacked)` : '';
|
||
const rate = wholeRate(t.token.rateSats, t.token.decimals);
|
||
const underVal = BigInt(t.satsUnderbacked || '0') > 0n ? ` · underbacked ${satsWithBch(t.satsUnderbacked)}` : '';
|
||
const total = (BigInt(t.unitsRedeemable || '0') + BigInt(t.unitsUnderbacked || '0')).toString();
|
||
return `<div class="asset">` +
|
||
`<div class="tok">${star}<span class="tick">${escapeHtml(tk)}</span>` +
|
||
`<span class="amt">${t.unitsRedeemable}${under}</span> ${badge(t.verification)}` +
|
||
`<span class="cls">${escapeHtml(t.assetClassLabel || '')}</span></div>` +
|
||
`<div class="tok-detail">@ ${rate} sats/unit · value ${satsWithBch(t.satsRedeemable)}${underVal}</div>` +
|
||
assetMenu([
|
||
{ op: 'redeem', ticker: tk, label: 'Redeem' },
|
||
{ op: 'redeem', ticker: tk, label: 'Redeem all', units: total },
|
||
{ op: 'send', ticker: tk, label: 'Send' },
|
||
{ op: 'swap', ticker: tk, label: 'Swap' },
|
||
]) + `</div>`;
|
||
}
|
||
if (t.kind === 'tier_b') {
|
||
const tk = t.token.ticker;
|
||
// Tier B is just a label over plain BCH — "Convert to BCH" forgets the label.
|
||
const menu = `<div class="asset-menu">` +
|
||
`<button class="mini" data-act="send-b" data-ticker="${escapeHtml(tk)}">Send</button>` +
|
||
`<button class="mini convert" data-act="forget" data-id="${escapeHtml(t.token.id)}" ` +
|
||
`data-ticker="${escapeHtml(tk)}">Convert to BCH</button></div>`;
|
||
return `<div class="asset">` +
|
||
`<div class="tok">${star}<span class="tick">${escapeHtml(tk)}</span>` +
|
||
`<span class="amt">${t.unitsTotal} units</span> <span class="badge B">wallet-local</span></div>` +
|
||
`<div class="tok-detail">${satsWithBch(t.satsAssociated)}</div>` +
|
||
menu + `</div>`;
|
||
}
|
||
const tk = t.token.ticker;
|
||
return `<div class="asset">` +
|
||
`<div class="tok">${star}<span class="tick">${escapeHtml(tk)}</span>` +
|
||
`<span class="amt">${t.unitsTotal} units</span><span class="cls">${escapeHtml(t.assetClassLabel || 'Token')}</span></div>` +
|
||
`<div class="tok-detail">${satsWithBch(t.satsTotal)} · no peg</div>` +
|
||
assetMenu([
|
||
{ op: 'send', ticker: tk, label: 'Send' },
|
||
{ op: 'swap', ticker: tk, label: 'Swap' },
|
||
]) + `</div>`;
|
||
}
|
||
function unknownRowHtml(u) {
|
||
return `<div class="asset">` +
|
||
`<div class="tok"><span class="tick muted">?</span>` +
|
||
`<span class="amt muted">FT ${u.ftAmount}</span>` +
|
||
`<span class="cls">${u.category.slice(0, 12)}…</span></div>` +
|
||
`<div class="tok-detail">${satsWithBch(u.satoshis)} · unknown — Register to name it</div>` +
|
||
assetMenu([{ op: 'register', ticker: '', label: 'Register' }]) + `</div>`;
|
||
}
|
||
|
||
function onAssetClick(e) {
|
||
const star = e.target.closest('.star');
|
||
if (star) { e.stopPropagation(); toggleFavorite(star.dataset.id, star.dataset.pinned !== 'true'); return; }
|
||
const actBtn = e.target.closest('.asset-menu button');
|
||
if (actBtn) {
|
||
e.stopPropagation();
|
||
if (actBtn.dataset.act === 'forget') forgetToken(actBtn.dataset.id, actBtn.dataset.ticker);
|
||
else assetAction(actBtn.dataset.act, actBtn.dataset.ticker, actBtn.dataset.units);
|
||
return;
|
||
}
|
||
const asset = e.target.closest('.asset');
|
||
if (asset) asset.classList.toggle('open');
|
||
}
|
||
|
||
// "Convert to BCH" for a Tier-B token: forget the wallet-local label. The BCH
|
||
// was never locked — it stays in the wallet and shows as plain BCH afterward.
|
||
async function forgetToken(id, ticker) {
|
||
if (!confirm(
|
||
`Convert "${ticker}" back to BCH?\n\n` +
|
||
`This removes the wallet-local label only. Tier B was never locked on-chain — ` +
|
||
`the BCH stays in your wallet and will show as plain BCH.`
|
||
)) return;
|
||
try { await postJson('/api/forget', { id }); }
|
||
catch (e) { alert('Could not convert: ' + e.message); return; }
|
||
await fetchBalance();
|
||
if (!$('#view-home').hidden) renderHome(cachedBalance);
|
||
if (!$('#view-assets').hidden) renderBalance(cachedBalance);
|
||
}
|
||
|
||
async function toggleFavorite(id, pinned) {
|
||
try { await postJson('/api/favorite', { id, pinned }); }
|
||
catch (e) { alert('Could not update favorite: ' + e.message); return; }
|
||
await fetchBalance();
|
||
if (!$('#view-home').hidden) renderHome(cachedBalance);
|
||
if (!$('#view-assets').hidden) renderBalance(cachedBalance);
|
||
}
|
||
|
||
// ---- Home ----
|
||
async function loadHome() {
|
||
try { renderHome(await fetchBalance()); }
|
||
catch (e) { $('#favorites').innerHTML = `<span class="err-line">${escapeHtml(e.message)}</span>`; }
|
||
}
|
||
function renderHome(b) {
|
||
// Tier-B "drafts" are just labels over plain BCH, so their sats ARE BCH —
|
||
// count them in the BCH figure (labeling must not shrink your balance).
|
||
let draft = 0n;
|
||
let mba = 0n;
|
||
for (const t of b.tokens) {
|
||
if (t.kind === 'tier_b') draft += BigInt(t.satsAssociated || '0');
|
||
else if (t.kind === 'tier_a') mba += BigInt(t.satsRedeemable || '0');
|
||
}
|
||
const bch = BigInt(b.bchSats) + draft;
|
||
const total = bch + mba;
|
||
$('#home-native').textContent = satsWithBch(bch.toString());
|
||
$('#home-total').innerHTML =
|
||
`<span class="muted">Total incl. MBA</span> <strong>${satsWithBch(total.toString())}</strong>` +
|
||
`<div class="muted breakdown">BCH ${bchOf(bch.toString())} + MBA ${bchOf(mba.toString())} BCH</div>`;
|
||
// Favorites are real assets only — drafts are not assets.
|
||
const favs = b.tokens.filter((t) => t.kind !== 'tier_b' && t.token && t.token.pinned);
|
||
$('#favorites').innerHTML = favs.length
|
||
? favs.map((t) => assetRowHtml(t, { star: true })).join('')
|
||
: '<span class="muted">No favorites yet — tap ★ on an asset in My Assets.</span>';
|
||
}
|
||
|
||
// ---- My Assets ----
|
||
async function loadBalance() {
|
||
const el = $('#balance');
|
||
el.textContent = 'Loading…';
|
||
try { renderBalance(await fetchBalance()); }
|
||
catch (e) { el.innerHTML = `<span class="err-line">Balance error: ${escapeHtml(e.message)}</span>`; }
|
||
}
|
||
function renderBalance(b) {
|
||
const real = b.tokens.filter((t) => t.kind !== 'tier_b'); // on-chain CashTokens
|
||
const drafts = b.tokens.filter((t) => t.kind === 'tier_b'); // wallet-local labels
|
||
let html = `<div class="bch">${satsWithBch(b.bchSats)} <span class="unit">plain</span></div>`;
|
||
|
||
// Real, on-chain assets first.
|
||
for (const t of real) html += assetRowHtml(t, { star: true });
|
||
for (const u of b.unknownTokens) html += unknownRowHtml(u);
|
||
if (real.length === 0 && b.unknownTokens.length === 0) {
|
||
html += '<span class="muted">No on-chain tokens yet.</span>';
|
||
}
|
||
|
||
// Drafts / labels — clearly separated, honestly framed.
|
||
if (drafts.length) {
|
||
html += `<div class="section-head">Drafts / Labels` +
|
||
`<span class="muted"> — private notes over your BCH. Not real tokens; other wallets can't see them.</span></div>`;
|
||
for (const t of drafts) html += assetRowHtml(t, { star: false });
|
||
}
|
||
|
||
$('#balance').innerHTML = html;
|
||
}
|
||
|
||
// ---- ASM view ----
|
||
async function loadAsm() {
|
||
const el = $('#asm-list');
|
||
el.textContent = 'Loading…';
|
||
try {
|
||
const [tokens, bal] = await Promise.all([getJson('/api/tokens'), fetchBalance()]);
|
||
const held = {};
|
||
for (const t of bal.tokens) if (t.kind === 'tier_a') held[t.token.id] = { units: t.unitsRedeemable, value: t.satsRedeemable };
|
||
const eligible = tokens.filter((t) => t.asm && t.asm !== 'none');
|
||
if (eligible.length === 0) {
|
||
el.innerHTML = '<span class="muted">No ASM-eligible assets in your registry. Register a verified DEA/PLA/DRA to see it priced here.</span>';
|
||
return;
|
||
}
|
||
let html = '';
|
||
for (const t of eligible) {
|
||
const rate = wholeRate(t.rateSats, t.decimals);
|
||
const h = held[t.id];
|
||
const holdLine = h ? `you hold ${h.units} · ${satsWithBch(h.value)}` : `<span class="muted">not held</span>`;
|
||
html += `<div class="asm-row">` +
|
||
`<div class="tok"><span class="tick">${escapeHtml(t.ticker)}</span>` +
|
||
`<span class="amt">1 ${escapeHtml(t.ticker)} = ${rate} sats (${bchOf(rate)} BCH)</span>` +
|
||
`<span class="cls">${escapeHtml(t.assetClassLabel)} · ${t.asm}</span></div>` +
|
||
`<div class="tok-detail">${holdLine}</div>` +
|
||
`<div class="asset-menu open">` +
|
||
(h ? `<button class="mini" data-act="redeem" data-ticker="${escapeHtml(t.ticker)}">Redeem → BCH</button>` : '') +
|
||
`<button class="mini" data-act="swap" data-ticker="${escapeHtml(t.ticker)}">Swap</button>` +
|
||
`</div></div>`;
|
||
}
|
||
el.innerHTML = html;
|
||
} catch (e) {
|
||
el.innerHTML = `<span class="err-line">ASM error: ${escapeHtml(e.message)}</span>`;
|
||
}
|
||
}
|
||
$('#asm-list').addEventListener('click', (e) => {
|
||
const actBtn = e.target.closest('.asset-menu button');
|
||
if (actBtn) assetAction(actBtn.dataset.act, actBtn.dataset.ticker, actBtn.dataset.units);
|
||
});
|
||
|
||
// Jump to Actions with an op selected and fields pre-filled.
|
||
function assetAction(op, ticker, units) {
|
||
switchView('actions');
|
||
applyOp(op);
|
||
const f = $('#action-form');
|
||
if (op === 'swap') {
|
||
if (f.elements['source']) f.elements['source'].value = ticker || '';
|
||
if (f.elements['target']) f.elements['target'].focus();
|
||
} else if (f.elements['token'] && op !== 'register') {
|
||
f.elements['token'].value = ticker || '';
|
||
}
|
||
if (units !== undefined && units !== '' && f.elements['units']) f.elements['units'].value = units;
|
||
updateMaxUi();
|
||
}
|
||
|
||
// ---- Max / max-redeemable ----
|
||
function maxApplicable(op) { return op === 'redeem' || op === 'send'; }
|
||
function lookupHeld(ref) {
|
||
if (!cachedBalance || !ref) return null;
|
||
const q = ref.trim().toLowerCase();
|
||
for (const t of cachedBalance.tokens) {
|
||
if (!t.token) continue;
|
||
if (t.token.ticker.toLowerCase() === q || t.token.id === ref.trim()) return t;
|
||
}
|
||
return null;
|
||
}
|
||
function maxUnitsOf(t) {
|
||
if (t.kind === 'tier_a') return (BigInt(t.unitsRedeemable || '0') + BigInt(t.unitsUnderbacked || '0')).toString();
|
||
return String(t.unitsTotal || '0');
|
||
}
|
||
function updateMaxUi() {
|
||
const show = maxApplicable(currentOp);
|
||
$('#max-btn').hidden = !show;
|
||
const hint = $('#units-max-hint');
|
||
if (!show) { hint.textContent = ''; return; }
|
||
const t = lookupHeld($('#action-form').elements['token']?.value || '');
|
||
if (!t) { hint.textContent = ''; return; }
|
||
const max = maxUnitsOf(t);
|
||
const val = t.kind === 'tier_a' ? ` ≈ ${bchOf(t.satsRedeemable)} BCH` : '';
|
||
hint.textContent = `· max ${max}${val}`;
|
||
}
|
||
async function applyMax() {
|
||
await ensureBalance();
|
||
const t = lookupHeld($('#action-form').elements['token']?.value || '');
|
||
if (!t) { showResult('<span class="err-line">Enter a token you hold first, then tap Max.</span>', true); return; }
|
||
$('#action-form').elements['units'].value = maxUnitsOf(t);
|
||
updateMaxUi();
|
||
}
|
||
|
||
// ---- action form ----
|
||
let currentOp = 'claim';
|
||
const OP_TITLE = {
|
||
claim: 'Claim', redeem: 'Redeem → BCH', send: 'Send CashToken (Tier A)',
|
||
swap: 'Swap', 'send-b': 'Send wallet-local (Tier B)', create: 'Create asset', register: 'Add a token',
|
||
};
|
||
const OP_FIELDS = {
|
||
claim: { show: ['token', 'units', 'to'], unitsLabel: 'Units to claim', toLabel: 'Recipient', toHint: '(blank = yourself)' },
|
||
redeem: { show: ['token', 'units', 'to'], unitsLabel: 'Units to redeem → BCH', toLabel: 'Send BCH to', toHint: '(blank = yourself)' },
|
||
send: { show: ['token', 'units', 'to'], unitsLabel: 'Units to send', toLabel: 'Recipient token address', toHint: '(required)' },
|
||
swap: { show: ['source', 'target', 'units', 'to'], unitsLabel: 'Target units', toLabel: 'Recipient', toHint: '(blank = yourself)' },
|
||
'send-b': { show: ['token', 'units', 'to'], unitsLabel: 'Units to send', toLabel: 'Recipient cashaddr', toHint: '(required)' },
|
||
create: { show: ['cname', 'cticker', 'crate', 'cfee', 'creservoir', 'cmint'], unitsLabel: '', toLabel: '', toHint: '' },
|
||
register: { show: ['regmode', 'rname', 'rticker', 'rrate', 'raddr', 'definition'], unitsLabel: '', toLabel: '', toHint: '' },
|
||
};
|
||
|
||
function applyOp(op) {
|
||
currentOp = op;
|
||
document.querySelectorAll('.tab').forEach((b) => b.classList.toggle('active', b.dataset.op === op));
|
||
const cfg = OP_FIELDS[op];
|
||
document.querySelectorAll('.field').forEach((f) => { f.hidden = !cfg.show.includes(f.dataset.for); });
|
||
$('#units-label').textContent = cfg.unitsLabel || '';
|
||
$('#to-label').textContent = cfg.toLabel || '';
|
||
$('#to-hint').textContent = cfg.toHint || '';
|
||
$('#action-title').textContent = OP_TITLE[op] || 'Action';
|
||
$('#submit-btn').textContent = op === 'register' ? 'Add token' : 'Preview';
|
||
if (op === 'register') applyRegMode(regMode);
|
||
hidePreview();
|
||
$('#result').hidden = true;
|
||
updateMaxUi();
|
||
}
|
||
|
||
// ---- Register: guided vs paste ----
|
||
let regMode = 'guided';
|
||
function applyRegMode(mode) {
|
||
regMode = mode;
|
||
document.querySelectorAll('#regmode-seg .segbtn').forEach((b) => b.classList.toggle('active', b.dataset.regmode === mode));
|
||
const show = mode === 'guided'
|
||
? ['regmode', 'rname', 'rticker', 'rrate', 'raddr']
|
||
: ['regmode', 'definition'];
|
||
['rname', 'rticker', 'rrate', 'raddr', 'definition'].forEach((f) => {
|
||
const el = document.querySelector(`.field[data-for="${f}"]`);
|
||
if (el) el.hidden = !show.includes(f);
|
||
});
|
||
$('#reg-hint').textContent = mode === 'guided'
|
||
? 'Adds a DRAFT — a private label over your own BCH. Not a real token; only your wallet sees it (no on-chain verification). Good for personal tracking or a closed group.'
|
||
: 'Advanced: paste a real on-chain token definition from an issuer (verified via D-1).';
|
||
if (mode === 'guided') prefillOwnAddress();
|
||
}
|
||
async function prefillOwnAddress() {
|
||
const el = $('#action-form').elements['raddr'];
|
||
if (el && !el.value) { try { el.value = (await getOwnAddrs()).cashaddr; } catch { /* ignore */ } }
|
||
}
|
||
|
||
function formToCommand(withYes) {
|
||
const f = $('#action-form');
|
||
const get = (n) => (f.elements[n]?.value || '').trim();
|
||
if (currentOp === 'create') {
|
||
const flags = {};
|
||
if (get('cname')) flags.name = get('cname');
|
||
if (get('creservoir')) flags.reservoir = get('creservoir');
|
||
if (get('cmint')) flags.mint = get('cmint');
|
||
if (withYes) flags.yes = true;
|
||
return { command: 'create', args: [get('cticker'), get('crate'), get('cfee')], flags };
|
||
}
|
||
const flags = {};
|
||
if (get('to')) flags.to = get('to');
|
||
if (withYes) flags.yes = true;
|
||
if (currentOp === 'swap') return { command: 'swap', args: [get('source'), get('target'), get('units')], flags };
|
||
return { command: currentOp, args: [get('token'), get('units')], flags };
|
||
}
|
||
|
||
// ---- preview / broadcast ----
|
||
function hidePreview() { $('#preview').hidden = true; }
|
||
function showResult(html, isErr) {
|
||
const el = $('#result');
|
||
el.hidden = false;
|
||
el.className = 'result' + (isErr ? ' err' : '');
|
||
el.innerHTML = html;
|
||
}
|
||
async function doPreview(e) {
|
||
e.preventDefault();
|
||
$('#result').hidden = true;
|
||
const btn = e.submitter;
|
||
btn.disabled = true;
|
||
try {
|
||
if (currentOp === 'register') { await doRegister(); return; }
|
||
const res = await postCommand(formToCommand(false));
|
||
$('#preview-text').textContent = res.output;
|
||
$('#preview').hidden = false;
|
||
} catch (err) {
|
||
showResult(`<span class="err-line">${escapeHtml(err.message)}</span>`, true);
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function doRegister() {
|
||
const f = $('#action-form');
|
||
const g = (n) => (f.elements[n]?.value || '').trim();
|
||
let definition;
|
||
if (regMode === 'guided') {
|
||
const name = g('rname'), ticker = g('rticker'), rate = g('rrate'), addr = g('raddr');
|
||
if (!name || !ticker || !rate || !addr) {
|
||
showResult('<span class="err-line">Fill in name, ticker, rate and your address.</span>', true);
|
||
return;
|
||
}
|
||
if (!/^\d+$/.test(rate) || BigInt(rate) <= 0n) {
|
||
showResult('<span class="err-line">Rate must be a positive whole number of sats per unit.</span>', true);
|
||
return;
|
||
}
|
||
const now = Date.now();
|
||
definition = {
|
||
version: 1, id: `tb-${now}-${Math.random().toString(36).slice(2, 8)}`, tier: 'B',
|
||
name, ticker, rateSats: rate, createdAt: now, lastSeen: now,
|
||
userVerified: false, pinned: false, hidden: false, associatedAddresses: [addr],
|
||
};
|
||
} else {
|
||
definition = g('definition');
|
||
if (!definition) { showResult('<span class="err-line">Paste a token definition first.</span>', true); return; }
|
||
}
|
||
const res = await postRegister(definition);
|
||
let html = escapeHtml(res.summary);
|
||
if (res.confirmation) {
|
||
html += `<pre class="confirm">${escapeHtml(res.confirmation.headline)}\n\n${escapeHtml(res.confirmation.body)}</pre>`;
|
||
}
|
||
showResult(html, false);
|
||
cachedBalance = null;
|
||
// Clear guided inputs so the next add starts fresh.
|
||
if (regMode === 'guided') ['rname', 'rticker', 'rrate'].forEach((n) => { if (f.elements[n]) f.elements[n].value = ''; });
|
||
}
|
||
|
||
async function doBroadcast() {
|
||
const btn = $('#broadcast');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Broadcasting…';
|
||
try {
|
||
const res = await postCommand(formToCommand(true));
|
||
hidePreview();
|
||
if (res.txid) {
|
||
showResult(`Broadcast sent.<br/>TXID: <code>${escapeHtml(res.txid)}</code>`, false);
|
||
setTimeout(() => { fetchBalance().then(() => { if (!$('#view-home').hidden) renderHome(cachedBalance); }); }, 1500);
|
||
} else {
|
||
showResult(`<pre>${escapeHtml(res.output)}</pre>`, false);
|
||
}
|
||
} catch (err) {
|
||
showResult(`<span class="err-line">${escapeHtml(err.message)}</span>`, true);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = 'Confirm & Broadcast';
|
||
}
|
||
}
|
||
|
||
// ---- modals (Send / Receive) ----
|
||
function openModal(sel) { $(sel).hidden = false; }
|
||
function closeModal(sel) { $(sel).hidden = true; }
|
||
function recvRow(label, addr) {
|
||
return `<div class="recv-row"><div class="muted">${label}</div>` +
|
||
`<code class="addr">${escapeHtml(addr)}</code>` +
|
||
`<button class="mini" data-copy="${escapeHtml(addr)}">Copy</button></div>`;
|
||
}
|
||
async function showReceive() {
|
||
const body = $('#receive-body');
|
||
body.innerHTML = 'Loading…';
|
||
openModal('#receive-modal');
|
||
try {
|
||
const w = await getOwnAddrs();
|
||
body.innerHTML = recvRow('BCH address (receive BCH)', w.cashaddr) +
|
||
recvRow('Token address (receive CashTokens)', w.tokenAddress);
|
||
} catch (e) {
|
||
body.innerHTML = `<span class="err-line">${escapeHtml(e.message)}</span>`;
|
||
}
|
||
}
|
||
|
||
// ---- Buy / On-ramp (ADR-0036) ----
|
||
// Renders the ranked third-party acquisition providers. Three transparency
|
||
// signals are shown SEPARATELY and never merged into one Deviant score:
|
||
// objective facts (KYC posture + regulator flags), external reputation
|
||
// (kycnot.me, attributed), and the user's own local rating. Execution is an
|
||
// external handoff — the wallet never custodies and never endorses.
|
||
|
||
const KYC_LABEL = {
|
||
none: 'No KYC',
|
||
threshold: 'Low-KYC',
|
||
'per-provider': 'KYC varies',
|
||
required: 'KYC required',
|
||
};
|
||
const JOURNEY_LABEL = { 'fiat-to-bch': 'Fiat→BCH', 'crypto-to-bch': 'Crypto→BCH' };
|
||
|
||
// Tier-3 (ADR-0036): is the in-wallet SideShift shift flow available on this
|
||
// server? Set from /api/onramp/config. Single active status poll at a time.
|
||
let buyShiftEnabled = false;
|
||
let shiftPollTimer = null;
|
||
const SHIFT_TERMINAL = ['settled', 'refunded', 'expired'];
|
||
|
||
// Warnings shown as a prominent banner vs. quietly in a fine-print footnote.
|
||
const LOUD_WARNINGS = new Set(['onramp-regulator-flag', 'onramp-region-unavailable']);
|
||
|
||
function kycChip(facts) {
|
||
let label = KYC_LABEL[facts.kycPosture] || facts.kycPosture;
|
||
if (facts.kycPosture === 'threshold' && facts.kycThreshold) {
|
||
label += ` ≤ ${facts.kycThreshold.amount} ${facts.kycThreshold.currency}`;
|
||
}
|
||
return `<span class="chip kyc-${facts.kycPosture}">${escapeHtml(label)}</span>`;
|
||
}
|
||
function reputationChip(rep) {
|
||
if (!rep || !rep.available) return `<span class="chip rep-na" title="External reputation unavailable">kycnot.me · n/a</span>`;
|
||
const score = Number(rep.score).toFixed(1);
|
||
const inner = `kycnot.me · ${score}/10`;
|
||
return rep.sourceUrl
|
||
? `<a class="chip rep" href="${escapeHtml(rep.sourceUrl)}" target="_blank" rel="noopener">${inner} ↗</a>`
|
||
: `<span class="chip rep">${inner}</span>`;
|
||
}
|
||
function ratingStars(p) {
|
||
const cur = p.localRating || 0;
|
||
let out = '<span class="rating" title="Your private rating (this device only)">';
|
||
for (let n = 1; n <= 5; n++) {
|
||
out += `<button class="rstar${n <= cur ? ' on' : ''}" data-onramp-rate="${escapeHtml(p.id)}" data-n="${n}">${n <= cur ? '★' : '☆'}</button>`;
|
||
}
|
||
if (cur) out += `<button class="rstar clear" data-onramp-rate="${escapeHtml(p.id)}" data-n="0" title="Clear">✕</button>`;
|
||
return out + '</span>';
|
||
}
|
||
function buyCardHtml(p) {
|
||
const journeys = (p.journeys || []).map((j) => `<span class="chip j">${JOURNEY_LABEL[j] || j}</span>`).join('');
|
||
const loud = (p.warnings || []).filter((w) => LOUD_WARNINGS.has(w.code));
|
||
const quiet = (p.warnings || []).filter((w) => !LOUD_WARNINGS.has(w.code));
|
||
const loudHtml = loud.map((w) => `<div class="warn ${w.severity}">${escapeHtml(w.message)}</div>`).join('');
|
||
const quietHtml = quiet.length
|
||
? `<details class="fineprint"><summary>Details & disclosures</summary>` +
|
||
quiet.map((w) => `<div class="note ${w.severity}">${escapeHtml(w.message)}</div>`).join('') + `</details>`
|
||
: '';
|
||
const featured = p.featuredEligible ? `<span class="chip featured" title="Meets the facts bar (no regulator flag, available in region)">Top match</span>` : '';
|
||
const unavailable = p.availableInRegion === false ? ' unavailable' : '';
|
||
const canQuote = (p.id === 'sideshift' || p.id === 'trocador') && p.journeys.includes('crypto-to-bch');
|
||
const canShift = p.id === 'sideshift' && buyShiftEnabled && p.availableInRegion !== false;
|
||
const id = escapeHtml(p.id);
|
||
// SideShift Tier-3: in-wallet swap (primary) + a link-out fallback. Everyone
|
||
// else is link-out only.
|
||
const primary = canShift
|
||
? `<button class="primary mini" data-onramp-shift="${id}">Swap in wallet</button>` +
|
||
`<button class="mini" data-onramp-open="${id}">Open site ↗</button>`
|
||
: `<button class="primary mini" data-onramp-open="${id}"${p.availableInRegion === false ? ' disabled' : ''}>Open ${escapeHtml(p.name)} ↗</button>`;
|
||
return `<div class="buycard${unavailable}" data-id="${id}" data-journeys="${escapeHtml((p.journeys || []).join(','))}">` +
|
||
`<div class="buycard-head"><span class="buy-name">${escapeHtml(p.name)}</span>${featured}</div>` +
|
||
`<div class="chips">${journeys}${kycChip(p.facts)}${reputationChip(p.reputation)}</div>` +
|
||
loudHtml +
|
||
`<div class="buycard-actions">` +
|
||
primary +
|
||
(canQuote ? `<button class="mini" data-onramp-quote="${id}">Get quote</button>` : '') +
|
||
ratingStars(p) +
|
||
`</div>` +
|
||
`<div class="quote-out" hidden></div>` +
|
||
quietHtml +
|
||
`</div>`;
|
||
}
|
||
function buyQuery() {
|
||
const qs = new URLSearchParams();
|
||
const j = $('#buy-journey').value;
|
||
const c = ($('#buy-country').value || '').trim();
|
||
if (j) qs.set('journey', j);
|
||
if (c) qs.set('country', c);
|
||
return qs.toString() ? `?${qs}` : '';
|
||
}
|
||
async function loadBuy() {
|
||
const list = $('#buy-list');
|
||
list.innerHTML = 'Loading…';
|
||
if (shiftPollTimer) { clearInterval(shiftPollTimer); shiftPollTimer = null; }
|
||
try {
|
||
// Capability flag first so cards can offer the in-wallet swap (best-effort).
|
||
try { buyShiftEnabled = !!(await getJson('/api/onramp/config')).shiftEnabled; }
|
||
catch { buyShiftEnabled = false; }
|
||
const providers = await getJson('/api/onramp' + buyQuery());
|
||
list.innerHTML = providers.length
|
||
? providers.map(buyCardHtml).join('')
|
||
: '<p class="muted">No providers match this filter.</p>';
|
||
} catch (e) {
|
||
list.innerHTML = `<span class="err-line">${escapeHtml(e.message)}</span>`;
|
||
}
|
||
}
|
||
|
||
// Render an in-wallet SideShift shift: deposit address + range + status. The
|
||
// secret never reaches here — only this non-secret shift data does.
|
||
function renderShift(card, shift) {
|
||
const out = card.querySelector('.quote-out');
|
||
out.hidden = false;
|
||
const coin = escapeHtml((shift.depositCoin || '').toUpperCase());
|
||
const range = (shift.depositMin || shift.depositMax)
|
||
? `Send between ${escapeHtml(shift.depositMin || '?')} and ${escapeHtml(shift.depositMax || '?')} ${coin}.`
|
||
: `Send any amount of ${coin}.`;
|
||
out.innerHTML =
|
||
`<div class="shift">` +
|
||
`<div class="muted">Send your <b>${coin}</b> to this SideShift address — BCH settles to <b>your own</b> wallet address. Self-custody; nothing is held by Deviant.</div>` +
|
||
`<div class="recv-row"><code class="addr">${escapeHtml(shift.depositAddress)}</code>` +
|
||
`<button class="mini" data-copy="${escapeHtml(shift.depositAddress)}">Copy</button></div>` +
|
||
(shift.depositMemo ? `<div class="muted">Memo/Tag (required): <code>${escapeHtml(shift.depositMemo)}</code></div>` : '') +
|
||
`<div class="muted">${range}</div>` +
|
||
`<div class="muted">Settles to: <code class="addr">${escapeHtml(shift.settleAddress)}</code></div>` +
|
||
`<div class="shift-status">Status: <b>${escapeHtml(shift.status)}</b></div>` +
|
||
`</div>`;
|
||
}
|
||
function startShiftPoll(card, id) {
|
||
if (shiftPollTimer) clearInterval(shiftPollTimer);
|
||
shiftPollTimer = setInterval(async () => {
|
||
try {
|
||
const r = await getJson('/api/onramp/shift?id=' + encodeURIComponent(id));
|
||
const s = r.shift;
|
||
if (!s) return;
|
||
const el = card.querySelector('.shift-status');
|
||
if (el) el.innerHTML = `Status: <b>${escapeHtml(s.status)}</b>` +
|
||
(s.settleAmount ? ` · ${escapeHtml(s.settleAmount)} BCH` : '');
|
||
if (SHIFT_TERMINAL.includes(s.status)) { clearInterval(shiftPollTimer); shiftPollTimer = null; }
|
||
} catch { /* transient — keep polling */ }
|
||
}, 6000);
|
||
}
|
||
function pickJourney(card) {
|
||
const journeys = (card.dataset.journeys || '').split(',').filter(Boolean);
|
||
const selected = $('#buy-journey').value;
|
||
if (selected && journeys.includes(selected)) return selected;
|
||
return journeys[0] || 'crypto-to-bch';
|
||
}
|
||
async function onBuyClick(e) {
|
||
const card = e.target.closest('.buycard');
|
||
// Copy (deposit / settle address).
|
||
const copyBtn = e.target.closest('[data-copy]');
|
||
if (copyBtn) {
|
||
try {
|
||
await navigator.clipboard.writeText(copyBtn.dataset.copy);
|
||
const old = copyBtn.textContent; copyBtn.textContent = 'Copied!';
|
||
setTimeout(() => { copyBtn.textContent = old; }, 1200);
|
||
} catch { /* clipboard blocked */ }
|
||
return;
|
||
}
|
||
// Tier-3: create an in-wallet SideShift shift (secret stays server-side).
|
||
const shiftBtn = e.target.closest('[data-onramp-shift]');
|
||
if (shiftBtn && card) {
|
||
const out = card.querySelector('.quote-out'); out.hidden = false;
|
||
const depositCoin = ($('#buy-from').value || '').trim();
|
||
if (!depositCoin) {
|
||
out.innerHTML = `<span class="err-line">Enter the coin you'll deposit in the "from" field (e.g. btc, eth, usdtErc20), then tap again.</span>`;
|
||
return;
|
||
}
|
||
out.textContent = 'Creating shift…'; shiftBtn.disabled = true;
|
||
try {
|
||
const shift = await postJson('/api/onramp/shift', { depositCoin });
|
||
renderShift(card, shift);
|
||
startShiftPoll(card, shift.id);
|
||
} catch (err) {
|
||
out.innerHTML = `<span class="err-line">${escapeHtml(err.message)}</span>`;
|
||
} finally { shiftBtn.disabled = false; }
|
||
return;
|
||
}
|
||
// Local rating (private, this device only).
|
||
const rateBtn = e.target.closest('[data-onramp-rate]');
|
||
if (rateBtn) {
|
||
const n = Number(rateBtn.dataset.n);
|
||
try {
|
||
await postJson('/api/onramp/rate', { providerId: rateBtn.dataset.onrampRate, rating: n === 0 ? null : n });
|
||
await loadBuy();
|
||
} catch (err) { /* surface inline */ if (card) card.querySelector('.quote-out').hidden = false, card.querySelector('.quote-out').innerHTML = `<span class="err-line">${escapeHtml(err.message)}</span>`; }
|
||
return;
|
||
}
|
||
// Tier-1 handoff: build URL server-side, open externally (never embedded).
|
||
const openBtn = e.target.closest('[data-onramp-open]');
|
||
if (openBtn && card) {
|
||
openBtn.disabled = true;
|
||
try {
|
||
const out = await postJson('/api/onramp/handoff', {
|
||
providerId: openBtn.dataset.onrampOpen,
|
||
journey: pickJourney(card),
|
||
amount: ($('#buy-amount').value || '').trim() || undefined,
|
||
fromUnit: ($('#buy-from').value || '').trim() || undefined,
|
||
});
|
||
window.open(out.url, '_blank', 'noopener,noreferrer');
|
||
} catch (err) {
|
||
const o = card.querySelector('.quote-out'); o.hidden = false;
|
||
o.innerHTML = `<span class="err-line">${escapeHtml(err.message)}</span>`;
|
||
} finally { openBtn.disabled = false; }
|
||
return;
|
||
}
|
||
// Tier-2 read-only quote.
|
||
const quoteBtn = e.target.closest('[data-onramp-quote]');
|
||
if (quoteBtn && card) {
|
||
const o = card.querySelector('.quote-out'); o.hidden = false; o.textContent = 'Quoting…';
|
||
try {
|
||
const res = await postJson('/api/onramp/quote', {
|
||
providerId: quoteBtn.dataset.onrampQuote,
|
||
journey: 'crypto-to-bch',
|
||
fromAmount: ($('#buy-amount').value || '').trim(),
|
||
fromUnit: ($('#buy-from').value || '').trim(),
|
||
});
|
||
if (!res.quote) {
|
||
o.innerHTML = '<span class="muted">Quote unavailable (needs a from-amount + crypto unit, and the provider’s quote API).</span>';
|
||
} else {
|
||
o.innerHTML = `<span class="muted">≈ ${satsWithBch(res.quote.estimatedBchSats)} — indicative; the provider re-quotes at checkout.</span>`;
|
||
}
|
||
} catch (err) {
|
||
o.innerHTML = `<span class="err-line">${escapeHtml(err.message)}</span>`;
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
// ---- wire up ----
|
||
document.querySelectorAll('.navbtn').forEach((b) => b.addEventListener('click', () => switchView(b.dataset.view)));
|
||
document.querySelectorAll('.tab').forEach((b) => b.addEventListener('click', () => applyOp(b.dataset.op)));
|
||
document.querySelectorAll('#regmode-seg .segbtn').forEach((b) => b.addEventListener('click', () => applyRegMode(b.dataset.regmode)));
|
||
$('#balance').addEventListener('click', onAssetClick);
|
||
$('#favorites').addEventListener('click', onAssetClick);
|
||
$('#action-form').addEventListener('submit', doPreview);
|
||
$('#action-form').elements['token'].addEventListener('input', updateMaxUi);
|
||
$('#max-btn').addEventListener('click', applyMax);
|
||
$('#broadcast').addEventListener('click', doBroadcast);
|
||
$('#cancel').addEventListener('click', hidePreview);
|
||
$('#refresh').addEventListener('click', loadBalance);
|
||
$('#refresh-home').addEventListener('click', loadHome);
|
||
$('#refresh-asm').addEventListener('click', loadAsm);
|
||
$('#refresh-buy').addEventListener('click', loadBuy);
|
||
$('#buy-list').addEventListener('click', onBuyClick);
|
||
$('#buy-journey').addEventListener('change', loadBuy);
|
||
$('#buy-country').addEventListener('change', loadBuy);
|
||
|
||
// Bottom bar + modals
|
||
$('#btn-send').addEventListener('click', () => openModal('#send-modal'));
|
||
$('#btn-receive').addEventListener('click', showReceive);
|
||
$('#send-close').addEventListener('click', () => closeModal('#send-modal'));
|
||
$('#receive-close').addEventListener('click', () => closeModal('#receive-modal'));
|
||
$('#send-token').addEventListener('click', () => { closeModal('#send-modal'); switchView('actions'); applyOp('send'); });
|
||
$('#send-local').addEventListener('click', () => { closeModal('#send-modal'); switchView('actions'); applyOp('send-b'); });
|
||
$('#receive-body').addEventListener('click', async (e) => {
|
||
const btn = e.target.closest('button[data-copy]');
|
||
if (!btn) return;
|
||
try {
|
||
await navigator.clipboard.writeText(btn.dataset.copy);
|
||
const old = btn.textContent; btn.textContent = 'Copied!';
|
||
setTimeout(() => { btn.textContent = old; }, 1200);
|
||
} catch { /* clipboard blocked */ }
|
||
});
|
||
// Click backdrop to close either modal.
|
||
document.querySelectorAll('.modal').forEach((m) =>
|
||
m.addEventListener('click', (e) => { if (e.target === m) m.hidden = true; }));
|
||
|
||
applyOp('claim');
|
||
loadWalletMeta();
|
||
switchView('home');
|