zkPix v0.0.0
This commit is contained in:
+18
-5
@@ -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
|
||||
|
||||
@@ -26,3 +26,10 @@ dist-ssr
|
||||
|
||||
*.crt
|
||||
*.key
|
||||
|
||||
# certificates, keys and every environment file except the example
|
||||
*.pem
|
||||
*.p12
|
||||
*.zip
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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(' '));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user