zkPix v0.0.0

This commit is contained in:
root
2026-09-14 21:19:58 -03:00
parent f1759f366c
commit e25caafa8d
11 changed files with 865 additions and 368 deletions
+18 -5
View File
@@ -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
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
+7
View File
@@ -26,3 +26,10 @@ dist-ssr
*.crt
*.key
# certificates, keys and every environment file except the example
*.pem
*.p12
*.zip
.env.*
!.env.example
+91 -81
View File
@@ -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 `<chainId>-<numeroParticipante>`).
## 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 `<chainId>-<lockId>`, 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 <arquivo>.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 <arquivo>.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/
`bun run digest-check <release-body.json>` 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 <arquivo>.p12 -legacy -nocerts -noenc -out client.key
openssl pkcs12 -in <arquivo>.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.
+191 -267
View File
@@ -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<void> {
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<void> {
await this.setupOauth();
}
}
class BBRegister extends BBPay {
public async post(req: Request, res: Response): Promise<void> {
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<void> {
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<void> {
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 `<chainId>-<lockId>`.
*/
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<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('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<void> => {
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);
});
+18 -15
View File
@@ -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"
}
}
+80
View File
@@ -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<string, string>;
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<Record<string, string>> {
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();
}
}
+31
View File
@@ -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<string, CacheEntry>();
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);
}
}
+79
View File
@@ -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,
};
};
+53
View File
@@ -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<string, AccessToken>();
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<string> {
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(' '));
}
}
+92
View File
@@ -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<string, string>;
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<unknown> = Promise.resolve();
constructor(private readonly config: Config['primus'], private readonly log: (...args: unknown[]) => void) {
this.address = config.wallet.address;
}
async init(): Promise<void> {
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<AttestResult> {
const run = async (): Promise<AttestResult> => {
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<PrimusNetwork['attest']>[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<void> {
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' };
};
+205
View File
@@ -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,
},
});