diff --git a/.env.example b/.env.example index 71228e0..0b2f92a 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,26 @@ +# Wallet that pays Primus tasks (needs ETH on the Primus chain). Its address +# is the attestation recipient and must match the contract's `prover`. PRIVATE_KEY=0xYOUR_PRIVATE_KEY +PRIMUS_CHAIN_ID=84532 +PRIMUS_RPC_URL=https://sepolia.base.org -# BBpay +# BB Pay credentials (Portal Developers BB) CLIENT_ID= CLIENT_SECRET= DEV_APP_KEY= CONVENIO=701 -ITP_OAUTH_URL=https://oauth.hm.bb.com.br/oauth/token -ITP_API_URL=https://checkout.mtls.api.bb.com.br -ITP_API_URL=https://checkout.mtls.api.hm.bb.com.br +# mTLS client certificate chain and its private key (PEM). Use a certificate +# registered with BB for this purpose, not the company e-CNPJ. +CLIENT_CRT=cert.pem +CLIENT_KEY=key.pem +# Extra CA bundle, only for hosts whose chain is not in Node's trust store. +#BB_CA=bb.pem -DEBUG=false \ No newline at end of file +ITP_OAUTH_URL=https://oauth.hm.bb.com.br/oauth/token +ITP_API_URL=https://checkout.mtls.api.hm.bb.com.br/v2 +#ITP_OAUTH_URL=https://oauth.bb.com.br/oauth/token +#ITP_API_URL=https://checkout.mtls.api.bb.com.br/v2 + +PORT=5000 +DEBUG=false diff --git a/.gitignore b/.gitignore index f38e58a..7cc20f1 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,10 @@ dist-ssr *.crt *.key + +# certificates, keys and every environment file except the example +*.pem +*.p12 +*.zip +.env.* +!.env.example diff --git a/README.md b/README.md index b3f6960..d5090b8 100644 --- a/README.md +++ b/README.md @@ -1,103 +1,113 @@ -# P2Pix zkTLS ITP prover +# P2Pix zkTLS prover -## Variáveis de ambiente necessárias no arquivo `.env`: +Proves that a BB Pay Pix charge was paid by having a Primus attestor replay +the bank's own `GET /solicitacoes/{n}` over zkTLS. The attestor signs what +the bank answered: the request URL and five revealed fields (amount, settled +sum, payee, Pix `txId`, reconciliation code). The P2Pix contract verifies +that signature and the fields in `release(lockID, proof)`. The charge is +bound to P2Pix's convênio only through the `numeroConvenio` query of the +attested URL, so the contract's URL suffix must be pinned to the URL exactly +as the attestor records it. The prover holds no signing key of its own. -- `CLIENT_ID`, `CLIENT_SECRET`, e `DEV_APP_KEY`: Fornecidas pelo Banco do Brasil -- `ITP_OAUTH_URL`, `ITP_API_URL`: Endpoints BBPay -- `PRIVATE_KEY`: - Chave privada Ethereum (em formato hexadecimal com prefixo '0x') - que vai assinar as liberações de pagamento) +## Variáveis de ambiente (`.env`, ver `.env.example`) -# Como rodar - - `npm install` - - `npm start` +- `CLIENT_ID`, `CLIENT_SECRET`, `DEV_APP_KEY`, `CONVENIO`: fornecidas pelo Banco do Brasil +- `ITP_OAUTH_URL`, `ITP_API_URL`: endpoints BB Pay (homologação ou produção) +- `CLIENT_CRT`, `CLIENT_KEY`: certificado cliente mTLS e sua chave privada (PEM) +- `PRIVATE_KEY`: carteira que paga as tarefas Primus (ETH na `PRIMUS_CHAIN_ID`, Base Sepolia por padrão); o endereço dela é o `recipient` da atestação e tem que ser o `prover` configurado no contrato -# Endpoints +## Como rodar -## POST /register -### Registra um participante. -Chamado pelo vendedor antes de fazer `deposit` no smart contract. +``` +bun install # ou npm install +bun start # ou npm start +``` -#### Parametros requeridos: - - `chainID` - - `tipoDocumento` - - `numeroDocumento` - - `numeroConta` - - `tipoConta` - - `codigoIspb` - -``` exemplo -curl --request POST \ - --url http://localhost:5000/register \ - --header 'content-type: application/json' \ - --data '{ - "chainID": "1337", - "tipoDocumento": 1, - "numeroDocumento": 12345678900, - "numeroConta": 1234567890123456, - "numeroAgencia": 123, - "tipoConta": 1, - "codigoIspb": 0 +`DEBUG=true` liga os logs, inclusive o registro completo de cada atestação +(request, resolves, data, attConditions, additionParams, verificação). + +## Endpoints + +### POST /register + +Registra um participante. Chamado pelo vendedor antes do `deposit`. + +``` +curl -X POST http://localhost:5000/register -H 'content-type: application/json' -d '{ + "chainID": "11155111", "tipoDocumento": 1, "numeroDocumento": 12345678900, + "numeroConta": 1234567890123456, "numeroAgencia": 123, "tipoConta": 1, "codigoIspb": 0 }' ``` -#### Retorna em formato JSON: - - `numeroParticipante` (usado no `deposit` do SC como `pixTarget`) +Retorna a resposta do BB, com `numeroParticipante` (usado no `deposit` como `-`). -## POST /request -### Solicitação de Pagamento -Chamado pelo comprador após o `lock` no smart contract. +### POST /request + +Cria a solicitação de pagamento. Chamado pelo comprador depois do `lock`. +`lockId` e `chainId` viram o código de conciliação `-`, que +o contrato exige na prova. -#### Parametros requeridos: -- `amount` -- `pixTarget` ``` -curl -X POST http://localhost:5000/request \ --H "Content-Type: application/json" \ --d '{ - "amount": 100.00, - "pixTarget": 123 +curl -X POST http://localhost:5000/request -H 'content-type: application/json' -d '{ + "amount": 100.00, "pixTarget": 123, "lockId": "42", "chainId": "11155111" }' ``` -#### Retorna em formato JSON: - - `numeroSolicitacao`: usado para chamar `/release` depois - - `textoQrCode`: usado para gerar o QR PIX -## GET /release/{numeroSolicitacao} -### Liberação de Pagamento -Chamado pelo comprador após pagar o Pix -``` exemplo -curl http://localhost:5000/release/123 +Retorna a resposta do BB mais `numeroSolicitacao` (string) e `textoQrCode`. + +### GET /release/{numeroSolicitacao} + +Prova de pagamento Pix. Chamado pelo comprador depois de pagar. + +- `402` enquanto o banco não confirma o pagamento +- `202` enquanto a atestação está sendo produzida (15 s a 1 min) +- `200` com a prova +- `502` banco ou Primus falharam de forma definitiva; `503` vale repetir + ``` -#### Retorna em formato JSON: -- `chainid`-`pixTarget` -- `amount`: valor em wei -- `pixTimestamp` -- `signature`: assinatura ethereum compatível - -# mTLS - -##### `key.pem`: chave privada e certificado da empresa -Descriptografar o e-CNPJ em formato PKCS#12 e converter em formato PEM (☢️ contém chave privada): -``` -umask 077; # tirar permissão de leitura global -openssl pkcs12 -in .p12 -legacy -clcerts -noenc -out key.pem +curl -i http://localhost:5000/release/1408799 ``` -##### `bb.pem`: certificado do BB -Descarregar o certificado de https://apoio.developers.bb.com.br/referency/post/646799afa2e2b90012c5ede8 e converter em formato PEM: ``` -openssl x509 -in raiz_v3.der -inform DER -outform PEM -out bb.pem +{ + "numeroSolicitacao": "1408799", + "proof": { + "recipient": "0x…", "attestor": "0x…", + "data": "{\"amount\":\"100\",…}", + "timestamp": 1766377317483, + "additionParams": "{\"algorithmType\":\"proxytls\"}", + "attConditions": "", + "signature": "0x…", "taskId": "0x…", "reportTxHash": "0x…", + "requests": [{ "url": "…", "header": "", "method": "GET", "body": "" }], + "responseResolves": [[…]], + "selfVerified": true, "contractRebuildMatches": true + } +} ``` -### Envio de certificado -Para criar a cadeia de certificados pra enviar pro BB usar: -``` -openssl pkcs12 -in .p12 -nokeys -legacy -out cert.pem -``` -enviar o `cert.pem` no formulário do portal developers como "cadeia completa". +`release(lockID, proof)` takes `numeroSolicitacao`, `recipient`, `data`, +`timestamp`, `additionParams`, `attConditions`, `attestor` and `signature`; +the rest is evidence. `selfVerified` means the signature recovers the +attestor over the returned struct; `contractRebuildMatches` means the +contract's rebuild rules (empty header and body, `GET`) reproduce it. -# Observações -- Para ambiente de desenvolvimento use `DEBUG=true` -- Em produção, o servidor usa Waitress como servidor WSGI -- Para mais informações, consulte a documentação oficial: https://developers.bb.com.br/ \ No newline at end of file +`bun run digest-check ` recomputes the digest of a saved +answer and prints the URL, header, parseType and data layout the contract +constants are pinned to. + +## mTLS + +`CLIENT_CRT` is the certificate chain BB has on file for this application and +`CLIENT_KEY` its private key, both PEM. Register a dedicated client +certificate for the prover; the attested requests run through the Primus +attestor, so the key material should not be the company e-CNPJ. To extract a +key-only PEM from a PKCS#12 file: + +``` +umask 077 +openssl pkcs12 -in .p12 -legacy -nocerts -noenc -out client.key +openssl pkcs12 -in .p12 -legacy -nokeys -out client.crt +``` + +The `*.mtls.api.*.bb.com.br` hosts present a publicly trusted chain, so no +extra CA is needed; set `BB_CA` only for hosts signed by BB's own CA. diff --git a/bbpay.ts b/bbpay.ts index 4bdf6fe..1ce41ab 100644 --- a/bbpay.ts +++ b/bbpay.ts @@ -1,286 +1,210 @@ -import PrimusNetwork from '@primuslabs/network-core-sdk'; -import dotenv from 'dotenv-flow'; -import express, { Request, Response } from 'express'; import cors from 'cors'; -import axios, { AxiosInstance } from 'axios'; -import { ClientCredentials, Token } from 'simple-oauth2'; -import { ethers } from 'ethers'; -import bs58 from 'bs58'; -import { Wallet } from 'ethers'; -import { toWei } from 'web3-utils'; -import https from 'https'; -import http from 'http'; import debug from 'debug'; -import axiosDebugLog from 'axios-debug-log'; -import fs from 'fs'; +import express, { Request, Response } from 'express'; +import { BBPay, 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'; -// Load environment variables from .env file -dotenv.config(); +const log = debug('zkpix'); +const config = loadConfig(); +if (config.debug) debug.enable('zkpix,simple-oauth2'); -// Create a debug instance -const log = debug('bbpay'); - -// Enable debug messages based on the DEBUG environment variable -if (process.env.DEBUG) { - debug.enable('bbpay,simple-oauth2,axios'); -} - -// Enable debug messages for Axios requests -axiosDebugLog({ - request: function (debug, config) { - debug('Request:', config); - }, - response: function (debug, response) { - debug('Response:', response); - }, - error: function (debug, error) { - debug('Error:', error); - }, -}); +const oauth = new OAuth(config.bb); +const bb = new BBPay(config.bb, oauth); +const primus = new Primus(config.primus, log); +const cache = new ProofCache(); const app = express(); app.use(cors()); app.use(express.json()); -class BBPay { - protected oauth: AxiosInstance; - protected cert: string; - protected key: string; - protected verifySsl: string; - protected baseUrl: string; - protected params: any; - protected scope: string[]; +const DIGITS = /^[0-9]+$/; +const isDigits = (value: unknown): value is string | number => + (typeof value === 'string' || typeof value === 'number') && DIGITS.test(String(value)); - protected async setupOauth(): Promise { - log('Setting up OAuth...'); - const client = new ClientCredentials({ - client: { - id: process.env.CLIENT_ID, - secret: process.env.CLIENT_SECRET, - }, - auth: { - tokenHost: process.env.ITP_OAUTH_URL, - }, - }); - - this.cert = fs.readFileSync('cert.pem').toString(); - this.key = fs.readFileSync('key.pem').toString(); - this.verifySsl = fs.readFileSync('bb.pem').toString(); - this.baseUrl = process.env.ITP_API_URL as string; - - this.params = { - numeroConvenio: process.env.CONVENIO, - 'gw-dev-app-key': process.env.DEV_APP_KEY, - }; - - this.scope = [ - 'checkout.solicitacoes-requisicao', - 'checkout.participantes-requisicao', - 'checkout.solicitacoes-info', - 'checkout.participantes-info', - ]; - - const tokenParams = { - scope: this.scope.join(' '), - }; - - log('Fetching access token...'); - const accessToken: Token = await client.getToken(tokenParams); - log('Access token fetched successfully.'); - - this.oauth = axios.create({ - baseURL: this.baseUrl, - headers: { - Authorization: `Bearer ${accessToken.token.access_token}`, - }, - httpsAgent: new https.Agent({ - cert: this.cert, - key: this.key, - rejectUnauthorized: false, - }), - }); - } - - public async init(): Promise { - await this.setupOauth(); - } -} - -class BBRegister extends BBPay { - public async post(req: Request, res: Response): Promise { - log('Registering participant...'); - const data = req.body; - const body = { - numeroConvenio: process.env.CONVENIO, - nomeParticipante: data.chainID, - tipoDocumento: data.tipoDocumento, - numeroDocumento: data.numeroDocumento, - numeroConta: data.numeroConta, - numeroAgencia: data.numeroAgencia, - tipoConta: data.tipoConta, - codigoIspb: data.codigoIspb, // Código identificador do Sistema de Pagamentos Brasileiro. Atualmente aceitamos apenas Banco do Brasil, codigoIspb igual a 0 - }; - - try { - log('Sending request to register participant...'); - const response = await this.oauth.post('/participantes', body, { - params: this.params, - }); - - if (response.status !== 201) { - log('Upstream error:', response.status); - res.status(response.status).send('Upstream error'); - return; - } - - log('Participant registered successfully.'); - res.json(response.data); - } catch (error) { - log('Internal server error:', error); - res.status(500).send('Internal server error'); - } - } -} - -class BBRequest extends BBPay { - public async post(req: Request, res: Response): Promise { - log('Creating request...'); - const data = req.body; - const body = { - geral: { - numeroConvenio: process.env.CONVENIO, - pagamentoUnico: true, - descricaoSolicitacao: 'P2Pix', - valorSolicitacao: data.amount, - codigoConciliacaoSolicitacao: data.lockid, - }, - formasPagamento: [{ codigoTipoPagamento: 'PIX', quantidadeParcelas: 1 }], - repasse: { - tipoValorRepasse: 'Percentual', - recebedores: [{ - identificadorRecebedor: data.pixTarget, - tipoRecebedor: 'Participante', - valorRepasse: 100, - }], - }, - }; - - try { - log('Sending request to create request...'); - const response = await this.oauth.post('/solicitacoes', body, { - params: this.params, - }); - - if (response.status !== 201) { - log('Upstream error:', response.status); - res.status(response.status).send('Upstream error'); - return; - } - - log('Request created successfully.'); - res.json(response.data); - } catch (error) { - log('Internal server error:', error); - res.status(500).send('Internal server error'); - } - } -} - -class BBRelease extends BBPay { - public async get(req: Request, res: Response): Promise { - const numeroSolicitacao = req.params.numeroSolicitacao; - log(`Releasing request ${numeroSolicitacao}...`); - - try { - log('Fetching request details...'); - const response = await this.oauth.get(`/solicitacoes/${numeroSolicitacao}`, { - params: this.params, - }); - - if (response.status !== 200) { - log('Upstream error:', response.status); - res.status(response.status).send('Upstream error'); - return; - } - - const data = response.data; - const numeroParticipante = data.repasse.recebedores[0].identificadorRecebedor; - const pixTimestamp = ethers.utils.solidityPack(['bytes32'], [ethers.utils.hexZeroPad(bs58.decode(data.informacoesPix.txId),32)]); - const valorSolicitacao = toWei(data.valorSolicitacao, 'ether'); - const codigoEstadoSolicitacao = data.codigoEstadoSolicitacao; - - if (codigoEstadoSolicitacao !== 1) { - log('Pix not paid.'); - res.status(204).send('Pix not paid'); - return; - } - - log('Fetching participant details...'); - const participantResponse = await this.oauth.get(`/participantes/${numeroParticipante}`, { - params: this.params, - }); - - if (participantResponse.status !== 200) { - log('Upstream error:', participantResponse.status); - res.status(participantResponse.status).send('Upstream error'); - return; - } - - const chainID = participantResponse.data.nomeParticipante; - const packed = ethers.utils.solidityPack(['bytes32', 'uint80', 'bytes32'], [ - ethers.utils.hexZeroPad(ethers.utils.toUtf8Bytes(`${chainID}-${numeroParticipante}`),32), - ethers.BigNumber.from(valorSolicitacao), - ethers.utils.hexZeroPad(pixTimestamp,32), - ]); - const signable = ethers.utils.keccak256(packed); - const wallet = new Wallet(process.env.PRIVATE_KEY as string); - const signature = await wallet.signMessage(signable); - - log('Request released successfully.'); - res.json({ - pixTarget: `${chainID}-${numeroParticipante}`, - amount: valorSolicitacao.toString(), - pixTimestamp: pixTimestamp, - signature: signature, - }); - } catch (error) { - log('Internal server error:', error); - res.status(500).send('Internal server error'); - } - } -} - -// (CPF, nome, conta) -> participantID -// should be called before deposit -const register = new BBRegister(); +/** Seller registration, called before `deposit`. Passes BB's answer through. */ app.post('/register', async (req: Request, res: Response) => { - await register.init(); - await register.post(req, res); + 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) { + log('register: upstream %d %o', response.status, response.data); + res.status(502).json({ error: 'Upstream error', status: response.status }); + return; + } + res.json(response.data); + } catch (error) { + log('register failed: %o', error); + res.status(500).json({ error: 'Internal server error' }); + } }); -// (amount,pixtarget) -> requestID, QRcodeText -// should be called after lock -const request = new BBRequest(); +/** + * Charge creation, called after `lock`. The reconciliation code binds the + * charge to the lock: the contract requires it to equal `-`. + */ app.post('/request', async (req: Request, res: Response) => { - await request.init(); - await request.post(req, res); + 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) { + log('request: upstream %d %o', response.status, response.data); + 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) { + log('request failed: %o', error); + res.status(500).json({ error: 'Internal server error' }); + } }); -// (requestID) -> sig(pixTarget, amount, pixTimestamp) -// should be called before release -const release = new BBRelease(); +/** The Primus job for one paid charge; the result lands in the cache. */ +const prove = async (numeroSolicitacao: string, solicitation: Solicitation): Promise => { + 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('attestation for %s: %s', numeroSolicitacao, JSON.stringify({ + 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('attestation for %s failed (%s): %o', numeroSolicitacao, mapped.code, error); + 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) => { - await release.init(); - await release.get(req, res); + 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) { + log('release: upstream %d %o', response.status, response.data); + 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) { + log('release failed: %o', error); + res.status(500).json({ error: 'Internal server error' }); + } }); -if (process.env.DEBUG) { - app.listen(process.env.PORT || 5000, () => { - log(`Server running on port ${process.env.PORT || 5000}`); - }); -} else { - const server = http.createServer(app); - server.listen(process.env.PORT || 5000, () => { - log(`Server running on port ${process.env.PORT || 5000}`); - }); -} +const main = async (): Promise => { + await primus.init(); + await primus.reclaim(); + app.listen(config.port, () => log(`Prover listening on port ${config.port}`)); +}; + +main().catch((error) => { + console.error('zkPix prover failed to start:', error); + process.exit(1); +}); diff --git a/package.json b/package.json index 3ffa855..e7c17b0 100644 --- a/package.json +++ b/package.json @@ -1,28 +1,31 @@ { - "name": "network-core-sdk-example", - "version": "1.0.0", - "description": "", - "main": "index.js", + "name": "zkpix-prover", + "version": "0.0.0", + "description": "P2Pix prover: proves a BB Pay Pix payment through a Primus zkTLS attestation", + "main": "bbpay.ts", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", - "start": "tsx bbpay.ts" + "start": "tsx bbpay.ts", + "typecheck": "tsc -p tsconfig.json", + "digest-check": "tsx scripts/digest-check.ts" }, - "author": "", - "license": "ISC", + "license": "AGPL", "dependencies": { - "@primuslabs/network-core-sdk": "^0.1.5", + "@primuslabs/network-core-sdk": "^0.1.19", "axios": "^1.13.2", - "axios-debug-log": "^1.0.0", - "bs58": "^6.0.0", "cors": "^2.8.5", + "debug": "^4.4.0", "dotenv-flow": "^4.1.0", "ethers": "^5.8.0", "express": "^5.2.1", - "simple-oauth2": "^5.1.0", - "typescript": "^5.9.3", - "web3-utils": "^4.3.3" + "simple-oauth2": "^5.1.0" }, "devDependencies": { - "@types/express": "^5.0.6" + "@types/cors": "^2.8.17", + "@types/debug": "^4.1.12", + "@types/express": "^5.0.6", + "@types/node": "^24.1.0", + "@types/simple-oauth2": "^5.0.7", + "tsx": "^4.20.0", + "typescript": "^5.9.3" } } diff --git a/src/bb.ts b/src/bb.ts new file mode 100644 index 0000000..eaa65fa --- /dev/null +++ b/src/bb.ts @@ -0,0 +1,80 @@ +import axios, { AxiosInstance } from 'axios'; +import https from 'https'; +import type { Config } from './config'; +import { FULL_SCOPES, OAuth } from './oauth'; + +/** The subset of `GET /solicitacoes/{n}` the prover reads before attesting. */ +export interface Solicitation { + numeroSolicitacao: number; + codigoEstadoSolicitacao: number; + valorSolicitacao: number; + codigoConciliacaoSolicitacao?: string; + informacoesPix?: { txId?: string; textoQrCode?: string }; + repasse?: { + tipoValorRepasse?: string; + recebedores?: { identificadorRecebedor: number; tipoRecebedor?: string; valorRepasse?: number }[]; + }; +} + +/** Maps the checkout API onto the three calls the prover makes. */ +export class BBPay { + private readonly http: AxiosInstance; + private readonly params: Record; + + constructor(private readonly config: Config['bb'], private readonly oauth: OAuth) { + this.params = { numeroConvenio: config.convenio, 'gw-dev-app-key': config.devAppKey }; + this.http = axios.create({ + baseURL: config.apiUrl, + httpsAgent: new https.Agent({ + cert: config.clientCrt, + key: config.clientKey, + ca: config.ca, + }), + validateStatus: () => true, + }); + } + + private async headers(): Promise> { + return { Authorization: await this.oauth.bearer(FULL_SCOPES) }; + } + + /** `POST /participantes`: returns BB's body (`numeroParticipante` on 201). */ + async registerParticipant(body: unknown): Promise<{ status: number; data: unknown }> { + const response = await this.http.post('/participantes', body, { + params: this.params, + headers: await this.headers(), + }); + return { status: response.status, data: response.data }; + } + + /** `POST /solicitacoes`: returns BB's body (`numeroSolicitacao`, `informacoesPix` on 201). */ + async createSolicitation(body: unknown): Promise<{ status: number; data: unknown }> { + const response = await this.http.post('/solicitacoes', body, { + params: this.params, + headers: await this.headers(), + }); + return { status: response.status, data: response.data }; + } + + /** `GET /solicitacoes/{n}`. */ + async getSolicitation(numeroSolicitacao: string): Promise<{ status: number; data: Solicitation }> { + const response = await this.http.get(`/solicitacoes/${numeroSolicitacao}`, { + params: this.params, + headers: await this.headers(), + }); + return { status: response.status, data: response.data as Solicitation }; + } + + /** + * The exact URLs the attestor replays. They carry the same query the prover + * sends; whether the query survives into the signed attestation is pinned by + * the first real run (see proof.ts). + */ + solicitationUrl(numeroSolicitacao: string): string { + return `${this.config.apiUrl}/solicitacoes/${numeroSolicitacao}?${this.query()}`; + } + + private query(): string { + return new URLSearchParams(this.params).toString(); + } +} diff --git a/src/cache.ts b/src/cache.ts new file mode 100644 index 0000000..066a83a --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,31 @@ +import type { ReleaseBody } from './proof'; + +export type CacheEntry = + | { status: 'proving'; since: number } + | { status: 'done'; body: ReleaseBody } + | { status: 'failed'; at: number; error: string; retryable: boolean }; + +/** How long a failed, non-retryable proof is remembered before a poll retries it. */ +const FAILED_TTL_MS = 10 * 60 * 1000; + +/** Per-solicitation proof state, so repeated polls never re-attest a charge. */ +export class ProofCache { + private readonly entries = new Map(); + + get(numeroSolicitacao: string): CacheEntry | undefined { + const entry = this.entries.get(numeroSolicitacao); + if (entry?.status === 'failed' && (entry.retryable || Date.now() - entry.at > FAILED_TTL_MS)) { + this.entries.delete(numeroSolicitacao); + return undefined; + } + return entry; + } + + set(numeroSolicitacao: string, entry: CacheEntry): void { + this.entries.set(numeroSolicitacao, entry); + } + + clear(numeroSolicitacao: string): void { + this.entries.delete(numeroSolicitacao); + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..6561b93 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,79 @@ +import dotenv from 'dotenv-flow'; +import fs from 'fs'; +import { Wallet } from 'ethers'; + +dotenv.config(); + +const required = (name: string): string => { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`Missing ${name} in the environment`); + return value; +}; + +const optional = (name: string, fallback: string): string => + process.env[name]?.trim() || fallback; + +const readPem = (name: string, path: string): string => { + try { + return fs.readFileSync(path).toString(); + } catch (error) { + throw new Error(`${name}: cannot read ${path} (${(error as Error).message})`); + } +}; + +/** Everything the prover needs, validated once at boot. */ +export interface Config { + port: number; + debug: boolean; + bb: { + clientId: string; + clientSecret: string; + devAppKey: string; + convenio: string; + oauthUrl: string; + /** Base URL of the checkout API, without a trailing slash. */ + apiUrl: string; + /** PEM strings for mutual TLS: the client certificate chain and its private key. */ + clientCrt: string; + clientKey: string; + /** Extra CA bundle, only needed for hosts whose chain is not in Node's store. */ + ca?: string; + }; + primus: { + chainId: number; + rpcUrl: string; + /** Signs and pays Primus tasks; its address is the attestation recipient. */ + wallet: Wallet; + }; +} + +/** The Primus side alone: enough to submit a task without any BB credential. */ +export const loadPrimusConfig = (): Config['primus'] => ({ + chainId: Number(optional('PRIMUS_CHAIN_ID', '84532')), + rpcUrl: optional('PRIMUS_RPC_URL', 'https://sepolia.base.org'), + wallet: new Wallet(required('PRIVATE_KEY')), +}); + +export const loadConfig = (): Config => { + const primus = loadPrimusConfig(); + const apiUrl = required('ITP_API_URL').replace(/\/+$/, ''); + const clientCrtPath = optional('CLIENT_CRT', 'cert.pem'); + const clientKeyPath = optional('CLIENT_KEY', 'key.pem'); + const caPath = process.env.BB_CA?.trim(); + return { + port: Number(optional('PORT', '5000')), + debug: /^(1|true)$/i.test(optional('DEBUG', 'false')), + bb: { + clientId: required('CLIENT_ID'), + clientSecret: required('CLIENT_SECRET'), + devAppKey: required('DEV_APP_KEY'), + convenio: required('CONVENIO'), + oauthUrl: required('ITP_OAUTH_URL'), + apiUrl, + clientCrt: readPem('CLIENT_CRT', clientCrtPath), + clientKey: readPem('CLIENT_KEY', clientKeyPath), + ca: caPath ? readPem('BB_CA', caPath) : undefined, + }, + primus, + }; +}; diff --git a/src/oauth.ts b/src/oauth.ts new file mode 100644 index 0000000..fac08b5 --- /dev/null +++ b/src/oauth.ts @@ -0,0 +1,53 @@ +import { AccessToken, ClientCredentials } from 'simple-oauth2'; +import type { Config } from './config'; + +/** Scopes the prover itself needs: it creates participants and charges. */ +export const FULL_SCOPES = [ + 'checkout.solicitacoes-requisicao', + 'checkout.participantes-requisicao', + 'checkout.solicitacoes-info', + 'checkout.participantes-info', +]; + +/** + * Scope of the token that travels with the attested request. It can only + * read charges, so its exposure to the Primus attestor (and to anyone who + * sees the attested request) cannot cancel charges or rewrite a seller's + * bank account. + */ +export const READ_SCOPES = ['checkout.solicitacoes-info']; + +/** Seconds before expiry at which a cached token is renewed. */ +const RENEW_WINDOW = 60; + +/** + * One cached client-credentials token per scope set. The token endpoint does + * not require mTLS, so this runs outside the attestation. + */ +export class OAuth { + private readonly client: ClientCredentials; + private readonly tokens = new Map(); + + constructor(config: Config['bb']) { + const url = new URL(config.oauthUrl); + this.client = new ClientCredentials({ + client: { id: config.clientId, secret: config.clientSecret }, + auth: { tokenHost: url.origin, tokenPath: url.pathname }, + }); + } + + async bearer(scopes: string[]): Promise { + const scope = scopes.join(' '); + let token = this.tokens.get(scope); + if (!token || token.expired(RENEW_WINDOW)) { + token = await this.client.getToken({ scope }); + this.tokens.set(scope, token); + } + return `Bearer ${token.token.access_token as string}`; + } + + /** Drops a cached token so the next call mints a fresh one. */ + forget(scopes: string[]): void { + this.tokens.delete(scopes.join(' ')); + } +} diff --git a/src/primus.ts b/src/primus.ts new file mode 100644 index 0000000..0e09be7 --- /dev/null +++ b/src/primus.ts @@ -0,0 +1,92 @@ +import { PrimusNetwork } from '@primuslabs/network-core-sdk'; +import { ethers } from 'ethers'; +import type { Config } from './config'; +import type { AttestResult, Resolve } from './proof'; + +export interface AttestRequest { + url: string; + method: string; + header: Record; + body: string; +} + +/** + * One `PrimusNetwork` for the process. `attest` runs one task at a time: the + * SDK rejects concurrent sessions (error 00003), and every task costs the + * fee the Task contract quotes at send time. + */ +export class Primus { + private readonly network = new PrimusNetwork(); + private readonly address: string; + private queue: Promise = Promise.resolve(); + + constructor(private readonly config: Config['primus'], private readonly log: (...args: unknown[]) => void) { + this.address = config.wallet.address; + } + + async init(): Promise { + const provider = new ethers.providers.JsonRpcProvider(this.config.rpcUrl); + const wallet = this.config.wallet.connect(provider); + await this.network.init(wallet, this.config.chainId); + this.log(`Primus initialised on chain ${this.config.chainId} as ${this.address}`); + } + + /** The attestation recipient: the prover's own task wallet. */ + get recipient(): string { + return this.address; + } + + attest( + requests: AttestRequest[], + responseResolves: Resolve[][], + /** Client certificate for the attested host; omitted for public endpoints. */ + mTLS?: { clientCrt: string; clientKey: string }, + ): Promise { + const run = async (): Promise => { + const submitTaskParams = { address: this.address }; + const submitTaskResult = await this.network.submitTask(submitTaskParams); + this.log('Primus task submitted', submitTaskResult); + const attestParams = { + ...submitTaskParams, + ...(submitTaskResult as object), + requests, + responseResolves, + ...(mTLS ? { mTLS } : {}), + } as Parameters[0]; + const attestResult = (await this.network.attest(attestParams)) as AttestResult[]; + if (!attestResult?.[0]) throw new Error('Primus returned no attestation'); + return attestResult[0]; + }; + const next = this.queue.then(run, run); + this.queue = next.catch(() => undefined); + return next; + } + + /** + * Reclaims the fee of tasks that missed the 900 s reporting window. Called + * at boot and after a failed job; a revert means there is nothing to reclaim. + */ + async reclaim(): Promise { + try { + await this.network.withdrawBalance(); + this.log('Reclaimed timed-out Primus task fees'); + } catch (error) { + this.log('Nothing to reclaim from Primus:', (error as Error).message?.slice(0, 120)); + } + } +} + +/** HTTP status and retry semantics for a failed attestation. */ +export const mapPrimusError = (error: unknown): { status: number; retryable: boolean; code: string } => { + const err = error as { code?: unknown; errorData?: { code?: unknown }; message?: string }; + const code = String(err?.code ?? err?.errorData?.code ?? ''); + if (code === '00003') return { status: 202, retryable: true, code }; + if (['00013', '30001', '30002', '30004'].includes(code)) return { status: 502, retryable: false, code }; + if (/^(00002|1000[1-4]|40002|50004|50006|50009|00000)$/.test(code)) { + return { status: 503, retryable: true, code }; + } + if (/insufficient funds|nonce too low/i.test(err?.message ?? '')) { + return { status: 500, retryable: true, code: code || 'wallet' }; + } + return { status: 500, retryable: false, code: code || 'unknown' }; +}; diff --git a/src/proof.ts b/src/proof.ts new file mode 100644 index 0000000..1cd5e62 --- /dev/null +++ b/src/proof.ts @@ -0,0 +1,205 @@ +import { ethers } from 'ethers'; + +/** One `responseResolves` entry as the SDK takes it. */ +export interface Resolve { + keyName: string; + parseType: string; + parsePath: string; +} + +/** + * Fields revealed from `GET /solicitacoes/{n}`, in the order they are sent. + * BB-controlled values come first; the reconciliation code, which the prover + * wrote at creation, comes last. The contract compares every one of them. + */ +export const SOLICITATION_RESOLVES: Resolve[] = [ + { keyName: 'amount', parseType: 'json', parsePath: '$.valorSolicitacao' }, + { keyName: 'settled', parseType: 'json', parsePath: '$.valorSomatorioPagamentosEfetivados' }, + { keyName: 'payee', parseType: 'json', parsePath: '$.repasse.recebedores[0].identificadorRecebedor' }, + { keyName: 'txId', parseType: 'json', parsePath: '$.informacoesPix.txId' }, + { keyName: 'lock', parseType: 'json', parsePath: '$.codigoConciliacaoSolicitacao' }, +]; + +export interface AttestedRequest { + url: string; + header: unknown; + method: string; + body: string; +} + +/** One request's resolves as the attestor reports them on-chain. */ +export interface OneUrlResolves { + oneUrlResponseResolve: Resolve[]; +} + +/** + * The struct the attestor signed, as the SDK hands it back: the on-chain + * layout (`request[]`, per-request `oneUrlResponseResolve[]`, empty strings + * present) under the key `responseResolves`; `responseResolve` is the key the + * Task contract uses for the same field. + */ +export interface AttestationStruct { + recipient: string; + request: AttestedRequest[]; + responseResolves?: (Resolve[] | OneUrlResolves)[]; + responseResolve?: (Resolve[] | OneUrlResolves)[]; + data: string; + attConditions: string; + timestamp: number; + additionParams: string; +} + +/** `attestResult[0]` of `PrimusNetwork.attest`. */ +export interface AttestResult { + attestation: AttestationStruct; + attestor: string; + signature: string; + taskId: string; + reportTxHash?: string; + attestationTime?: number; + attestorUrl?: string; +} + +const keccakStrings = (parts: string[]): string => + parts.length === 0 + ? ethers.utils.keccak256('0x') + : ethers.utils.solidityKeccak256(parts.map(() => 'string'), parts); + +const headerAsString = (header: unknown): string => + typeof header === 'string' ? header : header == null ? '' : JSON.stringify(header); + +export const resolvesOf = (attestation: AttestationStruct): Resolve[][] => + (attestation.responseResolves ?? attestation.responseResolve ?? []).map((r) => + Array.isArray(r) ? r : r.oneUrlResponseResolve, + ); + +/** `PrimusZKTLS.encodeRequest`: one keccak over url‖header‖method‖body of every request. */ +export const encodeRequest = (requests: AttestedRequest[]): string => + keccakStrings(requests.flatMap((r) => [r.url, headerAsString(r.header), r.method, r.body])); + +/** `PrimusZKTLS.encodeResponse`: one keccak over keyName‖parseType‖parsePath of every resolve. */ +export const encodeResponse = (resolves: Resolve[][]): string => + keccakStrings(resolves.flat().flatMap((r) => [r.keyName, r.parseType, r.parsePath])); + +/** `PrimusZKTLS.encodeAttestation`: the raw keccak the attestor signs (no EIP-191 prefix). */ +export const digestOf = ( + attestation: AttestationStruct, + requests: AttestedRequest[] = attestation.request, + resolves: Resolve[][] = resolvesOf(attestation), +): string => + ethers.utils.solidityKeccak256( + ['address', 'bytes32', 'bytes32', 'string', 'string', 'uint64', 'string'], + [ + attestation.recipient, + encodeRequest(requests), + encodeResponse(resolves), + attestation.data, + attestation.attConditions, + attestation.timestamp, + attestation.additionParams, + ], + ); + +export interface SelfVerification { + /** The struct as returned recovers `attestor` under the upstream encoding. */ + verified: boolean; + recovered: string; + /** How the signed URL relates to the URL the prover sent. */ + urlForm: 'query-kept' | 'query-stripped' | 'other'; + /** `parseType` as the attestor recorded it: `""`, whatever was sent. */ + parseType: string; + /** Header exactly as the attestor echoed it (expected: empty string). */ + headers: unknown[]; + /** The contract's rebuild rules (header/body "", method GET) reproduce the digest. */ + contractRebuildMatches: boolean; +} + +/** + * Recomputes the digest from the returned struct and checks the signature, + * then reports the facts the contract constants must be pinned to. + */ +export const selfVerify = ( + result: AttestResult, + sentUrls: string[], +): SelfVerification => { + const attestation = result.attestation; + const digest = digestOf(attestation); + let recovered = ethers.constants.AddressZero; + try { + recovered = ethers.utils.recoverAddress(digest, result.signature); + } catch { + // an unparsable signature simply fails verification + } + const verified = recovered.toLowerCase() === result.attestor.toLowerCase(); + + const signedUrls = attestation.request.map((r) => r.url); + const stripped = sentUrls.map((u) => `${u.split('?')[0]}?`); + const urlForm = + signedUrls.every((u, i) => u === sentUrls[i]) + ? 'query-kept' + : signedUrls.every((u, i) => u === stripped[i]) + ? 'query-stripped' + : 'other'; + + const resolves = resolvesOf(attestation); + const parseType = resolves[0]?.[0]?.parseType ?? ''; + + const rebuilt = attestation.request.map((r) => ({ url: r.url, header: '', method: 'GET', body: '' })); + const contractRebuildMatches = + verified && + ethers.utils + .recoverAddress(digestOf(attestation, rebuilt), result.signature) + .toLowerCase() === result.attestor.toLowerCase(); + + return { + verified, + recovered, + urlForm, + parseType, + headers: attestation.request.map((r) => r.header), + contractRebuildMatches, + }; +}; + +/** What `GET /release/:n` answers once the charge is proven. */ +export interface ReleaseBody { + numeroSolicitacao: string; + proof: { + recipient: string; + attestor: string; + data: string; + timestamp: number; + additionParams: string; + attConditions: string; + signature: string; + taskId: string; + reportTxHash?: string; + requests: { url: string; header: unknown; method: string; body: string }[]; + responseResolves: Resolve[][]; + selfVerified: boolean; + contractRebuildMatches: boolean; + }; +} + +export const toReleaseBody = ( + numeroSolicitacao: string, + result: AttestResult, + verification: SelfVerification, +): ReleaseBody => ({ + numeroSolicitacao, + proof: { + recipient: result.attestation.recipient, + attestor: result.attestor, + data: result.attestation.data, + timestamp: result.attestation.timestamp, + additionParams: result.attestation.additionParams, + attConditions: result.attestation.attConditions, + signature: result.signature, + taskId: result.taskId, + reportTxHash: result.reportTxHash, + requests: result.attestation.request, + responseResolves: resolvesOf(result.attestation), + selfVerified: verification.verified, + contractRebuildMatches: verification.contractRebuildMatches, + }, +});