Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
10 changes: 7 additions & 3 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,20 @@

## Summary

<!-- What does this change do? Keep it concise — one or two sentences. -->
<!-- What does this change do, and why? -->

## Why

<!-- Why is this change needed? Link the motivation, design decision, or user-facing problem being solved.
CONTRIBUTING.md asks: "describe *why* the change is needed, not just what changed." -->

## Area

<!-- circuits / contracts / frontend / demo scripts / CI -->

## Testing done

<!-- What did you run to verify this change? -->
<!-- What did you run to verify this change? e.g. `just test`, `just test-e2e`, `nargo execute` + proof round-trip for circuit changes -->

- [ ] `just test` passes (contracts + frontend)
- [ ] `just test-e2e` passes, if this touches deposit/withdraw/compliance flows
Expand All @@ -25,6 +29,6 @@

## Related issue

<!-- Link the issue this PR closes, e.g. "Closes #41" -->
<!-- Link the issue this PR closes, e.g. "Closes #61" -->

Closes #
8 changes: 4 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Thanks for your interest in contributing. DShield is a shielded stablecoin wallet on Stellar (Soroban contracts + Noir circuits + a Next.js frontend), currently a testnet demo. This guide covers how to get set up and how to submit changes.

> **New to ZK or Stellar?** See [GLOSSARY.md](GLOSSARY.md) for plain-English definitions of terms like *nullifier*, *commitment*, *Merkle root*, *UltraHonk*, *Poseidon2*, *selective disclosure*, *relayer*, and more — everything you need before diving in.
> **New to ZK or Stellar?** See [GLOSSARY.md](GLOSSARY.md) for plain-English definitions of terms like _nullifier_, _commitment_, _Merkle root_, _UltraHonk_, _Poseidon2_, _selective disclosure_, _relayer_, and more — everything you need before diving in.

## Ground rules

