From 33633616a0b15d9f5e0c6f89f350b637b7322d21 Mon Sep 17 00:00:00 2001 From: Iko <6572003+iap@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:39:26 +0700 Subject: [PATCH 1/8] test: verify GPG signing and GH013 From 3760724388c51176394c73d80aed6d869fb08f99 Mon Sep 17 00:00:00 2001 From: Iko <6572003+iap@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:57:12 +0700 Subject: [PATCH 2/8] chore: remove accidentally committed empty = file --- = | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 = diff --git a/= b/= deleted file mode 100644 index e69de29..0000000 From 28b94bd25bc4f68905e66ac9c6839731f5791b47 Mon Sep 17 00:00:00 2001 From: Iko <6572003+iap@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:10:53 +0700 Subject: [PATCH 3/8] fix: circuit test, CI timeout, and stale file cleanup - Add missing inSecret0Upper input to MARKPool.fast.mjs happy-path test - Increase ci-fast timeout to 1200s for cold Solidity compilation - Move Semgrep to standalone task scoped to contracts/ and circuits/ - Remove orphaned circuits/scripts/test-fast.mjs --- .mise.toml | 8 ++++---- circuits/scripts/test-fast.mjs | 22 ---------------------- circuits/test/MARKPool.fast.mjs | 1 + 3 files changed, 5 insertions(+), 26 deletions(-) delete mode 100644 circuits/scripts/test-fast.mjs diff --git a/.mise.toml b/.mise.toml index 5f739db..bf346e4 100644 --- a/.mise.toml +++ b/.mise.toml @@ -85,8 +85,8 @@ run = [ [tasks.ci-fast] description = "Fast CI checks before push to feat/*" -timeout = "180s" -run = "pnpm typecheck && pnpm lint && uvx semgrep scan --config=auto --config=./.semgrep.yml --error && cd contracts && forge test --no-match-test invariant && cd ../circuits && pnpm test" +timeout = "1200s" +run = "pnpm typecheck && pnpm lint && cd contracts && forge test --no-match-test invariant && cd ../circuits && pnpm test" [tasks.ci-full] description = "Full CI before PR to dev/main" @@ -117,8 +117,8 @@ description = "Run Slither static analysis" run = "cd contracts && uv run slither ." [tasks.semgrep] -description = "Run Semgrep with MARK custom rules" -run = "uvx semgrep scan --config=auto --config=p/security-audit --config=./.semgrep.yml --error" +description = "Run Semgrep security scan (contracts + circuits)" +run = "uvx semgrep scan --config=./.semgrep.yml contracts/ circuits/" [tasks.clean] description = "Nuke all build artifacts + caches. Use when stuff is weird." diff --git a/circuits/scripts/test-fast.mjs b/circuits/scripts/test-fast.mjs deleted file mode 100644 index 4f7f80a..0000000 --- a/circuits/scripts/test-fast.mjs +++ /dev/null @@ -1,22 +0,0 @@ -// CI-fast witness tests: reuse existing circom build when present; otherwise build once. -import { existsSync } from "fs"; -import { spawnSync } from "child_process"; -import path from "path"; -import { fileURLToPath } from "url"; - -const root = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); -const wasm = path.join(root, "build/MARKPool_js/MARKPool.wasm"); - -function run(cmd, args) { - // nosemgrep:security.detect-child-process.detect-child-process - // Safe: only called with hardcoded 'pnpm'/'node' and controlled args - const r = spawnSync(cmd, args, { cwd: root, stdio: "inherit", shell: false }); - if (r.status !== 0) process.exit(r.status ?? 1); -} - -if (!existsSync(wasm)) { - console.log("circuits test:fast — no wasm artifact; running build once"); - run("pnpm", ["run", "build"]); -} - -run("node", ["test/MARKPool.test.mjs"]); diff --git a/circuits/test/MARKPool.fast.mjs b/circuits/test/MARKPool.fast.mjs index e30edcb..db3f282 100644 --- a/circuits/test/MARKPool.fast.mjs +++ b/circuits/test/MARKPool.fast.mjs @@ -154,6 +154,7 @@ const validBase = { withdrawOwner: 0n, withdrawRecipient: 0n, withdrawAmount: 0n, + inSecret0Upper: 0n, }; console.log("MARKPool circuit fast tests"); From 5c76f2bec46376a25c708f7c3e1dafda823fb29d Mon Sep 17 00:00:00 2001 From: Iko <6572003+iap@users.noreply.github.com> Date: Tue, 23 Jun 2026 06:21:06 +0700 Subject: [PATCH 4/8] fix(ci): restore semgrep to ci-fast, add withdrawal binding tests - Restore Semgrep scan with --severity ERROR filter to ci-fast task - Change circuits step from pnpm test to pnpm test:fast for faster iteration - Add RELAYER_MAX constant and withdrawal binding test cases in MARKPool.fast.mjs - Add happy path test for withdrawal (exercises inSecret0Upper constraint) - Add sad path test for tampered withdrawOwner (attack POC) --- .mise.toml | 4 ++-- circuits/test/MARKPool.fast.mjs | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/.mise.toml b/.mise.toml index bf346e4..4f9ba6d 100644 --- a/.mise.toml +++ b/.mise.toml @@ -85,8 +85,8 @@ run = [ [tasks.ci-fast] description = "Fast CI checks before push to feat/*" -timeout = "1200s" -run = "pnpm typecheck && pnpm lint && cd contracts && forge test --no-match-test invariant && cd ../circuits && pnpm test" +timeout = "600s" +run = "pnpm typecheck && pnpm lint && uvx semgrep scan --config=./.semgrep.yml --severity ERROR contracts/ circuits/ && cd contracts && forge test --no-match-test invariant && cd ../circuits && pnpm test:fast" [tasks.ci-full] description = "Full CI before PR to dev/main" diff --git a/circuits/test/MARKPool.fast.mjs b/circuits/test/MARKPool.fast.mjs index db3f282..f870e14 100644 --- a/circuits/test/MARKPool.fast.mjs +++ b/circuits/test/MARKPool.fast.mjs @@ -72,6 +72,7 @@ const DOMAIN_NULLIFIER_TAG = DOMAIN_VERSION * 100n + DOMAIN_NULLIFIER; const DEPTH = 20; const CHAIN_ID = 11155420n; // OP Sepolia +const RELAYER_MAX = 2n ** 160n; // Build a valid note function makeNote(amount, secret, blinding) { @@ -162,6 +163,22 @@ console.log("MARKPool circuit fast tests"); // Happy path - core functionality await expectPass("valid 2-in 2-out transact", validBase); +// Withdrawal path - exercises inSecret0Upper binding constraint +const withdrawOwner = in0.secret % RELAYER_MAX; // lower 160 bits +const withdrawBase = { + ...validBase, + fee: 300n, + withdrawOwner, + withdrawRecipient: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8n, + withdrawAmount: 200n, + inSecret0Upper: in0.secret / RELAYER_MAX, +}; +await expectPass("valid 2-in 2-out transact with withdrawal", withdrawBase); +await expectFail("wrong withdrawOwner (tampered)", { + ...withdrawBase, + withdrawOwner: 222n, +}); + // Balance equation - critical invariants await expectFail("fee too low (balance broken)", { ...validBase, fee: fee - 1n }); await expectFail("fee too high (balance broken)", { ...validBase, fee: fee + 1n }); From 040bab3b7471b9db94b186c8c3cde8d5f71cffe9 Mon Sep 17 00:00:00 2001 From: iap Date: Mon, 22 Jun 2026 23:31:18 +0000 Subject: [PATCH 5/8] fix: restore semgrep blocking flags and add forged-upper attack test - ci-fast: add --error to semgrep so ERROR findings cause non-zero exit - semgrep task: restore --config=auto --config=p/security-audit and --error (lost when the task was previously refactored) - MARKPool.fast.mjs: add forged-upper owner-binding bypass test that computes inSecret0Upper = (secret - spoofedOwner) * RELAYER_MAX^-1 mod p and verifies the Num2Bits(93) range check rejects the out-of-range value Co-authored-by: Codesmith --- .mise.toml | 4 ++-- circuits/test/MARKPool.fast.mjs | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/.mise.toml b/.mise.toml index 4f9ba6d..1b01d5c 100644 --- a/.mise.toml +++ b/.mise.toml @@ -86,7 +86,7 @@ run = [ [tasks.ci-fast] description = "Fast CI checks before push to feat/*" timeout = "600s" -run = "pnpm typecheck && pnpm lint && uvx semgrep scan --config=./.semgrep.yml --severity ERROR contracts/ circuits/ && cd contracts && forge test --no-match-test invariant && cd ../circuits && pnpm test:fast" +run = "pnpm typecheck && pnpm lint && uvx semgrep scan --config=./.semgrep.yml --severity ERROR --error contracts/ circuits/ && cd contracts && forge test --no-match-test invariant && cd ../circuits && pnpm test:fast" [tasks.ci-full] description = "Full CI before PR to dev/main" @@ -118,7 +118,7 @@ run = "cd contracts && uv run slither ." [tasks.semgrep] description = "Run Semgrep security scan (contracts + circuits)" -run = "uvx semgrep scan --config=./.semgrep.yml contracts/ circuits/" +run = "uvx semgrep scan --config=auto --config=p/security-audit --config=./.semgrep.yml --error contracts/ circuits/" [tasks.clean] description = "Nuke all build artifacts + caches. Use when stuff is weird." diff --git a/circuits/test/MARKPool.fast.mjs b/circuits/test/MARKPool.fast.mjs index f870e14..2a7003a 100644 --- a/circuits/test/MARKPool.fast.mjs +++ b/circuits/test/MARKPool.fast.mjs @@ -73,6 +73,20 @@ const DOMAIN_NULLIFIER_TAG = DOMAIN_VERSION * 100n + DOMAIN_NULLIFIER; const DEPTH = 20; const CHAIN_ID = 11155420n; // OP Sepolia const RELAYER_MAX = 2n ** 160n; +// BN254 scalar field prime (matches circom's field) +const BN254_P = 21888242871839275222246405745257275088548364400416034343698204186575808495617n; + +// Modular inverse via extended Euclidean algorithm +function modInverse(a, m) { + let [old_r, r] = [a % m, m]; + let [old_s, s] = [1n, 0n]; + while (r !== 0n) { + const q = old_r / r; + [old_r, r] = [r, old_r - q * r]; + [old_s, s] = [s, old_s - q * s]; + } + return ((old_s % m) + m) % m; +} // Build a valid note function makeNote(amount, secret, blinding) { @@ -179,6 +193,18 @@ await expectFail("wrong withdrawOwner (tampered)", { withdrawOwner: 222n, }); +// Forged-upper attack: prover supplies inSecret0Upper = (secret - spoofedOwner) * RELAYER_MAX^-1 mod p +// This satisfies the linear decomposition equation in-field but inSecret0Upper is ~254 bits, +// which the Num2Bits(93) range check must reject. +const spoofedOwner = 222n; +const forgedUpper = + ((in0.secret - spoofedOwner + BN254_P) % BN254_P) * modInverse(RELAYER_MAX, BN254_P) % BN254_P; +await expectFail("forged-upper owner binding bypass", { + ...withdrawBase, + withdrawOwner: spoofedOwner, + inSecret0Upper: forgedUpper, +}); + // Balance equation - critical invariants await expectFail("fee too low (balance broken)", { ...validBase, fee: fee - 1n }); await expectFail("fee too high (balance broken)", { ...validBase, fee: fee + 1n }); From 081cd0a72c452d83d0d7a49a00e479b687876826 Mon Sep 17 00:00:00 2001 From: iap Date: Mon, 22 Jun 2026 23:32:40 +0000 Subject: [PATCH 6/8] test: add withdrawal happy path with non-zero inSecret0Upper Previous withdrawal test used secret=111n which is smaller than 2^160, so inSecret0Upper was always 0n and the upper-bits term of the binding constraint was never exercised. Add a second happy-path case using secret = RELAYER_MAX + 42n (upper=1n, lower=42n) so a regression that ignores inSecret0Upper or uses the wrong multiplier would be caught. Co-authored-by: Codesmith --- circuits/test/MARKPool.fast.mjs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/circuits/test/MARKPool.fast.mjs b/circuits/test/MARKPool.fast.mjs index 2a7003a..4895cc3 100644 --- a/circuits/test/MARKPool.fast.mjs +++ b/circuits/test/MARKPool.fast.mjs @@ -188,6 +188,35 @@ const withdrawBase = { inSecret0Upper: in0.secret / RELAYER_MAX, }; await expectPass("valid 2-in 2-out transact with withdrawal", withdrawBase); + +// Happy path with non-zero inSecret0Upper: secret = RELAYER_MAX + 42n so upper = 1n, lower = 42n. +// This exercises the `inSecret0Upper * 2^160` term in the binding constraint. +const in0Large = makeNote(500n, RELAYER_MAX + 42n, 999n); +const treeLarge = buildTwoLeafRoot(in0Large.commitment, in1.commitment, DEPTH); +const nullifier0Large = makeNullifier(in0Large, CHAIN_ID); +await expectPass("withdrawal with non-zero inSecret0Upper (upper-bits binding)", { + inAmount: [in0Large.amount, in1.amount], + inSecret: [in0Large.secret, in1.secret], + inBlinding: [in0Large.blinding, in1.blinding], + inPathElements: [treeLarge.path0.elements, treeLarge.path1.elements], + inPathIndices: [treeLarge.path0.indices, treeLarge.path1.indices], + outAmount: [out0Amount, out1Amount], + outSecret: [out0Secret, out1Secret], + outBlinding: [out0Blinding, out1Blinding], + merkleRoot: treeLarge.root, + chainId: CHAIN_ID, + dstChainId: CHAIN_ID, + protocolEpoch: 0n, + fee: 300n, + relayer: 0n, + nullifier: [nullifier0Large, nullifier1], + outCommitment: [outC0, outC1], + withdrawOwner: in0Large.secret % RELAYER_MAX, // 42n — lower 160 bits + withdrawRecipient: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8n, + withdrawAmount: 200n, + inSecret0Upper: in0Large.secret / RELAYER_MAX, // 1n — non-zero upper bits +}); + await expectFail("wrong withdrawOwner (tampered)", { ...withdrawBase, withdrawOwner: 222n, From 21ed994cd48fba3fdc44d12d2d5a83da0dd45e49 Mon Sep 17 00:00:00 2001 From: iap Date: Mon, 22 Jun 2026 23:39:06 +0000 Subject: [PATCH 7/8] fix: restore --config=auto to ci-fast semgrep invocation The fast gate previously ran --config=auto alongside the MARK custom rules. This was dropped when semgrep was re-added to ci-fast, narrowing the scan to only project-specific rules and letting generic security findings through. Co-authored-by: Codesmith --- .mise.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.mise.toml b/.mise.toml index 1b01d5c..aca6333 100644 --- a/.mise.toml +++ b/.mise.toml @@ -86,7 +86,7 @@ run = [ [tasks.ci-fast] description = "Fast CI checks before push to feat/*" timeout = "600s" -run = "pnpm typecheck && pnpm lint && uvx semgrep scan --config=./.semgrep.yml --severity ERROR --error contracts/ circuits/ && cd contracts && forge test --no-match-test invariant && cd ../circuits && pnpm test:fast" +run = "pnpm typecheck && pnpm lint && uvx semgrep scan --config=auto --config=./.semgrep.yml --severity ERROR --error contracts/ circuits/ && cd contracts && forge test --no-match-test invariant && cd ../circuits && pnpm test:fast" [tasks.ci-full] description = "Full CI before PR to dev/main" From 78a655bebbddcc33837498383bb46c4968577391 Mon Sep 17 00:00:00 2001 From: iap Date: Mon, 22 Jun 2026 23:47:33 +0000 Subject: [PATCH 8/8] fix: restore full-repo semgrep scope and port missing circuit tests to fast suite - ci-fast/semgrep tasks: remove contracts/ circuits/ target restriction so frontend JS/TS is scanned again (matches historical no-target invocation; .semgrepignore handles build artifact exclusions) - MARKPool.fast.mjs: port four security-relevant tests from the full suite that were missing from the fast gate: - withdraw amount non-zero but owner zero - withdraw amount non-zero but recipient zero - withdraw amount zero but owner non-zero - wrong output commitment Co-authored-by: Codesmith --- .mise.toml | 4 ++-- circuits/test/MARKPool.fast.mjs | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/.mise.toml b/.mise.toml index aca6333..763f5b5 100644 --- a/.mise.toml +++ b/.mise.toml @@ -86,7 +86,7 @@ run = [ [tasks.ci-fast] description = "Fast CI checks before push to feat/*" timeout = "600s" -run = "pnpm typecheck && pnpm lint && uvx semgrep scan --config=auto --config=./.semgrep.yml --severity ERROR --error contracts/ circuits/ && cd contracts && forge test --no-match-test invariant && cd ../circuits && pnpm test:fast" +run = "pnpm typecheck && pnpm lint && uvx semgrep scan --config=auto --config=./.semgrep.yml --severity ERROR --error && cd contracts && forge test --no-match-test invariant && cd ../circuits && pnpm test:fast" [tasks.ci-full] description = "Full CI before PR to dev/main" @@ -118,7 +118,7 @@ run = "cd contracts && uv run slither ." [tasks.semgrep] description = "Run Semgrep security scan (contracts + circuits)" -run = "uvx semgrep scan --config=auto --config=p/security-audit --config=./.semgrep.yml --error contracts/ circuits/" +run = "uvx semgrep scan --config=auto --config=p/security-audit --config=./.semgrep.yml --error" [tasks.clean] description = "Nuke all build artifacts + caches. Use when stuff is weird." diff --git a/circuits/test/MARKPool.fast.mjs b/circuits/test/MARKPool.fast.mjs index 4895cc3..9ddba82 100644 --- a/circuits/test/MARKPool.fast.mjs +++ b/circuits/test/MARKPool.fast.mjs @@ -221,6 +221,20 @@ await expectFail("wrong withdrawOwner (tampered)", { ...withdrawBase, withdrawOwner: 222n, }); +await expectFail("withdraw amount non-zero but owner zero", { + ...withdrawBase, + withdrawOwner: 0n, +}); +await expectFail("withdraw amount non-zero but recipient zero", { + ...withdrawBase, + withdrawRecipient: 0n, +}); +await expectFail("withdraw amount zero but owner non-zero", { + ...validBase, + withdrawOwner: withdrawOwner, + withdrawRecipient: 0n, + withdrawAmount: 0n, +}); // Forged-upper attack: prover supplies inSecret0Upper = (secret - spoofedOwner) * RELAYER_MAX^-1 mod p // This satisfies the linear decomposition equation in-field but inSecret0Upper is ~254 bits, @@ -258,5 +272,9 @@ await expectFail("zero input amount", { inAmount: [0n, in1.amount], fee: in1.amount, }); +await expectFail("wrong output commitment", { + ...validBase, + outCommitment: [outC0 + 1n, outC1], +}); console.log("\nAll fast tests passed.");