diff --git a/README.md b/README.md index 01be9ff..b559beb 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,13 @@ Compiles the generated Compact code and writes the artifacts into `generated/man pnpm test ``` -Runs the test suite with Vitest (compact code must be generated and compiled before running the tests). +It generates the code, compiles it and runs the test suite with Vitest. The default command uses `all-5` from the [examples/inputs](./examples/inputs/) as the input to generate the code. + +To use another example, the command must be run like: + +```bash +TEST_INPUT=examples/inputs/ pnpm test +``` ## Documentation diff --git a/__tests__/generator/commit.test.ts b/__tests__/generator/commit.test.ts new file mode 100644 index 0000000..7ea231b --- /dev/null +++ b/__tests__/generator/commit.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; + +import { NativeScriptSchema, collectCmtLeaves } from '../../src/index.js'; +import { WardenSimulator } from './warden-simulator.js'; + +const input = NativeScriptSchema.parse(JSON.parse(readFileSync(process.env.TEST_INPUT!, 'utf-8'))); +const cmtLeaves = collectCmtLeaves(input); +const cmtHashes = cmtLeaves.flatMap((leaf) => leaf.hashes); +const secretPairs = JSON.parse(readFileSync('examples/example-pairs.json', 'utf-8')); + +const cmtHexSet = new Set(cmtHashes.map((h) => Buffer.from(h).toString('hex'))); +const matchingPairs = secretPairs.filter((p) => cmtHexSet.has(p.commitment)); + +describe('Commit circuit', () => { + it('prevents double commit of the same pair', () => { + const { secret, randomness } = matchingPairs[0]; + const secretBytes = new Uint8Array(Buffer.from(secret, 'hex')); + const randomnessBytes = new Uint8Array(Buffer.from(randomness, 'hex')); + + const sim = new WardenSimulator(secretBytes, randomnessBytes); + sim.init(); + sim.commit(); + expect(() => sim.commit()).toThrow('already been registered'); + }); + + it('rejects unauthorized secret', () => { + const sim = new WardenSimulator(); + sim.init(); + expect(() => sim.commit()).toThrow('not authorized to commit'); + }); + + it('rejects commit on uninitialized program', () => { + const sim = new WardenSimulator(); + expect(() => sim.commit()).toThrow('Cannot commit to uninitialized contract'); + }); + + it('inserts each commitment only into its authorized paths', () => { + const allPaths = [...new Set(cmtLeaves.map((l) => l.path))]; + for (const { secret, randomness, commitment } of matchingPairs) { + const authorizedLeaves = cmtLeaves.filter((l) => + l.hashes.some((h) => Buffer.from(h).toString('hex') === commitment) + ); + const authorizedPaths = authorizedLeaves.map((l) => l.path); + const otherPaths = allPaths.filter((p) => !authorizedPaths.includes(p)); + const secretBytes = new Uint8Array(Buffer.from(secret, 'hex')); + const randomnessBytes = new Uint8Array(Buffer.from(randomness, 'hex')); + const commitmentBytes = new Uint8Array(Buffer.from(commitment, 'hex')); + const sim = new WardenSimulator(secretBytes, randomnessBytes); + sim.init(); + const ledger = sim.commit(); + for (const path of authorizedPaths) { + const pathBytes = new Uint8Array(Buffer.from(path.padEnd(8))); + expect(ledger.idsToCommitments.lookup(pathBytes).member(commitmentBytes)).toBeTruthy(); + } + for (const path of otherPaths) { + const pathBytes = new Uint8Array(Buffer.from(path.padEnd(8))); + expect(ledger.idsToCommitments.lookup(pathBytes).member(commitmentBytes)).toBeFalsy(); + } + } + }); +}); diff --git a/__tests__/generator/warden-simulator.ts b/__tests__/generator/warden-simulator.ts index ff4db21..891b300 100644 --- a/__tests__/generator/warden-simulator.ts +++ b/__tests__/generator/warden-simulator.ts @@ -34,6 +34,11 @@ export class WardenSimulator { return ledger(this.circuitContext.currentQueryContext.state); } + commit(): Ledger { + this.circuitContext = this.contract.impureCircuits.commit(this.circuitContext).context; + return ledger(this.circuitContext.currentQueryContext.state); + } + getLedger(): Ledger { return ledger(this.circuitContext.currentQueryContext.state); } diff --git a/docs/design.md b/docs/design.md index da619dd..1bd27cd 100644 --- a/docs/design.md +++ b/docs/design.md @@ -40,7 +40,11 @@ This circuit initializes the ledger for the module. For each commitment hash, `c #### `commit circuit` -This circuit adds a users commitment to the contract's ledger. This aims to mimic the behavior of a multisignature script in which each wallet adds their signature to a transaction. The circuit checks a provided commitment (obtained via a witness or a builtin function) against the ledger's `commitmentsToIds`: if it belongs, it adds the commitment to the respective sets in `idsToCommitments`, if it doesn't, the commitment wasn't authorized and nothing is added. +This circuit adds a users commitment to the contract's ledger. This aims to mimic the behavior of a multisignature script in which each wallet adds their signature to a transaction. The circuit checks a commitment against the ledger's `commitmentsToIds`: if it belongs, it adds the commitment to the respective sets in `idsToCommitments`, if it doesn't, the commitment wasn't authorized and nothing is added. + +##### `getCommitment circuit` + +This circuit generates a commitment based on a given secret and randomness. It is used by the `commit` circuit to create the commitment that will be stored on the ledger. #### `verify circuit` diff --git a/examples/test-input.json b/examples/test-input.json deleted file mode 100644 index b6728a3..0000000 --- a/examples/test-input.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "scripts": [ - { "hash": "a1b2c3d4e5f6789012345678abcdef0123456789abcdef0123456789abcdef01", "type": "cmt" }, - { "hash": "b2c3d4e5f6789012345678abcdef0123456789abcdef0123456789abcdef2345", "type": "cmt" } - ], - "type": "any" -} diff --git a/package.json b/package.json index 3f58ee4..6176e85 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "format:check": "prettier --check \"**/*.{ts,js,json,md,yaml,yml}\"", "lint": "eslint .", "lint:fix": "eslint . --fix", - "pretest": "tsx src/cli.ts generate-code -i \"${TEST_INPUT:-examples/test-input.json}\" && pnpm compact", + "pretest": "tsx src/cli.ts generate-code -i \"${TEST_INPUT:-examples/inputs/all-5.json}\" && pnpm compact", "test": "vitest run", "test:watch": "vitest" }, diff --git a/src/generator/commit.ts b/src/generator/commit.ts new file mode 100644 index 0000000..bef837f --- /dev/null +++ b/src/generator/commit.ts @@ -0,0 +1,24 @@ +import { CmtLeaf, formatBytes, collectUniquePaths } from './utils.js'; + +/** + * Generates the body of the Compact `commit()` circuit. + * + * For each unique path in the commitment tree, emits an assertion that the + * commitment hasn't already been registered at that path, followed by a + * conditional insert if the commitment hash is authorized. + * + * @param leaves - Collection of commitment leaves from the script input + * @returns Compact source code for the commit circuit body + */ +export function commitCircuitBody(leaves: CmtLeaf[]): string { + const uniquePaths = collectUniquePaths(leaves); + + return Array.from(uniquePaths) + .map((path) => { + const pathPayload = formatBytes(path.padEnd(8)); + return `assert(!idsToCommitments.lookup(${pathPayload}).member(commitment), "This commitment has already been registered"); +if (commitmentsToIds.lookup(commitment).member(${pathPayload})) { idsToCommitments.lookup(${pathPayload}).insert(commitment); } +`; + }) + .join(''); +} diff --git a/src/generator/index.ts b/src/generator/index.ts index f709517..aadcbe3 100644 --- a/src/generator/index.ts +++ b/src/generator/index.ts @@ -1,4 +1,5 @@ import type { NativeScriptSchema } from '../index.js'; +import { commitCircuitBody } from './commit.js'; import { initCircuitBody } from './init.js'; import { collectCmtLeaves } from './utils.js'; @@ -12,7 +13,7 @@ export function generateCompact( ): string { const cmtLeaves = collectCmtLeaves(script); const initBody = initCircuitBody(cmtLeaves); - const commitBody = ''; + const commitBody = commitCircuitBody(cmtLeaves); const verifyBody = ''; const formatCircuit = (body: string) => { @@ -77,7 +78,11 @@ module Warden { * @description Add a commitment if it is authorized (member of * commitmentsToIds). */ - export circuit commit(): [] {${formatCircuit(commitBody)} + export circuit commit(): [] { + assert(!commitmentsToIds.isEmpty(), "Cannot commit to uninitialized contract"); + const commitment = getCommitment(localSecret(), randomness()); + assert(commitmentsToIds.member(commitment), "This key is not authorized to commit in this contract"); + assert(!commitmentsToIds.lookup(commitment).isEmpty(), "Commitment ID set is empty");${formatCircuit(commitBody)} } /** diff --git a/src/generator/init.ts b/src/generator/init.ts index 7489fc3..8353ccb 100644 --- a/src/generator/init.ts +++ b/src/generator/init.ts @@ -1,5 +1,15 @@ -import { CmtLeaf } from './utils.js'; +import { CmtLeaf, formatBytes, collectUniquePaths } from './utils.js'; +/** + * Generates the body of the Compact `init()` circuit. + * + * Populates the `commitmentsToIds` and `idsToCommitments` ledger maps with the + * initial state derived from the script input. Each commitment hash is mapped to + * its authorized path IDs, and each path ID gets an empty set ready for commit. + * + * @param leaves - Collection of commitment leaves from the script input + * @returns Compact source code for the init circuit body + */ export function initCircuitBody(leaves: CmtLeaf[]): string { const cmtToIds = leaves .flatMap((leaf) => @@ -10,36 +20,35 @@ export function initCircuitBody(leaves: CmtLeaf[]): string { ) .reduce((map, { key, path }) => { const existing = map.get(key); - const path_bytes = Buffer.from(path.padEnd(8)).toJSON().data.join(', '); + const formatted = formatBytes(path.padEnd(8)); if (existing) { - existing.push(`Bytes[${path_bytes}]`); + existing.push(formatted); } else { - map.set(key, [`Bytes[${path_bytes}]`]); + map.set(key, [formatted]); } return map; }, new Map()); const assertion = `assert(commitmentsToIds.isEmpty(), "Init circuit has already been called");\n`; - const uniquePaths = [...new Set(leaves.map((leaf) => leaf.path))]; + const uniquePaths = collectUniquePaths(leaves); return ( assertion + Array.from(cmtToIds) .map(([cmt, paths]) => { - const hash_bytes = Buffer.from(cmt, 'hex').toJSON().data.join(', '); + const formattedHash = formatBytes(Buffer.from(cmt, 'hex')); return ( - `commitmentsToIds.insertDefault(Bytes[${hash_bytes}]);\n` + + `commitmentsToIds.insertDefault(${formattedHash});\n` + paths - .map((path) => `commitmentsToIds.lookup(Bytes[${hash_bytes}]).insert(${path});\n`) + .map((path) => `commitmentsToIds.lookup(${formattedHash}).insert(${path});\n`) .join('') ); }) .join('') + Array.from(uniquePaths) .map((path) => { - const pathBytes = Buffer.from(path.padEnd(8)).toJSON().data.join(', '); - return `idsToCommitments.insertDefault(Bytes[${pathBytes}]);\n`; + return `idsToCommitments.insertDefault(${formatBytes(path.padEnd(8))});\n`; }) .join('') ); diff --git a/src/generator/utils.ts b/src/generator/utils.ts index c682ba6..4a0eae7 100644 --- a/src/generator/utils.ts +++ b/src/generator/utils.ts @@ -1,10 +1,75 @@ import { NativeScriptSchema } from '../index.js'; +/** + * Formats a byte array or string as a Compact `Bytes[...]` literal. + * + * @param input - Raw bytes as a `Uint8Array`, or a string whose UTF-8 encoding is used + * @returns Compact byte literal, e.g. `Bytes[72, 101, 108, 108, 111]` + */ +export function formatBytes(input: Uint8Array | string): string { + const buf = typeof input === 'string' ? Buffer.from(input) : Buffer.from(input); + return `Bytes[${buf.toJSON().data.join(', ')}]`; +} + +/** + * Collects the set of unique path strings from the given commitment leaves. + * + * Each leaf has a `.path` property like `"0"` or `"0.1"` identifying its + * position in the script tree. + * + * @param leaves - Commitment leaves to extract paths from + * @returns Unique path strings + */ +export function collectUniquePaths(leaves: CmtLeaf[]): string[] { + return [...new Set(leaves.map((leaf) => leaf.path))]; +} + +/** + * A commitment leaf in the script tree. + * + * @property hashes - Commitment hashes (32 bytes each) at this position + * @property path - Dot-separated tree path, e.g. `"0"` or `"0.1"` + */ export type CmtLeaf = { hashes: Uint8Array[]; path: string; }; +/** + * Walks the script tree and returns the maximum `block` value from all `after` clauses. + * + * Used by tests to determine the required block time so that time-lock + * conditions are satisfied during verification. + * + * @param script - The native script tree + * @returns The maximum after-block value, or `0n` if no after clauses exist + */ +export function collectMaxAfterBlock(script: NativeScriptSchema): bigint { + switch (script.type) { + case 'after': + return BigInt(script.block); + case 'before': + case 'cmt': + return 0n; + case 'any': + case 'all': + case 'atLeast': + return script.scripts.reduce((max: bigint, child) => { + const val = collectMaxAfterBlock(child); + return val > max ? val : max; + }, 0n); + } +} + +/** + * Collects all commitment leaves from the script input. + * + * Walks the tree recursively, aggregating `cmt` nodes into `CmtLeaf` entries + * grouped by their composite parent path. + * + * @param script - The native script input + * @returns Commitment leaves with their tree paths + */ export function collectCmtLeaves(script: NativeScriptSchema): CmtLeaf[] { switch (script.type) { case 'cmt': diff --git a/vitest.config.ts b/vitest.config.ts index a47ab24..6b70e18 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ globals: true, include: ['__tests__/**/*.test.ts'], env: { - TEST_INPUT: process.env.TEST_INPUT || 'examples/test-input.json', + TEST_INPUT: process.env.TEST_INPUT || 'examples/inputs/all-5.json', }, }, });