Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions apps/web/src/app/api/sync/proofs/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { NextResponse } from 'next/server';
import { withClient, ensureSchema } from '@/lib/db';
import { getMerchantFromRequest } from '@/lib/merchants';
import { ensureZkCommitmentsSchema, recordVerifiedCommitment } from '@/lib/zk-ledger';
import {
ZK_PROOF_SCHEME,
canonicalJson,
sha256Hex,
verifyOpeningProof,
type OpeningProof,
} from '@accensa/sdk/zk-proof';

export const dynamic = 'force-dynamic';

/**
* Zero-knowledge-verified state transitions (#173).
*
* The indexer's RPC sweep records on-chain truth about public transfers, but
* merchants also need to report state transitions privately — transaction
* volumes, routes, settlement details — without the indexer ever storing the
* plaintext. This endpoint is the privacy-preserving ingestion path:
*
* 1. The SDK commits to the transition with `createCommitment`, keeps the
* blinding secret, and submits `{ commitment, proof }` here.
* 2. The indexer verifies the opening proof (recomputing the commitment
* from the proof's payload + blinding). Verification happens entirely in
* memory — the plaintext payload never touches the database.
* 3. Only the commitment and a one-way SHA-256 of the canonical payload are
* persisted (see migrations/005_zk_commitments.sql). A leaked table
* exposes nothing about the underlying data.
*
* The verifier is pluggable (`ZkVerifier` in @accensa/sdk/zk-proof): a future
* migration to a real zk-SNARK circuit implements the same interface and this
* route does not change.
*
* Protected by session authentication via middleware, resolving to exactly
* the merchant that owns this dashboard session — a signed-in merchant can
* only submit proofs for themselves.
*/
export async function POST(request: Request) {
if (!process.env.DATABASE_URL) {
return NextResponse.json({ error: 'DATABASE_URL is not configured' }, { status: 500 });
}

let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}

const { commitment, proof } = (body ?? {}) as {
commitment?: unknown;
proof?: unknown;
};
if (typeof commitment !== 'string' || !/^[0-9a-f]{64}$/i.test(commitment)) {
return NextResponse.json(
{ error: 'commitment must be a 64-character hex SHA-256 digest' },
{ status: 400 },
);
}
if (!proof || typeof proof !== 'object') {
return NextResponse.json({ error: 'proof is required' }, { status: 400 });
}
const opening = proof as OpeningProof;
if (opening.scheme !== ZK_PROOF_SCHEME) {
return NextResponse.json(
{ error: `unsupported proof scheme: ${String(opening.scheme)}` },
{ status: 400 },
);
}

// Verify in memory before touching the database at all.
const valid = await verifyOpeningProof(commitment, opening);
if (!valid) {
return NextResponse.json({ error: 'proof does not open the commitment' }, { status: 422 });
}

try {
const result = await withClient(async (client) => {
await ensureSchema(client);
await ensureZkCommitmentsSchema(client);
const merchant = await getMerchantFromRequest(client, request);
if (!merchant) return null;
// The payload hash is the only trace of the plaintext ever persisted.
const payloadHash = await sha256Hex(canonicalJson(opening.payload));
const { recorded } = await recordVerifiedCommitment(client, merchant.id, {
commitment,
payloadHash,
scheme: opening.scheme,
});
return { address: merchant.address, recorded };
});

if (!result) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

return NextResponse.json(
{
success: true,
merchant: result.address,
commitment,
// 201 for a new commitment, 200 for an already-recorded one — the
// transition is accepted either way, it is simply idempotent.
recorded: result.recorded,
},
{ status: result.recorded ? 201 : 200 },
);
} catch {
return NextResponse.json(
{ success: false, error: 'Internal Server Error' },
{ status: 500 },
);
}
}
63 changes: 63 additions & 0 deletions apps/web/src/lib/zk-ledger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { Client } from 'pg';

/**
* ZK commitment ledger (#173).
*
* The sync API's proof endpoint verifies an opening proof entirely in memory,
* then records *only* the commitment and a one-way hash of the canonical
* payload here — never the plaintext, so a leaked table exposes nothing about
* the underlying data. This module owns that table's schema and the single
* write path into it.
*/

