deviant/scripts/deviant-web.mjs
walerikus 3148c05119 feat(onramp): SideShift Tier-3 in-wallet shift (ADR-0036 amendment)
Add Level-B for SideShift: the server creates a SideShift variable shift
via the authenticated API and shows the deposit address in-wallet; BCH
settles to the user's OWN address, so self-custody is preserved end-to-end
(funds never touch the wallet operator). Other providers stay link-out.

- src/onramp/sideshift-shift.ts: createVariableShift / getShiftStatus /
  verifySideShiftSecret (injected fetch, typed errors). Endpoints verified
  against docs.sideshift.ai; live secret check passes GET /v2/account.
- web: OnrampContext.sideshiftSecret/affiliate from SIDESHIFT_SECRET /
  SIDESHIFT_AFFILIATE env; handlers + routes GET /api/onramp/config,
  POST /api/onramp/shift, GET /api/onramp/shift?id=. Secret stays
  server-side (a test asserts it never appears in the response).
- ui: SideShift card gets "Swap in wallet" (deposit address + range +
  6s status poll) when shiftEnabled, with an "Open site" link-out fallback.
- guard: mainnet-only — a bchtest:/bchreg: settle address is rejected with
  a switch-to-mainnet message (SideShift settles mainnet BCH).
- scripts/deviant-web.mjs auto-loads .env; .env.example documents the keys.
- ADR-0036 amended: Tier-3 admitted narrowly for non-custodial API swap
  providers only.

Full suite 478 pass / 0 fail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 20:57:31 +02:00

111 lines
4.5 KiB
JavaScript

#!/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);
});