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>
This commit is contained in:
walerikus 2026-06-09 20:57:31 +02:00
parent a80a6f4d27
commit 3148c05119
13 changed files with 1058 additions and 5 deletions

28
.env.example Normal file
View file

@ -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=

View file

@ -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 ? `<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');
return `<div class="buycard${unavailable}" data-id="${escapeHtml(p.id)}" data-journeys="${escapeHtml((p.journeys || []).join(','))}">` +
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">` +
`<button class="primary mini" data-onramp-open="${escapeHtml(p.id)}"${p.availableInRegion === false ? ' disabled' : ''}>Open ${escapeHtml(p.name)} ↗</button>` +
(canQuote ? `<button class="mini" data-onramp-quote="${escapeHtml(p.id)}">Get quote</button>` : '') +
primary +
(canQuote ? `<button class="mini" data-onramp-quote="${id}">Get quote</button>` : '') +
ratingStars(p) +
`</div>` +
`<div class="quote-out" hidden></div>` +
@ -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 = `<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;
@ -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 = `<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) {

View file

@ -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; }

111
scripts/deviant-web.mjs Normal file
View file

@ -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 <path> WIF keys file (default .chipnet-keys.json)
* --network <net> chipnet | testnet4 | mainnet (default chipnet)
* --tokens <a,b> token JSON files (default asm + asm2)
* --port <n> 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);
});

View file

@ -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).

View file

@ -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';

View file

@ -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<string, string> {
const h: Record<string, string> = {
'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<string, unknown>): 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<unknown> }, fallback: string): Promise<string> {
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<Shift> {
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<string, unknown> = {
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<string, unknown>);
}
/** Read a shift's current status. No secret required. Returns null on failure. */
export async function getShiftStatus(id: string, fetchImpl: FetchLike): Promise<Shift | null> {
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<string, unknown>);
} 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<string> {
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;
}

View file

@ -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

View file

@ -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<unknown> {
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<unknown> {
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 };
}

View file

@ -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') {

View file

@ -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',
);
});

View file

@ -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<string, unknown>;
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');
});

View file

@ -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