diff --git a/__tests__/generator/index.test.ts b/__tests__/generator/index.test.ts index 605fd7c..d55d98f 100644 --- a/__tests__/generator/index.test.ts +++ b/__tests__/generator/index.test.ts @@ -30,12 +30,12 @@ describe('generateCompact', () => { it('declares the commitmentsToIds ledger', () => { const result = generateCompact(minimalScript); - expect(result).toContain('export ledger commitmentsToIds'); + expect(result).toContain('ledger commitmentsToIds'); }); it('declares the idsToCommitments ledger', () => { const result = generateCompact(minimalScript); - expect(result).toContain('export ledger idsToCommitments'); + expect(result).toContain('ledger idsToCommitments'); }); it('declares the localSecret witness', () => { diff --git a/__tests__/generator/verify.test.ts b/__tests__/generator/verify.test.ts index 3f198e2..0a1a9b7 100644 --- a/__tests__/generator/verify.test.ts +++ b/__tests__/generator/verify.test.ts @@ -12,6 +12,13 @@ const cmtHexSet = new Set( cmtLeaves.flatMap((l) => l.hashes).map((h) => Buffer.from(h).toString('hex')) ); const matchingPairs = secretPairs.filter((p: any) => cmtHexSet.has(p.commitment)); +const isAtLeast = input.type === 'atLeast'; +const atLeastRequired = isAtLeast ? input.required : 0; +const isFlatAtLeast = + isAtLeast && + (input.scripts as NativeScriptSchema[]).every( + (c) => c.type === 'cmt' || c.type === 'after' || c.type === 'before' + ); function hasTimeLocks(script: NativeScriptSchema): boolean { if (script.type === 'after' || script.type === 'before') return true; @@ -136,4 +143,107 @@ describe('Verify circuit', () => { } } ); + + it.runIf(isFlatAtLeast && matchingPairs.length >= atLeastRequired)( + 'atLeast: passes when exactly required number of commitments are committed', + () => { + const sim = new WardenSimulator(); + sim.init(); + for (const { secret, randomness } of matchingPairs.slice(0, atLeastRequired)) { + sim.commitWith( + new Uint8Array(Buffer.from(secret, 'hex')), + new Uint8Array(Buffer.from(randomness, 'hex')) + ); + } + expect(() => sim.verify()).not.toThrow(); + } + ); + + it.runIf(isFlatAtLeast && matchingPairs.length > atLeastRequired)( + 'atLeast: passes when more than required number are committed', + () => { + const sim = new WardenSimulator(); + sim.init(); + for (const { secret, randomness } of matchingPairs) { + sim.commitWith( + new Uint8Array(Buffer.from(secret, 'hex')), + new Uint8Array(Buffer.from(randomness, 'hex')) + ); + } + expect(() => sim.verify()).not.toThrow(); + } + ); + + it.runIf(isFlatAtLeast && atLeastRequired > 1 && matchingPairs.length >= atLeastRequired)( + 'atLeast: fails when fewer than required are committed', + () => { + const sim = new WardenSimulator(); + sim.init(); + for (const { secret, randomness } of matchingPairs.slice(0, atLeastRequired - 1)) { + sim.commitWith( + new Uint8Array(Buffer.from(secret, 'hex')), + new Uint8Array(Buffer.from(randomness, 'hex')) + ); + } + expect(() => sim.verify()).toThrow(); + } + ); + + it.runIf(isAtLeast && hasCmt)('atLeast: fails when no commitments are committed', () => { + const sim = new WardenSimulator(); + sim.init(); + expect(() => sim.verify()).toThrow('Commitments or time-lock conditions not satisfied'); + }); + + it.runIf(isAtLeast)('atLeast: fails on uninitialized contract', () => { + const sim = new WardenSimulator(); + expect(() => sim.verify()).toThrow(); + }); + + it.runIf(isFlatAtLeast && matchingPairs.length >= atLeastRequired)( + 'atLeast: cleans up idsToCommitments after successful verify', + () => { + const sim = new WardenSimulator(); + sim.init(); + for (const { secret, randomness } of matchingPairs.slice(0, atLeastRequired)) { + sim.commitWith( + new Uint8Array(Buffer.from(secret, 'hex')), + new Uint8Array(Buffer.from(randomness, 'hex')) + ); + } + + const paths = [...new Set(cmtLeaves.map((l) => l.path))]; + for (const p of paths) { + expect( + (sim.getLedger() as any).idsToCommitments.lookup(Buffer.from(p.padEnd(8))).isEmpty() + ).toBe(false); + } + + expect(() => sim.verify()).not.toThrow(); + + for (const p of paths) { + expect( + (sim.getLedger() as any).idsToCommitments.lookup(Buffer.from(p.padEnd(8))).isEmpty() + ).toBe(true); + } + } + ); + + it.runIf(isAtLeast && hasTimeLocks(input))( + 'atLeast: passes when time-lock conditions are met', + () => { + const sim = new WardenSimulator(); + if (hasCmt) { + sim.init(); + for (const { secret, randomness } of matchingPairs) { + sim.commitWith( + new Uint8Array(Buffer.from(secret, 'hex')), + new Uint8Array(Buffer.from(randomness, 'hex')) + ); + } + } + sim.setBlockTime(400); + expect(() => sim.verify()).not.toThrow(); + } + ); }); diff --git a/examples/inputs/atLeast-10-of-20.json b/examples/inputs/atLeast-10-of-20.json new file mode 100644 index 0000000..abc0515 --- /dev/null +++ b/examples/inputs/atLeast-10-of-20.json @@ -0,0 +1,26 @@ +{ + "type": "atLeast", + "required": 10, + "scripts": [ + { "type": "cmt", "hash": "21f314e3679165f234fe8a986f60d2a0308e4b846415fe68f4bf9d3d194ea74c" }, + { "type": "cmt", "hash": "9517dcb986da18099688e88b6fe1fce568fd0eeb7ddbba86a8263dc489f85783" }, + { "type": "cmt", "hash": "da6d42c11f5ca536f413497d8e8ebd3e64d78bdd80e21f5879537d7ea94b1583" }, + { "type": "cmt", "hash": "2e9d019799b4d93bf1cc68c8119c154cc4e7f814743f2201b6b7f8a8897b4fe1" }, + { "type": "cmt", "hash": "725bb974e724df4497f9ec262f5bf3859ac6a81949a7e29a6faeb2c72aad8a86" }, + { "type": "cmt", "hash": "ba660000921e9e4f1d6e178440fc187e28a4eefc7a2a20e90bb75c3d1fa66252" }, + { "type": "cmt", "hash": "78d6b87a286cea8de00926528df0fb9dd2d0e8de86a4838b303a82ecf19e6a91" }, + { "type": "cmt", "hash": "25b75103abd3122985dc69e586aea16ca25a90831b69f15d4a38a7559acbd82a" }, + { "type": "cmt", "hash": "13e29897dcb463d2a6c471694c4643593b7e8b5783631d14fd5ef1e0422ed565" }, + { "type": "cmt", "hash": "c64cc99262310c372db285c5d173a1ccf8d81ee571cf6aaa21a55e6e2e35c352" }, + { "type": "cmt", "hash": "74e084e4af59297a23511ff1d1e4f51665cec78ae26428338a67eb9278d78715" }, + { "type": "cmt", "hash": "23531f33c3b9fb790a40cc2dd31a1bc562a3e214ba843d03e0e613f1f3b07fd9" }, + { "type": "cmt", "hash": "7af0d79f2936593051b77ac6fd8026f2923dbaecf8ae25cfa245db1cf8a3a5ce" }, + { "type": "cmt", "hash": "926587b6cdbf1c50d6a45be911989b14b2ec7cf4be14dacbce89c2bc4ccfcf6c" }, + { "type": "cmt", "hash": "bee8dee9fb2da87ae60b650a4582d6b80eebd41dc62bd31446bee9185f991c8a" }, + { "type": "cmt", "hash": "04c8b7c3ba1b9423c3c90efd9138690f7265d053013f6c17ecd4bb8d4420ea9a" }, + { "type": "cmt", "hash": "12f5821d63b30642f8f75dc9694cc33620a20104c62a886b89ecd29b4c8343af" }, + { "type": "cmt", "hash": "6fdb14f712f93813edf7fece303d60d9239f70b21d594dd791d4c776d31c084d" }, + { "type": "cmt", "hash": "d5e7184b811f179e8b5662511bef2be2f22584870c1f34e8aac90f94dfee52d4" }, + { "type": "cmt", "hash": "8954876f9030dba9110149dfac22b5333dd2b9abb0316dcee20ca0af4dccadd7" } + ] +} diff --git a/examples/inputs/atLeast-nested-big.json b/examples/inputs/atLeast-nested-big.json new file mode 100644 index 0000000..3896a42 --- /dev/null +++ b/examples/inputs/atLeast-nested-big.json @@ -0,0 +1,102 @@ +{ + "type": "atLeast", + "required": 2, + "scripts": [ + { + "type": "atLeast", + "required": 2, + "scripts": [ + { + "type": "cmt", + "hash": "21f314e3679165f234fe8a986f60d2a0308e4b846415fe68f4bf9d3d194ea74c" + }, + { + "type": "cmt", + "hash": "9517dcb986da18099688e88b6fe1fce568fd0eeb7ddbba86a8263dc489f85783" + }, + { + "type": "cmt", + "hash": "da6d42c11f5ca536f413497d8e8ebd3e64d78bdd80e21f5879537d7ea94b1583" + }, + { + "type": "cmt", + "hash": "2e9d019799b4d93bf1cc68c8119c154cc4e7f814743f2201b6b7f8a8897b4fe1" + } + ] + }, + { + "type": "atLeast", + "required": 3, + "scripts": [ + { + "type": "cmt", + "hash": "725bb974e724df4497f9ec262f5bf3859ac6a81949a7e29a6faeb2c72aad8a86" + }, + { + "type": "cmt", + "hash": "ba660000921e9e4f1d6e178440fc187e28a4eefc7a2a20e90bb75c3d1fa66252" + }, + { + "type": "cmt", + "hash": "78d6b87a286cea8de00926528df0fb9dd2d0e8de86a4838b303a82ecf19e6a91" + }, + { + "type": "cmt", + "hash": "25b75103abd3122985dc69e586aea16ca25a90831b69f15d4a38a7559acbd82a" + }, + { + "type": "cmt", + "hash": "13e29897dcb463d2a6c471694c4643593b7e8b5783631d14fd5ef1e0422ed565" + } + ] + }, + { + "type": "atLeast", + "required": 2, + "scripts": [ + { + "type": "cmt", + "hash": "c64cc99262310c372db285c5d173a1ccf8d81ee571cf6aaa21a55e6e2e35c352" + }, + { + "type": "cmt", + "hash": "74e084e4af59297a23511ff1d1e4f51665cec78ae26428338a67eb9278d78715" + }, + { + "type": "cmt", + "hash": "23531f33c3b9fb790a40cc2dd31a1bc562a3e214ba843d03e0e613f1f3b07fd9" + }, + { + "type": "cmt", + "hash": "7af0d79f2936593051b77ac6fd8026f2923dbaecf8ae25cfa245db1cf8a3a5ce" + } + ] + }, + { + "type": "atLeast", + "required": 3, + "scripts": [ + { + "type": "cmt", + "hash": "926587b6cdbf1c50d6a45be911989b14b2ec7cf4be14dacbce89c2bc4ccfcf6c" + }, + { + "type": "cmt", + "hash": "bee8dee9fb2da87ae60b650a4582d6b80eebd41dc62bd31446bee9185f991c8a" + }, + { + "type": "cmt", + "hash": "04c8b7c3ba1b9423c3c90efd9138690f7265d053013f6c17ecd4bb8d4420ea9a" + }, + { + "type": "cmt", + "hash": "12f5821d63b30642f8f75dc9694cc33620a20104c62a886b89ecd29b4c8343af" + }, + { + "type": "cmt", + "hash": "6fdb14f712f93813edf7fece303d60d9239f70b21d594dd791d4c776d31c084d" + } + ] + } + ] +} diff --git a/src/generator/index.ts b/src/generator/index.ts index a7a5783..e29898c 100644 --- a/src/generator/index.ts +++ b/src/generator/index.ts @@ -53,14 +53,14 @@ export function generateCompact( * @key commitment hash * @value set of IDs */ - export ledger commitmentsToIds: Map, Set>>; + ${testMode ? 'export ' : ''}ledger commitmentsToIds: Map, Set>>; /** * @description Store commitments. * @key hash id of the set * @value set of commitments */ - export ledger idsToCommitments: Map, Set>>; + ${testMode ? 'export ' : ''}ledger idsToCommitments: Map, Set>>; ` : ''; @@ -80,7 +80,7 @@ export function generateCompact( ` : ''; - const getCommitmentCircuit = testMode + const getCommitmentCircuit = hasCmtLeaves ? ` /** * @description Generates the commitment that will be added into the idsToCommitments ledger. diff --git a/src/generator/verify.ts b/src/generator/verify.ts index 18e4355..521b04b 100644 --- a/src/generator/verify.ts +++ b/src/generator/verify.ts @@ -2,20 +2,6 @@ import type { NativeScriptSchema } from '../index.js'; import { formatBytes, collectUniquePaths } from './utils.js'; import type { CmtLeaf } from './utils.js'; -/** - * Generates all k-sized combinations from an array. - * - * @param arr - Source elements - * @param k - Size of each combination - * @returns Array of combinations - */ -function combinations(arr: T[], k: number): T[][] { - if (k === 0) return [[]]; - if (arr.length < k) return []; - const [first, ...rest] = arr; - return [...combinations(rest, k - 1).map((c) => [first, ...c]), ...combinations(rest, k)]; -} - /** * Result of recursively generating a Compact expression from a script subtree. * @@ -31,7 +17,7 @@ type GenResult = { * Recursively generates Compact expressions for a native script subtree. * * Walks the tree and emits constraint expressions for commitment lookups, - * time-lock checks, and combinators (`any`/`all`/`atLeast`). + * time-lock checks, and combinators (`any`, `all`, `atLeast`). * * @param script - Script subtree to generate expressions for * @param path - Dot-separated tree path identifying this node's position @@ -57,8 +43,14 @@ function genExpr(script: NativeScriptSchema, path: string): GenResult { lines: [`const id_${path} = blockTimeLt(${script.block});`], }; } - case 'any': - case 'all': + /** + * Generates a linear counter sum for atLeast N-of-M conditions. + * + * Each child expression is converted to a boolean via ternary (`? 1 : 0`), + * summed with `+`, and compared against the required threshold. + * This is O(n) in the number of children, avoiding the combinatorial + * explosion of generating all k-combinations. + */ case 'atLeast': { const children: GenResult[] = []; const inlineExprs: string[] = []; @@ -86,18 +78,40 @@ function genExpr(script: NativeScriptSchema, path: string): GenResult { const parts = [...inlineExprs, ...children.map((c) => c.expr)]; if (parts.length === 0) return { expr: '', lines }; - let combined: string; + const sum = parts.map((p) => `(${p} ? 1 : 0)`).join(' + '); + lines.push(`const id_${path} = (${sum}) >= ${script.required};`); + return { expr: `id_${path}`, lines }; + } + case 'any': + case 'all': { + const children: GenResult[] = []; + const inlineExprs: string[] = []; + const lines: string[] = []; - if (script.type === 'atLeast') { - combined = combinations(parts, script.required) - .map((c) => `(${c.join(' && ')})`) - .join(' || '); - } else { - const op = script.type === 'all' ? ' && ' : ' || '; - combined = parts.join(op); + for (let i = 0; i < script.scripts.length; i++) { + const child = script.scripts[i]; + if (child.type === 'cmt') { + const hash = formatBytes(child.hash); + const id = formatBytes(path.padEnd(8)); + inlineExprs.push(`idsToCommitments.lookup(${id}).member(${hash})`); + } else if (child.type === 'after') { + inlineExprs.push(`blockTimeGte(${child.block})`); + } else if (child.type === 'before') { + inlineExprs.push(`blockTimeLt(${child.block})`); + } else if (child.type === 'any' || child.type === 'all' || child.type === 'atLeast') { + const childPath = path ? `${path}_${i}` : `${i}`; + const childResult = genExpr(child, childPath); + if (childResult.expr === '') continue; + children.push(childResult); + lines.push(...childResult.lines); + } } - lines.push(`const id_${path} = ${combined};`); + const parts = [...inlineExprs, ...children.map((c) => c.expr)]; + if (parts.length === 0) return { expr: '', lines }; + + const op = script.type === 'all' ? ' && ' : ' || '; + lines.push(`const id_${path} = ${parts.join(op)};`); return { expr: `id_${path}`, lines }; } }