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>
174 lines
7.6 KiB
TypeScript
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');
|
|
});
|