Skip to content
Open
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
47 changes: 13 additions & 34 deletions apps/cli/src/cli/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { communicationCommitmentRandomness, fromHex, toHex } from '@midnight-ntwrk/compact-runtime';
import { normalizeRule, SentinelContract, validateRules } from '@midnight-sentinel/api';
import type { Input } from '@midnight-sentinel/contract';
import { configureProviders } from '@midnight-sentinel/contract/providers';
import {
getBalancesAndAddresses,
Expand All @@ -9,36 +9,7 @@ import {
import type { Interface } from 'readline/promises';
import { type Config } from '../config.js';
import { circuitMenu, contractMenu } from './menus.js';

// TODO: handle other types of inputs
const askForInputs = async (rli: Interface): Promise<Input[]> => {
const inputs = [];
while (true) {
const input: string = await rli.question(
`Enter the input ${inputs.length + 1} (type "done" to finish): `
);
if (input === 'done') break;
inputs.push({
uint: BigInt(input),
boolean: false,
bytes32: new Uint8Array(32),
field: BigInt(0),
});
}

return inputs;
};
const askForRules = async (rli: Interface): Promise<string[]> => {
const rules = [];
while (true) {
const rule: string = await rli.question(
`Enter the rule ${rules.length + 1} (type "done" to finish): `
);
if (rule === 'done') break;
rules.push(rule);
}
return rules;
};
import { askForInputs, askForRules } from './prompts.js';

async function handleCircuits(
contract: SentinelContract,
Expand All @@ -65,7 +36,12 @@ async function handleCircuits(
const rule = await rli.question('Enter the rule to add (JSON): ');
const parsedRule = JSON.parse(rule, normalizeRule);
const validatedRule = validateRules(parsedRule);
const tx = await contract.addRule(validatedRule);

const nonceHex = communicationCommitmentRandomness();
const nonce = fromHex(nonceHex).slice(0, 32);
console.log('Your nonce for this rule is: ', toHex(nonce));

const tx = await contract.addRule(validatedRule, nonce);
console.log(
'Rule ',
SentinelContract.prettyRules(validatedRule),
Expand All @@ -78,8 +54,11 @@ async function handleCircuits(
break;
case '3':
try {
const address = await rli.question('Enter the public key of the rule owner to remove: ');
const tx = await contract.removeRule(address);
const nonce = await rli.question(
'You will remove the rule with your public key and the nonce you provided.\nEnter the nonce: '
);
const nonceBytes = fromHex(nonce);
const tx = await contract.removeRule(nonceBytes);
console.log('Rule removed on tx: ', tx?.public.txHash);
} catch (err) {
console.log(err);
Expand Down
122 changes: 122 additions & 0 deletions apps/cli/src/cli/prompts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import type { Input } from '@midnight-sentinel/contract';
import type { Interface } from 'readline/promises';

export const parseHexBytes32 = (hex: string): Uint8Array => {
const cleanHex = hex.startsWith('0x') ? hex.slice(2) : hex;
if (cleanHex.length !== 64) {
throw new Error('bytes32 must be exactly 32 bytes (64 hex characters)');
}
const bytes = new Uint8Array(32);
for (let i = 0; i < 32; i++) {
bytes[i] = parseInt(cleanHex.slice(i * 2, i * 2 + 2), 16);
}
return bytes;
};

export const askForInputs = async (rli: Interface): Promise<Input[]> => {
const inputs: Input[] = [];

while (true) {
const addMore = await rli.question(
`\nAdd input ${inputs.length + 1}? (press Enter to add, "done" to finish): `
);
if (addMore === 'done') break;

console.log(`--- Input ${inputs.length + 1} ---`);
console.log(
'Enter space-separated values with prefixes: i<uint>, b<boolean>, x<bytes32-hex>, f<field>.'
);
console.log(
'Examples: "i31 btrue", "x0x' + '0'.repeat(64) + '", "f42". Press Enter to use all defaults.'
);

const input: Input = {
uint: BigInt(0),
boolean: false,
bytes32: new Uint8Array(32),
field: BigInt(0),
};

const line = await rli.question(' Values: ');
const tokens = line
.trim()
.split(/\s+/)
.filter((t) => t.length > 0);

for (const token of tokens) {
const prefix = token[0];
const value = token.slice(1);

if (prefix === 'i') {
try {
const parsed = BigInt(value);
if (parsed < 0 || parsed >= BigInt(2) ** BigInt(64)) {
console.error(' Warning: uint out of range, keeping previous value');
} else {
input.uint = parsed;
}
} catch {
console.error(' Warning: invalid uint token, ignoring');
}
} else if (prefix === 'b') {
if (value === 'true') {
input.boolean = true;
} else if (value === 'false') {
input.boolean = false;
} else {
console.error(' Warning: invalid boolean token, expected "btrue" or "bfalse", ignoring');
}
} else if (prefix === 'x') {
try {
input.bytes32 = parseHexBytes32(value);
} catch (err) {
console.error(
` Warning: ${
err instanceof Error ? err.message : 'invalid bytes32 token'
}, keeping previous value`
);
}
} else if (prefix === 'f') {
try {
input.field = BigInt(value);
} catch {
console.error(' Warning: invalid field token, ignoring');
}
} else {
console.error(
` Warning: unknown token prefix "${prefix}" in "${token}", expected one of i/b/x/f`
);
}
}

console.log(
`\nReview Input ${inputs.length + 1}: { uint: ${input.uint}, boolean: ${input.boolean}, bytes32: ${input.bytes32.length} bytes, field: ${input.field} }`
);
const confirm = await rli.question(
'Is this input correct? (y to confirm, anything else to discard and re-enter): '
);

if (confirm.toLowerCase() === 'y' || confirm.toLowerCase() === 'yes') {
inputs.push(input);
console.log(
`Input ${inputs.length} added: { uint: ${input.uint}, boolean: ${input.boolean}, bytes32: ${input.bytes32.length} bytes, field: ${input.field} }`
);
} else {
console.log('Discarding input, please re-enter.');
}
}

return inputs;
};

export const askForRules = async (rli: Interface): Promise<string[]> => {
const rules = [];
while (true) {
const rule: string = await rli.question(
`Enter the rule ${rules.length + 1} (type "done" to finish): `
);
if (rule === 'done') break;
rules.push(rule);
}
return rules;
};
6 changes: 3 additions & 3 deletions apps/ui/src/components/rules.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ export const Rules = ({ rules }: { rules: Ledger['rules'] }) => {

return (
<pre className="p-4 bg-muted rounded-md text-sm font-mono overflow-auto max-h-[300px]">
{[...rules].map(([owner, ownerRules], idx) => (
{[...rules].map(([ruleKey, rule], idx) => (
<div key={idx}>
<div>Owner: {toHex(owner.bytes)}</div>
<div>Rules: {SentinelContract.prettyRules(ownerRules)}</div>
<div>Key: {toHex(ruleKey)}</div>
<div>Rules: {SentinelContract.prettyRules(rule)}</div>
</div>
))}
</pre>
Expand Down
21 changes: 10 additions & 11 deletions packages/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,10 @@ export const toHex = (arr: Uint8Array) =>
.map((b) => b.toString(16).padStart(2, '0'))
.join('');

const zipRulesAndInputs = (
keys: string[],
userInputs: Input[]
): [{ bytes: Uint8Array }, Input][] => {
const zipRulesAndInputs = (keys: Uint8Array[], userInputs: Input[]): [Uint8Array, Input][] => {
if (keys.length !== userInputs.length)
throw new Error('Keys and user inputs must have the same length');
return keys.map((key, index) => [{ bytes: fromHex(key) }, userInputs[index]]);
return keys.map((key, index) => [key, userInputs[index]]);
};

export interface Config {
Expand Down Expand Up @@ -219,19 +216,20 @@ export class SentinelContract {
}

for (const item of rules) {
console.log('Owner: ', toHex(item[0].bytes));
console.log('Key: ', toHex(item[0]));
console.log('Rules: ', SentinelContract.prettyRules(item[1]));
}
});
}

async addRule(rule: SentinelRules) {
async addRule(rule: SentinelRules, nonce: Uint8Array) {
const pubKey = this.providers.walletProvider.getCoinPublicKey();
return await this.deployedContract?.callTx.addRule({ bytes: fromHex(pubKey) }, rule);
return await this.deployedContract?.callTx.addRule({ bytes: fromHex(pubKey) }, nonce, rule);
}

async removeRule(address: string) {
return await this.deployedContract?.callTx.removeRule({ bytes: fromHex(address) });
async removeRule(nonce: Uint8Array) {
const pubKey = this.providers.walletProvider.getCoinPublicKey();
return await this.deployedContract?.callTx.removeRule({ bytes: fromHex(pubKey) }, nonce);
}

async transferAdmin(newAdmin: Uint8Array) {
Expand All @@ -241,7 +239,8 @@ export class SentinelContract {
async mintToken(userInputs: Input[], keys: string[], recipient: UnshieldedAddress) {
const domainSep = new Uint8Array(32).fill(0);
const recipientBytes = { bytes: fromHex(recipient.hexString) };
const rulesAndInputs = zipRulesAndInputs(keys, userInputs);
const bytesKeys = keys.map((key) => fromHex(key));
const rulesAndInputs = zipRulesAndInputs(bytesKeys, userInputs);

return await this.deployedContract?.callTx.mintSpecialToken(
rulesAndInputs,
Expand Down
45 changes: 19 additions & 26 deletions packages/contract/src/sentinel.compact
Original file line number Diff line number Diff line change
Expand Up @@ -55,29 +55,26 @@ type Disjunction<#n, #m> = Vector<m, Conjunction<n>>;
export type Rules = Disjunction<2,2>;

///////// LEDGER /////////
export ledger rules: Map<ZswapCoinPublicKey, Rules>;
export ledger rules: Map<Bytes<32>, Rules>;
export ledger admin: Either<ZswapCoinPublicKey, ContractAddress>;
//export ledger baseNonce: Bytes<32>;
// export ledger mintCount: Counter;

///////// CONSTRUCTOR /////////
constructor(pubKey: ZswapCoinPublicKey) {
const deployAdmin = left<ZswapCoinPublicKey, ContractAddress>(pubKey);
admin = disclose(deployAdmin);
// TODO: research if baseNonce can be safely public
// baseNonce = disclose(initialBaseNonce);
}

///////// EXPORTED CIRCUITS /////////
export circuit addRule(owner: ZswapCoinPublicKey, rule: Rules): [] {
export circuit addRule(owner: ZswapCoinPublicKey, nonce: Bytes<32>, rule: Rules): [] {
const ruleKey = hashCoinPublicKey(owner, nonce);
// TODO: add whitelist
rules.insert(disclose(owner), disclose(rule));
rules.insert(disclose(ruleKey), disclose(rule));
}

export circuit removeRule(owner: ZswapCoinPublicKey): [] {
assert(isRuleOwner(owner) || isAdmin(), "You are not the owner of this rule or admin of this contract");
assert(rules.member(disclose(owner)), "Rule does not exist");
rules.remove(disclose(owner));
export circuit removeRule(publicKey: ZswapCoinPublicKey, nonce: Bytes<32>): [] {
const ruleKey = hashCoinPublicKey(publicKey, nonce);
assert(isRuleOwner(ruleKey) || isAdmin(), "You are not the rule owner, the admin of this contract or the rule does not exist");
rules.remove(disclose(ruleKey));
}

export circuit transferAdmin(newAdmin: ZswapCoinPublicKey): [] {
Expand All @@ -86,36 +83,32 @@ export circuit transferAdmin(newAdmin: ZswapCoinPublicKey): [] {
}

export circuit mintSpecialToken(
rulesAndInputs: Vector<2, [ZswapCoinPublicKey, Input]>,
rulesAndInputs: Vector<2, [Bytes<32>, Input]>,
address: UserAddress,
domainSep: Bytes<32>
): [] {
assert(fold((acc, [rule, input]) => acc && satisfies(rule, input), true, disclose(rulesAndInputs)), "Rules not satisfied");

const amount: Uint<64> = 1;

//TODO: shielded mints don't work on latest version
//const recipientPk = ownPublicKey();
// TODO: use coin base nonce here
//const nonce = evolveNonce(mintCount, baseNonce);
///const recipient = left<ZswapCoinPublicKey, ContractAddress>(recipientPk);
//const _coin = mintShieldedToken(disclose(input.bytes32), amount, nonce, recipient);

const recipient = right<ContractAddress, UserAddress>(address);
const _coin = mintUnshieldedToken(disclose(domainSep), amount, disclose(recipient));
}

///////// HELPER CIRCUITS /////////
circuit isAdmin(): Boolean {
return ownPublicKey() == disclose(admin.left);
circuit hashCoinPublicKey(cpk: ZswapCoinPublicKey, nonce: Bytes<32>): Bytes<32> {
return persistentCommit<Bytes<32>>(cpk.bytes, nonce);
}

circuit isRuleOwner(owner: ZswapCoinPublicKey): Boolean {
return ownPublicKey() == disclose(owner);
circuit isRuleOwner(ruleKey: Bytes<32>): Boolean {
return rules.member(disclose(ruleKey));
}

circuit isAdmin(): Boolean {
return ownPublicKey() == disclose(admin.left);
}

circuit satisfies(ruleOwner: ZswapCoinPublicKey, input: Input): Boolean {
return fold((acc, or) => acc || satOr(or, input), false, rules.lookup(ruleOwner));
circuit satisfies(ruleKey: Bytes<32>, input: Input): Boolean {
return fold((acc, or) => acc || satOr(or, input), false, rules.lookup(ruleKey));
}

circuit checkNumberProp(rule: NumberProp, input: Input): Boolean {
Expand Down