diff --git a/apps/cli/src/cli/index.ts b/apps/cli/src/cli/index.ts index 29a7541..1767e35 100644 --- a/apps/cli/src/cli/index.ts +++ b/apps/cli/src/cli/index.ts @@ -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, @@ -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 => { - 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 => { - 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, @@ -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), @@ -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); diff --git a/apps/cli/src/cli/prompts.ts b/apps/cli/src/cli/prompts.ts new file mode 100644 index 0000000..fb542fc --- /dev/null +++ b/apps/cli/src/cli/prompts.ts @@ -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 => { + 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, b, x, f.' + ); + 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 => { + 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; +}; diff --git a/apps/ui/src/components/rules.tsx b/apps/ui/src/components/rules.tsx index df6240d..582b77d 100644 --- a/apps/ui/src/components/rules.tsx +++ b/apps/ui/src/components/rules.tsx @@ -12,10 +12,10 @@ export const Rules = ({ rules }: { rules: Ledger['rules'] }) => { return (
-      {[...rules].map(([owner, ownerRules], idx) => (
+      {[...rules].map(([ruleKey, rule], idx) => (
         
-
Owner: {toHex(owner.bytes)}
-
Rules: {SentinelContract.prettyRules(ownerRules)}
+
Key: {toHex(ruleKey)}
+
Rules: {SentinelContract.prettyRules(rule)}
))}
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index f8c306d..2e38c80 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -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 { @@ -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) { @@ -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, diff --git a/packages/contract/src/sentinel.compact b/packages/contract/src/sentinel.compact index ca08f5e..956f5f5 100644 --- a/packages/contract/src/sentinel.compact +++ b/packages/contract/src/sentinel.compact @@ -55,29 +55,26 @@ type Disjunction<#n, #m> = Vector>; export type Rules = Disjunction<2,2>; ///////// LEDGER ///////// -export ledger rules: Map; +export ledger rules: Map, Rules>; export ledger admin: Either; -//export ledger baseNonce: Bytes<32>; -// export ledger mintCount: Counter; ///////// CONSTRUCTOR ///////// constructor(pubKey: ZswapCoinPublicKey) { const deployAdmin = left(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): [] { @@ -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(recipientPk); - //const _coin = mintShieldedToken(disclose(input.bytes32), amount, nonce, recipient); - const recipient = right(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>(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 {