Private, ZK-Powered P2P Exchange for the Nigerian Market on Stellar 12-day hackathon build plan: architecture, ZK circuits, smart contracts, backend, database, frontend, integrations, and day-by-day timeline.
Before anything else: how does the ZK proof actually get verified?
Path B — Hybrid attestation model (recommended default). Proof generation happens fully client-side in the browser (so private data never leaves the user's device — this is the actual privacy guarantee). A lightweight relayer/verifier service checks the proof using the same proving backend's JS verifier, and if valid, submits a transaction to a Soroban contract that simply records "nullifier X has been verified" as an immutable on-chain fact.
Future upgrade (Protocol 25): Stellar's Protocol 25 ("X-Ray") introduced native BN254 host functions (
bn254_pairing_check,bn254_g1_add,bn254_g1_mul) and Poseidon hash support directly in Soroban. This enables full on-chain ZK proof verification without the relayer. The hybrid model can be upgraded to Path A (fully on-chain verification) once the SDK tooling matures.
Everything below assumes Path B as the default.
Core flow: A user proves three things about themselves — passed Liveness Check, valid Nigerian Resident (BVN linked), and not flagged for scams — without revealing identity, name, or BVN. A P2P escrow trade only initiates on Stellar once that proof is verified.
What's real:
- Real Stellar testnet accounts, real Soroban escrow contracts, real on-chain locking/release of assets (USDC, XLM, NGNC).
- Real Noir circuit, real client-side proof generation, real cryptographic verification.
- Real Merkle tree of attestation commitments.
What's mocked (and you should say so openly in your demo):
- The BVN provider — you'll build a simple form that takes an 11-digit number and auto-approves, standing in for Paystack/Mono identity APIs.
- Dispute Resolution — For the hackathon, we assume the seller releases crypto honestly once they see the fiat payment. Complex admin dispute panels are skipped.
┌─────────────────┐ ┌──────────────────────┐
│ FRONTEND │ │ Stellar Wallet │
│ React + Vite │◄────►│ (Freighter) │
│ bb.js (in- │ └──────────────────────┘
│ browser prover)│
└────────┬─────────┘
│ REST/JSON
▼
┌──────────────────────────────────────────────┐
│ BACKEND API │
│ Node.js + Express + TypeScript │
│ │
│ ┌────────────┐ ┌───────────────┐ ┌───────┐ │
│ │ Auth / │ │ Compliance │ │ Proof │ │
│ │ User mgmt │ │ Issuer Service │ │ Relayer│ │
│ └────────────┘ └───────┬───────┘ └───┬───┘ │
└────────────────────────────┼──────────────┼────┘
│ │
┌─────────▼──────┐ ┌────▼─────────────┐
│ SQLite │ │ Stellar RPC / │
│ (Prisma ORM) │ │ Horizon │
└─────────────────┘ └────────┬──────────┘
│
┌─────────▼──────────────┐
│ Soroban Contracts │
│ - compliance_registry │
│ - p2p_escrow │
└──────────────────────────┘
Sequence — full user journey:
- User connects Stellar wallet.
- User completes mock BVN onboarding → backend Issuer Service computes attribute flags (
is_human,bvn_verified,good_standing). - Issuer Service computes a Poseidon-hash commitment of these flags +
secret_salt, inserts it as a leaf into an off-chain Merkle tree, and returns the commitment + Merkle path +secret_saltto the user. - Issuer Service periodically publishes the current Merkle root on-chain via
compliance_registry.update_root(). - User enters the P2P Marketplace and clicks "Accept Offer".
- Frontend generates a ZK proof in the browser using the stored
secret_salt+ Merkle path, proving membership + flags. - Relayer verifies the proof, and if valid, submits
record_verified_nullifier()to the Soroban contract. p2p_escrowcontract allows the user to lock crypto or start a fiat trade.- Buyer sends Naira via traditional bank transfer. Seller confirms receipt and calls
release_crypto()on the contract.
| Layer | Choice |
|---|---|
| Frontend | React + Vite + TypeScript + Tailwind |
| Wallet | Freighter / Stellar Wallets Kit |
| ZK circuit | Noir (nargo) |
| Proving backend | Barretenberg (@aztec/bb.js) |
| Smart contracts | Rust + soroban-sdk |
| Backend | Node.js + TypeScript + Express |
| Database | SQLite + Prisma ORM (hackathon; PostgreSQL for production) |
Design principle: Keep the circuit small.
Private inputs: secret_salt, is_human, bvn_verified, good_standing, merkle_path[DEPTH], merkle_indices[DEPTH]
Public inputs: merkle_root, current_timestamp, nullifier
// circuits/kyc_proof/src/main.nr
use std;
global DEPTH: u32 = 8; // Depth of the Merkle Tree
// Custom Merkle membership function (std::merkle was removed from the Noir stdlib)
fn merkle_membership(
leaf: Field,
path: [Field; DEPTH],
indices: [Field; DEPTH]
) -> Field {
let mut node = leaf;
for i in 0..DEPTH {
let is_right = indices[i];
let (l, r) = if is_right == 1 {
(path[i], node)
} else {
(node, path[i])
};
node = std::hash::poseidon::bn254::hash_2([l, r]);
}
node
}
fn main(
secret_salt: Field,
is_human: Field,
bvn_verified: Field,
good_standing: Field,
merkle_path: [Field; DEPTH],
merkle_indices: [Field; DEPTH],
merkle_root: pub Field,
current_timestamp: pub Field,
nullifier: pub Field
) {
// 1. Reconstruct the leaf commitment from private witnesses
let leaf = std::hash::poseidon::bn254::hash_4([
secret_salt, is_human, bvn_verified, good_standing
]);
// 2. Prove this leaf is in the published tree
let computed_root = merkle_membership(leaf, merkle_path, merkle_indices);
assert(computed_root == merkle_root);
// 3. Ensure compliance flags are true
assert(is_human == 1);
assert(bvn_verified == 1);
assert(good_standing == 1);
// 4. Generate the Compliance Nullifier (Reusable Pass bound to time)
let computed_nullifier = std::hash::poseidon::bn254::hash_2([secret_salt, current_timestamp]);
assert(computed_nullifier == nullifier);
}Note on Nargo.toml: The
compiler_version >= 0.28.0is specified, and no external dependencies are needed since Poseidon is still instd::hashand the Merkle membership function is implemented inline.
- Accept mock BVN submission (11-digit number).
- Set
is_human = 1,bvn_verified = 1,good_standing = 1. - Generate fresh
secret_saltusing cryptographically securecrypto.randomBytes(). - Compute leaf commitment, insert into Merkle tree.
- Return
{secret_salt, merkle_path}to client. DO NOT store salt server-side.
Stores the current Merkle root and the set of nullifiers that have been verified. Provides the on-chain source of truth for whether a user has passed ZK compliance verification.
#![no_std]
use soroban_sdk::{contract, contractimpl, contracttype, Address, BytesN, Env};
#[contracttype]
pub enum DataKey {
Admin,
MerkleRoot,
Nullifier(BytesN<32>),
}
#[contract]
pub struct ComplianceRegistry;
#[contractimpl]
impl ComplianceRegistry {
/// Initializes the registry with a trusted admin (the KYC Issuer or a Multi-Sig DAO)
pub fn init(env: Env, admin: Address) {
if env.storage().instance().has(&DataKey::Admin) {
panic!("already initialized");
}
env.storage().instance().set(&DataKey::Admin, &admin);
}
/// Admin securely updates the Merkle Root of all valid Compliance Nullifiers
pub fn update_root(env: Env, new_root: BytesN<32>) {
let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
admin.require_auth();
env.storage().instance().set(&DataKey::MerkleRoot, &new_root);
}
/// Public getter for the current valid Merkle root
pub fn get_root(env: Env) -> BytesN<32> {
env.storage().instance().get(&DataKey::MerkleRoot)
.unwrap_or(BytesN::from_array(&env, &[0; 32]))
}
/// Records a verified nullifier after the relayer validates the ZK proof.
/// Only the admin (relayer service) can call this.
/// Prevents double-use (replay protection).
pub fn record_verified_nullifier(env: Env, nullifier: BytesN<32>) {
let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
admin.require_auth();
let key = DataKey::Nullifier(nullifier.clone());
assert!(!env.storage().persistent().has(&key), "nullifier already used");
env.storage().persistent().set(&key, &true);
}
/// Public getter — returns true if the nullifier has been recorded
pub fn is_verified(env: Env, nullifier: BytesN<32>) -> bool {
let key = DataKey::Nullifier(nullifier);
env.storage().persistent().has(&key)
}
}Handles locking and releasing assets.
#![no_std]
use soroban_sdk::{contract, contractimpl, token, Address, BytesN, Env, Symbol};
#[contract]
pub struct P2PEscrow;
#[contractimpl]
impl P2PEscrow {
// Seller locks crypto to create an offer
pub fn create_offer(
env: Env,
registry_contract: Address,
seller: Address,
token_contract: Address,
amount: i128,
nullifier: BytesN<32>,
) {
seller.require_auth();
// Verify nullifier via registry to ensure seller is KYC'd
let verified: bool = env.invoke_contract(
®istry_contract,
&Symbol::new(&env, "is_verified"),
soroban_sdk::vec![&env, nullifier.into_val(&env)],
);
assert!(verified, "seller not verified");
// Transfer crypto from seller to this escrow contract
let token_client = token::Client::new(&env, &token_contract);
token_client.transfer(&seller, &env.current_contract_address(), &amount);
// ... store offer details ...
}
// Seller calls this after receiving Naira
pub fn release_crypto(env: Env, seller: Address, buyer: Address, token_contract: Address, amount: i128) {
seller.require_auth();
// ... logic to verify offer exists ...
let token_client = token::Client::new(&env, &token_contract);
token_client.transfer(&env.current_contract_address(), &buyer, &amount);
}
}| Method | Path | Purpose |
|---|---|---|
| POST | /kyc/submit-bvn |
Submit mock BVN form (11-digit number) |
| POST | /compliance/issue-attestation |
Compute flags, insert Merkle leaf, return salt |
| GET | /compliance/root |
Return current published Merkle root |
| POST | /verify/submit-proof |
Relayer endpoint: verify proof & submit on-chain |
| GET | /p2p/offers |
List open P2P offers |
| POST | /p2p/offers |
Create a new P2P offer (fiat amounts, rates) |
| POST | /p2p/offers/:id/accept |
Buyer indicates intent to pay fiat |
Using SQLite for hackathon simplicity (swap to PostgreSQL for production by changing the datasource in schema.prisma).
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model User {
id String @id @default(uuid())
walletAddress String @unique
createdAt DateTime @default(now())
attestation ComplianceAttestation?
}
model ComplianceAttestation {
id String @id @default(uuid())
userId String @unique
user User @relation(fields: [userId], references: [id])
leafIndex Int
leafCommitment String
isHuman Boolean @default(true)
bvnVerified Boolean @default(true)
goodStanding Boolean @default(true)
createdAt DateTime @default(now())
}
model P2POffer {
id String @id @default(uuid())
sellerWallet String
assetType String // USDC, XLM, NGNC
cryptoAmount String
nairaRate String
bankDetails String // e.g. "GTBank 0123456789"
status String @default("open") // open | locked | completed | canceled
createdAt DateTime @default(now())
}
model RelayerTransaction {
id String @id @default(uuid())
walletAddress String
action String
txHash String @unique
status String // pending | success | failed | mock
createdAt DateTime @default(now())
}The TypeScript SDK (@shieldpass/sdk) provides a clean API for frontend integrations. Located in SDK/src/.
| Module | File | Responsibility |
|---|---|---|
| ShieldPassClient | ShieldPassClient.ts |
Facade class — orchestrates prover + Stellar + relayer |
| ShieldPassProver | prover.ts |
Wraps @noir-lang/noir_js + @aztec/bb.js for in-browser ZK proof generation |
| StellarContractClient | stellar.ts |
Builds and submits Soroban transactions (create_offer, release_crypto) |
| TrustedIssuer | issuer.ts |
Generates leaf commitments + mock Merkle paths for the KYC flow |
| Types | types.ts |
Shared interfaces (KYCProofParams, ZKProofResult, CreateOfferParams) |
Usage pattern:
import { ShieldPassClient } from '@shieldpass/sdk';
const client = new ShieldPassClient({
rpcUrl: 'https://soroban-testnet.stellar.org',
networkPassphrase: Networks.TESTNET,
contractId: 'CA2ELE2XWYFIFHLU45...',
});
await client.init(); // Loads WASM backend
const proof = await client.generateKYCProof({
secret_salt: userSecretSalt,
is_human: '1',
bvn_verified: '1',
good_standing: '1',
merkle_path: [...],
merkle_indices: [...],
merkle_root: publishedRoot,
current_timestamp: roundedTimestamp,
nullifier: computedNullifier,
});
await client.submitProofToRelayer(
'http://localhost:3001',
userWalletAddress,
proof,
'create_offer'
);A standalone React + Vite app in frontend-tester/ for testing all backend APIs and in-browser ZK proof generation without the full frontend.
4 Tabs:
- KYC Onboarding — Submit a mock BVN, receive secret salt
- P2P Market — Browse and accept open offers
- Create Offer — Create new sell offers with Naira rate + bank details
- ZK Relayer — Generate a real ZK proof in-browser, then submit to the backend relayer
Key component: useZkProof.ts hook handles the full ZK lifecycle:
- Dynamic import of
@noir-lang/noir_js+@aztec/bb.js - Fetch compiled circuit from
/public/reusable_kyc.json - Initialize
BarretenbergWASM backend - Generate witness → generate UltraHonk proof
- Submit proof to relayer
Running: cd frontend-tester && npm install && npm run dev
# Stellar Testnet Configuration
STELLAR_CONTRACT_ID=CA2ELE2XWYFIFHLU45TZREVLY6535FCOVLVHYUISD5YQ6U5OBDP6Y4QU
# Relayer secret key (starts with S)
# Get it by running: stellar keys show deployer
STELLAR_RELAYER_SECRET=<your-secret-key>
# Server port (optional, defaults to 3001)
PORT=3001API_URL— hardcoded tohttp://localhost:3001inApp.tsx- Circuit JSON must be placed at
frontend-tester/public/reusable_kyc.json
Security warning: Never commit real secret keys to version control. Use
.envfiles that are in.gitignore.
cd SDK/circuits/reusable_kyc
nargo compile
# Output: target/reusable_kyc.jsoncd backend
npm install
npx prisma generate
npx prisma db push
npm run dev
# Runs on http://localhost:3001cd frontend-tester
npm install
# Copy compiled circuit to public folder:
cp ../SDK/circuits/reusable_kyc/target/reusable_kyc.json public/
npm run dev
# Runs on http://localhost:5173# Build the contract
cd SDK/contracts/compliance_registry
cargo build --target wasm32-unknown-unknown --release
# Deploy to testnet
stellar contract deploy \
--wasm target/wasm32-unknown-unknown/release/compliance_registry.wasm \
--source deployer \
--network testnet
# Initialize the registry
stellar contract invoke \
--id <CONTRACT_ID> \
--source deployer \
--network testnet \
-- init --admin <DEPLOYER_PUBLIC_KEY>| Risk | Mitigation |
|---|---|
| Secret salt exposure | Salt is stored client-side only (localStorage or in-memory). Warn users not to share it. Backend never stores it. |
| Nullifier replay | On-chain record_verified_nullifier() prevents double-use. Persistent storage survives TTL. |
| Rate limiting | KYC and relayer endpoints should be rate-limited (e.g., express-rate-limit) to prevent abuse. Not implemented in hackathon MVP. |
| Private key management | .env file with relayer secret must be in .gitignore. For production, use a KMS (e.g., AWS Secrets Manager). |
| BVN data handling | BVN is validated on the backend but never stored — only the Poseidon hash commitment is persisted. |
| Mock proof verification | The hackathon relayer skips actual bb.js verification (isProofValid = true). In production, run full UltraHonk verification. |
| Layer | Error Type | Handling |
|---|---|---|
| Noir Circuit | Constraint failure (e.g., flags ≠ 1, bad Merkle path) | noir.execute() throws — caught in useZkProof.ts with user-friendly message |
| bb.js Backend | WASM init failure, proof generation timeout | try/catch in ShieldPassProver.init() / proveKYC() |
| Stellar RPC | Simulation failure, insufficient funds, sequence number mismatch | rpc.Api.isSimulationSuccess() check before signing; retry with fresh sequence |
| Backend API | Validation errors (400), internal errors (500) | JSON { error: string } response format; frontend displays in result boxes |
| Contract | panic!() / assert!() failures |
Transaction fails on-chain; relayer returns error to frontend |
Pages Needed:
- Landing Page: "The first private, scam-free P2P for Nigeria."
- Onboarding: Mock BVN entry screen.
- P2P Marketplace (Order Book): List of available offers.
- Trade Room: Escrow screen showing bank details and "Release Crypto" button.
- Dashboard: Active balances and trade history.
- Story: "Emeka in Lagos wants to sell his USDC for Naira. He wants a safe P2P experience but doesn't want his real identity tied to his crypto wallet publicly."
- Live: Walk through the BVN onboarding, then show the P2P marketplace. Emeka locks crypto.
- On-chain proof: Show the transaction on Stellar Expert. Point out the event only contains a nullifier hash, never PII.
- Close: "ShieldPass provides the safety of KYC without the privacy invasion, unlocking safe P2P markets in Nigeria."