zkPix v0.0.0

This commit is contained in:
root committed 2026-09-14 21:19:58 -03:00
1 parent f1759f366c
commit e25caafa8d
11 files changed
+865 -368

No files matched your search

+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,
},
});