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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion circuits/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,12 @@ The following tables define the ABI order of public inputs for each credential c
| 0 | `commitment` | `Field` | `Poseidon2([income, salt], 2)` |
| 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 annual income |
| 3 | `threshold` | `u64` | Threshold mode lower bound; set to `0` when using range mode |
| 4 | `min` | `u64` | Inclusive lower bound of the income band in range mode |
| 5 | `max` | `u64` | Inclusive upper bound of the income band in range mode |
| 6 | `mode` | `u64` | `0` = threshold mode (`income >= threshold`), `1` = range mode (`min <= income <= max`) |

The circuit keeps the legacy threshold check (`mode = 0`) and adds an inclusive range check (`mode = 1`) without revealing the underlying income value. In range mode, both `min` and `max` must be supplied and `min <= max` must hold.

### `funds_proof`
| Index | Name | Type | Description |
Expand Down
65 changes: 52 additions & 13 deletions circuits/income_proof/src/main.nr
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ fn main(
issuer_x: pub [u8; 32],
issuer_y: pub [u8; 32],
threshold: pub u64,
min: pub u64,
max: pub u64,
mode: pub u64,
) {
// Binds the private income to the issuer-signed commitment and verifies the
// issuer's signature in-circuit. circuits/lib/src/lib.nr covers what each
Expand All @@ -34,15 +37,16 @@ fn main(
// different incomes onto the same commitment.
credential_lib::assert_committed_and_signed(income as Field, salt, sig, commitment, issuer_x, issuer_y);

// The claim itself, proved rather than asserted. `threshold` is a public
// input the prover chooses, so on its own it is an unbacked assertion; this
// constraint is what binds it to the signed private income. It cannot be
// enforced on-chain because the contract never sees `income`, so without it
// a holder submits whatever threshold they want and still verifies.
// The contract remains responsible for confirming that the `threshold` in
// the public inputs is the figure its policy actually requires. The circuit
// proves the relation; it cannot know the policy.
assert(income >= threshold);
// Threshold mode preserves the historical behaviour (income >= threshold).
// Range mode proves the band constraint without revealing the exact value.
assert(mode == 0 || mode == 1);
if mode == 0 {
assert(income >= threshold);
} else {
assert(min <= max);
assert(income >= min);
assert(income <= max);
}
}

// --- Tests ---------------------------------------------------------------
Expand All @@ -52,9 +56,26 @@ global INC_ZERO_SIG: [u8; 64] = [0; 64];
global INC_ZERO_X: [u8; 32] = [0; 32];
global INC_ZERO_Y: [u8; 32] = [0; 32];

#[test]
fn test_threshold_mode_still_works() {
let income: u64 = 250000;
let threshold: u64 = 200000;
let commitment = Poseidon2::hash([income as Field, 0x1], 2);
main(income, 0x1, INC_ZERO_SIG, commitment, INC_ZERO_X, INC_ZERO_Y, threshold, 0, 0, 0);
}

#[test]
fn test_income_range_mode_passes() {
let income: u64 = 180000;
let min: u64 = 150000;
let max: u64 = 220000;
let commitment = Poseidon2::hash([income as Field, 0x2], 2);
main(income, 0x2, INC_ZERO_SIG, commitment, INC_ZERO_X, INC_ZERO_Y, 0, min, max, 1);
}

#[test(should_fail)]
fn test_wrong_commitment_rejected() {
main(250000, 0xabcd, INC_ZERO_SIG, 0x1, INC_ZERO_X, INC_ZERO_Y, 100000);
main(250000, 0xabcd, INC_ZERO_SIG, 0x1, INC_ZERO_X, INC_ZERO_Y, 100000, 0, 0, 0);
}

// income < threshold: person does not meet the minimum.
Expand All @@ -63,7 +84,7 @@ fn test_income_below_threshold_rejected() {
let income: u64 = 49999;
let threshold: u64 = 50000;
let bad_commitment = 0x2;
main(income, 0x1, INC_ZERO_SIG, bad_commitment, INC_ZERO_X, INC_ZERO_Y, threshold);
main(income, 0x1, INC_ZERO_SIG, bad_commitment, INC_ZERO_X, INC_ZERO_Y, threshold, 0, 0, 0);
}

// income == threshold - 1: off-by-one boundary.
Expand All @@ -72,7 +93,25 @@ fn test_income_one_below_threshold_rejected() {
let income: u64 = 99999;
let threshold: u64 = 100000;
let bad_commitment = 0x3;
main(income, 0x1, INC_ZERO_SIG, bad_commitment, INC_ZERO_X, INC_ZERO_Y, threshold);
main(income, 0x1, INC_ZERO_SIG, bad_commitment, INC_ZERO_X, INC_ZERO_Y, threshold, 0, 0, 0);
}

#[test(should_fail)]
fn test_income_range_below_min_rejected() {
let income: u64 = 120000;
let min: u64 = 150000;
let max: u64 = 220000;
let commitment = Poseidon2::hash([income as Field, 0x3], 2);
main(income, 0x3, INC_ZERO_SIG, commitment, INC_ZERO_X, INC_ZERO_Y, 0, min, max, 1);
}

#[test(should_fail)]
fn test_income_range_above_max_rejected() {
let income: u64 = 250000;
let min: u64 = 150000;
let max: u64 = 220000;
let commitment = Poseidon2::hash([income as Field, 0x4], 2);
main(income, 0x4, INC_ZERO_SIG, commitment, INC_ZERO_X, INC_ZERO_Y, 0, min, max, 1);
}

// salt mismatch.
Expand All @@ -82,5 +121,5 @@ fn test_salt_mismatch_rejected() {
let real_salt: Field = 0xfeed;
let wrong_salt: Field = 0xface;
let commitment = Poseidon2::hash([income as Field, real_salt], 2);
main(income, wrong_salt, INC_ZERO_SIG, commitment, INC_ZERO_X, INC_ZERO_Y, 100000);
main(income, wrong_salt, INC_ZERO_SIG, commitment, INC_ZERO_X, INC_ZERO_Y, 100000, 0, 0, 0);
}
9 changes: 7 additions & 2 deletions frontend/app/api/witness/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,19 @@ async function buildInputs(
current_date: currentDate,
threshold_years: asFieldString(params.threshold_years, DEFAULT_THRESHOLD_YEARS),
};
case "income":
case "income": {
const hasRange = params.min !== undefined || params.max !== undefined;
return {
income: value,
salt,
...sigInputs,
commitment,
threshold: asFieldString(params.threshold, DEFAULT_INCOME_THRESHOLD),
threshold: asFieldString(params.threshold, hasRange ? "0" : DEFAULT_INCOME_THRESHOLD),
min: asFieldString(params.min, hasRange ? "0" : "0"),
max: asFieldString(params.max, hasRange ? "0" : "0"),
mode: hasRange ? "1" : "0",
};
}
case "jurisdiction":
return {
country_code: value,
Expand Down
28 changes: 20 additions & 8 deletions frontend/app/verify/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ function VerifyInner() {
claim: claimParam,
thresholdYears: searchParams.get("threshold_years"),
threshold: searchParams.get("threshold"),
min: searchParams.get("min"),
max: searchParams.get("max"),
restricted: searchParams.get("restricted"),
currentOrigin: typeof window !== "undefined" ? window.location.origin : undefined,
});
Expand All @@ -107,6 +109,8 @@ function VerifyInner() {
)
? minThresholdParam
: undefined),
min: searchParams.get("min") ?? undefined,
max: searchParams.get("max") ?? undefined,
restricted:
searchParams.get("restricted")?.split(",").filter(Boolean) ?? undefined,
mode: searchParams.get("mode") ?? undefined,
Expand All @@ -132,6 +136,8 @@ function VerifyInner() {
paramValidation.claimError,
paramValidation.thresholdYearsError,
paramValidation.thresholdError,
paramValidation.minError,
paramValidation.maxError,
paramValidation.restrictedError,
].filter(Boolean) as string[];
const [done, setDone] = useState(false);
Expand Down Expand Up @@ -209,6 +215,8 @@ function VerifyInner() {
claim: null,
thresholdYears: null,
threshold: null,
min: null,
max: null,
restricted: null,
currentOrigin: window.location.origin,
});
Expand Down Expand Up @@ -671,15 +679,19 @@ function VerifyInner() {
: key === "age" &&
claimParamsFromUrl.threshold_years
? `age ≥ ${claimParamsFromUrl.threshold_years}`
: key === "income" && claimParamsFromUrl.threshold
? `income > $${Number(claimParamsFromUrl.threshold).toLocaleString("en-US")}`
: key === "accreditation" &&
claimParamsFromUrl.threshold
? `net worth ≥ $${Number(claimParamsFromUrl.threshold).toLocaleString("en-US")}`
: key === "employment" &&
: key === "income" &&
claimParamsFromUrl.min &&
claimParamsFromUrl.max
? `income $${Number(claimParamsFromUrl.min).toLocaleString("en-US")}–$${Number(claimParamsFromUrl.max).toLocaleString("en-US")}`
: key === "income" && claimParamsFromUrl.threshold
? `income > $${Number(claimParamsFromUrl.threshold).toLocaleString("en-US")}`
: key === "accreditation" &&
claimParamsFromUrl.threshold
? `seniority ≥ ${claimParamsFromUrl.threshold} yrs`
: m.claim}
? `net worth ≥ $${Number(claimParamsFromUrl.threshold).toLocaleString("en-US")}`
: key === "employment" &&
claimParamsFromUrl.threshold
? `seniority ≥ ${claimParamsFromUrl.threshold} yrs`
: m.claim}
</span>
</div>

