223 lines
8.1 KiB
TypeScript
223 lines
8.1 KiB
TypeScript
import cors from 'cors';
|
|
import debug from 'debug';
|
|
import express, { type Request, type Response } from 'express';
|
|
import { BBPay, type Solicitation } from './src/bb';
|
|
import { ProofCache } from './src/cache';
|
|
import { loadConfig } from './src/config';
|
|
import { OAuth, READ_SCOPES } from './src/oauth';
|
|
import { Primus, mapPrimusError } from './src/primus';
|
|
import { resolvesOf, selfVerify, SOLICITATION_RESOLVES, toReleaseBody } from './src/proof';
|
|
import { log } from './src/log';
|
|
|
|
const config = loadConfig();
|
|
if (config.debug) debug.enable('simple-oauth2');
|
|
|
|
const oauth = new OAuth(config.bb);
|
|
const bb = new BBPay(config.bb, oauth);
|
|
const primus = new Primus(config.primus, (...args) => log.info(...args));
|
|
const cache = new ProofCache();
|
|
|
|
const app = express();
|
|
app.use(cors());
|
|
app.use(express.json());
|
|
/** Only what the caller actually sent; empty sections stay out of the line. */
|
|
const received = (req: Request): Record<string, unknown> =>
|
|
Object.fromEntries(
|
|
Object.entries({ query: req.query, body: req.body }).filter(
|
|
([, value]) => typeof value === 'object' && value !== null && Object.keys(value).length > 0,
|
|
),
|
|
);
|
|
|
|
if (config.debug) {
|
|
// Every incoming request, its parameters and its outcome — success paths log nothing otherwise.
|
|
app.use((req, res, next) => {
|
|
const started = Date.now();
|
|
res.on('finish', () =>
|
|
log.info(`${req.method} ${req.originalUrl} → ${res.statusCode} (${Date.now() - started} ms)`, received(req)),
|
|
);
|
|
next();
|
|
});
|
|
}
|
|
|
|
const DIGITS = /^[0-9]+$/;
|
|
const isDigits = (value: unknown): value is string | number =>
|
|
(typeof value === 'string' || typeof value === 'number') && DIGITS.test(String(value));
|
|
|
|
/** Seller registration, called before `deposit`. Passes BB's answer through. */
|
|
app.post('/register', async (req: Request, res: Response) => {
|
|
const data = req.body ?? {};
|
|
const body = {
|
|
numeroConvenio: config.bb.convenio,
|
|
nomeParticipante: data.chainID,
|
|
tipoDocumento: data.tipoDocumento,
|
|
numeroDocumento: data.numeroDocumento,
|
|
numeroConta: data.numeroConta,
|
|
numeroAgencia: data.numeroAgencia,
|
|
tipoConta: data.tipoConta,
|
|
codigoIspb: data.codigoIspb, // only Banco do Brasil (0) is accepted today
|
|
};
|
|
try {
|
|
const response = await bb.registerParticipant(body);
|
|
if (response.status !== 201) {
|
|
res.status(502).json({ error: 'Upstream error', status: response.status });
|
|
return;
|
|
}
|
|
res.json(response.data);
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Charge creation, called after `lock`. The reconciliation code binds the
|
|
* charge to the lock: the contract requires it to equal `<chainId>-<lockId>`.
|
|
*/
|
|
app.post('/request', async (req: Request, res: Response) => {
|
|
const data = req.body ?? {};
|
|
const amount = Number(data.amount);
|
|
if (!(amount > 0) || !isDigits(data.pixTarget) || !isDigits(data.lockId) || !isDigits(data.chainId)) {
|
|
res.status(400).json({ error: 'amount, pixTarget, lockId and chainId are required' });
|
|
return;
|
|
}
|
|
const body = {
|
|
geral: {
|
|
numeroConvenio: config.bb.convenio,
|
|
pagamentoUnico: true,
|
|
descricaoSolicitacao: 'P2Pix',
|
|
valorSolicitacao: amount,
|
|
codigoConciliacaoSolicitacao: `${data.chainId}-${data.lockId}`,
|
|
},
|
|
formasPagamento: [{ codigoTipoPagamento: 'PIX', quantidadeParcelas: 1 }],
|
|
repasse: {
|
|
tipoValorRepasse: 'Percentual',
|
|
recebedores: [
|
|
{ identificadorRecebedor: Number(data.pixTarget), tipoRecebedor: 'Participante', valorRepasse: 100 },
|
|
],
|
|
},
|
|
};
|
|
try {
|
|
const response = await bb.createSolicitation(body);
|
|
if (response.status !== 201) {
|
|
res.status(502).json({ error: 'Upstream error', status: response.status });
|
|
return;
|
|
}
|
|
// BB's creation answer spells the Pix block `informacoesPIX` (the spec and `GET` say `informacoesPix`).
|
|
const created = response.data as { numeroSolicitacao?: number; informacoesPIX?: { textoQrCode?: string } };
|
|
res.json({
|
|
...created,
|
|
numeroSolicitacao: String(created.numeroSolicitacao),
|
|
textoQrCode: created.informacoesPIX?.textoQrCode,
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
/** The Primus job for one paid charge; the result lands in the cache. */
|
|
const prove = async (numeroSolicitacao: string, solicitation: Solicitation): Promise<void> => {
|
|
const payee = String(solicitation.repasse?.recebedores?.[0]?.identificadorRecebedor ?? '');
|
|
if (!DIGITS.test(payee)) {
|
|
cache.set(numeroSolicitacao, { status: 'failed', at: Date.now(), error: 'charge has no participant payee', retryable: false });
|
|
return;
|
|
}
|
|
const urls = [bb.solicitationUrl(numeroSolicitacao)];
|
|
try {
|
|
const header = { Authorization: await oauth.bearer(READ_SCOPES) };
|
|
const result = await primus.attest(
|
|
urls.map((url) => ({ url, method: 'GET', header, body: '' })),
|
|
[SOLICITATION_RESOLVES],
|
|
{ clientCrt: config.bb.clientCrt, clientKey: config.bb.clientKey },
|
|
);
|
|
const verification = selfVerify(result, urls);
|
|
// Everything the contract constants are pinned to, verbatim.
|
|
log.info(`attestation for ${numeroSolicitacao}`, {
|
|
request: result.attestation.request,
|
|
responseResolves: resolvesOf(result.attestation),
|
|
data: result.attestation.data,
|
|
attConditions: result.attestation.attConditions,
|
|
additionParams: result.attestation.additionParams,
|
|
timestamp: result.attestation.timestamp,
|
|
attestor: result.attestor,
|
|
taskId: result.taskId,
|
|
reportTxHash: result.reportTxHash,
|
|
verification,
|
|
});
|
|
if (!verification.verified) {
|
|
cache.set(numeroSolicitacao, {
|
|
status: 'failed',
|
|
at: Date.now(),
|
|
error: `attestation signature does not recover the attestor (recovered ${verification.recovered})`,
|
|
retryable: false,
|
|
});
|
|
return;
|
|
}
|
|
cache.set(numeroSolicitacao, { status: 'done', body: toReleaseBody(numeroSolicitacao, result, verification) });
|
|
} catch (error) {
|
|
const mapped = mapPrimusError(error);
|
|
log.error(error as Error, `attestation for ${numeroSolicitacao} failed (${mapped.code})`);
|
|
if (mapped.code === '30004' || mapped.code === '30001') oauth.forget(READ_SCOPES);
|
|
cache.set(numeroSolicitacao, {
|
|
status: 'failed',
|
|
at: Date.now(),
|
|
error: `Primus ${mapped.code}`,
|
|
retryable: mapped.retryable,
|
|
});
|
|
void primus.reclaim();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Proof of Pix payment, polled by the buyer after paying.
|
|
* 402 while the bank has not confirmed the charge,
|
|
* 202 while the attestation is being produced,
|
|
* 200 with the proof the contract's `release` takes,
|
|
* 502/503/500 when the bank or Primus failed (503 is worth retrying).
|
|
*/
|
|
app.get('/release/:numeroSolicitacao', async (req: Request, res: Response) => {
|
|
const numeroSolicitacao = String(req.params.numeroSolicitacao);
|
|
if (!DIGITS.test(numeroSolicitacao)) {
|
|
res.status(400).json({ error: 'numeroSolicitacao must be numeric' });
|
|
return;
|
|
}
|
|
const cached = cache.get(numeroSolicitacao);
|
|
if (cached?.status === 'done') {
|
|
res.json(cached.body);
|
|
return;
|
|
}
|
|
if (cached?.status === 'proving') {
|
|
res.status(202).json({ status: 'proving', since: cached.since });
|
|
return;
|
|
}
|
|
if (cached?.status === 'failed') {
|
|
res.status(502).json({ error: 'Upstream error', detail: cached.error });
|
|
return;
|
|
}
|
|
try {
|
|
const response = await bb.getSolicitation(numeroSolicitacao);
|
|
if (response.status !== 200) {
|
|
res.status(502).json({ error: 'Upstream error', status: response.status });
|
|
return;
|
|
}
|
|
if (response.data.codigoEstadoSolicitacao !== 1) {
|
|
res.status(402).json({ error: 'Pix not paid' });
|
|
return;
|
|
}
|
|
cache.set(numeroSolicitacao, { status: 'proving', since: Date.now() });
|
|
void prove(numeroSolicitacao, response.data);
|
|
res.status(202).json({ status: 'proving' });
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
const main = async (): Promise<void> => {
|
|
await primus.init();
|
|
await primus.reclaim();
|
|
app.listen(config.port, () => log.info(`Prover listening on port ${config.port}`));
|
|
};
|
|
|
|
main().catch((error) => {
|
|
console.error('zkPix prover failed to start:', error);
|
|
process.exit(1);
|
|
});
|