diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ce12710 --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# Deviant Wallet — environment config (on-ramp providers, ADR-0036) +# +# HOW TO USE: +# 1. Copy this file to `.env` in the project root: cp .env.example .env +# 2. Fill in the values you have (all are OPTIONAL — the wallet runs without +# any of them; missing keys just leave that provider's feature dormant). +# 3. Start the web UI: npm run deviant:web (it auto-loads `.env`) +# +# `.env` is gitignored — it is never committed. This `.env.example` is the +# safe, value-free template; keep your real secrets only in `.env`. + +# --- Reputation (external, read-only) --- +# kycnot.me API key. Admin-approved: request access at https://kycnot.me/contact, +# then generate the key at https://kycnot.me/account/api-keys (format: kycnot_...). +# Without it, providers show "kycnot.me · n/a" and ranking uses facts + your ratings. +KYCNOT_API_KEY= + +# --- Provider API keys (Tier-2 live quotes) --- +# Trocador affiliate/API key — required for Trocador live quotes (handoff works +# without it). Register: https://trocador.app/en/register/affiliate +TROCADOR_API_KEY= + +# --- Referral / affiliate IDs (optional revenue; never affect ranking) --- +# These only ride in the Tier-1 handoff URL to attribute commission to you. +GUARDARIAN_REF= +SIDESHIFT_AFFILIATE= +TROCADOR_REF= +CHANGELLY_REF= diff --git a/public/app.js b/public/app.js index 5f913cf..3156502 100644 --- a/public/app.js +++ b/public/app.js @@ -497,6 +497,12 @@ const KYC_LABEL = { }; 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']); @@ -536,13 +542,21 @@ function buyCardHtml(p) { const featured = p.featuredEligible ? `Top match` : ''; const unavailable = p.availableInRegion === false ? ' unavailable' : ''; const canQuote = (p.id === 'sideshift' || p.id === 'trocador') && p.journeys.includes('crypto-to-bch'); - return `
` + + 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 + ? `` + + `` + : ``; + return `
` + `
${escapeHtml(p.name)}${featured}
` + `
${journeys}${kycChip(p.facts)}${reputationChip(p.reputation)}
` + loudHtml + `
` + - `` + - (canQuote ? `` : '') + + primary + + (canQuote ? `` : '') + ratingStars(p) + `
` + `` + @@ -560,7 +574,11 @@ function buyQuery() { 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('') @@ -569,6 +587,41 @@ async function loadBuy() { list.innerHTML = `${escapeHtml(e.message)}`; } } + +// 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 = + `
` + + `
Send your ${coin} to this SideShift address — BCH settles to your own wallet address. Self-custody; nothing is held by Deviant.
` + + `
${escapeHtml(shift.depositAddress)}` + + `
` + + (shift.depositMemo ? `
Memo/Tag (required): ${escapeHtml(shift.depositMemo)}
` : '') + + `
${range}
` + + `
Settles to: ${escapeHtml(shift.settleAddress)}
` + + `
Status: ${escapeHtml(shift.status)}
` + + `
`; +} +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: ${escapeHtml(s.status)}` + + (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; @@ -577,6 +630,35 @@ function pickJourney(card) { } 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 = `Enter the coin you'll deposit in the "from" field (e.g. btc, eth, usdtErc20), then tap again.`; + 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 = `${escapeHtml(err.message)}`; + } finally { shiftBtn.disabled = false; } + return; + } // Local rating (private, this device only). const rateBtn = e.target.closest('[data-onramp-rate]'); if (rateBtn) { diff --git a/public/style.css b/public/style.css index c1d2e70..f4bbbc6 100644 --- a/public/style.css +++ b/public/style.css @@ -194,6 +194,10 @@ body { padding-bottom: 4.5rem; } .warn.error { background: #2a1414; border: 1px solid #5a2b2b; color: #f0a3a0; } .warn.info { background: #11202c; border: 1px solid #234; color: var(--muted); } .quote-out { font-size: .82rem; margin-top: .5rem; } +.shift { display: flex; flex-direction: column; gap: .45rem; } +.shift .recv-row { display: flex; align-items: center; gap: .5rem; } +.shift .addr { word-break: break-all; background: #0d1218; border: 1px solid var(--border); border-radius: 6px; padding: .35rem .5rem; font-size: .76rem; flex: 1 1 auto; } +.shift-status { font-size: .85rem; color: var(--fg); } .fineprint { margin-top: .55rem; font-size: .78rem; } .fineprint summary { color: var(--muted); cursor: pointer; } .fineprint .note { color: var(--muted); padding: .3rem 0; border-top: 1px solid var(--border); margin-top: .3rem; } diff --git a/scripts/deviant-web.mjs b/scripts/deviant-web.mjs new file mode 100644 index 0000000..b7070cd --- /dev/null +++ b/scripts/deviant-web.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node +/** + * deviant-web — local web UI for the Deviant wallet. + * + * Starts a localhost HTTP server that serves the SPA in `public/` and a + * small JSON API backed by a real DeviantWallet (same core the CLI uses). + * + * Run: node --import tsx scripts/deviant-web.mjs + * npm: npm run deviant:web + * + * Flags (same defaults as the CLI): + * --keys WIF keys file (default .chipnet-keys.json) + * --network chipnet | testnet4 | mainnet (default chipnet) + * --tokens token JSON files (default asm + asm2) + * --port listen port (default 8088) + * + * Binds to 127.0.0.1 only — never expose this off the machine; it can sign + * and broadcast with the wallet's key. + */ +import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { TestNetWallet, Wallet } from 'mainnet-js'; +import { InMemoryTokenRegistry } from '../src/token/registry.js'; +import { tokenToPortable } from '../src/token/portable.js'; +import { connectWallet, tokenFileToTierA } from '../src/wallet/runtime.js'; +import { registerToken } from '../src/wallet/register.js'; +import { startWebServer } from '../src/web/server.js'; +import { parseArgs } from '../src/cli/wallet-cli.js'; + +const ROOT = join(import.meta.dirname, '..'); +const TOKENS_DIR = join(ROOT, '.deviant', 'tokens'); + +// Load `.env` from the project root if present, so on-ramp keys/referral IDs +// (KYCNOT_API_KEY, TROCADOR_API_KEY, SIDESHIFT_AFFILIATE, …) reach process.env. +// Uses Node's built-in loader (v20.12+/v21.7+); silently skipped if absent. +const ENV_FILE = join(ROOT, '.env'); +if (existsSync(ENV_FILE) && typeof process.loadEnvFile === 'function') { + try { process.loadEnvFile(ENV_FILE); } catch { /* malformed .env — ignore */ } +} + +async function main() { + // deviant-web takes only flags (no command). Prepend a placeholder so the + // flag parser doesn't consume the first `--flag` as the command slot. + const { flags } = parseArgs(['web', ...process.argv.slice(2)]); + const network = typeof flags.network === 'string' ? flags.network : 'chipnet'; + const port = typeof flags.port === 'string' ? Number(flags.port) : 8088; + const keysPath = typeof flags.keys === 'string' ? flags.keys : join(ROOT, '.chipnet-keys.json'); + const tokenPaths = typeof flags.tokens === 'string' + ? flags.tokens.split(',').map((p) => p.trim()) + : ['.chipnet-asm-token.json', '.chipnet-asm2-token.json'].map((f) => join(ROOT, f)); + + const registry = new InMemoryTokenRegistry(); + for (const p of tokenPaths) { + const abs = p.startsWith('/') || /^[A-Za-z]:/.test(p) ? p : join(ROOT, p); + if (existsSync(abs)) registry.add(tokenFileToTierA(JSON.parse(readFileSync(abs, 'utf8'))), 'replace'); + } + // Persistent registered tokens (same dir the CLI's `register` writes to). + if (existsSync(TOKENS_DIR)) { + for (const f of readdirSync(TOKENS_DIR)) { + if (!f.endsWith('.json')) continue; + try { + registerToken(JSON.parse(readFileSync(join(TOKENS_DIR, f), 'utf8')), registry, { policy: 'replace' }); + } catch (err) { + console.error(`[warn] skipping ${f}: ${err?.message ?? err}`); + } + } + } + + // Persist newly-registered tokens as portable JSON so they reload next run. + const persistToken = (token) => { + mkdirSync(TOKENS_DIR, { recursive: true }); + writeFileSync(join(TOKENS_DIR, `${token.id}.json`), JSON.stringify(tokenToPortable(token), null, 2)); + }; + // Drop a forgotten token's persisted file ("Convert to BCH" for Tier B). + const forgetToken = (id) => { + const f = join(TOKENS_DIR, `${id}.json`); + if (existsSync(f)) rmSync(f); + }; + + const { wif } = JSON.parse(readFileSync(keysPath, 'utf8')); + const w = network === 'mainnet' ? await Wallet.fromWIF(wif) : await TestNetWallet.fromWIF(wif); + + const wallet = connectWallet({ + registry, + network, + privateKey: w.privateKey, + cashaddr: w.cashaddr, + tokenAddress: w.tokenaddr, + mainnetWallet: w, // enables asset creation (genesis) + }); + + startWebServer( + { + ctx: { wallet, registry, ownTokenAddress: w.tokenaddr }, + publicDir: join(ROOT, 'public'), + walletInfo: { network, cashaddr: w.cashaddr, tokenAddress: w.tokenaddr }, + persistToken, + forgetToken, + }, + port, + ); + + console.log(`Deviant web UI → http://127.0.0.1:${port} (${network}, ${w.cashaddr})`); + console.log('Ctrl-C to stop.'); +} + +main().catch((err) => { + console.error('FAILED:', err?.message ?? err); + if (err?.stack) console.error(err.stack); + process.exit(1); +}); diff --git a/snapshots/0103-sideshift-tier-3-in-wallet-shift-level-b.md b/snapshots/0103-sideshift-tier-3-in-wallet-shift-level-b.md new file mode 100644 index 0000000..3d9b07e --- /dev/null +++ b/snapshots/0103-sideshift-tier-3-in-wallet-shift-level-b.md @@ -0,0 +1,314 @@ +# Snapshot 0103: sideshift tier-3 in-wallet shift level-b + +_Captured 2026-06-01T16:21:47.226Z_ + +## Git state + +- Rev: `d1bc0f0` +- Working tree: + +``` +M public/app.js + M public/style.css + M shells/app/index.html + M src/onramp/index.ts + M src/onramp/types.ts + M src/web/api.ts + M src/web/server.ts + M test/unit/web/onramp-route.test.ts + M wiki/decisions.md +?? .chipnet-anyhedge-hedge-leg-test.json +?? .chipnet-anyhedge-liquidation-test.json +?? .chipnet-anyhedge-test-oracle.json +?? .chipnet-clob-amm-test.json +?? .chipnet-clob-bch-test.json +?? .chipnet-clob-fungible-test.json +?? .chipnet-clob-mba-test.json +?? .chipnet-clob-orders-test.json +?? .chipnet-descfee-supply-test.json +?? .chipnet-divisible-metals.json +?? .chipnet-escfee-blockheight-test.json +?? .chipnet-escfee-supply-test.json +?? .chipnet-escfee-txcount-test.json +?? .chipnet-expiring-mba-test.json +?? .chipnet-metal-mbas.json +?? .chipnet-metal-revenue-test.json +?? .chipnet-opentote-test.json +?? .chipnet-oracle-markets-test.json +?? .chipnet-oracle-vault-test.json +?? .chipnet-tote2-k4-test.json +?? .chipnet-tote2-mba-rake-test.json +?? .chipnet-tote2-mba-test.json +?? .chipnet-tote2-rake-test.json +?? .chipnet-tote2-rs-test.json +?? .chipnet-tote2-test.json +?? .chipnet-tote4-test.json +?? .claude/ +?? .env.example +?? .gitignore +?? .tmp-mkutxo.mts +?? Archive/ +?? BCH_Token_Economy_Concept_Paper.md +?? CLAUDE.md +?? KnowledgeBase/ +?? README.md +?? bch_wallet_architecture.svg +?? contracts/ +?? design/ +?? deviant_technical_spec_v2.7.md +?? package-lock.json +?? package.json +?? scripts/ +?? shells/app/BETA_TESTING.md +?? shells/app/DEPLOY.md +?? shells/app/NATIVE_BUILD.md +?? shells/app/README.md +?? shells/app/android/ +?? shells/app/capacitor.config.ts +?? shells/app/package-lock.json +?? shells/app/package.json +?? shells/app/public/ +?? shells/app/src/buildInfo.ts +?? shells/app/src/components/ +?? shells/app/src/core/ +?? shells/app/src/format.ts +?? shells/app/src/keystore/ +?? shells/app/src/main.tsx +?? shells/app/src/screens/ +?? shells/app/src/shell.css +?? shells/app/src/sweep.ts +?? shells/app/src/theme/ +?? shells/app/test/ +?? shells/app/tsconfig.json +?? shells/app/vite.config.ts +?? shells/app/vitest.config.ts +?? shells/mobile/ +?? snapshots/0001-init.md +?? snapshots/0002-swap-core.md +?? snapshots/0003-regtest-harness.md +?? snapshots/0004-spec-arrival.md +?? snapshots/0005-q4-handoff.md +?? snapshots/0006-deps-pinned.md +?? snapshots/0007-cashtokens-contract.md +?? snapshots/0008-q4-result.md +?? snapshots/0009-cross-founder-mint.md +?? snapshots/0010-terminology-refresh.md +?? snapshots/0011-rate-peg-attack.md +?? snapshots/0012-vulnerability-audit.md +?? snapshots/0013-asm-flow-batching.md +?? snapshots/0014-asm-asm2-bidirectional-swap.md +?? snapshots/0015-bulk-swap-100x100.md +?? snapshots/0016-bulk-swap-100-complete.md +?? snapshots/0017-swap-mechanism-threat-report.md +?? snapshots/0018-non-canonical-contract-rug.md +?? snapshots/0018-phase2-token-registry.md +?? snapshots/0019-b-model-definition-poisoning.md +?? snapshots/0019-central-registry.md +?? snapshots/0020-t001-poc-chipnet.md +?? snapshots/0021-fee-economics-and-stablecoin-frame.md +?? snapshots/0022-d1-d2-d3-production.md +?? snapshots/0022-verify-module-d1-d2-d3.md +?? snapshots/0023-mutability-test-empirical.md +?? snapshots/0023-wallet-display-layer.md +?? snapshots/0024-protocol-boundary-empirical.md +?? snapshots/0024-wallet-send-import-d5.md +?? snapshots/0025-escalating-rate-bonding-curve.md +?? snapshots/0025-wallet-send-tier-a.md +?? snapshots/0026-rate-mechanics-and-consolidated-report.md +?? snapshots/0026-wallet-swap-plan.md +?? snapshots/0027-bonding-curve-swap-and-premium-feature.md +?? snapshots/0027-wallet-shell-tx-builder.md +?? snapshots/0028-unbacked-tokens-not-asm-swappable.md +?? snapshots/0028-wallet-finalized.md +?? snapshots/0029-chipnet-scripts-on-library.md +?? snapshots/0030-sqlite-registry.md +?? snapshots/0031-verify-discover.md +?? snapshots/0032-phase3-claim-integration.md +?? snapshots/0033-live-chipnet-smoke-test.md +?? snapshots/0034-tierb-live-and-address-fix.md +?? snapshots/0035-wallet-cli.md +?? snapshots/0036-swap-mechanics-divisibility-denomination.md +?? snapshots/0036-web-ui.md +?? snapshots/0037-denomination-and-dra-template.md +?? snapshots/0038-redeem-register-cli-gui-enrichment.md +?? snapshots/0039-game-pool-escrow.md +?? snapshots/0040-escrow-flat-fee-and-irregular.md +?? snapshots/0040-keystore-interface.md +?? snapshots/0041-rn-mobile-shell-slice.md +?? snapshots/0042-noble-crypto-shim-libauth-wasm-replaced-.md +?? snapshots/0043-rn-shell-decision-reopened-capacitor-spike.md +?? snapshots/0044-capacitor-spike-go.md +?? snapshots/0044-event-prediction-and-tote-design.md +?? snapshots/0045-oracle-markets-chipnet.md +?? snapshots/0046-parimutuel-tote-4party-chipnet.md +?? snapshots/0047-open-entry-tote-nft-free-chipnet.md +?? snapshots/0048-tier2-open-tote-chipnet.md +?? snapshots/0049-tier2-mba-tote-chipnet.md +?? snapshots/0050-tote-refund-sweep-and-denomination.md +?? snapshots/0051-tote-operator-rake-chipnet.md +?? snapshots/0052-canonical-artifacts-embedded-json.md +?? snapshots/0052-rake-folded-into-canonical-totes.md +?? snapshots/0053-cashconnect-scaffold-and-mba-rake-chipnet.md +?? snapshots/0053-cosmogenesis-wallet-ui-four-tab-shell-ri.md +?? snapshots/0054-k2-multioutcome-approach1-inline.md +?? snapshots/0054-nba-taxonomy-correction-bch-logo-nav-fix.md +?? snapshots/0055-taxonomy-adr0028-remaining-mockups-cashfusion-trade-consolidation.md +?? snapshots/0055-walletconnect-handshake-device-validated.md +?? snapshots/0056-cosmogenesis-four-tab-shell-built.md +?? snapshots/0056-reusable-proposer-core.md +?? snapshots/0057-shell-avalanche-wizard-and-adr0027-0028-reconcile.md +?? snapshots/0057-walletconnect-cosign-validated.md +?? snapshots/0058-escalating-fee-feasibility-chipnet.md +?? snapshots/0059-escalating-fee-adr-and-decreasing-variant.md +?? snapshots/0059-shell-slices-4-through-8-built.md +?? snapshots/0060-family-d-collateralized-binary-share-mar.md +?? snapshots/0060-shell-slice9-live-data-wired.md +?? snapshots/0061-cashconnect-adapter-pinned-to-installed-api.md +?? snapshots/0062-family-d-config-1-pure-bch-clob-split-me.md +?? snapshots/0062-synthetic-usd-hedge-adr0031.md +?? snapshots/0063-family-d-configs-2-3-mba-collateral-fung.md +?? snapshots/0064-family-d-on-chain-resting-limit-orders-s.md +?? snapshots/0065-family-d-amm-sharepairamm-adr-0031-oracl.md +?? snapshots/0066-expiringmba-time-boxed-self-backed-instr.md +?? snapshots/0067-adr-0033-cdp-stablecoin-taxonomy-collate.md +?? snapshots/0068-propagate-adr-0033-cdp-stablecoin-placem.md +?? snapshots/0069-buyer-funded-cashback-wallet-feature.md +?? snapshots/0070-propagate-cdp-stablecoin-ptu-into-glossa.md +?? snapshots/0071-adr-0033-asset-landscape-glossary-the-vo.md +?? snapshots/0072-adr-0033-decision-8-secured-is-not-backe.md +?? snapshots/0073-adr-0033-decision-9-leverage-value-flow-.md +?? snapshots/0074-capacitor-device-build-keystores.md +?? snapshots/0075-bch-usd-rail-adr0035-and-adr0031-amend.md +?? snapshots/0076-nba-readers-guide-asset-landscape.md +?? snapshots/0077-basket-stable-and-mba-floored-meta-stable.md +?? snapshots/0078-swap-flow-live-rates-route-detection.md +?? snapshots/0079-sim-cdp-hedge-bch-frame.md +?? snapshots/0081-swap-broadcast-two-phase-plan-execute.md +?? snapshots/0082-cosmogenesis-wizard-portfolio-refinements.md +?? snapshots/0083-seed-sweep-wallet-feature.md +?? snapshots/0084-anyhedge-paryon-turn0-compat-validated.md +?? snapshots/0084-redeem-all-batch-mba-to-bch-sweep.md +?? snapshots/0086-metal-mbas-gld-slv-cup-decreasing-fee-an.md +?? snapshots/0087-unpegged-sweep-tokens-for-non-mba-catego.md +?? snapshots/0088-swap-all-target-two-step-redeem-all-then.md +?? snapshots/0089-route-b-deviantdescfeesupply-wired-into-.md +?? snapshots/0090-divisible-metal-variants-gldd-slvd-fract.md +?? snapshots/0091-qr-activity-token-import-code-split.md +?? snapshots/0093-caveated-divisible-cupd-and-claim-confir.md +?? snapshots/0094-sim-rescaled-to-canonical-paryon.md +?? snapshots/0095-three-way-cdp-model-comparison.md +?? snapshots/0096-pre-turn1-anyhedge-oracle-format.md +?? snapshots/0097-chipnet-beta-launch-pwa.md +?? snapshots/0097-turn1-anyhedge-chipnet-validated.md +?? snapshots/0098-turn1-5-anyhedge-liquidation-plus-turn2-prep.md +?? snapshots/0099-path-c-confirmed-pre-turn2-priceoracle-read.md +?? snapshots/0100-alpha-hardening-shell-tests-derivation-f.md +?? snapshots/0101-adr-0039-mba-divisible-by-default-denomi.md +?? snapshots/0102-shell-splash-manifesto-ux.md +?? spike/ +?? src/cli/ +?? src/connect/ +?? src/crypto/ +?? src/index.ts +?? src/onramp/sideshift-shift.ts +?? src/registry/ +?? src/swapBuilder.ts +?? src/swapMath.ts +?? src/tier.ts +?? src/token/ +?? src/tokenId.ts +?? src/tokenMetadata.ts +?? src/utils/ +?? src/verify/ +?? src/wallet/ +?? templates/ +?? test/regtest/ +?? test/unit/cli/ +?? test/unit/connect/ +?? test/unit/crypto/ +?? test/unit/default-denomination.test.ts +?? test/unit/denomination.test.ts +?? test/unit/dra-verify.test.ts +?? test/unit/onramp/sideshift-shift.test.ts +?? test/unit/registry/ +?? test/unit/swapBuilder.test.ts +?? test/unit/swapMath.test.ts +?? test/unit/tier.test.ts +?? test/unit/token/ +?? test/unit/tokenId.test.ts +?? test/unit/verify/ +?? test/unit/wallet/ +?? test/unit/web/api.test.ts +?? tsconfig.json +?? wiki/architecture.md +?? wiki/asset-landscape.md +?? wiki/event-prediction-and-tote.md +?? wiki/fee-economics.md +?? wiki/glossary.md +?? wiki/index.md +?? wiki/integration-guide.md +?? wiki/integrations/ +?? wiki/minting-option-grid.xlsx +?? wiki/protocol-vs-wallet-enforcement.md +?? wiki/revenue-model.md +?? wiki/revenue-model.xlsx +?? wiki/risks.md +?? wiki/spec-amendments.md +?? wiki/sweep.md +?? wiki/testing-strategy.md +?? wiki/usd-rail.md +?? wiki/use-case-platform.md +``` + +## File counts + +| Area | Files | +|---|---| +| src/ | 66 | +| contracts/ | 56 | +| test/ | 57 | + +## Notes + +**What shipped — SideShift "Level B" (Tier-3 in-wallet swap), ADR-0036 amendment.** +Full suite **478 pass / 0 fail / 9 skipped**, `tsc` clean, `app.js` syntax-checked, +**live SideShift secret verified** against `GET /v2/account` (account id `fmZW8Ralz`). + +**New (`src/onramp/sideshift-shift.ts`):** server-side SideShift API client — +`createVariableShift` (`POST /v2/shifts/variable`, `x-sideshift-secret` + +`x-user-ip`, settleCoin=bch, affiliateId), `getShiftStatus` (`GET /v2/shifts/:id`, +no auth), `verifySideShiftSecret` (`GET /v2/account`). Injected `fetch`, typed +errors (`shift-not-configured`, `shift-failed`). + +**Web wiring:** `OnrampContext.sideshiftSecret` / `sideshiftAffiliate` from +`SIDESHIFT_SECRET` / `SIDESHIFT_AFFILIATE` env (read in `defaultOnrampContext`); +handlers `handleOnrampShift` / `handleOnrampShiftStatus` / `onrampShiftEnabled`; +routes `GET /api/onramp/config`, `POST /api/onramp/shift`, `GET /api/onramp/shift?id=`. +The secret stays **server-side only** — a test asserts it never appears in the +response body. `x-user-ip` from the request socket. + +**UI (`public/`):** SideShift card gets **"Swap in wallet"** (primary) + +"Open site ↗" fallback when `shiftEnabled`. Creates a shift → renders deposit +address + min/max range + memo + "settles to your own address" + a 6s status +poll (waiting→…→settled). Copy buttons. Other three providers stay link-out. + +**Key real-world findings:** +- SideShift's affiliate id == account id (`fmZW8Ralz`); the affiliate referral + link `/a/{id}` is where it comes from. +- **SideShift settles MAINNET BCH.** The in-wallet flow therefore needs a mainnet + `bitcoincash:` settle address — a `bchtest:`/`bchreg:` address is rejected with + a clear "switch to mainnet" message (link-out still works for chipnet testing). + So Tier-3 is effectively a mainnet feature; chipnet users see the button but get + the guidance message until they switch networks. + +**Design fidelity:** Tier-3 admitted **narrowly** — only non-custodial API swap +providers where funds never touch the operator (user → SideShift deposit → user's +own settle address). Fiat providers (Guardarian/Changelly) + Trocador stay +link-out. Opt-in via `SIDESHIFT_SECRET`; absent ⇒ SideShift falls back to link-out. + +**`.env` loading:** `scripts/deviant-web.mjs` now auto-loads `.env` +(`process.loadEnvFile`, Node 20.12+); `.env.example` documents every key. + +**Deferred:** fixed-rate (quote-locked) shifts; a live end-to-end create (needs a +real mainnet settle address — not run, to avoid creating shifts to a fabricated +address); kycnot.me key still pending admin approval (reputation dormant). diff --git a/src/onramp/index.ts b/src/onramp/index.ts index b2902e0..0038667 100644 --- a/src/onramp/index.ts +++ b/src/onramp/index.ts @@ -13,3 +13,4 @@ export * from './providers.js'; export * from './reputation.js'; export * from './jurisdiction.js'; export * from './registry.js'; +export * from './sideshift-shift.js'; diff --git a/src/onramp/sideshift-shift.ts b/src/onramp/sideshift-shift.ts new file mode 100644 index 0000000..d1afab6 --- /dev/null +++ b/src/onramp/sideshift-shift.ts @@ -0,0 +1,195 @@ +/** + * SideShift Tier-3 — in-wallet shift creation (ADR-0036 amendment). + * + * This is the one provider that goes beyond link-out: the wallet's *server* + * creates a SideShift shift via the authenticated API, shows the deposit + * address in-app, and BCH settles to the USER'S OWN address. Self-custody is + * preserved end-to-end — the user sends crypto directly to SideShift and BCH + * lands at their own `settleAddress`; the wallet operator never touches funds. + * + * SECURITY: the `x-sideshift-secret` is the account's private key and MUST stay + * server-side. It is read from env in `web/api.ts` and passed here; it is never + * sent to the browser. Reading a shift's status needs no secret. + * + * Pure transport with an injected `fetch` — unit-testable with a stub, no + * network in tests. Verified against docs.sideshift.ai (2026-05-31): + * POST /api/v2/shifts/variable (x-sideshift-secret, x-user-ip) + * GET /api/v2/shifts/:id (no auth) + * GET /api/v2/account (x-sideshift-secret) — secret check + */ + +import { OnrampError, type FetchLike } from './types.js'; + +const API = 'https://sideshift.ai/api/v2'; +/** BCH settle coin id on SideShift. */ +const SETTLE_COIN = 'bch'; + +/** Terminal + in-flight shift statuses SideShift can report. */ +export type ShiftStatus = + | 'waiting' + | 'pending' + | 'processing' + | 'settled' + | 'refund' + | 'refunded' + | 'expired' + | 'multiple'; + +export interface CreateShiftParams { + /** SideShift deposit coin id, e.g. 'btc', 'eth', 'usdtErc20'. */ + readonly depositCoin: string; + /** Network for multi-network/token coins, e.g. 'ethereum'. Optional for base coins. */ + readonly depositNetwork?: string; + /** The user's OWN BCH cashaddr — where settled BCH is sent. */ + readonly settleAddress: string; + /** Optional refund address if the deposit can't be settled. */ + readonly refundAddress?: string; + /** Optional commission override (0..2). Default is the account's 0.5%. */ + readonly commissionRate?: string; +} + +export interface ShiftCredentials { + /** x-sideshift-secret — server-side only. */ + readonly secret: string; + /** Affiliate id for attribution/commission. */ + readonly affiliateId?: string; + /** End-user IP for the required x-user-ip header (server integrations). */ + readonly userIp?: string; +} + +export interface Shift { + readonly id: string; + readonly depositCoin: string; + readonly depositNetwork?: string; + readonly settleCoin: string; + readonly depositAddress: string; + readonly depositMemo?: string; + readonly settleAddress: string; + readonly depositMin?: string; + readonly depositMax?: string; + readonly status: ShiftStatus; + readonly type?: string; + readonly expiresAt?: string; + /** Present once settled / in-flight. */ + readonly depositAmount?: string; + readonly settleAmount?: string; + readonly rate?: string; +} + +function headers(creds: ShiftCredentials): Record { + const h: Record = { + 'content-type': 'application/json', + accept: 'application/json', + 'x-sideshift-secret': creds.secret, + }; + if (creds.userIp) h['x-user-ip'] = creds.userIp; + return h; +} + +/** Map a raw SideShift shift object to our `Shift`. */ +function toShift(raw: Record): Shift { + return { + id: String(raw.id), + depositCoin: String(raw.depositCoin ?? ''), + depositNetwork: raw.depositNetwork ? String(raw.depositNetwork) : undefined, + settleCoin: String(raw.settleCoin ?? SETTLE_COIN), + depositAddress: String(raw.depositAddress ?? ''), + depositMemo: raw.depositMemo ? String(raw.depositMemo) : undefined, + settleAddress: String(raw.settleAddress ?? ''), + depositMin: raw.depositMin != null ? String(raw.depositMin) : undefined, + depositMax: raw.depositMax != null ? String(raw.depositMax) : undefined, + status: (raw.status as ShiftStatus) ?? 'waiting', + type: raw.type ? String(raw.type) : undefined, + expiresAt: raw.expiresAt ? String(raw.expiresAt) : undefined, + depositAmount: raw.depositAmount != null ? String(raw.depositAmount) : undefined, + settleAmount: raw.settleAmount != null ? String(raw.settleAmount) : undefined, + rate: raw.rate != null ? String(raw.rate) : undefined, + }; +} + +/** Pull a human message out of a SideShift error body. */ +async function errorMessage(res: { json(): Promise }, fallback: string): Promise { + try { + const body = (await res.json()) as { error?: { message?: string } | string }; + if (typeof body?.error === 'string') return body.error; + if (body?.error?.message) return body.error.message; + } catch { /* non-JSON */ } + return fallback; +} + +/** + * Create a variable-rate shift depositing `depositCoin` and settling BCH to the + * user's own address. Throws `OnrampError('shift-failed')` on any API error. + */ +export async function createVariableShift( + params: CreateShiftParams, + creds: ShiftCredentials, + fetchImpl: FetchLike, +): Promise { + if (!creds.secret) { + throw new OnrampError('SideShift secret not configured (set SIDESHIFT_SECRET)', 'shift-not-configured'); + } + if (!params.settleAddress) { + throw new OnrampError('settleAddress (your BCH address) is required', 'invalid-destination'); + } + if (!params.depositCoin) { + throw new OnrampError('depositCoin is required', 'shift-failed'); + } + const body: Record = { + settleAddress: params.settleAddress, + depositCoin: params.depositCoin, + settleCoin: SETTLE_COIN, + }; + if (params.depositNetwork) body.depositNetwork = params.depositNetwork; + if (params.refundAddress) body.refundAddress = params.refundAddress; + if (params.commissionRate) body.commissionRate = params.commissionRate; + if (creds.affiliateId) body.affiliateId = creds.affiliateId; + + let res; + try { + res = await fetchImpl(`${API}/shifts/variable`, { + method: 'POST', + headers: headers(creds), + body: JSON.stringify(body), + }); + } catch (err) { + throw new OnrampError(`SideShift request failed: ${(err as Error).message}`, 'shift-failed'); + } + if (!res.ok) { + throw new OnrampError(await errorMessage(res, `SideShift error (HTTP ${res.status})`), 'shift-failed'); + } + return toShift((await res.json()) as Record); +} + +/** Read a shift's current status. No secret required. Returns null on failure. */ +export async function getShiftStatus(id: string, fetchImpl: FetchLike): Promise { + if (!id) return null; + try { + const res = await fetchImpl(`${API}/shifts/${encodeURIComponent(id)}`, { headers: { accept: 'application/json' } }); + if (!res.ok) return null; + return toShift((await res.json()) as Record); + } catch { + return null; + } +} + +/** + * Verify an account secret by calling GET /v2/account. Returns the account id + * on success, or throws `OnrampError('shift-not-configured')` if the secret is + * missing/invalid. Used for a one-shot credential check, not on the hot path. + */ +export async function verifySideShiftSecret(secret: string, fetchImpl: FetchLike): Promise { + if (!secret) throw new OnrampError('no SideShift secret provided', 'shift-not-configured'); + let res; + try { + res = await fetchImpl(`${API}/account`, { headers: { accept: 'application/json', 'x-sideshift-secret': secret } }); + } catch (err) { + throw new OnrampError(`SideShift account check failed: ${(err as Error).message}`, 'shift-failed'); + } + if (!res.ok) { + throw new OnrampError(await errorMessage(res, `invalid SideShift secret (HTTP ${res.status})`), 'shift-not-configured'); + } + const body = (await res.json()) as { id?: string }; + if (!body?.id) throw new OnrampError('SideShift account response missing id', 'shift-failed'); + return body.id; +} diff --git a/src/onramp/types.ts b/src/onramp/types.ts index caa6454..ee370c4 100644 --- a/src/onramp/types.ts +++ b/src/onramp/types.ts @@ -236,7 +236,10 @@ export type OnrampErrorCode = | 'unsupported-journey' | 'unknown-provider' | 'invalid-rating' - | 'quote-failed'; + | 'quote-failed' + // Tier-3 (in-wallet API shift, ADR-0036 amendment): + | 'shift-not-configured' + | 'shift-failed'; export class OnrampError extends Error { // Plain field (not a TS parameter property) so the module loads under Node's diff --git a/src/web/api.ts b/src/web/api.ts index 38ec67a..513e480 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -26,6 +26,10 @@ import { } from '../onramp/registry.js'; import { createLaunchProviders } from '../onramp/providers.js'; import { fetchKycnotReputation, neutralReputation } from '../onramp/reputation.js'; +import { + createVariableShift, + getShiftStatus, +} from '../onramp/sideshift-shift.js'; import { type FetchLike, type LocalRating, @@ -207,6 +211,11 @@ export interface OnrampContext { readonly defaultAddress?: string; /** kycnot.me API key (required for reputation; without it, neutral). */ readonly kycnotApiKey?: string; + /** SideShift account secret (Tier-3 in-wallet shifts). SERVER-SIDE ONLY — + * never surfaced to the browser. Absent ⇒ Tier-3 disabled (link-out only). */ + readonly sideshiftSecret?: string; + /** SideShift affiliate id for shift attribution/commission. */ + readonly sideshiftAffiliate?: string; } /** Default on-ramp context: the four launch providers, in-memory ratings, and @@ -237,6 +246,8 @@ export function defaultOnrampContext(defaultAddress?: string): OnrampContext { fetchImpl: platformFetch, defaultAddress, kycnotApiKey: env.KYCNOT_API_KEY, + sideshiftSecret: env.SIDESHIFT_SECRET, + sideshiftAffiliate: env.SIDESHIFT_AFFILIATE, }; } @@ -325,3 +336,63 @@ export function handleOnrampRate(octx: OnrampContext, req: OnrampRateRequest): u octx.ratings.set(req.providerId, req.rating ?? null); return { providerId: req.providerId, rating: req.rating ?? null }; } + +// ---- Tier-3: in-wallet SideShift shift (ADR-0036 amendment) ---- +// +// The secret stays on this server; only non-secret shift data (deposit address, +// min/max, status) is returned to the browser. BCH settles to the user's OWN +// address — self-custody preserved. + +/** Is the in-wallet shift flow available (a SideShift secret + fetch configured)? */ +export function onrampShiftEnabled(octx: OnrampContext): boolean { + return Boolean(octx.sideshiftSecret && octx.fetchImpl); +} + +export interface OnrampShiftRequest { + /** SideShift deposit coin id, e.g. 'btc', 'eth', 'usdtErc20'. */ + readonly depositCoin: string; + readonly depositNetwork?: string; + /** Defaults to the wallet's own cashaddr when omitted. */ + readonly settleAddress?: string; + readonly refundAddress?: string; +} + +/** Create an in-wallet SideShift shift settling BCH to the user's own address. + * `clientIp` populates the required x-user-ip header. */ +export async function handleOnrampShift( + octx: OnrampContext, + req: OnrampShiftRequest, + clientIp?: string, +): Promise { + if (!req || typeof req.depositCoin !== 'string' || !req.depositCoin) { + throw new Error('depositCoin is required'); + } + if (!onrampShiftEnabled(octx)) { + throw new Error('in-wallet shift is not configured (set SIDESHIFT_SECRET)'); + } + const settleAddress = req.settleAddress ?? octx.defaultAddress; + if (!settleAddress) throw new Error('settleAddress is required (no wallet address available)'); + // SideShift settles MAINNET BCH — a testnet/regtest settle address can't receive it. + if (/^bch(test|reg):/i.test(settleAddress)) { + throw new Error('SideShift settles mainnet BCH — switch the wallet to mainnet to swap in-wallet (link-out still works for testing).'); + } + const shift = await createVariableShift( + { + depositCoin: req.depositCoin, + depositNetwork: req.depositNetwork, + settleAddress, + refundAddress: req.refundAddress, + }, + { secret: octx.sideshiftSecret!, affiliateId: octx.sideshiftAffiliate, userIp: clientIp }, + octx.fetchImpl!, + ); + return jsonSafe(shift); +} + +/** Read a shift's status. No secret needed; returns `{ shift: null }` if unknown. */ +export async function handleOnrampShiftStatus(octx: OnrampContext, id: string): Promise { + if (!id) throw new Error('shift id is required'); + if (!octx.fetchImpl) return { shift: null }; + const shift = await getShiftStatus(id, octx.fetchImpl); + return { shift: shift ? jsonSafe(shift) : null }; +} diff --git a/src/web/server.ts b/src/web/server.ts index aa2a808..5a8db90 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -17,6 +17,9 @@ * POST /api/onramp/handoff → { providerId, journey, … } → { url } (Tier 1) * POST /api/onramp/quote → { providerId, journey, fromAmount, fromUnit } → { quote } * POST /api/onramp/rate → { providerId, rating } → { providerId, rating } + * GET /api/onramp/config → { shiftEnabled, shiftProvider } (Tier-3 capability) + * POST /api/onramp/shift → { depositCoin, … } → SideShift shift (in-wallet) + * GET /api/onramp/shift?id=… → { shift } (status; no secret) * * Bind to localhost only — this exposes signing keys' wallet operations, so * it must never be reachable off the machine. @@ -38,6 +41,9 @@ import { handleOnrampHandoff, handleOnrampQuote, handleOnrampRate, + handleOnrampShift, + handleOnrampShiftStatus, + onrampShiftEnabled, defaultOnrampContext, type CommandRequest, type RegisterRequest, @@ -47,6 +53,7 @@ import { type OnrampHandoffRequest, type OnrampQuoteRequest, type OnrampRateRequest, + type OnrampShiftRequest, } from './api.js'; import type { OnrampJourney } from '../onramp/types.js'; import type { Token } from '../token/types.js'; @@ -189,6 +196,31 @@ export function createRequestListener(options: WebServerOptions) { return sendJson(res, 400, { error: message, code }); } } + // Tier-3 capability flag for the UI (is in-wallet shift available?). + if (path === '/api/onramp/config' && req.method === 'GET') { + return sendJson(res, 200, { shiftEnabled: onrampShiftEnabled(onramp), shiftProvider: 'sideshift' }); + } + // Create an in-wallet SideShift shift (secret stays server-side). + if (path === '/api/onramp/shift' && req.method === 'POST') { + const body = (await readBody(req)) as OnrampShiftRequest; + try { + const clientIp = (req.socket && req.socket.remoteAddress) || undefined; + return sendJson(res, 200, await handleOnrampShift(onramp, body, clientIp)); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const code = (err as { code?: string }).code; + return sendJson(res, 400, { error: message, code }); + } + } + // Read a shift's status (no secret needed). + if (path === '/api/onramp/shift' && req.method === 'GET') { + try { + return sendJson(res, 200, await handleOnrampShiftStatus(onramp, url.searchParams.get('id') ?? '')); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return sendJson(res, 400, { error: message }); + } + } // ---- static ---- if (req.method === 'GET') { diff --git a/test/unit/onramp/sideshift-shift.test.ts b/test/unit/onramp/sideshift-shift.test.ts new file mode 100644 index 0000000..326740d --- /dev/null +++ b/test/unit/onramp/sideshift-shift.test.ts @@ -0,0 +1,101 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + createVariableShift, + getShiftStatus, + verifySideShiftSecret, +} from '../../../src/onramp/sideshift-shift.js'; +import { OnrampError, type FetchLike } from '../../../src/onramp/types.js'; + +const ADDR = 'bitcoincash:qffp7s2sp7vn5syq40re5acmt0f0aut2fck59ye7gp'; +const SHIFT = { + id: 'abc123', + depositCoin: 'btc', + settleCoin: 'bch', + depositAddress: 'bc1qdeadbeef', + settleAddress: ADDR, + depositMin: '0.001', + depositMax: '1.5', + status: 'waiting', + type: 'variable', + expiresAt: '2026-06-01T00:00:00Z', +}; + +function recordingFetch(resp: { ok: boolean; status: number; body: unknown }) { + const calls: Array<{ url: string; init?: any }> = []; + const fn: FetchLike = async (url, init) => { + calls.push({ url, init }); + return { ok: resp.ok, status: resp.status, json: async () => resp.body }; + }; + return { fn, calls }; +} + +test('createVariableShift posts the right body + secret header, parses the shift', async () => { + const { fn, calls } = recordingFetch({ ok: true, status: 201, body: SHIFT }); + const shift = await createVariableShift( + { depositCoin: 'btc', settleAddress: ADDR }, + { secret: 'sec_123', affiliateId: 'fmZW8Ralz', userIp: '203.0.113.7' }, + fn, + ); + assert.equal(shift.id, 'abc123'); + assert.equal(shift.depositAddress, 'bc1qdeadbeef'); + assert.equal(shift.status, 'waiting'); + + const c = calls[0]; + assert.match(c.url, /\/api\/v2\/shifts\/variable$/); + assert.equal(c.init.method, 'POST'); + assert.equal(c.init.headers['x-sideshift-secret'], 'sec_123'); + assert.equal(c.init.headers['x-user-ip'], '203.0.113.7'); + const body = JSON.parse(c.init.body); + assert.equal(body.settleCoin, 'bch'); + assert.equal(body.depositCoin, 'btc'); + assert.equal(body.settleAddress, ADDR); + assert.equal(body.affiliateId, 'fmZW8Ralz'); +}); + +test('createVariableShift throws shift-not-configured without a secret', async () => { + const { fn } = recordingFetch({ ok: true, status: 201, body: SHIFT }); + await assert.rejects( + () => createVariableShift({ depositCoin: 'btc', settleAddress: ADDR }, { secret: '' }, fn), + (e: unknown) => e instanceof OnrampError && (e as OnrampError).code === 'shift-not-configured', + ); +}); + +test('createVariableShift surfaces the SideShift error message on failure', async () => { + const { fn } = recordingFetch({ ok: false, status: 400, body: { error: { message: 'invalid depositCoin' } } }); + await assert.rejects( + () => createVariableShift({ depositCoin: 'nope', settleAddress: ADDR }, { secret: 'sec' }, fn), + (e: unknown) => e instanceof OnrampError && (e as OnrampError).code === 'shift-failed' && /invalid depositCoin/.test((e as Error).message), + ); +}); + +test('createVariableShift requires a settle address', async () => { + const { fn } = recordingFetch({ ok: true, status: 201, body: SHIFT }); + await assert.rejects( + () => createVariableShift({ depositCoin: 'btc', settleAddress: '' }, { secret: 'sec' }, fn), + (e: unknown) => (e as OnrampError).code === 'invalid-destination', + ); +}); + +test('getShiftStatus parses a status; null on failure', async () => { + const ok = recordingFetch({ ok: true, status: 200, body: { ...SHIFT, status: 'settled', settleAmount: '0.23' } }); + const s = await getShiftStatus('abc123', ok.fn); + assert.equal(s?.status, 'settled'); + assert.equal(s?.settleAmount, '0.23'); + assert.match(ok.calls[0].url, /\/api\/v2\/shifts\/abc123$/); + + const bad = recordingFetch({ ok: false, status: 404, body: {} }); + assert.equal(await getShiftStatus('missing', bad.fn), null); +}); + +test('verifySideShiftSecret returns the account id on success, throws on 401', async () => { + const ok = recordingFetch({ ok: true, status: 200, body: { id: 'YQMi62XMb' } }); + assert.equal(await verifySideShiftSecret('sec', ok.fn), 'YQMi62XMb'); + assert.equal(ok.calls[0].init.headers['x-sideshift-secret'], 'sec'); + + const bad = recordingFetch({ ok: false, status: 401, body: { error: 'Invalid secret' } }); + await assert.rejects( + () => verifySideShiftSecret('wrong', bad.fn), + (e: unknown) => (e as OnrampError).code === 'shift-not-configured', + ); +}); diff --git a/test/unit/web/onramp-route.test.ts b/test/unit/web/onramp-route.test.ts index daadb7b..ee10809 100644 --- a/test/unit/web/onramp-route.test.ts +++ b/test/unit/web/onramp-route.test.ts @@ -2,6 +2,10 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createRequestListener } from '../../../src/web/server.js'; import type { RunContext } from '../../../src/cli/wallet-cli.js'; +import { OnrampRegistry, InMemoryRatingStore } from '../../../src/onramp/registry.js'; +import { createLaunchProviders } from '../../../src/onramp/providers.js'; +import type { FetchLike } from '../../../src/onramp/types.js'; +import type { OnrampContext } from '../../../src/web/api.js'; const ADDR = 'bitcoincash:qffp7s2sp7vn5syq40re5acmt0f0aut2fck59ye7gp'; @@ -33,6 +37,26 @@ function listener() { }); } +// A listener with a Tier-3-enabled on-ramp context backed by a stub fetch, so +// shift routes are deterministic and never touch the network. +function shiftListener(resp: { ok: boolean; status: number; body: unknown }) { + const fetchImpl: FetchLike = async () => ({ ok: resp.ok, status: resp.status, json: async () => resp.body }); + const onramp: OnrampContext = { + registry: new OnrampRegistry(createLaunchProviders()), + ratings: new InMemoryRatingStore(), + fetchImpl, + defaultAddress: ADDR, + sideshiftSecret: 'sec_test', + sideshiftAffiliate: 'fmZW8Ralz', + }; + return createRequestListener({ + ctx: {} as unknown as RunContext, + publicDir: process.cwd(), + walletInfo: { network: 'chipnet', cashaddr: ADDR, tokenAddress: 'x' }, + onramp, + }); +} + test('GET /api/onramp returns the four ranked providers', async () => { const res = mockRes(); await listener()(mockReq('GET', '/api/onramp'), res as never); @@ -84,3 +108,67 @@ test('POST /api/onramp/rate stores a local rating', async () => { assert.equal(res.statusCode, 200); assert.deepEqual(JSON.parse(res.body), { providerId: 'sideshift', rating: 4 }); }); + +test('GET /api/onramp/config reports shift disabled without a secret', async () => { + const res = mockRes(); + await listener()(mockReq('GET', '/api/onramp/config'), res as never); + // Default context has no SIDESHIFT_SECRET in the test env ⇒ disabled. + const cfg = JSON.parse(res.body) as { shiftEnabled: boolean; shiftProvider: string }; + assert.equal(cfg.shiftEnabled, false); + assert.equal(cfg.shiftProvider, 'sideshift'); +}); + +test('GET /api/onramp/config reports shift enabled when a secret is configured', async () => { + const res = mockRes(); + await shiftListener({ ok: true, status: 200, body: {} })(mockReq('GET', '/api/onramp/config'), res as never); + assert.equal((JSON.parse(res.body) as { shiftEnabled: boolean }).shiftEnabled, true); +}); + +test('POST /api/onramp/shift creates a shift and returns non-secret data only', async () => { + const shiftBody = { + id: 'abc123', depositCoin: 'btc', settleCoin: 'bch', + depositAddress: 'bc1qdeadbeef', settleAddress: ADDR, + depositMin: '0.001', depositMax: '1.5', status: 'waiting', type: 'variable', + }; + const res = mockRes(); + await shiftListener({ ok: true, status: 201, body: shiftBody })( + mockReq('POST', '/api/onramp/shift', { depositCoin: 'btc' }), + res as never, + ); + assert.equal(res.statusCode, 200); + const out = JSON.parse(res.body) as Record; + assert.equal(out.depositAddress, 'bc1qdeadbeef'); + assert.equal(out.settleAddress, ADDR); + assert.equal(out.status, 'waiting'); + // The secret must never be echoed back to the browser. + assert.ok(!JSON.stringify(out).includes('sec_test')); +}); + +test('POST /api/onramp/shift returns 400 when no secret is configured', async () => { + const res = mockRes(); + await listener()(mockReq('POST', '/api/onramp/shift', { depositCoin: 'btc' }), res as never); + assert.equal(res.statusCode, 400); + assert.match((JSON.parse(res.body) as { error: string }).error, /not configured/); +}); + +test('POST /api/onramp/shift rejects a testnet settle address with a clear message', async () => { + const res = mockRes(); + await shiftListener({ ok: true, status: 201, body: {} })( + mockReq('POST', '/api/onramp/shift', { depositCoin: 'btc', settleAddress: 'bchtest:qq00000000000000000000000000000000000000000' }), + res as never, + ); + assert.equal(res.statusCode, 400); + assert.match((JSON.parse(res.body) as { error: string }).error, /mainnet BCH/); +}); + +test('GET /api/onramp/shift?id= returns a shift status', async () => { + const res = mockRes(); + await shiftListener({ ok: true, status: 200, body: { id: 'abc123', status: 'settled', settleAmount: '0.23', settleCoin: 'bch', depositCoin: 'btc', depositAddress: 'x', settleAddress: ADDR } })( + mockReq('GET', '/api/onramp/shift?id=abc123'), + res as never, + ); + assert.equal(res.statusCode, 200); + const out = JSON.parse(res.body) as { shift: { status: string; settleAmount: string } | null }; + assert.equal(out.shift?.status, 'settled'); + assert.equal(out.shift?.settleAmount, '0.23'); +}); diff --git a/wiki/decisions.md b/wiki/decisions.md index 79ab367..d2632b0 100644 --- a/wiki/decisions.md +++ b/wiki/decisions.md @@ -1341,7 +1341,7 @@ The user also rejected the **pool-funded** alternative (customer pays full price ## ADR-0036 — BCH on-ramp: third-party acquisition providers in Apps → Discover; transparency-ranked, never Deviant-endorsed; read-only API depth only **Date:** 2026-05-31 -**Status:** Accepted — **implemented end-to-end (snapshots 0085–0086).** `src/onramp/` (types, four provider adapters, transparency ranking + registry, jurisdiction gating, kycnot.me read-only reputation client) + web API handlers/routes (`/api/onramp[/handoff|/quote|/rate]`, `?journey=&country=`) + the **Buy BCH** Discover tab in the web shell (`public/`). Tier 1 (referral handoff) + Tier 2 (read-only quote) only — Tier 3 deferred. **Full suite 460 pass / 0 fail.** Live-API verification done (2026-05-31): corrected kycnot.me (`kycLevel` 0–4, key-gated) and Trocador (`/anonpay/` handoff, key-gated quote); a few provider deep-link param names remain UNCONFIRMED (commented in code) and need a live re-check before mainnet, along with real per-provider country lists for the (currently `global`-seeded) jurisdiction gating. +**Status:** Accepted — **implemented end-to-end (snapshots 0085–0086).** `src/onramp/` (types, four provider adapters, transparency ranking + registry, jurisdiction gating, kycnot.me read-only reputation client) + web API handlers/routes (`/api/onramp[/handoff|/quote|/rate]`, `?journey=&country=`) + the **Buy BCH** Discover tab in the web shell (`public/`). Tier 1 (referral handoff) + Tier 2 (read-only quote) for all providers; **Tier 3 (in-wallet API shift) added for SideShift** — see the 2026-05-31 amendment below. **Full suite 478 pass / 0 fail.** Live-API verification done (2026-05-31): corrected kycnot.me (`kycLevel` 0–4, key-gated) and Trocador (`/anonpay/` handoff, key-gated quote); a few provider deep-link param names remain UNCONFIRMED (commented in code) and need a live re-check before mainnet, along with real per-provider country lists for the (currently `global`-seeded) jurisdiction gating. **Context:** The user asked for a *"fiat on-ramp, non-KYC exchange"* — a way to acquire BCH from inside the wallet. A 2026-05-31 web investigation (two research passes) plus a design conversation settled three things that shape the decision: @@ -1405,6 +1405,29 @@ The user also rejected the **pool-funded** alternative (customer pays full price **Implementation pointers (when built):** a new `src/onramp/` module with a thin `OnrampProvider` adapter interface (`buildHandoffUrl(addr, amount, fromAsset?)` + optional read-only `getQuote(...)` / `getReputation()`), patterned on the ADR-0016 `MarketProvider` plurality (multiple adapters, user choice, no hardwired venue); kycnot.me read-only reputation client; local rating reuses the favourite store / `handleFavorite` path; surfaced in the Cosmogenesis **Apps → Discover** "Buy / On-ramp" category (`wallet-ui-design.md`); referral IDs in handoff URLs per ADR-0021. **Cross-references:** ADR-0008 (verifier-not-authority — no blessed list, surface facts), ADR-0020 (sells visibility never truth; safety vs promotion never merged; external routing 0%), ADR-0016 (pluggable provider, read-first + handoff), ADR-0021 (referral commission), ADR-0026/0028 (Apps tab IA; Discover ≠ Market), ADR-0035 (open banking on/off-ramp item this complements), `wiki/integration-guide.md` (money-transmission caution), kycnot.me (external reputation source), memory: WizardConnect (sibling deferred Apps-tab integration). +### Amendment (2026-05-31) — Tier-3 admitted for API swap providers; SideShift in-wallet shift shipped + +**Status:** Accepted — implemented (snapshot 0099). Reverses, in a bounded way, Decision 4's deferral of Tier 3. + +**Context:** Decision 4 deferred Tier 3 ("create order / manage deposit / track in-wallet") as custody-adjacent. On building it for SideShift, the custody concern proved **not to apply to a non-custodial swap provider**: the user sends crypto **directly to a SideShift deposit address** and BCH **settles to the user's own address** — the wallet operator never holds, routes, or can divert funds. What actually remains of the deferral rationale is narrower: (a) the wallet now holds an API **secret**, (b) it accepts the provider's **integrator ToS / AML** expectations, (c) **maintenance** of the API surface, (d) it is **mainnet-only**. Those are real but acceptable for a single, user-opted-in provider — so Tier 3 is admitted **narrowly**, not generally. + +**Decision:** + +1. **Admit Tier 3 only for *non-custodial API swap providers*** — those where funds never touch the wallet operator (user → provider deposit address → user's own settle address). **SideShift is the first and only** such integration. **Fiat on-ramps (Guardarian, Changelly) and the aggregator (Trocador) stay link-out** (Tier 1/2) — a fiat processor's flow *is* custodial/KYC and does not qualify. +2. **The provider secret is server-side only.** `SIDESHIFT_SECRET` is read from env by the local server; it is **never** sent to the browser. The browser calls `/api/onramp/shift`; the server attaches `x-sideshift-secret`. Only non-secret shift data (deposit address, min/max, status) is returned. (Asserted by a test: the secret never appears in the response.) +3. **Opt-in and degrades cleanly.** Tier 3 is **off unless `SIDESHIFT_SECRET` is set** (`/api/onramp/config → shiftEnabled`). Absent ⇒ SideShift falls back to the same Tier-1 link-out as everyone else. The other transparency rules (ranking, warnings, no endorsement) are unchanged. +4. **Self-custody guardrails.** `settleAddress` is always the **user's own** BCH address (default = wallet cashaddr). **Mainnet-only:** SideShift settles mainnet BCH, so a `bchtest:`/`bchreg:` settle address is rejected with a clear "switch to mainnet" message (link-out still works for testnet testing). +5. **Variable-rate shift is the v1 shape** (`POST /v2/shifts/variable`): no amount lock, user sends any amount in the returned min/max range, rate set on deposit. Fixed-rate (quote-locked) shifts are a possible later enhancement. + +**Alternatives considered:** +- *Keep Tier 3 deferred / link-out only.* Rejected for SideShift specifically — the link-out can't prefill the settle address (no documented URL param), so the in-wallet API flow is materially better UX *and* keeps self-custody, with only the bounded costs above. +- *Generalise Tier 3 to all providers.* Rejected — fiat processors are custodial/KYC and don't share the "funds never touch us" property; admitting them would reintroduce exactly the liability Decision 4 guarded against. +- *Embed SideShift's widget iframe instead of the API.* Rejected — the in-wallet-iframe key-surface concern from Decision 4 stands; a server-side API call with the secret never reaching the browser is safer. + +**What this does NOT change:** the link-out + read-only-quote model for all other providers; the transparency-ranking/no-endorsement rules; the 0%/retention revenue posture (SideShift commission via the affiliate id is the same affiliate revenue, not a Deviant skim). + +**Implementation pointers:** `src/onramp/sideshift-shift.ts` (`createVariableShift` / `getShiftStatus` / `verifySideShiftSecret`, injected `fetch`); web handlers `handleOnrampShift` / `handleOnrampShiftStatus` / `onrampShiftEnabled` + routes `GET /api/onramp/config`, `POST /api/onramp/shift`, `GET /api/onramp/shift?id=`; `OnrampContext.sideshiftSecret` / `sideshiftAffiliate` from `SIDESHIFT_SECRET` / `SIDESHIFT_AFFILIATE` env; in-wallet shift UI in `public/` (deposit address + range + status poll). Live-verified: the configured secret authenticates against `GET /v2/account`. Tests: `test/unit/onramp/sideshift-shift.test.ts` + shift route tests (full suite 478 pass). + --- ## ADR-0037 — Supply semantics across the taxonomy: max-reservoir for circulating MBAs; Token-family supply is not a scarcity guarantee