Expand Down
5 changes: 5 additions & 0 deletions frontend/lib/__tests__/witness-input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ describe("validateWitnessCredential", () => {
expect(validateWitnessCredential("funds", cred)).toBeNull();
});

it("accepts an income band when min and max are both integers", () => {
const cred = validCredential({ claimParams: { min: "100000", max: "250000" } });
expect(validateWitnessCredential("income", cred)).toBeNull();
});

it("ignores an omitted threshold — the route applies a default", () => {
expect(validateWitnessCredential("income", validCredential())).toBeNull();
});
Expand Down
2 changes: 2 additions & 0 deletions frontend/lib/credential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { isStorageAvailable } from "./safe-storage";
export interface ClaimParams {
threshold_years?: string;
threshold?: string;
min?: string;
max?: string;
restricted?: string[];
/** "0" = denylist/block (default), "1" = allowlist/allow */
mode?: string;
Expand Down
33 changes: 32 additions & 1 deletion frontend/lib/verifyParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,13 +154,17 @@ export function validateVerifyParams(params: {
claim: string | null;
thresholdYears: string | null;
threshold: string | null;
min: string | null;
max: string | null;
restricted: string | null;
currentOrigin?: string;
}): {
returnUrlError: string | null;
claimError: string | null;
thresholdYearsError: string | null;
thresholdError: string | null;
minError: string | null;
maxError: string | null;
restrictedError: string | null;
hasErrors: boolean;
} {
Expand All @@ -185,19 +189,46 @@ export function validateVerifyParams(params: {
min: 1,
});

const minResult = validateNumericParam(params.min, {
name: "min",
min: 1,
});

const maxResult = validateNumericParam(params.max, {
name: "max",
min: 1,
});

const rResult = validateRestrictedList(params.restricted);

const returnUrlError = returnUrlResult.ok ? null : returnUrlResult.error;
const thresholdYearsError = tyResult.ok ? null : (tyResult as { ok: false; error: string }).error;
const thresholdError = tResult.ok ? null : (tResult as { ok: false; error: string }).error;
const minError = minResult.ok ? null : (minResult as { ok: false; error: string }).error;
const maxError = maxResult.ok ? null : (maxResult as { ok: false; error: string }).error;
const restrictedError = rResult.ok ? null : rResult.error;

if (params.min && params.max && Number(params.min) > Number(params.max)) {
return {
returnUrlError,
claimError,
thresholdYearsError,
thresholdError,
minError: "Invalid min: must be less than or equal to max.",
maxError: "Invalid max: must be greater than or equal to min.",
restrictedError,
hasErrors: true,
};
}

return {
returnUrlError,
claimError,
thresholdYearsError,
thresholdError,
minError,
maxError,
restrictedError,
hasErrors: !!(returnUrlError ?? claimError ?? thresholdYearsError ?? thresholdError ?? restrictedError),
hasErrors: !!(returnUrlError ?? claimError ?? thresholdYearsError ?? thresholdError ?? minError ?? maxError ?? restrictedError),
};
}
28 changes: 27 additions & 1 deletion frontend/lib/witness-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ const DIGITS_RE = /^[0-9]+$/;
export interface ClaimParams {
threshold_years?: string;
threshold?: string;
min?: string;
max?: string;
restricted?: string[];
/** "0" = denylist (default), "1" = allowlist */
mode?: string;
Expand Down Expand Up @@ -205,7 +207,31 @@ export function validateWitnessCredential(
switch (type) {
case "age":
return checkThreshold(params.threshold_years, "credential.claimParams.threshold_years");
case "income":
case "income": {
const thresholdErr = checkThreshold(params.threshold, "credential.claimParams.threshold");
if (thresholdErr) return thresholdErr;

const minErr = checkThreshold(params.min, "credential.claimParams.min");
if (minErr) return minErr;

const maxErr = checkThreshold(params.max, "credential.claimParams.max");
if (maxErr) return maxErr;

if ((params.min !== undefined) !== (params.max !== undefined)) {
return err(
"credential.claimParams",
"must provide both min and max for income range mode",
);
}
if (params.min !== undefined && params.max !== undefined) {
const min = BigInt(params.min);
const max = BigInt(params.max);
if (min > max) {
return err("credential.claimParams.max", "must be greater than or equal to credential.claimParams.min");
}
}
return null;
}
case "funds":
case "accreditation":
return checkThreshold(params.threshold, "credential.claimParams.threshold");
Expand Down
9 changes: 9 additions & 0 deletions frontend/packages/issuer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export type CredentialType = (typeof CREDENTIAL_TYPES)[number];
export interface ClaimParams {
threshold_years?: string;
threshold?: string;
min?: string;
max?: string;
restricted?: string[];
/** "0" = denylist/block (default), "1" = allowlist/allow */
mode?: string;
Expand Down Expand Up @@ -199,6 +201,13 @@ function buildClaimLabel(type: CredentialType, claimParams?: ClaimParams): strin
case "age":
return `age ≥ ${claimParams?.threshold_years ?? "18"}`;
case "income": {
const min = claimParams?.min;
const max = claimParams?.max;
if (min && max) {
const minNum = Number(min);
const maxNum = Number(max);
return `income $${minNum.toLocaleString("en-US")}–$${maxNum.toLocaleString("en-US")}`;
}
const t = Number(claimParams?.threshold ?? "200000");
return `income > $${t.toLocaleString("en-US")}`;
}
Expand Down
8 changes: 7 additions & 1 deletion frontend/packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,10 @@ export function buildVerifyUrl(options: {
threshold_years?: string;
/** For "income" / "funds" claims: minimum value in whole units (default varies). */
threshold?: string;
/** For "income" claims in range mode: inclusive minimum value. */
min?: string;
/** For "income" claims in range mode: inclusive maximum value. */
max?: string;
/** For "jurisdiction" claims: ISO 3166-1 numeric codes (default []). */
restricted?: string | string[];
/** For "jurisdiction" claims: "block" = denylist (default), "allow" = allowlist. */
Expand Down Expand Up @@ -837,9 +841,11 @@ export function buildVerifyUrl(options: {
url.searchParams.set("return_url", returnUrl);
url.searchParams.set("claim", options.claim);
if (options.claimParams) {
const { threshold_years, threshold, restricted, mode } = options.claimParams;
const { threshold_years, threshold, min, max, restricted, mode } = options.claimParams;
if (threshold_years) url.searchParams.set("threshold_years", threshold_years);
if (threshold) url.searchParams.set("threshold", threshold);
if (min) url.searchParams.set("min", min);
if (max) url.searchParams.set("max", max);
if (restricted) {
url.searchParams.set("restricted", Array.isArray(restricted) ? restricted.join(",") : restricted);
}
Expand Down
Loading