deviant/test/unit/web/onramp-route.test.ts
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

174 lines
7.6 KiB
TypeScript

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';
// Minimal req/res mocks — the on-ramp routes don't touch the wallet RunContext,
// so a dummy ctx is fine; they use the default on-ramp context built from
// walletInfo.cashaddr. No KYCNOT_API_KEY ⇒ reputations are neutral (no network).
function mockReq(method: string, url: string, body?: unknown) {
return {
method,
url,
async *[Symbol.asyncIterator]() {
if (body !== undefined) yield Buffer.from(JSON.stringify(body));
},
} as unknown as Parameters<ReturnType<typeof createRequestListener>>[0];
}
function mockRes() {
return {
statusCode: 0,
body: '',
writeHead(s: number) { this.statusCode = s; },
end(p?: string) { this.body = p ?? ''; },
};
}
function listener() {
return createRequestListener({
ctx: {} as unknown as RunContext,
publicDir: process.cwd(),
walletInfo: { network: 'chipnet', cashaddr: ADDR, tokenAddress: 'x' },
});
}
// 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);
assert.equal(res.statusCode, 200);
const providers = JSON.parse(res.body) as Array<{ id: string; featuredEligible: boolean }>;
assert.equal(providers.length, 4);
assert.equal(providers[0].id, 'sideshift'); // best KYC posture, offline-neutral reputation
});
test('GET /api/onramp?journey=fiat-to-bch filters', async () => {
const res = mockRes();
await listener()(mockReq('GET', '/api/onramp?journey=fiat-to-bch'), res as never);
const ids = (JSON.parse(res.body) as Array<{ id: string }>).map((p) => p.id).sort();
assert.deepEqual(ids, ['changelly', 'guardarian']);
});
test('GET /api/onramp?country=US sinks region-restricted providers (none at launch)', async () => {
const res = mockRes();
await listener()(mockReq('GET', '/api/onramp?country=US'), res as never);
const providers = JSON.parse(res.body) as Array<{ availableInRegion: boolean }>;
// Launch set is all `global`, so US stays fully available — proves the param is wired.
assert.ok(providers.every((p) => p.availableInRegion === true));
});
test('POST /api/onramp/handoff builds a URL with the wallet address', async () => {
const res = mockRes();
await listener()(
mockReq('POST', '/api/onramp/handoff', { providerId: 'guardarian', journey: 'fiat-to-bch', amount: '100', fromUnit: 'eur' }),
res as never,
);
assert.equal(res.statusCode, 200);
const out = JSON.parse(res.body) as { url: string };
assert.match(out.url, /guardarian\.com/);
assert.ok(out.url.includes(encodeURIComponent(ADDR)) || out.url.includes(ADDR));
});
test('POST /api/onramp/handoff returns 400 on an unknown provider', async () => {
const res = mockRes();
await listener()(mockReq('POST', '/api/onramp/handoff', { providerId: 'nope', journey: 'crypto-to-bch' }), res as never);
assert.equal(res.statusCode, 400);
const out = JSON.parse(res.body) as { error: string; code?: string };
assert.match(out.error, /unknown on-ramp provider/);
assert.equal(out.code, 'unknown-provider');
});
test('POST /api/onramp/rate stores a local rating', async () => {
const res = mockRes();
await listener()(mockReq('POST', '/api/onramp/rate', { providerId: 'sideshift', rating: 4 }), res as never);
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');
});