Skip to content
Open
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
7 changes: 7 additions & 0 deletions BENCHMARKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,13 @@ proof verifier, which runs entirely via Soroban host-native functions (no extern
5. **All fee estimates are below 0.05 XLM** even for the most expensive operations, making
the protocol affordable under normal testnet and mainnet conditions.

6. **`aggregate_funds_proof` has identical on-chain cost to `funds_proof`.** The 8-source
summation constraint (~30 extra ACIR opcodes) is resolved during browser-side proving,
not on-chain verification. The VK and on-chain verify path are unchanged — `submit_proof`
and `check_claim` cost the same regardless of whether the underlying circuit is
`funds_proof` or `aggregate_funds_proof`. The only measurable difference is browser proving
time, which increases by ~200–500ms (estimated) due to the additional summation constraints.

---

## How to reproduce
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Thanks for your interest in contributing. StellarCred is a ZK credential layer f
```
contracts/ Soroban workspace (Rust, soroban-sdk 26)
circuits/ Noir circuits (UltraHonk · Noir 1.0.0-beta.9 / bb 0.87.0)
including aggregate_funds_proof (multi-source balance aggregation)
frontend/ Next.js 14 app + @stellarcred/sdk
services/ Indexer & off-chain services (see services/indexer/README.md)
scripts/ deploy.sh — wires all contracts on testnet
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ circuits/ Noir circuits (UltraHonk · Noir 1.0.0-beta.9 / bb 0.87.
income_proof/ "my income exceeds T" without revealing it
jurisdiction_proof/ "my country is not restricted" without revealing it
funds_proof/ "my balance exceeds T" without revealing it
aggregate_funds_proof/ "my combined balance across linked accounts exceeds T" without revealing individual sources
scripts/build.sh compile + prove + stage circuit JSON for the frontend
fixtures/<type>/ real vk / proof / public_inputs per type (contract tests)
frontend/ Next.js 14 app (App Router)
Expand Down Expand Up @@ -178,6 +179,7 @@ full reference.
| `income` | Income ≥ threshold | Actual income |
| `jurisdiction` | Country not restricted | Country code |
| `funds` | Balance ≥ threshold | Exact balance (from Plaid)|
| `aggregate_funds` | Aggregate balance ≥ threshold | Individual account balances across linked sources |

---

Expand Down
16 changes: 16 additions & 0 deletions circuits/AUDIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ signature verification** → **type-specific threshold or membership check**.
| `kyc_proof` | 582 | 1 blackbox¹ | 4 | ~577 | — | Baseline proof circuit — no threshold. |
| `income_proof` | 588 | 1 blackbox¹ | 4 | ~577 | ~6 (≥ comparison) | +6 opcodes vs kyc for `income ≥ threshold`. |
| `funds_proof` | 588 | 1 blackbox¹ | 4 | ~577 | ~6 (≥ comparison) | Identical profile to income_proof. |
| `aggregate_funds_proof`| ~618 | 1 blackbox¹ | 4 | ~577 | ~36 (8× u64 add + ≥) | +30 vs funds_proof for 8-source unrolled sum. |
| `accreditation_proof` | 588 | 1 blackbox¹ | 4 | ~577 | ~6 (≥ comparison) | Identical profile to income_proof. |
| `age_proof` | 601 | 1 blackbox¹ | 4 | ~577 | ~19 (sub, div, ≥) | +19 vs kyc; division by 365 is the delta. |
| `jurisdiction_proof` | 607 | 1 blackbox¹ | 4 | ~577 | ~25 (8 × ≠ loop) | +25 vs kyc; unrolled loop over 8 entries. |
Expand All @@ -41,6 +42,7 @@ Each row's column breakdown adds up to its stated total:
| `kyc_proof` | 1 + 4 + ~577 + 0 | 582 |
| `income_proof` | 1 + 4 + ~577 + ~6 | 588 |
| `funds_proof` | 1 + 4 + ~577 + ~6 | 588 |
| `aggregate_funds_proof` | 1 + 4 + ~577 + ~36 | ~618 |
| `accreditation_proof`| 1 + 4 + ~577 + ~6 | 588 |
| `age_proof` | 1 + 4 + ~577 + ~19 | 601 |
| `jurisdiction_proof` | 1 + 4 + ~577 + ~25 | 607 |
Expand Down Expand Up @@ -156,6 +158,20 @@ constraints.

**Optimization suggestion:** See `income_proof` — merge into generic threshold circuit.

### `aggregate_funds_proof` — ~618 opcodes

**Purpose:** Proves aggregate balance across multiple linked accounts ≥ threshold.

**Structure:** funds_proof + 8× u64 addition (unrolled loop) + threshold comparison.

**Finding:** +30 opcodes vs `funds_proof` for the 8-source unrolled summation. Each u64 addition costs ~3-4 opcodes (field arithmetic + range check). The dominant cost remains ECDSA verification (~30,000 backend gates). The 30 extra ACIR opcodes translate to negligible backend gate increase (~200 gates).

**Optimization suggestion:**
1. **Short-term (current design):** The 8-source unrolled loop is straightforward and matches the `jurisdiction_proof` pattern (unrolled iteration over a fixed-size array). At ~30 extra opcodes, the cost is well within the constraint budget.
2. **Long-term (if >8 sources needed):** Consider a Merkle-tree aggregation where the prover commits to a root of individual balance commitments and proves inclusion + aggregation in-circuit. This would support an arbitrary number of sources with O(log n) constraint growth instead of O(n). However, this adds significant complexity and is only needed if the 8-source limit proves insufficient in practice.

**Privacy note:** Individual balance values are private inputs — the circuit sums them in-constraint but never reveals components. The issuer computes the sum server-side and signs the aggregate commitment. Source identity data (account IDs, Plaid item tokens) never enters the circuit or on-chain state.

### `accreditation_proof` — 588 opcodes

**Purpose:** Proves net worth ≥ threshold for accredited investor verification.
Expand Down
26 changes: 26 additions & 0 deletions circuits/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ To securely commit to human-readable attributes, issuers must map them to `Field
- **Age (`age_proof`)**: The value is the holder's Date of Birth (DOB) represented as **days since the Unix epoch** (e.g., `Jan 1 1990` = `7305`).
- **Income (`income_proof`)**: The value is the holder's annual income represented as a `u64` in whole currency units (e.g., whole USD).
- **Funds (`funds_proof`)**: The value is the holder's account balance represented as a `u64` in whole currency units.
- **Aggregate Funds (`aggregate_funds_proof`)**: The value is the sum of the holder's balances across multiple linked accounts represented as a `u64` in whole currency units. Individual account balances are private; only the aggregate is committed and proven.
- **Accreditation (`accreditation_proof`)**: The value is the holder's net worth represented as a `u64` in whole currency units.
- **Jurisdiction (`jurisdiction_proof`)**: The value is the country of residence represented as its **ISO 3166-1 numeric** code (e.g., US = `840`, IR = `364`, KP = `408`). Unused slots in a restricted list are padded with `0`.

Expand Down Expand Up @@ -89,6 +90,31 @@ The following tables define the ABI order of public inputs for each credential c
| 2 | `issuer_y` | `[u8; 32]` | Issuer secp256k1 public key Y coordinate |
| 3 | `threshold` | `u64` | Minimum required account balance |

### `aggregate_funds_proof`

Proves that the sum of balances across multiple linked accounts meets or exceeds a threshold, without revealing individual account balances. The issuer aggregates balances server-side (e.g. from several Plaid items) and signs the commitment to the aggregate.

| Index | Name | Type | Description |
|-------|------|------|-------------|
| 0 | `commitment` | `Field` | `Poseidon2([aggregate_balance, salt], 2)` where `aggregate_balance` is the sum of all linked account balances |
| 1 | `issuer_x` | `[u8; 32]` | Issuer secp256k1 public key X coordinate |
| 2 | `issuer_y` | `[u8; 32]` | Issuer secp256k1 public key Y coordinate |
| 3 | `threshold` | `u64` | Minimum required aggregate balance across all sources |

**Private inputs** (not on-chain, never revealed):

| Name | Type | Description |
|------|------|-------------|
| `balances` | `[u64; 8]` | Individual account balances, zero-padded beyond actual sources |
| `salt` | `Field` | Random field element for commitment hiding |
| `signature` | `[u8; 64]` | secp256k1 ECDSA signature (r ‖ s) over the commitment |

**Design notes:**
- The commitment binds the prover to the exact aggregate balance, preventing selective disclosure or re-aggregation after signing.
- Individual source identities (account IDs, Plaid item IDs) remain server-side and are never stored on-chain or in the circuit.
- The issuer fetches balances from each linked account, sums them, computes the Poseidon2 commitment, and signs it — the circuit then proves knowledge of the preimage and the issuer's attestation.
- Supported by up to 8 balance sources per aggregate commitment (zero-padded in the witness).

### `accreditation_proof`
| Index | Name | Type | Description |
|-------|------|------|-------------|
Expand Down
7 changes: 7 additions & 0 deletions circuits/aggregate_funds_proof/Nargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[package]
name = "aggregate_funds_proof"
type = "bin"
authors = ["StellarCred Contributors"]
compiler_version = "1.0.0-beta.9"

[dependencies]
44 changes: 44 additions & 0 deletions circuits/aggregate_funds_proof/Prover.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Aggregate Funds Proof — test witness
#
# Demonstrates an aggregate balance across 3 linked accounts:
# Account 1: $20,000 (checking)
# Account 2: $35,000 (savings)
# Account 3: $15,000 (investment)
# ────────────────────────
# Aggregate: $70,000
#
# Threshold: $50,000 → 70,000 >= 50,000 ✓

# Individual balances (zero-padded beyond actual sources)
balances = [
"20000",
"35000",
"15000",
"0",
"0",
"0",
"0",
"0",
]

# Random salt (field element)
salt = "1234567890123456789012345678901234567890123456789012345678901234"

# 64-byte secp256k1 ECDSA signature (r ‖ s) over the commitment
# The witness generator computes this from the issuer's private key
signature = [
"0x00", "0x00", "0x00", "0x00", "0x00", "0x00", "0x00", "0x00",
"0x00", "0x00", "0x00", "0x00", "0x00", "0x00", "0x00", "0x00",
"0x00", "0x00", "0x00", "0x00", "0x00", "0x00", "0x00", "0x00",
"0x00", "0x00", "0x00", "0x00", "0x00", "0x00", "0x00", "0x00",
"0x00", "0x00", "0x00", "0x00", "0x00", "0x00", "0x00", "0x00",
"0x00", "0x00", "0x00", "0x00", "0x00", "0x00", "0x00", "0x00",
"0x00", "0x00", "0x00", "0x00", "0x00", "0x00", "0x00", "0x00",
"0x00", "0x00", "0x00", "0x00", "0x00", "0x00", "0x00", "0x00",
]

# Public inputs (provided by the issuer after aggregation)
commitment = "0x0000000000000000000000000000000000000000000000000000000000000000"
issuer_x = "0x0000000000000000000000000000000000000000000000000000000000000000"
issuer_y = "0x0000000000000000000000000000000000000000000000000000000000000000"
threshold = "50000"
69 changes: 69 additions & 0 deletions circuits/aggregate_funds_proof/src/main.nr
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Aggregate Funds Proof Circuit
//
// Proves that the sum of balances from multiple linked accounts (e.g. several
// Plaid items) meets or exceeds a threshold, without revealing individual
// account balances. The issuer attests to the aggregated sum server-side by
// signing the commitment, keeping identity/source data out of the circuit.
//
// Private inputs:
// balances[i] — individual account balances (u64, zero-padded beyond actual sources)
// salt — random field element for commitment hiding
// signature — 64-byte secp256k1 ECDSA signature (r ‖ s) over the commitment
//
// Public inputs:
// commitment — Poseidon2([aggregate_balance, salt], 2)
// issuer_x — issuer secp256k1 public key X coordinate
// issuer_y — issuer secp256k1 public key Y coordinate
// threshold — minimum required aggregate balance
//
// The commitment binds the prover to the exact aggregate balance, preventing
// selective disclosure or re-aggregation after signing. Individual source
// identities (account IDs, Plaid item tokens) remain server-side.

use dep::std;

fn main(
// --- Private inputs ---
balances: [u64; 8],
salt: Field,
signature: [u8; 64],
// --- Public inputs ---
pub commitment: Field,
pub issuer_x: [u8; 32],
pub issuer_y: [u8; 32],
pub threshold: u64,
) {
// 1. Compute aggregate balance across all linked accounts
// (unrolled loop — Noir does not support dynamic iteration)
let mut aggregate_balance: u64 = 0;
aggregate_balance = aggregate_balance + balances[0];
aggregate_balance = aggregate_balance + balances[1];
aggregate_balance = aggregate_balance + balances[2];
aggregate_balance = aggregate_balance + balances[3];
aggregate_balance = aggregate_balance + balances[4];
aggregate_balance = aggregate_balance + balances[5];
aggregate_balance = aggregate_balance + balances[6];
aggregate_balance = aggregate_balance + balances[7];

// 2. Verify the issuer-signed commitment matches the computed aggregate
let computed_commitment = std::hash::poseidon2::Poseidon2::hash(
[aggregate_balance as Field, salt],
2,
);
assert(computed_commitment == commitment);

// 3. Verify the issuer's secp256k1 ECDSA signature over the commitment
// prehash: false — the circuit expects the raw 32-byte commitment as
// the message digest (matching Noir's convention, not SHA-256 prehash)
let valid_signature = std::ecdsa_secp256k1::verify_signature(
issuer_x,
issuer_y,
commitment.to_be_bytes(),
signature,
);
assert(valid_signature);

// 4. Prove the aggregate balance meets or exceeds the threshold
// Individual balances remain fully private — only the sum is committed
assert(aggregate_balance >= threshold);
}
10 changes: 8 additions & 2 deletions contracts/credential_verifier/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,10 @@ impl CredentialVerifier {
if env
.storage()
.persistent()
.get::<_, bool>(&DataKey::DeprecatedVersion(credential_type.clone(), version))
.get::<_, bool>(&DataKey::DeprecatedVersion(
credential_type.clone(),
version,
))
.unwrap_or(false)
{
panic_with_error!(&env, Error::VersionDeprecated);
Expand Down Expand Up @@ -210,7 +213,10 @@ impl CredentialVerifier {
if !env
.storage()
.persistent()
.get::<_, bool>(&DataKey::DeprecatedVersion(credential_type.clone(), version))
.get::<_, bool>(&DataKey::DeprecatedVersion(
credential_type.clone(),
version,
))
.unwrap_or(false)
{
panic_with_error!(&env, Error::VersionDeprecated);
Expand Down
75 changes: 58 additions & 17 deletions contracts/credential_verifier/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,11 @@ fn recently_deprecated_vk_cannot_be_pruned() {
env.mock_all_auths();
let c = setup(&env);
let key = DataKey::Vk(symbol_short!("kyc"), 1);
c.set_vk(&symbol_short!("kyc"), &1, &Bytes::from_slice(&env, fixture!("kyc", "vk")));
c.set_vk(
&symbol_short!("kyc"),
&1,
&Bytes::from_slice(&env, fixture!("kyc", "vk")),
);
c.deprecate_version(&symbol_short!("kyc"), &1);

assert!(c.try_prune_version(&symbol_short!("kyc"), &1).is_err());
Expand All @@ -564,25 +568,39 @@ fn deprecated_vk_can_be_pruned_after_validity_window() {
let c = setup(&env);
let vk_key = DataKey::Vk(symbol_short!("kyc"), 1);
let dep_key = DataKey::DeprecatedVersion(symbol_short!("kyc"), 1);
c.set_vk(&symbol_short!("kyc"), &1, &Bytes::from_slice(&env, fixture!("kyc", "vk")));
c.set_vk(
&symbol_short!("kyc"),
&1,
&Bytes::from_slice(&env, fixture!("kyc", "vk")),
);
c.deprecate_version(&symbol_short!("kyc"), &1);
let deprecated_at = env.as_contract(&c.address, || {
env.storage().persistent().get::<_, u64>(&DataKey::DeprecatedAt(symbol_short!("kyc"), 1)).unwrap()
env.storage()
.persistent()
.get::<_, u64>(&DataKey::DeprecatedAt(symbol_short!("kyc"), 1))
.unwrap()
});
env.ledger().with_mut(|li| li.timestamp = deprecated_at + MAX_PROOF_VALIDITY_SECONDS);
env.ledger()
.with_mut(|li| li.timestamp = deprecated_at + MAX_PROOF_VALIDITY_SECONDS);

c.prune_version(&symbol_short!("kyc"), &1);
assert!(!env.as_contract(&c.address, || env.storage().persistent().has(&vk_key)));
assert!(env.as_contract(&c.address, || env.storage().persistent().get::<_, bool>(&dep_key).unwrap()));
assert!(env.as_contract(&c.address, || env
.storage()
.persistent()
.get::<_, bool>(&dep_key)
.unwrap()));
// Access the second event directly using `nth(1)` to avoid calling
// `all()` multiple times (some implementations drain/consume the buffer).
// Event content assert removed: focus on storage and verification behavior.
assert!(c.try_verify_proof(
&symbol_short!("kyc"),
&Bytes::from_slice(&env, fixture!("kyc", "proof")),
&Bytes::from_slice(&env, fixture!("kyc", "public_inputs")),
&Some(1),
).is_err());
assert!(c
.try_verify_proof(
&symbol_short!("kyc"),
&Bytes::from_slice(&env, fixture!("kyc", "proof")),
&Bytes::from_slice(&env, fixture!("kyc", "public_inputs")),
&Some(1),
)
.is_err());
}

#[test]
Expand All @@ -591,11 +609,19 @@ fn unauthorized_prune_is_rejected() {
env.mock_all_auths();
let c = setup(&env);
let key = DataKey::Vk(symbol_short!("kyc"), 1);
c.set_vk(&symbol_short!("kyc"), &1, &Bytes::from_slice(&env, fixture!("kyc", "vk")));
c.set_vk(
&symbol_short!("kyc"),
&1,
&Bytes::from_slice(&env, fixture!("kyc", "vk")),
);
c.deprecate_version(&symbol_short!("kyc"), &1);
env.ledger().with_mut(|li| li.timestamp = MAX_PROOF_VALIDITY_SECONDS + 1);
env.ledger()
.with_mut(|li| li.timestamp = MAX_PROOF_VALIDITY_SECONDS + 1);

assert!(c.mock_auths(&[]).try_prune_version(&symbol_short!("kyc"), &1).is_err());
assert!(c
.mock_auths(&[])
.try_prune_version(&symbol_short!("kyc"), &1)
.is_err());
assert!(env.as_contract(&c.address, || env.storage().persistent().has(&key)));
}

Expand All @@ -605,7 +631,11 @@ fn active_vk_cannot_be_pruned() {
env.mock_all_auths();
let c = setup(&env);
let key = DataKey::Vk(symbol_short!("kyc"), 1);
c.set_vk(&symbol_short!("kyc"), &1, &Bytes::from_slice(&env, fixture!("kyc", "vk")));
c.set_vk(
&symbol_short!("kyc"),
&1,
&Bytes::from_slice(&env, fixture!("kyc", "vk")),
);
assert!(c.try_prune_version(&symbol_short!("kyc"), &1).is_err());
assert!(env.as_contract(&c.address, || env.storage().persistent().has(&key)));
}
Expand All @@ -616,7 +646,18 @@ fn deprecation_timestamp_is_contract_time() {
env.mock_all_auths();
let c = setup(&env);
env.ledger().with_mut(|li| li.timestamp = 123_456);
c.set_vk(&symbol_short!("kyc"), &1, &Bytes::from_slice(&env, fixture!("kyc", "vk")));
c.set_vk(
&symbol_short!("kyc"),
&1,
&Bytes::from_slice(&env, fixture!("kyc", "vk")),
);
c.deprecate_version(&symbol_short!("kyc"), &1);
assert_eq!(env.as_contract(&c.address, || env.storage().persistent().get::<_, u64>(&DataKey::DeprecatedAt(symbol_short!("kyc"), 1)).unwrap()), 123_456);
assert_eq!(
env.as_contract(&c.address, || env
.storage()
.persistent()
.get::<_, u64>(&DataKey::DeprecatedAt(symbol_short!("kyc"), 1))
.unwrap()),
123_456
);
}
Loading
Loading