From 4e73412e8c8338dc849462037b40ca87ce2f7004 Mon Sep 17 00:00:00 2001 From: Goodness-0x Date: Mon, 31 Aug 2026 04:28:55 +0000 Subject: [PATCH] feat: add income range mode --- circuits/README.md | 7 +- circuits/income_proof/src/main.nr | 65 +++++++++++++++---- frontend/app/api/witness/route.ts | 9 ++- frontend/app/verify/page.tsx | 28 +++++--- frontend/lib/__tests__/witness-input.test.ts | 5 ++ frontend/lib/credential.ts | 2 + frontend/lib/verifyParams.ts | 33 +++++++++- frontend/lib/witness-input.ts | 28 +++++++- frontend/packages/issuer/src/index.ts | 9 +++ frontend/packages/sdk/src/index.ts | 8 ++- .../packages/sdk/test/integration.test.ts | 12 ++++ 11 files changed, 179 insertions(+), 27 deletions(-) diff --git a/circuits/README.md b/circuits/README.md index 52bcd0fd..ded3aa9e 100644 --- a/circuits/README.md +++ b/circuits/README.md @@ -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 | diff --git a/circuits/income_proof/src/main.nr b/circuits/income_proof/src/main.nr index 2777646a..5607ab24 100644 --- a/circuits/income_proof/src/main.nr +++ b/circuits/income_proof/src/main.nr @@ -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 @@ -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 --------------------------------------------------------------- @@ -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. @@ -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. @@ -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. @@ -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); } diff --git a/frontend/app/api/witness/route.ts b/frontend/app/api/witness/route.ts index fd18405b..f27a940a 100644 --- a/frontend/app/api/witness/route.ts +++ b/frontend/app/api/witness/route.ts @@ -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, diff --git a/frontend/app/verify/page.tsx b/frontend/app/verify/page.tsx index b2333157..b89e7afc 100644 --- a/frontend/app/verify/page.tsx +++ b/frontend/app/verify/page.tsx @@ -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, }); @@ -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, @@ -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); @@ -209,6 +215,8 @@ function VerifyInner() { claim: null, thresholdYears: null, threshold: null, + min: null, + max: null, restricted: null, currentOrigin: window.location.origin, }); @@ -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} diff --git a/frontend/lib/__tests__/witness-input.test.ts b/frontend/lib/__tests__/witness-input.test.ts index bfa4035b..91f083df 100644 --- a/frontend/lib/__tests__/witness-input.test.ts +++ b/frontend/lib/__tests__/witness-input.test.ts @@ -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(); }); diff --git a/frontend/lib/credential.ts b/frontend/lib/credential.ts index a35755f8..2695a4ad 100644 --- a/frontend/lib/credential.ts +++ b/frontend/lib/credential.ts @@ -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; diff --git a/frontend/lib/verifyParams.ts b/frontend/lib/verifyParams.ts index 58012c6c..f6c40faa 100644 --- a/frontend/lib/verifyParams.ts +++ b/frontend/lib/verifyParams.ts @@ -154,6 +154,8 @@ export function validateVerifyParams(params: { claim: string | null; thresholdYears: string | null; threshold: string | null; + min: string | null; + max: string | null; restricted: string | null; currentOrigin?: string; }): { @@ -161,6 +163,8 @@ export function validateVerifyParams(params: { claimError: string | null; thresholdYearsError: string | null; thresholdError: string | null; + minError: string | null; + maxError: string | null; restrictedError: string | null; hasErrors: boolean; } { @@ -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), }; } diff --git a/frontend/lib/witness-input.ts b/frontend/lib/witness-input.ts index 4eb21791..a2931dfe 100644 --- a/frontend/lib/witness-input.ts +++ b/frontend/lib/witness-input.ts @@ -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; @@ -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"); diff --git a/frontend/packages/issuer/src/index.ts b/frontend/packages/issuer/src/index.ts index 4c5f42df..4cf82c9c 100644 --- a/frontend/packages/issuer/src/index.ts +++ b/frontend/packages/issuer/src/index.ts @@ -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; @@ -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")}`; } diff --git a/frontend/packages/sdk/src/index.ts b/frontend/packages/sdk/src/index.ts index ec941f09..15b4688b 100644 --- a/frontend/packages/sdk/src/index.ts +++ b/frontend/packages/sdk/src/index.ts @@ -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. */ @@ -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); } diff --git a/frontend/packages/sdk/test/integration.test.ts b/frontend/packages/sdk/test/integration.test.ts index 5ea344df..f2af702b 100644 --- a/frontend/packages/sdk/test/integration.test.ts +++ b/frontend/packages/sdk/test/integration.test.ts @@ -191,6 +191,18 @@ describe("StellarCred SDK integration (testnet)", () => { expect(url).toContain("claim=funds"); }); + it("appends income min/max for banded income claims", () => { + const url = buildVerifyUrl({ + returnUrl: "https://example.com/vault", + claim: "income", + claimParams: { min: "100000", max: "250000" }, + }); + + expect(url).toContain("min=100000"); + expect(url).toContain("max=250000"); + expect(url).toContain("claim=income"); + }); + it("appends restricted as comma-separated list for jurisdiction claims", () => { const url = buildVerifyUrl({ returnUrl: "https://example.com/app",