deviant/public/app.js
walerikus 3148c05119 feat(onramp): SideShift Tier-3 in-wallet shift (ADR-0036 amendment)
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>
2026-06-09 20:57:31 +02:00

754 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 &amp; 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');