Expand Down Expand Up @@ -32,9 +32,8 @@ Run `just --list` for the full set of available recipes (build, deploy, demo, cl
- `just test` — both
- `just test-e2e` (or `tests/e2e.sh`) — full on-chain deposit/withdraw loop against a local network
4. If you touch a Noir circuit, make sure it still compiles and the corresponding proof round-trips: `nargo compile && nargo execute` in the circuit's directory, then regenerate the checked-in `frontend/src/circuits/*.json` / `frontend/public/circuits/*.json` artifacts the frontend embeds for client-side proving (see `just build-circuits`).
5. Run the frontend linter (`pnpm lint` in `frontend/`) and make sure `pnpm build` type-checks cleanly.
5. The pre-commit hook runs `eslint --fix` automatically on staged `src/**/*.{ts,tsx}` files when you commit. You can also run `pnpm lint` in `frontend/` manually at any time. Make sure `pnpm build` type-checks cleanly before opening a PR.
6. Open a PR against `dev`, not `main` — all active development merges into `dev`. Describe *why* the change is needed, not just what changed — link the issue if there is one. CI (circuit compile/proof round-trip, contract tests, frontend tests, lint, and an on-chain e2e run) must pass before merge.
5. Run the frontend linter (`pnpm lint` in `frontend/`) and make sure `pnpm build` type-checks cleanly. The pre-commit hook runs `eslint --fix` automatically on staged `src/**/*.{ts,tsx}` files when you commit.
6. Open a PR against `dev`, not `main` — all active development merges into `dev`. Describe _why_ the change is needed, not just what changed — link the issue if there is one. CI (circuit compile/proof round-trip, contract tests, frontend tests, lint, and an on-chain e2e run) must pass before merge.

## Where things live

Expand All @@ -52,6 +51,7 @@ Use GitHub Issues. For anything that could be a security vulnerability (a way to
## Changelog Requirements

All user-facing or architectural PRs must include a corresponding entry in the `CHANGELOG.md` file under the `## [Unreleased]` header, utilizing standard changelog tags:

- `Added` - for new features
- `Changed` - for changes in existing functionality
- `Deprecated` - for soon-to-be removed features
Expand Down
24 changes: 12 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Built for **Stellar Hacks: Real-World ZK**, DShield demonstrates how privacy and

## New to ZK or Stellar?

See **[GLOSSARY.md](GLOSSARY.md)** for plain-English definitions of terms like *nullifier*, *commitment*, *Merkle root*, *UltraHonk*, *Poseidon2*, *selective disclosure*, *relayer*, and more — everything you need to navigate the codebase without a cryptography background.
See **[GLOSSARY.md](GLOSSARY.md)** for plain-English definitions of terms like _nullifier_, _commitment_, _Merkle root_, _UltraHonk_, _Poseidon2_, _selective disclosure_, _relayer_, and more — everything you need to navigate the codebase without a cryptography background.

---

Expand Down Expand Up @@ -152,8 +152,8 @@ Reveal only the specific information required by regulators while preserving ove

What is **built and verified on-chain today** (testnet), versus the broader vision above:

| Capability | Status |
| --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Capability | Status |
| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Shielded deposit (USDC → commitment in a Merkle tree) | ✅ Working on testnet |
| Client-side ZK proof (Noir + UltraHonk, keccak transform) | ✅ |
| On-chain proof verification (Soroban + BN254/Poseidon2) | ✅ |
Expand All @@ -163,7 +163,7 @@ What is **built and verified on-chain today** (testnet), versus the broader visi
| **Relayer** — withdrawer's account never appears on-chain | ✅ |
| Compliance: KYC registry + compliance proof verification | ✅ Verified on testnet via CLI (`just demo-compliance`); not yet wired into the web app UI. `disclosed_amount` is cross-checked against the real pool's fixed `deposit_amount` on-chain, not self-asserted by the prover |
| Selective disclosure: threshold proofs (balance ≥ X) | ✅ Circuit + contract implemented and CLI-verified; no web UI yet. `threshold` is likewise checked against the pool's real `deposit_amount` on-chain |
| Arbitrary-amount private _transfers_ between users | 🚧 Future (today: fixed-denomination pools) |
| Arbitrary-amount private _transfers_ between users | 🚧 Future (today: fixed-denomination pools) |

DShield is currently a **fixed-denomination shielded pool** (Tornado-style: deposit a tier amount, withdraw it to any address). Privacy comes from breaking the on-chain link between depositor and recipient — not from hiding the tier amount. Relayed withdrawals mean the withdrawer never signs or pays a fee from their own account.

Expand Down Expand Up @@ -258,7 +258,7 @@ This allows DShield to verify proofs on-chain efficiently and affordably.

## Architecture

```
````
+-----------------------+
| DShield App |
+-----------------------+
Expand Down Expand Up @@ -292,7 +292,7 @@ flowchart TD
B --> C["Shielded Pool\nCommitments · Nullifiers"]
C --> D["Soroban Verifier\nBN254 Verification"]
D --> E["Stellar Network"]
```
````

## Tech Stack

Expand Down Expand Up @@ -415,11 +415,11 @@ Organizations can transact confidentially while remaining compliant.

| Feature | Traditional Blockchain | Privacy Coins | DShield |
| -------------------- | ---------------------- | ------------- | ------- |
| Private Payments | ❌ | ✅ | ✅ |
| Compliance Friendly | ✅ | ❌ | ✅ |
| Selective Disclosure | ❌ | ❌ | ✅ |
| Stablecoin Focus | ✅ | ❌ | ✅ |
| Consumer UX | ⚠️ | ⚠️ | ✅ |
| Private Payments | ❌ | ✅ | ✅ |
| Compliance Friendly | ✅ | ❌ | ✅ |
| Selective Disclosure | ❌ | ❌ | ✅ |
| Stablecoin Focus | ✅ | ❌ | ✅ |
| Consumer UX | ⚠️ | ⚠️ | ✅ |

---

Expand All @@ -439,7 +439,7 @@ Built with the belief that privacy should be a default right, not a premium feat

## Contributing

Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for setup, testing, and PR guidelines. Please review the [Code of Conduct](CODE_OF_CONDUCT.md) before participating, and report security vulnerabilities per [SECURITY.md](SECURITY.md) rather than opening a public issue.
Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for setup, testing, and PR guidelines. If you're new to ZK cryptography or Soroban, check [GLOSSARY.md](GLOSSARY.md) for plain-English definitions of the key terms used throughout this codebase. Please review the [Code of Conduct](CODE_OF_CONDUCT.md) before participating, and report security vulnerabilities per [SECURITY.md](SECURITY.md) rather than opening a public issue.

---

Expand Down
18 changes: 15 additions & 3 deletions circuits/compliance/src/main.nr
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ use dep::poseidon::poseidon2::Poseidon2;

global TREE_DEPTH: u32 = 20;

// Domain separation tags for Poseidon2 hashing
global LEAF_DOMAIN: Field = 0x4c454146; // "LEAF" in hex (right-aligned)
global KYC_DOMAIN: Field = 0x4b5943; // "KYC" in hex (right-aligned to 3 bytes)

fn hash2(a: Field, b: Field) -> Field {
Poseidon2::hash([a, b], 2)
}
Expand All @@ -10,6 +14,14 @@ fn hash3(a: Field, b: Field, c: Field) -> Field {
Poseidon2::hash([a, b, c], 3)
}

fn hash_leaf(nullifier: Field, secret: Field) -> Field {
hash3(LEAF_DOMAIN, nullifier, secret)
}

fn hash_kyc(kyc_preimage: Field) -> Field {
hash3(KYC_DOMAIN, kyc_preimage, 0)
}

fn constrain_bit(bit: Field) {
assert(bit * (1 - bit) == 0);
}
Expand Down Expand Up @@ -45,16 +57,16 @@ pub fn main(
path_bits: [Field; TREE_DEPTH],
) {
// 1. KYC proof: user knows the preimage behind the registered kyc_hash
let computed_kyc = hash2(kyc_preimage, 0);
let computed_kyc = hash_kyc(kyc_preimage);
assert(computed_kyc == kyc_hash);

// 2. Wallet authorization: user owns a note in the shielded pool
let leaf = hash2(nullifier, secret);
let leaf = hash_leaf(nullifier, secret);
let computed_root = compute_root(leaf, path_siblings, path_bits);
assert(computed_root == merkle_root);

// 3. Selective disclosure: `amount`/`disclosed_amount` are NOT bound to the
// note above (the leaf is hash2(nullifier, secret) -- amount plays no
// note above (the leaf is hash3(LEAF_DOMAIN, nullifier, secret) -- amount plays no
// part in it), so this assert alone proves nothing about the real note;
// a prover could pick any two equal values here. `disclosure_commitment`
// and `expected` are computed but deliberately unused for the same
Expand Down
22 changes: 19 additions & 3 deletions circuits/disclosure/src/main.nr
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,26 @@ use dep::poseidon::poseidon2::Poseidon2;

global TREE_DEPTH: u32 = 20;

// Domain separation tags for Poseidon2 hashing
global LEAF_DOMAIN: Field = 0x4c454146; // "LEAF" in hex (right-aligned)
global KYC_DOMAIN: Field = 0x4b5943; // "KYC" in hex (right-aligned to 3 bytes)

fn hash2(a: Field, b: Field) -> Field {
Poseidon2::hash([a, b], 2)
}

fn hash3(a: Field, b: Field, c: Field) -> Field {
Poseidon2::hash([a, b, c], 3)
}

fn hash_leaf(nullifier: Field, secret: Field) -> Field {
hash3(LEAF_DOMAIN, nullifier, secret)
}

fn hash_kyc(kyc_preimage: Field) -> Field {
hash3(KYC_DOMAIN, kyc_preimage, 0)
}

fn constrain_bit(bit: Field) {
assert(bit * (1 - bit) == 0);
}
Expand Down Expand Up @@ -41,17 +57,17 @@ pub fn main(
path_bits: [Field; TREE_DEPTH],
) {
// 1. KYC proof: user knows the preimage behind the registered kyc_hash
let computed_kyc = hash2(kyc_preimage, 0);
let computed_kyc = hash_kyc(kyc_preimage);
assert(computed_kyc == kyc_hash);

// 2. Wallet authorization: user owns a note in the shielded pool
let leaf = hash2(nullifier, secret);
let leaf = hash_leaf(nullifier, secret);
let computed_root = compute_root(leaf, path_siblings, path_bits);
assert(computed_root == merkle_root);

// 3. Threshold proof: balance >= threshold without revealing exact amount.
// Like compliance/main.nr, `amount` is NOT bound to the note above (the
// leaf is hash2(nullifier, secret)), so this assert alone doesn't prove
// leaf is hash3(LEAF_DOMAIN, nullifier, secret)), so this assert alone doesn't prove
// the prover's real balance meets `threshold` -- it only proves the
// prover's own self-chosen `amount` does. The `_binding` hash below is
// likewise unconstrained (never asserted against anything) and proves
Expand Down
20 changes: 18 additions & 2 deletions circuits/shielded_pool/src/main.nr
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,26 @@ use dep::poseidon::poseidon2::Poseidon2;

global TREE_DEPTH: u32 = 20;

// Domain separation tags for Poseidon2 hashing
global LEAF_DOMAIN: Field = 0x4c454146; // "LEAF" in hex (right-aligned)
global NULLIFIER_DOMAIN: Field = 0x4e554c4c; // "NULLIFIER" in hex (right-aligned to 4 bytes)

fn hash2(a: Field, b: Field) -> Field {
Poseidon2::hash([a, b], 2)
}

fn hash3(a: Field, b: Field, c: Field) -> Field {
Poseidon2::hash([a, b, c], 3)
}

fn hash_leaf(nullifier: Field, secret: Field) -> Field {
hash3(LEAF_DOMAIN, nullifier, secret)
}

fn hash_nullifier(nullifier: Field) -> Field {
hash3(NULLIFIER_DOMAIN, nullifier, 0)
}

fn constrain_bit(bit: Field) {
assert(bit * (1 - bit) == 0);
}
Expand Down Expand Up @@ -35,9 +51,9 @@ pub fn main(
path_siblings: [Field; TREE_DEPTH],
path_bits: [Field; TREE_DEPTH],
) {
let leaf = hash2(nullifier, secret);
let leaf = hash_leaf(nullifier, secret);

let nf = hash2(nullifier, 0);
let nf = hash_nullifier(nullifier);
assert(nf == nullifier_hash);

let computed_root = compute_root(leaf, path_siblings, path_bits);
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
72 changes: 72 additions & 0 deletions frontend/src/app/compliance/ThresholdDisclosure.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { useState } from "react";
import { ShieldedNote } from "@/lib/notes";
import { generateThresholdProof } from "@/lib/prover";
import { Button } from "@/components/ui/Button";
import { useToast } from "@/components/ui/Toast";

interface Props {
notes: ShieldedNote[];
}

export default function ThresholdDisclosure({ notes }: Props) {
const [selectedNote, setSelectedNote] = useState<ShieldedNote | null>(null);
const [threshold, setThreshold] = useState<string>("");
const [proof, setProof] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const { toast } = useToast();

async function handleGenerate() {
if (!selectedNote || !threshold) return;
setLoading(true);
try {
const result = await generateThresholdProof(selectedNote, threshold);
setProof(JSON.stringify(result, null, 2));
toast("Threshold proof generated", "success");
} catch (e) {
toast(`Error: ${e}`, "error");
} finally {
setLoading(false);
}
}

return (
<div className="p-4 glassmorphism rounded-xl backdrop-blur-sm">
<h2 className="mb-4 text-xl font-semibold">Threshold Disclosure</h2>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Select Note</label>
<select
className="w-full rounded border p-2"
value={selectedNote?.commitment ?? ""}
onChange={(e) => {
const note = notes.find((n) => n.commitment === e.target.value);
setSelectedNote(note ?? null);
}}
>
<option value="">-- Choose a note --</option>
{notes.map((note) => (
<option key={note.commitment} value={note.commitment}>
{note.commitment.slice(0, 12)}…
</option>
))}
</select>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Threshold (e.g., 1000)</label>
<input
type="text"
className="w-full rounded border p-2"
value={threshold}
onChange={(e) => setThreshold(e.target.value)}
/>
</div>
<Button onClick={handleGenerate} disabled={loading || !selectedNote || !threshold}>
{loading ? "Generating…" : "Generate Proof"}
</Button>
{proof && (
<pre className="mt-4 max-h-64 overflow-auto rounded bg-zinc-900 p-2 text-xs text-green-400">
{proof}
</pre>
)}
</div>
);
}
Loading
Loading