/**
* Creates the `zk_commitments` ledger schema (#173).
*
* See migrations/005_zk_commitments.sql for the same steps as a standalone SQL
* file. Called defensively at the top of the proofs route, the same way
* `ensureSchema` runs at the top of every DB-touching handler. Idempotent.
*/
export async function ensureZkCommitmentsSchema(client: Client): Promise<void> {
await client.query(`
CREATE TABLE IF NOT EXISTS zk_commitments (
id BIGSERIAL PRIMARY KEY,
merchant_id INT NOT NULL REFERENCES merchants(id),
commitment TEXT NOT NULL,
payload_hash TEXT NOT NULL,
proof_scheme TEXT NOT NULL DEFAULT 'sha256-commitment-opening',
verified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT zk_commitments_unique_commitment
UNIQUE (merchant_id, commitment)
);
`);
await client.query(
`CREATE INDEX IF NOT EXISTS idx_zk_commitments_merchant_verified
ON zk_commitments (merchant_id, verified_at DESC);`,
);
}

/**
* Records a verified zero-knowledge commitment.
*
* Called by the sync API *after* the proof has been verified — this function
* only persists the commitment and a one-way hash of the canonical payload,
* never the plaintext. Idempotent per (merchant, commitment): re-submitting
* the same verified transition is a no-op, not a duplicate.
*
* @returns Whether this was a new commitment (`true`) or an already-recorded
* one (`false`).
*/
export async function recordVerifiedCommitment(
client: Client,
merchantId: number,
c: { commitment: string; payloadHash: string; scheme: string },
): Promise<{ recorded: boolean }> {
const res = await client.query(
`INSERT INTO zk_commitments (merchant_id, commitment, payload_hash, proof_scheme)
VALUES ($1, $2, $3, $4)
ON CONFLICT (merchant_id, commitment) DO NOTHING`,
[merchantId, c.commitment, c.payloadHash, c.scheme],
);
return { recorded: (res.rowCount ?? 0) > 0 };
}
54 changes: 54 additions & 0 deletions migrations/005_zk_commitments.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
-- 005_zk_commitments.sql
--
-- Commitment ledger for zero-knowledge-verified state transitions (#173).
--
-- Merchants require privacy for transaction volumes: the indexer must be able
-- to accept and verify a commitment to a state transition without storing the
-- plaintext. This table records what was *proven* — a binding commitment plus
-- a hash of the canonical payload that opened it — never the payload itself.
--
-- A leaked table therefore exposes nothing about the underlying data: the
-- commitment is hiding (an observer cannot recover the payload from it) and
-- the payload hash is one-way. The full plaintext lives only with the SDK
-- that submitted the proof.
--
-- Rows are scoped to the merchant that submitted them (`merchant_id`), and
-- every write path resolves the merchant from the session before inserting.
--
-- This file is applied automatically by `ensureZkCommitmentsSchema()` in
-- apps/web/src/lib/zk-ledger.ts on every request to the proofs route. It is
-- committed here too for documentation and for anyone restoring a database
-- outside the app.

BEGIN;

CREATE TABLE IF NOT EXISTS zk_commitments (
id BIGSERIAL PRIMARY KEY,
merchant_id INT NOT NULL REFERENCES merchants(id),
-- Hex SHA-256 commitment as submitted by the SDK. Unique per merchant so
-- the same transition cannot be recorded twice.
commitment TEXT NOT NULL,
-- SHA-256 of the canonical JSON payload that opened the commitment. This
-- is the only trace of the underlying data; the payload itself is never
-- stored.
payload_hash TEXT NOT NULL,
-- Which verification scheme accepted this commitment, so a future scheme
-- migration can tell old rows from new.
proof_scheme TEXT NOT NULL DEFAULT 'sha256-commitment-opening',
verified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT zk_commitments_unique_commitment
UNIQUE (merchant_id, commitment)
);

CREATE INDEX IF NOT EXISTS idx_zk_commitments_merchant_verified
ON zk_commitments (merchant_id, verified_at DESC);

COMMIT;

-- ============================== DOWN ==============================
-- Not executed automatically. Run by hand to roll back.
--
-- BEGIN;
-- DROP TABLE IF EXISTS zk_commitments;
-- COMMIT;
16 changes: 16 additions & 0 deletions packages/sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,22 @@ export {
type TokenMeta,
} from './src/price-formatter';

/** Zero-knowledge commitments and opening proofs for off-chain privacy (#173). */
export {
ZK_PROOF_SCHEME,
canonicalJson,
sha256Hex,
randomBlinding,
commitmentOf,
createCommitment,
createOpeningProof,
verifyOpeningProof,
sha256CommitmentVerifier,
type CommitmentResult,
type OpeningProof,
type ZkVerifier,
} from './src/zk-proof';

/**
* This package deliberately ships no paywall middleware.
*
Expand Down
3 changes: 2 additions & 1 deletion packages/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"./types": "./src/types/index.ts",
"./webhooks": "./webhooks.ts",
"./retry": "./retry.ts",
"./currency": "./currency.ts"
"./currency": "./currency.ts",
"./zk-proof": "./src/zk-proof.ts"
},
"scripts": {
"test": "vitest run",
Expand Down
105 changes: 105 additions & 0 deletions packages/sdk/src/zk-proof.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, it, expect } from 'vitest';
import {
canonicalJson,
commitmentOf,
createCommitment,
createOpeningProof,
verifyOpeningProof,
sha256Hex,
ZK_PROOF_SCHEME,
} from './zk-proof';

const PAYLOAD = { amount: '1000', route: '/api/data', meta: { region: 'eu', tier: 3 } };

describe('canonicalJson', () => {
it('serializes deterministically regardless of key order', () => {
const a = canonicalJson({ b: 1, a: 2, c: { y: 1, x: 2 } });
const b = canonicalJson({ c: { x: 2, y: 1 }, a: 2, b: 1 });
expect(a).toBe(b);
expect(a).toBe('{"a":2,"b":1,"c":{"x":2,"y":1}}');
});
});

describe('createCommitment / commitmentOf', () => {
it('is deterministic under the same payload and blinding', async () => {
const blinding = '00'.repeat(32);
const first = await commitmentOf(PAYLOAD, blinding);
const second = await commitmentOf(PAYLOAD, blinding);
expect(first).toBe(second);
expect(first).toMatch(/^[0-9a-f]{64}$/);
});

it('is hiding: the same payload commits differently with fresh blinding', async () => {
const a = await createCommitment(PAYLOAD);
const b = await createCommitment(PAYLOAD);
expect(a.commitment).not.toBe(b.commitment);
expect(a.blinding).not.toBe(b.blinding);
expect(a.blinding).toMatch(/^[0-9a-f]{64}$/);
});

it('is binding: a different payload never opens to the same commitment', async () => {
const blinding = '11'.repeat(32);
const c = await commitmentOf(PAYLOAD, blinding);
const other = await commitmentOf({ ...PAYLOAD, amount: '2000' }, blinding);
expect(c).not.toBe(other);
});
});

describe('verifyOpeningProof', () => {
it('accepts a genuine opening', async () => {
const { commitment, blinding } = await createCommitment(PAYLOAD);
const proof = await createOpeningProof(PAYLOAD, blinding);
expect(await verifyOpeningProof(commitment, proof)).toBe(true);
});

it('accepts a proof built without an explicit blinding (commit+open in one step)', async () => {
const proof = await createOpeningProof(PAYLOAD);
const commitment = await commitmentOf(PAYLOAD, proof.blinding);
expect(await verifyOpeningProof(commitment, proof)).toBe(true);
});

it('rejects a tampered payload', async () => {
const { commitment, blinding } = await createCommitment(PAYLOAD);
const proof = await createOpeningProof({ ...PAYLOAD, amount: '9999' }, blinding);
expect(await verifyOpeningProof(commitment, proof)).toBe(false);
});

it('rejects a wrong blinding even for the right payload', async () => {
const { commitment } = await createCommitment(PAYLOAD);
const proof = await createOpeningProof(PAYLOAD, 'ff'.repeat(32));
expect(await verifyOpeningProof(commitment, proof)).toBe(false);
});

it('rejects mismatched schemes, missing fields, and malformed commitments', async () => {
const { commitment, blinding } = await createCommitment(PAYLOAD);
const proof = await createOpeningProof(PAYLOAD, blinding);

expect(await verifyOpeningProof(commitment, { ...proof, scheme: 'snarkjs-groth16' })).toBe(
false,
);
expect(await verifyOpeningProof(commitment, { ...proof, blinding: '' })).toBe(false);
expect(await verifyOpeningProof('', proof)).toBe(false);
expect(await verifyOpeningProof('not-hex', proof)).toBe(false);
});
});

describe('sha256Hex', () => {
it('produces the SHA-256 digest of its input', async () => {
// sha256("abc") — the NIST test vector.
expect(await sha256Hex('abc')).toBe(
'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad',
);
});

it('round-trips through the canonical payload hash the indexer stores', async () => {
const hash = await sha256Hex(canonicalJson(PAYLOAD));
expect(hash).toMatch(/^[0-9a-f]{64}$/);
expect(hash).toBe(await sha256Hex(canonicalJson({ meta: { tier: 3, region: 'eu' }, route: '/api/data', amount: '1000' })));
});
});

describe('ZK_PROOF_SCHEME', () => {
it('is the scheme identifier the sync API checks', () => {
expect(ZK_PROOF_SCHEME).toBe('sha256-commitment-opening');
});
});
Loading
Loading