Skip to content
Merged
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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<example.json> pnpm test
```

## Documentation

Expand Down
62 changes: 62 additions & 0 deletions __tests__/generator/commit.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
});
});
5 changes: 5 additions & 0 deletions __tests__/generator/warden-simulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
6 changes: 5 additions & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
7 changes: 0 additions & 7 deletions examples/test-input.json

This file was deleted.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
24 changes: 24 additions & 0 deletions src/generator/commit.ts
Original file line number Diff line number Diff line change
@@ -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('');
}
9 changes: 7 additions & 2 deletions src/generator/index.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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) => {
Expand Down Expand Up @@ -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)}
}

/**
Expand Down
29 changes: 19 additions & 10 deletions src/generator/init.ts
Original file line number Diff line number Diff line change
@@ -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) =>
Expand All @@ -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<string, string[]>());

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('')
);
Expand Down
65 changes: 65 additions & 0 deletions src/generator/utils.ts
Original file line number Diff line number Diff line change
@@ -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':
Expand Down
2 changes: 1 addition & 1 deletion vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
},
});
Loading