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:
parent
a80a6f4d27
commit
3148c05119
13 changed files with 1058 additions and 5 deletions
28
.env.example
Normal file
28
.env.example
Normal 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=
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
111
scripts/deviant-web.mjs
Normal 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);
|
||||
});
|
||||
314
snapshots/0103-sideshift-tier-3-in-wallet-shift-level-b.md
Normal file
314
snapshots/0103-sideshift-tier-3-in-wallet-shift-level-b.md
Normal 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).
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
195
src/onramp/sideshift-shift.ts
Normal file
195
src/onramp/sideshift-shift.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
101
test/unit/onramp/sideshift-shift.test.ts
Normal file
101
test/unit/onramp/sideshift-shift.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue