diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 7021a83d29..3a2a252893 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -40,6 +40,7 @@ - **Worktree visibility:** Always tell the user which worktree (full path) you will work in as part of the plan. When finished, state where the changes live (worktree path and branch name). - **Commit authorship:** Always commit as Claude, not as the user. Use: `git -c user.name="Claude (Opus)" -c user.email="noreply@anthropic.com" -c commit.gpgsign=false commit -m "message"` - **Commit frequency:** Always commit at the end of each task. Avoid single commits that span multiple unrelated changes. +- **Responding to PR review:** When addressing review feedback on a pushed PR, add new commits on top of the branch (e.g. `fix: address review comments`). NEVER amend, squash, or otherwise rewrite already-pushed commits to incorporate review changes, and NEVER force-push the branch to do so - this destroys the diff reviewers rely on to see what changed since their review. The only time a force-push is acceptable is when the branch must be rebased onto an updated base (or a PR lower in a stack changed); that is a base update, not a review response, and should be called out explicitly. ## Output Formatting diff --git a/.claude/README.md b/.claude/README.md new file mode 100644 index 0000000000..6488819e5e --- /dev/null +++ b/.claude/README.md @@ -0,0 +1,56 @@ +# Claude Code in this repo + +The `/work` command, lifecycle hooks, review agents, skills, and settings are +all committed here under `.claude/`, so they come with the clone - no Claude +config to copy in. The hooks need the usual toolchain on the host: Python 3, +the `claude` and `gh` CLIs (authenticated), and `make` / the Rust toolchain. + +## The `/work` flow + +1. **Clone the repo.** The committed `.claude/` wires up hooks, skills, + agents, and the command automatically. +2. **Start a session** in bypass-permissions mode so Claude isn't stopped for + per-action approvals: + + ```bash + claude --permission-mode bypassPermissions + ``` + + Recommended: add `--worktree` so the session runs in its own git worktree + (parallel agents don't collide) and `--tmux` so it runs inside tmux (the run + survives disconnects on a remote/cloud host; `--tmux` requires `--worktree`). + For example: + + ```bash + claude --permission-mode bypassPermissions --worktree issue-1234 --tmux + ``` +3. **Run `/work `** and plan it out together. The command starts + in plan mode and writes no code until you approve. Base defaults to `next` + (`--base ` to override). +4. **Let it work.** Claude implements the plan and opens a **draft** PR when + it's ready. +5. **Review the PR on GitHub.** If changes are needed, tell Claude to apply + them. When the feedback reflects a reusable convention, also have Claude + codify it as a skill or hook under `.claude/` - ideally as a separate PR for + the Claude setup, kept apart from the feature work. + +## Guardrails + +Hooks in `settings.json` enforce quality at the commit, push, and PR boundaries: + +- `pre_commit_lint` - runs `make lint` before any commit. +- `pre_push_test` - runs `make test` before any push. +- `pre_push_review` - runs the code-reviewer and security-reviewer agents + before any push; blocks on Critical/Important/Warning findings. +- `pre_pr_draft` - every PR must be created with `--draft`; a human promotes + it to ready-for-review. +- `post_pr_create_changelog` - classifies the diff and either requires a + CHANGELOG entry or applies the `no changelog` label. + +## Contents + +- `commands/work.md` - the `/work` command. +- `hooks/` - the lifecycle hooks above (plus their tests). +- `agents/` - code-reviewer, security-reviewer, changelog-manager. +- `skills/` - Miden Assembly (`.masm`) authoring conventions. +- `settings.json` - wires the hooks to tool events. diff --git a/.claude/agents/changelog-manager.md b/.claude/agents/changelog-manager.md index 83ebebd209..58e7f9f9ed 100644 --- a/.claude/agents/changelog-manager.md +++ b/.claude/agents/changelog-manager.md @@ -20,19 +20,20 @@ You receive a prompt like: `Check changelog for PR #N (URL)` ``` gh pr view --json labels --jq '.labels[].name' ``` -2. Check if CHANGELOG.md is already modified in the diff: +2. Check if the PR already modifies CHANGELOG.md: ``` - git diff origin/next...HEAD -- CHANGELOG.md + gh pr diff --name-only | grep -qx CHANGELOG.md ``` If either condition is met, output `SKIP: already handled` and stop. ## Step 2: Analyze the Diff -Run: +Classify the PR's own diff (not a local `git diff`): ``` -git diff origin/next...HEAD -- ':(exclude)CHANGELOG.md' +gh pr diff ``` +Disregard changes to CHANGELOG.md itself. ## Step 3: Classify diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md index f912df21d0..0d56fa2c74 100644 --- a/.claude/agents/code-reviewer.md +++ b/.claude/agents/code-reviewer.md @@ -1,6 +1,6 @@ --- name: code-reviewer -description: Staff engineer code reviewer evaluating changes across correctness, readability, architecture, API design, and performance. Spawned automatically before push. +description: Staff engineer code reviewer evaluating changes across correctness, readability, architecture, API design, and performance. Spawned automatically after each commit and before PR creation. model: opus effort: max tools: Read, Grep, Glob, Bash @@ -13,11 +13,15 @@ You are an experienced Staff Engineer conducting a thorough code review with fre ## Step 1: Gather Context -Run `git diff @{upstream}...HEAD`. If no upstream is set, resolve the default -branch with `gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name'` -and run `git diff origin/...HEAD`. +Your prompt names the diff range under review (e.g. `HEAD~1..HEAD` for a +single commit, or `..HEAD` for a whole PR). See exactly what +changed: -For every file in the diff, read the **full file** - not just the changed lines. Bugs hide in how new code interacts with existing code. +``` +git diff +``` + +Don't review the diff in isolation. Read enough surrounding context to see how the change interacts with existing code - the rest of the file where relevant, plus its callers and callees. Bugs hide in those interactions. Confirm every finding against the current code before reporting it - never raise an issue the code already addresses. ## Step 2: Review Tests First @@ -74,6 +78,15 @@ Categorize every finding: **Nit** - Worth improving (naming, style, minor readability, optional optimization) +### Documented, intentional incompleteness + +A change may deliberately ship incomplete behavior as one stage of a larger, planned effort (a skeleton, placeholder, or stub). When the incompleteness is **all** of: +- explicitly documented in the code (a doc comment or module note stating what is not yet implemented), +- clearly scoped and warned about (the docs say what not to rely on and reference the follow-up work), and +- not wired into any path that depends on the missing behavior being correct, + +then the incompleteness itself is NOT a Critical or Important finding - the change is complete for what it claims to be. Treat it as a Nit at most, or acknowledge the clear documentation under "What's Done Well". Escalate only when the documentation is missing, inaccurate, or misleading, or when the incomplete code is actually relied upon as if it were complete. Distinguish "incomplete but correct, documented, and self-contained" from "broken or silently incomplete". + ## Output Format ``` @@ -105,8 +118,9 @@ Categorize every finding: 5. If uncertain about something, say so and suggest investigation rather than guessing 6. Be direct. "This will panic when the vec is empty" not "this might possibly be a concern" 7. New code without tests is always a finding +8. Respect the user's intent. Your prompt may name what the user asked for this session - treat deliberate, explicitly-requested choices as intended, not mistakes, and don't recommend reversing them. Intent does not excuse a real defect: a genuine correctness bug or exploitable risk stays Critical or Important even when requested. Downgrade to a Nit only when your objection is stylistic or defensive-programming preference, not a real defect. -**All findings (Critical, Important, and Nit) block the merge.** Every issue must be addressed before pushing. +**Critical and Important findings block the merge; Nits are surfaced but do not block.** Address the blocking findings before pushing. -If you find any issues at any severity level, start your final response with `BLOCK:` followed by the review. -If there are zero findings, start your final response with `APPROVE:` followed by the review. +If you find any Critical or Important issues, start your final response with `BLOCK:` followed by the review. +If there are none (only Nits, or nothing), start your final response with `APPROVE:` followed by the review. diff --git a/.claude/agents/security-reviewer.md b/.claude/agents/security-reviewer.md index cd1b4a0cbd..bc003e7661 100644 --- a/.claude/agents/security-reviewer.md +++ b/.claude/agents/security-reviewer.md @@ -1,6 +1,6 @@ --- name: security-reviewer -description: Adversarial security reviewer that tries to break code through two hostile personas - Adversary and Auditor. Spawned automatically before push. +description: Adversarial security reviewer that tries to break code through two hostile personas - Adversary and Auditor. Spawned automatically after each commit and before PR creation. model: opus effort: max tools: Read, Grep, Glob, Bash @@ -13,11 +13,15 @@ You are a hostile reviewer. Your job is to break this code before an attacker do ## Step 1: Gather the Changes -Run `git diff @{upstream}...HEAD`. If no upstream is set, resolve the default -branch with `gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name'` -and run `git diff origin/...HEAD`. +Your prompt names the diff range under review (e.g. `HEAD~1..HEAD` for a +single commit, or `..HEAD` for a whole PR). See exactly what +changed: -For every file in the diff, read the **full file**. Vulnerabilities hide in how new code interacts with existing code, not just in the diff itself. +``` +git diff +``` + +Don't review the diff in isolation. Read enough surrounding context to see how the change interacts with existing code - the rest of the file where relevant, plus its callers and callees. Vulnerabilities hide in those interactions. Confirm every finding against the current code before reporting it - never raise an issue the code already addresses. ## Step 2: Run Both Personas @@ -79,6 +83,15 @@ After both personas report: **NOTE** - Minor improvement opportunity or fragile assumption worth documenting. +### Documented, intentional incompleteness + +Some changes deliberately ship a security-relevant placeholder as one stage of planned work (e.g., a verifier that does not yet bind certain data, a check that is stubbed). When such a limitation is **all** of: +- explicitly documented in the code (a doc comment or module note stating exactly what is not yet enforced), +- accompanied by a clear warning against misuse (e.g., "must not be relied on at a trust boundary") and a reference to the follow-up that will close it, and +- not actually reachable from a trust boundary in this change (no caller relies on the missing guarantee), + +then classify it as a NOTE, not CRITICAL or WARNING. Surfacing it keeps it visible without blocking a correctly-staged change. The finding is the ABSENCE or INADEQUACY of that documentation, or the incomplete code being wired into a real trust boundary - not the incompleteness itself. If the limitation is undocumented, the warning is missing or misleading, or a caller already depends on the unenforced guarantee, keep the CRITICAL/WARNING severity. + ## Output Format ``` @@ -99,11 +112,11 @@ After both personas report: [2-3 sentences: overall risk profile and the single most important thing to fix] ``` -**All findings (Critical, Warning, and Note) block the merge.** Every issue must be addressed before pushing. +**Critical and Warning findings block the merge; Notes are surfaced but do not block.** Address the blocking findings before pushing. **Verdicts:** -- **BLOCK** - Any findings at any severity level. Do not merge until addressed. -- **CLEAN** - Zero findings. Safe to merge. +- **BLOCK** - Any Critical or Warning finding. Do not merge until addressed. +- **CLEAN** - No Critical or Warning findings (Notes, if any, are surfaced but do not block). Safe to merge. ## Anti-Patterns - Do NOT Do These @@ -111,7 +124,8 @@ After both personas report: - **Pulling punches** - "This might possibly be a minor concern" is useless. Say what's wrong. - **Restating the diff** - "This function was added" is not a finding. What's WRONG with it? - **Cosmetic-only findings** - Reporting style issues while missing a panic is worse than no review. -- **Reviewing only changed lines** - Read the full file. The bug is in the interaction. +- **Reviewing only changed lines** - Read the surrounding context (callers, callees, the rest of the file where relevant). The bug is in the interaction. +- **Contradicting user intent** - Your prompt may name what the user asked for. Treat deliberate, explicitly-requested choices as intended; don't push to reverse them. But intent does not downgrade real risk: if a requested choice is genuinely exploitable, keep it Critical or Warning so it blocks. Drop it to a Note only when your objection is defensive-programming hardening or a non-exploitable weakness. ## Breaking the Self-Review Trap @@ -122,5 +136,5 @@ You may share the same mental model as the code's author. To break this: 4. Assume every external call will fail 5. Ask: "If I deleted this change entirely, what would break?" If nothing, the change might be unnecessary. -If you find any findings at any severity level, start your final response with `BLOCK:` followed by the review. -If there are zero findings, start with `CLEAN:` followed by the review. +If you find any Critical or Warning findings, start your final response with `BLOCK:` followed by the review. +If there are none (only Notes, or nothing), start with `CLEAN:` followed by the review. diff --git a/.claude/commands/work.md b/.claude/commands/work.md new file mode 100644 index 0000000000..ad1ce1e107 --- /dev/null +++ b/.claude/commands/work.md @@ -0,0 +1,14 @@ +--- +description: Plan and implement a GitHub issue, then open a draft PR (base defaults to next) +argument-hint: [--base ] +allowed-tools: Bash, Read, Edit, Write, Grep, Glob, Task +--- + +Work on a GitHub issue and open a **draft** PR. + +`$ARGUMENTS`: an issue number, plus optional `--base ` (defaults to `next`). + +1. Read the issue: `gh issue view `. +2. **Start in plan mode.** Produce an implementation plan and wait for approval before writing any code. +3. After approval: implement per `.claude/CLAUDE.md` (worktree, branch, commit conventions). +4. Open the draft PR against the base branch: `gh pr create --draft --base --body "Closes # ..."`. Report the PR URL. diff --git a/.claude/hooks/_hookutils.py b/.claude/hooks/_hookutils.py index 0ee1fb50f8..5f75c30a0d 100644 --- a/.claude/hooks/_hookutils.py +++ b/.claude/hooks/_hookutils.py @@ -10,11 +10,16 @@ from __future__ import annotations import json +import re import subprocess import sys from pathlib import Path from typing import Any +# Strips harness-injected blocks from user message text so +# only what the human actually typed is surfaced to the reviewers. +_SYSTEM_REMINDER = re.compile(r".*?", re.DOTALL) + def repo_root() -> Path | None: """Return the absolute path of the current git worktree's top @@ -83,3 +88,73 @@ def read_command(stdin: Any = None) -> str | None: input (so the hook can fail open with `sys.exit(0)`). """ return command_from_payload(read_payload(stdin)) + + +def recent_user_prompts( + transcript_path: Any, + max_messages: int = 12, + max_chars: int = 3000, +) -> str | None: + """Return the human-typed prompts from a session transcript as a + bulleted, most-recent-first string, or None if unavailable/empty. + + Reads the JSONL transcript named by a hook payload's `transcript_path`, + keeps only genuine user turns (dropping tool results, tool calls, and + harness-injected `` content), and caps the output so the + reviewers get the user's intent without the whole conversation. + """ + if not isinstance(transcript_path, str) or not transcript_path: + return None + try: + lines = Path(transcript_path).read_text().splitlines() + except OSError: + return None + + prompts: list[str] = [] + for line in lines: + try: + entry = json.loads(line) + except (json.JSONDecodeError, ValueError): + continue + if not isinstance(entry, dict) or entry.get("type") != "user" or entry.get("isMeta"): + continue + message = entry.get("message") + if not isinstance(message, dict): + continue + text = _user_text(message.get("content")) + if text: + prompts.append(text) + if not prompts: + return None + + out: list[str] = [] + total = 0 + for prompt in reversed(prompts): # most recent first + if len(prompt) > 500: + prompt = prompt[:500].rstrip() + "..." + bullet = f"- {prompt}" + if out and total + len(bullet) > max_chars: + break + out.append(bullet) + total += len(bullet) + if len(out) >= max_messages: + break + return "\n".join(out) + + +def _user_text(content: Any) -> str | None: + """Extract human text from a user message's `content`, dropping + tool_result/tool_use blocks and `` noise. Returns None + if nothing human-authored remains.""" + if isinstance(content, str): + parts = [content] + elif isinstance(content, list): + parts = [ + block["text"] + for block in content + if isinstance(block, dict) and block.get("type") == "text" and isinstance(block.get("text"), str) + ] + else: + return None + cleaned = _SYSTEM_REMINDER.sub("", "\n".join(parts)).strip() + return cleaned or None diff --git a/.claude/hooks/_review.py b/.claude/hooks/_review.py new file mode 100644 index 0000000000..1003136fa9 --- /dev/null +++ b/.claude/hooks/_review.py @@ -0,0 +1,170 @@ +"""Shared reviewer orchestration for the review hooks. + +`run_review(diff_range)` spawns the `code-reviewer` + `security-reviewer` +agents in parallel over a given git diff range and decides whether the +change is blocked. Callers (`post_commit_review`, `pre_pr_review`) supply +the range and emit the block in their own hook protocol — this module only +runs the reviewers, renders their output, and counts blocking findings. + +Severity policy (single source of truth, not the agent prompts): + BLOCK on ### Critical Issues | ### Critical Findings + ### Important Issues | ### Warnings + IGNORE ### Nits | ### Notes | ### What's Done Well | ### Summary +""" + +from __future__ import annotations + +import concurrent.futures +import re +import subprocess +from dataclasses import dataclass + +_ALLOWED_TOOLS = "Bash(git:*) Read Grep Glob" + +# Recognized blocking-section headings (case-sensitive). +_BLOCKING_HEADINGS = re.compile(r"^### (Critical|Important|Warnings)(\s|$)") +# Any `### ` heading ends a previous section. +_ANY_THIRD_LEVEL = re.compile(r"^### ") +# Any second-level heading also ends a section. +_SECOND_LEVEL = re.compile(r"^##[^#]|^## ") +# A bullet line `-` or `*` followed by content. +_BULLET = re.compile(r"^\s*[-*]\s+\S") +# Absence markers we explicitly do NOT count as findings. +_ABSENCE = re.compile(r"^\s*[-*]\s+(None|N/A|n/a)\.?\s*$") + + +@dataclass +class ReviewerResult: + name: str + returncode: int + stdout: str + stderr: str + + +def run_review( + diff_range: str, + cwd: str | None = None, + intent: str | None = None, +) -> tuple[bool, str]: + """Run both reviewers in parallel over `diff_range`. + + `intent`, when given, is the session's user prompts — passed so the + reviewers respect what the user explicitly asked for instead of + second-guessing deliberate choices. + + Returns `(blocked, rendered)` where `blocked` is True if either reviewer + reported a Critical/Important/Warning finding, crashed, or produced + malformed output, and `rendered` is the human-readable report the caller + should surface (per-reviewer output plus a one-line verdict each). + """ + prompt = ( + f"Review the changes in diff range `{diff_range}`. " + f"Run `git diff {diff_range}` to see exactly what is under review." + ) + if intent: + prompt += ( + "\n\n## User intent (this session, most recent first)\n" + f"{intent}\n\n" + "Respect this intent: treat deliberate, explicitly-requested choices as " + "intended, not mistakes, and don't recommend reversing them. Intent never " + "excuses a real defect - apply your severity rules to any genuine risk a " + "requested approach introduces." + ) + + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + futures = { + pool.submit(_run_reviewer, "code-reviewer", prompt, cwd): "CODE REVIEWER", + pool.submit(_run_reviewer, "security-reviewer", prompt, cwd): "SECURITY REVIEWER", + } + results: list[ReviewerResult] = [] + for fut, name in futures.items(): + try: + rc, stdout, stderr = fut.result() + except Exception as exc: # noqa: BLE001 + results.append(ReviewerResult(name, returncode=1, stdout="", stderr=str(exc))) + continue + results.append(ReviewerResult(name, returncode=rc, stdout=stdout, stderr=stderr)) + + blocked = False + chunks: list[str] = [] + for result in results: + cleared, rendered = _evaluate_reviewer(result) + chunks.append(rendered) + if not cleared: + blocked = True + return blocked, "\n".join(chunks) + + +def _run_reviewer(agent: str, prompt: str, cwd: str | None) -> tuple[int, str, str]: + result = subprocess.run( + [ + "claude", + "--agent", + agent, + "--allowedTools", + _ALLOWED_TOOLS, + "-p", + prompt, + ], + capture_output=True, + text=True, + cwd=cwd, + ) + return result.returncode, result.stdout, result.stderr + + +def _evaluate_reviewer(result: ReviewerResult) -> tuple[bool, str]: + """Return `(cleared, rendered)` for one reviewer. `cleared` is False if + this reviewer blocks (crash, malformed output, or a blocking finding).""" + lines = [f"=== {result.name} ==="] + + if result.returncode != 0: + lines.append(f"{result.name}: agent exited with status {result.returncode}; treating as block.") + if result.stdout: + lines.append(result.stdout) + if result.stderr: + lines.append(f"--- agent stderr ---\n{result.stderr}") + return False, "\n".join(lines) + + if not _looks_like_review(result.stdout): + lines.append(f"{result.name}: empty or malformed output; treating as block.") + if result.stdout: + lines.append(result.stdout) + return False, "\n".join(lines) + + lines.append(result.stdout) + count = _count_blocking_findings(result.stdout) + if count > 0: + lines.append(f"{result.name}: {count} blocking finding(s) (Critical/Important/Warning).") + return False, "\n".join(lines) + lines.append(f"{result.name}: no blocking findings (nits/notes do not block).") + return True, "\n".join(lines) + + +def _looks_like_review(text: str) -> bool: + return bool(text.strip()) and any(line.startswith("### ") for line in text.splitlines()) + + +def _count_blocking_findings(text: str) -> int: + """Walk the reviewer's markdown line by line. Count bullets that appear + under `### Critical Issues / ### Important Issues / ### Warnings` + headings, treating any other `### ` heading or `##` heading as the end of + the current section. Bullets matching `- None.` / `- N/A` are explicitly + skipped — those are absence markers, not findings. + """ + count = 0 + in_block = False + for line in text.splitlines(): + if _SECOND_LEVEL.match(line): + in_block = False + continue + if _ANY_THIRD_LEVEL.match(line): + in_block = bool(_BLOCKING_HEADINGS.match(line)) + continue + if not in_block: + continue + if _ABSENCE.match(line): + continue + if _BULLET.match(line): + count += 1 + return count diff --git a/.claude/hooks/post_commit_review.py b/.claude/hooks/post_commit_review.py new file mode 100644 index 0000000000..c5b133d440 --- /dev/null +++ b/.claude/hooks/post_commit_review.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Post-commit hook: reviews the commit that was just created. + +Runs after `git commit` (including `-a`, `--amend`, rebase, cherry-pick) +and reviews the commit's own delta, `HEAD~1..HEAD` — the diff is always +exactly the new commit's content regardless of how it was produced, so no +base-selection logic is needed. Spawns code-reviewer + security-reviewer +via `_review.run_review`. + +PostToolUse cannot un-make the commit, so on a blocking finding it exits 2: +Claude Code feeds the findings back to the agent and halts progress. The +agent fixes and `git commit --amend`s, which re-fires this hook on the +amended commit until it's clean. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from _classify import matches # noqa: E402 +from _hookutils import command_from_payload, read_payload, recent_user_prompts # noqa: E402 +from _review import run_review # noqa: E402 + +TARGET = ("git", ["commit"]) + +# Substrings in the commit's output that mean no commit was created. We +# review only when the commit actually happened — otherwise HEAD~1..HEAD +# would re-review the previous (unrelated) commit. +_FAILURE_MARKERS = ( + "nothing to commit", + "no changes added to commit", + "nothing added to commit", +) + + +def main() -> None: + payload = read_payload() + command = command_from_payload(payload) + if command is None or not matches(command, *TARGET): + sys.exit(0) + assert payload is not None # guarded by command check above + + if _commit_failed(payload): + sys.exit(0) + + cwd = payload.get("cwd") or None + if cwd is not None and not isinstance(cwd, str): + cwd = None + + # Root commit has no parent — nothing to diff against; skip. + if not _has_parent(cwd): + sys.exit(0) + if _no_changes(cwd): + sys.exit(0) + + sys.stderr.write("Post-commit: reviewing HEAD~1..HEAD (code + security)...\n") + intent = recent_user_prompts(payload.get("transcript_path")) + blocked, rendered = run_review("HEAD~1..HEAD", cwd, intent) + sys.stderr.write(rendered + "\n") + + if blocked: + sys.stderr.write( + "\nPost-commit: blocking findings above. Fix them and `git commit --amend` " + "to re-review the amended commit.\n" + ) + sys.exit(2) + sys.stderr.write("\nPost-commit: no blocking findings.\n") + sys.exit(0) + + +def _commit_failed(payload: dict) -> bool: + """Return True if the commit did not create a new commit (e.g. nothing + to commit). Inspects the tool response text for known failure markers.""" + response = payload.get("tool_response", "") + if not isinstance(response, str): + response = json.dumps(response) + text = response.lower() + return any(marker in text for marker in _FAILURE_MARKERS) + + +def _has_parent(cwd: str | None) -> bool: + result = subprocess.run( + ["git", "rev-parse", "--verify", "--quiet", "HEAD~1"], + capture_output=True, + text=True, + cwd=cwd, + ) + return result.returncode == 0 and bool(result.stdout.strip()) + + +def _no_changes(cwd: str | None) -> bool: + result = subprocess.run( + ["git", "diff", "--quiet", "HEAD~1", "HEAD"], + capture_output=True, + text=True, + cwd=cwd, + ) + return result.returncode == 0 + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/pre_pr_review.py b/.claude/hooks/pre_pr_review.py new file mode 100644 index 0000000000..a0475dd86d --- /dev/null +++ b/.claude/hooks/pre_pr_review.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Pre-PR-creation hook: the final whole-PR review gate. + +Per-commit review (`post_commit_review`) catches issues as each commit is +made; this hook is the comprehensive backstop run before `gh pr create`. It +reviews the entire PR — `merge-base(HEAD, origin/HEAD)..HEAD` — so it can +catch cross-commit issues a single-commit view misses. + +PreToolUse runs before the PR is created, so on a blocking finding it denies +the tool call (JSON `permissionDecision: deny`), and the PR is not created +until the findings are addressed. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from _classify import matches # noqa: E402 +from _hookutils import command_from_payload, read_payload, recent_user_prompts # noqa: E402 +from _review import run_review # noqa: E402 + +TARGET = ("gh", ["pr", "create"]) + + +def main() -> None: + payload = read_payload() + command = command_from_payload(payload) + if command is None or not matches(command, *TARGET): + sys.exit(0) + assert payload is not None # guarded by command check above + + cwd = payload.get("cwd") or None + if cwd is not None and not isinstance(cwd, str): + cwd = None + + merge_base = _merge_base(_diff_base(cwd), cwd) + if merge_base is None: + sys.stderr.write("Pre-PR: cannot resolve merge-base; allowing.\n") + sys.exit(0) + if _no_changes_vs(merge_base, cwd): + sys.stderr.write("Pre-PR: no changes vs base; allowing.\n") + sys.exit(0) + + sys.stderr.write("Pre-PR: reviewing the whole PR (code + security)...\n") + intent = recent_user_prompts(payload.get("transcript_path")) + blocked, rendered = run_review(f"{merge_base}..HEAD", cwd, intent) + sys.stderr.write(rendered + "\n") + + if blocked: + _emit_deny(rendered) + sys.exit(0) + + +def _diff_base(cwd: str | None) -> str: + """The integration branch to review the whole PR against — the remote's + default branch (e.g. `origin/next`).""" + result = subprocess.run( + ["git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD"], + capture_output=True, + text=True, + cwd=cwd, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + return "origin/next" + + +def _merge_base(base: str, cwd: str | None) -> str | None: + result = subprocess.run( + ["git", "merge-base", "HEAD", base], + capture_output=True, + text=True, + cwd=cwd, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + fallback = subprocess.run( + ["git", "rev-parse", "HEAD~1"], + capture_output=True, + text=True, + cwd=cwd, + ) + if fallback.returncode == 0 and fallback.stdout.strip(): + return fallback.stdout.strip() + return None + + +def _no_changes_vs(merge_base: str, cwd: str | None) -> bool: + result = subprocess.run( + ["git", "diff", "--quiet", merge_base, "HEAD"], + capture_output=True, + text=True, + cwd=cwd, + ) + return result.returncode == 0 + + +def _emit_deny(review: str) -> None: + reason = ( + "Pre-PR review found blocking findings. Address them, then re-run " + "`gh pr create`:\n\n" + review + ) + output = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } + } + sys.stdout.write(json.dumps(output) + "\n") + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/pre_push_review.py b/.claude/hooks/pre_push_review.py deleted file mode 100755 index 02f4b25467..0000000000 --- a/.claude/hooks/pre_push_review.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env python3 -"""Pre-push hook: spawns code-reviewer + security-reviewer in parallel. -Blocks the push on (a) any Critical/Important/Warning finding from -either reviewer, or (b) reviewer crash or malformed output. Nits and -Notes are surfaced to the user but never block. - -Severity policy (single source of truth, not the agent prompts): - BLOCK on ### Critical Issues | ### Critical Findings - ### Important Issues | ### Warnings - IGNORE ### Nits | ### Notes | ### What's Done Well | ### Summary -""" - -from __future__ import annotations - -import concurrent.futures -import re -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from _classify import matches # noqa: E402 -from _hookutils import read_command, repo_root # noqa: E402 - -TARGET = ("git", ["push"]) - -_ALLOWED_TOOLS = "Bash(git:*) Read Grep Glob" - -# Recognized blocking-section headings (case-sensitive). -_BLOCKING_HEADINGS = re.compile(r"^### (Critical|Important|Warnings)(\s|$)") -# Any `### ` heading ends a previous section. -_ANY_THIRD_LEVEL = re.compile(r"^### ") -# Any second-level heading also ends a section. -_SECOND_LEVEL = re.compile(r"^##[^#]|^## ") -# A bullet line `-` or `*` followed by content. -_BULLET = re.compile(r"^\s*[-*]\s+\S") -# Absence markers we explicitly do NOT count as findings. -_ABSENCE = re.compile(r"^\s*[-*]\s+(None|N/A|n/a)\.?\s*$") - - -@dataclass -class ReviewerResult: - name: str - returncode: int - stdout: str - stderr: str - - -def main() -> None: - command = read_command() - if command is None or not matches(command, *TARGET): - sys.exit(0) - - root = repo_root() - if root is None: - sys.stderr.write("Pre-push: not inside a git worktree, skipping.\n") - sys.exit(0) - - base = _diff_base() - merge_base = _merge_base(base) - if merge_base is None: - sys.stderr.write(f"Pre-push: cannot resolve merge-base against {base}; allowing.\n") - sys.exit(0) - if _no_changes_vs(merge_base): - sys.stderr.write(f"Pre-push: no changes vs {base}; skipping.\n") - sys.exit(0) - - sys.stderr.write("Pre-push: spawning code-reviewer + security-reviewer...\n") - prompt = f"Review the changes about to be pushed (diff base: {merge_base})." - - with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: - futures = { - pool.submit(_run_reviewer, "code-reviewer", prompt): "CODE REVIEWER", - pool.submit(_run_reviewer, "security-reviewer", prompt): "SECURITY REVIEWER", - } - results: list[ReviewerResult] = [] - for fut, name in futures.items(): - try: - rc, stdout, stderr = fut.result() - except Exception as exc: # noqa: BLE001 - results.append(ReviewerResult(name, returncode=1, stdout="", stderr=str(exc))) - continue - results.append(ReviewerResult(name, returncode=rc, stdout=stdout, stderr=stderr)) - - blocked = False - for result in results: - if not _evaluate_reviewer(result): - blocked = True - - if blocked: - sys.stderr.write("\nPre-push: push blocked. Address Critical/Important/Warning findings above and retry.\n") - sys.exit(2) - sys.stderr.write("\nPre-push: all checks passed.\n") - sys.exit(0) - - -def _diff_base() -> str: - """Prefer the configured upstream; fall back to origin/next.""" - result = subprocess.run( - ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], - capture_output=True, - text=True, - ) - if result.returncode == 0 and result.stdout.strip(): - return result.stdout.strip() - return "origin/next" - - -def _merge_base(base: str) -> str | None: - result = subprocess.run( - ["git", "merge-base", "HEAD", base], - capture_output=True, - text=True, - ) - if result.returncode == 0 and result.stdout.strip(): - return result.stdout.strip() - # Fall back to HEAD~1. - fallback = subprocess.run( - ["git", "rev-parse", "HEAD~1"], - capture_output=True, - text=True, - ) - if fallback.returncode == 0 and fallback.stdout.strip(): - return fallback.stdout.strip() - return None - - -def _no_changes_vs(merge_base: str) -> bool: - result = subprocess.run( - ["git", "diff", "--quiet", merge_base, "HEAD"], - capture_output=True, - text=True, - ) - return result.returncode == 0 - - -def _run_reviewer(agent: str, prompt: str) -> tuple[int, str, str]: - result = subprocess.run( - [ - "claude", - "--agent", - agent, - "--allowedTools", - _ALLOWED_TOOLS, - "-p", - prompt, - ], - capture_output=True, - text=True, - ) - return result.returncode, result.stdout, result.stderr - - -def _evaluate_reviewer(result: ReviewerResult) -> bool: - """Return True if this reviewer cleared the push, False if it blocks.""" - sys.stderr.write(f"\n=== {result.name} ===\n") - - if result.returncode != 0: - sys.stderr.write(f"{result.name}: agent exited with status {result.returncode}; treating as block.\n") - if result.stdout: - sys.stderr.write(result.stdout) - if result.stderr: - sys.stderr.write(f"--- agent stderr ---\n{result.stderr}\n") - return False - - if not _looks_like_review(result.stdout): - sys.stderr.write(f"{result.name}: empty or malformed output; treating as block.\n") - if result.stdout: - sys.stderr.write(result.stdout) - return False - - sys.stderr.write(result.stdout) - sys.stderr.write("\n") - count = _count_blocking_findings(result.stdout) - if count > 0: - sys.stderr.write(f"{result.name}: {count} blocking finding(s) (Critical/Important/Warning).\n") - return False - sys.stderr.write(f"{result.name}: no blocking findings (nits/notes do not block).\n") - return True - - -def _looks_like_review(text: str) -> bool: - return bool(text.strip()) and any(line.startswith("### ") for line in text.splitlines()) - - -def _count_blocking_findings(text: str) -> int: - """Walk the reviewer's markdown line by line. Count bullets that - appear under `### Critical Issues / ### Important Issues / ### Warnings` - headings, treating any other `### ` heading or `##` heading as the end - of the current section. Bullets matching `- None.` / `- N/A` are - explicitly skipped — those are absence markers, not findings. - """ - count = 0 - in_block = False - for line in text.splitlines(): - if _SECOND_LEVEL.match(line): - in_block = False - continue - if _ANY_THIRD_LEVEL.match(line): - in_block = bool(_BLOCKING_HEADINGS.match(line)) - continue - if not in_block: - continue - if _ABSENCE.match(line): - continue - if _BULLET.match(line): - count += 1 - return count - - -if __name__ == "__main__": - main() diff --git a/.claude/hooks/tests/test_hooks.py b/.claude/hooks/tests/test_hooks.py index fc944d7f0a..d4efc10e7b 100644 --- a/.claude/hooks/tests/test_hooks.py +++ b/.claude/hooks/tests/test_hooks.py @@ -9,6 +9,7 @@ from __future__ import annotations import importlib +from types import SimpleNamespace import pytest @@ -17,8 +18,9 @@ HOOK_NAMES = [ "pre_commit_lint", + "post_commit_review", "pre_pr_draft", - "pre_push_review", + "pre_pr_review", "pre_push_test", "post_pr_create_changelog", ] @@ -36,29 +38,31 @@ # changes one hook's TARGET without the other, that test fails and the # missing per-hook coverage is restored. HOOK_CASES: list[tuple[str, str, bool]] = [ - # pre_push_review — every must-run / must-not-run case for `git push`. - # Routing for pre_push_test (same TARGET) is covered transitively. - ("pre_push_review", "git push", True), - ("pre_push_review", "git push origin main", True), - ("pre_push_review", "git -C . push", True), - ("pre_push_review", "git -c user.name=foo push", True), - ("pre_push_review", "git -c user.name=foo -C . push origin main", True), - ("pre_push_review", "cd repo && git push", True), - ("pre_push_review", "FOO=bar git push", True), - ("pre_push_review", "echo git push", False), - ("pre_push_review", 'echo "git push"', False), - ("pre_push_review", "git status", False), - ("pre_push_review", "git --version", False), - ("pre_push_review", "git push-graph", False), + # pre_push_test — every must-run / must-not-run case for `git push`. + ("pre_push_test", "git push", True), + ("pre_push_test", "git push origin main", True), + ("pre_push_test", "git -C . push", True), + ("pre_push_test", "git -c user.name=foo push", True), + ("pre_push_test", "git -c user.name=foo -C . push origin main", True), + ("pre_push_test", "cd repo && git push", True), + ("pre_push_test", "FOO=bar git push", True), + ("pre_push_test", "echo git push", False), + ("pre_push_test", 'echo "git push"', False), + ("pre_push_test", "git status", False), + ("pre_push_test", "git --version", False), + ("pre_push_test", "git push-graph", False), # pre_commit_lint — `git commit`. + # Routing for post_commit_review (same TARGET) is covered transitively. ("pre_commit_lint", "git commit -m hello", True), ("pre_commit_lint", 'git -c commit.gpgsign=false commit -m "x"', True), ("pre_commit_lint", "git -c user.name=foo commit", True), + ("pre_commit_lint", "git commit --amend --no-edit", True), ("pre_commit_lint", "echo git commit", False), ("pre_commit_lint", "git status", False), ("pre_commit_lint", "git push", False), # pre_pr_draft — `gh pr create`. - # Routing for post_pr_create_changelog (same TARGET) is covered transitively. + # Routing for post_pr_create_changelog and pre_pr_review (same TARGET) is + # covered transitively. ("pre_pr_draft", "gh pr create", True), ("pre_pr_draft", "gh --repo 0xMiden/miden-base pr create", True), ("pre_pr_draft", "gh -R 0xMiden/miden-base pr create --draft", True), @@ -74,8 +78,9 @@ # hook in each pair is the one whose routing cases appear in HOOK_CASES; # the second's coverage rides on the assertion that the targets match. PAIRED_TARGETS: list[tuple[str, str]] = [ - ("pre_push_review", "pre_push_test"), + ("pre_commit_lint", "post_commit_review"), ("pre_pr_draft", "post_pr_create_changelog"), + ("pre_pr_draft", "pre_pr_review"), ] @@ -139,3 +144,73 @@ def test_paired_hooks_share_target( f"Either re-align or remove the entry from PAIRED_TARGETS and add " f"explicit per-hook routing cases for both." ) + + +# _review.run_review's severity parser is the single source of truth for what +# blocks. Verify it counts only Critical/Important/Warnings bullets and ignores +# nits, notes, and absence markers. +def test_count_blocking_findings_counts_only_blocking_sections() -> None: + import _review + + review = "\n".join( + [ + "## Review Summary", + "### Critical Issues", + "- foo.rs:10 will panic on empty input", + "### Important Issues", + "- bar.rs:20 missing test", + "- baz.rs:30 wrong abstraction", + "### Warnings", + "- qux.rs:40 unchecked arithmetic", + "### Nits", + "- naming could be clearer", + "### Notes", + "- consider documenting this", + "### What's Done Well", + "- great test coverage", + ] + ) + assert _review._count_blocking_findings(review) == 4 + + +def test_count_blocking_findings_ignores_absence_markers() -> None: + import _review + + review = "\n".join( + [ + "### Critical Issues", + "- None.", + "### Important Issues", + "- N/A", + "### Nits", + "- a small thing", + ] + ) + assert _review._count_blocking_findings(review) == 0 + + +# pre_pr_review reviews the whole PR against the integration branch. Verify the +# base resolves to origin/HEAD when set and falls back to origin/next otherwise. +def _fake_proc(returncode: int = 0, stdout: str = "", stderr: str = "") -> SimpleNamespace: + return SimpleNamespace(returncode=returncode, stdout=stdout, stderr=stderr) + + +def test_pre_pr_diff_base_prefers_origin_head(monkeypatch: pytest.MonkeyPatch) -> None: + import pre_pr_review + + def fake_run(cmd: list[str], **_kwargs: object) -> SimpleNamespace: + assert "symbolic-ref" in cmd + return _fake_proc(stdout="origin/next\n") + + monkeypatch.setattr(pre_pr_review.subprocess, "run", fake_run) + assert pre_pr_review._diff_base(None) == "origin/next" + + +def test_pre_pr_diff_base_falls_back(monkeypatch: pytest.MonkeyPatch) -> None: + import pre_pr_review + + def fake_run(cmd: list[str], **_kwargs: object) -> SimpleNamespace: + return _fake_proc(returncode=1) + + monkeypatch.setattr(pre_pr_review.subprocess, "run", fake_run) + assert pre_pr_review._diff_base(None) == "origin/next" diff --git a/.claude/settings.json b/.claude/settings.json index a5c83a74b5..86481a7402 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -16,13 +16,13 @@ }, { "type": "command", - "if": "Bash(*git *)", - "command": ".claude/hooks/pre_push_review.py" + "if": "Bash(*gh *)", + "command": ".claude/hooks/pre_pr_draft.py" }, { "type": "command", "if": "Bash(*gh *)", - "command": ".claude/hooks/pre_pr_draft.py" + "command": ".claude/hooks/pre_pr_review.py" } ] } @@ -31,6 +31,11 @@ { "matcher": "Bash", "hooks": [ + { + "type": "command", + "if": "Bash(*git *)", + "command": ".claude/hooks/post_commit_review.py" + }, { "type": "command", "if": "Bash(*gh *)", diff --git a/.claude/skills/advice-provider-hygiene/SKILL.md b/.claude/skills/advice-provider-hygiene/SKILL.md new file mode 100644 index 0000000000..bf0811fe5b --- /dev/null +++ b/.claude/skills/advice-provider-hygiene/SKILL.md @@ -0,0 +1,101 @@ +--- +name: advice-provider-hygiene +description: Use when writing kernel, account, or note MASM code that reads from or writes to the advice provider (advice stack / advice map) — validate advice data. +--- + +# Advice Provider Hygiene + +## Rules + +The advice provider is untrusted input supplied by the (potentially adversarial) prover. Any kernel, account, or note procedure that touches it must follow three rules. + +### 1. Validate advice data against a commitment + +Before consuming data loaded from the advice provider: + +1. Read the data from the advice stack / advice map into memory. +2. Compute its hash with Poseidon2 over the loaded region. +3. Assert the computed hash equals an expected commitment that the kernel already trusts — on-chain storage, a prior input, or a value already on the stack. + +Do not consume advice data before this check passes. The advice provider's only role is to supply witness data for commitments the kernel has already received. + +### 2. Key advice map entries by content hash + +When inserting into the advice map, the key must be a hash of the value it indexes (or a derived commitment of the same data): + +- Use `Poseidon2(value)` (or whichever commitment matches the consumer's check) as the key. +- Do not hard-code keys like `0x0000_0000_0000_0001`, `ADVICE_KEY_NOTE_DATA`, or per-procedure magic constants. + +Readers retrieve the entry by recomputing the same hash from data they already trust; rule 1's commitment check binds the lookup result to that trusted hash. + +### 3. Missing advice is an error + +A missing advice-map entry, an empty advice stack, or an absent required value is an error — not a default. Surface it with `assert.err=ERR_...`. Don't substitute zero / empty / a fallback and continue. + +## Why + +The advice provider is filled by a potentially adversarial prover. Validating every value against a commitment the kernel already trusts, keying map entries by content hash, and erroring on missing entries are what stop untrusted advice from silently corrupting the kernel's invariants. + +## Examples + +Advice data is tied to a commitment by piping it into memory. There are two mechanisms, and they differ in whether the commitment check happens for you. + +### Piping words to memory — you must validate + +`adv_pipe`, `adv_loadw`, and `mem::pipe_double_words_to_memory` copy advice data into memory but do *not* check it against any commitment. Hash the loaded region with Poseidon2 yourself and assert it equals a commitment the kernel already trusts. + +```masm +# Good: pipe words while hashing, then assert against the trusted commitment +# (permute rounds abbreviated; the real path pipes the full region before squeezing) +exec.poseidon2::init_no_padding +adv_pipe exec.poseidon2::permute +# ... one permute per piped block ... +exec.poseidon2::squeeze_digest +# => [COMPUTED_COMMITMENT, ...] +exec.memory::get_block_commitment +assert_eqw.err=ERR_PROLOGUE_GLOBAL_INPUTS_PROVIDED_DO_NOT_MATCH_BLOCK_COMMITMENT + +# Good: pipe double words while hashing, then assert against the provided commitment +exec.poseidon2::init_no_padding +exec.mem::pipe_double_words_to_memory +exec.poseidon2::squeeze_digest +# => [COMPUTED_ASSETS_COMMITMENT, ...] +exec.memory::get_input_note_assets_commitment +assert_eqw.err=ERR_PROLOGUE_PROVIDED_INPUT_ASSETS_INFO_DOES_NOT_MATCH_ITS_COMMITMENT + +# Bad: pipe advice into memory and use it without the hash/assert step +adv_pipe +# ... data could be anything the prover supplied +``` + +### Piping a preimage to memory — validated for you + +`mem::pipe_preimage_to_memory` takes the commitment on the stack, copies the preimage from the advice stack into memory, and asserts its sequential Poseidon2 hash equals that commitment — all in one step. + +```masm +# Good: data is checked against COMMITMENT as part of the pipe +# stack: [num_words, write_ptr, COMMITMENT] +exec.mem::pipe_preimage_to_memory +# => [write_ptr'] — data in memory is guaranteed to match COMMITMENT +``` + +### Content-addressed keys and missing entries + +```masm +# Good: key the advice map entry by the commitment itself +push.NOTE_DATA_COMMITMENT +adv.push_mapval + +# Bad: hard-coded magic key +push.0x1234_5678_0000_0001 +adv.push_mapval + +# Good: a missing required entry is an error +adv.has_mapkey +assert.err=ERR_MISSING_REQUIRED_ADVICE + +# Bad: silent zero on missing key +adv.push_mapval # no-op if key absent; proceed as if zero +``` + +For the Rust analog (returning `Err` on bad/missing external input rather than panicking or defaulting), see `return-error-not-panic`. diff --git a/.claude/skills/assert-specific-error-in-tests/SKILL.md b/.claude/skills/assert-specific-error-in-tests/SKILL.md new file mode 100644 index 0000000000..22bf8b3cef --- /dev/null +++ b/.claude/skills/assert-specific-error-in-tests/SKILL.md @@ -0,0 +1,42 @@ +--- +name: assert-specific-error-in-tests +description: Use when writing a Rust test that exercises a failure path or a MASM test that expects a `panic` / `assert` — assert on the specific expected error variant or error code. +--- + +# Negative Tests Must Pin the Expected Error + +## Rule + +A test that exercises an error path must assert on the specific error returned: + +- In Rust: use `assert_matches!(result, Err(MyError::SpecificVariant { .. }))` or destructure the error and assert on its fields. Don't accept "any `Err`" via `assert!(result.is_err())`. +- In MASM: assert that the trapping error code matches the expected `ERR_*` constant, not just that the transaction failed. + +If multiple error conditions could plausibly fire on the same input, assert on the one this test is actually exercising. + +## Why + +A test that only checks `is_err()` passes even when an unrelated bug breaks the function, so it no longer validates the failure mode it claims to. Pinning the exact variant also catches reorderings where one error path starts firing instead of another. + +## Examples + +```rust +// Good +use assert_matches::assert_matches; + +let result = AccountId::try_from(&bytes); +assert_matches!(result, Err(AccountError::InvalidLength { expected: 32, got: 5 })); + +// Bad +let result = AccountId::try_from(&bytes); +assert!(result.is_err()); +``` + +```rust +// Good (MASM test) +let err = run_kernel(...).unwrap_err(); +assert_eq!(err.code(), ERR_NOTE_NOT_FOUND); + +// Bad +assert!(run_kernel(...).is_err()); +``` diff --git a/.claude/skills/cheap-masm-equivalents/SKILL.md b/.claude/skills/cheap-masm-equivalents/SKILL.md new file mode 100644 index 0000000000..d74a93a566 --- /dev/null +++ b/.claude/skills/cheap-masm-equivalents/SKILL.md @@ -0,0 +1,48 @@ +--- +name: cheap-masm-equivalents +description: Use when writing or reviewing MASM hot paths — prefer the cheaper equivalent instruction: `neq.0` over `gt.0` for non-zero checks, `cdrop` over an `if/else` selecting between two values, `dup.N` over `loc_load` for a value still on the stack, `eqw` over element-wise word comparison, `u32gt`/`u32lt` over generic `gt`/`lt` on known-u32 operands. +--- + +# Prefer Cheap MASM Equivalents + +## Rule + +Several MASM idioms have a cheap and an expensive form. Use the cheap one when both produce the same result on the inputs the procedure can see: + +- Non-zero check: `neq.0` (3 cycles) over `gt.0` (16 cycles). +- Selecting between two values on a flag: `cdrop` over an `if.true ... else ... end` branch with the same effect. +- Re-fetch a recently-pushed value: `dup.N` over `loc_load.N` when the value is still on the stack. +- Whole-word equality: `eqw` over element-wise comparisons. +- u32-known operands: `u32gt`/`u32lt` over generic `gt`/`lt`. + +Don't apply the cheap form when the operands violate its precondition (e.g. `u32gt` on a value that might exceed `u32::MAX`). + +## Why + +MASM cycle costs are not uniform — `gt.0` does signed-comparison work that `neq.0` skips, so a hot path using the expensive form pays for it on every call. The swaps are semantically equivalent under their preconditions, so the saving is free. + +## Examples + +```masm +# Good +push.0 neq # non-zero check, 3 cycles +# or simply +neq.0 + +# Bad +push.0 gt # same answer, 16 cycles +``` + +```masm +# Good: cdrop for ternary selection +# stack: [b, a, cond] +cdrop +# stack: [a if cond else b] + +# Bad: branchy equivalent +if.true + drop # drop b, keep a +else + swap drop # drop a, keep b +end +``` diff --git a/.claude/skills/checked-arithmetic/SKILL.md b/.claude/skills/checked-arithmetic/SKILL.md new file mode 100644 index 0000000000..45c7f4edd1 --- /dev/null +++ b/.claude/skills/checked-arithmetic/SKILL.md @@ -0,0 +1,32 @@ +--- +name: checked-arithmetic +description: Use when writing Rust arithmetic on amounts or other quantities derived from external/user input — guard against silent overflow. +--- + +# Checked Arithmetic on User-Supplied Values + +## Rule + +Any arithmetic where one or more operands originates from external input (transaction body, advice provider, user RPC, deserialized payload) must use checked or overflowing arithmetic and surface the overflow: + +- Prefer `checked_add` / `checked_sub` / `checked_mul` and return an error on `None`. +- Use `overflowing_add` / `widening_mul` when you need the wrapping value *and* the overflow flag; then `assert!(!overflow)` (or branch) before using the result. +- Do not use the default `+`, `-`, `*` operators on untrusted values in release builds — debug-only overflow checks are not enough. + +## Why + +The default `+`, `-`, `*` operators wrap silently in release builds, so an overflow on a balance or amount yields a wrong value with no error. Checked and overflowing operations surface the overflow so it can be rejected. + +## Examples + +```rust +// Good: checked +let total = balance.checked_add(amount).ok_or(Error::Overflow)?; + +// Good: overflowing with explicit flag check +let (product, overflow) = a.widening_mul(b); +if overflow { return Err(Error::Overflow); } + +// Bad: wraps on overflow in release +let total = balance + amount; +``` diff --git a/.claude/skills/conversion-method-naming/SKILL.md b/.claude/skills/conversion-method-naming/SKILL.md new file mode 100644 index 0000000000..f8a6adedd5 --- /dev/null +++ b/.claude/skills/conversion-method-naming/SKILL.md @@ -0,0 +1,46 @@ +--- +name: conversion-method-naming +description: Use when naming a conversion or accessor method in Rust — follow the Rust API naming conventions for the method's cost and ownership. +--- + +# Rust Conversion Method Naming + +## Rule + +Pick the prefix that matches the cost and ownership of the conversion: + +- `as_` — free or near-free, returns a borrow (e.g. `as_bytes() -> &[u8]`). +- `to_` — non-trivial cost, returns an owned value, leaves `self` intact (e.g. `to_string() -> String`). +- `into_` — consumes `self`, returns the inner/converted value. +- `from_` — associated function on the target type that constructs it. +- `with_` — *only* for builder-style methods that take and return `Self` with a field set. + +Do not name a non-borrowing method `as_*`. Do not name a non-builder method `with_*`. Do not use `to_*` for a free borrow. + +## Why + +The prefixes come from the Rust API guidelines and are baked into the standard library: readers expect `as_` to be cheap, `to_` to allocate, `into_` to consume, and `with_` to return `Self`. Violating that costs every reader a moment of confusion and erodes trust in the API. + +## Examples + +```rust +// Good +impl Header { + pub fn as_bytes(&self) -> &[u8] { ... } // cheap borrow + pub fn to_vec(&self) -> Vec { ... } // allocates + pub fn into_payload(self) -> Payload { self.payload } // consumes +} + +impl HeaderBuilder { + pub fn with_version(mut self, v: u8) -> Self { self.version = v; self } +} + +// Bad +impl Header { + pub fn as_vec(&self) -> Vec { ... } // allocates, should be to_vec + pub fn to_bytes(&self) -> &[u8] { ... } // borrow, should be as_bytes +} + +// Bad: with_* on a non-builder method +fn with_seed(rng: &mut Rng, seed: u64) { ... } // not returning Self +``` diff --git a/.claude/skills/decouple-component-from-storage/SKILL.md b/.claude/skills/decouple-component-from-storage/SKILL.md new file mode 100644 index 0000000000..6fa822adfc --- /dev/null +++ b/.claude/skills/decouple-component-from-storage/SKILL.md @@ -0,0 +1,37 @@ +--- +name: decouple-component-from-storage +description: Use when writing a MASM procedure inside a reusable account component that accesses account storage — receive the storage slot as a parameter so the component is portable across storage layouts. +--- + +# Decouple Component Procedures from Storage Layout + +## Rule + +A reusable account component must not bake a storage-slot index into its procedure bodies. The same component can be installed into many accounts, each mapping it to a different slot, so a hard-coded slot index only works for one layout. + +Instead, take the storage slot as a parameter — the slot id, split into its `slot_id_prefix` / `slot_id_suffix` felts — and pass it into the storage-access procedure (`active_account::get_item` / `get_map_item`, `native_account::set_item` / `set_map_item`). The account-level glue procedure that knows the real layout supplies the slot id. + +## Why + +A component installed into different accounts sits at a different storage slot in each. Hard-coding the slot ties the component to one layout and silently misreads storage everywhere else; taking the slot id as a parameter makes the procedure portable. + +## Examples + +```masm +# Good: the component proc takes the slot id and uses it for the storage access +pub proc get(slot_id_suffix: felt, slot_id_prefix: felt, index: felt) -> word + movup.2 push.0.0.0 # build KEY = [0, 0, 0, index] + movup.5 movup.5 # => [slot_id_suffix, slot_id_prefix, KEY] + exec.active_account::get_map_item + # => [VALUE] +end + +# The account-level caller knows the real layout and passes the slot in: +push.index push.MY_SLOT_ID_PREFIX push.MY_SLOT_ID_SUFFIX +exec.get + +# Bad: the component hard-codes its own slot, so it only works at that one layout +pub proc get_authority + push.AUTHORITY_SLOT[0..2] exec.active_account::get_item +end +``` diff --git a/.claude/skills/domain-newtypes-over-primitives/SKILL.md b/.claude/skills/domain-newtypes-over-primitives/SKILL.md new file mode 100644 index 0000000000..cf32ec521f --- /dev/null +++ b/.claude/skills/domain-newtypes-over-primitives/SKILL.md @@ -0,0 +1,44 @@ +--- +name: domain-newtypes-over-primitives +description: Use when introducing API parameters, struct fields, or return types that carry a domain value — represent them with a domain newtype that enforces their invariants. +--- + +# Use Domain Newtypes, Not Raw Primitives + +## Rule + +When an API boundary takes or returns a value with a domain meaning, define a newtype that: + +- Validates the value at construction time (see `validate-in-constructor`). +- Exposes the inner representation only through deliberate accessors. +- Is used at every API boundary touching the concept (not raw `u64`/`Word`/tuples). + +Raw `(AccountId, u64)` tuples, bare `Word` parameters, and primitive-typed amounts must be replaced with a named type like `FungibleAsset`, `FaucetId`, `BlockNumber`. + +## Why + +A newtype is the one place an invariant gets enforced; once a function takes a raw `u64` for an amount, every caller and reviewer must re-check the bound. It also localizes representation changes to a single type instead of every signature. + +## Examples + +```rust +// Good +pub fn mint(asset: FungibleAsset, to: AccountId) -> Result; + +// Bad +pub fn mint(faucet_id: AccountId, amount: u64, to: AccountId) -> Result; +``` + +```rust +// Good: validated wrapper with explicit constructor +pub struct BlockNumber(u32); +impl BlockNumber { + pub fn new(n: u32) -> Result { + if n > MAX_BLOCK_NUMBER { return Err(Error::OutOfRange); } + Ok(Self(n)) + } +} + +// Bad: raw u32 leaks into every signature, every caller checks the bound +pub fn lookup_block(n: u32) -> Option; +``` diff --git a/.claude/skills/felt-construction/SKILL.md b/.claude/skills/felt-construction/SKILL.md new file mode 100644 index 0000000000..0f1b627f8e --- /dev/null +++ b/.claude/skills/felt-construction/SKILL.md @@ -0,0 +1,33 @@ +--- +name: felt-construction +description: Use when constructing a `Felt` from a numeric value in Rust — avoid silently truncating values that may exceed the field modulus. +--- + +# Felt Construction From Untrusted Numeric Inputs + +## Rule + +Do not call `Felt::new(x)` when `x` could exceed the field modulus. `Felt::new` silently truncates oversized values, which produces a valid-looking `Felt` that no longer equals the original input — a classic source of hard-to-attribute bugs. + +Use one of: + +- `Felt::from(x)` where `x` is a `u32` or smaller (infallible). +- `Felt::try_from(x)` for `u64`-and-larger inputs, returning `Result`. +- An explicit `assert!(x < Felt::MODULUS)` before `Felt::new(x)` if you have already proven the bound. + +## Why + +The field modulus sits just below `2^64`, so `Felt::new` truncates only for a narrow band of large values — most tests pass and production hits the bad input as a value mismatch far from the call. `Felt::from(u32)` cannot truncate and `Felt::try_from` forces the bound check. + +## Examples + +```rust +// Good: u32 input, infallible conversion +let f = Felt::from(slot_index as u32); + +// Good: untrusted u64 input, checked conversion +let f = Felt::try_from(user_value).map_err(|_| Error::FeltOverflow)?; + +// Bad: silent truncation on any value >= MODULUS +let f = Felt::new(user_value); +``` diff --git a/.claude/skills/intra-doc-links/SKILL.md b/.claude/skills/intra-doc-links/SKILL.md new file mode 100644 index 0000000000..ceb285a5cd --- /dev/null +++ b/.claude/skills/intra-doc-links/SKILL.md @@ -0,0 +1,44 @@ +--- +name: intra-doc-links +description: Use when writing or editing a Rust doc comment that references a type, method, or module — link the reference so renames are caught by rustdoc. +--- + +# Use Intra-Doc Links in Rust Doc Comments + +## Rule + +When a doc comment mentions a type, method, trait, constant, or module that exists in scope, write it as an intra-doc link: + +```rust +/// Returns the [`AccountId`] associated with this [`Account`]. +/// +/// See also [`AccountStorage::commitment`] for the storage commitment. +``` + +Use `[`Name`]` for items already in scope; use `[`Name`](crate::path::Name)` for items elsewhere; use `[`Name`]: ...` reference-style at the bottom for long paths. + +Do not write type names as plain text or inside single backticks alone (e.g. `` `AccountId` `` without brackets) when the item is reachable from rustdoc. + +## Why + +Intra-doc links are checked by rustdoc, so renaming a linked item produces a warning while plain-text references silently go stale. They also render as clickable navigation in the generated docs. + +## Examples + +```rust +// Good +/// Returns the [`AccountId`] of this account. +/// +/// # Errors +/// +/// Returns [`AccountError::NotFound`] if the storage slot is empty. +pub fn account_id(&self) -> Result { ... } + +// Bad +/// Returns the `AccountId` of this account. +/// +/// # Errors +/// +/// Returns `AccountError::NotFound` if the storage slot is empty. +pub fn account_id(&self) -> Result { ... } +``` diff --git a/.claude/skills/lowercase-error-messages/SKILL.md b/.claude/skills/lowercase-error-messages/SKILL.md new file mode 100644 index 0000000000..f5ce6a48fd --- /dev/null +++ b/.claude/skills/lowercase-error-messages/SKILL.md @@ -0,0 +1,50 @@ +--- +name: lowercase-error-messages +description: Use when writing or editing a Rust error message, panic/assert string, or MASM `ERR_*` constant — follow the Rust convention for error-message casing and punctuation. +--- + +# Lowercase Error Messages, No Trailing Punctuation + +## Rule + +Error messages (in `Error` enum `#[error("...")]` attributes, `panic!`, `assert!` messages, MASM `ERR_*` constant strings) follow Rust's convention: + +- Start with a lowercase letter (unless beginning with a proper noun or acronym). +- No trailing period, exclamation, or other punctuation. +- Imperative or descriptive, not "ERROR: ..." or "Failed to ...". + +Examples of correct shape: `"invalid account id length"`, `"note not found"`, `"failed to deserialize storage"`. + +## Why + +Error messages get composed into chains and contexts, where a capital letter or trailing period mid-chain makes them read like disconnected sentences. Lowercase-with-no-punctuation is the convention `std`, `thiserror`, and most crates already follow. + +## Examples + +```rust +// Good +#[derive(Debug, thiserror::Error)] +pub enum AccountError { + #[error("invalid account id length, expected {expected} bytes, got {got}")] + InvalidLength { expected: usize, got: usize }, + #[error("account not found")] + NotFound, +} + +// Bad +#[derive(Debug, thiserror::Error)] +pub enum AccountError { + #[error("Invalid account id length: expected {expected} bytes, got {got}.")] + InvalidLength { ... }, + #[error("Account not found!")] + NotFound, +} +``` + +```masm +# Good +const ERR_NOTE_NOT_FOUND = "note not found" + +# Bad +const ERR_NOTE_NOT_FOUND = "Note not found!" +``` diff --git a/.claude/skills/masm-error-constants/SKILL.md b/.claude/skills/masm-error-constants/SKILL.md new file mode 100644 index 0000000000..218e55a239 --- /dev/null +++ b/.claude/skills/masm-error-constants/SKILL.md @@ -0,0 +1,50 @@ +--- +name: masm-error-constants +description: Use when adding or editing MASM `assert*` / `panic` instructions — give every assertion a descriptive named error code. +--- + +# MASM Error Constants + +## Rule + +Every MASM assertion must carry a descriptive error code: + +```masm +assert.err=ERR_NOTE_NOT_FOUND +assert_eqw.err=ERR_COMMITMENT_MISMATCH +``` + +The error constant must: + +- Use the `ERR_` prefix. +- Live in the file's dedicated errors section (see `masm-constants` skill). +- Have a descriptive string value, not a bare numeric code: `const ERR_NOTE_NOT_FOUND = "note not found"`. +- Be unique per distinct failure condition — do not share one `ERR_` across two unrelated asserts. + +## Why + +A bare `assert` traps with a generic message that tells the debugger nothing about which check failed; a descriptive `ERR_` constant ties each trap site to a specific failure mode. Distinct constants per condition also let tests pin the expected error (see `assert-specific-error-in-tests`). + +## Examples + +```masm +# Good +const ERR_NOTE_NOT_FOUND = "note not found" +const ERR_COMMITMENT_MISMATCH = "stored commitment does not match recomputed value" + +proc verify_note + # ... + assert.err=ERR_NOTE_NOT_FOUND + # ... + assert_eqw.err=ERR_COMMITMENT_MISMATCH +end + +# Bad: bare assertion +proc verify_note + assert +end + +# Bad: shared generic constant for unrelated cases +const ERR_INVALID = "invalid" +assert.err=ERR_INVALID # used in 6 places, each meaning something different +``` diff --git a/.claude/skills/masm-explicit-stack-inputs/SKILL.md b/.claude/skills/masm-explicit-stack-inputs/SKILL.md new file mode 100644 index 0000000000..f6e87cea1c --- /dev/null +++ b/.claude/skills/masm-explicit-stack-inputs/SKILL.md @@ -0,0 +1,51 @@ +--- +name: masm-explicit-stack-inputs +description: Use when defining the interface for a new MASM procedure — keep its inputs explicit on the stack so the signature reflects what it consumes. +--- + +# Pass MASM Procedure Inputs Explicitly on the Stack + +## Rule + +A MASM procedure's inputs should arrive on the stack, named in its `Inputs:` doc block. Do not design a procedure that reads its inputs from a fixed memory location that the caller must populate beforehand. + +Use memory I/O only when: + +- The data has a fixed canonical home (account storage, kernel inputs, advice-keyed regions). +- The data is too large to keep on the stack (a full Merkle proof, a large vector). + +For everything else — counts, indices, single words, small structs — pass on the stack. + +## Why + +Hidden memory inputs make the procedure's signature a lie — a reader of `Inputs: [ptr]` can't tell what's behind the pointer or what the caller had to set up, and the real contract drifts out of sync in prose. Stack inputs are typed by the doc, testable in isolation, and trap if the shape is wrong. + +## Examples + +```masm +# Good +#! Inputs: [note_index, ASSET] +#! Outputs: [] +proc add_asset_to_note + # ... uses values directly from the stack +end + +# Bad: implicit input via memory location the caller had to populate +#! Inputs: [] +#! Outputs: [] +proc add_asset_to_note + mem_load.PENDING_NOTE_PTR # caller had to set this first + mem_loadw.PENDING_ASSET_PTR + # ... +end + +# OK: the exception — data too large for the stack lives in memory, and the +# pointer to it is an explicit stack input named in the doc block +#! Inputs: [proof_ptr, leaf_index, ROOT] +#! Outputs: [is_valid] +proc verify_merkle_proof + # the full proof (many words) was written to memory by the caller; + # only the pointer, index, and root travel on the stack + # ... +end +``` diff --git a/.claude/skills/masm-locals-over-globals/SKILL.md b/.claude/skills/masm-locals-over-globals/SKILL.md new file mode 100644 index 0000000000..d685223e82 --- /dev/null +++ b/.claude/skills/masm-locals-over-globals/SKILL.md @@ -0,0 +1,36 @@ +--- +name: masm-locals-over-globals +description: Use when a MASM procedure needs temporary scratch storage — keep it in procedure-local memory so it cannot collide in a shared global region. +--- + +# Prefer Procedure Locals for MASM Scratch Storage + +## Rule + +When a MASM procedure needs scratch storage that lives only for the duration of one invocation, use procedure-local memory (`loc_store`, `loc_load`, `loc_storew`, `loc_loadw`) rather than allocating in a shared global memory region. + +Global memory regions are reserved for state that crosses procedure boundaries (kernel inputs, account data, advice-keyed state). Stashing per-call scratch there leaks an implementation detail into a shared namespace and ties the procedure to a fixed address. + +## Why + +Procedure locals are allocated and freed by the VM, so two callers of the same procedure can't collide. A hard-coded scratch slot in global memory risks colliding with another procedure, forces every caller to avoid clobbering it, and locks the layout. + +## Examples + +```masm +# Good +proc compute_hash + # allocate two local slots + loc_store.0 + loc_store.1 + # ... + loc_load.0 + loc_load.1 +end + +# Bad: scratch in a shared region +const SCRATCH_PTR = 0x4000 +proc compute_hash + mem_store.SCRATCH_PTR # collides with anyone else using SCRATCH_PTR +end +``` diff --git a/.claude/skills/masm-named-literals/SKILL.md b/.claude/skills/masm-named-literals/SKILL.md new file mode 100644 index 0000000000..45aeff9c94 --- /dev/null +++ b/.claude/skills/masm-named-literals/SKILL.md @@ -0,0 +1,41 @@ +--- +name: masm-named-literals +description: Use when writing or editing MASM or Rust code that contains meaningful numeric literals — promote them to named constants with a single source of truth. +--- + +# Replace Magic Numbers with Named Constants + +## Rule + +Numeric literals embedded inline in MASM and Rust code must be promoted to named constants when they represent: + +- Memory offsets, slot indices, or layout sizes +- Protocol/tag/type/version discriminants +- Domain values reused in more than one place + +Define each constant exactly once. In MASM, declare it in the file's `CONSTANTS` section (see `masm-constants` skill). In Rust, define it as an associated constant on the type it describes (`Type::CAPACITY`, not a free-floating `const CAPACITY`). + +## Why + +A bare `47` or `0x1234` is invisible to grep, indistinguishable from coincidentally-equal numbers, and drifts when one occurrence is updated and another missed. A named constant documents intent and gives refactors a single source of truth. + +## Examples + +```masm +# Good +const ACCOUNT_DATA_PTR = 4 +mem_load.ACCOUNT_DATA_PTR + +# Bad +mem_load.4 # what is at offset 4? +``` + +```rust +// Good +impl AccountStorage { + pub const MAX_NUM_STORAGE_SLOTS: usize = 255; +} + +// Bad +if slots.len() > 255 { ... } // 255 also appears unrelated elsewhere +``` diff --git a/.claude/skills/masm-rust-constant-parity/SKILL.md b/.claude/skills/masm-rust-constant-parity/SKILL.md new file mode 100644 index 0000000000..7ac216f3fd --- /dev/null +++ b/.claude/skills/masm-rust-constant-parity/SKILL.md @@ -0,0 +1,45 @@ +--- +name: masm-rust-constant-parity +description: Use when changing a numeric or string constant in Rust or MASM that has a counterpart on the other side — keep the two definitions from drifting apart. +--- + +# Keep Rust and MASM Constants Aligned + +## Rule + +Constants that exist on both the MASM and the Rust side must not drift. Prefer a single source of truth: define the constant in MASM and generate the Rust counterpart from it, rather than hand-maintaining the same literal in two places. + +This repo already does that in `crates/miden-protocol/build.rs`: it scans the MASM sources for `const ERR_... = "..."` and `const X = event("...")` definitions and emits Rust files (`tx_kernel_errors.rs`, `protocol_errors.rs`, `transaction_events.rs`) that are pulled in with `include!(concat!(env!("OUT_DIR"), ...))` (see `src/errors/mod.rs` and `src/transaction/kernel/tx_event_id.rs`). A new error or event constant added in MASM gets its Rust binding automatically. + +For constants not covered by codegen (memory offsets, capacity limits, field widths still duplicated in `src/constants.rs` / `memory.rs`), update both sides in the same PR — and prefer extending the generation over adding another hand-copied literal. + +## Why + +The kernel reads memory at offsets the Rust host wrote. If one side changes `ACCOUNT_HEADER_LEN` and the other doesn't, every transaction misreads its state, and the bug stays invisible until a value happens to straddle the changed offset. Generating one side from the other removes the chance to forget. + +## Examples + +Source of truth in MASM: + +```masm +const ERR_PROLOGUE_NEW_ACCOUNT_VAULT_MUST_BE_EMPTY="new account must have an empty vault" +``` + +Generated into `OUT_DIR` by `build.rs` and included via `include!(concat!(env!("OUT_DIR"), "/tx_kernel_errors.rs"))`: + +```rust +pub const ERR_PROLOGUE_NEW_ACCOUNT_VAULT_MUST_BE_EMPTY: MasmError = + MasmError::from_static_str("new account must have an empty vault"); +``` + +For a constant that is still hand-duplicated, change both in the same PR: + +```rust +// crates/miden-protocol/src/constants.rs +pub const MAX_INPUT_NOTES_PER_TX: usize = 1024; +``` + +```masm +# crates/miden-protocol/asm/.../constants.masm — must equal the Rust constant +pub const MAX_INPUT_NOTES_PER_TX = 1024 +``` diff --git a/.claude/skills/non-exhaustive-public-types/SKILL.md b/.claude/skills/non-exhaustive-public-types/SKILL.md new file mode 100644 index 0000000000..3b3ba4ced6 --- /dev/null +++ b/.claude/skills/non-exhaustive-public-types/SKILL.md @@ -0,0 +1,64 @@ +--- +name: non-exhaustive-public-types +description: Use when defining a public `enum` or `struct` in a library crate that may gain variants or fields later — keep future additions from being breaking changes. +--- + +# Mark Public Types `#[non_exhaustive]` + +## Rule + +Public enums and public-fielded structs in library crates whose variants/fields are expected to grow should be marked `#[non_exhaustive]`: + +```rust +#[non_exhaustive] +pub enum Authority { + AuthControlled, + OwnerControlled, + RbacControlled { role: RoleSymbol }, +} + +#[non_exhaustive] +pub struct Header { + pub version: u8, + pub flags: u32, +} +``` + +This forces external code to use a wildcard match arm (or default field syntax) and lets the library add new variants/fields in a minor release without breaking downstreams. + +Don't mark types `#[non_exhaustive]` when the closed set is part of the contract — e.g. `NoteType`, a protocol-level enum fixed by the spec and serialized with a fixed-width discriminant, where adding a variant is a breaking protocol change anyway. + +## Why + +Without `#[non_exhaustive]`, a downstream exhaustive `match` or struct literal compiles today but breaks the moment a minor release adds a variant or field. The attribute makes the wildcard arm mandatory, turning those additions from breaking to non-breaking. + +## Examples + +```rust +// Good: a standards-level enum that is expected to gain variants over time +#[non_exhaustive] +pub enum TransferPolicy { + AllowAll, + Blocklist, + Allowlist { allow_list: AllowlistStorage }, + Custom(AccountProcedureRoot), +} + +// External callers are forced to include a wildcard, so a future variant +// (e.g. a time-locked or volume-limited policy) stays non-breaking: +match policy { + TransferPolicy::AllowAll => ..., + TransferPolicy::Blocklist => ..., + TransferPolicy::Allowlist { .. } => ..., + TransferPolicy::Custom(_) => ..., + _ => ..., // adding a variant in a minor release: still compiles +} + +// Bad: closed public enum, so any new transfer policy is a breaking change +pub enum TransferPolicy { + AllowAll, + Blocklist, + Allowlist { allow_list: AllowlistStorage }, + Custom(AccountProcedureRoot), +} +``` diff --git a/.claude/skills/parametrize-related-tests/SKILL.md b/.claude/skills/parametrize-related-tests/SKILL.md new file mode 100644 index 0000000000..b723752fe9 --- /dev/null +++ b/.claude/skills/parametrize-related-tests/SKILL.md @@ -0,0 +1,44 @@ +--- +name: parametrize-related-tests +description: Use when writing two or more tests that differ only by inputs or expected outputs — collapse the duplication into parameterized cases. +--- + +# Parameterize Repeated Tests with `rstest` + +## Rule + +When you'd write two or more test functions that share their body and differ only by inputs/expected values, write a single `#[rstest]` function with one `#[case]` per input set: + +```rust +#[rstest] +#[case::happy("abc", true)] +#[case::empty("", false)] +#[case::too_long(LONG_INPUT, false)] +fn validate_name(#[case] input: &str, #[case] expected: bool) { + assert_eq!(validate(input), expected); +} +``` + +This applies especially to "one test per enum variant" patterns and "one test per error condition" patterns. + +## Why + +Copy-pasted test bodies drift — a fix in one case doesn't reach the others. Parameterized cases keep the assertion in one place, name each case in the output, and make a missing case obvious; adding one is a single line. + +## Examples + +```rust +// Good +#[rstest] +#[case::fungible(Asset::Fungible(make_fungible()), AssetKind::Fungible)] +#[case::nft(Asset::Nft(make_nft()), AssetKind::Nft)] +fn asset_kind(#[case] asset: Asset, #[case] expected: AssetKind) { + assert_eq!(asset.kind(), expected); +} + +// Bad: two test functions duplicating the body +#[test] +fn asset_kind_fungible() { assert_eq!(Asset::Fungible(make_fungible()).kind(), AssetKind::Fungible); } +#[test] +fn asset_kind_nft() { assert_eq!(Asset::Nft(make_nft()).kind(), AssetKind::Nft); } +``` diff --git a/.claude/skills/preserve-error-source/SKILL.md b/.claude/skills/preserve-error-source/SKILL.md new file mode 100644 index 0000000000..53113d59b5 --- /dev/null +++ b/.claude/skills/preserve-error-source/SKILL.md @@ -0,0 +1,42 @@ +--- +name: preserve-error-source +description: Use when defining a new error variant or wrapping a lower-level error — keep the underlying error's source chain intact. +--- + +# Preserve Error Source Chains + +## Rule + +When a new error wraps a lower-level error, preserve the source so the chain remains traversable: + +- Use `thiserror`'s `#[source]` attribute (or `#[from]`) to attach the underlying error. +- For dynamic sources, `Box`. +- Do not call `.to_string()` on the source and embed it into the wrapper's message — that breaks `Error::source()` traversal and destroys structured information. + +## Why + +Tools like anyhow, tracing, and logging walk `Error::source()` to render full chains and group by root cause. Stringifying the source into the message flattens it to one opaque string — the chain can't be walked and the inner error's fields are gone. + +## Examples + +```rust +// Good +#[derive(Debug, thiserror::Error)] +pub enum AccountError { + #[error("failed to deserialize account storage")] + StorageDeser(#[source] DeserializationError), + + #[error("failed to load account from {path}")] + Load { path: PathBuf, #[source] io: io::Error }, +} + +// Bad: source is stringified, chain is lost +#[derive(Debug, thiserror::Error)] +pub enum AccountError { + #[error("failed to deserialize account storage: {0}")] + StorageDeser(String), +} + +// Bad: source baked into the message via format! +return Err(AccountError::StorageDeser(format!("{e}"))); +``` diff --git a/.claude/skills/private-fields-with-accessors/SKILL.md b/.claude/skills/private-fields-with-accessors/SKILL.md new file mode 100644 index 0000000000..bbbf7344b9 --- /dev/null +++ b/.claude/skills/private-fields-with-accessors/SKILL.md @@ -0,0 +1,40 @@ +--- +name: private-fields-with-accessors +description: Use when adding a public struct or changing a field's visibility in a Rust library crate — keep fields encapsulated so the layout can evolve without breaking callers. +--- + +# Keep Struct Fields Private; Expose Accessors + +## Rule + +Public structs in library crates have private fields. Read access goes through an `pub fn field(&self) -> &T` accessor; mutation goes through dedicated methods (no `pub fn field_mut`). + +Exceptions: + +- "Open" data types whose layout is part of the contract (e.g. `Point { x, y }`) may have public fields, but they should be `#[non_exhaustive]`. +- Internal/`pub(crate)` types may have public fields when keeping them private adds no value. + +## Why + +Public fields freeze the representation: you can't rename, retype, split, or compute-on-read them without breaking every caller. An accessor lets a field become a computed expression in a later release with no one noticing. + +## Examples + +```rust +// Good +pub struct FungibleTokenMetadata { + name: Box, + supply: u64, +} + +impl FungibleTokenMetadata { + pub fn name(&self) -> &str { &self.name } + pub fn supply(&self) -> u64 { self.supply } +} + +// Bad: every consumer locked to these field names and types forever +pub struct FungibleTokenMetadata { + pub name: String, + pub supply: u64, +} +``` diff --git a/.claude/skills/return-error-not-panic/SKILL.md b/.claude/skills/return-error-not-panic/SKILL.md new file mode 100644 index 0000000000..3830478337 --- /dev/null +++ b/.claude/skills/return-error-not-panic/SKILL.md @@ -0,0 +1,63 @@ +--- +name: return-error-not-panic +description: Use when writing a public Rust API, a deserialization path, or any code reachable from untrusted input — surface recoverable failures as a `Result`. +--- + +# Return Errors, Don't Panic on External Input + +## Rule + +Functions that touch external input — public API entry points, `Deserializable::read_from`, RPC handlers, advice-provider readers, parsing of user data — must surface failures as `Err`, not panics: + +- No `unwrap()`, `expect()`, or `panic!` on values whose validity depends on external data. +- No `unwrap_or_default()` to silently substitute a fallback for invalid input. +- No `Option` return type that hides the cause of failure when a real error is available. +- Missing required input is itself an error — don't substitute zero/empty/default and continue. + +Convert any internal panic on external-derived values into a typed error variant. + +### Two-tier API for the trusted case + +When a no-check fast path is genuinely needed for callers operating on already-validated in-memory state, expose it as a separate constructor (e.g. `new_unchecked`, `from_parts_unchecked`) with a `# Safety` doc comment spelling out the caller's obligation. The default constructor stays fallible. + +## Why + +A panic on untrusted input is a denial-of-service vector and a debugging black hole, and `unwrap_or_default()` is worse — it fabricates a valid-looking value the caller never sent. A named `*_unchecked` entry point keeps the default path strict while forcing opt-in callers to acknowledge what they skip. + +## Examples + +```rust +// Good +pub fn parse_account_id(bytes: &[u8]) -> Result { + if bytes.len() != ACCOUNT_ID_LEN { + return Err(AccountError::InvalidLength { expected: ACCOUNT_ID_LEN, got: bytes.len() }); + } + AccountId::try_from(bytes) +} + +// Bad: panics on bad input +pub fn parse_account_id(bytes: &[u8]) -> AccountId { + AccountId::try_from(bytes).unwrap() +} + +// Bad: silently substitutes a default +pub fn parse_account_id(bytes: &[u8]) -> AccountId { + AccountId::try_from(bytes).unwrap_or_default() +} +``` + +Two-tier API when a trusted fast path is justified: + +```rust +impl AccountId { + /// Fallible default — validates the bytes. + pub fn try_from_bytes(b: &[u8]) -> Result { /* ... */ } + + /// # Safety + /// Caller must guarantee `b` came from a previously validated source + /// (e.g. a value already constructed via `try_from_bytes`). + pub fn from_bytes_unchecked(b: &[u8]) -> Self { /* ... */ } +} +``` + +For the MASM analog (validating against a commitment, content-addressed advice keys, erroring on missing advice), see `advice-provider-hygiene`. diff --git a/.claude/skills/u32-assert-before-u32-ops/SKILL.md b/.claude/skills/u32-assert-before-u32-ops/SKILL.md new file mode 100644 index 0000000000..ea22f5397b --- /dev/null +++ b/.claude/skills/u32-assert-before-u32-ops/SKILL.md @@ -0,0 +1,39 @@ +--- +name: u32-assert-before-u32-ops +description: Use when writing MASM `u32*` instructions on values from user input or untrusted sources — ensure the operands are valid u32s first. +--- + +# Validate u32 Operands Before u32 Instructions + +## Rule + +MASM's `u32*` instructions assume their operands are valid `u32` values (i.e. fit in 32 bits). Operating on a non-u32 value silently produces garbage or traps with a generic message. + +Before applying any `u32*` instruction to a value that is not already known to be a valid u32 (e.g. it came from the stack as input, was read from memory, or arose from a non-u32 arithmetic op), assert the bound: + +```masm +u32assert # one value +u32assert2 # two top values +u32assert4 # four top values +``` + +If the operand is already known-valid (just produced by another `u32*` op, or a value loaded from a slot whose layout is u32 by construction), skip the assert. + +## Why + +`u32*` instructions are tuned for the precondition that operands fit in 32 bits, and the VM does not check it for you. Skipping `u32assert*` lets a non-u32 input silently produce a wrong result or trap uninformatively; the assert gives the bug a named failure mode. + +## Examples + +```masm +# Good: assert u32 before the u32 op +u32assert.err=ERR_VALUE_NOT_U32 +u32add + +# Good: both operands at once +u32assert2.err=ERR_VALUES_NOT_U32 +u32lt + +# Bad: u32 op on untrusted input +u32add # one operand could be >2^32; silently wraps or traps +``` diff --git a/.claude/skills/use-bon-builder/SKILL.md b/.claude/skills/use-bon-builder/SKILL.md new file mode 100644 index 0000000000..b66b02a43c --- /dev/null +++ b/.claude/skills/use-bon-builder/SKILL.md @@ -0,0 +1,50 @@ +--- +name: use-bon-builder +description: Use when introducing a Rust type whose constructor takes more than ~3 optional or named parameters — give it a generated builder instead of hand-written boilerplate. +--- + +# Use `bon` for Builders With Many Optional Fields + +## Rule + +When a type's constructor has many optional or named parameters, derive its builder with `#[bon::builder]`: + +```rust +#[bon::builder] +pub fn new( + required: Foo, + #[builder(default)] optional: Option, + #[builder(into)] name: String, +) -> Self { ... } +``` + +Don't hand-write a separate `FooBuilder` module just to expose a fluent API. Don't add a constellation of `new`, `new_with_x`, `new_with_x_and_y` constructors. + +`bon` handles compile-time required-field enforcement, optional defaults, and `impl Into` parameters for free. + +## Why + +Hand-written builders are boilerplate that drifts — add a struct field, forget to set it in the builder, and the bug surfaces later. `bon` derives the builder from the constructor signature so the two can't diverge, and enforces required fields at compile time. + +## Examples + +```rust +// Good +#[bon::builder] +impl Account { + pub fn new( + id: AccountId, + #[builder(default)] storage: AccountStorage, + #[builder(into)] code: AccountCode, + ) -> Self { ... } +} + +let acc = Account::builder() + .id(my_id) + .code(my_code) + .build(); + +// Bad: bespoke builder module that drifts +pub struct AccountBuilder { id: Option, storage: Option, ... } +impl AccountBuilder { ... 80 lines ... } +``` diff --git a/.claude/skills/use-test-fixtures/SKILL.md b/.claude/skills/use-test-fixtures/SKILL.md new file mode 100644 index 0000000000..41c6db0b22 --- /dev/null +++ b/.claude/skills/use-test-fixtures/SKILL.md @@ -0,0 +1,44 @@ +--- +name: use-test-fixtures +description: Use when a Rust or MASM test needs to construct an account, note, transaction, or other domain object — build it with the existing test fixtures. +--- + +# Use Existing Test Fixtures, Don't Hand-Roll + +## Rule + +When a test needs a domain object, reach for the existing fixture infrastructure: + +- Notes: `NoteBuilder`. +- Scripts: `ScriptBuilder`. +- Account IDs: `AccountIdBuilder` (or the existing `ACCOUNT_ID_*` constants). +- Random felts/words: `rand_value()` (deterministic seed-driven RNG). +- Accounts: `AccountBuilder` with the `testing` feature. + +Don't write a new `AccountId::dummy(...)`, `Note::test_only(...)`, or one-off random helper. If the existing fixtures can't express what you need, extend them — don't fork. + +## Why + +Shared fixtures encode the domain's validation rules, so a fixture-built object has the right bits and survives serialization; a hand-rolled `dummy()` usually doesn't, letting tests pass against invariants the real code never enforces. Reusing fixtures also lets one upgrade propagate to every test. + +## Examples + +```rust +// Good +let note = NoteBuilder::new() + .recipient(test_recipient()) + .with_asset(rand_value()) + .build()?; + +let account_id = AccountIdBuilder::new().build(); + +// Bad +let note = Note { + metadata: NoteMetadata::default(), + inputs: NoteInputs::default(), + assets: NoteAssets::default(), + recipient: NoteRecipient::dummy(), +}; + +let account_id = AccountId::try_from(Word::default()).unwrap(); +``` diff --git a/.claude/skills/validate-in-constructor/SKILL.md b/.claude/skills/validate-in-constructor/SKILL.md new file mode 100644 index 0000000000..ac1a9a8223 --- /dev/null +++ b/.claude/skills/validate-in-constructor/SKILL.md @@ -0,0 +1,54 @@ +--- +name: validate-in-constructor +description: Use when writing or reviewing a Rust constructor, `try_new`, or builder `build` — centralize validation so every instance is valid by construction. +--- + +# Validate Invariants in Constructors + +## Rule + +Every fallible construction path for a struct must run all invariants in a single canonical constructor (or builder `build`). The constructor must: + +1. Validate every invariant the type promises to uphold. +2. Return `Err` on any input that would make the resulting value unusable — empty allowlists, zero thresholds, mutually inconsistent fields, etc. +3. Be the only externally callable way to produce an instance (struct literal construction must not be possible from outside the module). + +Do not split validation across the constructor and downstream methods; do not allow direct field initialization that bypasses checks. + +## Why + +If a type can be constructed in an invalid or in-between state, every consumer has to defend against it; centralizing validation means holding a `T` is proof its invariants hold. Rejecting unusable configurations early keeps bugs from surfacing far from their cause. + +## Examples + +```rust +// Good: single validating constructor, no public fields +pub struct ProcedurePolicyMode { + immediate_threshold: u32, +} + +impl ProcedurePolicyMode { + pub fn new(immediate_threshold: u32) -> Result { + if immediate_threshold == 0 { + return Err(PolicyError::ZeroThreshold); + } + Ok(Self { immediate_threshold }) + } +} + +// Good: reject empty allowlist that would brick the account +impl ScriptRoots { + pub fn new(roots: BTreeSet) -> Result { + if roots.is_empty() { + return Err(Error::EmptyAllowlist); + } + Ok(Self { roots }) + } +} + +// Bad: in-between invalid state possible +let mut metadata = FungibleTokenMetadata::default(); +metadata.set_name(name); +metadata.set_supply(supply); +metadata.validate()?; // can be forgotten +``` diff --git a/.claude/skills/workspace-shared-dependencies/SKILL.md b/.claude/skills/workspace-shared-dependencies/SKILL.md new file mode 100644 index 0000000000..ce80dc2bca --- /dev/null +++ b/.claude/skills/workspace-shared-dependencies/SKILL.md @@ -0,0 +1,48 @@ +--- +name: workspace-shared-dependencies +description: Use when adding or modifying a dependency in a crate's `Cargo.toml` that is or may become shared by multiple workspace crates — keep its version defined in one place. +--- + +# Workspace-Level Shared Dependencies + +## Rule + +When adding a dependency that another crate in the workspace already uses (or that you anticipate adding to another crate), declare it once in the root `Cargo.toml`'s `[workspace.dependencies]` table. In each crate that uses it, write: + +```toml +[dependencies] +serde = { workspace = true } +``` + +Don't duplicate version strings across crates. Don't keep a dependency crate-local once a second crate adopts it — promote it to the workspace. + +For dependencies used in only one crate, keep them crate-local. Promote when a second crate starts using them. + +## Why + +A workspace declaration is the single source of truth for a shared version. Without it, each crate pins its own, versions drift, cargo resolves duplicates, and compile times grow. + +## Examples + +```toml +# Good (workspace root) +[workspace.dependencies] +serde = "1.0" +thiserror = "1.0" + +# Good (crate) +[dependencies] +serde = { workspace = true, features = ["derive"] } +thiserror = { workspace = true } +``` + +```toml +# Bad: two crates pinning the same dep at potentially different versions +# crates/foo/Cargo.toml +[dependencies] +serde = "1.0" + +# crates/bar/Cargo.toml +[dependencies] +serde = "1.0.130" +``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 3aca0e4aca..b979014a98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,76 @@ # Changelog +## v0.16.0 (TBD) + +### Changes + +- Added a skeleton batch kernel ([#1122](https://github.com/0xMiden/protocol/issues/1122)) wired through `LocalBatchProver::prove` and attached to `ProvenBatch` as an `ExecutionProof`. It does not yet perform any verification. +- [BREAKING] Renamed `AccountStorageDelta` to `AccountStoragePatch` ([#3002](https://github.com/0xMiden/protocol/pull/3002)). +- [BREAKING] Replaced the per-tree account and nullifier backend traits with shared `SmtBackend` and `SmtBackendReader` traits, split into read-only and read-write capabilities, enabling read-only `LargeSmt`-backed tree views via `reader()` ([#2755](https://github.com/0xMiden/protocol/pull/2755), [#3009](https://github.com/0xMiden/protocol/pull/3009)). +- [BREAKING] Block validator signatures are now verified against the validator key committed to by the parent block, enabling safe validator key rotation. `BlockHeader::validator_key` now denotes the signer of the *next* block, `ProvenBlock`/`SignedBlock` `new` no longer verify the signature (pass the parent header to `validate` to authenticate a block against its parent's validator key), and `ProposedBlock` serialization gained a trailing `next_validator_key` field ([#3030](https://github.com/0xMiden/protocol/pull/3030)). +- [BREAKING] Refactored `TransferPolicy`, `MintPolicyConfig`, and `BurnPolicyConfig` from enums into structs ([#2974](https://github.com/0xMiden/protocol/pull/2974)). +- Added `AccountComponent::has_procedure(root)` helper ([#2974](https://github.com/0xMiden/protocol/pull/2974)). +- Added `active_note::is_public` and `active_note::is_private` MASM procedures for checking whether the active note is public or private ([#2988](https://github.com/0xMiden/protocol/pull/2988)). +- Clarified the transaction definition and the distinction between execution and proving on the architecture overview page ([#3015](https://github.com/0xMiden/protocol/pull/3015)). +- Added a `min_burn_amount` fungible faucet burn policy that rejects burns below a configurable, owner-gated minimum burn amount ([#3021](https://github.com/0xMiden/protocol/pull/3021)). +- [BREAKING] Renamed the `miden-tx-batch-prover` crate to `miden-tx-batch` ([#3035](https://github.com/0xMiden/protocol/pull/3035)). +- Added the `active_account::has_storage_slot` MASM procedure for checking whether a storage slot exists on the active account without panicking ([#3037](https://github.com/0xMiden/protocol/pull/3037)). +- Added `Note::has_attachments` and `NoteMetadata::has_attachments` helpers, and retained private note attachments in `MockChain` ([#3060](https://github.com/0xMiden/protocol/pull/3060)). +- Introduced `AccountPatch` and `AccountVaultPatch` ([#3010](https://github.com/0xMiden/protocol/pull/3010), [#3071](https://github.com/0xMiden/protocol/pull/3071)). +- Added `AccountPatch::merge` for combining patches across consecutive transactions ([#3082](https://github.com/0xMiden/protocol/pull/3082)). +- [BREAKING] Replaced the account delta in `AccountUpdateDetails`, `TxAccountUpdate`, and the kernel-emitted account update commitment with the account patch ([#3089](https://github.com/0xMiden/protocol/pull/3089)). +- Optimized protocol MASM stack-cleaning sequences, saving 1 cycle per occurrence across 9 single-element-extraction procedures ([#3041](https://github.com/0xMiden/protocol/pull/3041)). +- [BREAKING] Removed `AuthMethod` enum, `AccountAuthComponent` / `AccountAuthScheme`, and the `AccessControl::AuthControlled` variant. Faucet and wallet factories now take concrete auth-component types so invalid configurations are rejected at compile time ([#2944](https://github.com/0xMiden/protocol/pull/2944)). +- [BREAKING] Split `create_fungible_faucet` into `create_user_fungible_faucet(auth_component: AuthSingleSigAcl, ...)` (installs `Authority::AuthControlled` directly) and the opinionated `create_network_fungible_faucet(access_control, ...)` (always `AccountType::Public`, builds the `AuthNetworkAccount` allowlist internally from `MintNote` + `BurnNote` script roots with an empty tx-script allowlist). Other auth schemes / shapes are no longer supported through these helpers — fall back to `AccountBuilder` directly. A `user_faucet_single_sig_acl` testing helper is provided behind the `testing` feature ([#2944](https://github.com/0xMiden/protocol/pull/2944)). +- Added `create_multisig_wallet` and `create_guarded_wallet` helpers for `BasicWallet` accounts authenticated by `AuthMultisig` and `AuthGuardedMultisig` respectively ([#2944](https://github.com/0xMiden/protocol/pull/2944)). +- [BREAKING] `create_basic_wallet` now takes `AuthSingleSig` directly and returns `AccountError` instead of the removed `BasicWalletError` ([#2944](https://github.com/0xMiden/protocol/pull/2944)). +- [BREAKING] Removed `AccountInterface::auth()` and `AccountComponentInterface::auth_scheme()`. Auth components are now discovered via `AccountInterface::auth_components()`, which iterates `AccountComponentInterface` variants flagged by `is_auth_component()` ([#2944](https://github.com/0xMiden/protocol/pull/2944)). +- [BREAKING] `FungibleFaucet` no longer installs the `is_paused` storage slot itself. Faucet factories (`create_user_fungible_faucet` / `create_network_fungible_faucet`) now bundle the `Pausable` component (slot + `is_paused()` view procedure) alongside `PausableManager`. Callers using `AccountBuilder` directly must also install `Pausable` or the faucet's mint / burn / transfer / metadata-setter procedures will panic at runtime ([#2944](https://github.com/0xMiden/protocol/pull/2944)). +- Optimized `rbac::grant_role_internal` and `rbac::revoke_role_internal` by removing the redundant membership read and rearranging the stack ([#3090](https://github.com/0xMiden/protocol/pull/3090)). +- [BREAKING] Refactored `TokenPolicyManager` by adding `invoke_send_policy` / `invoke_receive_policy` wrappers (stored in the protocol reserved asset callback slots) that read the active policy root from the new `active_send_policy_proc_root` / `active_receive_policy_proc_root` storage slots ([#3047](https://github.com/0xMiden/protocol/pull/3047)). +- Added regression tests ensuring a `TokenPolicyManager` with only reserved send/receive policies installs the protocol reserved asset callback slots, so `has_callbacks` is correct from creation and minted assets carry the callback flag ([#3091](https://github.com/0xMiden/protocol/pull/3091)). +- Fixed the `TokenPolicyManager` `get_mint_policy` / `get_burn_policy` / `get_send_policy` / `get_receive_policy` getters to align the 16-felt `call` ABI. ([#3114](https://github.com/0xMiden/protocol/pull/3114)). +- Added a definition of the Miden operator on the architecture overview page and linked it from the note lifecycle ([#3017](https://github.com/0xMiden/protocol/pull/3017)). +- [BREAKING] Extended `Authority::RbacControlled` to assign roles per authority-gated procedure ([#3072](https://github.com/0xMiden/protocol/pull/3072)). +- Clarified Miden's operational roles on the architecture overview page and linked them from the note lifecycle ([#3017](https://github.com/0xMiden/protocol/pull/3017)). +- [BREAKING] Added GER removal mechanism with a dedicated `ger_remover` role, `remove_ger` MASM procedure, `REMOVE_GER` note script, `RemoveGerNote` Rust helper, and a running keccak256 removed-GER hash chain; `AggLayerBridge::new`, `create_bridge_account`, and `create_existing_bridge_account` now take a `ger_remover_id` argument ([#2837](https://github.com/0xMiden/protocol/pull/2837)). +- [BREAKING] Unified the fungible and non-fungible asset vault deltas into a single asset delta, changing the on-chain account delta commitment layout ([#3038](https://github.com/0xMiden/protocol/pull/3038)). +- Added the canonical `ExpirationTransactionScript` to `miden-standards`, with a delta-independent script root that network accounts can allowlist ([#3051](https://github.com/0xMiden/protocol/pull/3051)). +- [BREAKING] Replaced `AccountInterface::build_send_notes_script` with a standalone `SendNotesTransactionScript` built against `AccountCodeInterface` ([#3055](https://github.com/0xMiden/protocol/pull/3055)). +- Added an `AccountCode::interface` helper that returns the public `AccountCodeInterface` ([#3080](https://github.com/0xMiden/protocol/pull/3080)). +- [BREAKING] Tightened `AccountStorage::get_map_item` to take a `StorageMapKey` instead of a raw `Word` ([#3080](https://github.com/0xMiden/protocol/pull/3080)). +- [BREAKING] Renamed the `TransactionEvent::AuthRequest` field from `pub_key_hash: Word` to `pub_key_commitment: PublicKeyCommitment` ([#3080](https://github.com/0xMiden/protocol/pull/3080)). +- Simplified the Ownable2Step owner-check API: merged the owner assertion into a single `exec` `assert_sender_is_owner` and renamed `is_sender_owner_internal` to `is_sender_owner` ([#3088](https://github.com/0xMiden/protocol/pull/3088)). +- Added a global emergency switch to `Authority`: owner-gated `freeze` / `unfreeze` procedures toggle an `is_frozen` flag that makes `assert_authorized` block every authority-gated procedure at once, regardless of role or owner membership ([#3102](https://github.com/0xMiden/protocol/pull/3102)). +- [BREAKING] Removed the automatic fee computation and removal from the transaction kernel ([#3108](https://github.com/0xMiden/protocol/issues/3108)). +- [BREAKING] Removed `AccountDelta` from `ExecutedTransaction` which is replaced by `AccountPatch` ([#3109](https://github.com/0xMiden/protocol/pull/3109)). +- [BREAKING] Removed `Account::apply_delta` and `AccountDelta::merge` ([#3110](https://github.com/0xMiden/protocol/pull/3110)). +- [BREAKING] Made the RBAC role guard `rbac::assert_sender_has_role` an `exec` procedure and removed it from the `RoleBasedAccessControl` component re-exports ([#3116](https://github.com/0xMiden/protocol/pull/3116)). +- Added a validation inside `set_max_supply` rejects a new cap above `FUNGIBLE_ASSET_MAX_AMOUNT`, keeping the stored cap consistent with the bound enforced at mint time ([#3118](https://github.com/0xMiden/protocol/pull/3118)). +- Fixed misleading documentation in the faucet and transfer policy procedures ([#3119](https://github.com/0xMiden/protocol/pull/3119)). +- Refactored `is_max_supply_mutable_internal` in the fungible faucet to read the mutability config through the `get_mutability_config_word` getter instead of accessing the storage slot directly ([#3120](https://github.com/0xMiden/protocol/pull/3120)). +- Added a zero-root check before dispatching the active mint and burn policy in `TokenPolicyManager`, failing with a descriptive error ([#3121](https://github.com/0xMiden/protocol/pull/3121)). + +### Fixes +- Fixed `update_ger` to explicitly reject duplicate GER insertions with `ERR_GER_ALREADY_REGISTERED` instead of silently accepting them ([#2983](https://github.com/0xMiden/protocol/pull/2983)). +- AggLayer `bridge_out` now rejects B2AGG notes whose `NoteType` is not `Public`, preventing a recipient-identical private note from desyncing the Local Exit Tree from AggLayer's off-chain mirror ([#2988](https://github.com/0xMiden/protocol/pull/2988)). +- Fixed `pausable::assert_not_paused` to guard its storage read with `active_account::has_storage_slot`, making it a no-op on accounts without the `Pausable` component instead of panicking on the missing `is_paused` slot ([#3047](https://github.com/0xMiden/protocol/pull/3047)). +- [BREAKING] Fixed batch ID being serialized/deserialized and potentially not matching the serialized transaction headers ([#3061](https://github.com/0xMiden/protocol/pull/3061)). + +## v0.15.2 (TBD) + +### Changes + +- [BREAKING] `AuthNetworkAccount` now gates transaction scripts with a root allowlist instead of banning them outright, enabling network accounts to run approved tx scripts such as setting the expiration delta ([#3028](https://github.com/0xMiden/protocol/pull/3028)). +- [BREAKING] `TransactionScript::root()` now returns `TransactionScriptRoot` instead of `Word` ([#3028](https://github.com/0xMiden/protocol/pull/3028)). +- Renamed `AuthNetworkAccount::with_allowlist` to `with_allowed_notes` and aligned the component's internal allowlist field names, for consistency with `with_allowed_tx_scripts` ([#3049](https://github.com/0xMiden/protocol/pull/3049)). + +## v0.15.1 (TBD) + +### Changes + +- Reject batches and blocks where an unauthenticated note is consumed before it is created to prevent circular note dependencies ([#2993](https://github.com/0xMiden/protocol/pull/2993)). + ## v0.15.0 (2026-05-22) ### Features @@ -59,6 +130,7 @@ - Added standardized `NetworkAccountNoteAllowlist` slot for detecting network accounts ([#2883](https://github.com/0xMiden/protocol/pull/2883)). - [BREAKING] Merged `BasicFungibleFaucet` and `NetworkFungibleFaucet` ([#2890](https://github.com/0xMiden/protocol/pull/2890)). - [BREAKING] Renamed `NoteMetadata` to `PartialNoteMetadata` and renamed `NoteMetadataHeader` to `NoteMetadata` ([#2887](https://github.com/0xMiden/protocol/pull/2887)). +- [BREAKING] Hashed `AssetVaultKey` before insertion into the asset vault SMT ([#2912](https://github.com/0xMiden/protocol/pull/2912)). - [BREAKING] Renamed account ID version 0 to version 1 and made encoded version 0 invalid ([#2842](https://github.com/0xMiden/protocol/issues/2842)). - [BREAKING] Changed note metadata version 1 to encode as `1`, leaving encoded version `0` invalid. - [BREAKING] Added `NetworkAccount` wrapper for convenient network account identification ([#2915](https://github.com/0xMiden/protocol/pull/2915)). @@ -71,6 +143,7 @@ - [BREAKING] Removed `AccountType` and renamed `AccountStorageMode` to `AccountType` ([#2939](https://github.com/0xMiden/protocol/pull/2939), [#2942](https://github.com/0xMiden/protocol/pull/2942)). - [BREAKING] Updated note nullifiers to include note metadata and attachments commitment ([#2953](https://github.com/0xMiden/protocol/pull/2953)). - Exposed `token_config_slot_value` on `FungibleFaucet` to allow reading the token config word directly from the account storage ([#2954](https://github.com/0xMiden/protocol/pull/2954)). +- [BREAKING] Introduced `AccountCodeInterface` ([#2924](https://github.com/0xMiden/protocol/pull/2924)). ### Fixes diff --git a/Cargo.lock b/Cargo.lock index dba1077aa0..86f3e4c58d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1599,7 +1599,7 @@ dependencies = [ [[package]] name = "miden-agglayer" -version = "0.15.0" +version = "0.16.0" dependencies = [ "alloy-sol-types", "fs-err", @@ -1678,7 +1678,7 @@ dependencies = [ [[package]] name = "miden-block-prover" -version = "0.15.0" +version = "0.16.0" dependencies = [ "miden-protocol", "thiserror", @@ -1962,7 +1962,7 @@ dependencies = [ [[package]] name = "miden-protocol" -version = "0.15.0" +version = "0.16.0" dependencies = [ "anyhow", "assert_matches", @@ -2022,7 +2022,7 @@ dependencies = [ [[package]] name = "miden-standards" -version = "0.15.0" +version = "0.16.0" dependencies = [ "anyhow", "assert_matches", @@ -2062,7 +2062,7 @@ dependencies = [ [[package]] name = "miden-testing" -version = "0.15.0" +version = "0.16.0" dependencies = [ "anyhow", "assert_matches", @@ -2076,7 +2076,7 @@ dependencies = [ "miden-protocol", "miden-standards", "miden-tx", - "miden-tx-batch-prover", + "miden-tx-batch", "primitive-types", "rand 0.9.4", "rand_chacha", @@ -2089,23 +2089,25 @@ dependencies = [ [[package]] name = "miden-tx" -version = "0.15.0" +version = "0.16.0" dependencies = [ "miden-processor", "miden-protocol", "miden-prover", "miden-standards", - "miden-verifier", "rand_chacha", "thiserror", ] [[package]] -name = "miden-tx-batch-prover" -version = "0.15.0" +name = "miden-tx-batch" +version = "0.16.0" dependencies = [ + "miden-processor", "miden-protocol", - "miden-tx", + "miden-prover", + "miden-verifier", + "thiserror", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 94f216c54e..294c15af92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ "crates/miden-standards", "crates/miden-testing", "crates/miden-tx", - "crates/miden-tx-batch-prover", + "crates/miden-tx-batch", ] resolver = "3" @@ -20,7 +20,7 @@ homepage = "https://miden.xyz" license = "MIT" repository = "https://github.com/0xMiden/protocol" rust-version = "1.90" -version = "0.15.0" +version = "0.16.0" [profile.release] codegen-units = 1 @@ -36,13 +36,13 @@ lto = true [workspace.dependencies] # Workspace crates -miden-agglayer = { default-features = false, path = "crates/miden-agglayer", version = "0.15" } -miden-block-prover = { default-features = false, path = "crates/miden-block-prover", version = "0.15" } -miden-protocol = { default-features = false, path = "crates/miden-protocol", version = "0.15" } -miden-standards = { default-features = false, path = "crates/miden-standards", version = "0.15" } -miden-testing = { default-features = false, path = "crates/miden-testing", version = "0.15" } -miden-tx = { default-features = false, path = "crates/miden-tx", version = "0.15" } -miden-tx-batch-prover = { default-features = false, path = "crates/miden-tx-batch-prover", version = "0.15" } +miden-agglayer = { default-features = false, path = "crates/miden-agglayer", version = "0.16" } +miden-block-prover = { default-features = false, path = "crates/miden-block-prover", version = "0.16" } +miden-protocol = { default-features = false, path = "crates/miden-protocol", version = "0.16" } +miden-standards = { default-features = false, path = "crates/miden-standards", version = "0.16" } +miden-testing = { default-features = false, path = "crates/miden-testing", version = "0.16" } +miden-tx = { default-features = false, path = "crates/miden-tx", version = "0.16" } +miden-tx-batch = { default-features = false, path = "crates/miden-tx-batch", version = "0.16" } # Miden dependencies miden-assembly = { default-features = false, version = "0.23" } diff --git a/bin/bench-transaction/bench-tx.json b/bin/bench-transaction/bench-tx.json index 081a3ea21f..e277d65dcf 100644 --- a/bin/bench-transaction/bench-tx.json +++ b/bin/bench-transaction/bench-tx.json @@ -1,174 +1,224 @@ { "consume single P2ID note with Falcon signing": { - "prologue": 3512, - "notes_processing": 1757, + "prologue": 3657, + "notes_processing": 1836, "note_execution": { - "0x2cc6f4f31352e856292d9daf412f97468ffce9993e8e9b403a5d4a4fb7be8163": 1717 + "0x90ed93aa718d7deac71ebb550c74ddd914471ccc645ad36fefb7be7a0cd76a50": 1796 }, "tx_script_processing": 42, "epilogue": { - "total": 72815, - "auth_procedure": 71322, - "after_tx_cycles_obtained": 603 + "total": 73562, + "auth_procedure": 71653, + "after_tx_cycles_obtained": 687 }, "trace": { - "core_rows": 78170, - "chiplets_rows": 61069, - "range_rows": 20367, + "core_rows": 79141, + "chiplets_rows": 62347, + "range_rows": 20451, "chiplets_shape": { - "hasher_rows": 58320, - "bitwise_rows": 392, - "memory_rows": 2301, - "kernel_rom_rows": 55, + "hasher_rows": 59264, + "bitwise_rows": 696, + "memory_rows": 2330, + "kernel_rom_rows": 56, "ace_rows": 0 } } }, "consume single P2ID note with ECDSA signing": { - "prologue": 3512, - "notes_processing": 1757, + "prologue": 3657, + "notes_processing": 1836, "note_execution": { - "0x48854330460cc896ea9d2761baeff069b6deea60c70a590f06c0cca7c75ef30b": 1717 + "0xbc5842419e6cce40ef02662d411152992cb038f86a5d179955f1e411c86ce4c9": 1796 }, "tx_script_processing": 42, "epilogue": { - "total": 4823, - "auth_procedure": 3330, - "after_tx_cycles_obtained": 603 + "total": 5570, + "auth_procedure": 3661, + "after_tx_cycles_obtained": 687 }, "trace": { - "core_rows": 10178, - "chiplets_rows": 18845, - "range_rows": 1201, + "core_rows": 11149, + "chiplets_rows": 20091, + "range_rows": 1281, "chiplets_shape": { - "hasher_rows": 17728, - "bitwise_rows": 392, - "memory_rows": 669, - "kernel_rom_rows": 55, + "hasher_rows": 18640, + "bitwise_rows": 696, + "memory_rows": 698, + "kernel_rom_rows": 56, "ace_rows": 0 } } }, "consume two P2ID notes": { - "prologue": 4592, - "notes_processing": 3636, + "prologue": 4863, + "notes_processing": 3794, "note_execution": { - "0x6a0b18f0dd3a23ac61b77ac70c4b1be62878087d3af8c235eac5ef6e453f964c": 1870, - "0xa469507fa4c9f2259e30a2052b5d700e0cfe6be3b6c3eb8f747ed8d86e8fb59a": 1717 + "0x5153234001bf2a836f22dd97a65c0b196e4c1d65eecfbe50492cfda75592c69c": 1949, + "0x77a169617f76d8ce94eb21a95c128e7921e1d42ab1c2a685d6e3755f8e5643b8": 1796 }, "tx_script_processing": 42, "epilogue": { - "total": 72763, - "auth_procedure": 71296, - "after_tx_cycles_obtained": 603 + "total": 73510, + "auth_procedure": 71627, + "after_tx_cycles_obtained": 687 }, "trace": { - "core_rows": 81077, - "chiplets_rows": 63669, - "range_rows": 20335, + "core_rows": 82253, + "chiplets_rows": 65149, + "range_rows": 20389, "chiplets_shape": { - "hasher_rows": 60656, - "bitwise_rows": 560, - "memory_rows": 2397, - "kernel_rom_rows": 55, + "hasher_rows": 61664, + "bitwise_rows": 1000, + "memory_rows": 2428, + "kernel_rom_rows": 56, "ace_rows": 0 } } }, "create single P2ID note": { - "prologue": 1791, + "prologue": 1810, "notes_processing": 32, "note_execution": {}, - "tx_script_processing": 1661, + "tx_script_processing": 1777, "epilogue": { - "total": 73917, - "auth_procedure": 71646, - "after_tx_cycles_obtained": 603 + "total": 74817, + "auth_procedure": 72000, + "after_tx_cycles_obtained": 687 }, "trace": { - "core_rows": 77445, - "chiplets_rows": 59632, - "range_rows": 20271, + "core_rows": 78480, + "chiplets_rows": 60946, + "range_rows": 20207, "chiplets_shape": { - "hasher_rows": 57008, - "bitwise_rows": 328, - "memory_rows": 2240, - "kernel_rom_rows": 55, + "hasher_rows": 57936, + "bitwise_rows": 672, + "memory_rows": 2281, + "kernel_rom_rows": 56, "ace_rows": 0 } } }, "consume CLAIM note (L1 to Miden)": { - "prologue": 2979, - "notes_processing": 29656, + "prologue": 3121, + "notes_processing": 28891, "note_execution": { - "0xa1946d015e643cf1956ab511c73583f75c30017c417cd40961d38b8e17ad3ad3": 29616 + "0x3238c24911d58231d7bda2fd3e26eb625c1a21a172f321f1b8b54cd6e9734dda": 28851 }, "tx_script_processing": 42, "epilogue": { - "total": 5249, - "auth_procedure": 1781, - "after_tx_cycles_obtained": 603 + "total": 5720, + "auth_procedure": 1809, + "after_tx_cycles_obtained": 687 }, "trace": { - "core_rows": 37970, - "chiplets_rows": 45953, - "range_rows": 2685, + "core_rows": 37818, + "chiplets_rows": 45994, + "range_rows": 2491, "chiplets_shape": { - "hasher_rows": 39728, - "bitwise_rows": 2728, - "memory_rows": 3441, - "kernel_rom_rows": 55, + "hasher_rows": 39824, + "bitwise_rows": 2752, + "memory_rows": 3361, + "kernel_rom_rows": 56, "ace_rows": 0 } } }, "consume CLAIM note (L2 to Miden)": { - "prologue": 2979, - "notes_processing": 41869, + "prologue": 3121, + "notes_processing": 41104, "note_execution": { - "0x7c4ab3b63bd083dfd2a48a147bf31d45d827a4caf46e3d6515798dbbccf1094e": 41829 + "0xc562329e136d746aa2b69fd0c124dede46c35c4afc7df5bda28ad9e040e8d13b": 41064 }, "tx_script_processing": 42, "epilogue": { - "total": 5249, - "auth_procedure": 1781, - "after_tx_cycles_obtained": 603 + "total": 5720, + "auth_procedure": 1809, + "after_tx_cycles_obtained": 687 }, "trace": { - "core_rows": 50183, - "chiplets_rows": 52159, - "range_rows": 2905, + "core_rows": 50031, + "chiplets_rows": 52200, + "range_rows": 2723, "chiplets_shape": { - "hasher_rows": 44448, - "bitwise_rows": 2984, - "memory_rows": 4671, - "kernel_rom_rows": 55, + "hasher_rows": 44544, + "bitwise_rows": 3008, + "memory_rows": 4591, + "kernel_rom_rows": 56, "ace_rows": 0 } } }, "consume B2AGG note (bridge-out)": { - "prologue": 3793, - "notes_processing": 145479, + "prologue": 4042, + "notes_processing": 115765, "note_execution": { - "0xa76b8bcf2daa5556bb5c2bd421dac2695996e86095648e16f8c608349025ff51": 145439 + "0x834e0fd024a73952937b8b2383b7a6e5dc452397ba7e3bfb100911757f5a392a": 115725 }, "tx_script_processing": 42, "epilogue": { - "total": 14898, - "auth_procedure": 1781, - "after_tx_cycles_obtained": 603 + "total": 15476, + "auth_procedure": 1809, + "after_tx_cycles_obtained": 687 }, "trace": { - "core_rows": 164256, - "chiplets_rows": 178248, - "range_rows": 4361, + "core_rows": 135369, + "chiplets_rows": 165022, + "range_rows": 4001, "chiplets_shape": { - "hasher_rows": 163312, - "bitwise_rows": 2680, - "memory_rows": 12200, - "kernel_rom_rows": 55, + "hasher_rows": 151920, + "bitwise_rows": 3464, + "memory_rows": 9581, + "kernel_rom_rows": 56, + "ace_rows": 0 + } + } + }, + "consume B2AGG note (bridge-out, 2^31 leaves)": { + "prologue": 4042, + "notes_processing": 114080, + "note_execution": { + "0x834e0fd024a73952937b8b2383b7a6e5dc452397ba7e3bfb100911757f5a392a": 114040 + }, + "tx_script_processing": 42, + "epilogue": { + "total": 15188, + "auth_procedure": 1809, + "after_tx_cycles_obtained": 687 + }, + "trace": { + "core_rows": 133396, + "chiplets_rows": 165371, + "range_rows": 4019, + "chiplets_shape": { + "hasher_rows": 152400, + "bitwise_rows": 3464, + "memory_rows": 9450, + "kernel_rom_rows": 56, + "ace_rows": 0 + } + } + }, + "consume B2AGG note (bridge-out, 2^31-1 leaves)": { + "prologue": 4042, + "notes_processing": 60091, + "note_execution": { + "0x834e0fd024a73952937b8b2383b7a6e5dc452397ba7e3bfb100911757f5a392a": 60051 + }, + "tx_script_processing": 42, + "epilogue": { + "total": 6548, + "auth_procedure": 1809, + "after_tx_cycles_obtained": 687 + }, + "trace": { + "core_rows": 70767, + "chiplets_rows": 72065, + "range_rows": 2801, + "chiplets_shape": { + "hasher_rows": 63024, + "bitwise_rows": 3464, + "memory_rows": 5520, + "kernel_rom_rows": 56, "ace_rows": 0 } } diff --git a/bin/bench-transaction/src/context_setups.rs b/bin/bench-transaction/src/context_setups.rs index 75e47ef1f3..fbdf73c349 100644 --- a/bin/bench-transaction/src/context_setups.rs +++ b/bin/bench-transaction/src/context_setups.rs @@ -15,7 +15,7 @@ use miden_agglayer::{ }; use miden_protocol::account::auth::AuthScheme; use miden_protocol::account::{Account, StorageMapKey}; -use miden_protocol::asset::{Asset, FungibleAsset}; +use miden_protocol::asset::{Asset, AssetCallbackFlag, FungibleAsset}; use miden_protocol::crypto::rand::FeltRng; use miden_protocol::note::{NoteAssets, NoteType}; use miden_protocol::testing::account_id::ACCOUNT_ID_SENDER; @@ -182,15 +182,24 @@ pub async fn tx_consume_claim_note(data_source: ClaimDataSource) -> Result Result) -> Result) -> Result) -> Result [1, 0, 0, 0]` in the `ger_map`, marking the GER as known. +4. Reverts if the GER was already present in the map (duplicate insertions are rejected). Subsequent CLAIM notes reference a GER that must be present in this map for the claim to be valid. -TODO: GERs cannot be removed once inserted -([#2702](https://github.com/0xMiden/protocol/issues/2702)). +> **Note on Solidity divergence:** The Solidity `GlobalExitRootManager` contract treats a +> duplicate GER insertion as an idempotent no-op. Miden intentionally diverges: a duplicate +> `UPDATE_GER` note causes the consuming transaction to revert. Because `UPDATE_GER` is a +> network note (consumed by the note nullifier mechanism), a duplicate would become +> permanently unconsumable rather than silently accepted. Rejecting duplicates makes the +> failure explicit and prevents the GER injector from accidentally creating unconsumed notes. + +A separate GER Remover role can revoke a previously-registered GER by sending a +[`REMOVE_GER`](#45-remove_ger) note. The bridge consumes such a note and: + +1. Asserts the note sender is the designated GER remover (a role distinct from the GER + injector so that insertion and revocation authority can be split). +2. Computes `KEY = poseidon2::merge(GER_LOWER, GER_UPPER)`. +3. Asserts that `ger_map[KEY] == [1, 0, 0, 0]`, i.e. that the GER is currently known. +4. Overwrites `ger_map[KEY]` with `[0, 0, 0, 0]`, the Miden equivalent of Solidity's + `delete globalExitRootMap[ger]`. After this, any CLAIM note referencing the removed + GER will fail `assert_valid_ger`. +5. Updates a running keccak256 hash chain over all removed GERs: + `removed_ger_hash_chain = keccak256(removed_ger_hash_chain || removed_ger)`. This + chain is stored across two Word slots (`removed_ger_hash_chain_lo` / + `removed_ger_hash_chain_hi`) and mirrors the + `removeGlobalExitRoots` chain in Solidity's + `GlobalExitRootManagerL2SovereignChain`, providing an auditable record of every + removal. + +GER removal is an exceptional, emergency-only control: under normal operation GERs are +only ever injected, never removed. A removal is expected when a GER was registered that +should not have been - for example because an invalid or malicious exit root was +propagated from the upstream AggLayer/L1 state, or a GER was injected in error. Removing +the GER closes the claim window it opened: any `CLAIM` note that has not yet been +processed and that references the removed GER will fail `assert_valid_ger` and revert. +Claims that were already processed against the GER are not reversed - removal only +prevents future claims against that root. + +Note that removal does not blocklist a GER permanently: because the map entry is reset +to the empty word, the GER injector can re-register the same GER via a subsequent +`UPDATE_GER` note (re-insertion does not touch the removal chain). This is a security +caveat worth calling out: a compromised or faulty GER injector can undo a `REMOVE_GER` +emergency patch and re-open the very claim window the removal was meant to close. The +split between the injector and remover roles bounds this only if the offending role can be +rotated out, which is not yet supported +([#2706](https://github.com/0xMiden/protocol/issues/2706)). The removed-GER hash chain is +therefore an append-only log of removal events, not a registry of currently revoked GERs +- a GER listed in the chain may have been revived since its removal. TODO: No hash chain tracks GER insertions for proof generation ([#2707](https://github.com/0xMiden/protocol/issues/2707)). -TODO: Duplicate GER insertions are silently accepted -([#2708](https://github.com/0xMiden/protocol/issues/2708)). - ### 2.4 Faucet Registration ![Faucet registration flow](diagrams/faucet-registration.png) @@ -156,14 +196,17 @@ TODO: Faucet existence and code commitment are not validated during registration ### 2.5 Administration -The bridge has two administrative roles set at account creation time: +The bridge has three administrative roles set at account creation time: - **Bridge admin** (`admin_account_id`): authorizes faucet registration via [`CONFIG_AGG_BRIDGE`](#43-config_agg_bridge) notes. -- **GER manager** (`ger_manager_account_id`): authorizes GER updates via [`UPDATE_GER`](#44-update_ger) +- **GER injector** (`ger_injector_account_id`): authorizes GER updates via [`UPDATE_GER`](#44-update_ger) notes. +- **GER remover** (`ger_remover_account_id`): authorizes GER removals via + [`REMOVE_GER`](#45-remove_ger) notes. Kept distinct from the GER injector so that insertion + and revocation authority can be split. -Both roles are verified by checking the note sender against the stored account ID. +All roles are verified by checking the note sender against the stored account ID. TODO: Administrative roles cannot be transferred after account creation ([#2706](https://github.com/0xMiden/protocol/issues/2706)). @@ -182,6 +225,7 @@ which is a thin wrapper that re-exports procedures from the `agglayer` library m - `bridge_config::register_faucet` - `bridge_config::update_ger` +- `bridge_config::remove_ger` - `bridge_in::claim` - `bridge_out::bridge_out` @@ -237,12 +281,14 @@ Asserts the note sender matches the bridge admin stored in | **Inputs** | `[GER_LOWER(4), GER_UPPER(4), pad(8)]` | | **Outputs** | `[pad(16)]` | | **Context** | Consuming an `UPDATE_GER` note on the bridge account | -| **Panics** | Note sender is not the GER manager | +| **Panics** | Note sender is not the GER injector; GER has already been registered in storage | -Asserts the note sender matches the GER manager stored in -`agglayer::bridge::ger_manager_account_id`, then computes +Asserts the note sender matches the GER injector stored in +`agglayer::bridge::ger_injector_account_id`, then computes `KEY = poseidon2::merge(GER_LOWER, GER_UPPER)` and stores `KEY -> [1, 0, 0, 0]` in the `ger_map` map slot. This marks the GER as "known". +Duplicate insertions (same GER value) are explicitly rejected: if the key already exists +in the map the procedure panics with `ERR_GER_ALREADY_REGISTERED`. #### `bridge_in::claim` @@ -280,7 +326,7 @@ Validates a bridge-in claim and creates a MINT note targeting the faucet: 7. Verifies the `faucet_mint_amount` against the leaf data's U256 amount and the faucet's scale factor (via FPI to `agglayer_faucet::get_scale`), using `asset_conversion::verify_u256_to_native_amount_conversion`. -8. Builds a MINT output note targeting the faucet (see [Section 4.7](#47-mint-generated)). +8. Builds a MINT output note targeting the faucet (see [Section 4.8](#48-mint-generated)). #### Bridge Account Storage @@ -297,10 +343,14 @@ Validates a bridge-in claim and creates a MINT note targeting the faucet: | `agglayer::bridge::cgi_chain_hash_lo` | Value | -- | Lower word of the CGI chain hash | CGI chain hash low word (Keccak-256 lower 16 bytes) | | `agglayer::bridge::cgi_chain_hash_hi` | Value | -- | Upper word of the CGI chain hash | CGI chain hash high word (Keccak-256 upper 16 bytes) | | `agglayer::bridge::admin_account_id` | Value | -- | `[0, 0, admin_suffix, admin_prefix]` | Bridge admin account ID for CONFIG note authorization | -| `agglayer::bridge::ger_manager_account_id` | Value | -- | `[0, 0, mgr_suffix, mgr_prefix]` | GER manager account ID for UPDATE_GER note authorization | +| `agglayer::bridge::ger_injector_account_id` | Value | -- | `[0, 0, mgr_suffix, mgr_prefix]` | GER injector account ID for UPDATE_GER note authorization | +| `agglayer::bridge::ger_remover_account_id` | Value | -- | `[0, 0, rem_suffix, rem_prefix]` | GER remover account ID for REMOVE_GER note authorization | +| `agglayer::bridge::removed_ger_hash_chain_lo` | Value | -- | Lower word of the removed-GER hash chain | Removed-GER hash chain low word (Keccak-256 lower 16 bytes) | +| `agglayer::bridge::removed_ger_hash_chain_hi` | Value | -- | Upper word of the removed-GER hash chain | Removed-GER hash chain high word (Keccak-256 upper 16 bytes) | Initial state: all map slots empty, all value slots `[0, 0, 0, 0]` except -`admin_account_id` and `ger_manager_account_id` (set at account creation time). +`admin_account_id`, `ger_injector_account_id`, and `ger_remover_account_id` (set at account +creation time). ### 3.2 Faucet Account Component @@ -333,7 +383,7 @@ recipient. Requires the faucet's owner (the bridge account) to be the creator of `mint_and_send` executes the current access policy via `exec.policy_manager::execute_mint_policy`). `mint_and_send` then derives the asset to mint for the active faucet and panics if the stored `ASSET_KEY` does not belong to that faucet, -which binds the MINT note to its resolved faucet (see §4.7). +which binds the MINT note to its resolved faucet (see §4.8). #### `agglayer_faucet::get_metadata_hash` @@ -602,7 +652,7 @@ CLAIM notes can be verified against it. | Field | Value | |-------|-------| -| `sender` | GER manager (sender authorization enforced by the bridge's `update_ger` procedure) | +| `sender` | GER injector (sender authorization enforced by the bridge's `update_ger` procedure) | | `note_type` | `NoteType::Public` | | `tag` | `NoteTag::default()` | | `attachment` | `NetworkAccountTarget` -- target is the bridge account; execution hint: Always | @@ -627,17 +677,66 @@ CLAIM notes can be verified against it. | 4-7 | `GER_UPPER` | Last 16 bytes as 4 x u32 felts | **Consumption:** Script validates attachment target, loads storage, and calls -`bridge_config::update_ger` (which asserts sender is GER manager), which computes +`bridge_config::update_ger` (which asserts sender is GER injector), which computes `poseidon2::merge(GER_LOWER, GER_UPPER)` and stores the result in the GER map. #### Permissions | Role | Enforcement | |------|------------| -| **Issuer** | GER manager only -- **enforced** by `bridge_config::update_ger` procedure | +| **Issuer** | GER injector only -- **enforced** by `bridge_config::update_ger` procedure | +| **Consumer** | Bridge account -- **enforced** via `NetworkAccountTarget` attachment | + +### 4.5 REMOVE_GER + +**Purpose:** Removes a previously-registered Global Exit Root (GER) from the bridge account so +that subsequent CLAIM notes referencing it fail validation, and folds the removed GER into the +removed-GER keccak256 hash chain. + +**`NoteHeader`** + +*`NoteMetadata`:* + +| Field | Value | +|-------|-------| +| `sender` | GER remover (sender authorization enforced by the bridge's `remove_ger` procedure) | +| `note_type` | `NoteType::Public` | +| `tag` | `NoteTag::default()` | +| `attachment` | `NetworkAccountTarget` -- target is the bridge account; execution hint: Always | + +**`NoteDetails`** + +*`NoteAssets`:* None (empty). + +*`NoteRecipient`:* + +| Field | Value | +|-------|-------| +| `serial_num` | Random (`rng.draw_word()`) | +| `script` | `remove_ger.masm` | +| `storage` | 8 felts -- see layout below | + +**Storage layout (8 felts):** + +| Range | Field | Encoding | +|-------|-------|----------| +| 0-3 | `GER_LOWER` | First 16 bytes as 4 x u32 felts | +| 4-7 | `GER_UPPER` | Last 16 bytes as 4 x u32 felts | + +**Consumption:** Script validates attachment target, loads storage, and calls +`bridge_config::remove_ger` (which asserts sender is GER remover), which computes +`poseidon2::merge(GER_LOWER, GER_UPPER)`, asserts the GER map entry equals `[1, 0, 0, 0]` +while overwriting it with `[0, 0, 0, 0]`, and updates the removed-GER hash chain as +`keccak256(prev_chain || GER)` (see [Section 2.3](#23-ger-injection)). + +#### Permissions + +| Role | Enforcement | +|------|------------| +| **Issuer** | GER remover only -- **enforced** by `bridge_config::remove_ger` procedure | | **Consumer** | Bridge account -- **enforced** via `NetworkAccountTarget` attachment | -### 4.5 BURN (generated) +### 4.6 BURN (generated) **Purpose:** Created by `bridge_out::bridge_out` to burn the bridged asset on the faucet. @@ -681,7 +780,7 @@ decreases the faucet's total token supply by the burned amount. | **Issuer** | Bridge account (created by `bridge_out::bridge_out`) | | **Consumer** | Target faucet only -- **enforced** via `NetworkAccountTarget` attachment | -### 4.6 P2ID (generated) +### 4.7 P2ID (generated) **Purpose:** Created by the faucet (via `mint_and_send`) when consuming a MINT note, to deliver minted assets to the recipient. @@ -729,7 +828,7 @@ script). All note assets are added to the consuming account via | **Issuer** | Faucet account (created by `mint_and_send`) | | **Consumer** | Destination account only -- **enforced** by P2ID script (checks `target_account_id`) | -### 4.7 MINT (generated) +### 4.8 MINT (generated) **Purpose:** Created by `bridge_in::claim` on the bridge account. Consumed by the faucet to mint and distribute assets to the recipient. diff --git a/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm b/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm index 15123668f2..5c27768e8f 100644 --- a/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm +++ b/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm @@ -1,4 +1,6 @@ +use miden::core::crypto::hashes::keccak256 use miden::core::crypto::hashes::poseidon2 +use miden::core::word use miden::protocol::account_id use miden::protocol::active_account use miden::protocol::active_note @@ -9,22 +11,35 @@ use agglayer::common::utils # ================================================================================================= const ERR_GER_NOT_FOUND = "GER not found in storage" +const ERR_GER_ALREADY_REGISTERED = "GER is already registered in storage" const ERR_FAUCET_NOT_REGISTERED = "faucet is not registered in the bridge's faucet registry" const ERR_TOKEN_NOT_REGISTERED = "(origin token address, origin network) pair is not registered in the bridge's token registry" const ERR_SENDER_NOT_BRIDGE_ADMIN = "note sender is not the bridge admin" -const ERR_SENDER_NOT_GER_MANAGER = "note sender is not the global exit root manager" +const ERR_SENDER_NOT_GER_INJECTOR = "note sender is not the global exit root injector" +const ERR_SENDER_NOT_GER_REMOVER = "note sender is not the global exit root remover" # CONSTANTS # ================================================================================================= # Storage slots const BRIDGE_ADMIN_SLOT = word("agglayer::bridge::admin_account_id") -const GER_MANAGER_SLOT = word("agglayer::bridge::ger_manager_account_id") +const GER_INJECTOR_SLOT = word("agglayer::bridge::ger_injector_account_id") +const GER_REMOVER_SLOT = word("agglayer::bridge::ger_remover_account_id") const GER_MAP_STORAGE_SLOT = word("agglayer::bridge::ger_map") const FAUCET_REGISTRY_MAP_SLOT = word("agglayer::bridge::faucet_registry_map") const TOKEN_REGISTRY_MAP_SLOT = word("agglayer::bridge::token_registry_map") const FAUCET_METADATA_MAP_SLOT = word("agglayer::bridge::faucet_metadata_map") +# Storage slot constants for the removed GER hash chain. +# The chain is updated as `keccak256(prev_chain || ger)` on each removal and stored in two +# separate value slots (lo/hi) since a Word holds only 4 felts but the chain is 8 felts. +# Both slots default to the empty value [0, 0, 0, 0] until the first removal, so the chain starts +# from 32 zero bytes and the first removal yields `keccak256(0..0 || ger)`. This matches the +# zero-initialized `removedGERHashChain` (a `bytes32`) in Solidity's +# `GlobalExitRootManagerL2SovereignChain`. +const REMOVED_GER_HASH_CHAIN_LO_SLOT = word("agglayer::bridge::removed_ger_hash_chain_lo") +const REMOVED_GER_HASH_CHAIN_HI_SLOT = word("agglayer::bridge::removed_ger_hash_chain_hi") + # Flags const GER_KNOWN_FLAG = [1, 0, 0, 0] const FAUCET_REGISTERED_FLAG = 1 @@ -59,12 +74,13 @@ const FAUCET_METADATA_SUBKEY_HASH_HI = 3 # METADATA_HASH_HI[4] #! Outputs: [pad(16)] #! #! Panics if: -#! - the note sender is not the global exit root manager. +#! - the note sender is not the global exit root injector. +#! - the GER has already been registered in storage. #! #! Invocation: call pub proc update_ger - # assert the note sender is the global exit root manager. - exec.assert_sender_is_ger_manager + # assert the note sender is the global exit root injector. + exec.assert_sender_is_ger_injector # => [GER_LOWER[4], GER_UPPER[4], pad(8)] # compute hash(GER) = poseidon2::merge(GER_LOWER, GER_UPPER) @@ -83,8 +99,63 @@ pub proc update_ger exec.native_account::set_map_item # => [OLD_VALUE, pad(12)] - - dropw + + # assert OLD_VALUE is EMPTY_WORD, i.e. the GER was not previously registered + exec.word::eqz assert.err=ERR_GER_ALREADY_REGISTERED + # => [pad(16)] +end + +#! Removes a Global Exit Root (GER) from the bridge account storage and folds it into the running +#! removed-GER keccak256 hash chain. +#! +#! Computes hash(GER) = poseidon2::merge(GER_LOWER, GER_UPPER), overwrites the map entry with +#! [0, 0, 0, 0] (Miden equivalent of Solidity's `delete globalExitRootMap[ger]`) while asserting +#! that the previous map value equals GER_KNOWN_FLAG (i.e. the GER was currently known), and +#! updates the removed-GER hash chain as NEW_CHAIN = keccak256::merge(OLD_CHAIN, GER). +#! +#! Inputs: [GER_LOWER[4], GER_UPPER[4], pad(8)] +#! Outputs: [pad(16)] +#! +#! Panics if: +#! - the note sender is not the global exit root remover. +#! - the GER is not currently registered in the bridge's GER map. +#! +#! Invocation: call +pub proc remove_ger + # assert the note sender is the global exit root remover. + exec.assert_sender_is_ger_remover + # => [GER_LOWER[4], GER_UPPER[4], pad(8)] + + # duplicate the GER (16 felts) so we can use one copy to compute the map key + # and the other copy as the keccak256 preimage for the chain hash update later. + dupw.1 dupw.1 + # => [GER_LOWER, GER_UPPER, GER_LOWER, GER_UPPER, pad(8)] + + # compute hash(GER) = poseidon2::merge(GER_LOWER, GER_UPPER) on the top copy. + exec.poseidon2::merge + # => [GER_HASH, GER_LOWER, GER_UPPER, pad(8)] + + # prepare VALUE = [0, 0, 0, 0] to mark the entry removed. + push.0.0.0.0 + # => [[0, 0, 0, 0], GER_HASH, GER_LOWER, GER_UPPER, pad(8)] + + swapw + # => [GER_HASH, [0, 0, 0, 0], GER_LOWER, GER_UPPER, pad(8)] + + push.GER_MAP_STORAGE_SLOT[0..2] + # => [slot_id_prefix, slot_id_suffix, GER_HASH, [0, 0, 0, 0], GER_LOWER, GER_UPPER, pad(8)] + + exec.native_account::set_map_item + # => [OLD_VALUE, GER_LOWER, GER_UPPER, pad(8)] + + # assert the GER was currently known: OLD_VALUE must equal GER_KNOWN_FLAG. A failed + # assertion aborts the transaction, discarding the map write above. + push.GER_KNOWN_FLAG + assert_eqw.err=ERR_GER_NOT_FOUND + # => [GER_LOWER, GER_UPPER, pad(8)] + + # update the removed-GER keccak256 hash chain: NEW_CHAIN = keccak256::merge(OLD_CHAIN, GER). + exec.update_removed_ger_hash_chain # => [pad(16)] end @@ -510,20 +581,20 @@ proc assert_sender_is_bridge_admin # => [pad(16)] end -#! Asserts that the note sender matches the global exit root manager stored in account storage. +#! Asserts that the note sender matches the global exit root injector stored in account storage. #! -#! Reads the GER manager account ID from GER_MANAGER_SLOT and compares it against the sender of the +#! Reads the GER injector account ID from GER_INJECTOR_SLOT and compares it against the sender of the #! currently executing note. #! #! Inputs: [pad(16)] #! Outputs: [pad(16)] #! #! Panics if: -#! - the note sender does not match the GER manager account ID. +#! - the note sender does not match the GER injector account ID. #! #! Invocation: exec -proc assert_sender_is_ger_manager - push.GER_MANAGER_SLOT[0..2] +proc assert_sender_is_ger_injector + push.GER_INJECTOR_SLOT[0..2] exec.active_account::get_item # => [0, 0, mgr_suffix, mgr_prefix, pad(16)] @@ -534,6 +605,90 @@ proc assert_sender_is_ger_manager # => [sender_suffix, sender_prefix, mgr_suffix, mgr_prefix, pad(16)] exec.account_id::is_equal - assert.err=ERR_SENDER_NOT_GER_MANAGER + assert.err=ERR_SENDER_NOT_GER_INJECTOR + # => [pad(16)] +end + +#! Asserts that the note sender matches the global exit root remover stored in account storage. +#! +#! Reads the GER remover account ID from GER_REMOVER_SLOT and compares it against the sender of the +#! currently executing note. +#! +#! Inputs: [pad(16)] +#! Outputs: [pad(16)] +#! +#! Panics if: +#! - the note sender does not match the GER remover account ID. +#! +#! Invocation: exec +proc assert_sender_is_ger_remover + push.GER_REMOVER_SLOT[0..2] + exec.active_account::get_item + # => [0, 0, rem_suffix, rem_prefix, pad(16)] + + drop drop + # => [rem_suffix, rem_prefix, pad(16)] + + exec.active_note::get_sender + # => [sender_suffix, sender_prefix, rem_suffix, rem_prefix, pad(16)] + + exec.account_id::is_equal + assert.err=ERR_SENDER_NOT_GER_REMOVER # => [pad(16)] end + +#! Updates the removed-GER keccak256 hash chain by folding in the provided GER. +#! +#! Computes NEW_CHAIN = keccak256::merge(OLD_CHAIN, GER), then writes the new chain to the +#! REMOVED_GER_HASH_CHAIN_LO_SLOT and REMOVED_GER_HASH_CHAIN_HI_SLOT slots. +#! +#! Inputs: [GER_LOWER[4], GER_UPPER[4]] +#! Outputs: [] +#! +#! Invocation: exec +proc update_removed_ger_hash_chain + # load OLD_CHAIN above the GER preimage on the stack so that keccak256::merge produces + # keccak256(OLD_CHAIN || GER), matching Solidity's + # `removedGERHashChain = efficientKeccak256(removedGERHashChain, removedGER)`. + exec.load_removed_ger_hash_chain_data + # => [OLD_CHAIN[8], GER_LOWER, GER_UPPER] + + exec.keccak256::merge + # => [NEW_CHAIN_LO, NEW_CHAIN_HI] + + exec.store_removed_ger_hash_chain + # => [] +end + +#! Pushes the old removed-GER hash chain onto the stack, above the GER preimage which is left +#! untouched below it. +#! +#! Inputs: [] +#! Outputs: [OLD_CHAIN[8]] +#! +#! Invocation: exec +proc load_removed_ger_hash_chain_data + push.REMOVED_GER_HASH_CHAIN_HI_SLOT[0..2] + exec.active_account::get_item + # => [OLD_CHAIN_HI, GER_LOWER, GER_UPPER] + + push.REMOVED_GER_HASH_CHAIN_LO_SLOT[0..2] + exec.active_account::get_item + # => [OLD_CHAIN_LO, OLD_CHAIN_HI, GER_LOWER, GER_UPPER] +end + +#! Stores the updated removed-GER hash chain into the corresponding lo/hi storage slots. +#! +#! Inputs: [NEW_CHAIN_LO, NEW_CHAIN_HI] +#! Outputs: [] +#! +#! Invocation: exec +proc store_removed_ger_hash_chain + push.REMOVED_GER_HASH_CHAIN_LO_SLOT[0..2] + exec.native_account::set_item dropw + # => [NEW_CHAIN_HI] + + push.REMOVED_GER_HASH_CHAIN_HI_SLOT[0..2] + exec.native_account::set_item dropw + # => [] +end diff --git a/crates/miden-agglayer/asm/agglayer/bridge/bridge_in_output.masm b/crates/miden-agglayer/asm/agglayer/bridge/bridge_in_output.masm index 629a699f6f..37ae1925c1 100644 --- a/crates/miden-agglayer/asm/agglayer/bridge/bridge_in_output.masm +++ b/crates/miden-agglayer/asm/agglayer/bridge/bridge_in_output.masm @@ -127,7 +127,7 @@ pub proc unlock_and_send # Remove the asset from the bridge's vault. Panics if the vault does not contain enough of # the asset, which is the desired failure mode for an invalid / double-spent claim. exec.native_account::remove_asset - # => [REMAINING_ASSET_VALUE] + # => [FINAL_ASSET_VALUE] dropw # => [] diff --git a/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm b/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm index 7d108e4f81..ffa068bbf2 100644 --- a/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm +++ b/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm @@ -22,6 +22,7 @@ use agglayer::common::eth_address::EthereumAddressFormat # ================================================================================================= const ERR_B2AGG_DESTINATION_NETWORK_IS_MIDEN = "B2AGG note destination network ID must not be Miden's AggLayer network ID" +const ERR_B2AGG_NOTE_MUST_BE_PUBLIC = "B2AGG note must be public" # CONSTANTS # ================================================================================================= @@ -106,11 +107,15 @@ const BURN_NOTE_NUM_STORAGE_ITEMS=0 #! - dest_address(5) are 5 u32 values representing a 20-byte Ethereum address. #! #! Panics if: +#! - the B2AGG note calling `bridge_out` is not public. #! - destination network ID is Miden's AggLayer network ID. #! #! Invocation: call @locals(15) pub proc bridge_out + exec.active_note::is_public assert.err=ERR_B2AGG_NOTE_MUST_BE_PUBLIC + # => [ASSET_KEY, ASSET_VALUE, dest_network_id, dest_address(5), pad(2)] + # Save ASSET to local memory for later BURN note creation locaddr.BRIDGE_OUT_BURN_ASSET_LOC exec.asset::store @@ -560,7 +565,7 @@ end #! Computes the SERIAL_NUM of the outputted BURN note. #! -#! The serial number is computed as hash(B2AGG_SERIAL_NUM, ASSET_KEY). +#! The serial number is computed as hash([num_leaves, 0, 0, 0], hash(B2AGG_SERIAL_NUM, ASSET_KEY))). #! #! Inputs: [ASSET_KEY] #! Outputs: [SERIAL_NUM] @@ -568,12 +573,21 @@ end #! Where: #! - ASSET_KEY is the vault key from which to compute the burn note serial number. #! - SERIAL_NUM is the computed serial number for the BURN note. +#! - num_leaves is the number of leaves in the Local Exit Tree, post-append. #! #! Invocation: exec proc compute_burn_note_serial_num exec.active_note::get_serial_number # => [B2AGG_SERIAL_NUM, ASSET_KEY] + exec.poseidon2::merge + # => [SERIAL_AND_ASSET_HASH] + + # load num_leaves from its value slot and merge it with the intermediate hash word + push.LET_NUM_LEAVES_SLOT[0..2] + exec.active_account::get_item + # => [num_leaves, 0, 0, 0, SERIAL_AND_ASSET_HASH] + exec.poseidon2::merge # => [SERIAL_NUM] end @@ -675,7 +689,7 @@ end #! Invocation: exec proc lock_asset exec.native_account::add_asset - # => [ASSET_VALUE'] + # => [FINAL_ASSET_VALUE] dropw # => [] diff --git a/crates/miden-agglayer/asm/components/bridge.masm b/crates/miden-agglayer/asm/components/bridge.masm index 14c5169f93..e8028400f9 100644 --- a/crates/miden-agglayer/asm/components/bridge.masm +++ b/crates/miden-agglayer/asm/components/bridge.masm @@ -6,11 +6,13 @@ # - `register_faucet` from the bridge_config module # - `store_faucet_metadata_hash` from the bridge_config module # - `update_ger` from the bridge_config module +# - `remove_ger` from the bridge_config module # - `claim` for bridge-in # - `bridge_out` for bridge-out pub use ::agglayer::bridge::bridge_config::register_faucet pub use ::agglayer::bridge::bridge_config::store_faucet_metadata_hash pub use ::agglayer::bridge::bridge_config::update_ger +pub use ::agglayer::bridge::bridge_config::remove_ger pub use ::agglayer::bridge::bridge_in::claim pub use ::agglayer::bridge::bridge_out::bridge_out diff --git a/crates/miden-agglayer/asm/note_scripts/b2agg.masm b/crates/miden-agglayer/asm/note_scripts/b2agg.masm index ae5906e1a3..30764588d5 100644 --- a/crates/miden-agglayer/asm/note_scripts/b2agg.masm +++ b/crates/miden-agglayer/asm/note_scripts/b2agg.masm @@ -50,6 +50,7 @@ const ERR_B2AGG_TARGET_ACCOUNT_MISMATCH="B2AGG note attachment target account do #! - The note does not contain exactly 6 storage items. #! - The note does not contain exactly 1 asset. #! - The note attachment does not target the consuming account. +#! - The note is not public (enforced by `bridge_out`). #! - The destination network ID equals Miden's AggLayer network ID. @note_script pub proc main diff --git a/crates/miden-agglayer/asm/note_scripts/remove_ger.masm b/crates/miden-agglayer/asm/note_scripts/remove_ger.masm new file mode 100644 index 0000000000..e591675646 --- /dev/null +++ b/crates/miden-agglayer/asm/note_scripts/remove_ger.masm @@ -0,0 +1,71 @@ +use agglayer::bridge::bridge_config +use miden::protocol::active_note +use miden::standards::attachments::network_account_target + +# CONSTANTS +# ================================================================================================= + +const REMOVE_GER_NOTE_NUM_STORAGE_ITEMS = 8 +const STORAGE_PTR_GER_LOWER = 0 +const STORAGE_PTR_GER_UPPER = 4 + +# ERRORS +# ================================================================================================= + +const ERR_REMOVE_GER_UNEXPECTED_NUMBER_OF_STORAGE_ITEMS = "REMOVE_GER script expects exactly 8 note storage items" +const ERR_REMOVE_GER_TARGET_ACCOUNT_MISMATCH = "REMOVE_GER note attachment target account does not match consuming account" + +# NOTE SCRIPT +# ================================================================================================= + +#! Agglayer Bridge REMOVE_GER script: removes a GER from the bridge account by calling the +#! bridge_config::remove_ger function. +#! +#! This note can only be consumed by the specific agglayer bridge account whose ID is provided +#! in the note attachment (target_account_id), and only if the note was sent by the global exit root +#! remover. +#! +#! Requires that the account exposes: +#! - agglayer::bridge_config::remove_ger procedure. +#! +#! Inputs: [ARGS, pad(12)] +#! Outputs: [pad(16)] +#! +#! NoteStorage layout (8 felts total): +#! - GER_LOWER [0..3] : 4 felts +#! - GER_UPPER [4..7] : 4 felts +#! +#! Panics if: +#! - account does not expose remove_ger procedure. +#! - target account ID does not match the consuming account ID. +#! - number of note storage items is not exactly 8. +#! - the note sender is not the global exit root remover. +#! - the GER is not currently registered in the bridge's GER map. +@note_script +pub proc main + dropw + # => [pad(16)] + + # Ensure note attachment targets the consuming bridge account. + exec.network_account_target::active_account_matches_target_account + assert.err=ERR_REMOVE_GER_TARGET_ACCOUNT_MISMATCH + # => [pad(16)] + + # Load note storage to memory + push.STORAGE_PTR_GER_LOWER exec.active_note::get_storage + # => [num_storage_items, pad(16)] + + # Validate the number of storage items + push.REMOVE_GER_NOTE_NUM_STORAGE_ITEMS assert_eq.err=ERR_REMOVE_GER_UNEXPECTED_NUMBER_OF_STORAGE_ITEMS + # => [pad(16)] + + # Load GER_LOWER and GER_UPPER from note storage + mem_loadw_le.STORAGE_PTR_GER_UPPER + # => [GER_UPPER[4], pad(12)] + + swapw mem_loadw_le.STORAGE_PTR_GER_LOWER + # => [GER_LOWER[4], GER_UPPER[4], pad(8)] + + call.bridge_config::remove_ger + # => [pad(16)] +end diff --git a/crates/miden-agglayer/asm/note_scripts/update_ger.masm b/crates/miden-agglayer/asm/note_scripts/update_ger.masm index bc1e1dd504..c3c2804749 100644 --- a/crates/miden-agglayer/asm/note_scripts/update_ger.masm +++ b/crates/miden-agglayer/asm/note_scripts/update_ger.masm @@ -23,7 +23,7 @@ const ERR_UPDATE_GER_TARGET_ACCOUNT_MISMATCH = "UPDATE_GER note attachment targe #! #! This note can only be consumed by the specific agglayer bridge account whose ID is provided #! in the note attachment (target_account_id), and only if the note was sent by the global exit root -#! manager. +#! injector. #! #! Requires that the account exposes: #! - agglayer::bridge_config::update_ger procedure. @@ -39,6 +39,7 @@ const ERR_UPDATE_GER_TARGET_ACCOUNT_MISMATCH = "UPDATE_GER note attachment targe #! - account does not expose update_ger procedure. #! - target account ID does not match the consuming account ID. #! - number of note storage items is not exactly 8. +#! - the GER has already been registered in storage. @note_script pub proc main dropw diff --git a/crates/miden-agglayer/build.rs b/crates/miden-agglayer/build.rs index 5300fa2a9f..64a2334c4d 100644 --- a/crates/miden-agglayer/build.rs +++ b/crates/miden-agglayer/build.rs @@ -15,9 +15,8 @@ use miden_protocol::transaction::TransactionKernel; use miden_standards::account::access::Authority; use miden_standards::account::auth::AuthNetworkAccount; use miden_standards::account::policies::{ - BurnPolicyConfig, - MintPolicyConfig, - PolicyRegistration, + BurnPolicy, + MintPolicy, TokenPolicyManager, TransferPolicy, }; @@ -327,7 +326,7 @@ fn generate_agglayer_constants( // The allowlist lives in storage, not code, and here we only care about the code commitment // of the accounts, so we can init the allowlists with dummy values. let placeholder_allowlist = BTreeSet::from([NoteScriptRoot::from_raw(Word::default())]); - let auth_component = AuthNetworkAccount::with_allowlist(placeholder_allowlist) + let auth_component = AuthNetworkAccount::with_allowed_notes(placeholder_allowlist) .expect("placeholder allowlist is non-empty"); let mut components: Vec = vec![AccountComponent::from(auth_component), agglayer_component]; @@ -347,17 +346,13 @@ fn generate_agglayer_constants( // Burn policy manager: active = `owner_only` (burns locked by default), `allow_all` // is registered as Reserved so the owner can open burns at runtime via // `set_burn_policy`. - let token_policy_manager = TokenPolicyManager::new() - .with_mint_policy(MintPolicyConfig::OwnerOnly, PolicyRegistration::Active) - .expect("active mint policy is registered exactly once") - .with_burn_policy(BurnPolicyConfig::OwnerOnly, PolicyRegistration::Active) - .expect("active burn policy is registered exactly once") - .with_burn_policy(BurnPolicyConfig::AllowAll, PolicyRegistration::Reserved) - .expect("reserved burn policy registration does not conflict") - .with_send_policy(TransferPolicy::AllowAll, PolicyRegistration::Active) - .expect("active send policy is registered exactly once") - .with_receive_policy(TransferPolicy::AllowAll, PolicyRegistration::Active) - .expect("active receive policy is registered exactly once"); + let token_policy_manager = TokenPolicyManager::builder() + .active_mint_policy(MintPolicy::owner_only()) + .active_burn_policy(BurnPolicy::owner_only()) + .allowed_burn_policy(BurnPolicy::allow_all()) + .active_send_policy(TransferPolicy::allow_all()) + .active_receive_policy(TransferPolicy::allow_all()) + .build(); components.extend(token_policy_manager); } diff --git a/crates/miden-agglayer/src/bridge.rs b/crates/miden-agglayer/src/bridge.rs index 06d1825c33..3b7588cc3e 100644 --- a/crates/miden-agglayer/src/bridge.rs +++ b/crates/miden-agglayer/src/bridge.rs @@ -6,7 +6,14 @@ use alloc::vec::Vec; use miden_core::{Felt, ONE, Word, ZERO}; use miden_protocol::account::component::AccountComponentMetadata; -use miden_protocol::account::{Account, AccountComponent, AccountId, StorageSlot, StorageSlotName}; +use miden_protocol::account::{ + Account, + AccountComponent, + AccountId, + StorageMapKey, + StorageSlot, + StorageSlotName, +}; use miden_protocol::block::account_tree::AccountIdKey; use miden_protocol::crypto::hash::poseidon2::Poseidon2; use miden_protocol::note::NoteScriptRoot; @@ -15,6 +22,10 @@ use thiserror::Error; use super::agglayer_bridge_component_library; use crate::claim_note::CgiChainHash; +use crate::utils::Keccak256Output; + +/// Removed-GER hash chain representation (32-byte Keccak256 hash) +pub type RemovedGerHashChain = Keccak256Output; pub use crate::{ B2AggNote, ClaimNote, @@ -30,6 +41,7 @@ pub use crate::{ LeafData, MetadataHash, ProofData, + RemoveGerNote, SmtNode, UpdateGerNote, }; @@ -49,14 +61,26 @@ static BRIDGE_ADMIN_ID_SLOT_NAME: LazyLock = LazyLock::new(|| { StorageSlotName::new("agglayer::bridge::admin_account_id") .expect("bridge admin account ID storage slot name should be valid") }); -static GER_MANAGER_ID_SLOT_NAME: LazyLock = LazyLock::new(|| { - StorageSlotName::new("agglayer::bridge::ger_manager_account_id") - .expect("GER manager account ID storage slot name should be valid") +static GER_INJECTOR_ID_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("agglayer::bridge::ger_injector_account_id") + .expect("GER injector account ID storage slot name should be valid") +}); +static GER_REMOVER_ID_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("agglayer::bridge::ger_remover_account_id") + .expect("GER remover account ID storage slot name should be valid") }); static GER_MAP_SLOT_NAME: LazyLock = LazyLock::new(|| { StorageSlotName::new("agglayer::bridge::ger_map") .expect("GER map storage slot name should be valid") }); +static REMOVED_GER_HASH_CHAIN_LO_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("agglayer::bridge::removed_ger_hash_chain_lo") + .expect("removed GER hash chain lo storage slot name should be valid") +}); +static REMOVED_GER_HASH_CHAIN_HI_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("agglayer::bridge::removed_ger_hash_chain_hi") + .expect("removed GER hash chain hi storage slot name should be valid") +}); static FAUCET_REGISTRY_MAP_SLOT_NAME: LazyLock = LazyLock::new(|| { StorageSlotName::new("agglayer::bridge::faucet_registry_map") .expect("faucet registry map storage slot name should be valid") @@ -113,6 +137,8 @@ static LET_NUM_LEAVES_SLOT_NAME: LazyLock = LazyLock::new(|| { /// The procedures of this component are: /// - `register_faucet`, which registers a faucet in the bridge. /// - `update_ger`, which injects a new GER into the storage map. +/// - `remove_ger`, which removes a GER from the storage map and folds it into the running +/// removed-GER keccak256 hash chain. /// - `bridge_out`, which bridges an asset out of Miden to the destination network. /// - `claim`, which validates a claim against the AggLayer bridge and creates a MINT note for the /// AggLayer Faucet. @@ -120,8 +146,13 @@ static LET_NUM_LEAVES_SLOT_NAME: LazyLock = LazyLock::new(|| { /// ## Storage Layout /// /// - [`Self::bridge_admin_id_slot_name`]: Stores the bridge admin account ID. -/// - [`Self::ger_manager_id_slot_name`]: Stores the GER manager account ID. +/// - [`Self::ger_injector_id_slot_name`]: Stores the GER injector account ID. +/// - [`Self::ger_remover_id_slot_name`]: Stores the GER remover account ID. /// - [`Self::ger_map_slot_name`]: Stores the GERs. +/// - [`Self::removed_ger_hash_chain_lo_slot_name`]: Stores the lower 128 bits of the removed-GER +/// keccak256 hash chain. +/// - [`Self::removed_ger_hash_chain_hi_slot_name`]: Stores the upper 128 bits of the removed-GER +/// keccak256 hash chain. /// - [`Self::faucet_registry_map_slot_name`]: Stores the faucet registry map. /// - [`Self::token_registry_map_slot_name`]: Stores the token address → faucet ID map. /// - [`Self::faucet_metadata_map_slot_name`]: Stores conversion metadata (origin address, origin @@ -145,7 +176,8 @@ static LET_NUM_LEAVES_SLOT_NAME: LazyLock = LazyLock::new(|| { #[derive(Debug, Clone)] pub struct AggLayerBridge { bridge_admin_id: AccountId, - ger_manager_id: AccountId, + ger_injector_id: AccountId, + ger_remover_id: AccountId, } impl AggLayerBridge { @@ -163,8 +195,16 @@ impl AggLayerBridge { // -------------------------------------------------------------------------------------------- /// Creates a new AggLayer bridge component with the standard configuration. - pub fn new(bridge_admin_id: AccountId, ger_manager_id: AccountId) -> Self { - Self { bridge_admin_id, ger_manager_id } + pub fn new( + bridge_admin_id: AccountId, + ger_injector_id: AccountId, + ger_remover_id: AccountId, + ) -> Self { + Self { + bridge_admin_id, + ger_injector_id, + ger_remover_id, + } } // PUBLIC ACCESSORS @@ -177,9 +217,14 @@ impl AggLayerBridge { &BRIDGE_ADMIN_ID_SLOT_NAME } - /// Storage slot name for the GER manager account ID. - pub fn ger_manager_id_slot_name() -> &'static StorageSlotName { - &GER_MANAGER_ID_SLOT_NAME + /// Storage slot name for the GER injector account ID. + pub fn ger_injector_id_slot_name() -> &'static StorageSlotName { + &GER_INJECTOR_ID_SLOT_NAME + } + + /// Storage slot name for the GER remover account ID. + pub fn ger_remover_id_slot_name() -> &'static StorageSlotName { + &GER_REMOVER_ID_SLOT_NAME } /// Storage slot name for the GERs map. @@ -187,6 +232,16 @@ impl AggLayerBridge { &GER_MAP_SLOT_NAME } + /// Storage slot name for the lower 128 bits of the removed-GER keccak256 hash chain. + pub fn removed_ger_hash_chain_lo_slot_name() -> &'static StorageSlotName { + &REMOVED_GER_HASH_CHAIN_LO_SLOT_NAME + } + + /// Storage slot name for the upper 128 bits of the removed-GER keccak256 hash chain. + pub fn removed_ger_hash_chain_hi_slot_name() -> &'static StorageSlotName { + &REMOVED_GER_HASH_CHAIN_HI_SLOT_NAME + } + /// Storage slot name for the faucet registry map. pub fn faucet_registry_map_slot_name() -> &'static StorageSlotName { &FAUCET_REGISTRY_MAP_SLOT_NAME @@ -260,6 +315,7 @@ impl AggLayerBridge { B2AggNote::script_root(), ConfigAggBridgeNote::script_root(), UpdateGerNote::script_root(), + RemoveGerNote::script_root(), ]) } @@ -286,7 +342,7 @@ impl AggLayerBridge { // equal to [1, 0, 0, 0] let stored_value = bridge_account .storage() - .get_map_item(AggLayerBridge::ger_map_slot_name(), ger_hash) + .get_map_item(AggLayerBridge::ger_map_slot_name(), StorageMapKey::from_raw(ger_hash)) .expect("provided account should have AggLayer Bridge specific storage slots"); if stored_value == Self::REGISTERED_GER_MAP_VALUE { @@ -361,25 +417,52 @@ impl AggLayerBridge { .get_item(AggLayerBridge::cgi_chain_hash_hi_slot_name()) .expect("failed to get CGI hash chain hi slot"); - let cgi_chain_hash_bytes = cgi_chain_hash_lo - .iter() - .chain(cgi_chain_hash_hi.iter()) - .flat_map(|felt| { - (u32::try_from(felt.as_canonical_u64()).expect("Felt value does not fit into u32")) - .to_le_bytes() - }) - .collect::>(); + Ok(CgiChainHash::new(Self::chain_hash_bytes(cgi_chain_hash_lo, cgi_chain_hash_hi))) + } - Ok(CgiChainHash::new( - cgi_chain_hash_bytes - .try_into() - .expect("keccak hash should consist of exactly 32 bytes"), - )) + /// Returns the removed-GER keccak256 hash chain from the corresponding storage slots. + /// + /// The chain is the running keccak256 of all removed GERs: + /// `chain_n = keccak256(chain_{n-1} || removed_ger_n)` with `chain_0 = 0...0`. + /// + /// # Errors + /// + /// Returns an error if: + /// - the provided account is not an [`AggLayerBridge`] account. + pub fn removed_ger_hash_chain( + bridge_account: &Account, + ) -> Result { + // check that the provided account is a bridge account + Self::assert_bridge_account(bridge_account)?; + + let chain_lo = bridge_account + .storage() + .get_item(AggLayerBridge::removed_ger_hash_chain_lo_slot_name()) + .expect("failed to get removed GER hash chain lo slot"); + let chain_hi = bridge_account + .storage() + .get_item(AggLayerBridge::removed_ger_hash_chain_hi_slot_name()) + .expect("failed to get removed GER hash chain hi slot"); + + Ok(RemovedGerHashChain::new(Self::chain_hash_bytes(chain_lo, chain_hi))) } // HELPER FUNCTIONS // -------------------------------------------------------------------------------------------- + /// Converts a keccak256 hash stored across two lo/hi storage words into its 32-byte form. + fn chain_hash_bytes(lo: Word, hi: Word) -> [u8; 32] { + lo.iter() + .chain(hi.iter()) + .flat_map(|felt| { + (u32::try_from(felt.as_canonical_u64()).expect("Felt value does not fit into u32")) + .to_le_bytes() + }) + .collect::>() + .try_into() + .expect("keccak hash should consist of exactly 32 bytes") + } + /// Checks that the provided account is an [`AggLayerBridge`] account. /// /// # Errors @@ -452,7 +535,10 @@ impl AggLayerBridge { &*TOKEN_REGISTRY_MAP_SLOT_NAME, &*FAUCET_METADATA_MAP_SLOT_NAME, &*BRIDGE_ADMIN_ID_SLOT_NAME, - &*GER_MANAGER_ID_SLOT_NAME, + &*GER_INJECTOR_ID_SLOT_NAME, + &*GER_REMOVER_ID_SLOT_NAME, + &*REMOVED_GER_HASH_CHAIN_LO_SLOT_NAME, + &*REMOVED_GER_HASH_CHAIN_HI_SLOT_NAME, &*CGI_CHAIN_HASH_LO_SLOT_NAME, &*CGI_CHAIN_HASH_HI_SLOT_NAME, &*CLAIM_NULLIFIERS_SLOT_NAME, @@ -463,7 +549,8 @@ impl AggLayerBridge { impl From for AccountComponent { fn from(bridge: AggLayerBridge) -> Self { let bridge_admin_word = AccountIdKey::new(bridge.bridge_admin_id).as_word(); - let ger_manager_word = AccountIdKey::new(bridge.ger_manager_id).as_word(); + let ger_injector_word = AccountIdKey::new(bridge.ger_injector_id).as_word(); + let ger_remover_word = AccountIdKey::new(bridge.ger_remover_id).as_word(); let bridge_storage_slots = vec![ StorageSlot::with_empty_map(GER_MAP_SLOT_NAME.clone()), @@ -475,7 +562,10 @@ impl From for AccountComponent { StorageSlot::with_empty_map(TOKEN_REGISTRY_MAP_SLOT_NAME.clone()), StorageSlot::with_empty_map(FAUCET_METADATA_MAP_SLOT_NAME.clone()), StorageSlot::with_value(BRIDGE_ADMIN_ID_SLOT_NAME.clone(), bridge_admin_word), - StorageSlot::with_value(GER_MANAGER_ID_SLOT_NAME.clone(), ger_manager_word), + StorageSlot::with_value(GER_INJECTOR_ID_SLOT_NAME.clone(), ger_injector_word), + StorageSlot::with_value(GER_REMOVER_ID_SLOT_NAME.clone(), ger_remover_word), + StorageSlot::with_value(REMOVED_GER_HASH_CHAIN_LO_SLOT_NAME.clone(), Word::empty()), + StorageSlot::with_value(REMOVED_GER_HASH_CHAIN_HI_SLOT_NAME.clone(), Word::empty()), StorageSlot::with_value(CGI_CHAIN_HASH_LO_SLOT_NAME.clone(), Word::empty()), StorageSlot::with_value(CGI_CHAIN_HASH_HI_SLOT_NAME.clone(), Word::empty()), StorageSlot::with_empty_map(CLAIM_NULLIFIERS_SLOT_NAME.clone()), diff --git a/crates/miden-agglayer/src/ger_note.rs b/crates/miden-agglayer/src/ger_note.rs new file mode 100644 index 0000000000..369742c245 --- /dev/null +++ b/crates/miden-agglayer/src/ger_note.rs @@ -0,0 +1,72 @@ +//! Shared construction for the GER note builders (UPDATE_GER and REMOVE_GER). +//! +//! Both notes carry the same payload (8 felts of GER data), target the bridge account, are +//! always public, and carry no assets; they differ only in the note script they reference. + +extern crate alloc; + +use alloc::string::ToString; +use alloc::vec; + +use miden_protocol::account::AccountId; +use miden_protocol::crypto::rand::FeltRng; +use miden_protocol::errors::NoteError; +use miden_protocol::note::{ + Note, + NoteAssets, + NoteAttachment, + NoteAttachments, + NoteRecipient, + NoteScript, + NoteStorage, + NoteType, + PartialNoteMetadata, +}; +use miden_standards::note::{NetworkAccountTarget, NoteExecutionHint}; + +use crate::ExitRoot; + +/// Creates a GER note (UPDATE_GER or REMOVE_GER) carrying the given GER data and running the +/// provided note `script`. +/// +/// The two GER notes are structurally identical - 8 felts of GER storage, a network-account +/// target on the bridge, public metadata, and no assets - so this helper holds their shared +/// construction and each note type only supplies its own script. +/// +/// The note storage contains 8 felts: GER[0..7]. +/// +/// # Parameters +/// - `ger`: the Global Exit Root data the note carries +/// - `sender_account_id`: the account ID of the note creator (the GER injector or remover) +/// - `target_account_id`: the account ID that will consume this note (the bridge account) +/// - `script`: the note script to run (UPDATE_GER or REMOVE_GER) +/// - `rng`: random number generator for the note serial number +/// +/// # Errors +/// Returns an error if note creation fails. +pub(crate) fn create_ger_note( + ger: ExitRoot, + sender_account_id: AccountId, + target_account_id: AccountId, + script: NoteScript, + rng: &mut R, +) -> Result { + // Create note storage with 8 felts: GER[0..7] + let storage_values = ger.to_elements().to_vec(); + let note_storage = NoteStorage::new(storage_values)?; + + // Generate a serial number for the note + let serial_num = rng.draw_word(); + + let recipient = NoteRecipient::new(serial_num, script, note_storage); + + let attachment = NetworkAccountTarget::new(target_account_id, NoteExecutionHint::Always) + .map_err(|e| NoteError::other(e.to_string()))?; + let attachments = NoteAttachments::from(NoteAttachment::from(attachment)); + let metadata = PartialNoteMetadata::new(sender_account_id, NoteType::Public); + + // GER notes don't carry assets + let assets = NoteAssets::new(vec![])?; + + Ok(Note::with_attachments(assets, metadata, recipient, attachments)) +} diff --git a/crates/miden-agglayer/src/lib.rs b/crates/miden-agglayer/src/lib.rs index 0826a1e777..3fab13acc9 100644 --- a/crates/miden-agglayer/src/lib.rs +++ b/crates/miden-agglayer/src/lib.rs @@ -11,9 +11,8 @@ use miden_standards::account::access::{Authority, Ownable2Step}; use miden_standards::account::auth::AuthNetworkAccount; use miden_standards::account::policies::{ BurnAllowAll, - BurnPolicyConfig, - MintPolicyConfig, - PolicyRegistration, + BurnPolicy, + MintPolicy, TokenPolicyManager, TransferPolicy, }; @@ -26,13 +25,15 @@ pub mod config_note; pub mod errors; pub mod eth_types; pub mod faucet; +mod ger_note; +pub mod remove_ger_note; #[cfg(feature = "testing")] pub mod testing; pub mod update_ger_note; pub mod utils; pub use b2agg_note::B2AggNote; -pub use bridge::{AggLayerBridge, AgglayerBridgeError}; +pub use bridge::{AggLayerBridge, AgglayerBridgeError, RemovedGerHashChain}; pub use claim_note::{ CgiChainHash, ClaimNote, @@ -56,6 +57,7 @@ pub use eth_types::{ MetadataHash, }; pub use faucet::{AggLayerFaucet, AgglayerFaucetError}; +pub use remove_ger_note::RemoveGerNote; pub use update_ger_note::UpdateGerNote; pub use utils::Keccak256Output; @@ -133,13 +135,14 @@ fn create_agglayer_faucet_component( fn create_bridge_account_builder( seed: Word, bridge_admin_id: AccountId, - ger_manager_id: AccountId, + ger_injector_id: AccountId, + ger_remover_id: AccountId, ) -> AccountBuilder { Account::builder(seed.into()) .account_type(AccountType::Public) - .with_component(AggLayerBridge::new(bridge_admin_id, ger_manager_id)) + .with_component(AggLayerBridge::new(bridge_admin_id, ger_injector_id, ger_remover_id)) .with_auth_component( - AuthNetworkAccount::with_allowlist(AggLayerBridge::allowed_notes()) + AuthNetworkAccount::with_allowed_notes(AggLayerBridge::allowed_notes()) .expect("bridge note allowlist is non-empty"), ) } @@ -150,9 +153,10 @@ fn create_bridge_account_builder( pub fn create_bridge_account( seed: Word, bridge_admin_id: AccountId, - ger_manager_id: AccountId, + ger_injector_id: AccountId, + ger_remover_id: AccountId, ) -> Account { - create_bridge_account_builder(seed, bridge_admin_id, ger_manager_id) + create_bridge_account_builder(seed, bridge_admin_id, ger_injector_id, ger_remover_id) .build() .expect("bridge account should be valid") } @@ -164,9 +168,10 @@ pub fn create_bridge_account( pub fn create_existing_bridge_account( seed: Word, bridge_admin_id: AccountId, - ger_manager_id: AccountId, + ger_injector_id: AccountId, + ger_remover_id: AccountId, ) -> Account { - create_bridge_account_builder(seed, bridge_admin_id, ger_manager_id) + create_bridge_account_builder(seed, bridge_admin_id, ger_injector_id, ger_remover_id) .build_existing() .expect("bridge account should be valid") } @@ -176,8 +181,8 @@ pub fn create_existing_bridge_account( /// The builder includes: /// - The `AggLayerFaucet` component (token metadata only). /// - The `Ownable2Step` component (bridge account ID as owner for mint authorization). -/// - A [`TokenPolicyManager`] (owner-controlled) configured with `MintPolicyConfig::OwnerOnly` and -/// `BurnPolicyConfig::OwnerOnly`. The manager additionally registers `BurnAllowAll::root()` as an +/// - A [`TokenPolicyManager`] (owner-controlled) configured with [`MintPolicy::owner_only`] and +/// [`BurnPolicy::owner_only`]. The manager additionally registers `BurnAllowAll::root()` as an /// allowed burn policy so the owner can open burns at runtime via `set_burn_policy`. The active /// mint policy component (`MintOwnerOnly`) and burn policy component (`BurnOwnerOnly`) are /// produced by the manager; `BurnAllowAll` is installed separately as the additional allowed burn @@ -197,17 +202,13 @@ fn create_agglayer_faucet_builder( // `allow_all` is explicitly registered as Reserved so the owner can open burns at runtime // via `set_burn_policy`. - let token_policy_manager = TokenPolicyManager::new() - .with_mint_policy(MintPolicyConfig::OwnerOnly, PolicyRegistration::Active) - .expect("active mint policy is registered exactly once") - .with_burn_policy(BurnPolicyConfig::OwnerOnly, PolicyRegistration::Active) - .expect("active burn policy is registered exactly once") - .with_burn_policy(BurnPolicyConfig::AllowAll, PolicyRegistration::Reserved) - .expect("reserved burn policy registration does not conflict") - .with_send_policy(TransferPolicy::AllowAll, PolicyRegistration::Active) - .expect("active send policy is registered exactly once") - .with_receive_policy(TransferPolicy::AllowAll, PolicyRegistration::Active) - .expect("active receive policy is registered exactly once"); + let token_policy_manager = TokenPolicyManager::builder() + .active_mint_policy(MintPolicy::owner_only()) + .active_burn_policy(BurnPolicy::owner_only()) + .allowed_burn_policy(BurnPolicy::allow_all()) + .active_send_policy(TransferPolicy::allow_all()) + .active_receive_policy(TransferPolicy::allow_all()) + .build(); Account::builder(seed.into()) .account_type(AccountType::Public) @@ -217,7 +218,7 @@ fn create_agglayer_faucet_builder( .with_components(token_policy_manager) .with_component(BurnAllowAll) .with_auth_component( - AuthNetworkAccount::with_allowlist(AggLayerFaucet::allowed_notes()) + AuthNetworkAccount::with_allowed_notes(AggLayerFaucet::allowed_notes()) .expect("faucet note allowlist is non-empty"), ) } diff --git a/crates/miden-agglayer/src/remove_ger_note.rs b/crates/miden-agglayer/src/remove_ger_note.rs new file mode 100644 index 0000000000..828d159648 --- /dev/null +++ b/crates/miden-agglayer/src/remove_ger_note.rs @@ -0,0 +1,81 @@ +//! REMOVE_GER note creation utilities. +//! +//! This module provides helpers for creating REMOVE_GER notes, +//! which are used to remove a Global Exit Root from the bridge account and fold it into the +//! running removed-GER keccak256 hash chain. + +use miden_assembly::Library; +use miden_assembly::serde::Deserializable; +use miden_protocol::account::AccountId; +use miden_protocol::crypto::rand::FeltRng; +use miden_protocol::errors::NoteError; +use miden_protocol::note::{Note, NoteScript, NoteScriptRoot}; +use miden_utils_sync::LazyLock; + +use crate::ExitRoot; +use crate::ger_note::create_ger_note; + +// NOTE SCRIPT +// ================================================================================================ + +// Initialize the REMOVE_GER note script only once +static REMOVE_GER_SCRIPT: LazyLock = LazyLock::new(|| { + let bytes = include_bytes!(concat!(env!("OUT_DIR"), "/assets/note_scripts/remove_ger.masl")); + let library = + Library::read_from_bytes(bytes).expect("shipped REMOVE_GER script library is well-formed"); + NoteScript::from_library(&library).expect("shipped REMOVE_GER script is well-formed") +}); + +// REMOVE_GER NOTE +// ================================================================================================ + +/// REMOVE_GER note. +/// +/// This note is used to remove a Global Exit Root (GER) from the bridge account and fold it into +/// the running removed-GER keccak256 hash chain. It carries the GER data and is always public. +pub struct RemoveGerNote; + +impl RemoveGerNote { + // CONSTANTS + // -------------------------------------------------------------------------------------------- + + /// Expected number of storage items for a REMOVE_GER note. + pub const NUM_STORAGE_ITEMS: usize = 8; + + // PUBLIC ACCESSORS + // -------------------------------------------------------------------------------------------- + + /// Returns the REMOVE_GER note script. + pub fn script() -> NoteScript { + REMOVE_GER_SCRIPT.clone() + } + + /// Returns the REMOVE_GER note script root. + pub fn script_root() -> NoteScriptRoot { + REMOVE_GER_SCRIPT.root() + } + + // BUILDERS + // -------------------------------------------------------------------------------------------- + + /// Creates a REMOVE_GER note with the given GER (Global Exit Root) data. + /// + /// The note storage contains 8 felts: GER[0..7] + /// + /// # Parameters + /// - `ger`: The Global Exit Root data to remove + /// - `sender_account_id`: The account ID of the note creator (must be the GER remover) + /// - `target_account_id`: The account ID that will consume this note (bridge account) + /// - `rng`: Random number generator for creating the note serial number + /// + /// # Errors + /// Returns an error if note creation fails. + pub fn create( + ger: ExitRoot, + sender_account_id: AccountId, + target_account_id: AccountId, + rng: &mut R, + ) -> Result { + create_ger_note(ger, sender_account_id, target_account_id, Self::script(), rng) + } +} diff --git a/crates/miden-agglayer/src/update_ger_note.rs b/crates/miden-agglayer/src/update_ger_note.rs index 3f1e7ef89a..ad5189862c 100644 --- a/crates/miden-agglayer/src/update_ger_note.rs +++ b/crates/miden-agglayer/src/update_ger_note.rs @@ -3,32 +3,16 @@ //! This module provides helpers for creating UPDATE_GER notes, //! which are used to update the Global Exit Root in the bridge account. -extern crate alloc; - -use alloc::string::ToString; -use alloc::vec; - use miden_assembly::Library; use miden_assembly::serde::Deserializable; use miden_protocol::account::AccountId; use miden_protocol::crypto::rand::FeltRng; use miden_protocol::errors::NoteError; -use miden_protocol::note::{ - Note, - NoteAssets, - NoteAttachment, - NoteAttachments, - NoteRecipient, - NoteScript, - NoteScriptRoot, - NoteStorage, - NoteType, - PartialNoteMetadata, -}; -use miden_standards::note::{NetworkAccountTarget, NoteExecutionHint}; +use miden_protocol::note::{Note, NoteScript, NoteScriptRoot}; use miden_utils_sync::LazyLock; use crate::ExitRoot; +use crate::ger_note::create_ger_note; // NOTE SCRIPT // ================================================================================================ @@ -91,24 +75,6 @@ impl UpdateGerNote { target_account_id: AccountId, rng: &mut R, ) -> Result { - // Create note storage with 8 felts: GER[0..7] - let storage_values = ger.to_elements().to_vec(); - - let note_storage = NoteStorage::new(storage_values)?; - - // Generate a serial number for the note - let serial_num = rng.draw_word(); - - let recipient = NoteRecipient::new(serial_num, Self::script(), note_storage); - - let attachment = NetworkAccountTarget::new(target_account_id, NoteExecutionHint::Always) - .map_err(|e| NoteError::other(e.to_string()))?; - let attachments = NoteAttachments::from(NoteAttachment::from(attachment)); - let metadata = PartialNoteMetadata::new(sender_account_id, NoteType::Public); - - // UPDATE_GER notes don't carry assets - let assets = NoteAssets::new(vec![])?; - - Ok(Note::with_attachments(assets, metadata, recipient, attachments)) + create_ger_note(ger, sender_account_id, target_account_id, Self::script(), rng) } } diff --git a/crates/miden-protocol/asm/kernels/batch/main.masm b/crates/miden-protocol/asm/kernels/batch/main.masm new file mode 100644 index 0000000000..bf9a6d5fad --- /dev/null +++ b/crates/miden-protocol/asm/kernels/batch/main.masm @@ -0,0 +1,41 @@ +# MAIN +# ================================================================================================= + +#! Batch kernel program (skeleton). +#! +#! A transaction batch groups a set of independently-proven transactions so they can later be +#! aggregated into a block by the block kernel. This program defines the public input/output +#! contract that the batch kernel will eventually verify, but currently does not yet perform +#! any verification: it drops its inputs and exits, leaving the all-zero word output region as the +#! stack's initial padding zeros. +#! +#! Inputs: [ +#! BLOCK_COMMITMENT, +#! BATCH_ID, +#! pad(8), +#! ] +#! +#! Outputs: [ +#! INPUT_NOTES_COMMITMENT, +#! BATCH_NOTE_TREE_ROOT, +#! batch_expiration_block_num, +#! pad(7), +#! ] +#! +#! Where: +#! - BLOCK_COMMITMENT is the commitment of the batch's reference block. +#! - BATCH_ID is the batch's `BatchId`, the commitment to its transactions. +#! - INPUT_NOTES_COMMITMENT will be the sequential hash over every transaction's input note +#! commitments. In this skeleton it is the empty word. +#! - BATCH_NOTE_TREE_ROOT will be the root of the batch note tree built over every transaction's +#! output notes. In this skeleton it is the empty word. +#! - batch_expiration_block_num will be the minimum of every transaction's +#! `expiration_block_num`. In this skeleton it is zero. +#! +proc main + dropw dropw +end + +begin + exec.main +end diff --git a/crates/miden-protocol/asm/kernels/transaction/api.masm b/crates/miden-protocol/asm/kernels/transaction/api.masm index 518de702df..0b6fb8ac10 100644 --- a/crates/miden-protocol/asm/kernels/transaction/api.masm +++ b/crates/miden-protocol/asm/kernels/transaction/api.masm @@ -1,6 +1,6 @@ use $kernel::asset use $kernel::account -use $kernel::account_delta +use $kernel::account_update use $kernel::account_id use $kernel::faucet use $kernel::input_note @@ -153,12 +153,12 @@ end #! - DELTA_COMMITMENT is the commitment to the account delta. #! #! Panics if: -#! - the vault or storage delta is not empty but the nonce increment is zero. +#! - the vault delta or storage patch is not empty but the nonce increment is zero. #! #! Invocation: dynexec pub proc account_compute_delta_commitment # compute the account delta commitment - exec.account_delta::compute_commitment + exec.account_update::compute_delta_commitment # => [DELTA_COMMITMENT, pad(16)] # truncate the stack @@ -552,14 +552,14 @@ end #! Adds the specified asset to the vault. #! #! Inputs: [ASSET_KEY, ASSET_VALUE, pad(8)] -#! Outputs: [ASSET_VALUE', pad(12)] +#! Outputs: [FINAL_ASSET_VALUE, pad(12)] #! #! Where: #! - ASSET_KEY is the vault key of the asset that is added to the vault. #! - ASSET_VALUE is the value of the asset to add to the vault. -#! - ASSET_VALUE' final asset in the account vault defined as follows: -#! - If ASSET_VALUE is a non-fungible asset, then ASSET_VALUE' is the same as ASSET_VALUE. -#! - If ASSET_VALUE is a fungible asset, then ASSET_VALUE' is the total fungible asset in the account vault +#! - FINAL_ASSET_VALUE is the asset in the account vault after the operation, defined as follows: +#! - If ASSET_VALUE is a non-fungible asset, then FINAL_ASSET_VALUE is the same as ASSET_VALUE. +#! - If ASSET_VALUE is a fungible asset, then FINAL_ASSET_VALUE is the total fungible asset in the account vault #! after ASSET_VALUE was added to it. #! #! Panics if: @@ -581,18 +581,18 @@ pub proc account_add_asset # add the specified asset to the account vault, emitting the corresponding events exec.account::add_asset_to_vault - # => [ASSET_VALUE', pad(12)] + # => [FINAL_ASSET_VALUE, pad(12)] end #! Removes the specified asset from the vault and returns the remaining asset value. #! #! Inputs: [ASSET_KEY, ASSET_VALUE, pad(8)] -#! Outputs: [REMAINING_ASSET_VALUE, pad(12)] +#! Outputs: [FINAL_ASSET_VALUE, pad(12)] #! #! Where: #! - ASSET_KEY is the vault key of the asset to remove from the vault. #! - ASSET_VALUE is the value of the asset to remove from the vault. -#! - REMAINING_ASSET_VALUE is the value of the asset remaining in the vault after removal. +#! - FINAL_ASSET_VALUE is the value of the asset remaining in the vault after removal. #! #! Panics if: #! - the fungible asset is not found in the vault. @@ -612,7 +612,7 @@ pub proc account_remove_asset # remove the specified asset from the account vault, emitting the corresponding events exec.account::remove_asset_from_vault - # => [REMAINING_ASSET_VALUE, pad(12)] + # => [FINAL_ASSET_VALUE, pad(12)] end #! Returns the asset associated with the provided asset vault key in the active account's vault. @@ -738,6 +738,30 @@ pub proc account_has_procedure # => [is_procedure_available, pad(15)] end +#! Returns a flag indicating whether a storage slot with the provided slot ID exists in the active +#! account's storage. +#! +#! Returns 1 if the slot exists and 0 otherwise. +#! +#! Inputs: [slot_id_suffix, slot_id_prefix, pad(14)] +#! Outputs: [has_slot, pad(15)] +#! +#! Where: +#! - slot_id_{suffix, prefix} are the suffix and prefix felts of the slot identifier, which are +#! the first two felts of the hashed slot name. +#! - has_slot is 1 if a slot with the provided slot ID exists, 0 otherwise. +#! +#! Invocation: dynexec +pub proc account_has_storage_slot + # authenticate that the procedure invocation originates from the account context + exec.authenticate_account_origin + # => [slot_id_suffix, slot_id_prefix, pad(14)] + + # check whether the storage slot exists + exec.account::has_storage_slot + # => [has_slot, pad(15)] +end + # FAUCET # ------------------------------------------------------------------------------------------------- diff --git a/crates/miden-protocol/asm/kernels/transaction/lib/account.masm b/crates/miden-protocol/asm/kernels/transaction/lib/account.masm index a11013385d..cbd180bc73 100644 --- a/crates/miden-protocol/asm/kernels/transaction/lib/account.masm +++ b/crates/miden-protocol/asm/kernels/transaction/lib/account.masm @@ -1,4 +1,4 @@ -use $kernel::account_delta +use $kernel::account_update use $kernel::account_id use $kernel::asset_vault use $kernel::callbacks @@ -198,7 +198,7 @@ pub use memory::get_account_nonce->get_nonce #! Panics if: #! - the nonce has already been incremented. pub proc incr_nonce - exec.account_delta::was_nonce_incremented + exec.account_update::was_nonce_incremented # => [was_nonce_incremented] # assert the nonce has not already been incremented @@ -440,6 +440,29 @@ pub proc find_item # => [is_found, VALUE] end +#! Returns a flag indicating whether a storage slot with the provided slot ID exists in the active +#! account's storage. +#! +#! Inputs: [slot_id_suffix, slot_id_prefix] +#! Outputs: [has_slot] +#! +#! Where: +#! - slot_id_{suffix, prefix} are the suffix and prefix felts of the slot identifier, which are +#! the first two felts of the hashed slot name. +#! - has_slot is 1 if a slot with the provided slot ID exists, 0 otherwise. +pub proc has_storage_slot + # get account storage slots section offset + exec.memory::get_account_active_storage_slots_section_ptr + # => [acct_storage_slots_section_offset, slot_id_suffix, slot_id_prefix] + + exec.find_storage_slot + # => [has_slot, slot_ptr] + + # drop the slot pointer, keeping only the found flag + swap drop + # => [has_slot] +end + #! Gets an item and its slot type from the account storage. #! #! Inputs: [slot_id_suffix, slot_id_prefix] @@ -657,14 +680,14 @@ end #! Adds the specified asset to the account vault. #! #! Inputs: [ASSET_KEY, ASSET_VALUE] -#! Outputs: [ASSET_VALUE'] +#! Outputs: [FINAL_ASSET_VALUE] #! #! Where: #! - ASSET_KEY is the vault key of the asset that is added to the vault. #! - ASSET_VALUE is the value of the asset that is added to the vault. -#! - ASSET_VALUE' final asset in the account vault defined as follows: -#! - If ASSET_VALUE is a non-fungible asset, then ASSET_VALUE' is the same as ASSET_VALUE. -#! - If ASSET_VALUE is a fungible asset, then ASSET_VALUE' is the total fungible asset in the account vault +#! - FINAL_ASSET_VALUE final asset in the account vault defined as follows: +#! - If ASSET_VALUE is a non-fungible asset, then FINAL_ASSET_VALUE is the same as ASSET_VALUE. +#! - If ASSET_VALUE is a fungible asset, then FINAL_ASSET_VALUE is the total fungible asset in the account vault #! after ASSET_VALUE was added to it. #! #! Panics if: @@ -680,73 +703,73 @@ pub proc add_asset_to_vault swapw # => [ASSET_KEY, PROCESSED_ASSET_VALUE] - # duplicate the asset for the later event and delta update - dupw.1 dupw.1 - # => [ASSET_KEY, PROCESSED_ASSET_VALUE, ASSET_KEY, PROCESSED_ASSET_VALUE] + # duplicate the asset key for the later event and delta update + swapw dupw.1 + # => [ASSET_KEY, PROCESSED_ASSET_VALUE, ASSET_KEY] # push the account vault root ptr exec.memory::get_account_vault_root_ptr movdn.8 - # => [ASSET_KEY, PROCESSED_ASSET_VALUE, account_vault_root_ptr, ASSET_KEY, PROCESSED_ASSET_VALUE] + # => [ASSET_KEY, PROCESSED_ASSET_VALUE, account_vault_root_ptr, ASSET_KEY] # emit event to signal that an asset is going to be added to the account vault emit.ACCOUNT_VAULT_BEFORE_ADD_ASSET_EVENT - # => [ASSET_KEY, PROCESSED_ASSET_VALUE, account_vault_root_ptr, ASSET_KEY, PROCESSED_ASSET_VALUE] + # => [ASSET_KEY, PROCESSED_ASSET_VALUE, account_vault_root_ptr, ASSET_KEY] # add the asset to the account vault exec.asset_vault::add_asset - # => [PROCESSED_ASSET_VALUE', ASSET_KEY, PROCESSED_ASSET_VALUE] + # => [INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE, ASSET_KEY] - movdnw.2 - # => [ASSET_KEY, PROCESSED_ASSET_VALUE, PROCESSED_ASSET_VALUE'] + dupw.1 swapw.3 + # => [ASSET_KEY, INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE, FINAL_ASSET_VALUE] # emit event to signal that an asset is being added to the account vault emit.ACCOUNT_VAULT_AFTER_ADD_ASSET_EVENT - # => [ASSET_KEY, PROCESSED_ASSET_VALUE, PROCESSED_ASSET_VALUE'] + # => [ASSET_KEY, INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE, FINAL_ASSET_VALUE] - exec.account_delta::add_asset - # => [PROCESSED_ASSET_VALUE'] + exec.account_update::update_asset + # => [FINAL_ASSET_VALUE] end #! Removes the specified asset from the account vault and returns the remaining asset value. #! #! Inputs: [ASSET_KEY, ASSET_VALUE] -#! Outputs: [REMAINING_ASSET_VALUE] +#! Outputs: [FINAL_ASSET_VALUE] #! #! Where: #! - ASSET_KEY is the asset vault key of the asset to remove from the vault. #! - ASSET_VALUE is the value of the asset to remove from the vault. -#! - REMAINING_ASSET_VALUE is the value of the asset remaining in the vault after removal. +#! - FINAL_ASSET_VALUE is the value of the asset remaining in the vault after removal. #! #! Panics if: #! - the fungible asset is not found in the vault. #! - the amount of the fungible asset in the vault is less than the amount to be removed. #! - the non-fungible asset is not found in the vault. pub proc remove_asset_from_vault - # duplicate the asset for the later event and delta update - dupw.1 dupw.1 - # => [ASSET_KEY, ASSET_VALUE, ASSET_KEY, ASSET_VALUE] + # duplicate the asset key for the later event and delta update + swapw dupw.1 + # => [ASSET_KEY, ASSET_VALUE, ASSET_KEY] # push the vault root ptr exec.memory::get_account_vault_root_ptr movdn.8 - # => [ASSET_KEY, ASSET_VALUE, account_vault_root_ptr, ASSET_KEY, ASSET_VALUE] + # => [ASSET_KEY, ASSET_VALUE, account_vault_root_ptr, ASSET_KEY] # emit event to signal that an asset is going to be removed from the account vault emit.ACCOUNT_VAULT_BEFORE_REMOVE_ASSET_EVENT - # => [ASSET_KEY, ASSET_VALUE, account_vault_root_ptr, ASSET_KEY, ASSET_VALUE] + # => [ASSET_KEY, ASSET_VALUE, account_vault_root_ptr, ASSET_KEY] # remove the asset from the account vault exec.asset_vault::remove_asset - # => [REMAINING_ASSET_VALUE, ASSET_KEY, ASSET_VALUE] + # => [INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE, ASSET_KEY] - movdnw.2 - # => [ASSET_KEY, ASSET_VALUE, REMAINING_ASSET_VALUE] + dupw.1 swapw.3 + # => [ASSET_KEY, INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE, FINAL_ASSET_VALUE] # emit event to signal that an asset is being removed from the account vault emit.ACCOUNT_VAULT_AFTER_REMOVE_ASSET_EVENT - # => [ASSET_KEY, ASSET_VALUE, REMAINING_ASSET_VALUE] + # => [ASSET_KEY, INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE, FINAL_ASSET_VALUE] - exec.account_delta::remove_asset - # => [REMAINING_ASSET_VALUE] + exec.account_update::update_asset + # => [FINAL_ASSET_VALUE] end #! Returns the ASSET_VALUE associated with the provided asset vault key in the active account's vault. @@ -1571,7 +1594,7 @@ proc set_map_item_raw exec.slot_ptr_to_index # => [slot_idx, KEY, OLD_VALUE, NEW_VALUE] - exec.account_delta::set_map_item + exec.account_update::set_map_item # => [] # load OLD_VALUE as return value on the stack diff --git a/crates/miden-protocol/asm/kernels/transaction/lib/account_delta.masm b/crates/miden-protocol/asm/kernels/transaction/lib/account_delta.masm deleted file mode 100644 index 7df7931432..0000000000 --- a/crates/miden-protocol/asm/kernels/transaction/lib/account_delta.masm +++ /dev/null @@ -1,883 +0,0 @@ -use $kernel::account -use $kernel::asset -use $kernel::constants::STORAGE_SLOT_TYPE_VALUE -use $kernel::fungible_asset -use $kernel::link_map -use $kernel::memory -use $kernel::memory::ACCOUNT_DELTA_FUNGIBLE_ASSET_PTR -use $kernel::memory::ACCOUNT_DELTA_NON_FUNGIBLE_ASSET_PTR -use $kernel::util::asset::FUNGIBLE_ASSET_MAX_AMOUNT -use miden::core::crypto::hashes::poseidon2 -use miden::core::word - -# ERRORS -# ================================================================================================= - -const ERR_ACCOUNT_DELTA_NONCE_MUST_BE_INCREMENTED_IF_VAULT_OR_STORAGE_CHANGED="nonce must be incremented if account vault or account storage changed" - -# CONSTANTS -# ================================================================================================= - -# The domain of an asset in the delta commitment. -const DOMAIN_ASSET = 1 -# The domain of a value storage slot in the delta commitment. -const DOMAIN_VALUE = 2 -# The domain of a map storage slot in the delta commitment. -const DOMAIN_MAP = 3 - -# The maximum value a felt can represent. -const FELT_MAX = 0xffffffff00000000 - -# PROCEDURES -# ================================================================================================= - -# DELTA COMPUTATION -# ------------------------------------------------------------------------------------------------- - -#! Computes the commitment to the native account's delta. -#! -#! See the Rust function `AccountDelta::to_commitment` for a detailed description of how it is computed. -#! -#! Inputs: [] -#! Outputs: [DELTA_COMMITMENT] -#! -#! Where: -#! - DELTA_COMMITMENT is the commitment to the account delta. -#! -#! Panics if: -#! - the vault or storage delta is not empty but the nonce increment is zero. -pub proc compute_commitment - # pad capacity and RATE1 of the hasher with empty words - padw padw - # => [EMPTY_WORD, CAPACITY] - - exec.memory::get_native_account_id - # => [native_acct_id_suffix, native_acct_id_prefix, EMPTY_WORD, CAPACITY] - - # the delta of the nonce is equal to was_nonce_incremented - push.0 exec.was_nonce_incremented - # => [nonce_delta, 0, native_acct_id_suffix, native_acct_id_prefix, EMPTY_WORD, CAPACITY] - # => [ID_AND_NONCE, EMPTY_WORD, CAPACITY] - - exec.poseidon2::permute - # => [RATE0, RATE1, CAPACITY] - - # save the ID and nonce digest (RATE0 word) for a later check - exec.poseidon2::copy_digest movdnw.3 - # => [RATE0, RATE1, CAPACITY, ID_AND_NONCE_DIGEST] - - exec.update_fungible_asset_delta - # => [RATE0, RATE1, CAPACITY, ID_AND_NONCE_DIGEST] - - exec.update_non_fungible_asset_delta - # => [RATE0, RATE1, CAPACITY, ID_AND_NONCE_DIGEST] - - exec.update_storage_delta - # => [RATE0, RATE1, CAPACITY, ID_AND_NONCE_DIGEST] - - exec.poseidon2::squeeze_digest - # => [DELTA_COMMITMENT, ID_AND_NONCE_DIGEST] - - exec.was_nonce_incremented not - # => [!was_nonce_incremented, DELTA_COMMITMENT, ID_AND_NONCE_DIGEST] - - if.true - # if the nonce wasn't incremented, then the vault and storage changes must be empty - # if the delta commitment is equivalent to the ID_AND_NONCE_DIGEST, then storage - # and vault delta were empty - assert_eqw.err=ERR_ACCOUNT_DELTA_NONCE_MUST_BE_INCREMENTED_IF_VAULT_OR_STORAGE_CHANGED - # => [] - - # if the delta is empty, its commitment is defined as the empty word - padw - # => [EMPTY_WORD] - else - # drop the ID and nonce digest - swapw dropw - # => [DELTA_COMMITMENT] - end -end - -#! Updates the given delta hasher with the storage slots. -#! -#! Inputs: [RATE0, RATE1, CAPACITY] -#! Outputs: [RATE0, RATE1, CAPACITY] -proc update_storage_delta - exec.memory::get_native_num_storage_slots movdn.12 - # => [RATE0, RATE1, CAPACITY, num_storage_slots] - - push.0 movdn.12 - # => [RATE0, RATE1, CAPACITY, slot_idx = 0, num_storage_slots] - - # loop if num_storage_slots != 0 - dup.13 neq.0 - # => [should_loop, RATE0, RATE1, CAPACITY, slot_idx, num_storage_slots] - - while.true - dup.12 - # => [slot_idx, RATE0, RATE1, CAPACITY, slot_idx, num_storage_slots] - - exec.update_slot_delta - # => [RATE0, RATE1, CAPACITY, slot_idx, num_storage_slots] - - # increment slot index - movup.12 add.1 - # => [next_slot_idx, RATE0, RATE1, CAPACITY, num_storage_slots] - - dup movdn.13 - # => [next_slot_idx, RATE0, RATE1, CAPACITY, next_slot_idx, num_storage_slots] - - # continue if next_slot_idx != num_storage_slots - # we use neq instead of lt for efficiency - dup.14 neq - # => [should_loop, RATE0, RATE1, CAPACITY, next_slot_idx, num_storage_slots] - end - # => [RATE0, RATE1, CAPACITY, next_slot_idx, num_storage_slots] - - # clean the stack - movup.12 drop movup.12 drop - # => [RATE0, RATE1, CAPACITY] -end - -#! Updates the given delta hasher with the storage slot at the provided index. -#! -#! Inputs: [slot_idx, RATE0, RATE1, CAPACITY] -#! Outputs: [RATE0, RATE1, CAPACITY] -proc update_slot_delta - dup exec.account::get_native_storage_slot_type - # => [storage_slot_type, slot_idx, RATE0, RATE1, CAPACITY] - - # check if slot is of type value - push.STORAGE_SLOT_TYPE_VALUE eq - # => [is_value_slot_type, slot_idx, RATE0, RATE1, CAPACITY] - - if.true - exec.update_value_slot_delta - else - exec.update_map_slot_delta - end - # => [RATE0, RATE1, CAPACITY] -end - -#! Updates the given delta hasher with the value storage slot at the provided index. -#! -#! Inputs: [slot_idx, RATE0, RATE1, CAPACITY] -#! Outputs: [RATE0, RATE1, CAPACITY] -proc update_value_slot_delta - exec.account::get_item_delta - # => [INIT_VALUE, CURRENT_VALUE, slot_id_suffix, slot_id_prefix, RATE0, RATE1, CAPACITY] - - exec.word::test_eq not - # => [was_changed, INIT_VALUE, CURRENT_VALUE, slot_id_suffix, slot_id_prefix, RATE0, RATE1, CAPACITY] - - # set was_changed to true if the account is new - # generally, the delta for a new account must include all its storage slots, regardless of the - # initial value and even if it is an empty word, because the initial delta for an account must - # represent its full state - exec.memory::is_new_account or - # => [was_changed, INIT_VALUE, CURRENT_VALUE, slot_id_suffix, slot_id_prefix, RATE0, RATE1, CAPACITY] - - # only include in delta if the slot's value has changed or the account is new - if.true - # drop init value - dropw - # => [CURRENT_VALUE, slot_id_suffix, slot_id_prefix, RATE0, RATE1, CAPACITY] - - # build value slot metadata - movup.5 movup.5 - # => [slot_id_suffix, slot_id_prefix, CURRENT_VALUE, RATE0, RATE1, CAPACITY] - - push.0.DOMAIN_VALUE - # => [[domain, 0, slot_id_suffix, slot_id_prefix], CURRENT_VALUE, RATE0, RATE1, CAPACITY] - - # clear rate elements - swapdw dropw dropw - # => [[domain, 0, slot_id_suffix, slot_id_prefix], CURRENT_VALUE, CAPACITY] - - exec.poseidon2::permute - # => [RATE0, RATE1, CAPACITY] - else - # drop init value, current value and slot name - dropw dropw drop drop - # => [RATE0, RATE1, CAPACITY] - end - # => [RATE0, RATE1, CAPACITY] -end - -#! Updates the given delta hasher with the map storage slot at the provided index. -#! -#! Inputs: [slot_idx, RATE0, RATE1, CAPACITY] -#! Outputs: [RATE0, RATE1, CAPACITY] -#! -#! Locals: -#! - 0: slot_id_suffix -#! - 1: slot_id_prefix -#! - 2: has_next -#! - 3: iter -#! - 4: num_changed_entries -@locals(5) -proc update_map_slot_delta - # initialize num_changed_entries = 0 - # this is necessary because this procedure can be called multiple times and the second - # invocation shouldn't reuse the first invocation's value - push.0 loc_store.4 - # => [slot_idx, RATE0, RATE1, CAPACITY] - - dup exec.account::get_slot_id - # => [slot_id_suffix, slot_id_prefix, slot_idx, RATE0, RATE1, CAPACITY] - - loc_store.0 loc_store.1 - # => [slot_idx, RATE0, RATE1, CAPACITY] - - exec.memory::get_account_delta_storage_map_ptr - # => [account_delta_storage_map_ptr, RATE0, RATE1, CAPACITY] - - exec.link_map::iter - # => [has_next, iter, RATE0, RATE1, CAPACITY] - - # enter loop if the link map is not empty - while.true - exec.link_map::next_key_double_value - # => [KEY, INIT_VALUE, NEW_VALUE, has_next, iter, ...] - - # store has_next - movup.12 loc_store.2 - # => [KEY, INIT_VALUE, NEW_VALUE, iter, ...] - - # store iter - movup.12 loc_store.3 - # => [KEY, INIT_VALUE, NEW_VALUE, ...] - - swapw.2 - # => [NEW_VALUE, INIT_VALUE, KEY, ...] - - exec.word::test_eq not - # => [was_changed, NEW_VALUE, INIT_VALUE, KEY, ...] - - # if the key-value pair has actually changed, update the hasher - if.true - # drop the initial value - swapw dropw swapw - # => [KEY, NEW_VALUE, RATE0, RATE1, CAPACITY] - - # increment number of changed entries in local - loc_load.4 add.1 loc_store.4 - # => [KEY, NEW_VALUE, RATE0, RATE1, CAPACITY] - - # drop previous RATE elements - swapdw dropw dropw - # => [KEY, NEW_VALUE, CAPACITY] - - exec.poseidon2::permute - # => [RATE0, RATE1, CAPACITY] - else - # discard the key and init and new value words loaded from the map - dropw dropw dropw - # => [RATE0, RATE1, CAPACITY] - end - # => [RATE0, RATE1, CAPACITY] - - # load iter and has_next - loc_load.3 - # => [iter, RATE0, RATE1, CAPACITY] - - loc_load.2 - # => [has_next, iter, RATE0, RATE1, CAPACITY] - end - - # drop iter - drop - # => [RATE0, RATE1, CAPACITY] - - # only include the map slot metadata if there were entries in the map that resulted in an - # update to the hasher state - loc_load.4 neq.0 - # => [is_num_changed_entries_non_zero, RATE0, RATE1, CAPACITY] - - # if the account is new (nonce == 0) include the map header even if it is an empty map - # in order to have the delta commit to this initial storage slot. - exec.memory::is_new_account or - # => [should_include_map_header, RATE0, RATE1, CAPACITY] - - if.true - # drop the previous RATE elements - dropw dropw - # => [CAPACITY] - - padw loc_load.1 loc_load.0 loc_load.4 push.DOMAIN_MAP - # => [[domain, num_changed_entries, slot_id_suffix, slot_id_prefix], EMPTY_WORD, CAPACITY] - - exec.poseidon2::permute - # => [RATE0, RATE1, CAPACITY] - end - # => [RATE0, RATE1, CAPACITY] -end - -#! Updates the given delta hasher with the fungible asset vault delta. -#! -#! Inputs: [RATE0, RATE1, CAPACITY] -#! Outputs: [RATE0, RATE1, CAPACITY] -#! -#! Locals: -#! - 0: has_next -#! - 1: iter -@locals(2) -proc update_fungible_asset_delta - push.ACCOUNT_DELTA_FUNGIBLE_ASSET_PTR - # => [account_delta_fungible_asset_ptr, RATE0, RATE1, CAPACITY] - - exec.link_map::iter - # => [has_next, iter, RATE0, RATE1, CAPACITY] - - # enter loop if the link map is not empty - while.true - exec.link_map::next_key_value - # => [KEY, VALUE0, has_next, iter, ...] - - # store has_next - movup.8 loc_store.0 - # => [KEY, VALUE0, iter, ...] - - # store iter - movup.8 loc_store.1 - # => [KEY, VALUE0, ...] - # this stack state is equivalent to: - # => [[0, 0, faucet_id_suffix_and_metadata, faucet_id_prefix], [delta_amount, 0, 0, 0], ...] - - swapw - # => [[delta_amount, 0, 0, 0], [0, 0, faucet_id_suffix_and_metadata, faucet_id_prefix], ...] - - # compute the absolute value of delta amount with a flag indicating whether it's positive - exec.delta_amount_absolute - # => [is_delta_amount_positive, [delta_amount_abs, 0, 0, 0], [0, 0, faucet_id_suffix_and_metadata, faucet_id_prefix], ...] - - # define the was_added value as equivalent to is_delta_amount_positive - # this value is 1 if the amount was added and 0 if the amount was removed - swap.6 drop - # => [[delta_amount_abs, 0, 0, 0], [0, was_added, faucet_id_suffix_and_metadata, faucet_id_prefix], ...] - - dup neq.0 - # => [is_delta_amount_non_zero, [delta_amount_abs, 0, 0, 0], [0, was_added, faucet_id_suffix_and_metadata, faucet_id_prefix], ...] - - # if delta amount is non-zero, update the hasher - if.true - push.DOMAIN_ASSET swap.5 drop - # => [[delta_amount_abs, 0, 0, 0], [domain, was_added, faucet_id_suffix_and_metadata, faucet_id_prefix], ...] - - # swap value and metadata words - swapw - # => [[domain, was_added, faucet_id_suffix_and_metadata, faucet_id_prefix], [delta_amount_abs, 0, 0, 0], RATE0, RATE1, CAPACITY] - - # drop previous RATE elements - swapdw dropw dropw - # => [[domain, was_added, faucet_id_suffix_and_metadata, faucet_id_prefix], [delta_amount_abs, 0, 0, 0], CAPACITY] - - exec.poseidon2::permute - # => [RATE0, RATE1, CAPACITY] - else - # discard values loaded from map: KEY, VALUE0 - dropw dropw - # => [RATE0, RATE1, CAPACITY] - end - # => [RATE0, RATE1, CAPACITY] - - # load iter and has_next - loc_load.1 - # => [iter, RATE0, RATE1, CAPACITY] - - loc_load.0 - # => [has_next, iter, RATE0, RATE1, CAPACITY] - end - - # drop iter - drop - # => [RATE0, RATE1, CAPACITY] -end - -#! Updates the given delta hasher with the non-fungible asset vault delta. -#! -#! Inputs: [RATE0, RATE1, CAPACITY] -#! Outputs: [RATE0, RATE1, CAPACITY] -#! -#! Locals: -#! - 0: has_next -#! - 1: iter -@locals(2) -proc update_non_fungible_asset_delta - push.ACCOUNT_DELTA_NON_FUNGIBLE_ASSET_PTR - # => [account_delta_non_fungible_asset_ptr, RATE0, RATE1, CAPACITY] - - exec.link_map::iter - # => [has_next, iter, RATE0, RATE1, CAPACITY] - - # enter loop if the link map is not empty - while.true - exec.link_map::next_key_double_value - # => [KEY, VALUE0, VALUE1, has_next, iter, ...] - - # store has_next - movup.12 loc_store.0 - # => [KEY, VALUE0, VALUE1, iter, ...] - - # store iter - movup.12 loc_store.1 - # => [KEY, VALUE0, VALUE1, ...] - # this stack state is equivalent to: - # => [ASSET_KEY, [was_added, 0, 0, 0], ASSET_VALUE, ...] - - dup.4 neq.0 - # => [was_added_or_removed, ASSET_KEY, [was_added, 0, 0, 0], ASSET_VALUE, ...] - - # if the asset was added or removed (i.e. if was_added != 0), update the hasher - if.true - swapw - # => [[was_added, 0, 0, 0], ASSET_KEY, ASSET_VALUE, ...] - - # convert was_added to a boolean - # was_added is 1 if the asset was added and 0 - 1 if it was removed - eq.1 - # => [[was_added, 0, 0, 0], [asset_id_suffix, asset_id_prefix, faucet_id_suffix_and_metadata, faucet_id_prefix], ASSET_VALUE, ...] - - # replace asset_id_prefix with was_added and drop the remaining word - swap.5 dropw - # => [[asset_id_suffix, was_added, faucet_id_suffix_and_metadata, faucet_id_prefix], ASSET_VALUE, ...] - - # replace asset_id_suffix with domain - drop push.DOMAIN_ASSET - # => [[domain, was_added, faucet_id_suffix_and_metadata, faucet_id_prefix], ASSET_VALUE, ...] - - # drop previous RATE elements - swapdw dropw dropw - # => [[domain, was_added, faucet_id_suffix_and_metadata, faucet_id_prefix], ASSET_VALUE, CAPACITY] - - exec.poseidon2::permute - # => [RATE0, RATE1, CAPACITY] - else - # discard the key, value0 and value1 words loaded from the map - dropw dropw dropw - # => [RATE0, RATE1, CAPACITY] - end - # => [RATE0, RATE1, CAPACITY] - - # load iter and has_next - loc_load.1 - # => [iter, RATE0, RATE1, CAPACITY] - - loc_load.0 - # => [has_next, iter, RATE0, RATE1, CAPACITY] - end - - # drop iter - drop - # => [RATE0, RATE1, CAPACITY] -end - -# DELTA BOOKKEEPING -# ------------------------------------------------------------------------------------------------- - -#! Returns a flag indicating whether the account's nonce was incremented. -#! -#! Inputs: [] -#! Outputs: [was_nonce_incremented] -#! -#! Where: -#! - was_nonce_incremented is the boolean flag indicating whether the account nonce was incremented. -pub proc was_nonce_incremented - exec.memory::get_init_nonce - # => [init_nonce] - - exec.memory::get_account_nonce - # => [current_nonce, init_nonce] - - neq - # => [was_nonce_incremented] -end - -#! Adds the given asset to the delta. -#! -#! Assumes the asset is valid, so it should be called after asset_vault::add_asset. -#! -#! Inputs: [ASSET_KEY, ASSET_VALUE] -#! Outputs: [] -#! -#! Where: -#! - ASSET_KEY is the vault key of the asset that is added. -#! - ASSET_VALUE is the value of the asset that is added. -pub proc add_asset - # check if the asset is a fungible asset - exec.asset::is_fungible_asset_key - # => [is_fungible_asset, ASSET_KEY, ASSET_VALUE] - - if.true - swapw - # => [ASSET_VALUE, ASSET_KEY] - - exec.fungible_asset::value_into_amount - movdn.4 - # => [ASSET_KEY, amount] - - exec.add_fungible_asset - # => [] - else - exec.add_non_fungible_asset - # => [] - end -end - -#! Removes the given asset from the delta. -#! -#! Assumes the asset is valid, so it should be called after asset_vault::remove_asset -#! (which would abort if the asset is invalid). -#! -#! Inputs: [ASSET_KEY, ASSET_VALUE] -#! Outputs: [] -#! -#! Where: -#! - ASSET_KEY is the vault key of the asset that is removed. -#! - ASSET_VALUE is the value of the asset that is removed. -pub proc remove_asset - # check if the asset is a fungible asset - exec.asset::is_fungible_asset_key - # => [is_fungible_asset, ASSET_KEY, ASSET_VALUE] - - if.true - swapw - # => [ASSET_VALUE, ASSET_KEY] - - exec.fungible_asset::value_into_amount - movdn.4 - # => [ASSET_KEY, amount] - - exec.remove_fungible_asset - # => [] - else - exec.remove_non_fungible_asset - # => [] - end -end - -#! Adds the given amount to the fungible asset delta for the asset identified by the asset key. -#! -#! Inputs: [ASSET_KEY, amount] -#! Outputs: [] -#! -#! Where: -#! - ASSET_KEY is the asset key of the fungible asset. -#! - amount is the amount by which the fungible asset's amount increases. -pub proc add_fungible_asset - dupw push.ACCOUNT_DELTA_FUNGIBLE_ASSET_PTR - # => [fungible_delta_map_ptr, ASSET_KEY, ASSET_KEY, amount] - - # retrieve the current delta amount - # contains_key can be ignored because the default value is a delta amount of 0 - # VALUE1 is unused so we drop it as well - exec.link_map::get drop swapw dropw - # => [delta_amount, 0, 0, 0, ASSET_KEY, amount] - - movup.8 - # => [amount, delta_amount, 0, 0, 0, ASSET_KEY] - - # compute delta + amount - # note that this operation wraps around - add - # => [delta_amount, 0, 0, 0, ASSET_KEY] - - # pad VALUE1 of the link map - swapw padw movdnw.2 - # => [ASSET_KEY, delta_amount, 0, 0, 0, EMPTY_WORD] - - push.ACCOUNT_DELTA_FUNGIBLE_ASSET_PTR - # => [fungible_delta_map_ptr, ASSET_KEY, delta_amount, 0, 0, 0, EMPTY_WORD] - - exec.link_map::set drop - # => [] -end - -#! Subtracts the given amount from the fungible asset delta for the asset identified by the asset key. -#! -#! Inputs: [ASSET_KEY, amount] -#! Outputs: [] -#! -#! Where: -#! - ASSET_KEY is the asset key of the fungible asset. -#! - amount is the amount by which the fungible asset's amount decreases. -pub proc remove_fungible_asset - dupw push.ACCOUNT_DELTA_FUNGIBLE_ASSET_PTR - # => [fungible_delta_map_ptr, ASSET_KEY, ASSET_KEY, amount] - - # retrieve the current delta amount - # contains_key can be ignored because the default value is a delta amount of 0 - # VALUE1 is unused so we drop it as well - exec.link_map::get drop swapw dropw - # => [delta_amount, 0, 0, 0, ASSET_KEY, amount] - - movup.8 - # => [amount, delta_amount, 0, 0, 0, ASSET_KEY] - - # compute delta - amount - # note that this operation wraps around - sub - # => [delta_amount, 0, 0, 0, ASSET_KEY] - - # pad VALUE1 of the link map - swapw padw movdnw.2 - # => [ASSET_KEY, delta_amount, 0, 0, 0, EMPTY_WORD] - - push.ACCOUNT_DELTA_FUNGIBLE_ASSET_PTR - # => [fungible_delta_map_ptr, ASSET_KEY, delta_amount, 0, 0, 0, EMPTY_WORD] - - exec.link_map::set drop - # => [] -end - -#! Adds the given non-fungible asset to the non-fungible asset vault delta. -#! -#! ASSET_VALUE must be a valid non-fungible asset. -#! -#! If the key does not exist in the delta map, the non-fungible asset's was_added value is 0. -#! When it is added to the account vault, was_added is incremented by 1; when it is removed from -#! the account vault, was_added is decremented by 1. -#! Since an asset can only be added to or removed from a vault once, was_added will always have a -#! value of -1, 0, or 1, where -1 is represented by `0 - 1` in felt operations. -#! This means adding and removing or removing and adding the asset will correctly cancel out. -#! -#! The final was_added value after transaction execution is then interpreted as follows: -#! -1 -> asset was removed -#! 0 -> no change to the asset -#! +1 -> asset was added -#! -#! Inputs: [ASSET_KEY, ASSET_VALUE] -#! Outputs: [] -#! -#! Where: -#! - ASSET_KEY is the vault key of the non-fungible asset to be added. -#! - ASSET_VALUE is the value of the non-fungible asset to be added. -pub proc add_non_fungible_asset - dupw push.ACCOUNT_DELTA_NON_FUNGIBLE_ASSET_PTR - # => [non_fungible_delta_map_ptr, ASSET_KEY, ASSET_KEY, ASSET_VALUE] - - # retrieve the current delta - # contains_key can be ignored because the asset vault ensures each asset key is only added to - # the delta once - # if no entry exists, the default value is an empty word and so the was_added value is 0 - exec.link_map::get drop - # => [was_added, 0, 0, 0, PREV_ASSET_VALUE, ASSET_KEY, ASSET_VALUE] - - dupw.3 movupw.2 - # => [PREV_ASSET_VALUE, ASSET_VALUE, was_added, 0, 0, 0, ASSET_KEY, ASSET_VALUE] - - # the asset vault guarantees that this procedure is only called when the asset was not yet - # _added_ to the vault, so it can either be absent or it could have been removed - # absent means PREV_ASSET_VALUE is the EMPTY_WORD - # removal means PREV_ASSET_VALUE is equal to ASSET_VALUE - # sanity check that this assumption is true - - exec.word::testz movdn.8 - # => [PREV_ASSET_VALUE, ASSET_VALUE, is_empty_word, was_added, 0, 0, 0, ASSET_KEY, ASSET_VALUE] - - exec.word::eq or - assert.err="add: prev_asset_value must be empty or equal to asset_value for non-fungible assets" - # => [was_added, 0, 0, 0, ASSET_KEY, ASSET_VALUE] - - # add 1 to cancel out a previous removal (was_added = 0) or mark the asset as added (was_added = 1) - add.1 - # => [was_added, 0, 0, 0, ASSET_KEY, ASSET_VALUE] - - swapw - # => [ASSET_KEY, was_added, 0, 0, 0, ASSET_VALUE] - - push.ACCOUNT_DELTA_NON_FUNGIBLE_ASSET_PTR - # => [non_fungible_delta_map_ptr, ASSET_KEY, was_added, 0, 0, 0, ASSET_VALUE] - - exec.link_map::set drop - # => [] -end - -#! Removes the given non-fungible asset from the non-fungible asset vault delta. -#! -#! ASSET_VALUE must be a valid non-fungible asset. -#! -#! See add_non_fungible_asset for documentation. -#! -#! Inputs: [ASSET_KEY, ASSET_VALUE] -#! Outputs: [] -#! -#! Where: -#! - ASSET_KEY is the vault key of the non-fungible asset to be removed. -#! - ASSET_VALUE is the value of the non-fungible asset to be removed. -pub proc remove_non_fungible_asset - dupw push.ACCOUNT_DELTA_NON_FUNGIBLE_ASSET_PTR - # => [non_fungible_delta_map_ptr, ASSET_KEY, ASSET_KEY, ASSET_VALUE] - - # retrieve the current delta - # contains_key can be ignored because the asset vault ensures each asset key is only removed - # from the delta once - # if no entry exists, the default value is an empty word and so the was_added value is 0 - exec.link_map::get drop - # => [was_added, 0, 0, 0, PREV_ASSET_VALUE, ASSET_KEY, ASSET_VALUE] - - dupw.3 movupw.2 - # => [PREV_ASSET_VALUE, ASSET_VALUE, was_added, 0, 0, 0, ASSET_KEY, ASSET_VALUE] - - # the asset vault guarantees that this procedure is only called when the asset was not yet - # _removed_ from the vault, so it can either be present or it could have been removed - # absent means PREV_ASSET_VALUE is the EMPTY_WORD - # addition means PREV_ASSET_VALUE is equal to ASSET_VALUE - # sanity check that this assumption is true - - exec.word::testz movdn.8 - # => [PREV_ASSET_VALUE, ASSET_VALUE, is_empty_word, was_added, 0, 0, 0, ASSET_KEY, ASSET_VALUE] - - exec.word::eq or - assert.err="remove: prev_asset_value must be empty or equal to asset_value for non-fungible assets" - # => [was_added, 0, 0, 0, ASSET_KEY, ASSET_VALUE] - - # sub 1 to cancel out a previous addition (was_added = 1) or mark the asset as removed (was_added = -1) - sub.1 - # => [was_added, 0, 0, 0, ASSET_KEY, ASSET_VALUE] - - swapw - # => [ASSET_KEY, was_added, 0, 0, 0, ASSET_VALUE] - - push.ACCOUNT_DELTA_NON_FUNGIBLE_ASSET_PTR - # => [non_fungible_delta_map_ptr, ASSET_KEY, was_added, 0, 0, 0, ASSET_VALUE] - - exec.link_map::set drop - # => [] -end - -#! Updates the storage map delta of the given slot index with the given key-value pair and its -#! previous value. -#! -#! The layout of a link map entry for a given KEY is: [KEY, INIT_VALUE, NEW_VALUE] where INIT_VALUE -#! represents the initial value for the given KEY at the beginning of transaction execution and -#! NEW_VALUE is the new value. The delta entry is a NOOP if INIT_VALUE and NEW_VALUE are equal. -#! -#! Inputs: [slot_index, KEY, PREV_VALUE, NEW_VALUE] -#! Outputs: [] -#! -#! Where: -#! - slot_index is the slot index of the storage map slot. -#! - KEY is the key in the storage map that is being updated. -#! - PREV_VALUE is the previous value of the key in the storage map that is being updated. -#! - NEW_VALUE is the new value of the key in the storage map that is being updated. -@locals(8) -pub proc set_map_item - # retrieve the link map ptr to the storage map delta for the provided index - exec.memory::get_account_delta_storage_map_ptr - # => [account_delta_storage_map_ptr, KEY, PREV_VALUE, NEW_VALUE] - - # store map ptr in local - loc_store.0 - # => [KEY, PREV_VALUE, NEW_VALUE] - - # store KEY in local - loc_storew_le.4 - # => [KEY, PREV_VALUE, NEW_VALUE] - - loc_load.0 - # => [account_delta_storage_map_ptr, KEY, PREV_VALUE, NEW_VALUE] - - # retrieve the current delta - exec.link_map::get - # => [contains_key, VALUE0, VALUE1, PREV_VALUE, NEW_VALUE] - - movdn.12 - # => [VALUE0, VALUE1, PREV_VALUE, contains_key, NEW_VALUE] - - # VALUE1 was the previous "new value" if this key was already updated, so in any case, - # we can drop it since this update overwrites the previous one - swapw dropw - # => [VALUE0, PREV_VALUE, contains_key, NEW_VALUE] - - movup.8 - # => [contains_key, VALUE0, PREV_VALUE, NEW_VALUE] - - # contains_key determines whether this is the first update to this KEY - # if this is the first update, PREV_VALUE is the *initial* value of the key-value pair - # if this is not the first update, VALUE0 is the initial value, so we want to store it back - # use cdropw to selectively keep the word that represents the initial value - # If contains_key VALUE0 remains. - # If !contains_key PREV_VALUE remains. - cdropw - # => [INITIAL_VALUE, NEW_VALUE] - - # load key and index from locals - padw loc_loadw_le.4 loc_load.0 - # => [account_delta_storage_map_ptr, KEY, INITIAL_VALUE, NEW_VALUE] - - exec.link_map::set drop - # => [] -end - -# FUNGIBLE ASSET AMOUNT MATH -# ------------------------------------------------------------------------------------------------- - -# Asset Amount Deltas can be signed or unsigned and are represented as felts. -# -# Any value in range [0, 2^63 - 2^31] (inclusive) is interpreted as a positive value. -# Any value in range [2^63 - 2^31 + 1, 2^64 - 2^32] (inclusive) is interpreted as a negative value. -# 2^64 - 2^32 represents -1, 2^64 - 2^32 - 1 represents -2, and so on, up to 2^63 - 2^31 + 1 which -# represents -(2^63 - 2^31). -# -# This allows us to use the add and sub operations which wrap around. E.g. push.5 sub.6 add.1 will -# correctly result in 0. -# -# To negate a negative `value`, we can multiply the value by -1, e.g. the result of push.0 sub.1. -# -# Don't we have to check for overflows? No, because we're building on top of the guarantees of the -# asset vault. It guarantees that the max value that can be added to the vault within a transaction -# is asset::FUNGIBLE_ASSET_MAX_AMOUNT and that the max value that can be removed is also that. -# Since the delta amount range can represent positive and negative -# asset::FUNGIBLE_ASSET_MAX_AMOUNT, this works out. -# -# With these ranges every positive value has a negative counterpart and vice versa. This is -# **unlike** two's complements because the goldilocks modulus is odd while the "modulus" in -# binary numbers is even. For example, a signed 8-bit integer can represent 128 positive and 128 -# negative values, because the total number of values is 256 divided by two. Zero is counted on the -# positive side and so i8::MAX is 127 and i8::MIN is -128 - therefore not every negative value has -# a positive counterpart. The "modulus", if you will, is 256 and even. -# -# Since the golidlocks modulus is 2^64 - 2^32 + 1 and therefore odd, we can represent -# modulus / 2 + 1 positive and modulus / 2 negative values. Again, 0 is counted on the positive -# side and so the largest representable positive value is modulus / 2 and the smallest -# representable negative value is -(modulus / 2). So, every negative value has a positive -# counterpart (and vice versa). - -#! Computes the absolute value of the given delta amount represented as a felt and returns a -#! boolean flag indicating whether the value is positive (or unsigned). -#! -#! Inputs: [delta_amount] -#! Outputs: [is_delta_amount_positive, delta_amount_abs] -#! -#! Where: -#! - delta_amount is the delta amount that can span the entire felt range. -#! - is_delta_amount_positive indicates whether the delta amount is positive. -#! - delta_amount_abs is the absolute value of the delta_amount, meaning negative values are -#! remapped to their positive equivalent value. -proc delta_amount_absolute - dup exec.is_delta_amount_negative not - # => [is_positive, delta_amount] - - # map 0 -> -1 and 1 -> 1 - dup mul.2 sub.1 - # => [multiplier, is_positive, delta_amount] - - # multiply negative values by -1 and positive values by 1 to get the absolute value - movup.2 mul swap - # => [is_positive, delta_amount_abs] -end - -#! Returns 1 if the given delta amount is negative (or signed). -#! -#! Inputs: [delta_amount] -#! Outputs: [is_delta_amount_negative] -#! -#! Where: -#! - delta_amount is the delta amount that can span the entire felt range. -#! - is_delta_amount_negative indicates whether the delta amount represents a negative value. -proc is_delta_amount_negative - # delta_amount represents a negative number if it is greater than the max amount - gt.FUNGIBLE_ASSET_MAX_AMOUNT - # => [is_delta_amount_negative] -end diff --git a/crates/miden-protocol/asm/kernels/transaction/lib/account_update.masm b/crates/miden-protocol/asm/kernels/transaction/lib/account_update.masm new file mode 100644 index 0000000000..ca0439a829 --- /dev/null +++ b/crates/miden-protocol/asm/kernels/transaction/lib/account_update.masm @@ -0,0 +1,930 @@ +use $kernel::account +use $kernel::asset +use $kernel::asset::COMPOSITION_NONE +use $kernel::constants::STORAGE_SLOT_TYPE_VALUE +use $kernel::asset::ASSET_SIZE +use $kernel::link_map +use $kernel::memory +use $kernel::memory::ACCOUNT_UPDATE_ASSET_PTR +use miden::core::crypto::hashes::poseidon2 +use miden::core::word + +# ERRORS +# ================================================================================================= + +const ERR_ACCOUNT_DELTA_NONCE_MUST_BE_INCREMENTED_IF_VAULT_OR_STORAGE_CHANGED="nonce in delta must have been incremented if account vault or account storage changed" + +const ERR_ACCOUNT_PATCH_NONCE_MUST_BE_INCREMENTED_IF_VAULT_OR_STORAGE_CHANGED="final nonce in patch must have been incremented if account vault or account storage changed" + +const ERR_ACCOUNT_DELTA_TOO_MANY_REMOVED_ASSETS="number of removed assets in the transaction exceeds the maximum limit of 1024" + +# EVENTS +# ================================================================================================= + +const ACCOUNT_BEFORE_ASSET_DELTA_COMPUTATION=event("miden::protocol::account::before_asset_delta_computation") + +const ACCOUNT_ON_ASSET_DELTA_COMPUTATION_EVENT=event("miden::protocol::account::on_asset_delta_computation") + +# CONSTANTS +# ================================================================================================= + +# The domain of the delta commitment header. +const DOMAIN_DELTA = 1 +# The domain of the patch commitment header. +const DOMAIN_PATCH = 2 +# The domain of an asset in the delta commitment. +const DOMAIN_ASSET = 3 +# The domain of a value storage slot in the delta commitment. +const DOMAIN_VALUE = 5 +# The domain of a map storage slot in the delta commitment. +const DOMAIN_MAP = 6 + +#! The default delta operation indicating no change. +const DELTA_OP_NONE = 0 + +#! The delta operation indicating that an asset was added. +const DELTA_OP_ADD = 1 + +#! The delta operation indicating that an asset was removed. +const DELTA_OP_REMOVE = 2 + +# The maximum value a felt can represent. +const FELT_MAX = 0xffffffff00000000 + +#! Local in track_asset_delta that stores the delta operation. +const TRACK_ASSET_DELTA_OP_LOC = 0 + +#! Locals in combine_asset_delta that store the incoming and current delta operations so that the +#! new delta operation can be selected after the magnitude comparison. +const COMBINE_INCOMING_DELTA_OP_LOC = 0 +const COMBINE_CURRENT_DELTA_OP_LOC = 1 + +#! Locals in commit_asset_delta. The last region is a buffer of removed assets that is hashed after +#! the added-assets section. +const COMMIT_ASSET_DELTA_HAS_NEXT_LOC = 0 +const COMMIT_ASSET_DELTA_ITER_LOC = 1 +const COMMIT_ASSET_DELTA_NUM_ADDED_LOC = 2 +const COMMIT_ASSET_DELTA_NUM_REMOVED_LOC = 3 +const COMMIT_ASSET_DELTA_REMOVED_ASSETS_LOC = 4 + +#! Locals in commit_asset_patch. +const COMMIT_ASSET_PATCH_HAS_NEXT_LOC = 0 +const COMMIT_ASSET_PATCH_ITER_LOC = 1 +const COMMIT_ASSET_PATCH_NUM_CHANGED_LOC = 2 + +#! The maximum number of assets that can be removed in a single transaction. +#! +#! This limit is arbitrary and can be increased accordingly with the number of locals of +#! commit_asset_delta. +const COMMIT_ASSET_DELTA_MAX_REMOVED_ASSETS = 1024 + +# PROCEDURES +# ================================================================================================= + +# DELTA & PATCH COMPUTATION +# ------------------------------------------------------------------------------------------------- + +#! Computes the commitment to the native account's patch. +#! +#! See the Rust function `AccountPatch::to_commitment` for a detailed description of how it is computed. +#! +#! Inputs: [] +#! Outputs: [PATCH_COMMITMENT] +#! +#! Where: +#! - PATCH_COMMITMENT is the commitment to the account patch. +#! +#! Panics if: +#! - the vault patch or storage patch is not empty but the nonce increment is zero. +pub proc compute_patch_commitment + # pad capacity and RATE1 of the hasher with empty words + padw padw + # => [EMPTY_WORD, CAPACITY] + + exec.memory::get_native_account_id + # => [native_account_id_suffix, native_account_id_prefix, EMPTY_WORD, CAPACITY] + + # get the nonce of the account and assume it is the final nonce + # we later assert that the nonce was incremented (unless the patch is empty) + exec.memory::get_account_nonce + push.DOMAIN_PATCH + # => [domain_patch, final_nonce, native_account_id_suffix, native_account_id_prefix, EMPTY_WORD, CAPACITY] + # => [ID_AND_NONCE, EMPTY_WORD, CAPACITY] + + exec.poseidon2::permute + # => [RATE0, RATE1, CAPACITY] + + # save the ID and nonce digest (RATE0 word) for a later check + exec.poseidon2::copy_digest movdnw.3 + # => [RATE0, RATE1, CAPACITY, ID_AND_NONCE_DIGEST] + + exec.commit_asset_patch + # => [RATE0, RATE1, CAPACITY, ID_AND_NONCE_DIGEST] + + exec.commit_storage_patch + # => [RATE0, RATE1, CAPACITY, ID_AND_NONCE_DIGEST] + + exec.poseidon2::squeeze_digest + # => [PATCH_COMMITMENT, ID_AND_NONCE_DIGEST] + + exec.was_nonce_incremented not + # => [!was_nonce_incremented, PATCH_COMMITMENT, ID_AND_NONCE_DIGEST] + + if.true + # if the nonce wasn't incremented, then the vault and storage patch is required to be empty + # storage and vault patch were empty if the patch commitment is equal to + # ID_AND_NONCE_DIGEST + assert_eqw.err=ERR_ACCOUNT_PATCH_NONCE_MUST_BE_INCREMENTED_IF_VAULT_OR_STORAGE_CHANGED + # => [] + + # if the patch is empty, its commitment is defined as the empty word + padw + # => [EMPTY_WORD] + else + # drop the ID and nonce digest + swapw dropw + # => [PATCH_COMMITMENT] + end +end + +#! Computes the commitment to the native account's delta. +#! +#! See the Rust function `AccountDelta::to_commitment` for a detailed description of how it is computed. +#! +#! Inputs: [] +#! Outputs: [DELTA_COMMITMENT] +#! +#! Where: +#! - DELTA_COMMITMENT is the commitment to the account delta. +#! +#! Panics if: +#! - the vault delta or storage patch is not empty but the nonce increment is zero. +pub proc compute_delta_commitment + # pad capacity and RATE1 of the hasher with empty words + padw padw + # => [EMPTY_WORD, CAPACITY] + + exec.memory::get_native_account_id + # => [native_acct_id_suffix, native_acct_id_prefix, EMPTY_WORD, CAPACITY] + + # the delta of the nonce is equal to was_nonce_incremented + exec.was_nonce_incremented push.DOMAIN_DELTA + # => [domain_delta, nonce_delta, native_acct_id_suffix, native_acct_id_prefix, EMPTY_WORD, CAPACITY] + # => [ID_AND_NONCE, EMPTY_WORD, CAPACITY] + + exec.poseidon2::permute + # => [RATE0, RATE1, CAPACITY] + + # save the ID and nonce digest (RATE0 word) for a later check + exec.poseidon2::copy_digest movdnw.3 + # => [RATE0, RATE1, CAPACITY, ID_AND_NONCE_DIGEST] + + exec.commit_asset_delta + # => [RATE0, RATE1, CAPACITY, ID_AND_NONCE_DIGEST] + + exec.commit_storage_patch + # => [RATE0, RATE1, CAPACITY, ID_AND_NONCE_DIGEST] + + exec.poseidon2::squeeze_digest + # => [DELTA_COMMITMENT, ID_AND_NONCE_DIGEST] + + exec.was_nonce_incremented not + # => [!was_nonce_incremented, DELTA_COMMITMENT, ID_AND_NONCE_DIGEST] + + if.true + # if the nonce wasn't incremented, then the vault and storage delta is required to be empty + # storage and vault delta were empty if the delta commitment is equal to + # ID_AND_NONCE_DIGEST + assert_eqw.err=ERR_ACCOUNT_DELTA_NONCE_MUST_BE_INCREMENTED_IF_VAULT_OR_STORAGE_CHANGED + # => [] + + # if the delta is empty, its commitment is defined as the empty word + padw + # => [EMPTY_WORD] + else + # drop the ID and nonce digest + swapw dropw + # => [DELTA_COMMITMENT] + end +end + +#! Updates the given delta hasher with the storage slots. +#! +#! Inputs: [RATE0, RATE1, CAPACITY] +#! Outputs: [RATE0, RATE1, CAPACITY] +proc commit_storage_patch + exec.memory::get_native_num_storage_slots movdn.12 + # => [RATE0, RATE1, CAPACITY, num_storage_slots] + + push.0 movdn.12 + # => [RATE0, RATE1, CAPACITY, slot_idx = 0, num_storage_slots] + + # loop if num_storage_slots != 0 + dup.13 neq.0 + # => [should_loop, RATE0, RATE1, CAPACITY, slot_idx, num_storage_slots] + + while.true + dup.12 + # => [slot_idx, RATE0, RATE1, CAPACITY, slot_idx, num_storage_slots] + + exec.commit_slot_patch + # => [RATE0, RATE1, CAPACITY, slot_idx, num_storage_slots] + + # increment slot index + movup.12 add.1 + # => [next_slot_idx, RATE0, RATE1, CAPACITY, num_storage_slots] + + dup movdn.13 + # => [next_slot_idx, RATE0, RATE1, CAPACITY, next_slot_idx, num_storage_slots] + + # continue if next_slot_idx != num_storage_slots + # we use neq instead of lt for efficiency + dup.14 neq + # => [should_loop, RATE0, RATE1, CAPACITY, next_slot_idx, num_storage_slots] + end + # => [RATE0, RATE1, CAPACITY, next_slot_idx, num_storage_slots] + + # clean the stack + movup.12 drop movup.12 drop + # => [RATE0, RATE1, CAPACITY] +end + +#! Updates the given delta hasher with the storage slot at the provided index. +#! +#! Inputs: [slot_idx, RATE0, RATE1, CAPACITY] +#! Outputs: [RATE0, RATE1, CAPACITY] +proc commit_slot_patch + dup exec.account::get_native_storage_slot_type + # => [storage_slot_type, slot_idx, RATE0, RATE1, CAPACITY] + + # check if slot is of type value + push.STORAGE_SLOT_TYPE_VALUE eq + # => [is_value_slot_type, slot_idx, RATE0, RATE1, CAPACITY] + + if.true + exec.commit_value_slot_patch + else + exec.commit_map_slot_patch + end + # => [RATE0, RATE1, CAPACITY] +end + +#! Updates the given delta hasher with the value storage slot at the provided index. +#! +#! Inputs: [slot_idx, RATE0, RATE1, CAPACITY] +#! Outputs: [RATE0, RATE1, CAPACITY] +proc commit_value_slot_patch + exec.account::get_item_delta + # => [INIT_VALUE, CURRENT_VALUE, slot_id_suffix, slot_id_prefix, RATE0, RATE1, CAPACITY] + + exec.word::test_eq not + # => [was_changed, INIT_VALUE, CURRENT_VALUE, slot_id_suffix, slot_id_prefix, RATE0, RATE1, CAPACITY] + + # set was_changed to true if the account is new + # generally, the delta for a new account must include all its storage slots, regardless of the + # initial value and even if it is an empty word, because the initial delta for an account must + # represent its full state + exec.memory::is_new_account or + # => [was_changed, INIT_VALUE, CURRENT_VALUE, slot_id_suffix, slot_id_prefix, RATE0, RATE1, CAPACITY] + + # only include in delta if the slot's value has changed or the account is new + if.true + # drop init value + dropw + # => [CURRENT_VALUE, slot_id_suffix, slot_id_prefix, RATE0, RATE1, CAPACITY] + + # build value slot metadata + movup.5 movup.5 + # => [slot_id_suffix, slot_id_prefix, CURRENT_VALUE, RATE0, RATE1, CAPACITY] + + push.0.DOMAIN_VALUE + # => [[domain, 0, slot_id_suffix, slot_id_prefix], CURRENT_VALUE, RATE0, RATE1, CAPACITY] + + # clear rate elements + swapdw dropw dropw + # => [[domain, 0, slot_id_suffix, slot_id_prefix], CURRENT_VALUE, CAPACITY] + + exec.poseidon2::permute + # => [RATE0, RATE1, CAPACITY] + else + # drop init value, current value and slot name + dropw dropw drop drop + # => [RATE0, RATE1, CAPACITY] + end + # => [RATE0, RATE1, CAPACITY] +end + +#! Updates the given delta hasher with the map storage slot at the provided index. +#! +#! Inputs: [slot_idx, RATE0, RATE1, CAPACITY] +#! Outputs: [RATE0, RATE1, CAPACITY] +#! +#! Locals: +#! - 0: slot_id_suffix +#! - 1: slot_id_prefix +#! - 2: has_next +#! - 3: iter +#! - 4: num_changed_entries +@locals(5) +proc commit_map_slot_patch + # initialize num_changed_entries = 0 + # this is necessary because this procedure can be called multiple times and the second + # invocation shouldn't reuse the first invocation's value + push.0 loc_store.4 + # => [slot_idx, RATE0, RATE1, CAPACITY] + + dup exec.account::get_slot_id + # => [slot_id_suffix, slot_id_prefix, slot_idx, RATE0, RATE1, CAPACITY] + + loc_store.0 loc_store.1 + # => [slot_idx, RATE0, RATE1, CAPACITY] + + exec.memory::get_account_patch_storage_map_ptr + # => [account_patch_storage_map_ptr, RATE0, RATE1, CAPACITY] + + exec.link_map::iter + # => [has_next, iter, RATE0, RATE1, CAPACITY] + + # enter loop if the link map is not empty + while.true + exec.link_map::next_key_double_value + # => [KEY, INIT_VALUE, NEW_VALUE, has_next, iter, RATE0, RATE1, CAPACITY] + + # store has_next + movup.12 loc_store.2 + # => [KEY, INIT_VALUE, NEW_VALUE, iter, RATE0, RATE1, CAPACITY] + + # store iter + movup.12 loc_store.3 + # => [KEY, INIT_VALUE, NEW_VALUE, RATE0, RATE1, CAPACITY] + + swapw.2 + # => [NEW_VALUE, INIT_VALUE, KEY, RATE0, RATE1, CAPACITY] + + exec.word::test_eq not + # => [was_changed, NEW_VALUE, INIT_VALUE, KEY, RATE0, RATE1, CAPACITY] + + # if the key-value pair has actually changed, update the hasher + if.true + # drop the initial value + swapw dropw swapw + # => [KEY, NEW_VALUE, RATE0, RATE1, CAPACITY] + + # increment number of changed entries in local + loc_load.4 add.1 loc_store.4 + # => [KEY, NEW_VALUE, RATE0, RATE1, CAPACITY] + + # drop previous RATE elements + swapdw dropw dropw + # => [KEY, NEW_VALUE, CAPACITY] + + exec.poseidon2::permute + # => [RATE0, RATE1, CAPACITY] + else + # discard the key and init and new value words loaded from the map + dropw dropw dropw + # => [RATE0, RATE1, CAPACITY] + end + # => [RATE0, RATE1, CAPACITY] + + # load iter and has_next + loc_load.3 + # => [iter, RATE0, RATE1, CAPACITY] + + loc_load.2 + # => [has_next, iter, RATE0, RATE1, CAPACITY] + end + + # drop iter + drop + # => [RATE0, RATE1, CAPACITY] + + # only include the map slot metadata if there were entries in the map that resulted in an + # update to the hasher state + loc_load.4 neq.0 + # => [is_num_changed_entries_non_zero, RATE0, RATE1, CAPACITY] + + # if the account is new (nonce == 0) include the map header even if it is an empty map + # in order to have the delta commit to this initial storage slot. + exec.memory::is_new_account or + # => [should_include_map_header, RATE0, RATE1, CAPACITY] + + if.true + # drop the previous RATE elements + dropw dropw + # => [CAPACITY] + + padw loc_load.1 loc_load.0 loc_load.4 push.DOMAIN_MAP + # => [[domain, num_changed_entries, slot_id_suffix, slot_id_prefix], EMPTY_WORD, CAPACITY] + + exec.poseidon2::permute + # => [RATE0, RATE1, CAPACITY] + end + # => [RATE0, RATE1, CAPACITY] +end + +#! Updates the given delta hasher with the asset vault delta. +#! +#! It iterates the asset delta link map once and processes the added assets first. It writes the +#! removed assets into local memory to enable a subsequent iteration over linear memory rather than +#! a second expensive link map iteration that would have to skip added assets. +#! +#! Inputs: [RATE0, RATE1, CAPACITY] +#! Outputs: [RATE0, RATE1, CAPACITY] +#! +#! Locals: +#! - 0: has_next +#! - 1: iter +#! - 2: num_added_assets +#! - 3: num_removed_assets +#! - 4..8196: removed_assets (COMMIT_ASSET_DELTA_MAX_REMOVED_ASSETS * ASSET_SIZE elements) +@locals(8196) +proc commit_asset_delta + # initialize num_added_assets = 0 and num_removed_assets = 0 + # this is necessary because this procedure can be called multiple times and the second + # invocation shouldn't reuse the first invocation's values + push.0 loc_store.COMMIT_ASSET_DELTA_NUM_ADDED_LOC + push.0 loc_store.COMMIT_ASSET_DELTA_NUM_REMOVED_LOC + + # signal to the host that delta computation is starting so it can reset its accumulating + # vault delta. this is necessary because compute_delta_commitment can be invoked multiple times + # in a single transaction (e.g. by auth and the epilogue) and each invocation re-emits the + # ACCOUNT_ON_ASSET_DELTA_COMPUTATION events. + emit.ACCOUNT_BEFORE_ASSET_DELTA_COMPUTATION + + # Iterate asset delta. + # Add added assets into the hasher. + # Store removed assets to memory for later processing. + # --------------------------------------------------------------------------------------------- + + push.ACCOUNT_UPDATE_ASSET_PTR + # => [asset_delta_map_ptr, RATE0, RATE1, CAPACITY] + + exec.link_map::iter + # => [has_next, iter, RATE0, RATE1, CAPACITY] + + # enter loop if the link map is not empty + while.true + exec.link_map::next_key_double_value + # => [ASSET_KEY, INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE, has_next, iter, RATE0, RATE1, CAPACITY] + + # store has_next and iter + movup.12 loc_store.COMMIT_ASSET_DELTA_HAS_NEXT_LOC + movup.12 loc_store.COMMIT_ASSET_DELTA_ITER_LOC + # => [ASSET_KEY, INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE, RATE0, RATE1, CAPACITY] + + movdnw.2 exec.word::test_eq not + # => [has_asset_changed, INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE, ASSET_KEY, RATE0, RATE1, CAPACITY] + + if.true + exec.compute_asset_delta + # => [delta_op, ASSET_KEY, DELTA_ASSET_VALUE, RATE0, RATE1, CAPACITY] + + emit.ACCOUNT_ON_ASSET_DELTA_COMPUTATION_EVENT + # => [delta_op, ASSET_KEY, DELTA_ASSET_VALUE, RATE0, RATE1, CAPACITY] + + eq.DELTA_OP_ADD + # => [is_addition, ASSET_KEY, DELTA_ASSET_VALUE, RATE0, RATE1, CAPACITY] + + if.true + # increment num_added_assets + loc_load.COMMIT_ASSET_DELTA_NUM_ADDED_LOC + add.1 + loc_store.COMMIT_ASSET_DELTA_NUM_ADDED_LOC + # => [ASSET_KEY, DELTA_ASSET_VALUE, RATE0, RATE1, CAPACITY] + + # drop previous RATE elements + swapdw dropw dropw + # => [ASSET_KEY, DELTA_ASSET_VALUE, CAPACITY] + + exec.poseidon2::permute + # => [RATE0, RATE1, CAPACITY] + else + loc_load.COMMIT_ASSET_DELTA_NUM_REMOVED_LOC + # => [num_removed_assets, ASSET_KEY, DELTA_ASSET_VALUE, RATE0, RATE1, CAPACITY] + + # make sure we do not write outside the assigned number of locals + dup neq.COMMIT_ASSET_DELTA_MAX_REMOVED_ASSETS + assert.err=ERR_ACCOUNT_DELTA_TOO_MANY_REMOVED_ASSETS + # => [num_removed_assets, ASSET_KEY, DELTA_ASSET_VALUE, RATE0, RATE1, CAPACITY] + + # compute the pointer at which to store the removed asset as + # locaddr.removed_assets_ptr + num_removed_assets * ASSET_SIZE + mul.ASSET_SIZE + locaddr.COMMIT_ASSET_DELTA_REMOVED_ASSETS_LOC + add + # => [removed_assets_ptr, ASSET_KEY, DELTA_ASSET_VALUE, RATE0, RATE1, CAPACITY] + + exec.asset::store + # => [RATE0, RATE1, CAPACITY] + + # increment num_removed_assets + loc_load.COMMIT_ASSET_DELTA_NUM_REMOVED_LOC + add.1 + loc_store.COMMIT_ASSET_DELTA_NUM_REMOVED_LOC + # => [RATE0, RATE1, CAPACITY] + end + else + # discard the values loaded from the map + dropw dropw dropw + # => [RATE0, RATE1, CAPACITY] + end + # => [RATE0, RATE1, CAPACITY] + + # load iter and has_next + loc_load.COMMIT_ASSET_DELTA_ITER_LOC + loc_load.COMMIT_ASSET_DELTA_HAS_NEXT_LOC + # => [has_next, iter, RATE0, RATE1, CAPACITY] + end + + # drop iter + drop + # => [RATE0, RATE1, CAPACITY] + + # Add the added assets trailer if num_added_assets != 0. + # --------------------------------------------------------------------------------------------- + + loc_load.COMMIT_ASSET_DELTA_NUM_ADDED_LOC push.DELTA_OP_ADD + exec.commit_asset_count_trailer + # => [RATE0, RATE1, CAPACITY] + + # Hash the removed assets. + # --------------------------------------------------------------------------------------------- + + # setup start_ptr and end_ptr for the removed assets buffer + locaddr.COMMIT_ASSET_DELTA_REMOVED_ASSETS_LOC dup + # => [start_ptr, start_ptr, RATE0, RATE1, CAPACITY] + + # end_ptr = start_ptr + num_removed_assets * ASSET_SIZE + loc_load.COMMIT_ASSET_DELTA_NUM_REMOVED_LOC + mul.ASSET_SIZE add + # => [end_ptr, start_ptr, RATE0, RATE1, CAPACITY] + + # move the pointers below the hasher state + movdn.13 movdn.12 + # => [RATE0, RATE1, CAPACITY, start_ptr, end_ptr] + + # absorb the removed assets from memory into the hasher state; this is a no-op when + # start_ptr == end_ptr (i.e. when no assets were removed) + exec.poseidon2::absorb_double_words_from_memory + # => [RATE0, RATE1, CAPACITY, end_ptr, end_ptr] + + movup.12 drop movup.12 drop + # => [RATE0, RATE1, CAPACITY] + + # Add the removed assets trailer if num_removed_assets != 0. + # --------------------------------------------------------------------------------------------- + + loc_load.COMMIT_ASSET_DELTA_NUM_REMOVED_LOC push.DELTA_OP_REMOVE + exec.commit_asset_count_trailer + # => [RATE0, RATE1, CAPACITY] +end + +#! Permutes the delta hasher with a trailer that commits to the number of assets affected by the +#! given delta operation. If num_assets is zero, this is a no-op and the hasher state is preserved. +#! +#! Inputs: [delta_op, num_assets, RATE0, RATE1, CAPACITY] +#! Outputs: [RATE0, RATE1, CAPACITY] +#! +#! Where: +#! - delta_op is the delta operation (DELTA_OP_ADD or DELTA_OP_REMOVE). +#! - num_assets is the number of assets affected by delta_op (added or removed). +proc commit_asset_count_trailer + dup.1 neq.0 + # => [is_non_zero, delta_op, num_assets, RATE0, RATE1, CAPACITY] + + if.true + # move delta_op and num_assets below RATE0 and RATE1 so the previous RATE elements + # can be dropped while preserving them + movdn.9 movdn.9 + # => [RATE0, RATE1, delta_op, num_assets, CAPACITY] + + dropw dropw + # => [delta_op, num_assets, CAPACITY] + + # build the metadata + push.0 movdn.2 push.DOMAIN_ASSET + # => [[domain, delta_op, num_assets, 0], CAPACITY] + + # prepend the empty word that follows the metadata word as the second hasher input + padw swapw + # => [[domain, delta_op, num_assets, 0], EMPTY_WORD, CAPACITY] + + exec.poseidon2::permute + # => [RATE0, RATE1, CAPACITY] + else + # discard delta_op and num_assets + drop drop + # => [RATE0, RATE1, CAPACITY] + end +end + +#! Updates the given patch hasher with the asset vault patches. +#! +#! Inputs: [RATE0, RATE1, CAPACITY] +#! Outputs: [RATE0, RATE1, CAPACITY] +#! +#! Locals: +#! - 0: has_next +#! - 1: iter +#! - 2: num_changed_assets +@locals(3) +proc commit_asset_patch + # initialize num_changed_assets = 0 + # this is necessary because this procedure can be called multiple times and the second + # invocation shouldn't reuse the first invocation's values + push.0 loc_store.COMMIT_ASSET_PATCH_NUM_CHANGED_LOC + + # Iterate and hash asset patches. + # --------------------------------------------------------------------------------------------- + + push.ACCOUNT_UPDATE_ASSET_PTR + # => [asset_delta_map_ptr, RATE0, RATE1, CAPACITY] + + exec.link_map::iter + # => [has_next, iter, RATE0, RATE1, CAPACITY] + + # enter loop if the link map is not empty + while.true + exec.link_map::next_key_double_value + # => [ASSET_KEY, INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE, has_next, iter, ...] + + # store has_next and iter + movup.12 loc_store.COMMIT_ASSET_PATCH_HAS_NEXT_LOC + movup.12 loc_store.COMMIT_ASSET_PATCH_ITER_LOC + # => [ASSET_KEY, INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE, ...] + + movdnw.2 exec.word::test_eq not + # => [has_asset_changed, INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE, ASSET_KEY, ...] + + if.true + # increment num_changed_assets + loc_load.COMMIT_ASSET_PATCH_NUM_CHANGED_LOC + add.1 + loc_store.COMMIT_ASSET_PATCH_NUM_CHANGED_LOC + # => [ASSET_KEY, FINAL_ASSET_VALUE, ...] + + # drop initial value + dropw swapw + # => [ASSET_KEY, FINAL_ASSET_VALUE, RATE0, RATE1, CAPACITY] + + # drop previous RATE elements + swapdw dropw dropw + # => [ASSET_KEY, FINAL_ASSET_VALUE, CAPACITY] + + exec.poseidon2::permute + # => [RATE0, RATE1, CAPACITY] + else + # discard the values loaded from the map + dropw dropw dropw + # => [RATE0, RATE1, CAPACITY] + end + # => [RATE0, RATE1, CAPACITY] + + # load iter and has_next + loc_load.COMMIT_ASSET_PATCH_ITER_LOC + loc_load.COMMIT_ASSET_PATCH_HAS_NEXT_LOC + # => [has_next, iter, RATE0, RATE1, CAPACITY] + end + + # drop iter + drop + # => [RATE0, RATE1, CAPACITY] + + # Add the asset patch trailer if num_changed_assets != 0. + # --------------------------------------------------------------------------------------------- + + loc_load.COMMIT_ASSET_PATCH_NUM_CHANGED_LOC neq.0 + if.true + # drop the previous RATE elements + dropw dropw + # => [CAPACITY] + + padw push.0.0 + loc_load.COMMIT_ASSET_PATCH_NUM_CHANGED_LOC + push.DOMAIN_ASSET + # => [[domain, num_changed_assets, 0, 0], EMPTY_WORD, CAPACITY] + + exec.poseidon2::permute + # => [RATE0, RATE1, CAPACITY] + end + # => [RATE0, RATE1, CAPACITY] +end + +# ACCOUNT UPDATE BOOKKEEPING +# ------------------------------------------------------------------------------------------------- + +#! Returns a flag indicating whether the account's nonce was incremented. +#! +#! Inputs: [] +#! Outputs: [was_nonce_incremented] +#! +#! Where: +#! - was_nonce_incremented is the boolean flag indicating whether the account nonce was incremented. +pub proc was_nonce_incremented + exec.memory::get_init_nonce + # => [init_nonce] + + exec.memory::get_account_nonce + # => [current_nonce, init_nonce] + + neq + # => [was_nonce_incremented] +end + +#! Adds the given asset update to the patch. +#! +#! Assumes the assets are valid, so it should be called after asset_vault::add_asset. +#! +#! Inputs: [ASSET_KEY, INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE] +#! Outputs: [] +#! +#! Where: +#! - ASSET_KEY is the vault key of the asset that is added. +#! - INITIAL_ASSET_VALUE is the value of the asset before the update. +#! - FINAL_ASSET_VALUE is the value of the asset after the update. +pub proc update_asset + swapw dupw.1 push.ACCOUNT_UPDATE_ASSET_PTR + # => [asset_delta_map_ptr, ASSET_KEY, INITIAL_ASSET_VALUE, ASSET_KEY, FINAL_ASSET_VALUE] + + # retrieve the current patch entry + exec.link_map::get + # => [contains_key, STORED_INITIAL_VALUE, STORED_FINAL_VALUE, INITIAL_ASSET_VALUE, ASSET_KEY, FINAL_ASSET_VALUE] + + movdn.12 + # => [STORED_INITIAL_VALUE, STORED_FINAL_VALUE, INITIAL_ASSET_VALUE, contains_key, ASSET_KEY, FINAL_ASSET_VALUE] + + swapw dropw + # => [STORED_INITIAL_VALUE, INITIAL_ASSET_VALUE, contains_key, ASSET_KEY, FINAL_ASSET_VALUE] + + movup.8 + # => [contains_key, STORED_INITIAL_VALUE, INITIAL_ASSET_VALUE, ASSET_KEY, FINAL_ASSET_VALUE] + + # If contains_key STORED_INITIAL_VALUE remains. + # If !contains_key INITIAL_ASSET_VALUE remains. + cdropw + # => [NEW_INITIAL_VALUE, ASSET_KEY, FINAL_ASSET_VALUE] + + swapw push.ACCOUNT_UPDATE_ASSET_PTR + # => [asset_delta_map_ptr, ASSET_KEY, NEW_INITIAL_VALUE, FINAL_ASSET_VALUE] + + exec.link_map::set drop + # => [] +end + +#! Computes the delta between two asset values and returns the resulting delta op and delta value. +#! +#! This procedure assumes the provided INITIAL_ASSET_VALUE and FINAL_ASSET_VALUE are NOT equal. +#! +#! This procedure computes the delta as follows: +#! - If the composition of the asset is none: +#! - If the initial value is the empty word, the final value must be non-empty, so the asset was +#! added. +#! - Conversely, if the initial value is not the empty word, the final value must be empty +#! according to the asset vault guarantees, so the asset was removed. +#! - If the composition is not none: +#! - Compute the delta as `GREATER_ASSET_VALUE - LESSER_ASSET_VALUE` using `asset::lt` to find +#! the larger value and `asset::split` for the computation itself. +#! - Since the procedure assumes the values are not equal, the final value must be less than or +#! greater than the initial value. If the final value is larger than the initial one, value +#! was added; if it is smaller, value was removed. +#! +#! Inputs: [INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE, ASSET_KEY] +#! Outputs: [delta_op, ASSET_KEY, DELTA_ASSET_VALUE] +#! +#! Where: +#! - ASSET_KEY is the vault key of the asset whose delta is computed. +#! - INITIAL_ASSET_VALUE is the value of the asset in the account vault at the beginning of the +#! transaction. +#! - FINAL_ASSET_VALUE is the value of the asset in the account vault at the time the procedure is +#! called. +#! - delta_op is the delta operation (addition or removal) for the asset. +#! - DELTA_ASSET_VALUE is the resulting delta between the initial and final asset value. +#! +#! Panics if: +#! - the asset's composition is Custom (not yet supported). +proc compute_asset_delta + dupw.2 + # => [ASSET_KEY, INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE, ASSET_KEY] + + exec.asset::key_to_composition + # => [asset_composition, ASSET_KEY, INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE, ASSET_KEY] + + eq.COMPOSITION_NONE + # => [is_composition_none, ASSET_KEY, INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE, ASSET_KEY] + + if.true + dropw + # => [INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE, ASSET_KEY] + + # the asset was removed if the initial value is not the empty word (in which case the final + # word must be empty) + exec.word::testz not + # => [was_removed, INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE, ASSET_KEY] + + # compute delta_op as was_removed + 1 + dup add.1 + # => [delta_op, was_removed, INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE, ASSET_KEY] + + movdn.13 + # => [was_removed, INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE, ASSET_KEY, delta_op] + + # if was_removed INITIAL_ASSET_VALUE is selected + # if !was_removed FINAL_ASSET_VALUE is selected + cdropw + # => [DELTA_ASSET_VALUE, ASSET_KEY, delta_op] + + swapw movup.8 + # => [delta_op, ASSET_KEY, DELTA_ASSET_VALUE] + else + dupw.2 dupw.2 movupw.2 + # => [ASSET_KEY, INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE, INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE, ASSET_KEY] + + exec.asset::lt + # => [is_final_lt_initial, INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE, ASSET_KEY] + + # compute delta_op as is_final_lt_initial + 1 + dup add.1 + # => [delta_op, is_final_lt_initial, INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE, ASSET_KEY] + + movdn.13 + # => [is_final_lt_initial, INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE, ASSET_KEY, delta_op] + + # place the greater asset at the deeper position in the stack + # if is_final_lt_initial the stack becomes [FINAL_ASSET_VALUE, INITIAL_ASSET_VALUE] + # if !is_final_lt_initial the stack remains [INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE] + cswapw + # => [LESSER_ASSET_VALUE, GREATER_ASSET_VALUE, ASSET_KEY, delta_op] + + # compute the delta as greater - lesser + dupw.2 exec.asset::split + # => [DELTA_ASSET_VALUE, ASSET_KEY, delta_op] + + swapw movup.8 + # => [delta_op, ASSET_KEY, DELTA_ASSET_VALUE] + end +end + +#! Updates the storage map patch of the given slot index with the given key-value pair and its +#! previous value. +#! +#! The layout of a link map entry for a given KEY is: [KEY, INIT_VALUE, NEW_VALUE] where INIT_VALUE +#! represents the initial value for the given KEY at the beginning of transaction execution and +#! NEW_VALUE is the new value. The delta entry is a NOOP if INIT_VALUE and NEW_VALUE are equal. +#! +#! Inputs: [slot_index, KEY, PREV_VALUE, NEW_VALUE] +#! Outputs: [] +#! +#! Where: +#! - slot_index is the slot index of the storage map slot. +#! - KEY is the key in the storage map that is being updated. +#! - PREV_VALUE is the previous value of the key in the storage map that is being updated. +#! - NEW_VALUE is the new value of the key in the storage map that is being updated. +@locals(8) +pub proc set_map_item + # retrieve the link map ptr to the storage map patch for the provided index + exec.memory::get_account_patch_storage_map_ptr + # => [account_patch_storage_map_ptr, KEY, PREV_VALUE, NEW_VALUE] + + # store map ptr in local + loc_store.0 + # => [KEY, PREV_VALUE, NEW_VALUE] + + # store KEY in local + loc_storew_le.4 + # => [KEY, PREV_VALUE, NEW_VALUE] + + loc_load.0 + # => [account_patch_storage_map_ptr, KEY, PREV_VALUE, NEW_VALUE] + + # retrieve the current patch + exec.link_map::get + # => [contains_key, VALUE0, VALUE1, PREV_VALUE, NEW_VALUE] + + movdn.12 + # => [VALUE0, VALUE1, PREV_VALUE, contains_key, NEW_VALUE] + + # VALUE1 was the previous "new value" if this key was already updated, so in any case, + # we can drop it since this update overwrites the previous one + swapw dropw + # => [VALUE0, PREV_VALUE, contains_key, NEW_VALUE] + + movup.8 + # => [contains_key, VALUE0, PREV_VALUE, NEW_VALUE] + + # contains_key determines whether this is the first update to this KEY + # if this is the first update, PREV_VALUE is the *initial* value of the key-value pair + # if this is not the first update, VALUE0 is the initial value, so we want to store it back + # use cdropw to selectively keep the word that represents the initial value + # If contains_key VALUE0 remains. + # If !contains_key PREV_VALUE remains. + cdropw + # => [INITIAL_VALUE, NEW_VALUE] + + # load key and index from locals + padw loc_loadw_le.4 loc_load.0 + # => [account_patch_storage_map_ptr, KEY, INITIAL_VALUE, NEW_VALUE] + + exec.link_map::set drop + # => [] +end diff --git a/crates/miden-protocol/asm/kernels/transaction/lib/asset.masm b/crates/miden-protocol/asm/kernels/transaction/lib/asset.masm index b3e294ef86..403b68b19e 100644 --- a/crates/miden-protocol/asm/kernels/transaction/lib/asset.masm +++ b/crates/miden-protocol/asm/kernels/transaction/lib/asset.masm @@ -8,6 +8,12 @@ use $kernel::util::asset->util_asset const ERR_VAULT_UNSUPPORTED_ASSET_COMPOSITION="asset composition Custom is not yet supported" +const ERR_VAULT_MERGE_COMPOSITION_NONE="assets with composition none cannot be merged" + +const ERR_VAULT_SPLIT_COMPOSITION_NONE="assets with composition none cannot be split" + +const ERR_VAULT_LT_COMPOSITION_NONE="assets with composition none cannot be compared" + # CONSTANT ACCESSORS # ================================================================================================= @@ -161,3 +167,120 @@ pub proc validate end # => [ASSET_KEY, ASSET_VALUE] end + +#! Merges two values for the same asset and returns the combined value. +#! +#! WARNING: This procedure assumes both asset values belong to the asset identified by ASSET_KEY +#! and have already been validated. +#! +#! Inputs: [ASSET_KEY, CURRENT_ASSET_VALUE, OTHER_ASSET_VALUE] +#! Outputs: [MERGED_ASSET_VALUE] +#! +#! Where: +#! - ASSET_KEY is the vault key of the asset whose values are being merged. +#! - CURRENT_ASSET_VALUE and OTHER_ASSET_VALUE are the two asset values to merge. +#! - MERGED_ASSET_VALUE is the combined value. +#! +#! Panics if: +#! - the asset's composition is None. +#! - the asset's composition is Custom (not yet supported). +#! - the underlying merge operation panics (see fungible_asset::merge). +pub proc merge + exec.key_to_composition + # => [asset_composition, ASSET_KEY, CURRENT_ASSET_VALUE, OTHER_ASSET_VALUE] + + dup neq.COMPOSITION_NONE assert.err=ERR_VAULT_MERGE_COMPOSITION_NONE + # => [asset_composition, ASSET_KEY, CURRENT_ASSET_VALUE, OTHER_ASSET_VALUE] + + eq.COMPOSITION_FUNGIBLE + # => [is_fungible_asset, ASSET_KEY, CURRENT_ASSET_VALUE, OTHER_ASSET_VALUE] + + if.true + dropw + # => [CURRENT_ASSET_VALUE, OTHER_ASSET_VALUE] + + exec.fungible_asset::merge + # => [MERGED_ASSET_VALUE] + else + push.0 assert.err="merging assets with custom composition is not yet supported" + end + # => [MERGED_ASSET_VALUE] +end + +#! Computes OTHER_ASSET_VALUE - CURRENT_ASSET_VALUE for the asset identified by ASSET_KEY. +#! +#! WARNING: This procedure assumes both asset values belong to the asset identified by ASSET_KEY +#! and have already been validated. +#! +#! Inputs: [ASSET_KEY, CURRENT_ASSET_VALUE, OTHER_ASSET_VALUE] +#! Outputs: [SPLIT_ASSET_VALUE] +#! +#! Where: +#! - ASSET_KEY is the vault key of the asset whose values are being split. +#! - CURRENT_ASSET_VALUE is the value subtracted from OTHER_ASSET_VALUE. +#! - OTHER_ASSET_VALUE is the value being subtracted from. +#! - SPLIT_ASSET_VALUE is OTHER_ASSET_VALUE - CURRENT_ASSET_VALUE. +#! +#! Panics if: +#! - the asset's composition is None. +#! - the asset's composition is Custom (not yet supported). +#! - the underlying split operation panics (see fungible_asset::split). +pub proc split + exec.key_to_composition + # => [asset_composition, ASSET_KEY, CURRENT_ASSET_VALUE, OTHER_ASSET_VALUE] + + dup neq.COMPOSITION_NONE assert.err=ERR_VAULT_SPLIT_COMPOSITION_NONE + # => [asset_composition, ASSET_KEY, CURRENT_ASSET_VALUE, OTHER_ASSET_VALUE] + + eq.COMPOSITION_FUNGIBLE + # => [is_fungible_asset, ASSET_KEY, CURRENT_ASSET_VALUE, OTHER_ASSET_VALUE] + + if.true + dropw + # => [CURRENT_ASSET_VALUE, OTHER_ASSET_VALUE] + + exec.fungible_asset::split + # => [SPLIT_ASSET_VALUE] + else + push.0 assert.err="splitting assets with custom composition is not yet supported" + end + # => [SPLIT_ASSET_VALUE] +end + +#! Returns 1 if OTHER_ASSET_VALUE is less than CURRENT_ASSET_VALUE, 0 otherwise. +#! +#! WARNING: This procedure assumes both asset values belong to the asset identified by ASSET_KEY +#! and have already been validated. +#! +#! Inputs: [ASSET_KEY, CURRENT_ASSET_VALUE, OTHER_ASSET_VALUE] +#! Outputs: [is_lt] +#! +#! Where: +#! - ASSET_KEY is the vault key of the asset whose values are being compared. +#! - CURRENT_ASSET_VALUE and OTHER_ASSET_VALUE are the two asset values to compare. +#! - is_lt is 1 if OTHER_ASSET_VALUE < CURRENT_ASSET_VALUE. +#! +#! Panics if: +#! - the asset's composition is None. +#! - the asset's composition is Custom (not yet supported). +pub proc lt + exec.key_to_composition + # => [asset_composition, ASSET_KEY, CURRENT_ASSET_VALUE, OTHER_ASSET_VALUE] + + dup neq.COMPOSITION_NONE assert.err=ERR_VAULT_LT_COMPOSITION_NONE + # => [asset_composition, ASSET_KEY, CURRENT_ASSET_VALUE, OTHER_ASSET_VALUE] + + eq.COMPOSITION_FUNGIBLE + # => [is_fungible_asset, ASSET_KEY, CURRENT_ASSET_VALUE, OTHER_ASSET_VALUE] + + if.true + dropw + # => [CURRENT_ASSET_VALUE, OTHER_ASSET_VALUE] + + exec.fungible_asset::lt + # => [is_lt] + else + push.0 assert.err="comparing assets with custom composition is not yet supported" + end + # => [is_lt] +end diff --git a/crates/miden-protocol/asm/kernels/transaction/lib/asset_vault.masm b/crates/miden-protocol/asm/kernels/transaction/lib/asset_vault.masm index 1808579a32..8dc5cca9fa 100644 --- a/crates/miden-protocol/asm/kernels/transaction/lib/asset_vault.masm +++ b/crates/miden-protocol/asm/kernels/transaction/lib/asset_vault.masm @@ -1,4 +1,5 @@ use miden::core::collections::smt +use miden::core::crypto::hashes::poseidon2 use $kernel::asset use $kernel::fungible_asset @@ -28,12 +29,16 @@ const ERR_VAULT_NON_FUNGIBLE_ASSET_TO_REMOVE_NOT_FOUND="failed to remove non-exi #! - ASSET_KEY is the asset vault key of the asset to fetch. #! - ASSET_VALUE is the value of the asset from the vault, which can be the EMPTY_WORD if it isn't present. pub proc get_asset + # hash the asset vault key before using it as the SMT key + exec.hash_asset_key + # => [ASSET_KEY_HASH, vault_root_ptr] + # load the asset vault root from memory padw movup.8 mem_loadw_le - # => [ASSET_VAULT_ROOT, ASSET_KEY] + # => [ASSET_VAULT_ROOT, ASSET_KEY_HASH] swapw - # => [ASSET_KEY, ASSET_VAULT_ROOT] + # => [ASSET_KEY_HASH, ASSET_VAULT_ROOT] # lookup asset exec.smt::get swapw dropw @@ -64,16 +69,20 @@ end #! - ASSET_KEY is the asset vault key of the asset to fetch. #! - ASSET_VALUE is the retrieved asset. pub proc peek_asset + # hash the asset vault key before using it as the SMT key + exec.hash_asset_key + # => [ASSET_KEY_HASH, vault_root_ptr] + # load the asset vault root from memory padw movup.8 mem_loadw_le - # => [ASSET_VAULT_ROOT, ASSET_KEY] + # => [ASSET_VAULT_ROOT, ASSET_KEY_HASH] swapw - # => [ASSET_KEY, ASSET_VAULT_ROOT] + # => [ASSET_KEY_HASH, ASSET_VAULT_ROOT] # lookup asset exec.smt::peek - # OS => [ASSET_KEY, ASSET_VAULT_ROOT] + # OS => [ASSET_KEY_HASH, ASSET_VAULT_ROOT] # AS => [ASSET_VALUE] dropw @@ -96,13 +105,16 @@ end #! remains unchanged. #! #! Inputs: [ASSET_KEY, ASSET_VALUE, vault_root_ptr] -#! Outputs: [ASSET_VALUE'] +#! Outputs: [INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE] #! #! Where: #! - vault_root_ptr is a pointer to the memory location at which the vault root is stored. #! - ASSET_KEY is the vault key of the fungible asset to add to the vault. #! - ASSET_VALUE is the fungible asset to add to the vault. -#! - ASSET_VALUE' is the total fungible asset in the account vault after ASSET_VALUE was added to it. +#! - INITIAL_ASSET_VALUE is the fungible asset associated with ASSET_KEY in the vault prior to the +#! merge, or EMPTY_WORD if not present. +#! - FINAL_ASSET_VALUE is the total fungible asset in the account vault after ASSET_VALUE was added +#! to it. #! #! Locals: #! - 0: vault_root_ptr @@ -122,60 +134,62 @@ pub proc add_fungible_asset # => [ASSET_KEY, vault_root_ptr, ASSET_KEY, ASSET_VALUE] exec.peek_asset - # => [CUR_VAULT_VALUE, ASSET_KEY, ASSET_VALUE] + # => [INITIAL_ASSET_VALUE, ASSET_KEY, ASSET_VALUE] # since we have peeked the value, we need to later assert that the actual value matches this # one, so we'll keep a copy for later - # set the current asset value equal to the current vault value swapw dupw.1 - # => [CURRENT_ASSET_VALUE, ASSET_KEY, CUR_VAULT_VALUE, ASSET_VALUE] + # => [INITIAL_ASSET_VALUE, ASSET_KEY, INITIAL_ASSET_VALUE, ASSET_VALUE] movupw.3 - # => [ASSET_VALUE, CURRENT_ASSET_VALUE, ASSET_KEY, CUR_VAULT_VALUE] + # => [ASSET_VALUE, INITIAL_ASSET_VALUE, ASSET_KEY, INITIAL_ASSET_VALUE] # Merge the assets. # --------------------------------------------------------------------------------------------- exec.fungible_asset::merge - # => [MERGED_ASSET_VALUE, ASSET_KEY, CUR_VAULT_VALUE] + # => [FINAL_ASSET_VALUE, ASSET_KEY, INITIAL_ASSET_VALUE] - # store a copy of MERGED_ASSET_VALUE for returning - movdnw.2 dupw.2 - # => [MERGED_ASSET_VALUE, ASSET_KEY, CUR_VAULT_VALUE, MERGED_ASSET_VALUE] + swapw + # => [ASSET_KEY, FINAL_ASSET_VALUE, INITIAL_ASSET_VALUE] # Insert the merged asset. # --------------------------------------------------------------------------------------------- # load the vault root padw loc_load.0 mem_loadw_le - # => [VAULT_ROOT, MERGED_ASSET_VALUE, ASSET_KEY, CUR_VAULT_VALUE, MERGED_ASSET_VALUE] + # => [VAULT_ROOT, ASSET_KEY, FINAL_ASSET_VALUE, INITIAL_ASSET_VALUE] - movdnw.2 - # => [MERGED_ASSET_VALUE, ASSET_KEY, VAULT_ROOT, CUR_VAULT_VALUE, MERGED_ASSET_VALUE] + swapw dupw.2 + # => [FINAL_ASSET_VALUE, ASSET_KEY, VAULT_ROOT, FINAL_ASSET_VALUE, INITIAL_ASSET_VALUE] - # update asset in vault - exec.smt::set - # => [PREV_VAULT_VALUE, NEW_VAULT_ROOT, CUR_VAULT_VALUE, MERGED_ASSET_VALUE] + # hash the asset key and update the asset in the vault + exec.set_asset + # => [PREV_VAULT_VALUE, NEW_VAULT_ROOT, FINAL_ASSET_VALUE, INITIAL_ASSET_VALUE] - # assert PREV_VAULT_VALUE = CUR_VAULT_VALUE to make sure peek_asset returned the correct asset - movupw.2 assert_eqw.err=ERR_VAULT_ADD_FUNGIBLE_ASSET_FAILED_INITIAL_VALUE_INVALID - # => [NEW_VAULT_ROOT, MERGED_ASSET_VALUE] + # assert PREV_VAULT_VALUE = INITIAL_ASSET_VALUE to make sure peek_asset returned the correct asset + dupw.3 assert_eqw.err=ERR_VAULT_ADD_FUNGIBLE_ASSET_FAILED_INITIAL_VALUE_INVALID + # => [NEW_VAULT_ROOT, FINAL_ASSET_VALUE, INITIAL_ASSET_VALUE] # update the vault root loc_load.0 mem_storew_le dropw - # => [MERGED_ASSET_VALUE] - # => [ASSET_VALUE'] + # => [FINAL_ASSET_VALUE, INITIAL_ASSET_VALUE] + + swapw + # => [INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE] end #! Add the specified non-fungible asset to the vault. #! #! Inputs: [ASSET_KEY, ASSET_VALUE, vault_root_ptr] -#! Outputs: [ASSET_VALUE] +#! Outputs: [INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE] #! #! Where: #! - vault_root_ptr is a pointer to the memory location at which the vault root is stored. #! - ASSET_KEY is the vault key of the non-fungible asset that is added to the vault. #! - ASSET_VALUE is the non-fungible asset that is added to the vault. +#! - INITIAL_ASSET_VALUE is always EMPTY_WORD since the proc asserts the asset is not yet present. +#! - FINAL_ASSET_VALUE is the same as ASSET_VALUE, the non-fungible asset now in the vault. #! #! Panics if: #! - the vault already contains the same non-fungible asset. @@ -192,8 +206,8 @@ pub proc add_non_fungible_asset dupw.2 # => [ASSET_VALUE, ASSET_KEY, VAULT_ROOT, ASSET_VALUE, vault_root_ptr] - # insert asset into vault - exec.smt::set + # hash the asset key and insert the asset into the vault + exec.set_asset # => [OLD_VAL, VAULT_ROOT', ASSET_VALUE, vault_root_ptr] # assert old value was empty @@ -202,22 +216,28 @@ pub proc add_non_fungible_asset # update the vault root movup.8 mem_storew_le dropw - # => [ASSET_VALUE] + # => [FINAL_ASSET_VALUE] + + # push EMPTY_WORD as INITIAL_ASSET_VALUE; the proc asserted the asset was not present + padw + # => [INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE] end #! Add the specified asset to the vault. #! #! Inputs: [ASSET_KEY, ASSET_VALUE, vault_root_ptr] -#! Outputs: [ASSET_VALUE'] +#! Outputs: [INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE] #! #! Where: #! - ASSET_KEY is the vault key of the asset that is added to the vault. #! - ASSET_VALUE is the value of the asset that is added to the vault. #! - vault_root_ptr is a pointer to the memory location at which the vault root is stored. -#! - ASSET_VALUE' final asset in the account vault defined as follows: -#! - If ASSET_VALUE is a non-fungible asset, then ASSET_VALUE' is the same as ASSET_VALUE. -#! - If ASSET_VALUE is a fungible asset, then ASSET_VALUE' is the total fungible asset in the account vault -#! after ASSET_VALUE was added to it. +#! - INITIAL_ASSET_VALUE is the value associated with ASSET_KEY in the vault prior to the +#! operation; EMPTY_WORD if the asset was not present (always EMPTY_WORD for non-fungible assets). +#! - FINAL_ASSET_VALUE is the final value associated with ASSET_KEY in the vault, defined as: +#! - If ASSET_VALUE is a non-fungible asset, FINAL_ASSET_VALUE is the same as ASSET_VALUE. +#! - If ASSET_VALUE is a fungible asset, FINAL_ASSET_VALUE is the total fungible asset in the +#! account vault after ASSET_VALUE was added to it. #! #! Panics if: #! - the asset is not valid. @@ -239,14 +259,14 @@ pub proc add_asset # => [ASSET_KEY, ASSET_VALUE, vault_root_ptr] exec.add_fungible_asset - # => [ASSET_VALUE'] + # => [INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE] else # validate the non-fungible asset exec.non_fungible_asset::validate # => [ASSET_KEY, ASSET_VALUE, vault_root_ptr] exec.add_non_fungible_asset - # => [ASSET_VALUE'] + # => [INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE] end end @@ -254,21 +274,23 @@ end # ================================================================================================= #! Splits ASSET_VALUE off the existing asset in the vault associated with the ASSET_KEY -#! and returns the remaining asset value. +#! and returns the initial and remaining asset values. #! #! For instance, if ASSET_KEY points to a fungible asset with amount 100, and ASSET_VALUE has -#! amount 30, then a fungible asset with amount 70 remains in the vault and is returned. +#! amount 30, then INITIAL_ASSET_VALUE has amount 100 and FINAL_ASSET_VALUE has amount 70. #! #! Inputs: [ASSET_KEY, ASSET_VALUE, vault_root_ptr] -#! Outputs: [REMAINING_ASSET_VALUE] +#! Outputs: [INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE] #! #! Where: #! - ASSET_KEY is the asset vault key of the fungible asset to remove from the vault. -#! - REMAINING_ASSET_VALUE is the value of the fungible asset remaining in the vault after removal. +#! - INITIAL_ASSET_VALUE is the fungible asset associated with ASSET_KEY in the vault prior to the +#! split. +#! - FINAL_ASSET_VALUE is the value of the fungible asset remaining in the vault after the split. #! - vault_root_ptr is a pointer to the memory location at which the vault root is stored. #! #! Locals: -#! - 0..4: REMAINING_ASSET_VALUE +#! - 0..4: FINAL_ASSET_VALUE #! #! Panics if: #! - the amount of the asset in the vault is less than the amount to be removed. @@ -281,46 +303,54 @@ pub proc remove_fungible_asset # => [ASSET_KEY, vault_root_ptr, ASSET_VALUE, ASSET_KEY, vault_root_ptr] exec.peek_asset - # => [PEEKED_ASSET_VALUE, ASSET_VALUE, ASSET_KEY, vault_root_ptr] + # => [INITIAL_ASSET_VALUE, ASSET_VALUE, ASSET_KEY, vault_root_ptr] movdnw.2 - # => [ASSET_VALUE, ASSET_KEY, PEEKED_ASSET_VALUE, vault_root_ptr] + # => [ASSET_VALUE, ASSET_KEY, INITIAL_ASSET_VALUE, vault_root_ptr] dupw.2 swapw - # => [ASSET_VALUE, PEEKED_ASSET_VALUE, ASSET_KEY, PEEKED_ASSET_VALUE, vault_root_ptr] + # => [ASSET_VALUE, INITIAL_ASSET_VALUE, ASSET_KEY, INITIAL_ASSET_VALUE, vault_root_ptr] - # compute REMAINING_ASSET_VALUE = PEEKED_ASSET_VALUE - ASSET_VALUE + # compute FINAL_ASSET_VALUE = INITIAL_ASSET_VALUE - ASSET_VALUE exec.fungible_asset::split - # => [REMAINING_ASSET_VALUE, ASSET_KEY, PEEKED_ASSET_VALUE, vault_root_ptr] + # => [FINAL_ASSET_VALUE, ASSET_KEY, INITIAL_ASSET_VALUE, vault_root_ptr] - # store remaining asset value so we can return it later + # store FINAL_ASSET_VALUE so we can return it at the end loc_storew_le.0 - # => [REMAINING_ASSET_VALUE, ASSET_KEY, PEEKED_ASSET_VALUE, vault_root_ptr] + # => [FINAL_ASSET_VALUE, ASSET_KEY, INITIAL_ASSET_VALUE, vault_root_ptr] dup.12 padw movup.4 mem_loadw_le - # => [VAULT_ROOT, REMAINING_ASSET_VALUE, ASSET_KEY, PEEKED_ASSET_VALUE, vault_root_ptr] + # => [VAULT_ROOT, FINAL_ASSET_VALUE, ASSET_KEY, INITIAL_ASSET_VALUE, vault_root_ptr] movdnw.2 - # => [REMAINING_ASSET_VALUE, ASSET_KEY, VAULT_ROOT, PEEKED_ASSET_VALUE, vault_root_ptr] + # => [FINAL_ASSET_VALUE, ASSET_KEY, VAULT_ROOT, INITIAL_ASSET_VALUE, vault_root_ptr] - # update asset in vault and assert the old value is equivalent to the peeked value provided - # via peek_asset - exec.smt::set - # => [OLD_VALUE, NEW_VAULT_ROOT, PEEKED_ASSET_VALUE, vault_root_ptr] + # hash the asset key and update the asset in the vault; the old value is asserted below to be + # equivalent to the peeked value provided via peek_asset + exec.set_asset + # => [OLD_VALUE, NEW_VAULT_ROOT, INITIAL_ASSET_VALUE, vault_root_ptr] - # assert OLD_VALUE == PEEKED_ASSET - movupw.2 assert_eqw.err=ERR_VAULT_REMOVE_FUNGIBLE_ASSET_FAILED_INITIAL_VALUE_INVALID - # => [NEW_VAULT_ROOT, vault_root_ptr] + dupw.2 + # => [INITIAL_ASSET_VALUE, OLD_VALUE, NEW_VAULT_ROOT, INITIAL_ASSET_VALUE, vault_root_ptr] + + # assert OLD_VALUE == INITIAL_ASSET_VALUE + assert_eqw.err=ERR_VAULT_REMOVE_FUNGIBLE_ASSET_FAILED_INITIAL_VALUE_INVALID + # => [NEW_VAULT_ROOT, INITIAL_ASSET_VALUE, vault_root_ptr] # update vault root - movup.4 mem_storew_le - # => [NEW_VAULT_ROOT] + movup.8 mem_storew_le + # => [NEW_VAULT_ROOT, INITIAL_ASSET_VALUE] + # load FINAL_ASSET_VALUE; this overwrites NEW_VAULT_ROOT at the top of the stack loc_loadw_le.0 - # => [REMAINING_ASSET_VALUE] + # => [FINAL_ASSET_VALUE, INITIAL_ASSET_VALUE] + + swapw + # => [INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE] end -#! Remove the specified non-fungible asset from the vault and return the remaining asset value. +#! Remove the specified non-fungible asset from the vault and return the initial and remaining +#! asset values. #! #! Since non-fungible assets are either fully present or absent, the remaining value after #! removal is always EMPTY_WORD. @@ -329,11 +359,13 @@ end #! vault. #! #! Inputs: [ASSET_KEY, ASSET_VALUE, vault_root_ptr] -#! Outputs: [REMAINING_ASSET_VALUE] +#! Outputs: [INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE] #! #! Where: #! - ASSET_KEY is the asset vault key of the non-fungible asset to remove from the vault. -#! - REMAINING_ASSET_VALUE is always EMPTY_WORD (nothing remains after removing a non-fungible asset). +#! - INITIAL_ASSET_VALUE equals ASSET_VALUE since the proc asserts the asset is present with that +#! exact value. +#! - FINAL_ASSET_VALUE is always EMPTY_WORD (nothing remains after removing a non-fungible asset). #! - vault_root_ptr is a pointer to the memory location at which the vault root is stored. #! #! Panics if: @@ -347,31 +379,40 @@ pub proc remove_non_fungible_asset swapw padw # => [EMPTY_WORD, ASSET_KEY, VAULT_ROOT, ASSET_VALUE, vault_root_ptr] - # insert empty word into the vault to remove the asset - exec.smt::set + # hash the asset key and insert the empty word into the vault to remove the asset + exec.set_asset # => [REMOVED_ASSET_VALUE, NEW_VAULT_ROOT, ASSET_VALUE, vault_root_ptr] - movupw.2 assert_eqw.err=ERR_VAULT_NON_FUNGIBLE_ASSET_TO_REMOVE_NOT_FOUND - # => [NEW_VAULT_ROOT, vault_root_ptr] + # dup ASSET_VALUE so it survives the assert; the assert proves it equals REMOVED_ASSET_VALUE + # so we can return it as INITIAL_ASSET_VALUE + dupw.2 + # => [ASSET_VALUE, REMOVED_ASSET_VALUE, NEW_VAULT_ROOT, ASSET_VALUE, vault_root_ptr] + + assert_eqw.err=ERR_VAULT_NON_FUNGIBLE_ASSET_TO_REMOVE_NOT_FOUND + # => [NEW_VAULT_ROOT, INITIAL_ASSET_VALUE, vault_root_ptr] # update the vault root - movup.4 mem_storew_le dropw - # => [] + movup.8 mem_storew_le dropw + # => [INITIAL_ASSET_VALUE] - # push EMPTY_WORD to represent that nothing remains after non-fungible removal - padw - # => [REMAINING_ASSET_VALUE] + # push EMPTY_WORD as FINAL_ASSET_VALUE (nothing remains after non-fungible removal) + padw swapw + # => [INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE] end -#! Remove the specified asset from the vault and return the remaining asset value. +#! Remove the specified asset from the vault and return the initial and remaining asset values. #! #! Inputs: [ASSET_KEY, ASSET_VALUE, vault_root_ptr] -#! Outputs: [REMAINING_ASSET_VALUE] +#! Outputs: [INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE] #! #! Where: #! - ASSET_KEY is the asset vault key of the asset to remove from the vault. #! - ASSET_VALUE is the value of the asset to remove from the vault. -#! - REMAINING_ASSET_VALUE is the value of the asset remaining in the vault after removal. +#! - INITIAL_ASSET_VALUE is the value associated with ASSET_KEY in the vault prior to the +#! operation. For non-fungible assets this equals ASSET_VALUE; for fungible assets this is the +#! pre-split balance. +#! - FINAL_ASSET_VALUE is the value of the asset remaining in the vault after removal; EMPTY_WORD +#! for non-fungible assets. #! - vault_root_ptr is a pointer to the memory location at which the vault root is stored. #! #! Panics if: @@ -390,9 +431,43 @@ pub proc remove_asset # remove the asset from the asset vault if.true exec.remove_fungible_asset - # => [REMAINING_ASSET_VALUE] + # => [INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE] else exec.remove_non_fungible_asset - # => [REMAINING_ASSET_VALUE] + # => [INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE] end end + +# HELPER PROCEDURES +# ================================================================================================= + +#! Hashes the raw asset vault key into the key used by the asset vault SMT. +#! +#! See [`AssetVaultKey::hash`] in Rust for the rationale. +#! +#! Inputs: [ASSET_KEY] +#! Outputs: [ASSET_KEY_HASH] +proc hash_asset_key + exec.poseidon2::hash + # => [ASSET_KEY_HASH] +end + +#! Hashes the raw asset vault key and writes ASSET_VALUE into the asset vault SMT at the hashed key, +#! returning the previous value stored there. +#! +#! Inputs: [ASSET_VALUE, ASSET_KEY, VAULT_ROOT] +#! Outputs: [OLD_VALUE, NEW_VAULT_ROOT] +#! +#! Where: +#! - ASSET_KEY is the raw (unhashed) asset vault key. +#! - ASSET_VALUE is the value to write into the vault at the hashed key. +#! - VAULT_ROOT is the current root of the asset vault SMT. +#! - OLD_VALUE is the value previously stored at the hashed key. +#! - NEW_VAULT_ROOT is the root of the asset vault SMT after the write. +proc set_asset + swapw exec.hash_asset_key swapw + # => [ASSET_VALUE, ASSET_KEY_HASH, VAULT_ROOT] + + exec.smt::set + # => [OLD_VALUE, NEW_VAULT_ROOT] +end diff --git a/crates/miden-protocol/asm/kernels/transaction/lib/epilogue.masm b/crates/miden-protocol/asm/kernels/transaction/lib/epilogue.masm index baf2ae2495..09754389a3 100644 --- a/crates/miden-protocol/asm/kernels/transaction/lib/epilogue.masm +++ b/crates/miden-protocol/asm/kernels/transaction/lib/epilogue.masm @@ -1,10 +1,9 @@ use $kernel::account -use $kernel::account_delta +use $kernel::account_update use $kernel::asset use $kernel::asset::ASSET_SIZE use $kernel::asset_vault use $kernel::constants::NOTE_MEM_SIZE -use $kernel::fungible_asset use $kernel::memory use $kernel::memory::OUTPUT_NOTE_SECTION_OFFSET use $kernel::memory::OUTPUT_VAULT_ROOT_PTR @@ -30,32 +29,12 @@ const ERR_EPILOGUE_NONCE_CANNOT_BE_0="nonce cannot be 0 after an account-creatin # Event emitted to signal that the compute_fee procedure has obtained the current number of cycles. const EPILOGUE_AFTER_TX_CYCLES_OBTAINED_EVENT=event("miden::protocol::epilogue::after_tx_cycles_obtained") -# Event emitted to signal that the fee was computed. -const EPILOGUE_BEFORE_TX_FEE_REMOVED_FROM_ACCOUNT_EVENT=event("miden::protocol::epilogue::before_tx_fee_removed_from_account") - # Event emitted to signal that an execution of the authentication procedure has started. const EPILOGUE_AUTH_PROC_START_EVENT=event("miden::protocol::epilogue::auth_proc_start") # Event emitted to signal that an execution of the authentication procedure has ended. const EPILOGUE_AUTH_PROC_END_EVENT=event("miden::protocol::epilogue::auth_proc_end") -# An additional number of cyclces to account for the number of cycles that smt::set will take when -# removing the computed fee from the asset vault. -# Theoretically, this can safely be set to worst_case_cycles - best_case_cycles of smt::set. That's -# because we can assume that the measured number of cycles already contains at least the best case -# number of cycles, and so we only need to add the difference. -const SMT_SET_ADDITIONAL_CYCLES=250 - -# The number of cycles the epilogue is estimated to take after compute_fee has been executed, -# including an unknown cycle number of the above-mentioned call to smt::set. It is safe to assume -# that this includes at least smt::set's best case number of cycles. -# This can be _estimated_ using the transaction measurements on ExecutedTransaction and can be set -# to the lowest observed value. -const NUM_POST_COMPUTE_FEE_CYCLES=608 - -# The number of cycles the epilogue is estimated to take after compute_fee has been executed. -const ESTIMATED_AFTER_COMPUTE_FEE_CYCLES=NUM_POST_COMPUTE_FEE_CYCLES+SMT_SET_ADDITIONAL_CYCLES - # OUTPUT NOTES PROCEDURES # ================================================================================================= @@ -117,10 +96,6 @@ end #! - we first copy the account vault root to the output vault root. #! - we then loop over the output notes and insert their assets into the output vault. #! -#! Note that this does not handle anything related to the fee asset. The fee asset is removed from -#! the account vault after this procedure has executed and since it is not added to an output note, -#! we don't have to account for it explicitly. -#! #! Inputs: [] #! Outputs: [] proc build_output_vault @@ -178,7 +153,7 @@ proc build_output_vault # output_vault_root_ptr, num_assets, note_data_ptr, output_notes_end_ptr] # insert output note asset into output vault - exec.asset_vault::add_asset dropw + exec.asset_vault::add_asset dropw dropw # => [assets_start_ptr, assets_end_ptr, output_vault_root_ptr, num_assets, # note_data_ptr, output_notes_end_ptr] @@ -259,9 +234,6 @@ proc compute_fee # => [num_current_cycles] emit.EPILOGUE_AFTER_TX_CYCLES_OBTAINED_EVENT - - # estimate the number of cycles the transaction will take - add.ESTIMATED_AFTER_COMPUTE_FEE_CYCLES # => [num_tx_cycles] # ilog2 will round down, but we need to round up, so we add 1 afterwards. @@ -278,130 +250,39 @@ proc compute_fee # => [verification_cost] end -#! Creates the fee asset with the provided fee amount and the fee faucet ID of the transaction's -#! reference block as the faucet ID. -#! -#! Inputs: [fee_amount] -#! Outputs: [FEE_ASSET_KEY, FEE_ASSET_VALUE] -#! -#! Where: -#! - fee_amount is the computed fee amount of the transaction in the fee asset. -#! - FEE_ASSET_KEY is the asset vault key of the fee asset. -#! - FEE_ASSET_VALUE is the fungible asset with amount set to fee_amount and the faucet ID set to -#! the fee faucet. -proc create_fee_asset - exec.memory::get_fee_faucet_id - # => [fee_faucet_id_suffix, fee_faucet_id_prefix, fee_amount] - - # assume the fee asset does not have callbacks - # this should be addressed more holistically with a fee construction refactor - push.0 - # => [enable_callbacks, fee_faucet_id_suffix, fee_faucet_id_prefix, fee_amount] - - # SAFETY: fee faucet ID should be fungible and amount should not be exceeded - exec.fungible_asset::create_unchecked - # => [FEE_ASSET_KEY, FEE_ASSET_VALUE] -end - -#! Computes the fee of this transaction and removes the asset from the native account's vault. -#! -#! Note that this does not have to account for the fee asset in the output vault explicitly, -#! because the fee asset is removed from the account vault after build_output_vault and because -#! it is not added to an output note. Effectively, the fee asset bypasses the asset preservation -#! check. That's okay, because the logic is entirely determined by the transaction kernel. -#! -#! Inputs: [] -#! Outputs: [fee_faucet_id_suffix, fee_faucet_id_prefix, fee_amount] -#! -#! Where: -#! - fee_amount is the computed fee amount of the transaction in the fee asset. -#! - fee_faucet_id_{prefix,suffix} are the prefix and suffix felts of the faucet that issues the -#! fee asset. -#! -#! Panics if: -#! - the account vault contains less than the computed fee. -proc compute_and_remove_fee - # compute the fee the tx needs to pay - exec.compute_fee dup - # => [fee_amount, fee_amount] - - # build the fee asset from the fee amount - exec.create_fee_asset - # => [FEE_ASSET_KEY, FEE_ASSET_VALUE, fee_amount] - - emit.EPILOGUE_BEFORE_TX_FEE_REMOVED_FROM_ACCOUNT_EVENT - # => [FEE_ASSET_KEY, FEE_ASSET_VALUE, fee_amount] - - # prepare the return value - exec.asset::key_to_faucet_id - # => [fee_faucet_id_suffix, fee_faucet_id_prefix, FEE_ASSET_KEY, FEE_ASSET_VALUE, fee_amount] - - movdn.9 movdn.9 - # => [FEE_ASSET_KEY, FEE_ASSET_VALUE, fee_faucet_id_suffix, fee_faucet_id_prefix, fee_amount] - - # remove the fee from the native account's vault - # note that this deliberately does not use account::remove_asset_from_vault, because that - # procedure modifies the vault delta of the account. That is undesirable because it does not - # take a constant number of cycles, which makes it much harder to calculate the number of - # cycles the kernel takes after computing the fee. It is also unnecessary, because the delta - # commitment has already been computed and so any modifications done to the delta at this point - # are essentially ignored. - - # fetch the vault root ptr - exec.memory::get_account_vault_root_ptr movdn.8 - # => [FEE_ASSET_KEY, FEE_ASSET_VALUE, account_vault_root_ptr, fee_faucet_id_suffix, fee_faucet_id_prefix, fee_amount] - - # remove the asset from the account vault - exec.asset_vault::remove_fungible_asset dropw - # => [fee_faucet_id_suffix, fee_faucet_id_prefix, fee_amount] -end - # TRANSACTION EPILOGUE PROCEDURE # ================================================================================================= #! Finalizes the transaction by performing the following steps: #! - invokes the native account's authentication procedure. #! - computes the output notes commitment. -#! - computes the transaction's fees and takes them out of the account vault. #! - computes the account delta commitment and the final account commitment, and the merged #! hash of both. #! -#! Note that the order of operations in this procedure is important. We want to run as many -#! operations as possible before computing the fee, since the fee depends on the transaction's -#! number of cycles. All operations that run before fee computation are included when invoking -#! `clk`. The cycles of operations that run after fee computation must be calculated to include -#! them in the fee and calculating is easiest when the operations are simple. -#! #! Inputs: [] #! Outputs: [ -#! OUTPUT_NOTES_COMMITMENT, ACCOUNT_UPDATE_COMMITMENT, -#! fee_faucet_id_suffix, fee_faucet_id_prefix, fee_amount, tx_expiration_block_num +#! OUTPUT_NOTES_COMMITMENT, ACCOUNT_UPDATE_COMMITMENT, tx_expiration_block_num #! ] #! #! Where: #! - OUTPUT_NOTES_COMMITMENT is the commitment of the output notes. #! - ACCOUNT_UPDATE_COMMITMENT is the hash of the the final account commitment and account -#! delta commitment. -#! - fee_amount is the computed fee amount of the transaction denominated in the fee asset. -#! - fee_faucet_id_{prefix,suffix} are the prefix and suffix felts of the faucet that issues the -#! fee asset. +#! patch commitment. #! - tx_expiration_block_num is the transaction expiration block number. #! #! Locals: #! - 0..4: OUTPUT_NOTES_COMMITMENT -#! - 4..8: FEE_ASSET_INFO -#! - 8..12: ACCOUNT_DELTA_COMMITMENT +#! - 4..8: ACCOUNT_PATCH_COMMITMENT #! #! Panics if: #! - the assets that were in input notes or in the account vault at the beginning of #! transaction execution are not in output notes or in the account vault at the end of the -#! transaction. The exception to this is the automatically computed and removed fee asset. +#! transaction. #! - the transaction does not change the chain state. This must be the case to avoid tx #! replayability. A tx changes the chain state if it either consumes 1 or more input notes or by -#! changing the account's state. The latter is validated as part of computing the account delta +#! changing the account's state. The latter is validated as part of computing the account patch #! commitment. -#! - the account vault does not contain the computed fee. -@locals(12) +@locals(8) pub proc finalize_transaction # make sure that the context was switched back to the native account exec.memory::assert_native_account @@ -441,48 +322,41 @@ pub proc finalize_transaction loc_storew_le.0 dropw # => [] - # ------ Compute account delta commitment ------ + # ------ Compute account patch commitment ------ + + exec.account_update::compute_patch_commitment + # => [ACCOUNT_PATCH_COMMITMENT] - exec.account_delta::compute_commitment - # => [ACCOUNT_DELTA_COMMITMENT] + # TODO(account_patch): Temporarily compute the delta commitment, to let the host track the delta. + # This will go away when the delta is fully replaced by the patch and removed from the + # executed transaction. + exec.account_update::compute_delta_commitment dropw + # => [ACCOUNT_PATCH_COMMITMENT] # store commitment in local - loc_storew_le.8 - # => [ACCOUNT_DELTA_COMMITMENT] + loc_storew_le.4 + # => [ACCOUNT_PATCH_COMMITMENT] # ------ Assert that account was changed or notes were consumed ------ - # check if the account delta commitment is the EMPTY_WORD, which is the case when the delta + # check if the account patch commitment is the EMPTY_WORD, which is the case when the patch # is empty, i.e. the account did not change in this transaction exec.word::eqz - # => [is_delta_commitment_empty] + # => [is_patch_commitment_empty] - # assert that if the delta commitment is empty, the transaction had input notes, + # assert that if the patch commitment is empty, the transaction had input notes, # otherwise this transaction is considered empty, which is not allowed exec.memory::get_input_notes_commitment - # => [INPUT_NOTES_COMMITMENT, is_delta_commitment_empty] + # => [INPUT_NOTES_COMMITMENT, is_patch_commitment_empty] # if the input notes commitment is an empty word there were no input notes in this transaction exec.word::eqz and - # => [is_delta_commitment_and_input_notes_commitment_empty] + # => [is_patch_commitment_and_input_notes_commitment_empty] # assert that either the account was changed or notes were consumed assertz.err=ERR_EPILOGUE_EXECUTED_TRANSACTION_IS_EMPTY # => [] - # ------ Compute fees ------ - - exec.compute_and_remove_fee - # => [fee_faucet_id_suffix, fee_faucet_id_prefix, fee_amount] - - # pad to word size so we can store the info as a word - push.0 movdn.3 - # => [fee_faucet_id_suffix, fee_faucet_id_prefix, fee_amount, 0] - - # store fee info in local memory - loc_storew_le.4 dropw - # => [] - # ------ Insert final account data into advice provider ------ # get the offset for the end of the account data section @@ -507,30 +381,25 @@ pub proc finalize_transaction # ------ Compute and insert account update commitment ------ - # load account delta commitment from local - padw loc_loadw_le.8 swapw - # => [FINAL_ACCOUNT_COMMITMENT, ACCOUNT_DELTA_COMMITMENT] + # load account patch commitment from local + padw loc_loadw_le.4 swapw + # => [FINAL_ACCOUNT_COMMITMENT, ACCOUNT_PATCH_COMMITMENT] - # insert into advice map ACCOUNT_UPDATE_COMMITMENT: (FINAL_ACCOUNT_COMMITMENT, ACCOUNT_DELTA_COMMITMENT), - # where ACCOUNT_UPDATE_COMMITMENT = hash(FINAL_ACCOUNT_COMMITMENT || ACCOUNT_DELTA_COMMITMENT) + # insert into advice map ACCOUNT_UPDATE_COMMITMENT: (FINAL_ACCOUNT_COMMITMENT, ACCOUNT_PATCH_COMMITMENT), + # where ACCOUNT_UPDATE_COMMITMENT = hash(FINAL_ACCOUNT_COMMITMENT || ACCOUNT_PATCH_COMMITMENT) adv.insert_hdword - # => [FINAL_ACCOUNT_COMMITMENT, ACCOUNT_DELTA_COMMITMENT] + # => [FINAL_ACCOUNT_COMMITMENT, ACCOUNT_PATCH_COMMITMENT] exec.poseidon2::merge # => [ACCOUNT_UPDATE_COMMITMENT] # ------ Build output stack ------ - # load fee asset from local - padw loc_loadw_le.4 swapw - # => [ACCOUNT_UPDATE_COMMITMENT, [fee_faucet_id_suffix, fee_faucet_id_prefix, fee_amount, 0]] - - # replace 0 with expiration block num - exec.memory::get_expiration_block_num swap.8 drop - # => [ACCOUNT_UPDATE_COMMITMENT, [fee_faucet_id_suffix, fee_faucet_id_prefix, fee_amount, tx_expiration_block_num]] + # place the expiration block num below the account update commitment word + exec.memory::get_expiration_block_num movdn.4 + # => [ACCOUNT_UPDATE_COMMITMENT, tx_expiration_block_num] # load output notes commitment from local padw loc_loadw_le.0 - # => [OUTPUT_NOTES_COMMITMENT, ACCOUNT_UPDATE_COMMITMENT, - # fee_faucet_id_suffix, fee_faucet_id_prefix, fee_amount, tx_expiration_block_num] + # => [OUTPUT_NOTES_COMMITMENT, ACCOUNT_UPDATE_COMMITMENT, tx_expiration_block_num] end diff --git a/crates/miden-protocol/asm/kernels/transaction/lib/faucet.masm b/crates/miden-protocol/asm/kernels/transaction/lib/faucet.masm index 2283f96726..b0117c0d24 100644 --- a/crates/miden-protocol/asm/kernels/transaction/lib/faucet.masm +++ b/crates/miden-protocol/asm/kernels/transaction/lib/faucet.masm @@ -36,6 +36,10 @@ pub proc mint_fungible_asset # add the asset to the input vault for asset preservation exec.asset_vault::add_fungible_asset + # => [INITIAL_ASSET_VALUE, NEW_ASSET_VALUE] + + # drop INITIAL_ASSET_VALUE; mint_fungible_asset returns only NEW_ASSET_VALUE + dropw # => [NEW_ASSET_VALUE] end @@ -67,10 +71,10 @@ proc burn_fungible_asset # remove the asset from the input vault for asset preservation exec.asset_vault::remove_fungible_asset - # => [REMAINING_ASSET_VALUE] + # => [INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE] - # drop the remaining value (not meaningful to the caller) - dropw + # drop both values (not meaningful to the caller) + dropw dropw # => [] end @@ -106,6 +110,10 @@ proc mint_non_fungible_asset # add the non-fungible asset to the input vault for asset preservation exec.asset_vault::add_non_fungible_asset + # => [INITIAL_ASSET_VALUE, NEW_ASSET_VALUE] + + # drop INITIAL_ASSET_VALUE; mint_non_fungible_asset returns only NEW_ASSET_VALUE + dropw # => [NEW_ASSET_VALUE] end @@ -136,10 +144,10 @@ proc burn_non_fungible_asset # => [ASSET_KEY, ASSET_VALUE, input_vault_root_ptr] exec.asset_vault::remove_non_fungible_asset - # => [REMAINING_ASSET_VALUE] + # => [INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE] - # drop the remaining value (not meaningful to the caller) - dropw + # drop both values (not meaningful to the caller) + dropw dropw # => [] end diff --git a/crates/miden-protocol/asm/kernels/transaction/lib/fungible_asset.masm b/crates/miden-protocol/asm/kernels/transaction/lib/fungible_asset.masm index 0670e57f94..5532dc3c06 100644 --- a/crates/miden-protocol/asm/kernels/transaction/lib/fungible_asset.masm +++ b/crates/miden-protocol/asm/kernels/transaction/lib/fungible_asset.masm @@ -75,11 +75,11 @@ end #! WARNING: This procedure assumes the assets have been validated. #! #! Inputs: [ASSET_VALUE_1, ASSET_VALUE_0] -#! Outputs: [NEW_ASSET_VALUE_0] +#! Outputs: [NEW_ASSET_VALUE] #! #! Where: #! - ASSET_VALUE_{0, 1} are the assets to split. -#! - NEW_ASSET_VALUE_0 is the result of the split computation. +#! - NEW_ASSET_VALUE is the result of the split computation. #! #! Panics if: #! - ASSET_VALUE_0 does not contain at least the amount of ASSET_VALUE_1. @@ -103,6 +103,25 @@ pub proc split # => [NEW_ASSET_VALUE] end +#! Returns 1 if ASSET_VALUE_0 is less than ASSET_VALUE_1, 0 otherwise. +#! +#! WARNING: This procedure assumes the assets have been validated and that it is never called when +#! ASSET_VALUE_0 = ASSET_VALUE_1. +#! +#! Inputs: [ASSET_VALUE_1, ASSET_VALUE_0] +#! Outputs: [is_lt] +#! +#! Where: +#! - ASSET_VALUE_{0, 1} are the assets to compare. +#! - is_lt is 1 if ASSET_VALUE_0 is less than ASSET_VALUE_1. +pub proc lt + exec.value_into_amount movdn.4 exec.value_into_amount swap + # => [amount_1, amount_0] + + lt + # => [amount_0 < amount_1] +end + #! Validates that a fungible asset is well formed. #! #! Inputs: [ASSET_KEY, ASSET_VALUE] diff --git a/crates/miden-protocol/asm/kernels/transaction/lib/memory.masm b/crates/miden-protocol/asm/kernels/transaction/lib/memory.masm index 3030e6cafd..8369638ebc 100644 --- a/crates/miden-protocol/asm/kernels/transaction/lib/memory.masm +++ b/crates/miden-protocol/asm/kernels/transaction/lib/memory.masm @@ -2,7 +2,6 @@ use $kernel::constants::ACCOUNT_PROCEDURE_DATA_LENGTH use $kernel::constants::MAX_ASSETS_PER_NOTE use $kernel::constants::NOTE_MEM_SIZE use $kernel::constants::WORD_NUM_ELEMENTS -# use $kernel::types::AccountId use miden::core::mem pub type AccountId = struct { prefix: felt, suffix: felt } @@ -220,16 +219,14 @@ const ACCT_ACTIVE_STORAGE_SLOTS_SECTION_OFFSET=3360 # NATIVE ACCOUNT DELTA # ------------------------------------------------------------------------------------------------- -# The link map pointer at which the delta of the fungible asset vault is stored. -pub const ACCOUNT_DELTA_FUNGIBLE_ASSET_PTR=532480 +# The link map pointer at which the delta of the asset vault is stored. +pub const ACCOUNT_UPDATE_ASSET_PTR=532480 -# The link map pointer at which the delta of the non-fungible asset vault is stored. -pub const ACCOUNT_DELTA_NON_FUNGIBLE_ASSET_PTR=ACCOUNT_DELTA_FUNGIBLE_ASSET_PTR+4 - -# The section of link map pointers where storage map deltas are stored. +# The section of link map pointers where storage map patches are stored. # This section is offset by `slot index` to get the link map ptr for the storage map # delta at that slot index. Slot indices that are not map slots are simply unused. -const ACCOUNT_DELTA_STORAGE_MAP_SECTION=ACCOUNT_DELTA_FUNGIBLE_ASSET_PTR+8 +# The section spans `MAX_NUM_STORAGE_SLOTS` (255) entries, one per possible slot index. +const ACCOUNT_PATCH_STORAGE_MAP_SECTION=ACCOUNT_UPDATE_ASSET_PTR+4 # INPUT NOTES DATA # ------------------------------------------------------------------------------------------------- @@ -738,20 +735,6 @@ pub proc get_blk_timestamp mem_load.BLOCK_METADATA_TIMESTAMP_PTR end -#! Returns the faucet ID of the fee asset as defined in the transaction's reference block. -#! -#! Inputs: [] -#! Outputs: [fee_faucet_id_suffix, fee_faucet_id_prefix] -#! -#! Where: -#! - fee_faucet_id_{prefix,suffix} are the prefix and suffix felts of the faucet ID that defines -#! the fee asset. -pub proc get_fee_faucet_id - mem_load.FEE_FAUCET_ID_PREFIX_PTR - mem_load.FEE_FAUCET_ID_SUFFIX_PTR - # => [fee_faucet_id_suffix, fee_faucet_id_prefix] -end - #! Returns the verification base fee from the transaction's reference block. #! #! Inputs: [] @@ -1427,16 +1410,16 @@ end ### ACCOUNT DELTA ################################################# -#! Returns the link map pointer to the storage map delta of the storage map in the given slot index. +#! Returns the link map pointer to the storage map patch of the storage map in the given slot index. #! #! Inputs: [slot_idx] -#! Outputs: [account_delta_storage_map_ptr] +#! Outputs: [account_patch_storage_map_ptr] #! #! Where: -#! - account_delta_storage_map_ptr is the link map pointer to the storage map delta for the +#! - account_patch_storage_map_ptr is the link map pointer to the storage map patch for the #! requested slot index. -pub proc get_account_delta_storage_map_ptr - add.ACCOUNT_DELTA_STORAGE_MAP_SECTION +pub proc get_account_patch_storage_map_ptr + add.ACCOUNT_PATCH_STORAGE_MAP_SECTION end #! Initializes the storage slots holding the initial values of each slot. diff --git a/crates/miden-protocol/asm/kernels/transaction/lib/prologue.masm b/crates/miden-protocol/asm/kernels/transaction/lib/prologue.masm index 06533bba66..f4bc84c615 100644 --- a/crates/miden-protocol/asm/kernels/transaction/lib/prologue.masm +++ b/crates/miden-protocol/asm/kernels/transaction/lib/prologue.masm @@ -736,7 +736,7 @@ proc add_input_note_assets_to_vault # the witnesses for the note assets should be added prior to transaction execution and so # there should be no need to fetch them lazily via an event. - exec.asset_vault::add_asset dropw + exec.asset_vault::add_asset dropw dropw # => [assets_start_ptr, assets_end_ptr, input_vault_root_ptr] add.ASSET_SIZE diff --git a/crates/miden-protocol/asm/kernels/transaction/main.masm b/crates/miden-protocol/asm/kernels/transaction/main.masm index 04e07c9404..c11a29f202 100644 --- a/crates/miden-protocol/asm/kernels/transaction/main.masm +++ b/crates/miden-protocol/asm/kernels/transaction/main.masm @@ -56,9 +56,8 @@ const EPILOGUE_END_EVENT=event("miden::protocol::tx::epilogue_end") #! account_id_suffix, account_id_prefix, block_num, pad(1) #! ] #! Outputs: [ -#! OUTPUT_NOTES_COMMITMENT, ACCOUNT_UPDATE_COMMITMENT, -#! fee_faucet_id_suffix, fee_faucet_id_prefix, fee_amount, tx_expiration_block_num, -#! pad(4) +#! OUTPUT_NOTES_COMMITMENT, ACCOUNT_UPDATE_COMMITMENT, tx_expiration_block_num, +#! pad(7) #! ] #! #! Where: @@ -69,10 +68,7 @@ const EPILOGUE_END_EVENT=event("miden::protocol::tx::epilogue_end") #! - INPUT_NOTES_COMMITMENT, see `transaction::api::get_input_notes_commitment`. #! - OUTPUT_NOTES_COMMITMENT is the commitment to the notes created by the transaction. #! - ACCOUNT_UPDATE_COMMITMENT is the hash of the the final account commitment and account -#! delta commitment. -#! - fee_amount is the computed fee amount of the transaction denominated in the fee asset. -#! - fee_faucet_id_{prefix,suffix} are the prefix and suffix felts of the faucet that issues the -#! fee asset. +#! patch commitment. #! - tx_expiration_block_num is the transaction expiration block number. @locals(1) proc main @@ -175,13 +171,13 @@ proc main # execute the transaction epilogue exec.epilogue::finalize_transaction - # => [OUTPUT_NOTES_COMMITMENT, ACCOUNT_UPDATE_COMMITMENT, - # fee_faucet_id_suffix, fee_faucet_id_prefix, fee_amount, tx_expiration_block_num, pad(16)] + # => [OUTPUT_NOTES_COMMITMENT, ACCOUNT_UPDATE_COMMITMENT, tx_expiration_block_num, pad(16)] # truncate the stack to contain 16 elements in total - repeat.3 movupw.3 dropw end - # => [OUTPUT_NOTES_COMMITMENT, ACCOUNT_UPDATE_COMMITMENT, - # fee_faucet_id_suffix, fee_faucet_id_prefix, fee_amount, tx_expiration_block_num, pad(4)] + # the epilogue leaves 9 output elements on top of the 16 padding elements (depth 25), so drop + # the 9 deepest padding elements: two full words followed by a single element + repeat.2 movupw.3 dropw end movup.15 drop + # => [OUTPUT_NOTES_COMMITMENT, ACCOUNT_UPDATE_COMMITMENT, tx_expiration_block_num, pad(7)] emit.EPILOGUE_END_EVENT end diff --git a/crates/miden-protocol/asm/protocol/active_account.masm b/crates/miden-protocol/asm/protocol/active_account.masm index 652c115ace..6a5ecbcfbc 100644 --- a/crates/miden-protocol/asm/protocol/active_account.masm +++ b/crates/miden-protocol/asm/protocol/active_account.masm @@ -11,6 +11,7 @@ use miden::protocol::kernel_proc_offsets::ACCOUNT_COMPUTE_STORAGE_COMMITMENT_OFF use miden::protocol::kernel_proc_offsets::ACCOUNT_GET_INITIAL_VAULT_ROOT_OFFSET use miden::protocol::kernel_proc_offsets::ACCOUNT_GET_VAULT_ROOT_OFFSET use miden::protocol::kernel_proc_offsets::ACCOUNT_GET_ITEM_OFFSET +use miden::protocol::kernel_proc_offsets::ACCOUNT_HAS_STORAGE_SLOT_OFFSET use miden::protocol::kernel_proc_offsets::ACCOUNT_GET_INITIAL_ITEM_OFFSET use miden::protocol::kernel_proc_offsets::ACCOUNT_GET_MAP_ITEM_OFFSET use miden::protocol::kernel_proc_offsets::ACCOUNT_GET_INITIAL_MAP_ITEM_OFFSET @@ -88,7 +89,7 @@ pub proc get_nonce # => [nonce, pad(15)] # clean the stack - swapdw dropw dropw swapw dropw movdn.3 drop drop drop + swapw.3 dropw dropw dropw movdn.3 drop drop drop # => [nonce] end @@ -333,6 +334,39 @@ pub proc get_item # => [VALUE] end +#! Returns a flag indicating whether a storage slot with the provided slot ID exists on the active +#! account. +#! +#! Returns 1 if the slot exists and 0 otherwise. +#! +#! Inputs: [slot_id_suffix, slot_id_prefix] +#! Outputs: [has_slot] +#! +#! Where: +#! - slot_id_{suffix, prefix} are the suffix and prefix felts of the slot identifier, which are +#! the first two felts of the hashed slot name. +#! - has_slot is 1 if a slot with the provided slot ID exists, 0 otherwise. +#! +#! Invocation: exec +pub proc has_storage_slot + push.0 movdn.2 + # => [slot_id_suffix, slot_id_prefix, 0] + + push.ACCOUNT_HAS_STORAGE_SLOT_OFFSET + # => [offset, slot_id_suffix, slot_id_prefix, 0] + + # pad the stack + padw swapw padw padw swapdw + # => [offset, slot_id_suffix, slot_id_prefix, pad(13)] + + syscall.exec_kernel_proc + # => [has_slot, pad(15)] + + # clean the stack + swapdw dropw dropw swapw dropw movdn.3 drop drop drop + # => [has_slot] +end + #! Gets the initial item from the active account storage slot as it was at the beginning of the #! transaction. #! @@ -662,7 +696,7 @@ pub proc has_procedure # => [is_procedure_available, pad(15)] # clean the stack - swapdw dropw dropw swapw dropw movdn.3 drop drop drop + swapw.3 dropw dropw dropw movdn.3 drop drop drop # => [is_procedure_available] end diff --git a/crates/miden-protocol/asm/protocol/active_note.masm b/crates/miden-protocol/asm/protocol/active_note.masm index 58b2f2e8dc..fa272cbec4 100644 --- a/crates/miden-protocol/asm/protocol/active_note.masm +++ b/crates/miden-protocol/asm/protocol/active_note.masm @@ -9,6 +9,8 @@ use miden::protocol::kernel_proc_offsets::INPUT_NOTE_GET_SERIAL_NUMBER_OFFSET use miden::protocol::kernel_proc_offsets::INPUT_NOTE_GET_SCRIPT_ROOT_OFFSET use miden::protocol::note use miden::protocol::input_note +use miden::protocol::util::note::NOTE_TYPE_PUBLIC +use miden::protocol::util::note::NOTE_TYPE_PRIVATE # ERRORS # ================================================================================================= @@ -174,6 +176,52 @@ pub proc get_metadata # => [METADATA] end +#! Returns whether the active note is public. +#! +#! Inputs: [] +#! Outputs: [is_public] +#! +#! Where: +#! - is_public is 1 if the active note is public, 0 otherwise. +#! +#! Panics if: +#! - no note is currently active. +#! +#! Invocation: exec +pub proc is_public + exec.get_metadata + # => [METADATA] + + exec.note::metadata_into_note_type + # => [note_type] + + eq.NOTE_TYPE_PUBLIC + # => [is_public] +end + +#! Returns whether the active note is private. +#! +#! Inputs: [] +#! Outputs: [is_private] +#! +#! Where: +#! - is_private is 1 if the active note is private, 0 otherwise. +#! +#! Panics if: +#! - no note is currently active. +#! +#! Invocation: exec +pub proc is_private + exec.get_metadata + # => [METADATA] + + exec.note::metadata_into_note_type + # => [note_type] + + eq.NOTE_TYPE_PRIVATE + # => [is_private] +end + #! Returns the sender of the active note. #! #! Inputs: [] diff --git a/crates/miden-protocol/asm/protocol/faucet.masm b/crates/miden-protocol/asm/protocol/faucet.masm index f630b6a134..84d04a7c40 100644 --- a/crates/miden-protocol/asm/protocol/faucet.masm +++ b/crates/miden-protocol/asm/protocol/faucet.masm @@ -155,6 +155,6 @@ pub proc has_callbacks # => [has_callbacks, pad(15)] # clean the stack - swapdw dropw dropw swapw dropw movdn.3 drop drop drop + swapw.3 dropw dropw dropw movdn.3 drop drop drop # => [has_callbacks] end diff --git a/crates/miden-protocol/asm/protocol/kernel_proc_offsets.masm b/crates/miden-protocol/asm/protocol/kernel_proc_offsets.masm index b5b8f305f3..7207196d95 100644 --- a/crates/miden-protocol/asm/protocol/kernel_proc_offsets.masm +++ b/crates/miden-protocol/asm/protocol/kernel_proc_offsets.masm @@ -26,71 +26,72 @@ pub const ACCOUNT_SET_ITEM_OFFSET=10 pub const ACCOUNT_GET_MAP_ITEM_OFFSET=11 pub const ACCOUNT_GET_INITIAL_MAP_ITEM_OFFSET=12 pub const ACCOUNT_SET_MAP_ITEM_OFFSET=13 +pub const ACCOUNT_HAS_STORAGE_SLOT_OFFSET=14 # Vault -pub const ACCOUNT_GET_INITIAL_VAULT_ROOT_OFFSET=14 -pub const ACCOUNT_GET_VAULT_ROOT_OFFSET=15 -pub const ACCOUNT_ADD_ASSET_OFFSET=16 -pub const ACCOUNT_REMOVE_ASSET_OFFSET=17 -pub const ACCOUNT_GET_ASSET_OFFSET=18 -pub const ACCOUNT_GET_INITIAL_ASSET_OFFSET=19 +pub const ACCOUNT_GET_INITIAL_VAULT_ROOT_OFFSET=15 +pub const ACCOUNT_GET_VAULT_ROOT_OFFSET=16 +pub const ACCOUNT_ADD_ASSET_OFFSET=17 +pub const ACCOUNT_REMOVE_ASSET_OFFSET=18 +pub const ACCOUNT_GET_ASSET_OFFSET=19 +pub const ACCOUNT_GET_INITIAL_ASSET_OFFSET=20 # Delta -pub const ACCOUNT_COMPUTE_DELTA_COMMITMENT_OFFSET=20 +pub const ACCOUNT_COMPUTE_DELTA_COMMITMENT_OFFSET=21 # Procedure introspection -pub const ACCOUNT_GET_NUM_PROCEDURES_OFFSET=21 -pub const ACCOUNT_GET_PROCEDURE_ROOT_OFFSET=22 -pub const ACCOUNT_WAS_PROCEDURE_CALLED_OFFSET=23 -pub const ACCOUNT_HAS_PROCEDURE_OFFSET=24 +pub const ACCOUNT_GET_NUM_PROCEDURES_OFFSET=22 +pub const ACCOUNT_GET_PROCEDURE_ROOT_OFFSET=23 +pub const ACCOUNT_WAS_PROCEDURE_CALLED_OFFSET=24 +pub const ACCOUNT_HAS_PROCEDURE_OFFSET=25 ### Faucet ###################################### -pub const FAUCET_MINT_ASSET_OFFSET=25 -pub const FAUCET_BURN_ASSET_OFFSET=26 -pub const FAUCET_HAS_CALLBACKS_OFFSET=27 +pub const FAUCET_MINT_ASSET_OFFSET=26 +pub const FAUCET_BURN_ASSET_OFFSET=27 +pub const FAUCET_HAS_CALLBACKS_OFFSET=28 ### Note ######################################## # input notes -pub const INPUT_NOTE_GET_METADATA_OFFSET=28 -pub const INPUT_NOTE_GET_RECIPIENT_OFFSET=29 -pub const INPUT_NOTE_GET_ASSETS_INFO_OFFSET=30 -pub const INPUT_NOTE_GET_ATTACHMENTS_COMMITMENT_OFFSET=31 -pub const INPUT_NOTE_GET_SCRIPT_ROOT_OFFSET=32 -pub const INPUT_NOTE_GET_STORAGE_INFO_OFFSET=33 -pub const INPUT_NOTE_GET_SERIAL_NUMBER_OFFSET=34 +pub const INPUT_NOTE_GET_METADATA_OFFSET=29 +pub const INPUT_NOTE_GET_RECIPIENT_OFFSET=30 +pub const INPUT_NOTE_GET_ASSETS_INFO_OFFSET=31 +pub const INPUT_NOTE_GET_ATTACHMENTS_COMMITMENT_OFFSET=32 +pub const INPUT_NOTE_GET_SCRIPT_ROOT_OFFSET=33 +pub const INPUT_NOTE_GET_STORAGE_INFO_OFFSET=34 +pub const INPUT_NOTE_GET_SERIAL_NUMBER_OFFSET=35 # output notes -pub const OUTPUT_NOTE_CREATE_OFFSET=35 -pub const OUTPUT_NOTE_GET_METADATA_OFFSET=36 -pub const OUTPUT_NOTE_GET_RECIPIENT_OFFSET=37 -pub const OUTPUT_NOTE_GET_ASSETS_INFO_OFFSET=38 -pub const OUTPUT_NOTE_GET_ATTACHMENTS_COMMITMENT_OFFSET=39 -pub const OUTPUT_NOTE_ADD_ASSET_OFFSET=40 -pub const OUTPUT_NOTE_ADD_ATTACHMENT_OFFSET=41 +pub const OUTPUT_NOTE_CREATE_OFFSET=36 +pub const OUTPUT_NOTE_GET_METADATA_OFFSET=37 +pub const OUTPUT_NOTE_GET_RECIPIENT_OFFSET=38 +pub const OUTPUT_NOTE_GET_ASSETS_INFO_OFFSET=39 +pub const OUTPUT_NOTE_GET_ATTACHMENTS_COMMITMENT_OFFSET=40 +pub const OUTPUT_NOTE_ADD_ASSET_OFFSET=41 +pub const OUTPUT_NOTE_ADD_ATTACHMENT_OFFSET=42 ### Tx ########################################## # input notes -pub const TX_GET_NUM_INPUT_NOTES_OFFSET=42 -pub const TX_GET_INPUT_NOTES_COMMITMENT_OFFSET=43 +pub const TX_GET_NUM_INPUT_NOTES_OFFSET=43 +pub const TX_GET_INPUT_NOTES_COMMITMENT_OFFSET=44 # output notes -pub const TX_GET_NUM_OUTPUT_NOTES_OFFSET=44 -pub const TX_GET_OUTPUT_NOTES_COMMITMENT_OFFSET=45 +pub const TX_GET_NUM_OUTPUT_NOTES_OFFSET=45 +pub const TX_GET_OUTPUT_NOTES_COMMITMENT_OFFSET=46 # block info -pub const TX_GET_BLOCK_COMMITMENT_OFFSET=46 -pub const TX_GET_BLOCK_NUMBER_OFFSET=47 -pub const TX_GET_BLOCK_TIMESTAMP_OFFSET=48 +pub const TX_GET_BLOCK_COMMITMENT_OFFSET=47 +pub const TX_GET_BLOCK_NUMBER_OFFSET=48 +pub const TX_GET_BLOCK_TIMESTAMP_OFFSET=49 # foreign context -pub const TX_PREPARE_FPI_OFFSET = 49 -pub const TX_EXEC_FOREIGN_PROC_OFFSET = 50 +pub const TX_PREPARE_FPI_OFFSET = 50 +pub const TX_EXEC_FOREIGN_PROC_OFFSET = 51 # expiration data -pub const TX_GET_EXPIRATION_DELTA_OFFSET=51 # accessor -pub const TX_UPDATE_EXPIRATION_BLOCK_DELTA_OFFSET=52 # mutator +pub const TX_GET_EXPIRATION_DELTA_OFFSET=52 # accessor +pub const TX_UPDATE_EXPIRATION_BLOCK_DELTA_OFFSET=53 # mutator # tx script -pub const TX_GET_TX_SCRIPT_ROOT_OFFSET=53 +pub const TX_GET_TX_SCRIPT_ROOT_OFFSET=54 diff --git a/crates/miden-protocol/asm/protocol/native_account.masm b/crates/miden-protocol/asm/protocol/native_account.masm index 82d0a9865d..78680135ef 100644 --- a/crates/miden-protocol/asm/protocol/native_account.masm +++ b/crates/miden-protocol/asm/protocol/native_account.masm @@ -99,7 +99,7 @@ end #! - DELTA_COMMITMENT is the commitment to the account delta. #! #! Panics if: -#! - the vault or storage delta is not empty but the nonce increment is zero. +#! - the vault or storage patch is not empty but the nonce increment is zero. pub proc compute_delta_commitment # pad the stack padw padw padw push.0.0.0 @@ -199,14 +199,14 @@ end #! Add the specified asset to the vault. #! #! Inputs: [ASSET_KEY, ASSET_VALUE] -#! Outputs: [ASSET_VALUE'] +#! Outputs: [FINAL_ASSET_VALUE] #! #! Where: #! - ASSET_KEY is the vault key of the asset that is added to the vault. #! - ASSET_VALUE is the value of the asset to add to the vault. -#! - ASSET_VALUE' final asset in the account vault defined as follows: -#! - If ASSET_VALUE is a non-fungible asset, then ASSET_VALUE' is the same as ASSET_VALUE. -#! - If ASSET_VALUE is a fungible asset, then ASSET_VALUE' is the total fungible asset in the account vault +#! - FINAL_ASSET_VALUE is the asset in the account vault after the operation, defined as follows: +#! - If ASSET_VALUE is a non-fungible asset, then FINAL_ASSET_VALUE is the same as ASSET_VALUE. +#! - If ASSET_VALUE is a fungible asset, then FINAL_ASSET_VALUE is the total fungible asset in the account vault #! after ASSET_VALUE was added to it. #! #! Panics if: @@ -224,22 +224,22 @@ pub proc add_asset # => [offset, ASSET_KEY, ASSET_VALUE, pad(7)] syscall.exec_kernel_proc - # => [ASSET_VALUE', pad(12)] + # => [FINAL_ASSET_VALUE, pad(12)] # clean the stack swapdw dropw dropw swapw dropw - # => [ASSET_VALUE'] + # => [FINAL_ASSET_VALUE] end #! Remove the specified asset from the vault and return the remaining asset value. #! #! Inputs: [ASSET_KEY, ASSET_VALUE] -#! Outputs: [REMAINING_ASSET_VALUE] +#! Outputs: [FINAL_ASSET_VALUE] #! #! Where: #! - ASSET_KEY is the vault key of the asset to remove from the vault. #! - ASSET_VALUE is the value of the asset to remove from the vault. -#! - REMAINING_ASSET_VALUE is the value of the asset remaining in the vault after removal which may +#! - FINAL_ASSET_VALUE is the value of the asset remaining in the vault after removal which may #! be the empty word if nothing remains (e.g. if a non-fungible asset is removed). #! #! Panics if: @@ -257,11 +257,11 @@ pub proc remove_asset # => [offset, ASSET_KEY, ASSET_VALUE, pad(7)] syscall.exec_kernel_proc - # => [REMAINING_ASSET_VALUE, pad(12)] + # => [FINAL_ASSET_VALUE, pad(12)] # clean the stack swapdw dropw dropw swapw dropw - # => [REMAINING_ASSET_VALUE] + # => [FINAL_ASSET_VALUE] end # CODE @@ -293,6 +293,6 @@ pub proc was_procedure_called # => [was_called, pad(15)] # clean the stack - swapdw dropw dropw swapw dropw movdn.3 drop drop drop + swapw.3 dropw dropw dropw movdn.3 drop drop drop # => [was_called] end diff --git a/crates/miden-protocol/asm/protocol/tx.masm b/crates/miden-protocol/asm/protocol/tx.masm index 7d36dc951f..06df0413df 100644 --- a/crates/miden-protocol/asm/protocol/tx.masm +++ b/crates/miden-protocol/asm/protocol/tx.masm @@ -32,7 +32,7 @@ pub proc get_block_number # => [num, pad(15)] # clean the stack - swapdw dropw dropw swapw dropw movdn.3 drop drop drop + swapw.3 dropw dropw dropw movdn.3 drop drop drop # => [num] end @@ -100,7 +100,7 @@ pub proc get_block_timestamp # => [timestamp, pad(15)] # clean the stack - swapdw dropw dropw swapw dropw movdn.3 drop drop drop + swapw.3 dropw dropw dropw movdn.3 drop drop drop # => [timestamp] end @@ -178,7 +178,7 @@ pub proc get_num_input_notes # => [num_input_notes, pad(15)] # clean the stack - swapdw dropw dropw swapw dropw movdn.3 drop drop drop + swapw.3 dropw dropw dropw movdn.3 drop drop drop # => [num_input_notes] end @@ -206,7 +206,7 @@ pub proc get_num_output_notes # => [num_output_notes, pad(15)] # clean the stack - swapdw dropw dropw swapw dropw movdn.3 drop drop drop + swapw.3 dropw dropw dropw movdn.3 drop drop drop # => [num_output_notes] end @@ -317,7 +317,7 @@ pub proc get_expiration_block_delta # => [expiration_delta, pad(15)] # clear the stack - swapdw dropw dropw swapw dropw movdn.3 drop drop drop + swapw.3 dropw dropw dropw movdn.3 drop drop drop # => [expiration_delta] end diff --git a/crates/miden-protocol/asm/shared_utils/util/asset.masm b/crates/miden-protocol/asm/shared_utils/util/asset.masm index c6ebf0ab53..0de32cdd6f 100644 --- a/crates/miden-protocol/asm/shared_utils/util/asset.masm +++ b/crates/miden-protocol/asm/shared_utils/util/asset.masm @@ -12,7 +12,7 @@ const ERR_VAULT_ASSET_METADATA_UNKNOWN_COMPOSITION = "unknown asset metadata com # Specifies the maximum amount a fungible asset can represent. # -# This is 2^63 - 2^31. See account_delta.masm for more details. +# This is 2^63 - 2^31. See account_update.masm for more details. pub const FUNGIBLE_ASSET_MAX_AMOUNT=0x7fffffff80000000 # The number of elements in an asset, i.e. vault key and value. diff --git a/crates/miden-protocol/build.rs b/crates/miden-protocol/build.rs index f116980634..a252bb7527 100644 --- a/crates/miden-protocol/build.rs +++ b/crates/miden-protocol/build.rs @@ -20,6 +20,7 @@ const ASM_PROTOCOL_DIR: &str = "protocol"; const SHARED_UTILS_DIR: &str = "shared_utils"; const SHARED_MODULES_DIR: &str = "shared_modules"; const ASM_TX_KERNEL_DIR: &str = "kernels/transaction"; +const ASM_BATCH_KERNEL_DIR: &str = "kernels/batch"; const PROTOCOL_LIB_NAMESPACE: &str = "miden::protocol"; @@ -85,6 +86,9 @@ fn main() -> Result<()> { let protocol_lib = compile_protocol_lib(&source_dir, &target_dir, assembler.clone())?; assembler.link_dynamic_library(protocol_lib)?; + // compile batch kernel + compile_batch_kernel(&source_dir, &target_dir.join("kernels"))?; + generate_error_constants(&source_dir, &build_dir)?; generate_event_constants(&source_dir, &target_dir)?; @@ -92,6 +96,22 @@ fn main() -> Result<()> { Ok(()) } +// COMPILE BATCH KERNEL +// ================================================================================================ + +/// Reads the batch kernel MASM source from the `source_dir`, compiles it, and saves the result +/// to the `target_dir` as a `batch_kernel.masb` binary file. +fn compile_batch_kernel(source_dir: &Path, target_dir: &Path) -> Result<()> { + let batch_kernel_dir = source_dir.join(ASM_BATCH_KERNEL_DIR); + let main_file_path = batch_kernel_dir.join("main.masm"); + + let assembler = build_assembler(None)?; + let batch_main = assembler.assemble_program(main_file_path)?; + + let masb_file_path = target_dir.join("batch_kernel.masb"); + batch_main.write_to_file(masb_file_path).into_diagnostic() +} + // COMPILE TRANSACTION KERNEL // ================================================================================================ diff --git a/crates/miden-protocol/masm_doc_comment_fmt.md b/crates/miden-protocol/masm_doc_comment_fmt.md deleted file mode 100644 index b46daa8d1c..0000000000 --- a/crates/miden-protocol/masm_doc_comment_fmt.md +++ /dev/null @@ -1,267 +0,0 @@ -# Format guidebook - -A guidebook defining the format of documentation comments and regular comments for `masm` procedures. - -## General - -Entire procedure doc comment should be created using the `#!` pair of symbols as the commenting sign. - -Doc comment for a procedure should have these blocks: - -- Procedure description. -- Inputs and outputs. -- Description of the values used in the "Inputs and outputs" block (optional). -- Panic block (optional). -- Invocation hint (optional and will become redundant after the procedure annotations will be implemented). - -Each block should be separated from the others with a blank line. - -Example: - -```masm -#! This procedure is executed somewhere in the execution pipeline. Its responsibility is: -#! 1. Do the first point of this list. -#! 2. Compute some extremely important values which will be used in future. -#! 3. Finally do the actions specified in the third point of this list. -#! -#! Inputs: -#! Operand stack: [ -#! single_felt, -#! [felt_1, 0, 0, felt_2], -#! SOME_WORD, -#! memory_value_ptr, -#! ] -#! Advice stack: [HASH_A, HASH_B, [ARRAY_OF_HASHES]] -#! Advice map: { -#! KEY_1: [VALUE_1], -#! KEY_2: [VALUE_2], -#! } -#! Outputs: -#! Operand stack: [] -#! Advice stack: [] -#! -#! Where: -#! - single_felt is the ordinary value placed on top of the stack. -#! - SOME_WORD is the word specifying maybe some hash. -#! -#! Panics if: -#! - something went wrong. -#! - some check is failed. -#! -#! Invocation: call -``` - -## Procedure description - -Contains the general information about the purpose of the procedure and the way it works. May contain any other valuable information. - -If some list is used for description, it should be formatted like so: - -- The description of the list should not have a blank like between it and the list. -- The description of the list should have a colon at the end. -- Depending on what kind of sentences form a list, they should start with a capital letter and end with a period, or start with a lowercase letter and without period at the end. -- List should use a `-` symbol in case of unordered list or arabic numerals for ordered ones (for example, for the description of the execution steps). -- Nested list should follow the same format. - -Some data could be formatted as a subparagraph, in that case a blank line should be used to separate them. - -Example: - -```masm -#! Transaction kernel program. -#! -#! This is the entry point of the transaction kernel, the program will perform the following -#! operations: -#! 1. Run the prologue to prepare the transaction's root context. -#! 2. Run all the notes' scripts. -#! 3. Run the transaction script. -#! 4. Run the epilogue to compute and validate the final state. -#! -#! See `prologue::prepare_transaction` for additional details on the VM's initial state, including -#! the advice provider. -``` - -## Inputs and outputs - -Each variable could represent a single value or a sequence of four values (a Word). Variable representing a single value should be written in lowercase, and a variable for the word should be written in uppercase. - -For multi-element values that are not exactly one word (4 felts), append `(N)` to indicate the count: - -- `value` is a single felt. -- `value(N)` are N felts (where N is not 4). -- `VALUE` is a word (4 felts). No `(4)` suffix is needed since uppercase already implies a word. - -Example: - -```masm -#! Inputs: [single_value, SOME_WORD] -#! Inputs: [dest_address(5), amount_u256(8), pad(2)] -``` - -Variable, which represents a memory address, should have a `_ptr` suffix in its name. For example, `note_script_commitment_ptr`. - -It is strongly not recommended to use a single-letter names for variables, with just an exception for the loop indexes (i.e. `i`). So, for example, instead of `H` a proper `HASH` or even more expanded version like `INIT_HASH` should be used. - -### Inputs - -Inputs block could contain three components: operand stack, advice stack and advice map. Description of the each container should be offset with two spaces relative to the start of the `Inputs` word. Each name of the container should be separated from its value by the colon (e.g. `Operand stack: [value_1]`). - -Operand stack and advice stack should be presented as an array containing some data. - -The lines which exceed 100 symbols should be formatted differently, it could be done in two different ways: - -1. The line should be broken, and the end of the line should be moved to the new line with an offset such that the first symbol of the first element on the second line should be directly above the first symbol of the first element on the first line (see the value of the `FOREIGN_ACCOUNT_ID` in the example in `Formats` section). -2. The exceeded array should be formatted in a column, forming a Word or some other number of related elements on each line. Each new line should be offset with two spaces relative to the name of the container (see example below). - -Example: - -```masm -#! Inputs: -#! Operand stack: [] -#! Advice stack: [ -#! account_id, 0, 0, account_nonce, -#! ACCOUNT_VAULT_ROOT, -#! ACCOUNT_STORAGE_COMMITMENT, -#! ACCOUNT_CODE_COMMITMENT -#! ] -``` - -To show that some internal value array could have dynamic length, additional brackets should be used (see the `[VALUE_B]` in the advice stack in the example in `Formats` section). - -In case some inputs are presented on the stack only if some condition is satisfied, such inputs should be placed in the "optional" box: inside the parentheses with a question mark at the end. Opening and closing brackets should be placed on a new line with the same offset as the other inputs, and values inside the brackets should be offset by two spaces. - -Example: - -```masm -#! ... -#! Advice stack: [ -#! NOTE_METADATA, -#! assets_count, -#! ( -#! block_num, -#! BLOCK_SUB_COMMITMENT, -#! NOTE_ROOT, -#! )? -#! ] -#! ... -``` - -Advice map should be presented as a sequence of the key-value pairs in the curly brackets. Opening bracket should stay on the same line, and the closing bracket should be placed on the next line after the last key-value pair with the same offset as the `Advice map` phrase. - -Each pair should start at the new line with additional two spaces offset relative to the start of the `Advice map` phrase. Pairs should be separated with comma. The same formatting rules as to the operand and advice stacks should be applied for the each key-value pair. - -### Outputs - -Outputs should show the final state of each container, used in the inputs, except for the advice map. Advice map should be specified in the outputs section only if it was modified. - -Example: - -```masm -#! Inputs: -#! Operand stack: [OUTPUT_NOTES_COMMITMENT] -#! Outputs: -#! Operand stack: [OUTPUT_NOTES_COMMITMENT] -#! Advice map: { -#! OUTPUT_NOTES_COMMITMENT: [mem[output_note_ptr]...mem[output_notes_end_ptr]], -#! } -#! -#! Where: -#! - OUTPUT_NOTES_COMMITMENT is the note commitment computed from note's id and metadata. -#! - output_note_ptr is the start boundary of the output notes section. -#! - output_notes_end_ptr is the end boundary of the output notes section. -#! - mem[i] is the memory value stored at some address i. -``` - -### Formats - -#### Full version - -In case the values are provided not only through the operand stack, but also through any other container, the full version if the inputs should be used. - -Notice that operand stack should be presented in any case, even if it is empty. Other components should be presented only if they have some values used in the describing function. - -Example: - -```masm -#! Inputs: -#! Operand stack: [] -#! Advice stack: [VALUE_A, [VALUE_B]] -#! Advice map: { -#! FOREIGN_ACCOUNT_ID: [[foreign_account_id, 0, 0, account_nonce], VAULT_ROOT, STORAGE_ROOT, -#! CODE_ROOT], -#! STORAGE_ROOT: [[STORAGE_SLOT_DATA]], -#! CODE_ROOT: [num_procs, [ACCOUNT_PROCEDURE_DATA]] -#! } -#! Outputs: -#! Operand stack: [value] -#! Advice stack: [] -``` - -#### Short version - -In case the values are provided only through the operand stack, a short version of the inputs and outputs should be used. In that case only `Inputs` and `Outputs` components are used, representing the values on the operand stack. - -Input values array should be offset by one space to be inline with the output values array (see the example). - -Example: - -```masm -#! Inputs: [single_value, WORD_1] -#! Outputs: [WORD_2] -``` - -## Description of the used values - -If some value was used in the inputs and outputs block (and its meaning is not obvious) this value should be described. - -Values description block should start with `Where` word with a colon at the end. Definitions should be represented as an unordered list constructed with `-` symbols, without any space offset. Each definition should start with the name of the variable followed by the `is/are the` phrase, after which the definition should be placed. At the end of each definition should be a period. - -Example: - -```masm -#! Where: -#! - tag is the tag to be included in the note. -#! - aux is the auxiliary metadata to be included in the note. -#! - note_type is the storage type of the note. -#! - execution_hint is the note's execution hint. -#! - RECIPIENT is the recipient of the note. -#! - note_idx is the index of the created note. -``` - -## Panic block - -If the describing procedure could potentially panic, a panic block should be specified. - -Panic block should start with `Panics if` phrase with a colon at the end. Panic cases should be represented as an unordered list constructed with `-` symbols, without any space offset. Definitions should start with lowercase letter, except for the cases which form the nested list (see example). Each case should end with a period. - -Example: - -```masm -#! Panics if: -#! - the transaction is not being executed against a faucet. -#! - the invocation of this procedure does not originate from the native account. -#! - the asset being burned is not associated with the faucet the transaction is being executed -#! against. -#! - the asset is not well formed. -#! - For fungible faucets: -#! - the amount being burned is greater than the total input to the transaction. -#! - For non-fungible faucets: -#! - the non-fungible asset being burned does not exist or was not provided as input to the -#! transaction via a note or the accounts vault. -``` - -## Invocation hint - -Invocation hint is the temporary comment showing how the procedure is meant to be used. It will help to implement the procedure annotations in future. - -The hint could show how this procedures is invoked: - -- with `exec` -- with `call`/`syscall` -- is not used anywhere - -Example: - -```masm -#! Invocation: call -``` diff --git a/crates/miden-protocol/src/account/code/mod.rs b/crates/miden-protocol/src/account/code/mod.rs index d62d27bc83..2e4b39af2b 100644 --- a/crates/miden-protocol/src/account/code/mod.rs +++ b/crates/miden-protocol/src/account/code/mod.rs @@ -15,7 +15,7 @@ use super::{ Serializable, }; use crate::Word; -use crate::account::AccountComponent; +use crate::account::{AccountCodeInterface, AccountComponent, AccountId}; pub mod procedure; use procedure::{AccountProcedureRoot, PrintableProcedure}; @@ -180,6 +180,13 @@ impl AccountCode { procedures_as_elements(self.procedures()) } + /// Returns the public interface of this account code: the given account ID and the set of + /// procedure roots exposed by this code. + pub fn interface(&self, account_id: AccountId) -> AccountCodeInterface { + AccountCodeInterface::new(account_id, self.procedures.iter().copied().collect()) + .expect("account code procedure count is enforced by AccountCode invariants") + } + /// Returns an iterator of printable representations for all procedures in this account code. /// /// # Returns diff --git a/crates/miden-protocol/src/account/component/mod.rs b/crates/miden-protocol/src/account/component/mod.rs index 3578d3e18b..f6d17591e8 100644 --- a/crates/miden-protocol/src/account/component/mod.rs +++ b/crates/miden-protocol/src/account/component/mod.rs @@ -200,6 +200,12 @@ impl AccountComponent { ) -> Option { self.code.get_procedure_root_by_path(proc_name) } + + /// Returns `true` if `root` is the procedure root of any procedure exported by this + /// component. + pub fn has_procedure(&self, root: AccountProcedureRoot) -> bool { + self.procedures().any(|(proc_root, _)| proc_root == root) + } } impl From for AccountComponentCode { diff --git a/crates/miden-protocol/src/account/delta/delta_op.rs b/crates/miden-protocol/src/account/delta/delta_op.rs new file mode 100644 index 0000000000..2c7a06a58e --- /dev/null +++ b/crates/miden-protocol/src/account/delta/delta_op.rs @@ -0,0 +1,39 @@ +use crate::errors::AssetError; + +/// Describes whether an asset was added or removed in an +/// [`AccountVaultDelta`](crate::account::AccountVaultDelta). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum AssetDeltaOperation { + Add = Self::ADD, + Remove = Self::REMOVE, +} + +impl AssetDeltaOperation { + // The encoding starts at 1 to leave 0 to encode a possible default `None` operation ("nothing + // has changed"). + const ADD: u8 = 1; + const REMOVE: u8 = 2; + + /// Encodes the delta operation as a `u8`. + pub const fn as_u8(&self) -> u8 { + *self as u8 + } +} + +impl TryFrom for AssetDeltaOperation { + type Error = AssetError; + + /// Decodes a delta operation from a `u8`. + /// + /// # Errors + /// + /// Returns an error if the value is not a valid delta operation. + fn try_from(value: u8) -> Result { + match value { + Self::ADD => Ok(Self::Add), + Self::REMOVE => Ok(Self::Remove), + _ => Err(AssetError::UnknownAssetDeltaOperation(value)), + } + } +} diff --git a/crates/miden-protocol/src/account/delta/mod.rs b/crates/miden-protocol/src/account/delta/mod.rs index a02c465315..0a75c3639a 100644 --- a/crates/miden-protocol/src/account/delta/mod.rs +++ b/crates/miden-protocol/src/account/delta/mod.rs @@ -6,6 +6,7 @@ use crate::account::{ AccountCode, AccountId, AccountStorage, + AccountStoragePatch, StorageSlot, StorageSlotType, }; @@ -21,8 +22,8 @@ use crate::utils::serde::{ }; use crate::{Felt, Word, ZERO}; -mod storage; -pub use storage::{AccountStorageDelta, StorageMapDelta, StorageSlotDelta}; +mod delta_op; +pub use delta_op::AssetDeltaOperation; mod vault; pub use vault::{ @@ -39,7 +40,7 @@ pub use vault::{ /// one or more transaction. /// /// The differences are represented as follows: -/// - storage: an [`AccountStorageDelta`] that contains the changes to the account storage. +/// - storage: an [`AccountStoragePatch`] that contains the changes to the account storage. /// - vault: an [`AccountVaultDelta`] object that contains the changes to the account vault. /// - nonce: if the nonce of the account has changed, the _delta_ of the nonce is stored, i.e. the /// value by which the nonce increased. @@ -57,8 +58,8 @@ pub struct AccountDelta { /// The ID of the account to which this delta applies. If the delta is created during /// transaction execution, that is the native account of the transaction. account_id: AccountId, - /// The delta of the account's storage. - storage: AccountStorageDelta, + /// The patch of the account's storage. + storage: AccountStoragePatch, /// The delta of the account's asset vault. vault: AccountVaultDelta, /// The code of a new account (`Some`) or `None` for existing accounts. @@ -69,6 +70,12 @@ pub struct AccountDelta { } impl AccountDelta { + // CONSTANTS + // -------------------------------------------------------------------------------------------- + + /// Domain separator for the account delta commitment header. + const DOMAIN: Felt = Felt::new_unchecked(1); + // CONSTRUCTOR // -------------------------------------------------------------------------------------------- @@ -79,7 +86,7 @@ impl AccountDelta { /// - Returns an error if storage or vault were updated, but the nonce_delta is 0. pub fn new( account_id: AccountId, - storage: AccountStorageDelta, + storage: AccountStoragePatch, vault: AccountVaultDelta, nonce_delta: Felt, ) -> Result { @@ -98,35 +105,6 @@ impl AccountDelta { // PUBLIC MUTATORS // -------------------------------------------------------------------------------------------- - /// Merge another [AccountDelta] into this one. - pub fn merge(&mut self, other: Self) -> Result<(), AccountDeltaError> { - let new_nonce_delta = self.nonce_delta + other.nonce_delta; - - if new_nonce_delta.as_canonical_u64() < self.nonce_delta.as_canonical_u64() { - return Err(AccountDeltaError::NonceIncrementOverflow { - current: self.nonce_delta, - increment: other.nonce_delta, - new: new_nonce_delta, - }); - } - - // TODO(code_upgrades): This should go away once we have proper account code updates in - // deltas. Then, the two code updates can be merged. For now, code cannot be merged - // and this should never happen. - if self.is_full_state() && other.is_full_state() { - return Err(AccountDeltaError::MergingFullStateDeltas); - } - - if let Some(code) = other.code { - self.code = Some(code); - } - - self.nonce_delta = new_nonce_delta; - - self.storage.merge(other.storage)?; - self.vault.merge(other.vault) - } - /// Returns a mutable reference to the account vault delta. pub fn vault_mut(&mut self) -> &mut AccountVaultDelta { &mut self.vault @@ -158,7 +136,7 @@ impl AccountDelta { } /// Returns storage updates for this account delta. - pub fn storage(&self) -> &AccountStorageDelta { + pub fn storage(&self) -> &AccountStoragePatch { &self.storage } @@ -182,8 +160,8 @@ impl AccountDelta { self.code.as_ref() } - /// Converts this storage delta into individual delta components. - pub fn into_parts(self) -> (AccountStorageDelta, AccountVaultDelta, Option, Felt) { + /// Converts this delta into its individual components. + pub fn into_parts(self) -> (AccountStoragePatch, AccountVaultDelta, Option, Felt) { (self.storage, self.vault, self.code, self.nonce_delta) } @@ -191,40 +169,38 @@ impl AccountDelta { /// /// ## Computation /// - /// The delta is a sequential hash over a vector of field elements which starts out empty and - /// is appended to in the following way. Whenever sorting is expected, it is that of a [`Word`]. + /// The delta commitment is a sequential hash over a vector of field elements which starts out + /// empty and is appended to in the following way. Whenever sorting is expected, it is that + /// of a [`Word`]. /// - /// - Append `[[nonce_delta, 0, account_id_suffix, account_id_prefix], EMPTY_WORD]`, where - /// `account_id_{prefix,suffix}` are the prefix and suffix felts of the native account id and - /// `nonce_delta` is the value by which the nonce was incremented. - /// - Fungible Asset Delta - /// - For each **updated** fungible asset, sorted by its vault key, whose amount delta is - /// **non-zero**: - /// - Append `[domain = 1, was_added, faucet_id_suffix_and_metadata, faucet_id_prefix]` - /// where `faucet_id_suffix_and_metadata` is the faucet ID suffix with asset metadata - /// (including the callbacks flag) encoded in the lower 8 bits. - /// - Append `[amount_delta, 0, 0, 0]` where `amount_delta` is the delta by which the - /// fungible asset's amount has changed and `was_added` is a boolean flag indicating - /// whether the amount was added (1) or subtracted (0). - /// - Non-Fungible Asset Delta - /// - For each **updated** non-fungible asset, sorted by its vault key: - /// - Append `[domain = 1, was_added, faucet_id_suffix, faucet_id_prefix]` where `was_added` - /// is a boolean flag indicating whether the asset was added (1) or removed (0). Note that - /// the domain is the same for assets since `faucet_id_suffix` and `faucet_id_prefix` are - /// at the same position in the layout for both assets, and, by design, they are never the - /// same for fungible and non-fungible assets. - /// - Append `[hash0, hash1, hash2, hash3]`, i.e. the non-fungible asset. + /// - Append `[[domain = 1, nonce_delta, account_id_suffix, account_id_prefix], EMPTY_WORD]`, + /// where `account_id_{prefix,suffix}` are the prefix and suffix felts of the native account + /// id, `nonce_delta` is the value by which the nonce was incremented, and `domain = 1` + /// identifies the header as the start of an account delta commitment. + /// - Asset Delta + /// - For each **added** asset, sorted by its vault key: + /// - Append `[ASSET_KEY, ASSET_VALUE]`. + /// - Append `[domain = 3, delta_op = 1, num_added_assets, 0]` if `num_added_assets != 0` + /// where `num_added_assets` is the number of added assets and `delta_op` is set to `1` + /// indicating asset addition. + /// - For each **removed** asset, sorted by its vault key: + /// - Append `[ASSET_KEY, ASSET_VALUE]`. + /// - Append `[domain = 3, delta_op = 2, num_removed_assets, 0]` if `num_removed_assets != 0` + /// where `num_removed_assets` is the number of removed assets and `delta_op` is set to `2` + /// indicating asset removal. + /// - Note that the domain is the same independent of asset addition or removal, since the + /// `delta_op` sufficiently distinguishes the two domains. /// - Storage Slots are sorted by slot ID and are iterated in this order. For each slot **whose /// value has changed**, depending on the slot type: /// - Value Slot - /// - Append `[[domain = 2, 0, slot_id_suffix, slot_id_prefix], NEW_VALUE]` where + /// - Append `[[domain = 5, 0, slot_id_suffix, slot_id_prefix], NEW_VALUE]` where /// `NEW_VALUE` is the new value of the slot and `slot_id_{suffix, prefix}` is the /// identifier of the slot. /// - Map Slot /// - For each key-value pair, sorted by key, whose new value is different from the previous /// value in the map: /// - Append `[KEY, NEW_VALUE]`. - /// - Append `[[domain = 3, num_changed_entries, slot_id_suffix, slot_id_prefix], 0, 0, 0, + /// - Append `[[domain = 6, num_changed_entries, slot_id_suffix, slot_id_prefix], 0, 0, 0, /// 0]`, where `slot_id_{suffix, prefix}` are the slot identifiers and /// `num_changed_entries` is the number of changed key-value pairs in the map. /// - For partial state deltas, the map header must only be included if @@ -276,26 +252,29 @@ impl AccountDelta { /// ```text /// [ /// ID_AND_NONCE, EMPTY_WORD, - /// [/* no fungible asset delta */], - /// [[domain = 1, was_added = 0, faucet_id_suffix, faucet_id_prefix], NON_FUNGIBLE_ASSET], - /// [/* no storage delta */] + /// [ASSET_KEY, ASSET_VALUE], + /// [[domain = 3, delta_op = 1, num_added_assets = 1, 0], EMPTY_WORD], + /// [/* no removed assets delta */], + /// [/* no storage patch */] /// ] /// ``` /// /// ```text /// [ /// ID_AND_NONCE, EMPTY_WORD, - /// [/* no fungible asset delta */], - /// [/* no non-fungible asset delta */], - /// [[domain = 2, 0, slot_id_suffix = faucet_id_suffix, slot_id_prefix = faucet_id_prefix], NEW_VALUE] + /// [/* no asset delta */], + /// [[domain = 5, 0, slot_id_suffix0, slot_id_prefix0], NEW_VALUE] + /// [[domain = 5, 0, slot_id_suffix1, slot_id_prefix1], NEW_VALUE] /// ] /// ``` /// - /// `NEW_VALUE` is user-controllable so it can be crafted to match `NON_FUNGIBLE_ASSET`. Users - /// would have to choose a slot ID (at account creation time) that is equal to the faucet ID. - /// The domain separator is then the only value that differentiates these two deltas. This shows - /// the importance of placing the domain separators in the same index within each word's layout - /// to ensure users cannot craft an ambiguous delta. + /// - `NEW_VALUE` is user-controlled and can be crafted to match `ASSET_VALUE` or `EMPTY_WORD`. + /// - Slot IDs are user-controlled and can be crafted to match the two most significant elements + /// in the asset key or `num_added_assets` and the fixed 0. + /// - This leaves only the domain separator and the delta_op to differentiate these two deltas. + /// + /// The delta and patch headers further use distinct domain separators (1 and 2 respectively), + /// so a delta and a patch with otherwise identical bodies can never collide. /// /// ### Number of Changed Entries /// @@ -304,20 +283,18 @@ impl AccountDelta { /// ```text /// [ /// ID_AND_NONCE, EMPTY_WORD, - /// [/* no fungible asset delta */], - /// [/* no non-fungible asset delta */], - /// [domain = 3, num_changed_entries = 0, slot_id_suffix = 20, slot_id_prefix = 21, 0, 0, 0, 0] - /// [domain = 3, num_changed_entries = 0, slot_id_suffix = 42, slot_id_prefix = 43, 0, 0, 0, 0] + /// [/* no asset delta */], + /// [domain = 6, num_changed_entries = 0, slot_id_suffix = 20, slot_id_prefix = 21, 0, 0, 0, 0] + /// [domain = 6, num_changed_entries = 0, slot_id_suffix = 42, slot_id_prefix = 43, 0, 0, 0, 0] /// ] /// ``` /// /// ```text /// [ /// ID_AND_NONCE, EMPTY_WORD, - /// [/* no fungible asset delta */], - /// [/* no non-fungible asset delta */], + /// [/* no asset delta */], /// [KEY0, VALUE0], - /// [domain = 3, num_changed_entries = 1, slot_id_suffix = 42, slot_id_prefix = 43, 0, 0, 0, 0] + /// [domain = 6, num_changed_entries = 1, slot_id_suffix = 42, slot_id_prefix = 43, 0, 0, 0, 0] /// ] /// ``` /// @@ -352,7 +329,7 @@ impl TryFrom<&AccountDelta> for Account { /// - If any vault delta operation removes an asset. /// - If any vault delta operation adds an asset that would overflow the maximum representable /// amount. - /// - If any storage delta update violates account storage constraints. + /// - If any storage patch update violates account storage constraints. fn try_from(delta: &AccountDelta) -> Result { if !delta.is_full_state() { return Err(AccountError::PartialStateDeltaToAccount); @@ -362,23 +339,31 @@ impl TryFrom<&AccountDelta> for Account { return Err(AccountError::PartialStateDeltaToAccount); }; + // The asset vault of a new account is empty, so if the delta contains removed assets, the + // delta is invalid. + if delta.vault().removed_assets().count() != 0 { + return Err(AccountError::AssetsRemovedFromNewAccount); + } + let mut vault = AssetVault::default(); - vault.apply_delta(delta.vault()).map_err(AccountError::AssetVaultUpdateError)?; + for added_asset in delta.vault().added_assets() { + vault.insert_asset(added_asset).map_err(AccountError::AssetVaultUpdateError)?; + } // Once we support addition and removal of storage slots, we may be able to change // this to create an empty account and use `Account::apply_delta` instead. // For now, we need to create the initial storage of the account with the same slot types. let mut empty_storage_slots = Vec::new(); - for (slot_name, slot_delta) in delta.storage().slots() { - let slot = match slot_delta.slot_type() { + for (slot_name, slot_patch) in delta.storage().slots() { + let slot = match slot_patch.slot_type() { StorageSlotType::Value => StorageSlot::with_empty_value(slot_name.clone()), StorageSlotType::Map => StorageSlot::with_empty_map(slot_name.clone()), }; empty_storage_slots.push(slot); } let mut storage = AccountStorage::new(empty_storage_slots) - .expect("storage delta should contain a valid number of slots"); - storage.apply_delta(delta.storage())?; + .expect("storage patch should contain a valid number of slots"); + storage.apply_patch(delta.storage())?; // The nonce of the account is the initial nonce of 0 plus the nonce_delta, so the // nonce_delta itself. @@ -405,8 +390,8 @@ impl SequentialCommit for AccountDelta { // ID and Nonce elements.extend_from_slice(&[ + Self::DOMAIN, self.nonce_delta, - ZERO, self.account_id.suffix(), self.account_id.prefix().as_felt(), ]); @@ -415,8 +400,8 @@ impl SequentialCommit for AccountDelta { // Vault Delta self.vault.append_delta_elements(&mut elements); - // Storage Delta - self.storage.append_delta_elements(&mut elements); + // Storage Patch + self.storage.append_patch_elements(&mut elements); debug_assert!( elements.len() % (2 * crate::WORD_SIZE) == 0, @@ -428,68 +413,6 @@ impl SequentialCommit for AccountDelta { } } -// ACCOUNT UPDATE DETAILS -// ================================================================================================ - -/// [`AccountUpdateDetails`] describes the details of one or more transactions executed against an -/// account. -/// -/// In particular, private account changes aren't tracked at all; they are represented as -/// [`AccountUpdateDetails::Private`]. -/// -/// Non-private accounts are tracked as an [`AccountDelta`]. If the account is new, the delta can be -/// converted into an [`Account`]. If not, the delta can be applied to the existing account using -/// [`Account::apply_delta`]. -/// -/// Note that these details can represent the changes from one or more transactions in which case -/// the deltas of each transaction are merged together using [`AccountDelta::merge`]. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum AccountUpdateDetails { - /// The state update details of a private account is not publicly accessible. - Private, - - /// The state update details of non-private accounts. - Delta(AccountDelta), -} - -impl AccountUpdateDetails { - /// Returns `true` if the account update details are for private account. - pub fn is_private(&self) -> bool { - matches!(self, Self::Private) - } - - /// Merges the `other` update into this one. - /// - /// This account update is assumed to come before the other. - pub fn merge(self, other: AccountUpdateDetails) -> Result { - let merged_update = match (self, other) { - (AccountUpdateDetails::Private, AccountUpdateDetails::Private) => { - AccountUpdateDetails::Private - }, - (AccountUpdateDetails::Delta(mut delta), AccountUpdateDetails::Delta(new_delta)) => { - delta.merge(new_delta)?; - AccountUpdateDetails::Delta(delta) - }, - (left, right) => { - return Err(AccountDeltaError::IncompatibleAccountUpdates { - left_update_type: left.as_tag_str(), - right_update_type: right.as_tag_str(), - }); - }, - }; - - Ok(merged_update) - } - - /// Returns the tag of the [`AccountUpdateDetails`] as a string for inclusion in error messages. - pub(crate) const fn as_tag_str(&self) -> &'static str { - match self { - AccountUpdateDetails::Private => "private", - AccountUpdateDetails::Delta(_) => "delta", - } - } -} - // SERIALIZATION // ================================================================================================ @@ -514,7 +437,7 @@ impl Serializable for AccountDelta { impl Deserializable for AccountDelta { fn read_from(source: &mut R) -> Result { let account_id = AccountId::read_from(source)?; - let storage = AccountStorageDelta::read_from(source)?; + let storage = AccountStoragePatch::read_from(source)?; let vault = AccountVaultDelta::read_from(source)?; let code = >::read_from(source)?; let nonce_delta = Felt::read_from(source)?; @@ -532,42 +455,6 @@ impl Deserializable for AccountDelta { } } -impl Serializable for AccountUpdateDetails { - fn write_into(&self, target: &mut W) { - match self { - AccountUpdateDetails::Private => { - 0_u8.write_into(target); - }, - AccountUpdateDetails::Delta(delta) => { - 1_u8.write_into(target); - delta.write_into(target); - }, - } - } - - fn get_size_hint(&self) -> usize { - // Size of the serialized enum tag. - let u8_size = 0u8.get_size_hint(); - - match self { - AccountUpdateDetails::Private => u8_size, - AccountUpdateDetails::Delta(account_delta) => u8_size + account_delta.get_size_hint(), - } - } -} - -impl Deserializable for AccountUpdateDetails { - fn read_from(source: &mut R) -> Result { - match u8::read_from(source)? { - 0 => Ok(Self::Private), - 1 => Ok(Self::Delta(AccountDelta::read_from(source)?)), - variant => Err(DeserializationError::InvalidValue(format!( - "Unknown variant {variant} for AccountDetails" - ))), - } - } -} - // HELPER FUNCTIONS // ================================================================================================ @@ -579,7 +466,7 @@ impl Deserializable for AccountUpdateDetails { /// - storage or vault were updated, but the nonce_delta was set to 0. fn validate_nonce( nonce_delta: Felt, - storage: &AccountStorageDelta, + storage: &AccountStoragePatch, vault: &AccountVaultDelta, ) -> Result<(), AccountDeltaError> { if (!storage.is_empty() || !vault.is_empty()) && nonce_delta == ZERO { @@ -596,22 +483,21 @@ fn validate_nonce( mod tests { use assert_matches::assert_matches; - use miden_core::Felt; - use super::{AccountDelta, AccountStorageDelta, AccountVaultDelta}; - use crate::account::delta::AccountUpdateDetails; + use super::{AccountDelta, AccountStoragePatch, AccountVaultDelta}; use crate::account::{ Account, AccountCode, AccountId, AccountStorage, AccountType, - StorageMapDelta, StorageMapKey, + StorageMapPatch, StorageSlotName, }; use crate::asset::{ Asset, + AssetCallbackFlag, AssetVault, FungibleAsset, NonFungibleAsset, @@ -630,61 +516,37 @@ mod tests { fn account_delta_nonce_validation() { let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER).unwrap(); // empty delta - let storage_delta = AccountStorageDelta::new(); + let storage_patch = AccountStoragePatch::new(); let vault_delta = AccountVaultDelta::default(); - AccountDelta::new(account_id, storage_delta.clone(), vault_delta.clone(), ZERO).unwrap(); - AccountDelta::new(account_id, storage_delta.clone(), vault_delta.clone(), ONE).unwrap(); + AccountDelta::new(account_id, storage_patch.clone(), vault_delta.clone(), ZERO).unwrap(); + AccountDelta::new(account_id, storage_patch.clone(), vault_delta.clone(), ONE).unwrap(); // non-empty delta - let storage_delta = AccountStorageDelta::from_iters([StorageSlotName::mock(1)], [], []); + let storage_patch = AccountStoragePatch::from_iters([StorageSlotName::mock(1)], [], []); assert_matches!( - AccountDelta::new(account_id, storage_delta.clone(), vault_delta.clone(), ZERO) + AccountDelta::new(account_id, storage_patch.clone(), vault_delta.clone(), ZERO) .unwrap_err(), AccountDeltaError::NonEmptyStorageOrVaultDeltaWithZeroNonceDelta ); - AccountDelta::new(account_id, storage_delta.clone(), vault_delta.clone(), ONE).unwrap(); + AccountDelta::new(account_id, storage_patch.clone(), vault_delta.clone(), ONE).unwrap(); } #[test] - fn account_delta_nonce_overflow() { - let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER).unwrap(); - let storage_delta = AccountStorageDelta::new(); - let vault_delta = AccountVaultDelta::default(); - - let nonce_delta0 = ONE; - let nonce_delta1 = Felt::try_from(0xffff_ffff_0000_0000u64).unwrap(); - - let mut delta0 = - AccountDelta::new(account_id, storage_delta.clone(), vault_delta.clone(), nonce_delta0) - .unwrap(); - let delta1 = - AccountDelta::new(account_id, storage_delta, vault_delta, nonce_delta1).unwrap(); - - assert_matches!(delta0.merge(delta1).unwrap_err(), AccountDeltaError::NonceIncrementOverflow { - current, increment, new - } => { - assert_eq!(current, nonce_delta0); - assert_eq!(increment, nonce_delta1); - assert_eq!(new, nonce_delta0 + nonce_delta1); - }); - } - - #[test] - fn account_update_details_size_hint() { + fn account_delta_size_hint() { // AccountDelta let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER).unwrap(); - let storage_delta = AccountStorageDelta::new(); + let storage_patch = AccountStoragePatch::new(); let vault_delta = AccountVaultDelta::default(); - assert_eq!(storage_delta.to_bytes().len(), storage_delta.get_size_hint()); + assert_eq!(storage_patch.to_bytes().len(), storage_patch.get_size_hint()); assert_eq!(vault_delta.to_bytes().len(), vault_delta.get_size_hint()); let account_delta = - AccountDelta::new(account_id, storage_delta, vault_delta, ZERO).unwrap(); + AccountDelta::new(account_id, storage_patch, vault_delta, ZERO).unwrap(); assert_eq!(account_delta.to_bytes().len(), account_delta.get_size_hint()); - let storage_delta = AccountStorageDelta::from_iters( + let storage_patch = AccountStoragePatch::from_iters( [StorageSlotName::mock(1)], [ (StorageSlotName::mock(2), Word::from([1, 1, 1, 1u32])), @@ -692,7 +554,7 @@ mod tests { ], [( StorageSlotName::mock(4), - StorageMapDelta::from_iters( + StorageMapPatch::from_iters( [ StorageMapKey::from_array([1, 1, 1, 0]), StorageMapKey::from_array([0, 1, 1, 1]), @@ -707,6 +569,7 @@ mod tests { .account_type(AccountType::Public) .build_with_rng(&mut rand::rng()), vec![6], + AssetCallbackFlag::Disabled, )) .into(); let fungible_2: Asset = FungibleAsset::new( @@ -714,15 +577,16 @@ mod tests { .account_type(AccountType::Public) .build_with_rng(&mut rand::rng()), 10, + AssetCallbackFlag::Disabled, ) .unwrap() .into(); let vault_delta = AccountVaultDelta::from_iters([non_fungible], [fungible_2]); - assert_eq!(storage_delta.to_bytes().len(), storage_delta.get_size_hint()); + assert_eq!(storage_patch.to_bytes().len(), storage_patch.get_size_hint()); assert_eq!(vault_delta.to_bytes().len(), vault_delta.get_size_hint()); - let account_delta = AccountDelta::new(account_id, storage_delta, vault_delta, ONE).unwrap(); + let account_delta = AccountDelta::new(account_id, storage_patch, vault_delta, ONE).unwrap(); assert_eq!(account_delta.to_bytes().len(), account_delta.get_size_hint()); // Account @@ -742,13 +606,5 @@ mod tests { let account = Account::new_existing(account_id, asset_vault, account_storage, account_code, ONE); assert_eq!(account.to_bytes().len(), account.get_size_hint()); - - // AccountUpdateDetails - - let update_details_private = AccountUpdateDetails::Private; - assert_eq!(update_details_private.to_bytes().len(), update_details_private.get_size_hint()); - - let update_details_delta = AccountUpdateDetails::Delta(account_delta); - assert_eq!(update_details_delta.to_bytes().len(), update_details_delta.get_size_hint()); } } diff --git a/crates/miden-protocol/src/account/delta/vault.rs b/crates/miden-protocol/src/account/delta/vault.rs index 871f500dca..4ea52ce520 100644 --- a/crates/miden-protocol/src/account/delta/vault.rs +++ b/crates/miden-protocol/src/account/delta/vault.rs @@ -3,6 +3,8 @@ use alloc::collections::btree_map::Entry; use alloc::string::ToString; use alloc::vec::Vec; +use miden_core::Word; + use super::{ AccountDeltaError, ByteReader, @@ -11,15 +13,13 @@ use super::{ DeserializationError, Serializable, }; +use crate::Felt; +use crate::account::delta::AssetDeltaOperation; use crate::asset::{Asset, AssetVaultKey, FungibleAsset, NonFungibleAsset}; -use crate::{Felt, ONE, ZERO}; // ACCOUNT VAULT DELTA // ================================================================================================ -/// The domain for the assets in the delta commitment. -const DOMAIN_ASSET: Felt = Felt::ONE; - /// [AccountVaultDelta] stores the difference between the initial and final account vault states. /// /// The difference is represented as follows: @@ -33,6 +33,9 @@ pub struct AccountVaultDelta { } impl AccountVaultDelta { + /// Domain separator for assets in the account delta commitment. + pub(in crate::account) const DOMAIN: Felt = Felt::new_unchecked(3); + /// Validates and creates an [AccountVaultDelta] with the given fungible and non-fungible asset /// deltas. /// @@ -73,22 +76,93 @@ impl AccountVaultDelta { } } - /// Merges another delta into this one, overwriting any existing values. - /// - /// The result is validated as part of the merge. - /// - /// # Errors - /// Returns an error if the resulted delta does not pass the validation. - pub fn merge(&mut self, other: Self) -> Result<(), AccountDeltaError> { - self.non_fungible.merge(other.non_fungible)?; - self.fungible.merge(other.fungible) + /// Returns an iterator over the added assets in this delta. + pub fn added_assets(&self) -> impl Iterator + '_ { + self.fungible + .0 + .iter() + .filter(|&(_, &value)| value >= 0) + .map(|(vault_key, &diff)| { + Asset::Fungible( + FungibleAsset::new( + vault_key.faucet_id(), + diff.unsigned_abs(), + vault_key.callback_flag(), + ) + .unwrap(), + ) + }) + .chain( + self.non_fungible + .filter_by_action(NonFungibleDeltaAction::Add) + .map(Asset::NonFungible), + ) + } + + /// Returns an iterator over the removed assets in this delta. + pub fn removed_assets(&self) -> impl Iterator + '_ { + self.fungible + .0 + .iter() + .filter(|&(_, &value)| value < 0) + .map(|(vault_key, &diff)| { + Asset::Fungible( + FungibleAsset::new( + vault_key.faucet_id(), + diff.unsigned_abs(), + vault_key.callback_flag(), + ) + .unwrap(), + ) + }) + .chain( + self.non_fungible + .filter_by_action(NonFungibleDeltaAction::Remove) + .map(Asset::NonFungible), + ) } /// Appends the vault delta to the given `elements` from which the delta commitment will be /// computed. pub(super) fn append_delta_elements(&self, elements: &mut Vec) { - self.fungible().append_delta_elements(elements); - self.non_fungible().append_delta_elements(elements); + // Add added and removed assets to a map to sort by vault key. + + // TODO(unified_delta): Refactor the internal asset delta structure to match the tx kernel + // internals and to make this extra allocation unnecessary. + let added_assets = BTreeMap::from_iter( + self.added_assets().map(|asset| (asset.vault_key(), asset.to_value_word())), + ); + let removed_assets = BTreeMap::from_iter( + self.removed_assets().map(|asset| (asset.vault_key(), asset.to_value_word())), + ); + + Self::add_asset_section(AssetDeltaOperation::Add, added_assets, elements); + Self::add_asset_section(AssetDeltaOperation::Remove, removed_assets, elements); + } + + fn add_asset_section( + delta_op: AssetDeltaOperation, + assets: BTreeMap, + elements: &mut Vec, + ) { + let num_changed_assets = assets.len(); + for (asset_vault_key, asset_value) in assets { + elements.extend_from_slice(asset_vault_key.to_word().as_elements()); + elements.extend_from_slice(asset_value.as_elements()); + } + + if num_changed_assets != 0 { + let num_changed_assets = Felt::try_from(num_changed_assets as u64) + .expect("number of changed assets should not exceed max representable felt"); + + elements.extend_from_slice(&[ + Self::DOMAIN, + Felt::from(delta_op.as_u8()), + num_changed_assets, + Felt::ZERO, + ]); + elements.extend_from_slice(Word::empty().as_elements()); + } } } @@ -126,46 +200,6 @@ impl AccountVaultDelta { Self { fungible, non_fungible } } - - /// Returns an iterator over the added assets in this delta. - pub fn added_assets(&self) -> impl Iterator + '_ { - self.fungible - .0 - .iter() - .filter(|&(_, &value)| value >= 0) - .map(|(vault_key, &diff)| { - Asset::Fungible( - FungibleAsset::new(vault_key.faucet_id(), diff.unsigned_abs()) - .unwrap() - .with_callbacks(vault_key.callback_flag()), - ) - }) - .chain( - self.non_fungible - .filter_by_action(NonFungibleDeltaAction::Add) - .map(Asset::NonFungible), - ) - } - - /// Returns an iterator over the removed assets in this delta. - pub fn removed_assets(&self) -> impl Iterator + '_ { - self.fungible - .0 - .iter() - .filter(|&(_, &value)| value < 0) - .map(|(vault_key, &diff)| { - Asset::Fungible( - FungibleAsset::new(vault_key.faucet_id(), diff.unsigned_abs()) - .unwrap() - .with_callbacks(vault_key.callback_flag()), - ) - }) - .chain( - self.non_fungible - .filter_by_action(NonFungibleDeltaAction::Remove) - .map(Asset::NonFungible), - ) - } } impl Serializable for AccountVaultDelta { @@ -247,25 +281,6 @@ impl FungibleAssetDelta { self.0.iter() } - /// Merges another delta into this one, overwriting any existing values. - /// - /// The result is validated as part of the merge. - /// - /// # Errors - /// Returns an error if the result did not pass validation. - pub fn merge(&mut self, other: Self) -> Result<(), AccountDeltaError> { - // Merge fungible assets. - // - // Track fungible asset amounts - positive and negative. `i64` is not lossy while - // fungibles are restricted to 2^63-1. Overflow is still possible but we check for that. - - for (&vault_key, &amount) in other.0.iter() { - self.add_delta(vault_key, amount)?; - } - - Ok(()) - } - // HELPER FUNCTIONS // --------------------------------------------------------------------------------------------- @@ -316,40 +331,6 @@ impl FungibleAssetDelta { Ok(()) } - - /// Appends the fungible asset vault delta to the given `elements` from which the delta - /// commitment will be computed. - /// - /// Note that the order in which elements are appended should be the link map key ordering. This - /// is fulfilled here because the link map key's most significant element takes precedence over - /// less significant ones. The most significant element in the fungible asset delta is the - /// faucet ID prefix and the delta happens to be sorted by vault keys. Since the faucet ID - /// prefix is unique, it will always decide on the ordering of a link map key, so less - /// significant elements are unimportant. This implicit sort should therefore always match the - /// link map key ordering, however this is subtle and fragile. - pub(super) fn append_delta_elements(&self, elements: &mut Vec) { - for (vault_key, amount_delta) in self.iter() { - // Note that this iterator is guaranteed to never yield zero amounts, so we don't have - // to exclude those explicitly. - debug_assert_ne!( - *amount_delta, 0, - "fungible asset iterator should never yield amount deltas of 0" - ); - - let was_added = if *amount_delta > 0 { ONE } else { ZERO }; - let amount_delta = Felt::try_from(amount_delta.unsigned_abs()) - .expect("amount delta should be less than i64::MAX"); - - let key_word = vault_key.to_word(); - elements.extend_from_slice(&[ - DOMAIN_ASSET, - was_added, - key_word[2], // faucet_id_suffix_and_metadata - key_word[3], // faucet_id_prefix - ]); - elements.extend_from_slice(&[amount_delta, ZERO, ZERO, ZERO]); - } - } } impl Serializable for FungibleAssetDelta { @@ -436,21 +417,6 @@ impl NonFungibleAssetDelta { .map(|(_key, (non_fungible_asset, delta_action))| (non_fungible_asset, delta_action)) } - /// Merges another delta into this one, overwriting any existing values. - /// - /// The result is validated as part of the merge. - /// - /// # Errors - /// Returns an error if duplicate non-fungible assets are added or removed. - pub fn merge(&mut self, other: Self) -> Result<(), AccountDeltaError> { - // Merge non-fungible assets. Each non-fungible asset can cancel others out. - for (&asset, &action) in other.iter() { - self.apply_action(asset, action)?; - } - - Ok(()) - } - // HELPER FUNCTIONS // --------------------------------------------------------------------------------------------- @@ -492,26 +458,6 @@ impl NonFungibleAssetDelta { .filter(move |&(_, (_asset, cur_action))| cur_action == &action) .map(|(_key, (asset, _action))| *asset) } - - /// Appends the non-fungible asset vault delta to the given `elements` from which the delta - /// commitment will be computed. - pub(super) fn append_delta_elements(&self, elements: &mut Vec) { - for (asset, action) in self.iter() { - let was_added = match action { - NonFungibleDeltaAction::Remove => ZERO, - NonFungibleDeltaAction::Add => ONE, - }; - - let key_word = asset.vault_key().to_word(); - elements.extend_from_slice(&[ - DOMAIN_ASSET, - was_added, - key_word[2], // faucet_id_suffix_and_metadata - key_word[3], // faucet_id_prefix - ]); - elements.extend_from_slice(asset.to_value_word().as_elements()); - } - } } impl Serializable for NonFungibleAssetDelta { @@ -570,11 +516,8 @@ pub enum NonFungibleDeltaAction { mod tests { use super::{AccountVaultDelta, Deserializable, Serializable}; use crate::account::AccountId; - use crate::asset::{Asset, FungibleAsset, NonFungibleAsset, NonFungibleAssetDetails}; - use crate::testing::account_id::{ - ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET, - ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, - }; + use crate::asset::{Asset, AssetCallbackFlag, FungibleAsset, NonFungibleAsset}; + use crate::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET; #[test] fn test_serde_account_vault() { @@ -590,93 +533,11 @@ mod tests { #[test] fn test_is_empty_account_vault() { let faucet = AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET).unwrap(); - let asset: Asset = FungibleAsset::new(faucet, 123).unwrap().into(); + let asset: Asset = + FungibleAsset::new(faucet, 123, AssetCallbackFlag::Disabled).unwrap().into(); assert!(AccountVaultDelta::default().is_empty()); assert!(!AccountVaultDelta::from_iters([asset], []).is_empty()); assert!(!AccountVaultDelta::from_iters([], [asset]).is_empty()); } - - #[rstest::rstest] - #[case::pos_pos(50, 50, Some(100))] - #[case::neg_neg(-50, -50, Some(-100))] - #[case::empty_pos(0, 50, Some(50))] - #[case::empty_neg(0, -50, Some(-50))] - #[case::nullify_pos_neg(100, -100, Some(0))] - #[case::nullify_neg_pos(-100, 100, Some(0))] - #[case::overflow(FungibleAsset::MAX_AMOUNT.as_i64(), FungibleAsset::MAX_AMOUNT.as_i64(), None)] - #[case::underflow(-(FungibleAsset::MAX_AMOUNT.as_i64()), -(FungibleAsset::MAX_AMOUNT.as_i64()), None)] - #[test] - fn merge_fungible_aggregation(#[case] x: i64, #[case] y: i64, #[case] expected: Option) { - /// Creates an [AccountVaultDelta] with a single [FungibleAsset] delta. This delta will - /// be added if `amount > 0`, removed if `amount < 0` or entirely missing if `amount == 0`. - fn create_delta_with_fungible(account_id: AccountId, amount: i64) -> AccountVaultDelta { - let asset = FungibleAsset::new(account_id, amount.unsigned_abs()).unwrap().into(); - match amount { - 0 => AccountVaultDelta::default(), - x if x.is_positive() => AccountVaultDelta::from_iters([asset], []), - _ => AccountVaultDelta::from_iters([], [asset]), - } - } - - let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET).unwrap(); - - let mut delta_x = create_delta_with_fungible(account_id, x); - let delta_y = create_delta_with_fungible(account_id, y); - - let result = delta_x.merge(delta_y); - - // None is used to indicate an error is expected. - if let Some(expected) = expected { - let expected = create_delta_with_fungible(account_id, expected); - assert_eq!(result.map(|_| delta_x).unwrap(), expected); - } else { - assert!(result.is_err()); - } - } - - #[rstest::rstest] - #[case::empty_removed(None, Some(false), Ok(Some(false)))] - #[case::empty_added(None, Some(true), Ok(Some(true)))] - #[case::add_remove(Some(true), Some(false), Ok(None))] - #[case::remove_add(Some(false), Some(true), Ok(None))] - #[case::double_add(Some(true), Some(true), Err(()))] - #[case::double_remove(Some(false), Some(false), Err(()))] - #[test] - fn merge_non_fungible_aggregation( - #[case] x: Option, - #[case] y: Option, - #[case] expected: Result, ()>, - ) { - /// Creates an [AccountVaultDelta] with an optional [NonFungibleAsset] delta. This delta - /// will be added if `Some(true)`, removed for `Some(false)` and missing for `None`. - fn create_delta_with_non_fungible( - account_id: AccountId, - added: Option, - ) -> AccountVaultDelta { - let asset: Asset = - NonFungibleAsset::new(&NonFungibleAssetDetails::new(account_id, vec![1, 2, 3])) - .into(); - - match added { - Some(true) => AccountVaultDelta::from_iters([asset], []), - Some(false) => AccountVaultDelta::from_iters([], [asset]), - None => AccountVaultDelta::default(), - } - } - - let account_id = NonFungibleAsset::mock_issuer(); - - let mut delta_x = create_delta_with_non_fungible(account_id, x); - let delta_y = create_delta_with_non_fungible(account_id, y); - - let result = delta_x.merge(delta_y); - - if let Ok(expected) = expected { - let expected = create_delta_with_non_fungible(account_id, expected); - assert_eq!(result.map(|_| delta_x).unwrap(), expected); - } else { - assert!(result.is_err()); - } - } } diff --git a/crates/miden-protocol/src/account/interface/code_interface.rs b/crates/miden-protocol/src/account/interface/code_interface.rs new file mode 100644 index 0000000000..77e9da85f6 --- /dev/null +++ b/crates/miden-protocol/src/account/interface/code_interface.rs @@ -0,0 +1,186 @@ +use alloc::collections::BTreeSet; + +use crate::account::AccountId; +use crate::account::code::AccountCode; +use crate::account::code::procedure::AccountProcedureRoot; +use crate::errors::AccountCodeInterfaceError; + +/// The set of procedures an account exposes. +/// +/// This is the next-generation replacement for the enum-based interface in `miden-standards`. It +/// is intentionally minimal: it only carries the account's ID and the set of procedure roots that +/// make up its callable surface. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountCodeInterface { + account_id: AccountId, + procedures: BTreeSet, +} + +impl AccountCodeInterface { + /// Constructs a new [`AccountCodeInterface`] from the given account ID and set of procedure + /// roots. + /// + /// # Errors + /// + /// Returns an error if the number of procedures is outside the + /// `[AccountCode::MIN_NUM_PROCEDURES, AccountCode::MAX_NUM_PROCEDURES]` range. + pub fn new( + account_id: AccountId, + procedures: BTreeSet, + ) -> Result { + let count = procedures.len(); + if count < AccountCode::MIN_NUM_PROCEDURES { + return Err(AccountCodeInterfaceError::TooFewProcedures { actual: count }); + } + if count > AccountCode::MAX_NUM_PROCEDURES { + return Err(AccountCodeInterfaceError::TooManyProcedures { actual: count }); + } + Ok(Self { account_id, procedures }) + } + + /// Returns the ID of the account this interface belongs to. + pub fn id(&self) -> AccountId { + self.account_id + } + + /// Returns the set of procedure roots exposed by this interface. + pub fn procedures(&self) -> &BTreeSet { + &self.procedures + } + + /// Returns `true` if every procedure root yielded by `procedures` is contained in this + /// interface. + pub fn contains(&self, procedures: impl IntoIterator) -> bool { + procedures + .into_iter() + .all(|procedure_root| self.procedures.contains(&procedure_root)) + } +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use alloc::vec; + use alloc::vec::Vec; + + use rstest::rstest; + + use super::*; + use crate::Word; + use crate::account::{AccountBuilder, PartialAccount}; + use crate::testing::add_component::AddComponent; + use crate::testing::noop_auth_component::NoopAuthComponent; + + fn procedure(value: u32) -> AccountProcedureRoot { + AccountProcedureRoot::from_raw(Word::from([value, value, value, value])) + } + + fn build_code_interface(values: &[u32]) -> AccountCodeInterface { + let procedures: BTreeSet = + values.iter().copied().map(procedure).collect(); + AccountCodeInterface::new(dummy_account_id(), procedures).unwrap() + } + + fn dummy_account_id() -> AccountId { + AccountId::try_from( + crate::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE, + ) + .unwrap() + } + + #[rstest] + #[case::empty(vec![])] + #[case::subset(vec![1, 2])] + #[case::exact(vec![1, 2, 3])] + fn contains_returns_true(#[case] query: Vec) { + let interface = build_code_interface(&[1, 2, 3]); + let query: Vec = query.into_iter().map(procedure).collect(); + assert!(interface.contains(query)); + } + + #[rstest] + #[case::missing_one(vec![1, 4])] + #[case::all_missing(vec![10, 11])] + fn contains_returns_false(#[case] query: Vec) { + let interface = build_code_interface(&[1, 2, 3]); + let query: Vec = query.into_iter().map(procedure).collect(); + assert!(!interface.contains(query)); + } + + #[test] + fn new_rejects_too_few_procedures() { + let procedures: BTreeSet = BTreeSet::new(); + let err = AccountCodeInterface::new(dummy_account_id(), procedures).unwrap_err(); + assert_matches::assert_matches!( + err, + AccountCodeInterfaceError::TooFewProcedures { actual: 0 } + ); + + let one_procedure: BTreeSet = [procedure(1)].into_iter().collect(); + let err = AccountCodeInterface::new(dummy_account_id(), one_procedure).unwrap_err(); + assert_matches::assert_matches!( + err, + AccountCodeInterfaceError::TooFewProcedures { actual: 1 } + ); + } + + #[test] + fn new_rejects_too_many_procedures() { + let procedures: BTreeSet = + (0..=AccountCode::MAX_NUM_PROCEDURES as u32).map(procedure).collect(); + let expected = AccountCode::MAX_NUM_PROCEDURES + 1; + let err = AccountCodeInterface::new(dummy_account_id(), procedures).unwrap_err(); + assert_matches::assert_matches!( + err, + AccountCodeInterfaceError::TooManyProcedures { actual } if actual == expected + ); + } + + #[test] + fn new_accepts_min_and_max_procedures() { + let min: BTreeSet = + (0..AccountCode::MIN_NUM_PROCEDURES as u32).map(procedure).collect(); + AccountCodeInterface::new(dummy_account_id(), min).unwrap(); + + let max: BTreeSet = + (0..AccountCode::MAX_NUM_PROCEDURES as u32).map(procedure).collect(); + AccountCodeInterface::new(dummy_account_id(), max).unwrap(); + } + + #[test] + fn account_code_interface_matches_code_procedures() -> anyhow::Result<()> { + let account = AccountBuilder::new([7; 32]) + .with_auth_component(NoopAuthComponent) + .with_component(AddComponent) + .build()?; + + let code_interface = account.code_interface(); + let expected: BTreeSet = + account.code().procedures().iter().copied().collect(); + + assert_eq!(code_interface.id(), account.id()); + assert_eq!(code_interface.procedures(), &expected); + + Ok(()) + } + + #[test] + fn partial_account_code_interface_matches_code_procedures() -> anyhow::Result<()> { + let account = AccountBuilder::new([8; 32]) + .with_auth_component(NoopAuthComponent) + .with_component(AddComponent) + .build()?; + let partial = PartialAccount::from(&account); + + let code_interface = partial.code_interface(); + let expected: BTreeSet = + partial.code().procedures().iter().copied().collect(); + + assert_eq!(code_interface.id(), partial.id()); + assert_eq!(code_interface.procedures(), &expected); + + Ok(()) + } +} diff --git a/crates/miden-protocol/src/account/interface/mod.rs b/crates/miden-protocol/src/account/interface/mod.rs index 8a5ff7d7b6..76c77d540f 100644 --- a/crates/miden-protocol/src/account/interface/mod.rs +++ b/crates/miden-protocol/src/account/interface/mod.rs @@ -1,2 +1,5 @@ +mod code_interface; +pub use code_interface::AccountCodeInterface; + mod name; pub use name::AccountComponentName; diff --git a/crates/miden-protocol/src/account/mod.rs b/crates/miden-protocol/src/account/mod.rs index 89c228bf90..ad39028f5c 100644 --- a/crates/miden-protocol/src/account/mod.rs +++ b/crates/miden-protocol/src/account/mod.rs @@ -41,18 +41,25 @@ pub mod component; pub use component::{AccountComponent, AccountComponentCode, AccountComponentMetadata}; pub mod interface; -pub use interface::AccountComponentName; +pub use interface::{AccountCodeInterface, AccountComponentName}; + +mod patch; +pub use patch::{ + AccountPatch, + AccountStoragePatch, + AccountUpdateDetails, + AccountVaultPatch, + StorageMapPatch, + StorageSlotPatch, +}; pub mod delta; pub use delta::{ AccountDelta, - AccountStorageDelta, AccountVaultDelta, FungibleAssetDelta, NonFungibleAssetDelta, NonFungibleDeltaAction, - StorageMapDelta, - StorageSlotDelta, }; pub mod storage; @@ -242,6 +249,12 @@ impl Account { &self.code } + /// Returns the public interface of this account: its ID and the set of procedure roots it + /// exposes. + pub fn code_interface(&self) -> AccountCodeInterface { + self.code.interface(self.id()) + } + /// Returns nonce for this account. pub fn nonce(&self) -> Felt { self.nonce @@ -282,34 +295,40 @@ impl Account { // DATA MUTATORS // -------------------------------------------------------------------------------------------- - /// Applies the provided delta to this account. This updates account vault, storage, and nonce - /// to the values specified by the delta. + /// Applies the provided patch to this account. This sets account vault, storage, and nonce to + /// the values specified by the patch. /// /// # Errors /// /// Returns an error if: - /// - [`AccountDelta::is_full_state`] returns `true`, i.e. represents the state of an entire - /// account. Only partial state deltas can be applied to an account. - /// - Applying vault sub-delta to the vault of this account fails. - /// - Applying storage sub-delta to the storage of this account fails. - /// - The nonce specified in the provided delta smaller than or equal to the current account + /// - The patch's account ID does not match this account's ID. + /// - The patch carries account code, i.e. represents a newly created account. Such patches can + /// be converted to accounts directly and cannot be applied to an existing account. + /// - Applying the vault sub-patch to the vault of this account fails. + /// - Applying the storage sub-patch to the storage of this account fails. + /// - The nonce specified in the provided patch is not strictly greater than the current account /// nonce. - pub fn apply_delta(&mut self, delta: &AccountDelta) -> Result<(), AccountError> { - if delta.is_full_state() { - return Err(AccountError::ApplyFullStateDeltaToAccount); + pub fn apply_patch(&mut self, patch: &AccountPatch) -> Result<(), AccountError> { + if patch.id() != self.id { + return Err(AccountError::PatchAccountIdMismatch { + account_id: self.id, + patch_id: patch.id(), + }); + } + + if patch.is_full_state() { + return Err(AccountError::ApplyFullStatePatchToAccount); } - // update vault; we don't check vault delta validity here because `AccountDelta` can contain - // only valid vault deltas self.vault - .apply_delta(delta.vault()) + .apply_patch(patch.vault()) .map_err(AccountError::AssetVaultUpdateError)?; - // update storage - self.storage.apply_delta(delta.storage())?; + self.storage.apply_patch(patch.storage())?; - // update nonce - self.increment_nonce(delta.nonce_delta())?; + if let Some(new_nonce) = patch.final_nonce() { + self.set_nonce(new_nonce)?; + } Ok(()) } @@ -323,12 +342,17 @@ impl Account { pub fn increment_nonce(&mut self, nonce_delta: Felt) -> Result<(), AccountError> { let new_nonce = self.nonce + nonce_delta; + self.set_nonce(new_nonce) + } + + /// Sets the nonce of this account to the provided value. + /// + /// # Errors + /// + /// Returns an error if `new_nonce` is not equal to or greater than the current account nonce. + pub fn set_nonce(&mut self, new_nonce: Felt) -> Result<(), AccountError> { if new_nonce.as_canonical_u64() < self.nonce.as_canonical_u64() { - return Err(AccountError::NonceOverflow { - current: self.nonce, - increment: nonce_delta, - new: new_nonce, - }); + return Err(AccountError::NonceMustIncrease { current: self.nonce, new: new_nonce }); } self.nonce = new_nonce; @@ -382,9 +406,9 @@ impl TryFrom for AccountDelta { .into_slots() .into_iter() .map(StorageSlot::into_parts) - .map(|(slot_name, slot_content)| (slot_name, StorageSlotDelta::from(slot_content))) + .map(|(slot_name, slot_content)| (slot_name, StorageSlotPatch::from(slot_content))) .collect(); - let storage_delta = AccountStorageDelta::from_raw(slot_deltas); + let storage_patch = AccountStoragePatch::from_raw(slot_deltas); let mut fungible_delta = FungibleAssetDelta::default(); let mut non_fungible_delta = NonFungibleAssetDelta::default(); @@ -411,7 +435,7 @@ impl TryFrom for AccountDelta { // SAFETY: As checked earlier, the nonce delta should be greater than 0 allowing for // non-empty state changes. - let delta = AccountDelta::new(id, storage_delta, vault_delta, nonce_delta) + let delta = AccountDelta::new(id, storage_patch, vault_delta, nonce_delta) .expect("nonce_delta should be greater than 0") .with_code(Some(code)); @@ -419,6 +443,48 @@ impl TryFrom for AccountDelta { } } +impl TryFrom for AccountPatch { + type Error = AccountError; + + /// Converts an [`Account`] into an [`AccountPatch`]. + /// + /// # Errors + /// + /// Returns an error if: + /// - the account has a seed. Accounts with seeds have a nonce of 0. Representing such accounts + /// as patches is not possible because patches with a non-empty state change need a + /// `final_nonce` greater than 0. + fn try_from(account: Account) -> Result { + let Account { id, vault, storage, code, nonce, seed } = account; + + if seed.is_some() { + return Err(AccountError::PatchFromAccountWithSeed); + } + + let slot_patches = storage + .into_slots() + .into_iter() + .map(StorageSlot::into_parts) + .map(|(slot_name, slot_content)| (slot_name, StorageSlotPatch::from(slot_content))) + .collect(); + let storage_patch = AccountStoragePatch::from_raw(slot_patches); + + let mut vault_patch = AccountVaultPatch::default(); + for asset in vault.assets() { + vault_patch.insert_asset(asset); + } + + // The account's nonce is the final (absolute) nonce of the patch. Since the seed was + // checked above, the nonce is guaranteed to be greater than zero, so the patch can + // represent non-empty state changes and the `final_nonce == 1` invariant is satisfied + // by passing the account code. + let patch = AccountPatch::new(id, storage_patch, vault_patch, Some(code), Some(nonce)) + .expect("non-seeded account should yield a valid patch"); + + Ok(patch) + } +} + impl SequentialCommit for Account { type Commitment = Word; @@ -515,23 +581,19 @@ mod tests { use miden_crypto::utils::{Deserializable, Serializable}; use miden_crypto::{Felt, Word}; - use super::{ - AccountCode, - AccountDelta, - AccountId, - AccountStorage, - AccountStorageDelta, - AccountVaultDelta, - }; + use super::{AccountCode, AccountDelta, AccountId, AccountStorage, AccountStoragePatch}; use crate::account::{ Account, AccountBuilder, AccountIdVersion, + AccountPatch, AccountType, + AccountVaultDelta, + AccountVaultPatch, PartialAccount, StorageMap, - StorageMapDelta, StorageMapKey, + StorageMapPatch, StorageSlot, StorageSlotContent, StorageSlotName, @@ -539,8 +601,8 @@ mod tests { use crate::asset::{Asset, AssetVault, FungibleAsset, NonFungibleAsset}; use crate::errors::AccountError; use crate::testing::account_id::{ - ACCOUNT_ID_PRIVATE_SENDER, ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE, + ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2, }; use crate::testing::add_component::AddComponent; use crate::testing::noop_auth_component::NoopAuthComponent; @@ -560,20 +622,14 @@ mod tests { #[test] fn test_serde_account_delta() { - let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER).unwrap(); let nonce_delta = Felt::from(2_u32); let asset_0 = FungibleAsset::mock(15); let asset_1 = NonFungibleAsset::mock(&[5, 5, 5]); - let storage_delta = AccountStorageDelta::new() + let storage_patch = AccountStoragePatch::new() .add_cleared_items([StorageSlotName::mock(0)]) .add_updated_values([(StorageSlotName::mock(1), Word::from([1, 2, 3, 4u32]))]); - let account_delta = build_account_delta( - account_id, - vec![asset_1], - vec![asset_0], - nonce_delta, - storage_delta, - ); + let account_delta = + build_account_delta(vec![asset_1], vec![asset_0], nonce_delta, storage_patch); let serialized = account_delta.to_bytes(); let deserialized = AccountDelta::read_from_bytes(&serialized).unwrap(); @@ -581,9 +637,7 @@ mod tests { } #[test] - fn valid_account_delta_is_correctly_applied() { - // build account - let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER).unwrap(); + fn account_patch_is_correctly_applied() -> anyhow::Result<()> { let init_nonce = Felt::from(1_u32); let asset_0 = FungibleAsset::mock(100); let asset_1 = NonFungibleAsset::mock(&[1, 2, 3]); @@ -591,48 +645,40 @@ mod tests { // build storage slots let storage_slot_value_0 = StorageSlotContent::Value(Word::from([1, 2, 3, 4u32])); let storage_slot_value_1 = StorageSlotContent::Value(Word::from([5, 6, 7, 8u32])); + let map_key_0 = StorageMapKey::from_array([101, 102, 103, 104]); + let map_key_1 = StorageMapKey::from_array([105, 106, 107, 108]); + let mut storage_map = StorageMap::with_entries([ - ( - StorageMapKey::from_array([101, 102, 103, 104]), - Word::from([1_u32, 2_u32, 3_u32, 4_u32]), - ), - ( - StorageMapKey::from_array([105, 106, 107, 108]), - Word::from([5_u32, 6_u32, 7_u32, 8_u32]), - ), + (map_key_0, Word::from([1, 2, 3, 4_u32])), + (map_key_1, Word::from([5, 6, 7, 8_u32])), ]) .unwrap(); let storage_slot_map = StorageSlotContent::Map(storage_map.clone()); - let mut account = build_account( + // build account + let initial_account = build_account( vec![asset_0], init_nonce, vec![storage_slot_value_0, storage_slot_value_1, storage_slot_map], ); - // update storage map - let key = StorageMapKey::from_array([101, 102, 103, 104]); let value = Word::from([9, 10, 11, 12u32]); + let updated_map = StorageMapPatch::from_iters([], [(map_key_0, value)]); + storage_map.insert(map_key_0, value).unwrap(); - let updated_map = StorageMapDelta::from_iters([], [(key, value)]); - storage_map.insert(key, value).unwrap(); - - // build account delta - let final_nonce = Felt::from(2_u32); - let storage_delta = AccountStorageDelta::new() + // build account patch + let final_nonce = init_nonce + Felt::ONE; + let storage_patch = AccountStoragePatch::new() .add_cleared_items([StorageSlotName::mock(0)]) .add_updated_values([(StorageSlotName::mock(1), Word::from([1, 2, 3, 4u32]))]) .add_updated_maps([(StorageSlotName::mock(2), updated_map)]); - let account_delta = build_account_delta( - account_id, - vec![asset_1], - vec![asset_0], - final_nonce - init_nonce, - storage_delta, - ); + let account_patch = + build_account_patch(final_nonce, vec![asset_1], vec![asset_0], storage_patch); + + // apply patch and create final_account + let mut account_with_patched = initial_account; - // apply delta and create final_account - account.apply_delta(&account_delta).unwrap(); + account_with_patched.apply_patch(&account_patch)?; let final_account = build_account( vec![asset_1], @@ -644,85 +690,138 @@ mod tests { ], ); - // assert account is what it should be - assert_eq!(account, final_account); + assert_eq!(account_with_patched, final_account); + + Ok(()) } #[test] - #[should_panic] - fn valid_account_delta_with_unchanged_nonce() { - // build account - let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER).unwrap(); + fn apply_patch_rejects_new_account_patch() -> anyhow::Result<()> { + let account_id = AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE)?; let init_nonce = Felt::from(1_u32); - let asset = FungibleAsset::mock(110); - let mut account = - build_account(vec![asset], init_nonce, vec![StorageSlotContent::Value(Word::empty())]); + let mut account = build_account(vec![], init_nonce, vec![]); - // build account delta - let storage_delta = AccountStorageDelta::new() - .add_cleared_items([StorageSlotName::mock(0)]) - .add_updated_values([(StorageSlotName::mock(1), Word::from([1, 2, 3, 4u32]))]); - let account_delta = - build_account_delta(account_id, vec![], vec![asset], init_nonce, storage_delta); + let patch = AccountPatch::new( + account_id, + AccountStoragePatch::new(), + AccountVaultPatch::default(), + Some(AccountCode::mock()), + Some(Felt::from(2_u32)), + )?; + + let err = account.apply_patch(&patch).unwrap_err(); + assert_matches!(err, AccountError::ApplyFullStatePatchToAccount); - // apply delta - account.apply_delta(&account_delta).unwrap() + Ok(()) } #[test] - #[should_panic] - fn valid_account_delta_with_decremented_nonce() { - // build account - let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER).unwrap(); - let init_nonce = Felt::from(2_u32); - let asset = FungibleAsset::mock(100); - let mut account = - build_account(vec![asset], init_nonce, vec![StorageSlotContent::Value(Word::empty())]); - - // build account delta - let final_nonce = Felt::from(1_u32); - let storage_delta = AccountStorageDelta::new() - .add_cleared_items([StorageSlotName::mock(0)]) - .add_updated_values([(StorageSlotName::mock(1), Word::from([1, 2, 3, 4u32]))]); - let account_delta = - build_account_delta(account_id, vec![], vec![asset], final_nonce, storage_delta); + fn apply_patch_rejects_non_increasing_nonce() -> anyhow::Result<()> { + let account_id = AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE)?; + let init_nonce = 5_u32; + let mut account = build_account(vec![], Felt::from(init_nonce), vec![]); - // apply delta - account.apply_delta(&account_delta).unwrap() + // Smaller nonce. + let patch_smaller = AccountPatch::new( + account_id, + AccountStoragePatch::new(), + AccountVaultPatch::default(), + None, + Some(Felt::from(init_nonce - 1)), + )?; + let err = account.apply_patch(&patch_smaller).unwrap_err(); + assert_matches!(err, AccountError::NonceMustIncrease { .. }); + + Ok(()) } #[test] - fn empty_account_delta_with_incremented_nonce() { - // build account - let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER).unwrap(); + fn apply_patch_rejects_id_mismatch() -> anyhow::Result<()> { + let other_account_id = + AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2)?; let init_nonce = Felt::from(1_u32); - let word = Word::from([1, 2, 3, 4u32]); - let storage_slot = StorageSlotContent::Value(word); - let mut account = build_account(vec![], init_nonce, vec![storage_slot]); + let mut account = build_account(vec![], init_nonce, vec![]); + + let patch = AccountPatch::new( + other_account_id, + AccountStoragePatch::default(), + AccountVaultPatch::default(), + None, + Some(Felt::from(2_u32)), + )?; - // build account delta - let nonce_delta = Felt::from(1_u32); - let account_delta = AccountDelta::new( - account_id, - AccountStorageDelta::new(), - AccountVaultDelta::default(), - nonce_delta, - ) - .unwrap(); + let err = account.apply_patch(&patch).unwrap_err(); + assert_matches!(err, AccountError::PatchAccountIdMismatch { .. }); + + Ok(()) + } + + #[test] + fn apply_empty_account_patch() -> anyhow::Result<()> { + let nonce = Felt::from(2u8); + let id = AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap(); + let empty_patch = AccountPatch::new( + id, + AccountStoragePatch::default(), + AccountVaultPatch::default(), + None, + None, + )?; + let init_account = build_account(vec![], nonce, vec![]); + + let mut account_with_patch = init_account.clone(); + account_with_patch.apply_patch(&empty_patch)?; - // apply delta - account.apply_delta(&account_delta).unwrap() + assert_eq!(init_account, account_with_patch, "account should be unchanged"); + + Ok(()) + } + + #[test] + fn apply_empty_account_patch_with_incremented_nonce() -> anyhow::Result<()> { + let initial_nonce = Felt::from(2u8); + let final_nonce = initial_nonce + Felt::ONE; + + let id = AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap(); + let empty_patch = AccountPatch::new( + id, + AccountStoragePatch::default(), + AccountVaultPatch::default(), + None, + Some(final_nonce), + )?; + + let init_account = build_account(vec![], initial_nonce, vec![]); + let final_account = build_account(vec![], final_nonce, vec![]); + + let mut account_with_patch = init_account.clone(); + account_with_patch.apply_patch(&empty_patch)?; + + assert_eq!(final_account, account_with_patch); + + Ok(()) } pub fn build_account_delta( - account_id: AccountId, added_assets: Vec, removed_assets: Vec, nonce_delta: Felt, - storage_delta: AccountStorageDelta, + storage_patch: AccountStoragePatch, ) -> AccountDelta { + let id = AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap(); let vault_delta = AccountVaultDelta::from_iters(added_assets, removed_assets); - AccountDelta::new(account_id, storage_delta, vault_delta, nonce_delta).unwrap() + AccountDelta::new(id, storage_patch, vault_delta, nonce_delta).unwrap() + } + + pub fn build_account_patch( + final_nonce: Felt, + added_assets: Vec, + removed_assets: Vec, + storage_patch: AccountStoragePatch, + ) -> AccountPatch { + let id = AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap(); + let vault_patch = AccountVaultPatch::from_iters(added_assets, removed_assets); + AccountPatch::new(id, storage_patch, vault_patch, None, Some(final_nonce)).unwrap() } pub fn build_account( diff --git a/crates/miden-protocol/src/account/partial.rs b/crates/miden-protocol/src/account/partial.rs index 91d3e38b63..dc8b32e55c 100644 --- a/crates/miden-protocol/src/account/partial.rs +++ b/crates/miden-protocol/src/account/partial.rs @@ -5,7 +5,7 @@ use miden_core::{Felt, ZERO}; use super::{Account, AccountCode, AccountId, PartialStorage}; use crate::Word; -use crate::account::{AccountHeader, validate_account_seed}; +use crate::account::{AccountCodeInterface, AccountHeader, validate_account_seed}; use crate::asset::PartialVault; use crate::crypto::SequentialCommit; use crate::errors::AccountError; @@ -95,6 +95,12 @@ impl PartialAccount { &self.code } + /// Returns the public interface of this account: its ID and the set of procedure roots it + /// exposes. + pub fn code_interface(&self) -> AccountCodeInterface { + self.code.interface(self.id) + } + /// Returns a reference to the partial storage representation of the account. pub fn storage(&self) -> &PartialStorage { &self.partial_storage diff --git a/crates/miden-protocol/src/account/patch/mod.rs b/crates/miden-protocol/src/account/patch/mod.rs new file mode 100644 index 0000000000..c646e6a442 --- /dev/null +++ b/crates/miden-protocol/src/account/patch/mod.rs @@ -0,0 +1,940 @@ +mod vault; + +mod storage; +mod update_details; +use alloc::string::ToString; +use alloc::vec::Vec; + +pub use storage::{AccountStoragePatch, StorageMapPatch, StorageSlotPatch}; +pub use update_details::AccountUpdateDetails; +pub use vault::AccountVaultPatch; + +use crate::account::{ + Account, + AccountCode, + AccountId, + AccountStorage, + StorageSlot, + StorageSlotType, +}; +use crate::asset::AssetVault; +use crate::crypto::SequentialCommit; +use crate::errors::{AccountError, AccountPatchError}; +use crate::utils::serde::{ + ByteReader, + ByteWriter, + Deserializable, + DeserializationError, + Serializable, +}; +use crate::{Felt, Word}; + +/// An [`AccountPatch`] describes the new absolute state of an account after one or more +/// transactions, in contrast to an [`AccountDelta`](crate::account::AccountDelta), which describes +/// the relative change. +/// +/// For example, where a delta might say "remove 50 USDC from the vault", a patch says "the new +/// USDC balance is 100". This means a patch can be applied to compute the new account state +/// without loading the previous state and without invoking any custom asset compose logic (e.g. +/// merge/split procedures defined by the issuing faucet). +/// +/// The presence of the code in a patch signals if the patch is a _full state_ or _partial state_ +/// patch. A full state patch must be converted into an [`Account`] object, while a partial state +/// patch must be applied to an existing [`Account`]. +/// +/// The patch represents updates to the account as follows: +/// - storage: an [`AccountStoragePatch`] containing the new values of changed storage slots and map +/// entries. Storage updates are already absolute per changed entry, so no dedicated patch type is +/// required for storage. +/// - vault: an [`AccountVaultPatch`] containing the new values of changed vault entries. +/// - nonce: the new (absolute) nonce of the account, in contrast to +/// [`AccountDelta::nonce_delta`](crate::account::AccountDelta::nonce_delta) which stores the +/// increment. +/// - code: an [`AccountCode`] for new accounts and `None` for others, with the same semantics as in +/// [`AccountDelta`](crate::account::AccountDelta). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountPatch { + /// The ID of the account to which this patch applies. + account_id: AccountId, + /// The new values of changed storage slots and map entries. + storage: AccountStoragePatch, + /// The new values of changed vault entries. + vault: AccountVaultPatch, + /// The code of a new account (`Some`) or `None` for existing accounts. + code: Option, + /// The new (absolute) nonce of the account. + /// + /// Should be set to `None` if the nonce wasn't updated. + final_nonce: Option, +} + +impl AccountPatch { + // CONSTANTS + // -------------------------------------------------------------------------------------------- + + /// Domain separator for the account patch commitment header. + const DOMAIN: Felt = Felt::new_unchecked(2); + + // CONSTRUCTOR + // -------------------------------------------------------------------------------------------- + + /// Returns a new [`AccountPatch`] instantiated from the provided components. + /// + /// `final_nonce` must be `Some(non_zero_nonce)` if `storage` or `vault` contain any updates, + /// and can be `None` only for empty patches. + /// + /// # Errors + /// + /// Returns an error if: + /// - `final_nonce` is `Some(Felt::ZERO)`. The tx kernel guarantees that an updated nonce is at + /// least one, so a zero nonce is never a valid post-tx-state. Empty patches must be + /// constructed with `None` instead. + /// - `storage` or `vault` contain updates or code is present but `final_nonce` is `None`. The + /// tx kernel mandates that the nonce is incremented whenever account state changes. + /// - `final_nonce` is 1 but `code` is not `Some`. Such a patch describes a new account and + /// should be convertible into a full [`Account`], so account code is required. + pub fn new( + account_id: AccountId, + storage: AccountStoragePatch, + vault: AccountVaultPatch, + code: Option, + final_nonce: Option, + ) -> Result { + // New nonce should never be zero as the tx kernel requires that the nonce must be + // incremented to at least 1 in the account-creating transaction. + // Patches that do not change the account (and the nonce) should pass `None`. + if final_nonce.is_some_and(|final_nonce| final_nonce == Felt::ZERO) { + return Err(AccountPatchError::FinalNonceIsZero); + } + + // If account storage or vault were updated or code is present, the patch represents a state + // change and so the nonce cannot be zero. The tx kernel mandates this (except it does not + // consider code yet). + if (!storage.is_empty() || !vault.is_empty() || code.is_some()) && final_nonce.is_none() { + return Err(AccountPatchError::StateChangeRequiresNonceUpdate); + } + + // Code must be provided for new accounts to be able to reconstruct the full Account. + // New accounts are defined with nonce 0, but here we have the post-creation + // final nonce, so we define new accounts as having final_nonce = 1. + if final_nonce.is_some_and(|final_nonce| final_nonce == Felt::ONE) && code.is_none() { + return Err(AccountPatchError::CodeMustBeProvidedForNewAccounts); + } + + Ok(Self { + account_id, + storage, + vault, + code, + final_nonce, + }) + } + + /// Returns an empty patch for the provided account ID. + pub fn empty(account_id: AccountId) -> Self { + AccountPatch::new( + account_id, + AccountStoragePatch::default(), + AccountVaultPatch::default(), + None, + None, + ) + .expect("empty patch should be valid") + } + + // PUBLIC MUTATORS + // -------------------------------------------------------------------------------------------- + + /// Merges the `other` [`AccountPatch`] into this one with patch semantics: entries present in + /// `other` overwrite their counterparts in `self`, and `other.final_nonce`, if present, + /// becomes the new final nonce. + /// + /// Both patches must apply to the same account, and `other.final_nonce` must be exactly one + /// greater than `self.final_nonce` whenever both are set. The exact `+1` requirement reflects + /// the tx kernel invariants that (a) a state-changing transaction must increment the nonce, + /// and (b) the nonce can be incremented at most once per transaction. As a consequence + /// the patch of the next transaction always lands at `self.final_nonce + 1`. The same nonce in + /// both patches represents a fork and a nonce delta larger than 1 means a missed transaction. + /// + /// # Errors + /// + /// Returns an error if: + /// - the two patches apply to different accounts. + /// - both patches carry a final nonce and the nonce in `other` is not exactly one greater than + /// the nonce in `self`. + /// - both patches are full state patches. + /// - a storage slot is used as different slot types in the two patches. + pub fn merge(&mut self, other: Self) -> Result<(), AccountPatchError> { + if self.account_id != other.account_id { + return Err(AccountPatchError::AccountIdMismatch { + expected: self.account_id, + actual: other.account_id, + }); + } + + match (self.final_nonce, other.final_nonce) { + // Both patches are empty, nothing to merge. + (None, None) => return Ok(()), + + // `self` is empty, so `other` becomes the merged result. + (None, Some(_)) => { + *self = other; + return Ok(()); + }, + + // `other` is empty, nothing to merge. + (Some(_), None) => return Ok(()), + + (Some(current), Some(new)) => { + if new != current + Felt::ONE { + return Err(AccountPatchError::NonceMustIncrementByOne { current, new }); + } + self.final_nonce = Some(new); + }, + } + + // TODO(code_upgrades): This should go away once we have proper account code updates in + // patches. For now, code cannot be merged and this should never happen. + if self.is_full_state() && other.is_full_state() { + return Err(AccountPatchError::MergingFullStatePatches); + } + + if let Some(code) = other.code { + self.code = Some(code); + } + + self.storage.merge(other.storage)?; + self.vault.merge(other.vault); + + Ok(()) + } + + // PUBLIC ACCESSORS + // -------------------------------------------------------------------------------------------- + + /// Returns the account ID to which this patch applies. + pub fn id(&self) -> AccountId { + self.account_id + } + + /// Returns the storage updates of this patch. + pub fn storage(&self) -> &AccountStoragePatch { + &self.storage + } + + /// Returns the vault updates of this patch. + pub fn vault(&self) -> &AccountVaultPatch { + &self.vault + } + + /// Returns a reference to the account code of this patch, if present. + pub fn code(&self) -> Option<&AccountCode> { + self.code.as_ref() + } + + /// Returns the new (absolute) nonce of the account after this patch is applied, or `None` if + /// the nonce wasn't updated. + pub fn final_nonce(&self) -> Option { + self.final_nonce + } + + /// Returns `true` if this patch is a "full state" patch, `false` otherwise, i.e. if it is a + /// "partial state" patch. + /// + /// See the type-level docs for more on this distinction. + pub fn is_full_state(&self) -> bool { + // TODO(code_upgrades): Change this to another detection mechanism once we have code upgrade + // support, at which point the presence of code may not be enough of an indication that a + // patch can be converted to a full account. + // + // The constructor enforces that `code.is_some()` implies `final_nonce.is_some()`, so the + // presence of code alone is sufficient to identify a full state patch. + self.code.is_some() + } + + /// Returns true if this account patch does not contain any vault or storage updates and the + /// nonce wasn't updated. + pub fn is_empty(&self) -> bool { + // The check can be implemented by checking only the nonce, since the constructor validates + // that non-empty storage or vault patches must increment the nonce. + self.final_nonce.is_none() + } + + /// Computes the commitment to the account patch. + /// + /// This is very similar to + /// [`AccountDelta::to_commitment`](crate::account::AccountDelta::to_commitment). See its docs + /// for the rationale, security aspects, and other details. The only differences between + /// these are: + /// - the patch includes the new nonce rather than the nonce delta. + /// - The patch includes the new absolute asset values ([`AccountVaultPatch`]) while the delta + /// includes the relative asset changes + /// ([`AccountVaultDelta`](crate::account::AccountVaultDelta)). + /// + /// ## Computation + /// + /// The patch commitment is a sequential hash over a vector of field elements which starts out + /// empty and is appended to in the following way. Whenever sorting is expected, it is that + /// of a [`Word`]. + /// + /// - Append `[[domain = 2, final_nonce, account_id_suffix, account_id_prefix], EMPTY_WORD]`, + /// where `account_id_{prefix,suffix}` are the prefix and suffix felts of the native account + /// id, `final_nonce` is the new nonce of the account, and `domain = 2` identifies the header + /// as the start of an account patch commitment (distinguishing it from a delta commitment, + /// which uses `domain = 1`). + /// - Asset Patch + /// - For each asset whose value has changed compared to the initial state of the transaction, + /// including if it was removed, sorted by its vault key: + /// - Append `[ASSET_KEY, ASSET_VALUE_OR_EMPTY_WORD]` which are the key and either the value + /// of the asset (for updates) or the empty word (for removals). + /// - Append `[[domain = 4, num_changed_assets, 0, 0], 0, 0, 0, 0]`, where + /// `num_changed_assets` is the number of assets that were appended. Note that this is a + /// distinct domain from the delta asset domain (`3`), so an asset delta and an asset + /// patch can never produce the same commitment. + /// - Storage Slots are sorted by slot ID and are iterated in this order. For each slot **whose + /// value has changed**, depending on the slot type: + /// - Value Slot + /// - Append `[[domain = 5, 0, slot_id_suffix, slot_id_prefix], NEW_VALUE]` where + /// `NEW_VALUE` is the new value of the slot and `slot_id_{suffix, prefix}` is the + /// identifier of the slot. + /// - Map Slot + /// - For each key-value pair, sorted by key, whose new value is different from the previous + /// value in the map: + /// - Append `[KEY, NEW_VALUE]`. + /// - Append `[[domain = 6, num_changed_entries, slot_id_suffix, slot_id_prefix], 0, 0, 0, + /// 0]`, where `slot_id_{suffix, prefix}` are the slot identifiers and + /// `num_changed_entries` is the number of changed key-value pairs in the map. + /// - For partial state deltas, the map header must only be included if + /// `num_changed_entries` is not zero. + /// - For full state deltas, the map header must always be included. + /// + /// Headers for storage map slots and asset patches are appended rather than prepended since the + /// tx kernel cannot efficiently get the number of changed entries before the iteration. + pub fn to_commitment(&self) -> Word { + ::to_commitment(self) + } +} + +impl TryFrom<&AccountPatch> for Account { + type Error = AccountError; + + /// Converts an [`AccountPatch`] into an [`Account`]. + /// + /// Conceptually, this applies the patch onto an empty account. Only patches that fully + /// describe an account (i.e. carry account code and a final nonce) can be converted; see + /// [`AccountPatch`] for details. + /// + /// # Errors + /// + /// Returns an error if: + /// - The patch does not carry account code or a final nonce. + /// - Applying the vault patch to an empty vault fails. + /// - Applying the storage patch to the reconstructed initial storage fails. + fn try_from(patch: &AccountPatch) -> Result { + if !patch.is_full_state() { + return Err(AccountError::PartialStatePatchToAccount); + } + + // The constructor guarantees that a full state patch carries both code and a final nonce. + let code = patch.code().cloned().expect("full state patch must carry code"); + let nonce = patch.final_nonce().expect("full state patch must carry final nonce"); + + let mut vault = AssetVault::default(); + vault.apply_patch(patch.vault()).map_err(AccountError::AssetVaultUpdateError)?; + + let mut empty_storage_slots = Vec::new(); + for (slot_name, slot_patch) in patch.storage().slots() { + let slot = match slot_patch.slot_type() { + StorageSlotType::Value => StorageSlot::with_empty_value(slot_name.clone()), + StorageSlotType::Map => StorageSlot::with_empty_map(slot_name.clone()), + }; + empty_storage_slots.push(slot); + } + let mut storage = AccountStorage::new(empty_storage_slots) + .expect("storage patch should contain a valid number of slots"); + storage.apply_patch(patch.storage())?; + + Account::new(patch.id(), vault, storage, code, nonce, None) + } +} + +impl SequentialCommit for AccountPatch { + type Commitment = Word; + + /// Reduces the patch to a sequence of field elements. + /// + /// See [AccountPatch::to_commitment()] for more details. + fn to_elements(&self) -> Vec { + // The commitment to an empty patch is defined as the empty word. + if self.is_empty() { + return Vec::new(); + } + + // Minor optimization: At least 8 elements are always added. + let mut elements = Vec::with_capacity(8); + + // ID and Nonce + let final_nonce = self.final_nonce.expect("non-empty patches should have a new nonce set"); + elements.extend_from_slice(&[ + Self::DOMAIN, + final_nonce, + self.account_id.suffix(), + self.account_id.prefix().as_felt(), + ]); + elements.extend_from_slice(Word::empty().as_elements()); + + // Vault patch + self.vault.append_patch_elements(&mut elements); + + // Storage Patch + self.storage.append_patch_elements(&mut elements); + + debug_assert!( + elements.len() % (2 * crate::WORD_SIZE) == 0, + "expected elements to contain an even number of words, but it contained {} elements", + elements.len() + ); + + elements + } +} + +impl Serializable for AccountPatch { + fn write_into(&self, target: &mut W) { + self.account_id.write_into(target); + self.storage.write_into(target); + self.vault.write_into(target); + self.code.write_into(target); + self.final_nonce.write_into(target); + } + + fn get_size_hint(&self) -> usize { + self.account_id.get_size_hint() + + self.storage.get_size_hint() + + self.vault.get_size_hint() + + self.code.get_size_hint() + + self.final_nonce.get_size_hint() + } +} + +impl Deserializable for AccountPatch { + fn read_from(source: &mut R) -> Result { + let account_id = AccountId::read_from(source)?; + let storage = AccountStoragePatch::read_from(source)?; + let vault = AccountVaultPatch::read_from(source)?; + let code = >::read_from(source)?; + let final_nonce = >::read_from(source)?; + + Self::new(account_id, storage, vault, code, final_nonce) + .map_err(|err| DeserializationError::InvalidValue(err.to_string())) + } +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use assert_matches::assert_matches; + use miden_core::serde::Deserializable; + + use super::{AccountPatch, AccountVaultPatch}; + use crate::account::{ + Account, + AccountCode, + AccountId, + AccountStoragePatch, + StorageMapKey, + StorageMapPatch, + StorageSlotName, + }; + use crate::asset::{Asset, AssetCallbackFlag, FungibleAsset, NonFungibleAsset}; + use crate::errors::{AccountError, AccountPatchError}; + use crate::testing::account_id::{ + ACCOUNT_ID_PRIVATE_SENDER, + ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE, + }; + use crate::utils::serde::Serializable; + use crate::{Felt, Word}; + + #[test] + fn account_patch_serde() -> anyhow::Result<()> { + let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER).unwrap(); + let asset_0 = FungibleAsset::mock(100); + let asset_1 = FungibleAsset::new( + ACCOUNT_ID_PRIVATE_SENDER.try_into()?, + 500_000, + AssetCallbackFlag::Disabled, + )? + .into(); + let asset_2 = NonFungibleAsset::mock(&[10]); + let asset_3 = NonFungibleAsset::mock(&[20]); + let vault_patch = AccountVaultPatch::with_assets([asset_0, asset_1, asset_2, asset_3]); + + let storage_patch = AccountStoragePatch::from_iters( + [StorageSlotName::mock(1)], + [ + (StorageSlotName::mock(2), Word::from([1, 1, 1, 1u32])), + (StorageSlotName::mock(3), Word::from([1, 1, 0, 1u32])), + ], + [( + StorageSlotName::mock(4), + StorageMapPatch::from_iters( + [ + StorageMapKey::from_array([1, 1, 1, 0]), + StorageMapKey::from_array([0, 1, 1, 1]), + ], + [(StorageMapKey::from_array([1, 1, 1, 1]), Word::from([1, 1, 1, 1u32]))], + ), + )], + ); + + assert_eq!(storage_patch.to_bytes().len(), storage_patch.get_size_hint()); + assert_eq!(vault_patch.to_bytes().len(), vault_patch.get_size_hint()); + + let account_patch = + AccountPatch::new(account_id, storage_patch, vault_patch, None, Some(Felt::from(5u8)))?; + assert_eq!(AccountPatch::read_from_bytes(&account_patch.to_bytes())?, account_patch); + assert_eq!(account_patch.to_bytes().len(), account_patch.get_size_hint()); + + Ok(()) + } + + /// A `final_nonce` set to `Some(Felt::ZERO)` is rejected: the tx kernel guarantees the nonce of + /// an updated account is at least one, so empty patches must pass `None` instead. + #[test] + fn account_patch_final_nonce_is_zero() -> anyhow::Result<()> { + let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?; + + let error = AccountPatch::new( + account_id, + AccountStoragePatch::new(), + AccountVaultPatch::default(), + None, + Some(Felt::ZERO), + ) + .unwrap_err(); + + assert_matches!(error, AccountPatchError::FinalNonceIsZero); + + Ok(()) + } + + /// A patch that updates storage, the vault, or carries code but leaves `final_nonce` as `None` + /// is rejected, since any account state change requires the nonce to be incremented. + #[rstest::rstest] + #[case::non_empty_storage( + AccountStoragePatch::from_iters([StorageSlotName::mock(1)], [], []), + AccountVaultPatch::default(), + None, + )] + #[case::non_empty_vault( + AccountStoragePatch::new(), + AccountVaultPatch::with_assets([FungibleAsset::mock(100)]), + None, + )] + #[case::present_code( + AccountStoragePatch::new(), + AccountVaultPatch::default(), + Some(AccountCode::mock()) + )] + #[test] + fn account_patch_with_state_change_requires_nonce_update( + #[case] storage: AccountStoragePatch, + #[case] vault: AccountVaultPatch, + #[case] code: Option, + ) -> anyhow::Result<()> { + let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?; + + let error = AccountPatch::new(account_id, storage, vault, code, None).unwrap_err(); + assert_matches!(error, AccountPatchError::StateChangeRequiresNonceUpdate); + + Ok(()) + } + + /// A patch for a newly created account (`final_nonce = Some(Felt::ONE)`) must include the + /// account code, since otherwise the full account cannot be reconstructed from the patch. + #[test] + fn account_patch_new_account_requires_code() -> anyhow::Result<()> { + let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?; + + let error = AccountPatch::new( + account_id, + AccountStoragePatch::new(), + AccountVaultPatch::default(), + None, + Some(Felt::ONE), + ) + .unwrap_err(); + assert_matches!(error, AccountPatchError::CodeMustBeProvidedForNewAccounts); + + // With the code provided, the same patch should succeed. + AccountPatch::new( + account_id, + AccountStoragePatch::new(), + AccountVaultPatch::default(), + Some(AccountCode::mock()), + Some(Felt::ONE), + )?; + + Ok(()) + } + + /// A patch carrying account code and a final nonce can be converted to an [`Account`] and back, + /// preserving all components. + #[test] + fn account_patch_roundtrip() -> anyhow::Result<()> { + let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?; + let code = AccountCode::mock(); + let asset = FungibleAsset::mock(42); + + let slot_name = StorageSlotName::mock(4); + let slot_value = Word::from([1, 2, 3, 4u32]); + + let storage_patch = + AccountStoragePatch::from_iters([], [(slot_name.clone(), slot_value)], []); + + let patch = AccountPatch::new( + account_id, + storage_patch, + AccountVaultPatch::with_assets([asset]), + Some(code.clone()), + Some(Felt::ONE), + )?; + + let account = Account::try_from(&patch)?; + + assert_eq!(account.id(), account_id); + assert_eq!(account.code(), &code); + assert_eq!(account.nonce(), Felt::ONE); + assert_eq!(account.storage().get_item(&slot_name)?, slot_value); + assert_eq!(account.vault().get(asset.vault_key()), Some(asset)); + + // Roundtrip back to a patch should reproduce the original. + let roundtripped_patch = AccountPatch::try_from(account)?; + assert_eq!(roundtripped_patch, patch); + + Ok(()) + } + + /// A patch lacking code cannot be converted to an [`Account`], whether or not a final nonce + /// is present. + #[rstest::rstest] + #[case::missing_code(Some(Felt::from(2_u32)))] + #[case::empty_patch(None)] + #[test] + fn account_try_from_partial_patch_fails( + #[case] final_nonce: Option, + ) -> anyhow::Result<()> { + let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?; + + let patch = AccountPatch::new( + account_id, + AccountStoragePatch::new(), + AccountVaultPatch::default(), + None, + final_nonce, + )?; + assert_matches!( + Account::try_from(&patch).unwrap_err(), + AccountError::PartialStatePatchToAccount + ); + + Ok(()) + } + + // MERGE TESTS + // ============================================================================================ + + /// Returns a partial-state patch with a single updated value slot and the provided final + /// nonce. + fn partial_patch(account_id: AccountId, final_nonce: u32) -> anyhow::Result { + let storage = AccountStoragePatch::from_iters( + [], + [(StorageSlotName::mock(1), Word::from([1u32, 0, 0, 0]))], + [], + ); + AccountPatch::new( + account_id, + storage, + AccountVaultPatch::default(), + None, + Some(Felt::from(final_nonce)), + ) + .map_err(Into::into) + } + + #[test] + fn account_patch_merge_rejects_id_mismatch() -> anyhow::Result<()> { + let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?; + let other_account_id = + AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE)?; + + let mut patch = partial_patch(account_id, 2)?; + let other = partial_patch(other_account_id, 3)?; + + assert_matches!( + patch.merge(other).unwrap_err(), + AccountPatchError::AccountIdMismatch { expected, actual } => { + assert_eq!(expected, account_id); + assert_eq!(actual, other_account_id); + } + ); + + Ok(()) + } + + #[test] + fn account_patch_merge_rejects_full_state_both() -> anyhow::Result<()> { + let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?; + let code = AccountCode::mock(); + + let mut patch = AccountPatch::new( + account_id, + AccountStoragePatch::new(), + AccountVaultPatch::default(), + Some(code.clone()), + Some(Felt::ONE), + )?; + + let other = AccountPatch::new( + account_id, + AccountStoragePatch::new(), + AccountVaultPatch::default(), + Some(code), + Some(Felt::from(2u32)), + )?; + + assert_matches!( + patch.merge(other).unwrap_err(), + AccountPatchError::MergingFullStatePatches + ); + + Ok(()) + } + + #[rstest::rstest] + #[case::equal(3, 3)] + #[case::smaller(3, 2)] + #[case::gap(3, 5)] + fn account_patch_merge_rejects_non_incrementing_nonce( + #[case] self_nonce: u32, + #[case] other_nonce: u32, + ) -> anyhow::Result<()> { + let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?; + let mut patch = partial_patch(account_id, self_nonce)?; + let other = partial_patch(account_id, other_nonce)?; + + assert_matches!( + patch.merge(other).unwrap_err(), + AccountPatchError::NonceMustIncrementByOne { current, new } => { + assert_eq!(current, Felt::from(self_nonce)); + assert_eq!(new, Felt::from(other_nonce)); + } + ); + + Ok(()) + } + + #[test] + fn account_patch_merge_rejects_storage_slot_type_conflict() -> anyhow::Result<()> { + let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?; + let shared_slot = StorageSlotName::mock(7); + + let value_storage = AccountStoragePatch::from_iters( + [], + [(shared_slot.clone(), Word::from([9u32, 0, 0, 0]))], + [], + ); + let map_storage = AccountStoragePatch::from_iters( + [], + [], + [(shared_slot.clone(), StorageMapPatch::default())], + ); + + let mut patch = AccountPatch::new( + account_id, + value_storage, + AccountVaultPatch::default(), + None, + Some(Felt::from(2u32)), + )?; + let other = AccountPatch::new( + account_id, + map_storage, + AccountVaultPatch::default(), + None, + Some(Felt::from(3u32)), + )?; + + assert_matches!( + patch.merge(other).unwrap_err(), + AccountPatchError::StorageSlotUsedAsDifferentTypes(slot) => { + assert_eq!(slot, shared_slot); + } + ); + + Ok(()) + } + + #[test] + fn account_patch_merge_overrides_vault_entry() -> anyhow::Result<()> { + let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?; + let asset_initial: Asset = FungibleAsset::mock(100); + let asset_updated: Asset = FungibleAsset::mock(250); + assert_eq!(asset_initial.vault_key(), asset_updated.vault_key()); + + let mut patch = AccountPatch::new( + account_id, + AccountStoragePatch::new(), + AccountVaultPatch::with_assets([asset_initial]), + None, + Some(Felt::from(2u32)), + )?; + let other = AccountPatch::new( + account_id, + AccountStoragePatch::new(), + AccountVaultPatch::with_assets([asset_updated]), + None, + Some(Felt::from(3u32)), + )?; + + patch.merge(other)?; + + assert_eq!(patch.vault().num_assets(), 1); + assert_eq!( + patch.vault().as_map().get(&asset_updated.vault_key()).copied(), + Some(asset_updated.to_value_word()) + ); + + Ok(()) + } + + #[test] + fn account_patch_merge_overrides_storage_value() -> anyhow::Result<()> { + let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?; + let slot_name = StorageSlotName::mock(1); + let initial_value = Word::from([1u32, 0, 0, 0]); + let updated_value = Word::from([2u32, 0, 0, 0]); + + let mut patch = AccountPatch::new( + account_id, + AccountStoragePatch::from_iters([], [(slot_name.clone(), initial_value)], []), + AccountVaultPatch::default(), + None, + Some(Felt::from(2u32)), + )?; + let other = AccountPatch::new( + account_id, + AccountStoragePatch::from_iters([], [(slot_name.clone(), updated_value)], []), + AccountVaultPatch::default(), + None, + Some(Felt::from(3u32)), + )?; + + patch.merge(other)?; + + assert_eq!(patch.storage().num_slots(), 1); + assert_eq!(patch.storage().get_value(&slot_name), Some(updated_value)); + + Ok(()) + } + + #[test] + fn account_patch_merge_extends_storage_map() -> anyhow::Result<()> { + let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?; + let map_slot = StorageSlotName::mock(1); + let key_self = StorageMapKey::from_array([1, 0, 0, 0]); + let value_self = Word::from([10u32, 0, 0, 0]); + let key_other = StorageMapKey::from_array([2, 0, 0, 0]); + let value_other = Word::from([20u32, 0, 0, 0]); + + let mut patch = AccountPatch::new( + account_id, + AccountStoragePatch::from_iters( + [], + [], + [(map_slot.clone(), StorageMapPatch::from_iters([], [(key_self, value_self)]))], + ), + AccountVaultPatch::default(), + None, + Some(Felt::from(2u32)), + )?; + let other = AccountPatch::new( + account_id, + AccountStoragePatch::from_iters( + [], + [], + [(map_slot.clone(), StorageMapPatch::from_iters([], [(key_other, value_other)]))], + ), + AccountVaultPatch::default(), + None, + Some(Felt::from(3u32)), + )?; + + patch.merge(other)?; + + assert_eq!(patch.storage().num_slots(), 1); + let merged_map = patch.storage().get_map(&map_slot).expect("map slot should be present"); + assert_eq!(merged_map.entries().len(), 2); + assert_eq!(merged_map.entries().get(&key_self).copied(), Some(value_self)); + assert_eq!(merged_map.entries().get(&key_other).copied(), Some(value_other)); + + Ok(()) + } + + #[test] + fn account_patch_merge_takes_other_code_and_nonce() -> anyhow::Result<()> { + let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?; + let code = AccountCode::mock(); + + let mut patch = partial_patch(account_id, 3)?; + + // `other` is a full-state patch. + let final_nonce = 4u32; + let other = AccountPatch::new( + account_id, + AccountStoragePatch::new(), + AccountVaultPatch::default(), + Some(code.clone()), + Some(Felt::from(final_nonce)), + )?; + + patch.merge(other)?; + + assert!(patch.is_full_state()); + assert_eq!(patch.code(), Some(&code)); + assert_eq!(patch.final_nonce(), Some(Felt::from(final_nonce))); + + Ok(()) + } + + /// A + B_empty = A + #[test] + fn account_patch_merge_empty_other_is_noop() -> anyhow::Result<()> { + let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?; + let mut patch = partial_patch(account_id, 4)?; + let snapshot = patch.clone(); + + let empty = AccountPatch::empty(account_id); + + patch.merge(empty)?; + assert_eq!(patch, snapshot); + + Ok(()) + } + + /// A_empty + B = B + #[test] + fn account_patch_merge_empty_self_adopts_other() -> anyhow::Result<()> { + let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?; + let mut empty = AccountPatch::empty(account_id); + let other = partial_patch(account_id, 7)?; + let expected = other.clone(); + + empty.merge(other)?; + assert_eq!(empty, expected); + + Ok(()) + } +} diff --git a/crates/miden-protocol/src/account/delta/storage.rs b/crates/miden-protocol/src/account/patch/storage.rs similarity index 54% rename from crates/miden-protocol/src/account/delta/storage.rs rename to crates/miden-protocol/src/account/patch/storage.rs index 20c0418bb6..ae8c73c6fe 100644 --- a/crates/miden-protocol/src/account/delta/storage.rs +++ b/crates/miden-protocol/src/account/patch/storage.rs @@ -2,15 +2,6 @@ use alloc::collections::BTreeMap; use alloc::collections::btree_map::Entry; use alloc::vec::Vec; -use super::{ - AccountDeltaError, - ByteReader, - ByteWriter, - Deserializable, - DeserializationError, - Serializable, - Word, -}; use crate::account::{ StorageMap, StorageMapKey, @@ -18,60 +9,79 @@ use crate::account::{ StorageSlotName, StorageSlotType, }; -use crate::{EMPTY_WORD, Felt, ZERO}; +use crate::errors::{AccountDeltaError, AccountPatchError}; +use crate::utils::serde::{ + ByteReader, + ByteWriter, + Deserializable, + DeserializationError, + Serializable, +}; +use crate::{EMPTY_WORD, Felt, Word, ZERO}; -// ACCOUNT STORAGE DELTA +// ACCOUNT STORAGE PATCH // ================================================================================================ -/// The [`AccountStorageDelta`] stores the differences between two states of account storage. +/// The [`AccountStoragePatch`] stores the differences between two states of account storage. /// -/// The delta consists of a map from [`StorageSlotName`] to [`StorageSlotDelta`]. +/// The patch consists of a map from [`StorageSlotName`] to [`StorageSlotPatch`]. #[derive(Clone, Debug, PartialEq, Eq)] -pub struct AccountStorageDelta { +pub struct AccountStoragePatch { /// The updates to the slots of the account. - deltas: BTreeMap, + patches: BTreeMap, } -impl AccountStorageDelta { - /// Creates a new, empty storage delta. +impl AccountStoragePatch { + /// Domain separator for value storage slots in delta and patch commitments. + const DOMAIN_VALUE: Felt = Felt::new_unchecked(5); + + /// Domain separator for map storage slots in delta and patch commitments. + const DOMAIN_MAP: Felt = Felt::new_unchecked(6); + + /// Creates a new, empty storage patch. pub fn new() -> Self { - Self { deltas: BTreeMap::new() } + Self { patches: BTreeMap::new() } } - /// Creates a new storage delta from the provided slot deltas. - pub fn from_raw(deltas: BTreeMap) -> Self { - Self { deltas } + /// Creates a new storage patch from the provided slot patches. + pub fn from_raw(patches: BTreeMap) -> Self { + Self { patches } } - /// Returns the delta for the provided slot name, or `None` if no delta exists. - pub fn get(&self, slot_name: &StorageSlotName) -> Option<&StorageSlotDelta> { - self.deltas.get(slot_name) + /// Returns the patch for the provided slot name, or `None` if no patch exists. + pub fn get(&self, slot_name: &StorageSlotName) -> Option<&StorageSlotPatch> { + self.patches.get(slot_name) } - /// Returns an iterator over the slot deltas. - pub(crate) fn slots(&self) -> impl Iterator { - self.deltas.iter() + /// Returns the number of slot patches. + pub fn num_slots(&self) -> usize { + self.patches.len() } - /// Returns an iterator over the updated values in this storage delta. + /// Returns an iterator over the slot patches. + pub(crate) fn slots(&self) -> impl Iterator { + self.patches.iter() + } + + /// Returns an iterator over the updated values in this storage patch. pub fn values(&self) -> impl Iterator { - self.deltas.iter().filter_map(|(slot_name, slot_delta)| match slot_delta { - StorageSlotDelta::Value(word) => Some((slot_name, word)), - StorageSlotDelta::Map(_) => None, + self.patches.iter().filter_map(|(slot_name, slot_patch)| match slot_patch { + StorageSlotPatch::Value(word) => Some((slot_name, word)), + StorageSlotPatch::Map(_) => None, }) } - /// Returns an iterator over the updated maps in this storage delta. - pub fn maps(&self) -> impl Iterator { - self.deltas.iter().filter_map(|(slot_name, slot_delta)| match slot_delta { - StorageSlotDelta::Value(_) => None, - StorageSlotDelta::Map(map_delta) => Some((slot_name, map_delta)), + /// Returns an iterator over the updated maps in this storage patch. + pub fn maps(&self) -> impl Iterator { + self.patches.iter().filter_map(|(slot_name, slot_patch)| match slot_patch { + StorageSlotPatch::Value(_) => None, + StorageSlotPatch::Map(map_patch) => Some((slot_name, map_patch)), }) } - /// Returns true if storage delta contains no updates. + /// Returns true if storage patch contains no updates. pub fn is_empty(&self) -> bool { - self.deltas.is_empty() + self.patches.is_empty() } /// Tracks a slot change. @@ -88,11 +98,11 @@ impl AccountStorageDelta { slot_name: StorageSlotName, new_slot_value: Word, ) -> Result<(), AccountDeltaError> { - if !self.deltas.get(&slot_name).map(StorageSlotDelta::is_value).unwrap_or(true) { + if !self.patches.get(&slot_name).map(StorageSlotPatch::is_value).unwrap_or(true) { return Err(AccountDeltaError::StorageSlotUsedAsDifferentTypes(slot_name)); } - self.deltas.insert(slot_name, StorageSlotDelta::Value(new_slot_value)); + self.patches.insert(slot_name, StorageSlotPatch::Value(new_slot_value)); Ok(()) } @@ -113,41 +123,42 @@ impl AccountStorageDelta { new_value: Word, ) -> Result<(), AccountDeltaError> { match self - .deltas + .patches .entry(slot_name.clone()) - .or_insert(StorageSlotDelta::Map(StorageMapDelta::default())) + .or_insert(StorageSlotPatch::Map(StorageMapPatch::default())) { - StorageSlotDelta::Value(_) => { + StorageSlotPatch::Value(_) => { return Err(AccountDeltaError::StorageSlotUsedAsDifferentTypes(slot_name)); }, - StorageSlotDelta::Map(storage_map_delta) => { - storage_map_delta.insert(key, new_value); + StorageSlotPatch::Map(storage_map_patch) => { + storage_map_patch.insert(key, new_value); }, }; Ok(()) } - /// Inserts an empty storage map delta for the provided slot name. + /// Inserts an empty storage map patch for the provided slot name. /// - /// This is useful for full state deltas to represent an empty map in the delta. + /// This is useful for full state patches to represent an empty map in the patch. /// - /// This overwrites the existing slot delta, if any. - pub fn insert_empty_map_delta(&mut self, slot_name: StorageSlotName) { - self.deltas.insert(slot_name, StorageSlotDelta::with_empty_map()); + /// This overwrites the existing slot patch, if any. + pub fn insert_empty_map_patch(&mut self, slot_name: StorageSlotName) { + self.patches.insert(slot_name, StorageSlotPatch::with_empty_map()); } - /// Merges another delta into this one, overwriting any existing values. - pub fn merge(&mut self, other: Self) -> Result<(), AccountDeltaError> { - for (slot_name, slot_delta) in other.deltas { - match self.deltas.entry(slot_name.clone()) { + /// Merges another patch into this one, overwriting any existing values. + pub fn merge(&mut self, other: Self) -> Result<(), AccountPatchError> { + for (slot_name, slot_patch) in other.patches { + match self.patches.entry(slot_name.clone()) { Entry::Vacant(vacant_entry) => { - vacant_entry.insert(slot_delta); + vacant_entry.insert(slot_patch); }, Entry::Occupied(mut occupied_entry) => { - occupied_entry.get_mut().merge(slot_delta).ok_or_else(|| { - AccountDeltaError::StorageSlotUsedAsDifferentTypes(slot_name) - })?; + occupied_entry + .get_mut() + .merge(slot_patch) + .ok_or(AccountPatchError::StorageSlotUsedAsDifferentTypes(slot_name))?; }, } } @@ -175,38 +186,35 @@ impl AccountStorageDelta { }) } - /// Appends the storage slots delta to the given `elements` from which the delta commitment will + /// Appends the storage slots patch to the given `elements` from which the delta commitment will /// be computed. - pub(super) fn append_delta_elements(&self, elements: &mut Vec) { - let domain_value = Felt::from_u8(2); - let domain_map = Felt::from_u8(3); - - for (slot_name, slot_delta) in self.deltas.iter() { + pub(in crate::account) fn append_patch_elements(&self, elements: &mut Vec) { + for (slot_name, slot_patch) in self.patches.iter() { let slot_id = slot_name.id(); - match slot_delta { - StorageSlotDelta::Value(new_value) => { + match slot_patch { + StorageSlotPatch::Value(new_value) => { elements.extend_from_slice(&[ - domain_value, + Self::DOMAIN_VALUE, ZERO, slot_id.suffix(), slot_id.prefix(), ]); elements.extend_from_slice(new_value.as_elements()); }, - StorageSlotDelta::Map(map_delta) => { - for (key, value) in map_delta.entries() { + StorageSlotPatch::Map(map_patch) => { + for (key, value) in map_patch.entries() { elements.extend_from_slice(key.as_elements()); elements.extend_from_slice(value.as_elements()); } - let num_changed_entries = Felt::try_from(map_delta.num_entries() as u64) + let num_changed_entries = Felt::try_from(map_patch.num_entries() as u64) .expect( "number of changed entries should not exceed max representable felt", ); elements.extend_from_slice(&[ - domain_map, + Self::DOMAIN_MAP, num_changed_entries, slot_id.suffix(), slot_id.prefix(), @@ -217,19 +225,19 @@ impl AccountStorageDelta { } } - /// Consumes self and returns the underlying map of the storage delta. - pub fn into_map(self) -> BTreeMap { - self.deltas + /// Consumes self and returns the underlying map of the storage patch. + pub fn into_map(self) -> BTreeMap { + self.patches } } -impl Default for AccountStorageDelta { +impl Default for AccountStoragePatch { fn default() -> Self { Self::new() } } -impl Serializable for AccountStorageDelta { +impl Serializable for AccountStoragePatch { fn write_into(&self, target: &mut W) { let num_cleared_values = self.cleared_values().count(); let num_cleared_values = @@ -258,11 +266,11 @@ impl Serializable for AccountStorageDelta { fn get_size_hint(&self) -> usize { let u8_size = 0u8.get_size_hint(); - let mut storage_map_delta_size = 0; - for (slot_name, storage_map_delta) in self.maps() { - // The serialized size of each entry is the combination of slot (key) and the delta + let mut storage_map_patch_size = 0; + for (slot_name, storage_map_patch) in self.maps() { + // The serialized size of each entry is the combination of slot (key) and the patch // (value). - storage_map_delta_size += slot_name.get_size_hint() + storage_map_delta.get_size_hint(); + storage_map_patch_size += slot_name.get_size_hint() + storage_map_patch.get_size_hint(); } // Length Prefixes @@ -273,91 +281,91 @@ impl Serializable for AccountStorageDelta { self.updated_values().fold(0, |acc, (slot_name, slot_value)| { acc + slot_name.get_size_hint() + slot_value.get_size_hint() }) + - // Storage Map Delta - storage_map_delta_size + // Storage Map Patch + storage_map_patch_size } } -impl Deserializable for AccountStorageDelta { +impl Deserializable for AccountStoragePatch { fn read_from(source: &mut R) -> Result { - let mut deltas = BTreeMap::new(); + let mut patches = BTreeMap::new(); let num_cleared_values = source.read_u8()?; for _ in 0..num_cleared_values { let cleared_value: StorageSlotName = source.read()?; - deltas.insert(cleared_value, StorageSlotDelta::with_empty_value()); + patches.insert(cleared_value, StorageSlotPatch::with_empty_value()); } let num_updated_values = source.read_u8()?; for _ in 0..num_updated_values { let (updated_slot, updated_value) = source.read()?; - deltas.insert(updated_slot, StorageSlotDelta::Value(updated_value)); + patches.insert(updated_slot, StorageSlotPatch::Value(updated_value)); } let num_maps = source.read_u8()? as usize; - for read_result in source.read_many_iter::<(StorageSlotName, StorageMapDelta)>(num_maps)? { - let (slot_name, map_delta) = read_result?; - deltas.insert(slot_name, StorageSlotDelta::Map(map_delta)); + for read_result in source.read_many_iter::<(StorageSlotName, StorageMapPatch)>(num_maps)? { + let (slot_name, map_patch) = read_result?; + patches.insert(slot_name, StorageSlotPatch::Map(map_patch)); } - Ok(Self::from_raw(deltas)) + Ok(Self::from_raw(patches)) } } -// STORAGE SLOT DELTA +// STORAGE SLOT PATCH // ================================================================================================ -/// The delta of a single storage slot. +/// The patch of a single storage slot. /// -/// - [`StorageSlotDelta::Value`] contains the value to which a value slot is updated. -/// - [`StorageSlotDelta::Map`] contains the [`StorageMapDelta`] which contains the key-value pairs +/// - [`StorageSlotPatch::Value`] contains the value to which a value slot is updated. +/// - [`StorageSlotPatch::Map`] contains the [`StorageMapPatch`] which contains the key-value pairs /// that were updated in a map slot. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum StorageSlotDelta { +pub enum StorageSlotPatch { Value(Word), - Map(StorageMapDelta), + Map(StorageMapPatch), } -impl StorageSlotDelta { +impl StorageSlotPatch { // CONSTANTS // ---------------------------------------------------------------------------------------- - /// The type byte for value slot deltas. + /// The type byte for value slot patches. const VALUE: u8 = 0; - /// The type byte for map slot deltas. + /// The type byte for map slot patches. const MAP: u8 = 1; // CONSTRUCTORS // ---------------------------------------------------------------------------------------- - /// Returns a new [`StorageSlotDelta::Value`] with an empty value. + /// Returns a new [`StorageSlotPatch::Value`] with an empty value. pub fn with_empty_value() -> Self { Self::Value(Word::empty()) } - /// Returns a new [`StorageSlotDelta::Map`] with an empty map delta. + /// Returns a new [`StorageSlotPatch::Map`] with an empty map patch. pub fn with_empty_map() -> Self { - Self::Map(StorageMapDelta::default()) + Self::Map(StorageMapPatch::default()) } // ACCESSORS // ---------------------------------------------------------------------------------------- - /// Returns the [`StorageSlotType`] of this slot delta. + /// Returns the [`StorageSlotType`] of this slot patch. pub fn slot_type(&self) -> StorageSlotType { match self { - StorageSlotDelta::Value(_) => StorageSlotType::Value, - StorageSlotDelta::Map(_) => StorageSlotType::Map, + StorageSlotPatch::Value(_) => StorageSlotType::Value, + StorageSlotPatch::Map(_) => StorageSlotType::Map, } } - /// Returns `true` if the slot delta is of type [`StorageSlotDelta::Value`], `false` otherwise. + /// Returns `true` if the slot patch is of type [`StorageSlotPatch::Value`], `false` otherwise. pub fn is_value(&self) -> bool { matches!(self, Self::Value(_)) } - /// Returns `true` if the slot delta is of type [`StorageSlotDelta::Map`], `false` otherwise. + /// Returns `true` if the slot patch is of type [`StorageSlotPatch::Map`], `false` otherwise. pub fn is_map(&self) -> bool { matches!(self, Self::Map(_)) } @@ -365,29 +373,29 @@ impl StorageSlotDelta { // MUTATORS // ---------------------------------------------------------------------------------------- - /// Unwraps a value slot delta into a [`Word`]. + /// Unwraps a value slot patch into a [`Word`]. /// /// # Panics /// /// Panics if: - /// - `self` is not of type [`StorageSlotDelta::Value`]. + /// - `self` is not of type [`StorageSlotPatch::Value`]. pub fn unwrap_value(self) -> Word { match self { - StorageSlotDelta::Value(value) => value, - StorageSlotDelta::Map(_) => panic!("called unwrap_value on a map slot delta"), + StorageSlotPatch::Value(value) => value, + StorageSlotPatch::Map(_) => panic!("called unwrap_value on a map slot patch"), } } - /// Unwraps a map slot delta into a [`StorageMapDelta`]. + /// Unwraps a map slot patch into a [`StorageMapPatch`]. /// /// # Panics /// /// Panics if: - /// - `self` is not of type [`StorageSlotDelta::Map`]. - pub fn unwrap_map(self) -> StorageMapDelta { + /// - `self` is not of type [`StorageSlotPatch::Map`]. + pub fn unwrap_map(self) -> StorageMapPatch { match self { - StorageSlotDelta::Value(_) => panic!("called unwrap_map on a value slot delta"), - StorageSlotDelta::Map(map_delta) => map_delta, + StorageSlotPatch::Value(_) => panic!("called unwrap_map on a value slot patch"), + StorageSlotPatch::Map(map_patch) => map_patch, } } @@ -400,11 +408,11 @@ impl StorageSlotDelta { #[must_use] fn merge(&mut self, other: Self) -> Option<()> { match (self, other) { - (StorageSlotDelta::Value(current_value), StorageSlotDelta::Value(new_value)) => { + (StorageSlotPatch::Value(current_value), StorageSlotPatch::Value(new_value)) => { *current_value = new_value; }, - (StorageSlotDelta::Map(current_map_delta), StorageSlotDelta::Map(new_map_delta)) => { - current_map_delta.merge(new_map_delta); + (StorageSlotPatch::Map(current_map_patch), StorageSlotPatch::Map(new_map_patch)) => { + current_map_patch.merge(new_map_patch); }, (..) => { return None; @@ -415,33 +423,33 @@ impl StorageSlotDelta { } } -impl From for StorageSlotDelta { +impl From for StorageSlotPatch { fn from(content: StorageSlotContent) -> Self { match content { - StorageSlotContent::Value(word) => StorageSlotDelta::Value(word), + StorageSlotContent::Value(word) => StorageSlotPatch::Value(word), StorageSlotContent::Map(storage_map) => { - StorageSlotDelta::Map(StorageMapDelta::from(storage_map)) + StorageSlotPatch::Map(StorageMapPatch::from(storage_map)) }, } } } -impl Serializable for StorageSlotDelta { +impl Serializable for StorageSlotPatch { fn write_into(&self, target: &mut W) { match self { - StorageSlotDelta::Value(value) => { + StorageSlotPatch::Value(value) => { target.write_u8(Self::VALUE); target.write(value); }, - StorageSlotDelta::Map(storage_map_delta) => { + StorageSlotPatch::Map(storage_map_patch) => { target.write_u8(Self::MAP); - target.write(storage_map_delta); + target.write(storage_map_patch); }, } } } -impl Deserializable for StorageSlotDelta { +impl Deserializable for StorageSlotPatch { fn read_from(source: &mut R) -> Result { match source.read_u8()? { Self::VALUE => { @@ -449,55 +457,55 @@ impl Deserializable for StorageSlotDelta { Ok(Self::Value(value)) }, Self::MAP => { - let map_delta = source.read()?; - Ok(Self::Map(map_delta)) + let map_patch = source.read()?; + Ok(Self::Map(map_patch)) }, other => Err(DeserializationError::InvalidValue(format!( - "unknown storage slot delta variant {other}" + "unknown storage slot patch variant {other}" ))), } } } -// STORAGE MAP DELTA +// STORAGE MAP PATCH // ================================================================================================ -/// [StorageMapDelta] stores the differences between two states of account storage maps. +/// [StorageMapPatch] stores the differences between two states of account storage maps. /// /// The differences are represented as leaf updates: a map of updated item key ([Word]) to /// value ([Word]). For cleared items the value is [EMPTY_WORD]. #[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct StorageMapDelta(BTreeMap); +pub struct StorageMapPatch(BTreeMap); -impl StorageMapDelta { - /// Creates a new storage map delta from the provided leaves. +impl StorageMapPatch { + /// Creates a new storage map patch from the provided leaves. pub fn new(map: BTreeMap) -> Self { Self(map) } - /// Returns the number of changed entries in this map delta. + /// Returns the number of changed entries in this map patch. pub fn num_entries(&self) -> usize { self.0.len() } - /// Returns a reference to the updated entries in this storage map delta. + /// Returns a reference to the updated entries in this storage map patch. /// /// Note that the returned key is the [`StorageMapKey`]. pub fn entries(&self) -> &BTreeMap { &self.0 } - /// Inserts an item into the storage map delta. + /// Inserts an item into the storage map patch. pub fn insert(&mut self, key: StorageMapKey, value: Word) { self.0.insert(key, value); } - /// Returns true if storage map delta contains no updates. + /// Returns true if storage map patch contains no updates. pub fn is_empty(&self) -> bool { self.0.is_empty() } - /// Merge `other` into this delta, giving precedence to `other`. + /// Merge `other` into this patch, giving precedence to `other`. pub fn merge(&mut self, other: Self) { // Aggregate the changes into a map such that `other` overwrites self. self.0.extend(other.0); @@ -524,8 +532,8 @@ impl StorageMapDelta { } #[cfg(any(feature = "testing", test))] -impl StorageMapDelta { - /// Creates a new [StorageMapDelta] from the provided iterators. +impl StorageMapPatch { + /// Creates a new [StorageMapPatch] from the provided iterators. pub fn from_iters( cleared_leaves: impl IntoIterator, updated_leaves: impl IntoIterator, @@ -541,14 +549,14 @@ impl StorageMapDelta { } } -/// Converts a [StorageMap] into a [StorageMapDelta] for initial delta construction. -impl From for StorageMapDelta { +/// Converts a [StorageMap] into a [StorageMapPatch] for initial patch construction. +impl From for StorageMapPatch { fn from(map: StorageMap) -> Self { - StorageMapDelta::new(map.into_entries().into_iter().collect()) + StorageMapPatch::new(map.into_entries().into_iter().collect()) } } -impl Serializable for StorageMapDelta { +impl Serializable for StorageMapPatch { fn write_into(&self, target: &mut W) { let cleared: Vec<&StorageMapKey> = self.cleared_keys().collect(); let updated: Vec<(&StorageMapKey, &Word)> = self.updated_entries().collect(); @@ -574,7 +582,7 @@ impl Serializable for StorageMapDelta { } } -impl Deserializable for StorageMapDelta { +impl Deserializable for StorageMapPatch { fn read_from(source: &mut R) -> Result { let mut map = BTreeMap::new(); @@ -602,143 +610,172 @@ mod tests { use anyhow::Context; use assert_matches::assert_matches; - use super::{AccountStorageDelta, Deserializable, Serializable}; - use crate::account::{StorageMapDelta, StorageMapKey, StorageSlotDelta, StorageSlotName}; + use super::{AccountStoragePatch, Deserializable, Serializable}; + use crate::account::{StorageMapKey, StorageMapPatch, StorageSlotName, StorageSlotPatch}; use crate::errors::AccountDeltaError; use crate::{ONE, Word}; #[test] - fn account_storage_delta_returns_err_on_slot_type_mismatch() { + fn account_storage_patch_returns_err_on_slot_type_mismatch() { let value_slot_name = StorageSlotName::mock(1); let map_slot_name = StorageSlotName::mock(2); - let mut delta = AccountStorageDelta::from_iters( + let mut patch = AccountStoragePatch::from_iters( [value_slot_name.clone()], [], - [(map_slot_name.clone(), StorageMapDelta::default())], + [(map_slot_name.clone(), StorageMapPatch::default())], ); - let err = delta + let err = patch .set_map_item(value_slot_name.clone(), StorageMapKey::empty(), Word::empty()) .unwrap_err(); assert_matches!(err, AccountDeltaError::StorageSlotUsedAsDifferentTypes(slot_name) => { assert_eq!(value_slot_name, slot_name) }); - let err = delta.set_item(map_slot_name.clone(), Word::empty()).unwrap_err(); + let err = patch.set_item(map_slot_name.clone(), Word::empty()).unwrap_err(); assert_matches!(err, AccountDeltaError::StorageSlotUsedAsDifferentTypes(slot_name) => { assert_eq!(map_slot_name, slot_name) }); } + #[test] + fn account_storage_patch_accessors() { + let value_slot = StorageSlotName::mock(1); + let map_slot = StorageSlotName::mock(2); + let absent_slot = StorageSlotName::mock(3); + + let value = Word::from([1u32, 2, 3, 4]); + let map_key = StorageMapKey::from_array([10, 11, 12, 13]); + let map_value = Word::from([5u32, 6, 7, 8]); + let absent_key = StorageMapKey::from_array([99, 99, 99, 99]); + + let patch = AccountStoragePatch::from_iters( + [], + [(value_slot.clone(), value)], + [(map_slot.clone(), StorageMapPatch::from_iters([], [(map_key, map_value)]))], + ); + + assert_eq!(patch.get_value(&value_slot), Some(value)); + assert_eq!(patch.get_value(&absent_slot), None); + + let map_patch = patch.get_map(&map_slot).unwrap(); + assert_eq!(map_patch.entries().get(&map_key), Some(&map_value)); + assert_eq!(patch.get_map(&absent_slot), None); + + assert_eq!(patch.get_map_value(&map_slot, &map_key), Some(map_value)); + assert_eq!(patch.get_map_value(&map_slot, &absent_key), None); + assert_eq!(patch.get_map_value(&absent_slot, &map_key), None); + } + #[test] fn test_is_empty() { - let storage_delta = AccountStorageDelta::new(); - assert!(storage_delta.is_empty()); + let storage_patch = AccountStoragePatch::new(); + assert!(storage_patch.is_empty()); - let storage_delta = AccountStorageDelta::from_iters([StorageSlotName::mock(1)], [], []); - assert!(!storage_delta.is_empty()); + let storage_patch = AccountStoragePatch::from_iters([StorageSlotName::mock(1)], [], []); + assert!(!storage_patch.is_empty()); - let storage_delta = AccountStorageDelta::from_iters( + let storage_patch = AccountStoragePatch::from_iters( [], [(StorageSlotName::mock(2), Word::from([ONE, ONE, ONE, ONE]))], [], ); - assert!(!storage_delta.is_empty()); + assert!(!storage_patch.is_empty()); - let storage_delta = AccountStorageDelta::from_iters( + let storage_patch = AccountStoragePatch::from_iters( [], [], - [(StorageSlotName::mock(3), StorageMapDelta::default())], + [(StorageSlotName::mock(3), StorageMapPatch::default())], ); - assert!(!storage_delta.is_empty()); + assert!(!storage_patch.is_empty()); } #[test] - fn test_serde_account_storage_delta() { - let storage_delta = AccountStorageDelta::new(); - let serialized = storage_delta.to_bytes(); - let deserialized = AccountStorageDelta::read_from_bytes(&serialized).unwrap(); - assert_eq!(deserialized, storage_delta); - assert_eq!(storage_delta.get_size_hint(), serialized.len()); - - let storage_delta = AccountStorageDelta::from_iters([StorageSlotName::mock(1)], [], []); - let serialized = storage_delta.to_bytes(); - let deserialized = AccountStorageDelta::read_from_bytes(&serialized).unwrap(); - assert_eq!(deserialized, storage_delta); - assert_eq!(storage_delta.get_size_hint(), serialized.len()); - - let storage_delta = AccountStorageDelta::from_iters( + fn test_serde_account_storage_patch() { + let storage_patch = AccountStoragePatch::new(); + let serialized = storage_patch.to_bytes(); + let deserialized = AccountStoragePatch::read_from_bytes(&serialized).unwrap(); + assert_eq!(deserialized, storage_patch); + assert_eq!(storage_patch.get_size_hint(), serialized.len()); + + let storage_patch = AccountStoragePatch::from_iters([StorageSlotName::mock(1)], [], []); + let serialized = storage_patch.to_bytes(); + let deserialized = AccountStoragePatch::read_from_bytes(&serialized).unwrap(); + assert_eq!(deserialized, storage_patch); + assert_eq!(storage_patch.get_size_hint(), serialized.len()); + + let storage_patch = AccountStoragePatch::from_iters( [], [(StorageSlotName::mock(2), Word::from([ONE, ONE, ONE, ONE]))], [], ); - let serialized = storage_delta.to_bytes(); - let deserialized = AccountStorageDelta::read_from_bytes(&serialized).unwrap(); - assert_eq!(deserialized, storage_delta); - assert_eq!(storage_delta.get_size_hint(), serialized.len()); + let serialized = storage_patch.to_bytes(); + let deserialized = AccountStoragePatch::read_from_bytes(&serialized).unwrap(); + assert_eq!(deserialized, storage_patch); + assert_eq!(storage_patch.get_size_hint(), serialized.len()); - let storage_delta = AccountStorageDelta::from_iters( + let storage_patch = AccountStoragePatch::from_iters( [], [], - [(StorageSlotName::mock(3), StorageMapDelta::default())], + [(StorageSlotName::mock(3), StorageMapPatch::default())], ); - let serialized = storage_delta.to_bytes(); - let deserialized = AccountStorageDelta::read_from_bytes(&serialized).unwrap(); - assert_eq!(deserialized, storage_delta); - assert_eq!(storage_delta.get_size_hint(), serialized.len()); + let serialized = storage_patch.to_bytes(); + let deserialized = AccountStoragePatch::read_from_bytes(&serialized).unwrap(); + assert_eq!(deserialized, storage_patch); + assert_eq!(storage_patch.get_size_hint(), serialized.len()); } #[test] - fn test_serde_storage_map_delta() { - let storage_map_delta = StorageMapDelta::default(); - let serialized = storage_map_delta.to_bytes(); - let deserialized = StorageMapDelta::read_from_bytes(&serialized).unwrap(); - assert_eq!(deserialized, storage_map_delta); - - let storage_map_delta = - StorageMapDelta::from_iters([StorageMapKey::from_array([1, 1, 1, 1])], []); - let serialized = storage_map_delta.to_bytes(); - let deserialized = StorageMapDelta::read_from_bytes(&serialized).unwrap(); - assert_eq!(deserialized, storage_map_delta); - - let storage_map_delta = StorageMapDelta::from_iters( + fn test_serde_storage_map_patch() { + let storage_map_patch = StorageMapPatch::default(); + let serialized = storage_map_patch.to_bytes(); + let deserialized = StorageMapPatch::read_from_bytes(&serialized).unwrap(); + assert_eq!(deserialized, storage_map_patch); + + let storage_map_patch = + StorageMapPatch::from_iters([StorageMapKey::from_array([1, 1, 1, 1])], []); + let serialized = storage_map_patch.to_bytes(); + let deserialized = StorageMapPatch::read_from_bytes(&serialized).unwrap(); + assert_eq!(deserialized, storage_map_patch); + + let storage_map_patch = StorageMapPatch::from_iters( [], [(StorageMapKey::empty(), Word::from([ONE, ONE, ONE, ONE]))], ); - let serialized = storage_map_delta.to_bytes(); - let deserialized = StorageMapDelta::read_from_bytes(&serialized).unwrap(); - assert_eq!(deserialized, storage_map_delta); + let serialized = storage_map_patch.to_bytes(); + let deserialized = StorageMapPatch::read_from_bytes(&serialized).unwrap(); + assert_eq!(deserialized, storage_map_patch); } #[test] - fn test_serde_storage_slot_value_delta() { - let slot_delta = StorageSlotDelta::with_empty_value(); - let serialized = slot_delta.to_bytes(); - let deserialized = StorageSlotDelta::read_from_bytes(&serialized).unwrap(); - assert_eq!(deserialized, slot_delta); + fn test_serde_storage_slot_value_patch() { + let slot_patch = StorageSlotPatch::with_empty_value(); + let serialized = slot_patch.to_bytes(); + let deserialized = StorageSlotPatch::read_from_bytes(&serialized).unwrap(); + assert_eq!(deserialized, slot_patch); - let slot_delta = StorageSlotDelta::Value(Word::from([1, 2, 3, 4u32])); - let serialized = slot_delta.to_bytes(); - let deserialized = StorageSlotDelta::read_from_bytes(&serialized).unwrap(); - assert_eq!(deserialized, slot_delta); + let slot_patch = StorageSlotPatch::Value(Word::from([1, 2, 3, 4u32])); + let serialized = slot_patch.to_bytes(); + let deserialized = StorageSlotPatch::read_from_bytes(&serialized).unwrap(); + assert_eq!(deserialized, slot_patch); } #[test] - fn test_serde_storage_slot_map_delta() { - let slot_delta = StorageSlotDelta::with_empty_map(); - let serialized = slot_delta.to_bytes(); - let deserialized = StorageSlotDelta::read_from_bytes(&serialized).unwrap(); - assert_eq!(deserialized, slot_delta); + fn test_serde_storage_slot_map_patch() { + let slot_patch = StorageSlotPatch::with_empty_map(); + let serialized = slot_patch.to_bytes(); + let deserialized = StorageSlotPatch::read_from_bytes(&serialized).unwrap(); + assert_eq!(deserialized, slot_patch); - let map_delta = StorageMapDelta::from_iters( + let map_patch = StorageMapPatch::from_iters( [StorageMapKey::from_array([1, 2, 3, 4])], [(StorageMapKey::from_array([5, 6, 7, 8]), Word::from([3, 4, 5, 6u32]))], ); - let slot_delta = StorageSlotDelta::Map(map_delta); - let serialized = slot_delta.to_bytes(); - let deserialized = StorageSlotDelta::read_from_bytes(&serialized).unwrap(); - assert_eq!(deserialized, slot_delta); + let slot_patch = StorageSlotPatch::Map(map_patch); + let serialized = slot_patch.to_bytes(); + let deserialized = StorageSlotPatch::read_from_bytes(&serialized).unwrap(); + assert_eq!(deserialized, slot_patch); } #[rstest::rstest] @@ -751,23 +788,23 @@ mod tests { #[case] y: Option, #[case] expected: Option, ) -> anyhow::Result<()> { - /// Creates a delta containing the item as an update if Some, else with the item cleared. - fn create_delta(item: Option) -> AccountStorageDelta { + /// Creates a patch containing the item as an update if Some, else with the item cleared. + fn create_patch(item: Option) -> AccountStoragePatch { let slot_name = StorageSlotName::mock(123); let item = item.map(|x| (slot_name.clone(), Word::from([x, 0, 0, 0]))); - AccountStorageDelta::new() + AccountStoragePatch::new() .add_cleared_items(item.is_none().then_some(slot_name.clone())) .add_updated_values(item) } - let mut delta_x = create_delta(x); - let delta_y = create_delta(y); - let expected = create_delta(expected); + let mut patch_x = create_patch(x); + let patch_y = create_patch(y); + let expected = create_patch(expected); - delta_x.merge(delta_y).context("failed to merge deltas")?; + patch_x.merge(patch_y).context("failed to merge patches")?; - assert_eq!(delta_x, expected); + assert_eq!(patch_x, expected); Ok(()) } @@ -778,22 +815,22 @@ mod tests { #[case::some_none(Some(1), None, None)] #[test] fn merge_maps(#[case] x: Option, #[case] y: Option, #[case] expected: Option) { - fn create_delta(value: Option) -> StorageMapDelta { + fn create_patch(value: Option) -> StorageMapPatch { let key = StorageMapKey::from_array([10, 0, 0, 0]); match value { Some(value) => { - StorageMapDelta::from_iters([], [(key, Word::from([value, 0, 0, 0]))]) + StorageMapPatch::from_iters([], [(key, Word::from([value, 0, 0, 0]))]) }, - None => StorageMapDelta::from_iters([key], []), + None => StorageMapPatch::from_iters([key], []), } } - let mut delta_x = create_delta(x); - let delta_y = create_delta(y); - let expected = create_delta(expected); + let mut patch_x = create_patch(x); + let patch_y = create_patch(y); + let expected = create_patch(expected); - delta_x.merge(delta_y); + patch_x.merge(patch_y); - assert_eq!(delta_x, expected); + assert_eq!(patch_x, expected); } } diff --git a/crates/miden-protocol/src/account/patch/update_details.rs b/crates/miden-protocol/src/account/patch/update_details.rs new file mode 100644 index 0000000000..4165923191 --- /dev/null +++ b/crates/miden-protocol/src/account/patch/update_details.rs @@ -0,0 +1,186 @@ +use crate::account::AccountPatch; +use crate::errors::AccountPatchError; +use crate::utils::serde::{ + ByteReader, + ByteWriter, + Deserializable, + DeserializationError, + Serializable, +}; + +// ACCOUNT UPDATE DETAILS +// ================================================================================================ + +/// Describes the update of an account at any aggregation level: as the result of a single +/// transaction, of all transactions in a batch, or of all batches in a block. The representation is +/// the same in all cases, which lets transaction-, batch-, and block-level updates merge into one +/// another. +/// +/// For a public account, the update is represented as an [`AccountPatch`] describing the new +/// absolute state of the changed components of the account. For a private account, the actual state +/// change is not publicly visible and only the state commitments are; this is captured by the +/// [`AccountUpdateDetails::Private`] variant. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AccountUpdateDetails { + /// The state update of a private account is not publicly accessible. + Private, + + /// The state update of a public account, represented as an [`AccountPatch`] describing the new + /// absolute state of the changed components of the account. + Public(AccountPatch), +} + +impl AccountUpdateDetails { + const PRIVATE_TAG: u8 = 0; + const PUBLIC_TAG: u8 = 1; + + /// Returns `true` if the account update details are for a private account, `false` otherwise. + pub fn is_private(&self) -> bool { + matches!(self, Self::Private) + } + + /// Returns `true` if the account update details are for a public account, `false` otherwise. + pub fn is_public(&self) -> bool { + matches!(self, Self::Public(_)) + } + + /// Merges the `other` update into this one. + /// + /// This account update (`self`) must come before `other`, i.e. `self.nonce + 1` must be equal + /// to `other.nonce`. + pub fn merge(self, other: AccountUpdateDetails) -> Result { + let merged_update = match (self, other) { + (AccountUpdateDetails::Private, AccountUpdateDetails::Private) => { + AccountUpdateDetails::Private + }, + (AccountUpdateDetails::Public(mut patch), AccountUpdateDetails::Public(new_patch)) => { + patch.merge(new_patch)?; + AccountUpdateDetails::Public(patch) + }, + (left, right) => { + return Err(AccountPatchError::IncompatibleAccountUpdates { + left_update_type: left.as_tag_str(), + right_update_type: right.as_tag_str(), + }); + }, + }; + + Ok(merged_update) + } + + /// Returns the tag of the [`AccountUpdateDetails`] as a string for inclusion in error messages. + pub(crate) const fn as_tag_str(&self) -> &'static str { + match self { + AccountUpdateDetails::Private => "private", + AccountUpdateDetails::Public(_) => "public", + } + } +} + +// SERIALIZATION +// ================================================================================================ + +impl Serializable for AccountUpdateDetails { + fn write_into(&self, target: &mut W) { + match self { + AccountUpdateDetails::Private => { + Self::PRIVATE_TAG.write_into(target); + }, + AccountUpdateDetails::Public(public) => { + Self::PUBLIC_TAG.write_into(target); + public.write_into(target); + }, + } + } + + fn get_size_hint(&self) -> usize { + // Size of the serialized enum tag. + let u8_size = 0u8.get_size_hint(); + + match self { + AccountUpdateDetails::Private => u8_size, + AccountUpdateDetails::Public(public) => u8_size + public.get_size_hint(), + } + } +} + +impl Deserializable for AccountUpdateDetails { + fn read_from(source: &mut R) -> Result { + match u8::read_from(source)? { + Self::PRIVATE_TAG => Ok(Self::Private), + Self::PUBLIC_TAG => Ok(Self::Public(AccountPatch::read_from(source)?)), + variant => Err(DeserializationError::InvalidValue(format!( + "Unknown variant {variant} for AccountUpdateDetails" + ))), + } + } +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use super::AccountUpdateDetails; + use crate::account::{ + AccountCode, + AccountId, + AccountPatch, + AccountStoragePatch, + AccountVaultPatch, + StorageMapKey, + StorageMapPatch, + StorageSlotName, + }; + use crate::asset::{Asset, FungibleAsset, NonFungibleAsset}; + use crate::testing::account_id::ACCOUNT_ID_PRIVATE_SENDER; + use crate::utils::serde::Serializable; + use crate::{ONE, Word}; + + #[test] + fn account_update_details_size_hint() -> anyhow::Result<()> { + let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?; + + let storage_patch = AccountStoragePatch::from_iters( + [StorageSlotName::mock(1)], + [ + (StorageSlotName::mock(2), Word::from([1, 1, 1, 1u32])), + (StorageSlotName::mock(3), Word::from([1, 1, 0, 1u32])), + ], + [( + StorageSlotName::mock(4), + StorageMapPatch::from_iters( + [ + StorageMapKey::from_array([1, 1, 1, 0]), + StorageMapKey::from_array([0, 1, 1, 1]), + ], + [(StorageMapKey::from_array([1, 1, 1, 1]), Word::from([1, 1, 1, 1u32]))], + ), + )], + ); + + let non_fungible: Asset = NonFungibleAsset::mock(&[6]); + let fungible: Asset = FungibleAsset::mock(42); + let vault_patch = AccountVaultPatch::with_assets([non_fungible, fungible]); + + let account_patch = AccountPatch::new( + account_id, + storage_patch, + vault_patch, + Some(AccountCode::mock()), + Some(ONE), + )?; + + let update_details_private = AccountUpdateDetails::Private; + assert_eq!(update_details_private.to_bytes().len(), update_details_private.get_size_hint()); + + let update_details_patch = AccountUpdateDetails::Public(account_patch); + assert_eq!(update_details_patch.to_bytes().len(), update_details_patch.get_size_hint()); + + // Verify the `Public` flavor differs from `Private` in size, just to confirm both branches + // are exercised. + assert!(update_details_patch.get_size_hint() > update_details_private.get_size_hint()); + + Ok(()) + } +} diff --git a/crates/miden-protocol/src/account/patch/vault.rs b/crates/miden-protocol/src/account/patch/vault.rs new file mode 100644 index 0000000000..7806aac8cd --- /dev/null +++ b/crates/miden-protocol/src/account/patch/vault.rs @@ -0,0 +1,195 @@ +use alloc::collections::BTreeMap; +use alloc::string::ToString; +use alloc::vec::Vec; + +use crate::asset::{Asset, AssetVaultKey}; +use crate::errors::AssetError; +use crate::utils::serde::{ + ByteReader, + ByteWriter, + Deserializable, + DeserializationError, + Serializable, +}; +use crate::{Felt, Word}; + +/// Describes the updates to an [`AssetVault`](crate::account::AssetVault) after a transaction. +/// +/// The patch entries map an [`AssetVaultKey`] to the final [`Word`] value of the asset after the +/// update. If the asset was removed, the value is [`Word::empty`]. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AccountVaultPatch { + entries: BTreeMap, +} + +impl AccountVaultPatch { + /// Domain separator for assets in the account patch commitment. + const DOMAIN: Felt = Felt::new_unchecked(3); + + /// Creates a new vault patch directly from its raw key/value entries. + /// + /// # Errors + /// + /// Returns an error if the provided entries are not valid assets, unless the value is + /// [`Word::empty`]. + pub fn new(entries: BTreeMap) -> Result { + for (key, value) in entries.iter() { + // If the asset was not removed (final value != Word::empty), ensure the provided entry + // is a valid asset. + if !value.is_empty() { + Asset::from_key_value(*key, *value)?; + } + } + + Ok(Self { entries }) + } + + /// Inserts an asset into the patch, overwriting the previous value. + pub fn insert_asset(&mut self, asset: Asset) { + self.entries.insert(asset.vault_key(), asset.to_value_word()); + } + + /// Marks an asset as removed by inserting [`Word::empty`] into the patch. + pub fn remove_asset(&mut self, asset_vault_key: AssetVaultKey) { + self.entries.insert(asset_vault_key, Word::empty()); + } + + /// Returns the number of assets being patched. + pub fn num_assets(&self) -> usize { + self.entries.len() + } + + /// Returns a reference to the underlying map of the vault patch. + pub fn as_map(&self) -> &BTreeMap { + &self.entries + } + + /// Consumes self and returns the underlying map of the vault patch. + pub fn into_map(self) -> BTreeMap { + self.entries + } + + /// Returns an iterator over the asset key-value pairs contained in this patch, sorted by vault + /// key. + pub fn iter(&self) -> impl Iterator { + self.entries.iter() + } + + /// Returns `true` if this vault patch contains no entries. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Merges another vault patch into this one. Entries from `other` overwrite any existing + /// entries in `self` for the same [`AssetVaultKey`]. + pub fn merge(&mut self, other: Self) { + self.entries.extend(other.entries); + } + + /// Appends the vault patch to the given `elements` from which the patch commitment will be + /// computed. + pub(super) fn append_patch_elements(&self, elements: &mut Vec) { + for (asset_vault_key, asset_value_or_empty_word) in self.entries.iter() { + elements.extend_from_slice(asset_vault_key.to_word().as_elements()); + elements.extend_from_slice(asset_value_or_empty_word.as_elements()); + } + + let num_changed_assets = self.entries.len(); + if num_changed_assets != 0 { + let num_changed_assets = Felt::try_from(num_changed_assets as u64) + .expect("number of assets should not exceed max representable felt"); + + elements.extend_from_slice(&[Self::DOMAIN, num_changed_assets, Felt::ZERO, Felt::ZERO]); + elements.extend_from_slice(Word::empty().as_elements()); + } + } + + /// Returns an iterator over the keys of assets that were removed (i.e. whose value is + /// [`Word::empty`]). + pub fn removed_asset_keys(&self) -> impl Iterator { + self.entries + .iter() + .filter(|(_key, value)| value.is_empty()) + .map(|(key, _value)| key) + } + + /// Returns an iterator over the assets that were added or updated (i.e. whose value is not + /// [`Word::empty`]). + pub fn updated_assets(&self) -> impl Iterator { + self.entries + .iter() + .filter(|(_key, value)| !value.is_empty()) + .map(|(key, value)| { + Asset::from_key_value(*key, *value).expect("patch should track valid assets") + }) + } +} + +impl Serializable for AccountVaultPatch { + fn write_into(&self, target: &mut W) { + target.write_usize(self.removed_asset_keys().count()); + target.write_many(self.removed_asset_keys()); + + target.write_usize(self.updated_assets().count()); + target.write_many(self.updated_assets()); + } + + fn get_size_hint(&self) -> usize { + let removed_size = AssetVaultKey::SERIALIZED_SIZE * self.removed_asset_keys().count(); + let updated_size: usize = self.updated_assets().map(|asset| asset.get_size_hint()).sum(); + + 2 * 0usize.get_size_hint() + removed_size + updated_size + } +} + +impl Deserializable for AccountVaultPatch { + fn read_from(source: &mut R) -> Result { + let num_removed_assets = source.read_usize()?; + let mut entries: BTreeMap = source + .read_many_iter::(num_removed_assets)? + .map(|result| result.map(|key| (key, Word::empty()))) + .collect::>()?; + + let num_added_assets = source.read_usize()?; + for result in source.read_many_iter::(num_added_assets)? { + let asset = result?; + entries.insert(asset.vault_key(), asset.to_value_word()); + } + + Self::new(entries).map_err(|err| DeserializationError::InvalidValue(err.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::asset::{AssetCallbackFlag, FungibleAsset, NonFungibleAsset}; + use crate::testing::account_id::ACCOUNT_ID_PRIVATE_SENDER; + + #[test] + fn account_vault_patch_serde() -> anyhow::Result<()> { + let empty_patch = AccountVaultPatch::default(); + let serialized = empty_patch.to_bytes(); + let deserialized = AccountVaultPatch::read_from_bytes(&serialized)?; + assert_eq!(empty_patch, deserialized); + assert_eq!(empty_patch.get_size_hint(), serialized.len()); + + let asset_0: Asset = FungibleAsset::mock(100); + let asset_1: Asset = FungibleAsset::new( + ACCOUNT_ID_PRIVATE_SENDER.try_into()?, + 500_000, + AssetCallbackFlag::Disabled, + )? + .into(); + let asset_2: Asset = NonFungibleAsset::mock(&[10]); + let asset_3: Asset = NonFungibleAsset::mock(&[20]); + let patch = AccountVaultPatch::from_iters([asset_0, asset_1, asset_2], [asset_3]); + + let serialized = patch.to_bytes(); + let deserialized = AccountVaultPatch::read_from_bytes(&serialized)?; + assert_eq!(deserialized, patch); + assert_eq!(patch.get_size_hint(), serialized.len()); + + Ok(()) + } +} diff --git a/crates/miden-protocol/src/account/storage/map/mod.rs b/crates/miden-protocol/src/account/storage/map/mod.rs index 0afdf2da8a..1c430db636 100644 --- a/crates/miden-protocol/src/account/storage/map/mod.rs +++ b/crates/miden-protocol/src/account/storage/map/mod.rs @@ -4,7 +4,7 @@ use miden_core::EMPTY_WORD; use miden_crypto::merkle::EmptySubtreeRoots; use super::{ByteReader, ByteWriter, Deserializable, DeserializationError, Serializable, Word}; -use crate::account::StorageMapDelta; +use crate::account::StorageMapPatch; use crate::crypto::merkle::InnerNodeInfo; use crate::crypto::merkle::smt::{LeafIndex, SMT_DEPTH, Smt, SmtLeaf}; use crate::errors::{AccountError, StorageMapError}; @@ -189,7 +189,7 @@ impl StorageMap { } /// Applies the provided delta to this account storage. - pub fn apply_delta(&mut self, delta: &StorageMapDelta) -> Result { + pub fn apply_patch(&mut self, delta: &StorageMapPatch) -> Result { // apply the updated and cleared leaves to the storage map for (&key, &value) in delta.entries().iter() { self.insert(key, value)?; diff --git a/crates/miden-protocol/src/account/storage/mod.rs b/crates/miden-protocol/src/account/storage/mod.rs index 99a22c9662..00897ba5d5 100644 --- a/crates/miden-protocol/src/account/storage/mod.rs +++ b/crates/miden-protocol/src/account/storage/mod.rs @@ -3,7 +3,7 @@ use alloc::vec::Vec; use super::{ AccountError, - AccountStorageDelta, + AccountStoragePatch, ByteReader, ByteWriter, Deserializable, @@ -44,7 +44,7 @@ pub use partial::PartialStorage; /// necessary to: /// - Simplify lookups of slots in the transaction kernel (using `std::collections::sorted_array` /// from the miden core library) -/// - Allow the [`AccountStorageDelta`] to work only with slot names instead of slot indices. +/// - Allow the [`AccountStoragePatch`] to work only with slot names instead of slot indices. /// - Make it simple to check for duplicates by iterating the slots and checking that no two /// adjacent items have the same slot name. #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -186,12 +186,12 @@ impl AccountStorage { pub fn get_map_item( &self, slot_name: &StorageSlotName, - key: Word, + key: StorageMapKey, ) -> Result { self.get(slot_name) .ok_or_else(|| AccountError::StorageSlotNameNotFound { slot_name: slot_name.clone() }) .and_then(|slot| match slot.content() { - StorageSlotContent::Map(map) => Ok(map.get(&StorageMapKey::from_raw(key))), + StorageSlotContent::Map(map) => Ok(map.get(&key)), _ => Err(AccountError::StorageSlotNotMap(slot_name.clone())), }) } @@ -205,14 +205,14 @@ impl AccountStorage { /// /// Returns an error if: /// - The updates violate storage constraints. - pub(super) fn apply_delta(&mut self, delta: &AccountStorageDelta) -> Result<(), AccountError> { + pub(super) fn apply_patch(&mut self, delta: &AccountStoragePatch) -> Result<(), AccountError> { // Update storage values for (slot_name, &value) in delta.values() { self.set_item(slot_name, value)?; } // Update storage maps - for (slot_name, map_delta) in delta.maps() { + for (slot_name, map_patch) in delta.maps() { let slot = self .get_mut(slot_name) .ok_or(AccountError::StorageSlotNameNotFound { slot_name: slot_name.clone() })?; @@ -222,7 +222,7 @@ impl AccountStorage { _ => return Err(AccountError::StorageSlotNotMap(slot_name.clone())), }; - storage_map.apply_delta(map_delta)?; + storage_map.apply_patch(map_patch)?; } Ok(()) diff --git a/crates/miden-protocol/src/asset/asset_amount.rs b/crates/miden-protocol/src/asset/asset_amount.rs index 521f90d086..532a892362 100644 --- a/crates/miden-protocol/src/asset/asset_amount.rs +++ b/crates/miden-protocol/src/asset/asset_amount.rs @@ -32,6 +32,9 @@ impl AssetAmount { /// The zero amount. pub const ZERO: Self = Self(0); + /// The serialized size of an [`AssetAmount`] in bytes. + pub const SERIALIZED_SIZE: usize = core::mem::size_of::(); + /// Returns a new `AssetAmount` if `amount` does not exceed [`Self::MAX`]. /// /// # Errors diff --git a/crates/miden-protocol/src/asset/fungible.rs b/crates/miden-protocol/src/asset/fungible.rs index c1926318b7..e4294c1b0c 100644 --- a/crates/miden-protocol/src/asset/fungible.rs +++ b/crates/miden-protocol/src/asset/fungible.rs @@ -36,7 +36,7 @@ impl FungibleAsset { /// Specifies the maximum amount a fungible asset can represent. /// /// This number was chosen so that it can be represented as a positive and negative number in a - /// field element. See `account_delta.masm` for more details on how this number was chosen. + /// field element. See `account_update.masm` for more details on how this number was chosen. pub const MAX_AMOUNT: AssetAmount = AssetAmount::MAX; /// The serialized size of a [`FungibleAsset`] in bytes. @@ -51,21 +51,22 @@ impl FungibleAsset { // CONSTRUCTOR // -------------------------------------------------------------------------------------------- - /// Returns a fungible asset instantiated with the provided faucet ID and amount. + /// Returns a fungible asset instantiated with the provided faucet ID, amount and callback + /// flag. /// /// # Errors /// /// Returns an error if: /// - The provided amount is greater than [`FungibleAsset::MAX_AMOUNT`]. - pub fn new(faucet_id: AccountId, amount: u64) -> Result { + pub fn new( + faucet_id: AccountId, + amount: u64, + callbacks: AssetCallbackFlag, + ) -> Result { // TODO: Take AssetAmount as input, then make the function infallible. let amount = AssetAmount::new(amount)?; - Ok(Self { - faucet_id, - amount, - callbacks: AssetCallbackFlag::default(), - }) + Ok(Self { faucet_id, amount, callbacks }) } /// Creates a fungible asset from the provided key and value. @@ -95,10 +96,7 @@ impl FungibleAsset { return Err(AssetError::FungibleAssetValueMostSignificantElementsMustBeZero(value)); } - let mut asset = Self::new(key.faucet_id(), value[0].as_canonical_u64())?; - asset.callbacks = key.callback_flag(); - - Ok(asset) + Self::new(key.faucet_id(), value[0].as_canonical_u64(), key.callback_flag()) } /// Creates a fungible asset from the provided key and value. @@ -114,12 +112,6 @@ impl FungibleAsset { Self::from_key_value(vault_key, value) } - /// Returns a copy of this asset with the given [`AssetCallbackFlag`]. - pub fn with_callbacks(mut self, callbacks: AssetCallbackFlag) -> Self { - self.callbacks = callbacks; - self - } - // PUBLIC ACCESSORS // -------------------------------------------------------------------------------------------- @@ -271,11 +263,8 @@ impl FungibleAsset { let amount: u64 = source.read()?; let callbacks = source.read()?; - let asset = FungibleAsset::new(faucet_id, amount) - .map_err(|err| DeserializationError::InvalidValue(err.to_string()))? - .with_callbacks(callbacks); - - Ok(asset) + FungibleAsset::new(faucet_id, amount, callbacks) + .map_err(|err| DeserializationError::InvalidValue(err.to_string())) } } @@ -358,7 +347,8 @@ mod tests { ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_3, ] { let account_id = AccountId::try_from(fungible_account_id).unwrap(); - let fungible_asset = FungibleAsset::new(account_id, 10).unwrap(); + let fungible_asset = + FungibleAsset::new(account_id, 10, AssetCallbackFlag::Disabled).unwrap(); assert_eq!( fungible_asset, FungibleAsset::read_from_bytes(&fungible_asset.to_bytes()).unwrap() diff --git a/crates/miden-protocol/src/asset/mod.rs b/crates/miden-protocol/src/asset/mod.rs index 33c93d5570..a05d8328ef 100644 --- a/crates/miden-protocol/src/asset/mod.rs +++ b/crates/miden-protocol/src/asset/mod.rs @@ -33,7 +33,14 @@ mod asset_composition; pub use asset_composition::AssetComposition; mod vault; -pub use vault::{AssetId, AssetVault, AssetVaultKey, AssetWitness, PartialVault}; +pub use vault::{ + AssetId, + AssetVault, + AssetVaultKey, + AssetVaultKeyHash, + AssetWitness, + PartialVault, +}; // ASSET // ================================================================================================ @@ -137,16 +144,6 @@ impl Asset { Self::from_key_value(vault_key, value) } - /// Returns a copy of this asset with the given [`AssetCallbackFlag`]. - pub fn with_callbacks(self, callbacks: AssetCallbackFlag) -> Self { - match self { - Asset::Fungible(fungible_asset) => fungible_asset.with_callbacks(callbacks).into(), - Asset::NonFungible(non_fungible_asset) => { - non_fungible_asset.with_callbacks(callbacks).into() - }, - } - } - /// Returns true if this asset is the same as the specified asset. /// /// Two assets are defined to be the same if their vault keys match. @@ -314,7 +311,8 @@ mod tests { ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_3, ] { let account_id = AccountId::try_from(fungible_account_id).unwrap(); - let fungible_asset: Asset = FungibleAsset::new(account_id, 10).unwrap().into(); + let fungible_asset: Asset = + FungibleAsset::new(account_id, 10, AssetCallbackFlag::Disabled).unwrap().into(); assert_eq!(fungible_asset, Asset::read_from_bytes(&fungible_asset.to_bytes()).unwrap()); assert_eq!( fungible_asset, @@ -331,7 +329,11 @@ mod tests { ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET_1, ] { let account_id = AccountId::try_from(non_fungible_account_id).unwrap(); - let details = NonFungibleAssetDetails::new(account_id, vec![1, 2, 3]); + let details = NonFungibleAssetDetails::new( + account_id, + vec![1, 2, 3], + AssetCallbackFlag::Disabled, + ); let non_fungible_asset: Asset = NonFungibleAsset::new(&details).into(); assert_eq!( non_fungible_asset, diff --git a/crates/miden-protocol/src/asset/nonfungible.rs b/crates/miden-protocol/src/asset/nonfungible.rs index 9bcf672614..f38c37dae3 100644 --- a/crates/miden-protocol/src/asset/nonfungible.rs +++ b/crates/miden-protocol/src/asset/nonfungible.rs @@ -52,20 +52,16 @@ impl NonFungibleAsset { /// Returns a non-fungible asset created from the specified asset details. pub fn new(details: &NonFungibleAssetDetails) -> Self { let data_hash = Hasher::hash(details.asset_data()); - Self::from_parts(details.faucet_id(), data_hash) + Self::from_parts(details.faucet_id(), data_hash, details.callbacks()) } - /// Return a non-fungible asset created from the specified faucet and using the provided - /// hash of the asset's data. + /// Return a non-fungible asset created from the specified faucet and callback flag, using the + /// provided hash of the asset's data. /// /// Hash of the asset's data is expected to be computed from the binary representation of the /// asset's data. - pub fn from_parts(faucet_id: AccountId, value: Word) -> Self { - Self { - faucet_id, - value, - callbacks: AssetCallbackFlag::default(), - } + pub fn from_parts(faucet_id: AccountId, value: Word, callbacks: AssetCallbackFlag) -> Self { + Self { faucet_id, value, callbacks } } /// Creates a non-fungible asset from the provided key and value. @@ -93,10 +89,7 @@ impl NonFungibleAsset { }); } - let mut asset = Self::from_parts(key.faucet_id(), value); - asset.callbacks = key.callback_flag(); - - Ok(asset) + Ok(Self::from_parts(key.faucet_id(), value, key.callback_flag())) } /// Creates a non-fungible asset from the provided key and value. @@ -112,12 +105,6 @@ impl NonFungibleAsset { Self::from_key_value(vault_key, value) } - /// Returns a copy of this asset with the given [`AssetCallbackFlag`]. - pub fn with_callbacks(mut self, callbacks: AssetCallbackFlag) -> Self { - self.callbacks = callbacks; - self - } - // ACCESSORS // -------------------------------------------------------------------------------------------- @@ -209,7 +196,7 @@ impl NonFungibleAsset { let value: Word = source.read()?; let callbacks: AssetCallbackFlag = source.read()?; - Ok(NonFungibleAsset::from_parts(faucet_id, value).with_callbacks(callbacks)) + Ok(NonFungibleAsset::from_parts(faucet_id, value, callbacks)) } } @@ -223,12 +210,14 @@ impl NonFungibleAsset { pub struct NonFungibleAssetDetails { faucet_id: AccountId, asset_data: Vec, + callbacks: AssetCallbackFlag, } impl NonFungibleAssetDetails { - /// Returns asset details instantiated from the specified faucet ID and asset data. - pub fn new(faucet_id: AccountId, asset_data: Vec) -> Self { - Self { faucet_id, asset_data } + /// Returns asset details instantiated from the specified faucet ID, asset data and callback + /// flag. + pub fn new(faucet_id: AccountId, asset_data: Vec, callbacks: AssetCallbackFlag) -> Self { + Self { faucet_id, asset_data, callbacks } } /// Returns ID of the faucet which issued this asset. @@ -240,6 +229,11 @@ impl NonFungibleAssetDetails { pub fn asset_data(&self) -> &[u8] { &self.asset_data } + + /// Returns the [`AssetCallbackFlag`] of this asset. + pub fn callbacks(&self) -> AssetCallbackFlag { + self.callbacks + } } // TESTS @@ -302,7 +296,11 @@ mod tests { ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET_1, ] { let account_id = AccountId::try_from(non_fungible_account_id).unwrap(); - let details = NonFungibleAssetDetails::new(account_id, vec![1, 2, 3]); + let details = NonFungibleAssetDetails::new( + account_id, + vec![1, 2, 3], + AssetCallbackFlag::Disabled, + ); let non_fungible_asset = NonFungibleAsset::new(&details); assert_eq!( non_fungible_asset, diff --git a/crates/miden-protocol/src/asset/vault/asset_witness.rs b/crates/miden-protocol/src/asset/vault/asset_witness.rs index bfa54d185a..9eb5d488aa 100644 --- a/crates/miden-protocol/src/asset/vault/asset_witness.rs +++ b/crates/miden-protocol/src/asset/vault/asset_witness.rs @@ -1,10 +1,13 @@ use alloc::boxed::Box; +use alloc::collections::BTreeMap; use alloc::string::ToString; +use alloc::vec::Vec; use miden_crypto::merkle::InnerNodeInfo; -use miden_crypto::merkle::smt::{SmtLeaf, SmtProof}; +use miden_crypto::merkle::smt::SmtProof; use super::vault_key::AssetVaultKey; +use crate::Word; use crate::asset::Asset; use crate::errors::AssetError; use crate::utils::serde::{ @@ -18,93 +21,165 @@ use crate::utils::serde::{ /// A witness of an asset in an [`AssetVault`](super::AssetVault). /// /// It proves inclusion of a certain asset in the vault. +/// +/// ## Guarantees +/// +/// This type guarantees that the raw key-value pairs it contains are all present in the +/// contained SMT proof (under their hashed form). Note that the inverse is not necessarily true: +/// the proof may contain more entries than the witness because to prove inclusion of a given raw +/// key A an [`SmtLeaf::Multiple`](miden_crypto::merkle::smt::SmtLeaf::Multiple) may be present +/// that contains both keys hash(A) and hash(B). However, B may not be present in the key-value +/// pairs and this is a valid state. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct AssetWitness(SmtProof); +pub struct AssetWitness { + proof: SmtProof, + /// Raw [`AssetVaultKey`]s -> asset value words, kept consistent with the proof's leaf entries. + entries: BTreeMap, +} impl AssetWitness { // CONSTRUCTORS // -------------------------------------------------------------------------------------------- - /// Creates a new [`AssetWitness`] from an SMT proof. + /// Creates a new [`AssetWitness`] from an SMT proof and a set of raw vault keys. + /// + /// For each key, looks up its hashed form in the proof and records the resulting value. /// /// # Errors /// /// Returns an error if: - /// - any of the key value pairs in the SMT leaf do not form a valid asset. - pub fn new(smt_proof: SmtProof) -> Result { - for (vault_key, asset_value) in smt_proof.leaf().entries() { - // This ensures that vault key and value are consistent. - Asset::from_key_value_words(*vault_key, *asset_value) - .map_err(|err| AssetError::AssetWitnessInvalid(Box::new(err)))?; + /// - any key's hashed form is not present in the proof. + /// - any of the resulting `(vault_key, value)` pairs do not form a valid asset. + pub fn new( + proof: SmtProof, + keys: impl IntoIterator, + ) -> Result { + let mut entries = BTreeMap::new(); + + for key in keys { + let value = proof + .get(&key.hash().as_word()) + .ok_or(AssetError::AssetWitnessMissingKey { key })?; + + // Validate that the (key, value) pair forms a valid asset (and skip empty entries). + if !value.is_empty() { + Asset::from_key_value(key, value) + .map_err(|err| AssetError::AssetWitnessInvalid(Box::new(err)))?; + } + + entries.insert(key, value); } - Ok(Self(smt_proof)) + Ok(Self { proof, entries }) } - /// Creates a new [`AssetWitness`] from an SMT proof without checking that the proof contains - /// valid assets. + /// Creates a new [`AssetWitness`] from an SMT proof and a set of key-value pairs without + /// validating that the pairs form valid assets. /// - /// Prefer [`AssetWitness::new`] whenever possible. - pub fn new_unchecked(smt_proof: SmtProof) -> Self { - Self(smt_proof) + /// Prefer [`AssetWitness::new`] whenever possible. See the type-level docs for the invariants + /// callers must uphold. + pub fn new_unchecked( + proof: SmtProof, + key_values: impl IntoIterator, + ) -> Self { + let entries: BTreeMap = key_values.into_iter().collect(); + + #[cfg(debug_assertions)] + for (key, value) in &entries { + debug_assert_eq!( + proof.get(&key.hash().as_word()), + Some(*value), + "AssetWitness::new_unchecked: (key, value) pair does not match the proof", + ); + } + + Self { proof, entries } } // PUBLIC ACCESSORS // -------------------------------------------------------------------------------------------- + /// Returns a reference to the underlying [`SmtProof`]. + pub fn proof(&self) -> &SmtProof { + &self.proof + } + /// Returns `true` if this [`AssetWitness`] authenticates the provided [`AssetVaultKey`], i.e. /// if its leaf index matches, `false` otherwise. pub fn authenticates_asset_vault_key(&self, vault_key: AssetVaultKey) -> bool { - self.0.leaf().index() == vault_key.to_leaf_index() + self.proof.leaf().index() == vault_key.hash().to_leaf_index() } /// Searches for an [`Asset`] in the witness with the given `vault_key`. pub fn find(&self, vault_key: AssetVaultKey) -> Option { - self.assets().find(|asset| asset.vault_key() == vault_key) + let value = self.entries.get(&vault_key).copied()?; + if value.is_empty() { + None + } else { + Some( + Asset::from_key_value(vault_key, value) + .expect("asset witness should track valid assets"), + ) + } } /// Returns an iterator over the [`Asset`]s in this witness. - pub fn assets(&self) -> impl Iterator { - // TODO: Avoid cloning the vector by not calling SmtLeaf::entries. - // Once SmtLeaf::entries returns a slice (i.e. once - // https://github.com/0xMiden/crypto/pull/521 is available), replace this match statement. - let entries = match self.0.leaf() { - SmtLeaf::Empty(_) => &[], - SmtLeaf::Single(kv_pair) => core::slice::from_ref(kv_pair), - SmtLeaf::Multiple(kv_pairs) => kv_pairs, - }; - - entries.iter().map(|(key, value)| { - Asset::from_key_value_words(*key, *value) - .expect("asset witness should track valid assets") + pub fn assets(&self) -> impl Iterator + '_ { + self.entries.iter().filter_map(|(key, value)| { + if value.is_empty() { + None + } else { + Some( + Asset::from_key_value(*key, *value) + .expect("asset witness should track valid assets"), + ) + } }) } + /// Returns an iterator over the raw `(vault_key, value)` pairs tracked by this witness. + pub(super) fn entries(&self) -> impl Iterator { + self.entries.iter() + } + + /// Decomposes the witness into its underlying [`SmtProof`] and the raw `(vault_key, value)` + /// entries it tracks. + pub(super) fn into_parts(self) -> (SmtProof, BTreeMap) { + (self.proof, self.entries) + } + /// Returns an iterator over every inner node of this witness' merkle path. pub fn authenticated_nodes(&self) -> impl Iterator + '_ { - self.0 + self.proof .path() - .authenticated_nodes(self.0.leaf().index().position(), self.0.leaf().hash()) + .authenticated_nodes(self.proof.leaf().index().position(), self.proof.leaf().hash()) .expect("leaf index is u64 and should be less than 2^SMT_DEPTH") } } impl From for SmtProof { fn from(witness: AssetWitness) -> Self { - witness.0 + witness.proof } } impl Serializable for AssetWitness { fn write_into(&self, target: &mut W) { - self.0.write_into(target); + target.write(&self.proof); + target.write_usize(self.entries.len()); + target.write_many(self.entries.keys()); } } impl Deserializable for AssetWitness { fn read_from(source: &mut R) -> Result { - let proof = SmtProof::read_from(source)?; - Self::new(proof).map_err(|err| DeserializationError::InvalidValue(err.to_string())) + let proof: SmtProof = source.read()?; + let num_keys: usize = source.read()?; + let keys = source + .read_many_iter::(num_keys)? + .collect::, _>>()?; + + Self::new(proof, keys).map_err(|err| DeserializationError::InvalidValue(err.to_string())) } } @@ -117,43 +192,28 @@ mod tests { use miden_crypto::merkle::smt::Smt; use super::*; - use crate::Word; - use crate::asset::{AssetVault, FungibleAsset, NonFungibleAsset}; + use crate::asset::{AssetCallbackFlag, AssetVault, FungibleAsset, NonFungibleAsset}; use crate::testing::account_id::{ ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET, ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_3, }; - /// Tests that constructing an asset witness fails if any asset in the smt proof is invalid. - #[test] - fn create_asset_witness_fails_on_invalid_asset() -> anyhow::Result<()> { - let invalid_asset = Word::from([0, 0, 0, 5u32]); - let smt = Smt::with_entries([(invalid_asset, invalid_asset)])?; - let proof = smt.open(&invalid_asset); - - let err = AssetWitness::new(proof).unwrap_err(); - - assert_matches!(err, AssetError::AssetWitnessInvalid(source) => { - assert_matches!(*source, AssetError::InvalidFaucetAccountId(_)); - }); - - Ok(()) - } - - /// Tests that constructing an asset witness fails if the vault key is from a fungible asset and - /// the asset is a non-fungible one. + /// Tests that constructing an asset witness fails if the (vault_key, value) pair stored in the + /// proof is inconsistent (here: a non-fungible value under a fungible vault key). #[test] fn create_asset_witness_fails_on_vault_key_mismatch() -> anyhow::Result<()> { let fungible_asset = FungibleAsset::mock(500); let non_fungible_asset = NonFungibleAsset::mock(&[1]); - let smt = Smt::with_entries([( - fungible_asset.vault_key().into(), + // Manually build a proof at the fungible asset's hashed key but with a non-fungible value. + let fungible_key = fungible_asset.vault_key(); + let inconsistent_smt = Smt::with_entries([( + fungible_key.hash().as_word(), non_fungible_asset.to_value_word(), )])?; - let proof = smt.open(&fungible_asset.vault_key().into()); + let proof = inconsistent_smt.open(&fungible_key.hash().as_word()); - let err = AssetWitness::new(proof).unwrap_err(); + let err = AssetWitness::new(proof, [fungible_key]).unwrap_err(); assert_matches!(err, AssetError::AssetWitnessInvalid(source) => { assert_matches!(*source, AssetError::FungibleAssetValueMostSignificantElementsMustBeZero(_)); @@ -162,12 +222,47 @@ mod tests { Ok(()) } + /// Tests that constructing an asset witness fails if the provided raw key is not actually + /// present (in hashed form) in the SMT proof. + #[test] + fn create_asset_witness_fails_on_missing_key() -> anyhow::Result<()> { + let asset_in_vault = FungibleAsset::new( + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_3.try_into()?, + 200, + AssetCallbackFlag::Disabled, + )?; + let other_key = FungibleAsset::new( + ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into()?, + 100, + AssetCallbackFlag::Disabled, + )? + .vault_key(); + + let vault = AssetVault::new(&[asset_in_vault.into()])?; + let proof = vault.open(asset_in_vault.vault_key()).proof().clone(); + + // The proof was opened at `asset_in_vault`'s hashed key, so a separate `other_key` won't + // be found in it. + let err = AssetWitness::new(proof, [other_key]).unwrap_err(); + assert_matches!(err, AssetError::AssetWitnessMissingKey { key } => { + assert_eq!(key, other_key); + }); + + Ok(()) + } + #[test] fn asset_witness_authenticates_asset_vault_key() -> anyhow::Result<()> { - let fungible_asset0 = - FungibleAsset::new(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_3.try_into()?, 200)?; - let fungible_asset1 = - FungibleAsset::new(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into()?, 100)?; + let fungible_asset0 = FungibleAsset::new( + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_3.try_into()?, + 200, + AssetCallbackFlag::Disabled, + )?; + let fungible_asset1 = FungibleAsset::new( + ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into()?, + 100, + AssetCallbackFlag::Disabled, + )?; let vault = AssetVault::new(&[fungible_asset0.into()])?; let witness0 = vault.open(fungible_asset0.vault_key()); diff --git a/crates/miden-protocol/src/asset/vault/mod.rs b/crates/miden-protocol/src/asset/vault/mod.rs index 8c530a6b24..a9f946b623 100644 --- a/crates/miden-protocol/src/asset/vault/mod.rs +++ b/crates/miden-protocol/src/asset/vault/mod.rs @@ -1,3 +1,4 @@ +use alloc::collections::BTreeMap; use alloc::string::ToString; use alloc::vec::Vec; @@ -16,7 +17,7 @@ use super::{ Serializable, }; use crate::Word; -use crate::account::{AccountVaultDelta, NonFungibleDeltaAction}; +use crate::account::AccountVaultPatch; use crate::crypto::merkle::smt::{SMT_DEPTH, Smt}; use crate::errors::{AssetError, AssetVaultError}; @@ -27,7 +28,7 @@ mod asset_witness; pub use asset_witness::AssetWitness; mod vault_key; -pub use vault_key::AssetVaultKey; +pub use vault_key::{AssetVaultKey, AssetVaultKeyHash}; mod asset_id; pub use asset_id::AssetId; @@ -38,17 +39,22 @@ pub use asset_id::AssetId; /// A container for an unlimited number of assets. /// /// An asset vault can contain an unlimited number of assets. The assets are stored in a Sparse -/// Merkle tree as follows: -/// - For fungible assets, the index of a node is defined by the issuing faucet ID, and the value of -/// the node is the asset itself. Thus, for any fungible asset there will be only one node in the -/// tree. -/// - For non-fungible assets, the index is defined by the asset itself, and the asset is also the -/// value of the node. +/// Merkle Tree, keyed by the hash of the [`AssetVaultKey`] (see [`AssetVaultKey::hash`]). +/// Hashing the raw key gives a uniform leaf distribution: in particular it prevents non-fungible +/// assets issued by the same faucet from sharing a leaf, which would otherwise happen because +/// their raw vault keys share their fourth element (the faucet ID prefix) - the element the SMT +/// uses to determine leaf membership. +/// +/// The raw (unhashed) [`AssetVaultKey`]s are retained alongside the SMT to allow iteration and +/// proof reconstruction. /// /// An asset vault can be reduced to a single hash which is the root of the Sparse Merkle Tree. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct AssetVault { + /// SMT keyed by hashed [`AssetVaultKey`]s. asset_tree: Smt, + /// Raw [`AssetVaultKey`]s -> asset value words, kept in sync with `asset_tree`. + entries: BTreeMap, } impl AssetVault { @@ -63,12 +69,23 @@ impl AssetVault { /// Returns a new [AssetVault] initialized with the provided assets. pub fn new(assets: &[Asset]) -> Result { - Ok(Self { - asset_tree: Smt::with_entries( - assets.iter().map(|asset| (asset.vault_key().to_word(), asset.to_value_word())), - ) - .map_err(AssetVaultError::DuplicateAsset)?, - }) + let asset_tree = Smt::with_entries( + assets + .iter() + .map(|asset| (asset.vault_key().hash().as_word(), asset.to_value_word())), + ) + .map_err(AssetVaultError::DuplicateAsset)?; + + // Filter empty values so the `entries` map stays in sync with the SMT, which treats + // empty values as no-ops. `Smt::with_entries` above already errored on duplicate keys, + // so collecting into a `BTreeMap` here cannot silently drop assets. + let entries = assets + .iter() + .filter(|asset| !asset.to_value_word().is_empty()) + .map(|asset| (asset.vault_key(), asset.to_value_word())) + .collect(); + + Ok(Self { asset_tree, entries }) } // PUBLIC ACCESSORS @@ -82,7 +99,7 @@ impl AssetVault { /// Returns the asset corresponding to the provided asset vault key, or `None` if the asset /// doesn't exist. pub fn get(&self, asset_vault_key: AssetVaultKey) -> Option { - let asset_value = self.asset_tree.get_value(&asset_vault_key.to_word()); + let asset_value = self.entries.get(&asset_vault_key).copied().unwrap_or_default(); if asset_value.is_empty() { None @@ -96,11 +113,7 @@ impl AssetVault { /// Returns true if the specified non-fungible asset is stored in this vault. pub fn has_non_fungible_asset(&self, asset: NonFungibleAsset) -> Result { - // check if the asset is stored in the vault - match self.asset_tree.get_value(&asset.vault_key().to_word()) { - asset if asset == Smt::EMPTY_VALUE => Ok(false), - _ => Ok(true), - } + Ok(self.entries.contains_key(&asset.vault_key())) } /// Returns the balance of the fungible asset identified by `vault_key`. @@ -119,7 +132,7 @@ impl AssetVault { }); } - let asset_value = self.asset_tree.get_value(&vault_key.to_word()); + let asset_value = self.entries.get(&vault_key).copied().unwrap_or_default(); let asset = FungibleAsset::from_key_value(vault_key, asset_value) .expect("asset vault should only store valid assets"); @@ -128,10 +141,9 @@ impl AssetVault { /// Returns an iterator over the assets stored in the vault. pub fn assets(&self) -> impl Iterator + '_ { - // SAFETY: The asset tree tracks only valid assets. - self.asset_tree.entries().map(|(key, value)| { - Asset::from_key_value_words(*key, *value) - .expect("asset vault should only store valid assets") + // SAFETY: The entries map only tracks valid assets. + self.entries.iter().map(|(key, value)| { + Asset::from_key_value(*key, *value).expect("asset vault should only store valid assets") }) } @@ -144,9 +156,12 @@ impl AssetVault { /// /// The `vault_key` can be obtained with [`Asset::vault_key`]. pub fn open(&self, vault_key: AssetVaultKey) -> AssetWitness { - let smt_proof = self.asset_tree.open(&vault_key.to_word()); - // SAFETY: The asset vault should only contain valid assets. - AssetWitness::new_unchecked(smt_proof) + let smt_proof = self.asset_tree.open(&vault_key.hash().as_word()); + let value = self.entries.get(&vault_key).copied().unwrap_or_default(); + + // SAFETY: The key-value pair is guaranteed to be present in the proof since we open its + // hashed form, and the asset vault only contains valid assets. + AssetWitness::new_unchecked(smt_proof, [(vault_key, value)]) } /// Returns a bool indicating whether the vault is empty. @@ -173,38 +188,16 @@ impl AssetVault { // PUBLIC MODIFIERS // -------------------------------------------------------------------------------------------- - /// Applies the specified delta to the asset vault. + /// Applies the specified patch to the asset vault. + /// + /// This updates each asset that is contained in the patch to its new value. /// /// # Errors - /// Returns an error: - /// - If the total value of the added assets is greater than [`FungibleAsset::MAX_AMOUNT`]. - /// - If the delta contains an addition/subtraction for a fungible asset that is not stored in - /// the vault. - /// - If the delta contains a non-fungible asset removal that is not stored in the vault. - /// - If the delta contains a non-fungible asset addition that is already stored in the vault. - /// - The maximum number of leaves per asset is exceeded. - pub fn apply_delta(&mut self, delta: &AccountVaultDelta) -> Result<(), AssetVaultError> { - for (vault_key, &delta) in delta.fungible().iter() { - // SAFETY: fungible asset delta should only contain fungible faucet IDs and delta amount - // should be in bounds - let asset = FungibleAsset::new(vault_key.faucet_id(), delta.unsigned_abs()) - .expect("fungible asset delta should be valid") - .with_callbacks(vault_key.callback_flag()); - match delta >= 0 { - true => self.add_fungible_asset(asset), - false => self.remove_fungible_asset(asset), - }?; - } - - for (&asset, &action) in delta.non_fungible().iter() { - match action { - NonFungibleDeltaAction::Add => { - self.add_non_fungible_asset(asset)?; - }, - NonFungibleDeltaAction::Remove => { - self.remove_non_fungible_asset(asset)?; - }, - } + /// + /// Returns an error if the maximum number of leaves per asset is exceeded. + pub fn apply_patch(&mut self, patch: &AccountVaultPatch) -> Result<(), AssetVaultError> { + for (&vault_key, &value) in patch.iter() { + self.insert_entry(vault_key, value)?; } Ok(()) @@ -212,6 +205,16 @@ impl AssetVault { // ADD ASSET // -------------------------------------------------------------------------------------------- + + /// Inserts the specified asset into the vault, overwriting the asset value at the same vault + /// key. Returns the value of the asset previously. + /// + /// # Errors + /// - The maximum number of leaves per asset is exceeded. + pub fn insert_asset(&mut self, asset: Asset) -> Result { + self.insert_entry(asset.vault_key(), asset.to_value_word()) + } + /// Add the specified asset to the vault. /// /// # Errors @@ -235,18 +238,16 @@ impl AssetVault { &mut self, other_asset: FungibleAsset, ) -> Result { - let current_asset_value = self.asset_tree.get_value(&other_asset.vault_key().to_word()); - let current_asset = - FungibleAsset::from_key_value(other_asset.vault_key(), current_asset_value) - .expect("asset vault should store valid assets"); + let vault_key = other_asset.vault_key(); + let current_asset_value = self.entries.get(&vault_key).copied().unwrap_or_default(); + let current_asset = FungibleAsset::from_key_value(vault_key, current_asset_value) + .expect("asset vault should store valid assets"); let new_asset = current_asset .add(other_asset) .map_err(AssetVaultError::AddFungibleAssetBalanceError)?; - self.asset_tree - .insert(new_asset.vault_key().to_word(), new_asset.to_value_word()) - .map_err(AssetVaultError::MaxLeafEntriesExceeded)?; + self.insert_entry(new_asset.vault_key(), new_asset.to_value_word())?; Ok(new_asset) } @@ -260,11 +261,7 @@ impl AssetVault { &mut self, asset: NonFungibleAsset, ) -> Result { - // add non-fungible asset to the vault - let old = self - .asset_tree - .insert(asset.vault_key().to_word(), asset.to_value_word()) - .map_err(AssetVaultError::MaxLeafEntriesExceeded)?; + let old = self.insert_entry(asset.vault_key(), asset.to_value_word())?; // if the asset already exists, return an error if old != Smt::EMPTY_VALUE { @@ -311,10 +308,10 @@ impl AssetVault { &mut self, other_asset: FungibleAsset, ) -> Result { - let current_asset_value = self.asset_tree.get_value(&other_asset.vault_key().to_word()); - let current_asset = - FungibleAsset::from_key_value(other_asset.vault_key(), current_asset_value) - .expect("asset vault should store valid assets"); + let vault_key = other_asset.vault_key(); + let current_asset_value = self.entries.get(&vault_key).copied().unwrap_or_default(); + let current_asset = FungibleAsset::from_key_value(vault_key, current_asset_value) + .expect("asset vault should store valid assets"); // If the asset's amount is 0, we consider it absent from the vault. if current_asset.amount() == AssetAmount::ZERO { @@ -335,9 +332,7 @@ impl AssetVault { } } - self.asset_tree - .insert(new_asset.vault_key().to_word(), new_asset.to_value_word()) - .map_err(AssetVaultError::MaxLeafEntriesExceeded)?; + self.insert_entry(new_asset.vault_key(), new_asset.to_value_word())?; Ok(new_asset) } @@ -351,11 +346,7 @@ impl AssetVault { &mut self, asset: NonFungibleAsset, ) -> Result<(), AssetVaultError> { - // remove the asset from the vault. - let old = self - .asset_tree - .insert(asset.vault_key().to_word(), Smt::EMPTY_VALUE) - .map_err(AssetVaultError::MaxLeafEntriesExceeded)?; + let old = self.insert_entry(asset.vault_key(), Smt::EMPTY_VALUE)?; // return an error if the asset did not exist in the vault. if old == Smt::EMPTY_VALUE { @@ -364,6 +355,30 @@ impl AssetVault { Ok(()) } + + /// Inserts the given `(vault_key, value)` pair into both the SMT and the raw-entry map. + /// + /// Returns the previous SMT value at the hashed key (the empty word if no entry existed). + fn insert_entry( + &mut self, + vault_key: AssetVaultKey, + value: Word, + ) -> Result { + // Insert into the SMT first so that `entries` is only mutated once the fallible insert + // succeeds; this keeps the two structures in sync even if the insert errors. + let old_value = self + .asset_tree + .insert(vault_key.hash().into(), value) + .map_err(AssetVaultError::MaxLeafEntriesExceeded)?; + + if value == Smt::EMPTY_VALUE { + self.entries.remove(&vault_key); + } else { + self.entries.insert(vault_key, value); + } + + Ok(old_value) + } } // SERIALIZATION @@ -414,4 +429,40 @@ mod tests { let err = vault.remove_asset(FungibleAsset::mock(50)).unwrap_err(); assert_matches!(err, AssetVaultError::FungibleAssetNotFound(_)); } + + /// Two non-fungible assets issued by the same faucet share their fourth raw-key element (the + /// faucet ID prefix), which historically caused them to land in the same SMT leaf because the + /// SMT uses element 3 for leaf membership. Hashing the vault key before insertion fixes that: + /// the assets must end up in different leaves. + /// + /// Regression test for . + #[test] + fn two_non_fungible_assets_from_same_faucet_use_different_leaves() -> anyhow::Result<()> { + let asset0 = NonFungibleAsset::mock(&[1, 2, 3]); + let asset1 = NonFungibleAsset::mock(&[4, 5, 6]); + + // Sanity check: the assets share their faucet but have distinct raw vault keys (different + // asset IDs). + assert_eq!(asset0.vault_key().faucet_id(), asset1.vault_key().faucet_id()); + assert_ne!(asset0.vault_key(), asset1.vault_key()); + + // Without hashing, both raw vault keys share their two most significant elements (the + // faucet ID suffix/metadata in element 2 and the faucet ID prefix in element 3). Element 3 + // is what the SMT uses for leaf membership, so the two would collide into a single leaf. + // Sanity-check that pre-condition. + assert_eq!(asset0.vault_key().to_word()[2], asset1.vault_key().to_word()[2]); + assert_eq!(asset0.vault_key().to_word()[3], asset1.vault_key().to_word()[3]); + + // With hashing, the hashed leaf indices differ, so they live in different SMT leaves. + assert_ne!( + asset0.vault_key().hash().to_leaf_index(), + asset1.vault_key().hash().to_leaf_index() + ); + + let vault = AssetVault::new(&[asset0, asset1])?; + assert_eq!(vault.num_leaves(), 2); + assert_eq!(vault.num_assets(), 2); + + Ok(()) + } } diff --git a/crates/miden-protocol/src/asset/vault/partial.rs b/crates/miden-protocol/src/asset/vault/partial.rs index 970d3c8508..232a16285c 100644 --- a/crates/miden-protocol/src/asset/vault/partial.rs +++ b/crates/miden-protocol/src/asset/vault/partial.rs @@ -1,4 +1,6 @@ +use alloc::collections::BTreeMap; use alloc::string::ToString; +use alloc::vec::Vec; use miden_crypto::merkle::smt::{PartialSmt, SmtLeaf, SmtProof}; use miden_crypto::merkle::{InnerNodeInfo, MerkleError}; @@ -20,10 +22,21 @@ use crate::utils::serde::{ /// Partial vault is used to provide verifiable access to specific assets in a vault /// without the need to provide the full vault data. It contains all required data for loading /// vault data into the transaction kernel for transaction execution. +/// +/// ## Guarantees +/// +/// This type guarantees that the raw key-value pairs it contains are all present in the contained +/// partial SMT (under their hashed form). Note that the inverse is not necessarily true: the SMT +/// may contain more entries than the map because to prove inclusion of a given raw key A an +/// [`SmtLeaf::Multiple`] may be present that contains both keys hash(A) and hash(B). However, B +/// may not be present in the key-value pairs and this is a valid state. #[derive(Clone, Debug, PartialEq, Eq, Default)] pub struct PartialVault { - /// An SMT with a partial view into an account's full [`AssetVault`]. + /// An SMT with a partial view into an account's full [`AssetVault`], keyed by hashed + /// [`AssetVaultKey`]s. partial_smt: PartialSmt, + /// Raw [`AssetVaultKey`]s -> asset value words, kept consistent with `partial_smt`. + entries: BTreeMap, } impl PartialVault { @@ -34,17 +47,43 @@ impl PartialVault { /// /// For conversion from an [`AssetVault`], prefer [`Self::new_minimal`] to be more explicit. pub fn new(root: Word) -> Self { - PartialVault { partial_smt: PartialSmt::new(root) } + PartialVault { + partial_smt: PartialSmt::new(root), + entries: BTreeMap::new(), + } + } + + /// Returns a new [`PartialVault`] with all provided witnesses added to it. + pub fn with_witnesses( + witnesses: impl IntoIterator, + ) -> Result { + let mut entries = BTreeMap::new(); + + let partial_smt = PartialSmt::from_proofs(witnesses.into_iter().map(|witness| { + // Skip empty values so `entries` only ever tracks valid assets (mirrors + // `AssetVault::new`). + entries.extend( + witness + .entries() + .filter(|(_, value)| !value.is_empty()) + .map(|(key, value)| (*key, *value)), + ); + SmtProof::from(witness) + })) + .map_err(PartialAssetVaultError::FailedToAddProof)?; + + Ok(PartialVault { partial_smt, entries }) } /// Converts an [`AssetVault`] into a partial vault representation. /// - /// The resulting [`PartialVault`] will contain the _full_ merkle paths of the original asset - /// vault. + /// The resulting [`PartialVault`] will contain the _full_ merkle paths and entries of the + /// original asset vault. pub fn new_full(vault: AssetVault) -> Self { let partial_smt = PartialSmt::from(vault.asset_tree); + let entries = vault.entries; - PartialVault { partial_smt } + PartialVault { partial_smt, entries } } /// Converts an [`AssetVault`] into a partial vault representation. @@ -55,6 +94,42 @@ impl PartialVault { PartialVault::new(vault.root()) } + /// Constructs a [`PartialVault`] from a [`PartialSmt`] and the raw [`AssetVaultKey`]s whose + /// values are looked up from the SMT. + /// + /// # Errors + /// + /// Returns an error if: + /// - any key's hashed form is not present in the partial SMT. + /// - any of the resulting `(vault_key, value)` pairs does not form a valid asset. + fn from_partial_smt_and_keys( + partial_smt: PartialSmt, + keys: impl IntoIterator, + ) -> Result { + let mut entries = BTreeMap::new(); + + for key in keys { + let value = partial_smt + .get_value(&key.hash().as_word()) + .map_err(PartialAssetVaultError::UntrackedAsset)?; + + // Validate that the (key, value) pair forms a valid asset, even when the value is + // empty: an empty value paired with e.g. a non-fungible key carrying a non-zero asset + // id is malformed and must be rejected rather than silently tracked. + Asset::from_key_value(key, value).map_err(|source| { + PartialAssetVaultError::InvalidAssetForKey { key, value, source } + })?; + + // Skip empty values so `entries` stays in sync with the SMT, which treats empty values + // as no-ops (mirrors `AssetVault::new`). + if !value.is_empty() { + entries.insert(key, value); + } + } + + Ok(Self { partial_smt, entries }) + } + // ACCESSORS // -------------------------------------------------------------------------------------------- @@ -71,13 +146,25 @@ impl PartialVault { self.partial_smt.inner_nodes() } - /// Returns an iterator over all leaves in the Sparse Merkle Tree proofs. - /// - /// Each item returned is a tuple containing the leaf index and a reference to the leaf. + /// Returns an iterator over all leaves of the underlying [`PartialSmt`]. pub fn leaves(&self) -> impl Iterator { self.partial_smt.leaves().map(|(_, leaf)| leaf) } + /// Returns an iterator over the [`Asset`]s tracked by this partial vault. + pub fn assets(&self) -> impl Iterator + '_ { + self.entries.iter().map(|(key, value)| { + Asset::from_key_value(*key, *value) + .expect("partial vault should only track valid assets") + }) + } + + /// Returns an iterator over the raw `(vault_key, value)` pairs tracked by this partial vault. + #[cfg(test)] + pub(super) fn entries(&self) -> impl Iterator { + self.entries.iter() + } + /// Returns an opening of the leaf associated with `vault_key`. /// /// The `vault_key` can be obtained with [`Asset::vault_key`]. @@ -89,10 +176,13 @@ impl PartialVault { pub fn open(&self, vault_key: AssetVaultKey) -> Result { let smt_proof = self .partial_smt - .open(&vault_key.into()) + .open(&vault_key.hash().as_word()) .map_err(PartialAssetVaultError::UntrackedAsset)?; - // SAFETY: The partial vault should only contain valid assets. - Ok(AssetWitness::new_unchecked(smt_proof)) + let value = self.entries.get(&vault_key).copied().unwrap_or_default(); + + // SAFETY: The key-value pair is guaranteed to be present in the proof since we open its + // hashed form, and the partial vault only tracks valid assets. + Ok(AssetWitness::new_unchecked(smt_proof, [(vault_key, value)])) } /// Returns the [`Asset`] associated with the given `vault_key`. @@ -104,18 +194,15 @@ impl PartialVault { /// Returns an error if: /// - the key is not tracked by this partial SMT. pub fn get(&self, vault_key: AssetVaultKey) -> Result, MerkleError> { - self.partial_smt.get_value(&vault_key.into()).map(|asset_value| { - if asset_value.is_empty() { - None - } else { - // SAFETY: If this returned a non-empty word, then it should be a valid asset, - // because the vault should only track valid ones. - Some( - Asset::from_key_value(vault_key, asset_value) - .expect("partial vault should only track valid assets"), - ) - } - }) + let value = self.partial_smt.get_value(&vault_key.hash().as_word())?; + if value.is_empty() { + Ok(None) + } else { + Ok(Some( + Asset::from_key_value(vault_key, value) + .expect("partial vault should only track valid assets"), + )) + } } // MUTATORS @@ -129,62 +216,37 @@ impl PartialVault { /// - the new root after the insertion of the leaf and the path does not match the existing root /// (except when the first leaf is added). pub fn add(&mut self, witness: AssetWitness) -> Result<(), PartialAssetVaultError> { - let proof = SmtProof::from(witness); + // Take ownership of the witness' entries up front so that, if `add_proof` fails, no + // partial state escapes into `self.entries`. The type-level guarantee (entries are a + // subset of partial_smt) must hold even after an error. + let (proof, new_entries) = witness.into_parts(); self.partial_smt .add_proof(proof) - .map_err(PartialAssetVaultError::FailedToAddProof) - } - - // HELPER FUNCTIONS - // -------------------------------------------------------------------------------------------- - - /// Validates that the provided entries are valid vault keys and assets. - /// - /// For brevity, the error conditions are only mentioned on the public methods that use this - /// function. - fn validate_entries<'a>( - entries: impl IntoIterator, - ) -> Result<(), PartialAssetVaultError> { - for (vault_key, asset_value) in entries { - // This ensures that vault key and value are consistent. - Asset::from_key_value_words(*vault_key, *asset_value).map_err(|source| { - PartialAssetVaultError::InvalidAssetInSmt { entry: *asset_value, source } - })?; - } - + .map_err(PartialAssetVaultError::FailedToAddProof)?; + // Skip empty values so `entries` only ever tracks valid assets (mirrors `AssetVault::new`). + self.entries + .extend(new_entries.into_iter().filter(|(_, value)| !value.is_empty())); Ok(()) } } -impl TryFrom for PartialVault { - type Error = PartialAssetVaultError; - - /// Returns a new instance of a partial vault from the provided partial SMT. - /// - /// # Errors - /// - /// Returns an error if: - /// - the provided SMT does not track only valid [`Asset`]s. - /// - the vault key at which the asset is stored does not match the vault key derived from the - /// asset. - fn try_from(partial_smt: PartialSmt) -> Result { - Self::validate_entries(partial_smt.entries())?; - - Ok(PartialVault { partial_smt }) - } -} - impl Serializable for PartialVault { fn write_into(&self, target: &mut W) { - target.write(&self.partial_smt) + target.write(&self.partial_smt); + target.write_usize(self.entries.len()); + target.write_many(self.entries.keys()); } } impl Deserializable for PartialVault { fn read_from(source: &mut R) -> Result { let partial_smt: PartialSmt = source.read()?; + let num_entries: usize = source.read()?; + let keys = source + .read_many_iter::(num_entries)? + .collect::, _>>()?; - PartialVault::try_from(partial_smt) + Self::from_partial_smt_and_keys(partial_smt, keys) .map_err(|err| DeserializationError::InvalidValue(err.to_string())) } } @@ -194,37 +256,152 @@ impl Deserializable for PartialVault { #[cfg(test)] mod tests { + use alloc::vec::Vec; + use assert_matches::assert_matches; use miden_crypto::merkle::smt::Smt; use super::*; - use crate::asset::FungibleAsset; + use crate::asset::{AssetCallbackFlag, FungibleAsset, NonFungibleAsset}; + use crate::testing::account_id::ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET; #[test] - fn partial_vault_ensures_asset_validity() -> anyhow::Result<()> { - let invalid_asset = Word::from([0, 0, 0, 5u32]); - let smt = Smt::with_entries([(invalid_asset, invalid_asset)])?; - let proof = smt.open(&invalid_asset); - let partial_smt = PartialSmt::from_proofs([proof.clone()])?; + fn partial_vault_open_returns_correct_asset_after_full_conversion() -> anyhow::Result<()> { + let asset = FungibleAsset::mock(500); + let vault = AssetVault::new(&[asset])?; + let partial = PartialVault::new_full(vault.clone()); + + let key = asset.vault_key(); + let witness = partial.open(key)?; - let err = PartialVault::try_from(partial_smt).unwrap_err(); - assert_matches!(err, PartialAssetVaultError::InvalidAssetInSmt { entry, .. } => { - assert_eq!(entry, invalid_asset); - }); + assert!(witness.authenticates_asset_vault_key(key)); + assert_eq!(witness.find(key), Some(asset)); + assert_eq!(partial.root(), vault.root()); Ok(()) } #[test] - fn partial_vault_ensures_asset_vault_key_matches() -> anyhow::Result<()> { + fn partial_vault_open_fails_for_untracked_key() -> anyhow::Result<()> { let asset = FungibleAsset::mock(500); - let invalid_vault_key = Word::from([0, 1, 2, 3u32]); - let smt = Smt::with_entries([(invalid_vault_key, asset.to_value_word())])?; - let proof = smt.open(&invalid_vault_key); - let partial_smt = PartialSmt::from_proofs([proof.clone()])?; + let vault = AssetVault::new(&[asset])?; + // `new_minimal` carries the root but no entries. + let partial = PartialVault::new_minimal(&vault); + + let err = partial.open(asset.vault_key()).unwrap_err(); + assert_matches!(err, PartialAssetVaultError::UntrackedAsset(_)); + + Ok(()) + } + + #[test] + fn partial_vault_with_witnesses_round_trips() -> anyhow::Result<()> { + let fungible = FungibleAsset::mock(500); + let non_fungible = NonFungibleAsset::mock(&[1, 2, 3]); + let vault = AssetVault::new(&[fungible, non_fungible])?; + + let witnesses = [vault.open(fungible.vault_key()), vault.open(non_fungible.vault_key())]; + let partial = PartialVault::with_witnesses(witnesses)?; + + assert_eq!(partial.root(), vault.root()); + assert_eq!(partial.entries().count(), 2); + + // Round-trip serialization preserves equality. + let bytes = partial.to_bytes(); + let roundtripped = PartialVault::read_from_bytes(&bytes)?; + assert_eq!(partial, roundtripped); + + Ok(()) + } - let err = PartialVault::try_from(partial_smt).unwrap_err(); - assert_matches!(err, PartialAssetVaultError::InvalidAssetInSmt { .. }); + #[test] + fn partial_vault_with_witnesses_fails_on_root_mismatch() -> anyhow::Result<()> { + // Two single-asset vaults rooted at different SMT roots. + let asset_a = FungibleAsset::mock(500); + let asset_b: Asset = FungibleAsset::new( + ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into()?, + 100, + AssetCallbackFlag::Disabled, + )? + .into(); + let vault_a = AssetVault::new(&[asset_a])?; + let vault_b = AssetVault::new(&[asset_b])?; + assert_ne!(vault_a.root(), vault_b.root()); + + let witness_a = vault_a.open(asset_a.vault_key()); + let witness_b = vault_b.open(asset_b.vault_key()); + + let err = PartialVault::with_witnesses([witness_a, witness_b]).unwrap_err(); + assert_matches!(err, PartialAssetVaultError::FailedToAddProof(_)); + + Ok(()) + } + + #[test] + fn partial_vault_add_extends_with_new_witness() -> anyhow::Result<()> { + let fungible = FungibleAsset::mock(500); + let non_fungible = NonFungibleAsset::mock(&[7, 8, 9]); + let vault = AssetVault::new(&[fungible, non_fungible])?; + + let mut partial = PartialVault::with_witnesses([vault.open(fungible.vault_key())])?; + assert_eq!(partial.entries().count(), 1); + + partial.add(vault.open(non_fungible.vault_key()))?; + + assert_eq!(partial.root(), vault.root()); + assert_eq!(partial.entries().count(), 2); + assert_eq!(partial.open(fungible.vault_key())?.find(fungible.vault_key()), Some(fungible)); + assert_eq!( + partial.open(non_fungible.vault_key())?.find(non_fungible.vault_key()), + Some(non_fungible), + ); + + Ok(()) + } + + #[test] + fn partial_vault_add_is_atomic_on_failure() -> anyhow::Result<()> { + // Build two distinct vaults so the second witness's root disagrees with the first. + let asset_a = FungibleAsset::mock(500); + let asset_b: Asset = FungibleAsset::new( + ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into()?, + 100, + AssetCallbackFlag::Disabled, + )? + .into(); + let vault_a = AssetVault::new(&[asset_a])?; + let vault_b = AssetVault::new(&[asset_b])?; + + let mut partial = PartialVault::with_witnesses([vault_a.open(asset_a.vault_key())])?; + let entries_before: Vec<_> = partial.entries().map(|(k, v)| (*k, *v)).collect(); + let root_before = partial.root(); + + let err = partial.add(vault_b.open(asset_b.vault_key())).unwrap_err(); + assert_matches!(err, PartialAssetVaultError::FailedToAddProof(_)); + + // Atomicity: failed `add` must not leak entries or shift the root. + let entries_after: Vec<_> = partial.entries().map(|(k, v)| (*k, *v)).collect(); + assert_eq!(entries_before, entries_after); + assert_eq!(partial.root(), root_before); + + Ok(()) + } + + #[test] + fn from_partial_smt_and_keys_rejects_inconsistent_asset() -> anyhow::Result<()> { + let fungible = FungibleAsset::mock(500); + let non_fungible = NonFungibleAsset::mock(&[4, 5, 6]); + + // Build an SMT that stores a non-fungible value under a fungible key's hashed slot, then + // wrap it in a partial SMT covering that key. + let fungible_key = fungible.vault_key(); + let inconsistent_smt = + Smt::with_entries([(fungible_key.hash().as_word(), non_fungible.to_value_word())])?; + let proof = inconsistent_smt.open(&fungible_key.hash().as_word()); + let partial_smt = PartialSmt::from_proofs([proof])?; + + let err = PartialVault::from_partial_smt_and_keys(partial_smt, [fungible_key]).unwrap_err(); + assert_matches!(err, PartialAssetVaultError::InvalidAssetForKey { .. }); Ok(()) } diff --git a/crates/miden-protocol/src/asset/vault/vault_key.rs b/crates/miden-protocol/src/asset/vault/vault_key.rs index 1ca7747fc3..a7febd7102 100644 --- a/crates/miden-protocol/src/asset/vault/vault_key.rs +++ b/crates/miden-protocol/src/asset/vault/vault_key.rs @@ -1,8 +1,9 @@ use alloc::boxed::Box; -use alloc::string::ToString; +use alloc::string::{String, ToString}; use core::fmt; use miden_crypto::merkle::smt::LeafIndex; +use miden_crypto_derive::WordWrapper; use crate::account::AccountId; use crate::asset::vault::AssetId; @@ -16,7 +17,7 @@ use crate::utils::serde::{ DeserializationError, Serializable, }; -use crate::{Felt, Word}; +use crate::{Felt, Hasher, Word}; /// The unique identifier of an [`Asset`] in the [`AssetVault`](crate::asset::AssetVault). /// @@ -33,6 +34,10 @@ use crate::{Felt, Word}; /// The composition is the discriminator between assets and so it is placed at a static offset much /// like the version in an account ID. This makes it slightly easier to change the asset metadata in /// the future without affecting identification of previous assets. +/// +/// Use [`AssetVaultKey::hash`] to produce the corresponding [`AssetVaultKeyHash`] that is used as +/// the key in the asset vault's underlying SMT. Hashing ensures a uniform distribution across +/// leaves regardless of how faucet IDs or asset IDs are chosen. #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub struct AssetVaultKey { /// The asset ID of the vault key. @@ -163,9 +168,39 @@ impl AssetVaultKey { self.composition } - /// Returns the leaf index of a vault key. + /// Hashes this raw vault key to produce the [`AssetVaultKeyHash`] used as the key in the asset + /// vault's underlying SMT. + pub fn hash(&self) -> AssetVaultKeyHash { + AssetVaultKeyHash::from_raw(Hasher::hash_elements(self.to_word().as_elements())) + } +} + +// ASSET VAULT KEY HASH +// ================================================================================================ + +/// A hashed [`AssetVaultKey`]. +/// +/// This is produced by hashing an [`AssetVaultKey`] and is used as the actual key in the +/// underlying SMT. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, WordWrapper)] +pub struct AssetVaultKeyHash(Word); + +impl AssetVaultKeyHash { + /// Returns the leaf index in the SMT for this hashed key. pub fn to_leaf_index(&self) -> LeafIndex { - LeafIndex::::from(self.to_word()) + self.0.into() + } +} + +impl From for Word { + fn from(key: AssetVaultKeyHash) -> Self { + key.0 + } +} + +impl From for AssetVaultKeyHash { + fn from(key: AssetVaultKey) -> Self { + key.hash() } } diff --git a/crates/miden-protocol/src/batch/account_update.rs b/crates/miden-protocol/src/batch/account_update.rs index c1e19fc735..c826f1d496 100644 --- a/crates/miden-protocol/src/batch/account_update.rs +++ b/crates/miden-protocol/src/batch/account_update.rs @@ -1,8 +1,7 @@ use alloc::boxed::Box; use crate::Word; -use crate::account::AccountId; -use crate::account::delta::AccountUpdateDetails; +use crate::account::{AccountId, AccountUpdateDetails}; use crate::errors::BatchAccountUpdateError; use crate::transaction::ProvenTransaction; use crate::utils::serde::{ diff --git a/crates/miden-protocol/src/batch/input_output_note_tracker.rs b/crates/miden-protocol/src/batch/input_output_note_tracker.rs deleted file mode 100644 index 99ae0cbfcd..0000000000 --- a/crates/miden-protocol/src/batch/input_output_note_tracker.rs +++ /dev/null @@ -1,393 +0,0 @@ -use alloc::collections::BTreeMap; -use alloc::vec::Vec; - -use crate::batch::{BatchId, ProvenBatch}; -use crate::block::{BlockHeader, BlockNumber}; -use crate::crypto::merkle::MerkleError; -use crate::errors::{ProposedBatchError, ProposedBlockError}; -use crate::note::{NoteHeader, NoteId, NoteInclusionProof, Nullifier}; -use crate::transaction::{ - InputNoteCommitment, - OutputNote, - PartialBlockchain, - ProvenTransaction, - TransactionId, -}; - -type BatchInputNotes = Vec; -type BlockInputNotes = Vec; -type ErasedNotes = Vec; -type BlockOutputNotes = BTreeMap; -type BatchOutputNotes = Vec; - -// INPUT OUTPUT NOTE TRACKER -// ================================================================================================ - -/// A helper struct to track input and output notes and erase those that are created and consumed -/// within the same batch or block. -/// -/// Its main purpose is to check for duplicates and allow for removal of output notes that are -/// consumed in the same batch/block, and so are not output notes of the batch/block. -/// -/// The approach for this is that: -/// - for batches, the input/output note set is initialized to the union of all input/output notes -/// of the transactions in the batch. -/// - for blocks, the input/output note set is initialized to the union of all input/output of the -/// batches in the block. -/// -/// All input notes for which a note inclusion proof is provided are authenticated and converted -/// into authenticated notes. -/// -/// All input notes which are also output notes are removed, as they are considered consumed within -/// the same batch/block and will not be visible as created or consumed notes for the batch/block. -#[derive(Debug)] -pub(crate) struct InputOutputNoteTracker { - /// An index from Nullifier to the identifier that consumes it (either a [`TransactionId`] or - /// [`BatchId`](crate::batch::BatchId)). - input_notes: BTreeMap, - /// An index from [`NoteId`]s to the transaction that creates the note and the note itself. - /// The transaction ID is tracked to produce better errors when a duplicate note is - /// encountered. - output_notes: BTreeMap, -} - -impl InputOutputNoteTracker { - /// Computes the input and output notes for a transaction batch from the provided iterator over - /// transactions. Implements batch-specific logic. - pub fn from_transactions<'a>( - txs: impl Iterator + Clone, - unauthenticated_note_proofs: &BTreeMap, - partial_blockchain: &PartialBlockchain, - batch_reference_block: &BlockHeader, - ) -> Result<(BatchInputNotes, BatchOutputNotes), ProposedBatchError> { - let input_notes_iter = txs.clone().flat_map(|tx| { - tx.input_notes() - .iter() - .map(|input_note_commitment| (input_note_commitment.clone(), tx.id())) - }); - let output_notes_iter = txs.flat_map(|tx| { - tx.output_notes().iter().map(|output_note| (output_note.clone(), tx.id())) - }); - - let tracker = Self::from_iter( - input_notes_iter, - output_notes_iter, - unauthenticated_note_proofs, - partial_blockchain, - batch_reference_block, - ) - .map_err(ProposedBatchError::from)?; - - let (batch_input_notes, _erased_notes, batch_output_notes) = - tracker.erase_notes().map_err(ProposedBatchError::from)?; - - // Collect the remaining (non-erased) output notes into the final set of output notes. - let final_output_notes = batch_output_notes - .into_iter() - .map(|(_, (_, output_note))| output_note) - .collect(); - - Ok((batch_input_notes, final_output_notes)) - } -} - -impl InputOutputNoteTracker { - /// Computes the input and output notes for a block from the provided iterator over batches. - /// Implements block-specific logic. - pub fn from_batches<'a>( - batches: impl Iterator + Clone, - unauthenticated_note_proofs: &BTreeMap, - partial_blockchain: &PartialBlockchain, - prev_block: &BlockHeader, - ) -> Result<(BlockInputNotes, ErasedNotes, BlockOutputNotes), ProposedBlockError> { - let input_notes_iter = batches.clone().flat_map(|batch| { - batch - .input_notes() - .iter() - .map(|input_note_commitment| (input_note_commitment.clone(), batch.id())) - }); - - let output_notes_iter = batches.flat_map(|batch| { - batch.output_notes().iter().map(|output_note| (output_note.clone(), batch.id())) - }); - - let tracker = Self::from_iter( - input_notes_iter, - output_notes_iter, - unauthenticated_note_proofs, - partial_blockchain, - prev_block, - ) - .map_err(ProposedBlockError::from)?; - - let (block_input_notes, erased_notes, block_output_notes) = - tracker.erase_notes().map_err(ProposedBlockError::from)?; - - Ok((block_input_notes, erased_notes, block_output_notes)) - } -} - -// GENERIC CODE FOR BATCHES AND BLOCKS -// ================================================================================================ - -impl InputOutputNoteTracker { - /// Creates the input and output note set while checking for duplicates and, in the process, - /// authenticating any unauthenticated notes for which proofs are provided. - fn from_iter( - input_notes_iter: impl Iterator, - output_notes_iter: impl Iterator, - unauthenticated_note_proofs: &BTreeMap, - partial_blockchain: &PartialBlockchain, - reference_block: &BlockHeader, - ) -> Result> { - let mut input_notes = BTreeMap::new(); - let mut output_notes = BTreeMap::new(); - - for (mut input_note_commitment, container_id) in input_notes_iter { - // Transform unauthenticated notes into authenticated ones if the provided proof is - // valid. - if let Some(note_header) = input_note_commitment.header() - && let Some(proof) = unauthenticated_note_proofs.get(¬e_header.id()) - { - input_note_commitment = Self::authenticate_unauthenticated_note( - input_note_commitment.nullifier(), - note_header, - proof, - partial_blockchain, - reference_block, - )?; - } - - let nullifier = input_note_commitment.nullifier(); - if let Some((first_container_id, _)) = - input_notes.insert(nullifier, (container_id, input_note_commitment)) - { - return Err(InputOutputNoteTrackerError::DuplicateInputNote { - note_nullifier: nullifier, - first_container_id, - second_container_id: container_id, - }); - } - } - - for (note, container_id) in output_notes_iter { - if let Some((first_container_id, _)) = - output_notes.insert(note.id(), (container_id, note.clone())) - { - return Err(InputOutputNoteTrackerError::DuplicateOutputNote { - note_id: note.id(), - first_container_id, - second_container_id: container_id, - }); - } - } - - Ok(Self { input_notes, output_notes }) - } - - /// Iterates the input notes and if an unauthenticated note is encountered, attempts to remove - /// it from the output notes if it is present in that set. - /// If it is, the note is considered erased and added to the list of erased notes, otherwise it - /// is added to the final input notes. - /// - /// Returns the sets of input notes, erased notes and output notes. - #[allow(clippy::type_complexity)] - fn erase_notes( - mut self, - ) -> Result< - ( - Vec, - ErasedNotes, - BTreeMap, - ), - InputOutputNoteTrackerError, - > { - let mut erased_notes = Vec::new(); - let mut final_input_notes = Vec::new(); - - for (_, input_note_commitment) in self.input_notes.values() { - match input_note_commitment.header() { - Some(input_note_header) => { - let is_output_note = - Self::remove_output_note(input_note_header, &mut self.output_notes); - - // If the unauthenticated note is also created as an output note we erase it by - // adding it to the erased notes and, crucially, not adding it to the - // final_input_notes. - if is_output_note { - erased_notes.push(input_note_commitment.nullifier()); - } else { - final_input_notes.push(input_note_commitment.clone()); - } - }, - None => { - final_input_notes.push(input_note_commitment.clone()); - }, - } - } - - Ok((final_input_notes, erased_notes, self.output_notes)) - } - - /// Attempts to remove the given input note from the output note set. - /// - /// Returns `true` if the given note existed in the output note set and was removed from it, - /// `false` otherwise. - fn remove_output_note( - input_note_header: &NoteHeader, - output_notes: &mut BTreeMap, - ) -> bool { - output_notes.remove(&input_note_header.id()).is_some() - } - - /// Verifies the note inclusion proof for the given input note commitment parts (nullifier and - /// note header). Uses the block header referenced by the inclusion proof from the partial - /// blockchain. - /// - /// If the proof is valid, it means the note is part of the chain and it is "marked" as - /// authenticated by returning an [`InputNoteCommitment`] without the note header. - fn authenticate_unauthenticated_note( - nullifier: Nullifier, - note_header: &NoteHeader, - proof: &NoteInclusionProof, - partial_blockchain: &PartialBlockchain, - reference_block: &BlockHeader, - ) -> Result> { - let proof_reference_block = proof.location().block_num(); - let note_block_header = if reference_block.block_num() == proof_reference_block { - reference_block - } else { - partial_blockchain.get_block(proof.location().block_num()).ok_or_else(|| { - InputOutputNoteTrackerError::UnauthenticatedInputNoteBlockNotInPartialBlockchain { - block_number: proof.location().block_num(), - note_id: note_header.id(), - } - })? - }; - - let note_index = proof.location().block_note_tree_index().into(); - let note_id = note_header.id().as_word(); - proof - .note_path() - .verify(note_index, note_id, ¬e_block_header.note_root()) - .map_err(|source| { - InputOutputNoteTrackerError::UnauthenticatedNoteAuthenticationFailed { - note_id: note_header.id(), - block_num: proof.location().block_num(), - source, - } - })?; - - // Erase the note header from the input note. - Ok(InputNoteCommitment::from(nullifier)) - } -} - -// INPUT OUTPUT NOTE TRACKER ERROR -// ================================================================================================ - -// An error generic over the ContainerId. It is only used to abstract over the concrete errors, so -// it does not implement any traits, Error or otherwise. -enum InputOutputNoteTrackerError { - DuplicateInputNote { - note_nullifier: Nullifier, - first_container_id: ContainerId, - second_container_id: ContainerId, - }, - DuplicateOutputNote { - note_id: NoteId, - first_container_id: ContainerId, - second_container_id: ContainerId, - }, - UnauthenticatedInputNoteBlockNotInPartialBlockchain { - block_number: BlockNumber, - note_id: NoteId, - }, - UnauthenticatedNoteAuthenticationFailed { - note_id: NoteId, - block_num: BlockNumber, - source: MerkleError, - }, -} - -impl From> for ProposedBlockError { - fn from(error: InputOutputNoteTrackerError) -> Self { - match error { - InputOutputNoteTrackerError::DuplicateInputNote { - note_nullifier, - first_container_id, - second_container_id, - } => ProposedBlockError::DuplicateInputNote { - note_nullifier, - first_batch_id: first_container_id, - second_batch_id: second_container_id, - }, - InputOutputNoteTrackerError::DuplicateOutputNote { - note_id, - first_container_id, - second_container_id, - } => ProposedBlockError::DuplicateOutputNote { - note_id, - first_batch_id: first_container_id, - second_batch_id: second_container_id, - }, - InputOutputNoteTrackerError::UnauthenticatedInputNoteBlockNotInPartialBlockchain { - block_number, - note_id, - } => ProposedBlockError::UnauthenticatedInputNoteBlockNotInPartialBlockchain { - block_number, - note_id, - }, - InputOutputNoteTrackerError::UnauthenticatedNoteAuthenticationFailed { - note_id, - block_num, - source, - } => ProposedBlockError::UnauthenticatedNoteAuthenticationFailed { - note_id, - block_num, - source, - }, - } - } -} - -impl From> for ProposedBatchError { - fn from(error: InputOutputNoteTrackerError) -> Self { - match error { - InputOutputNoteTrackerError::DuplicateInputNote { - note_nullifier, - first_container_id, - second_container_id, - } => ProposedBatchError::DuplicateInputNote { - note_nullifier, - first_transaction_id: first_container_id, - second_transaction_id: second_container_id, - }, - InputOutputNoteTrackerError::DuplicateOutputNote { - note_id, - first_container_id, - second_container_id, - } => ProposedBatchError::DuplicateOutputNote { - note_id, - first_transaction_id: first_container_id, - second_transaction_id: second_container_id, - }, - InputOutputNoteTrackerError::UnauthenticatedInputNoteBlockNotInPartialBlockchain { - block_number, - note_id, - } => ProposedBatchError::UnauthenticatedInputNoteBlockNotInPartialBlockchain { - block_number, - note_id, - }, - InputOutputNoteTrackerError::UnauthenticatedNoteAuthenticationFailed { - note_id, - block_num, - source, - } => ProposedBatchError::UnauthenticatedNoteAuthenticationFailed { - note_id, - block_num, - source, - }, - } - } -} diff --git a/crates/miden-protocol/src/batch/kernel.rs b/crates/miden-protocol/src/batch/kernel.rs new file mode 100644 index 0000000000..3ffd74e0e4 --- /dev/null +++ b/crates/miden-protocol/src/batch/kernel.rs @@ -0,0 +1,88 @@ +use alloc::vec::Vec; + +use miden_core::program::Kernel; + +use crate::batch::{BatchId, ProposedBatch}; +use crate::utils::serde::Deserializable; +use crate::utils::sync::LazyLock; +use crate::vm::{AdviceInputs, Program, ProgramInfo, StackInputs}; +use crate::{Felt, Word}; + +// CONSTANTS +// ================================================================================================ + +static KERNEL_MAIN: LazyLock = LazyLock::new(|| { + let bytes = include_bytes!(concat!(env!("OUT_DIR"), "/assets/kernels/batch_kernel.masb")); + Program::read_from_bytes(bytes).expect("failed to deserialize batch kernel runtime") +}); + +// BATCH KERNEL +// ================================================================================================ + +/// The batch kernel program: an executable Miden program that proves a batch of transactions. +/// +/// The kernel takes `[BLOCK_COMMITMENT, BATCH_ID]` as public inputs and emits +/// `[INPUT_NOTES_COMMITMENT, BATCH_NOTE_TREE_ROOT, batch_expiration_block_num]`. See +/// `asm/kernels/batch/main.masm` for the input/output contract. +pub struct BatchKernel; + +impl BatchKernel { + // KERNEL SOURCE CODE + // -------------------------------------------------------------------------------------------- + + /// Returns the executable batch kernel program loaded from the build's `OUT_DIR`. + pub fn main() -> Program { + KERNEL_MAIN.clone() + } + + /// Returns [`ProgramInfo`] for the batch kernel program. + /// + /// The batch kernel does not expose syscalls, so the associated [`Kernel`] is empty. + pub fn program_info() -> ProgramInfo { + ProgramInfo::new(Self::main().hash(), Kernel::default()) + } + + // INPUT BUILDERS + // -------------------------------------------------------------------------------------------- + + /// Transforms the provided [`ProposedBatch`] into the stack and advice inputs needed to + /// execute the batch kernel. + pub fn prepare_inputs(proposed_batch: &ProposedBatch) -> (StackInputs, AdviceInputs) { + let block_commitment = proposed_batch.reference_block_header().commitment(); + let batch_id = proposed_batch.id(); + + let stack_inputs = Self::build_input_stack(block_commitment, batch_id); + let advice_inputs = Self::build_advice_inputs(proposed_batch); + + (stack_inputs, advice_inputs) + } + + /// Returns the stack with the public inputs required by the batch kernel. + /// + /// The initial stack is: + /// + /// ```text + /// [BLOCK_COMMITMENT, BATCH_ID, pad(8)] + /// ``` + /// + /// Where: + /// - `BLOCK_COMMITMENT` is the commitment of the batch's reference block. + /// - `BATCH_ID` is the batch's [`BatchId`]. + pub fn build_input_stack(block_commitment: Word, batch_id: BatchId) -> StackInputs { + let mut inputs: Vec = Vec::with_capacity(8); + inputs.extend_from_slice(block_commitment.as_elements()); + inputs.extend_from_slice(batch_id.as_word().as_elements()); + + StackInputs::new(&inputs).expect("number of stack inputs should be <= 16") + } + + // ADVICE BUILDER + // -------------------------------------------------------------------------------------------- + + /// Builds the advice inputs (map + stack) consumed by the batch kernel. + /// + /// The skeleton kernel ignores its advice inputs, so this returns the default empty value. + fn build_advice_inputs(_proposed_batch: &ProposedBatch) -> AdviceInputs { + AdviceInputs::default() + } +} diff --git a/crates/miden-protocol/src/batch/mod.rs b/crates/miden-protocol/src/batch/mod.rs index 1cef432dd3..f8235397e8 100644 --- a/crates/miden-protocol/src/batch/mod.rs +++ b/crates/miden-protocol/src/batch/mod.rs @@ -16,5 +16,10 @@ pub use proposed_batch::ProposedBatch; mod ordered_batches; pub use ordered_batches::OrderedBatches; -mod input_output_note_tracker; -pub(crate) use input_output_note_tracker::InputOutputNoteTracker; +pub(super) mod note_tracker; + +mod kernel; +pub use kernel::BatchKernel; + +mod output; +pub use output::BatchOutputs; diff --git a/crates/miden-protocol/src/batch/note_tracker.rs b/crates/miden-protocol/src/batch/note_tracker.rs new file mode 100644 index 0000000000..cfe33e056a --- /dev/null +++ b/crates/miden-protocol/src/batch/note_tracker.rs @@ -0,0 +1,445 @@ +use alloc::collections::BTreeMap; +use alloc::vec::Vec; +use core::fmt::Debug; + +use crate::batch::{BatchId, ProvenBatch}; +use crate::block::{BlockHeader, BlockNumber}; +use crate::crypto::merkle::MerkleError; +use crate::errors::{ProposedBatchError, ProposedBlockError}; +use crate::note::{NoteHeader, NoteId, NoteInclusionProof, Nullifier}; +use crate::transaction::{ + InputNoteCommitment, + OutputNote, + PartialBlockchain, + ProvenTransaction, + TransactionId, +}; + +// NOTE CONTAINER +// ================================================================================================ + +/// Abstracts over a "container" of transactions or batches that +/// [`InputOutputNoteTracker::push`] can consume. +/// +/// This lets the tracker treat both a [`ProvenTransaction`] (when building a batch) and a +/// [`ProvenBatch`] (when building a block) uniformly, while keeping the container-specific ID +/// type (e.g. [`TransactionId`] or [`BatchId`]) attached to any error we produce. +pub(crate) trait NoteContainer { + type Id: Copy + Eq + Debug; + + fn id(&self) -> Self::Id; + + fn input_notes(&self) -> impl Iterator; + + fn output_notes(&self) -> impl Iterator; +} + +impl NoteContainer for &ProvenTransaction { + type Id = TransactionId; + + fn id(&self) -> Self::Id { + ProvenTransaction::id(self) + } + + fn input_notes(&self) -> impl Iterator { + ProvenTransaction::input_notes(self).iter().cloned() + } + + fn output_notes(&self) -> impl Iterator { + ProvenTransaction::output_notes(self).iter().cloned() + } +} + +impl NoteContainer for &ProvenBatch { + type Id = BatchId; + + fn id(&self) -> Self::Id { + ProvenBatch::id(self) + } + + fn input_notes(&self) -> impl Iterator { + ProvenBatch::input_notes(self).iter().cloned() + } + + fn output_notes(&self) -> impl Iterator { + ProvenBatch::output_notes(self).iter().cloned() + } +} + +// INPUT OUTPUT NOTE TRACKER +// ================================================================================================ + +/// Accumulates the input and output notes of a batch or a block, with cross-container duplicate +/// detection, in-flight note erasure, unauthenticated-note authentication, and a final cycle +/// (circular dependency) check. +/// +/// Containers (transactions for a batch, batches for a block) are added via +/// [`InputOutputNoteTracker::push`] in their final ordering. The tracker erases an unauthenticated +/// input note as soon as it sees a matching output note from a *previous* container, which is what +/// gives us the ordering guarantee: any unauthenticated input note still present in the input set +/// after all containers have been pushed, whose `NoteId` is also still present in the output set, +/// must have been pushed *before* its creator, and is therefore part of a circular dependency. +/// That check happens once in [`InputOutputNoteTracker::finalize`]. +/// +/// All input notes for which a note inclusion proof is provided are authenticated and converted +/// into authenticated notes, unless they were erased first. +pub(crate) struct NoteTracker<'container, ContainerId: Copy + Eq + Debug> { + partial_blockchain: &'container PartialBlockchain, + reference_block: &'container BlockHeader, + unauthenticated_note_proofs: &'container BTreeMap, + input_notes: BTreeMap, + output_notes: BTreeMap, + erased_notes: Vec, +} + +/// The result of [`NoteTracker::finalize`]. +pub(crate) struct TrackerOutput { + pub input_notes: Vec, + pub erased_notes: Vec, + pub output_notes: BTreeMap, +} + +impl<'container, ContainerId: Copy + Eq + Debug> NoteTracker<'container, ContainerId> { + /// Creates a new, empty tracker. + pub(crate) fn new( + partial_blockchain: &'container PartialBlockchain, + reference_block: &'container BlockHeader, + unauthenticated_note_proofs: &'container BTreeMap, + ) -> Self { + Self { + partial_blockchain, + reference_block, + unauthenticated_note_proofs, + input_notes: BTreeMap::new(), + output_notes: BTreeMap::new(), + erased_notes: Vec::new(), + } + } + + /// Pushes the input and output notes of `container` into the tracker. + /// + /// Output notes are processed before input notes. The order between the two doesn't matter for + /// the outcome, since these sets should be disjoint within a single container. The advantage + /// of processing output notes first is that we can detect the "note created and consumed + /// within same tx/batch" case as a panic (see [`Self::process_input_notes`]). + /// + /// # Errors + /// + /// Returns an error if: + /// - An output note from `container` was already pushed by a previous container. + /// - An input note from `container` was already pushed by a previous container. + /// - An unauthenticated input note has a [`NoteInclusionProof`] in + /// `unauthenticated_note_proofs` and authentication of that proof fails. + /// - An unauthenticated input note has a [`NoteInclusionProof`] whose referenced block is not + /// in the partial blockchain. + pub(crate) fn push>( + &mut self, + container: Container, + ) -> Result<(), NoteTrackerError> { + let container_id = container.id(); + + self.process_output_notes(container_id, container.output_notes())?; + self.process_input_notes(container_id, container.input_notes())?; + + Ok(()) + } + + /// Inserts each output note into the tracker, returning an error if a note with the same + /// [`NoteId`] was already pushed by a previous container. + fn process_output_notes( + &mut self, + container_id: ContainerId, + output_notes: impl Iterator, + ) -> Result<(), NoteTrackerError> { + for output_note in output_notes { + let output_note_id = output_note.id(); + if let Some((first_container_id, _)) = + self.output_notes.insert(output_note_id, (container_id, output_note)) + { + return Err(NoteTrackerError::DuplicateOutputNote { + note_id: output_note_id, + first_container_id, + second_container_id: container_id, + }); + } + } + Ok(()) + } + + /// Processes the input notes from a single container. + /// + /// Note erasure happens only when iterating unauthenticated input notes, since we only have + /// access to the note ID when we have a note header and we do not store the note ID in the + /// input_notes map. + /// + /// Running note erasure first technically means an unauthenticated note for which a + /// proof is provided (= effectively a note that exists on-chain) could be erased if + /// a note with the same ID is also created, and this is technically valid. + fn process_input_notes( + &mut self, + container_id: ContainerId, + input_notes: impl Iterator, + ) -> Result<(), NoteTrackerError> { + 'input_note_iter: for mut input_note_commitment in input_notes { + // If the note is unauthenticated (has a header), attempt to erase or authenticate it. + if let Some(input_note_header) = input_note_commitment.header() { + // Erase if the note is also in the output notes. + if let Some((created_by, _output_note)) = + self.output_notes.remove(&input_note_header.id()) + { + // We should never encounter a note that is both in the input and output note + // set of a given transaction or batch: + // - A `ProvenTransaction` guarantees that the set of input and output notes is + // disjoint. + // - A batch guarantees this as well due to executing _this_ function's logic. + // + // If we do anyway, it is better to panic than to proceed. + // + // Rationale: Notes that would be in the input _and_ output notes of + // transactions (or batches) must NOT be erased at the batch (or block) level, + // as it is a form of circular note dependency and could be abused. + // We should also not propagate them upwards to the batch or block, as it would + // lead to an unspendable note, so there is no reason to allow it in the first + // place. + assert_ne!( + created_by, container_id, + "transactions and batches should never create and consume the same note" + ); + + self.erased_notes.push(input_note_commitment.nullifier()); + + // Skip inserting the erased note into the input notes set. + continue 'input_note_iter; + } else { + // If the note wasn't erased and a proof is provided, transform it into an + // authenticated one. Otherwise the note stays unauthenticated. + if let Some(proof) = + self.unauthenticated_note_proofs.get(&input_note_header.id()) + { + input_note_commitment = self.authenticate_unauthenticated_note( + input_note_commitment.nullifier(), + input_note_header, + proof, + )?; + } + } + } + + // Insert the note into the set of input notes and prevent duplicates. + let nullifier = input_note_commitment.nullifier(); + if let Some((first_container_id, _)) = + self.input_notes.insert(nullifier, (container_id, input_note_commitment)) + { + return Err(NoteTrackerError::DuplicateInputNote { + note_nullifier: nullifier, + first_container_id, + second_container_id: container_id, + }); + } + } + Ok(()) + } + + /// Performs the final circular-dependency check and consumes the tracker, returning the + /// accumulated input notes, erased note nullifiers, and output notes. + /// + /// # Errors + /// + /// Any unauthenticated input notes that appear in the output notes indicate 1) an incorrect + /// ordering or 2) a circular dependency between two transactions or batches. Notes that were + /// created and consumed in-order would have been erased by [`Self::push`]. + /// + /// Regarding 1): We need to disallow incorrectly ordered transactions at the batch (and block) + /// level. Consider transaction A that creates note 1 and transaction B that consumes note + /// 1, but they are provided in order [B, A]. We return an error because we would otherwise + /// promote B's input note to an unauthenticated input note of the batch, and promote A's + /// output note to an output note of the batch. However, this would result in a batch that + /// contains the same note in its input and output note sets, which must be disallowed as it + /// has the same abuse potential as . + pub(crate) fn finalize( + self, + ) -> Result, NoteTrackerError> { + for (consumed_by, unauthenticated_input_note) in + self.input_notes.values().filter_map(|(consumed_by, input_note_commitment)| { + input_note_commitment.header().map(|header| (consumed_by, header)) + }) + { + if let Some((created_by, _)) = self.output_notes.get(&unauthenticated_input_note.id()) { + return Err(NoteTrackerError::NoteConsumedBeforeCreated { + note_id: unauthenticated_input_note.id(), + consumed_by: *consumed_by, + created_by: *created_by, + }); + } + } + + Ok(TrackerOutput { + input_notes: self + .input_notes + .into_values() + .map(|(_, input_note_commitment)| input_note_commitment) + .collect(), + erased_notes: self.erased_notes, + output_notes: self.output_notes, + }) + } + + /// Verifies the note inclusion proof for the given input note commitment parts (nullifier and + /// note header). Uses the block header referenced by the inclusion proof from the partial + /// blockchain. + /// + /// If the proof is valid, it means the note is part of the chain and it is "marked" as + /// authenticated by returning an [`InputNoteCommitment`] without the note header. + fn authenticate_unauthenticated_note( + &self, + nullifier: Nullifier, + note_header: &NoteHeader, + proof: &NoteInclusionProof, + ) -> Result> { + let proof_reference_block = proof.location().block_num(); + let note_block_header = if self.reference_block.block_num() == proof_reference_block { + self.reference_block + } else { + self.partial_blockchain.get_block(proof.location().block_num()).ok_or_else(|| { + NoteTrackerError::UnauthenticatedInputNoteBlockNotInPartialBlockchain { + block_number: proof.location().block_num(), + note_id: note_header.id(), + } + })? + }; + + let note_index = proof.location().block_note_tree_index().into(); + let note_id = note_header.id().as_word(); + proof + .note_path() + .verify(note_index, note_id, ¬e_block_header.note_root()) + .map_err(|source| NoteTrackerError::UnauthenticatedNoteAuthenticationFailed { + note_id: note_header.id(), + block_num: proof.location().block_num(), + source, + })?; + + // Erase the note header from the input note. + Ok(InputNoteCommitment::from(nullifier)) + } +} + +// INPUT OUTPUT NOTE TRACKER ERROR +// ================================================================================================ + +// An error generic over the ContainerId. It is only used to abstract over the concrete errors, so +// it does not implement any traits, Error or otherwise. +pub(crate) enum NoteTrackerError { + DuplicateInputNote { + note_nullifier: Nullifier, + first_container_id: ContainerId, + second_container_id: ContainerId, + }, + DuplicateOutputNote { + note_id: NoteId, + first_container_id: ContainerId, + second_container_id: ContainerId, + }, + UnauthenticatedInputNoteBlockNotInPartialBlockchain { + block_number: BlockNumber, + note_id: NoteId, + }, + UnauthenticatedNoteAuthenticationFailed { + note_id: NoteId, + block_num: BlockNumber, + source: MerkleError, + }, + NoteConsumedBeforeCreated { + note_id: NoteId, + consumed_by: ContainerId, + created_by: ContainerId, + }, +} + +impl From> for ProposedBlockError { + fn from(error: NoteTrackerError) -> Self { + match error { + NoteTrackerError::DuplicateInputNote { + note_nullifier, + first_container_id, + second_container_id, + } => ProposedBlockError::DuplicateInputNote { + note_nullifier, + first_batch_id: first_container_id, + second_batch_id: second_container_id, + }, + NoteTrackerError::DuplicateOutputNote { + note_id, + first_container_id, + second_container_id, + } => ProposedBlockError::DuplicateOutputNote { + note_id, + first_batch_id: first_container_id, + second_batch_id: second_container_id, + }, + NoteTrackerError::UnauthenticatedInputNoteBlockNotInPartialBlockchain { + block_number, + note_id, + } => ProposedBlockError::UnauthenticatedInputNoteBlockNotInPartialBlockchain { + block_number, + note_id, + }, + NoteTrackerError::UnauthenticatedNoteAuthenticationFailed { + note_id, + block_num, + source, + } => ProposedBlockError::UnauthenticatedNoteAuthenticationFailed { + note_id, + block_num, + source, + }, + NoteTrackerError::NoteConsumedBeforeCreated { note_id, consumed_by, created_by } => { + ProposedBlockError::NoteConsumedBeforeCreated { note_id, consumed_by, created_by } + }, + } + } +} + +impl From> for ProposedBatchError { + fn from(error: NoteTrackerError) -> Self { + match error { + NoteTrackerError::DuplicateInputNote { + note_nullifier, + first_container_id, + second_container_id, + } => ProposedBatchError::DuplicateInputNote { + note_nullifier, + first_transaction_id: first_container_id, + second_transaction_id: second_container_id, + }, + NoteTrackerError::DuplicateOutputNote { + note_id, + first_container_id, + second_container_id, + } => ProposedBatchError::DuplicateOutputNote { + note_id, + first_transaction_id: first_container_id, + second_transaction_id: second_container_id, + }, + NoteTrackerError::NoteConsumedBeforeCreated { note_id, consumed_by, created_by } => { + ProposedBatchError::NoteConsumedBeforeCreated { note_id, consumed_by, created_by } + }, + NoteTrackerError::UnauthenticatedInputNoteBlockNotInPartialBlockchain { + block_number, + note_id, + } => ProposedBatchError::UnauthenticatedInputNoteBlockNotInPartialBlockchain { + block_number, + note_id, + }, + NoteTrackerError::UnauthenticatedNoteAuthenticationFailed { + note_id, + block_num, + source, + } => ProposedBatchError::UnauthenticatedNoteAuthenticationFailed { + note_id, + block_num, + source, + }, + } + } +} diff --git a/crates/miden-protocol/src/batch/output.rs b/crates/miden-protocol/src/batch/output.rs new file mode 100644 index 0000000000..10c553707f --- /dev/null +++ b/crates/miden-protocol/src/batch/output.rs @@ -0,0 +1,223 @@ +use alloc::vec::Vec; + +use crate::block::BlockNumber; +use crate::errors::BatchOutputError; +use crate::vm::StackOutputs; +use crate::{Felt, Word}; + +// BATCH OUTPUTS +// ================================================================================================ + +/// The public outputs produced by the batch kernel. +/// +/// This is the parsed, typed form of the kernel's output stack. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BatchOutputs { + /// The commitment to the batch's input notes. + input_notes_commitment: Word, + /// The root of the batch's note tree (the [`BatchNoteTree`](crate::batch::BatchNoteTree)) over + /// the batch's output notes. + batch_note_tree_root: Word, + /// The block number at which the batch expires. + batch_expiration_block_num: BlockNumber, +} + +impl BatchOutputs { + // OUTPUT STACK LAYOUT + // -------------------------------------------------------------------------------------------- + + /// The element index at which the input notes commitment word starts on the output stack. + pub const INPUT_NOTES_COMMITMENT_WORD_IDX: usize = 0; + /// The element index at which the batch note tree root word starts on the output stack. + pub const BATCH_NOTE_TREE_ROOT_WORD_IDX: usize = 4; + /// The element index at which the batch expiration block number is stored on the output stack. + pub const BATCH_EXPIRATION_BLOCK_NUM_ELEMENT_IDX: usize = 8; + + // CONSTRUCTOR + // -------------------------------------------------------------------------------------------- + + /// Returns a new [`BatchOutputs`] instantiated from the provided data. + pub fn new( + input_notes_commitment: Word, + batch_note_tree_root: Word, + batch_expiration_block_num: BlockNumber, + ) -> Self { + Self { + input_notes_commitment, + batch_note_tree_root, + batch_expiration_block_num, + } + } + + // PARSER + // -------------------------------------------------------------------------------------------- + + /// Parses the batch kernel's output stack into a [`BatchOutputs`]. + /// + /// # Errors + /// + /// Returns [`BatchOutputError::OutputStackInvalid`] if: + /// - a required output word or element is missing from the stack; + /// - the cells following `batch_expiration_block_num` (positions 9..16) are not all zero. + /// + /// Returns [`BatchOutputError::ExpirationBlockNumberTooLarge`] if `batch_expiration_block_num` + /// does not fit into a `u32`. + pub fn parse(stack: &StackOutputs) -> Result { + let input_notes_commitment = + stack.get_word(Self::INPUT_NOTES_COMMITMENT_WORD_IDX).ok_or_else(|| { + BatchOutputError::OutputStackInvalid( + "input notes commitment word missing from output stack".into(), + ) + })?; + let batch_note_tree_root = + stack.get_word(Self::BATCH_NOTE_TREE_ROOT_WORD_IDX).ok_or_else(|| { + BatchOutputError::OutputStackInvalid( + "batch note tree root word missing from output stack".into(), + ) + })?; + + let expiration_felt = + stack.get_element(Self::BATCH_EXPIRATION_BLOCK_NUM_ELEMENT_IDX).ok_or_else(|| { + BatchOutputError::OutputStackInvalid( + "batch expiration block number missing from output stack".into(), + ) + })?; + + // Every cell after batch_expiration_block_num must be zero padding. + if stack[Self::BATCH_EXPIRATION_BLOCK_NUM_ELEMENT_IDX + 1..] + .iter() + .any(|&felt| felt != Felt::ZERO) + { + return Err(BatchOutputError::OutputStackInvalid( + "batch_expiration_block_num must be followed by zero padding".into(), + )); + } + + let batch_expiration_block_num = u32::try_from(expiration_felt.as_canonical_u64()) + .map_err(|_| BatchOutputError::ExpirationBlockNumberTooLarge(expiration_felt))? + .into(); + + Ok(Self::new( + input_notes_commitment, + batch_note_tree_root, + batch_expiration_block_num, + )) + } + + // PUBLIC ACCESSORS + // -------------------------------------------------------------------------------------------- + + /// Returns the commitment to the batch's input notes. + pub fn input_notes_commitment(&self) -> Word { + self.input_notes_commitment + } + + /// Returns the root of the batch's note tree. + pub fn batch_note_tree_root(&self) -> Word { + self.batch_note_tree_root + } + + /// Returns the block number at which the batch expires. + pub fn batch_expiration_block_num(&self) -> BlockNumber { + self.batch_expiration_block_num + } + + // CONVERSIONS + // -------------------------------------------------------------------------------------------- + + /// Encodes these [`BatchOutputs`] into the batch kernel's output stack. + /// + /// This is the inverse of [`BatchOutputs::parse`]; the resulting stack is laid out as: + /// + /// ```text + /// [INPUT_NOTES_COMMITMENT, BATCH_NOTE_TREE_ROOT, batch_expiration_block_num] + /// ``` + pub fn into_stack_outputs(self) -> StackOutputs { + let mut outputs: Vec = Vec::with_capacity(9); + outputs.extend_from_slice(self.input_notes_commitment.as_elements()); + outputs.extend_from_slice(self.batch_note_tree_root.as_elements()); + outputs.push(Felt::from(self.batch_expiration_block_num)); + + StackOutputs::new(&outputs).expect("number of stack outputs should be <= 16") + } +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_returns_outputs_for_well_formed_stack() { + let input_notes_commitment = + Word::from([Felt::from(1u32), Felt::from(2u32), Felt::from(3u32), Felt::from(4u32)]); + let batch_note_tree_root = + Word::from([Felt::from(5u32), Felt::from(6u32), Felt::from(7u32), Felt::from(8u32)]); + let elements = [ + Felt::from(1u32), + Felt::from(2u32), + Felt::from(3u32), + Felt::from(4u32), + Felt::from(5u32), + Felt::from(6u32), + Felt::from(7u32), + Felt::from(8u32), + Felt::from(1234u32), + ]; + let stack = StackOutputs::new(&elements).unwrap(); + + let outputs = BatchOutputs::parse(&stack).unwrap(); + + assert_eq!(outputs.input_notes_commitment(), input_notes_commitment); + assert_eq!(outputs.batch_note_tree_root(), batch_note_tree_root); + assert_eq!(outputs.batch_expiration_block_num(), BlockNumber::from(1234u32)); + } + + #[test] + fn parse_rejects_non_zero_padding() { + // A valid 9-element output followed by a non-zero felt in the padding region (>= idx 9). + let elements = [ + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, + Felt::from(7u32), + Felt::from(1u32), + ]; + let stack = StackOutputs::new(&elements).unwrap(); + + assert!(matches!( + BatchOutputs::parse(&stack), + Err(BatchOutputError::OutputStackInvalid(_)) + )); + } + + #[test] + fn parse_rejects_oversized_expiration() { + // An expiration value that does not fit into a u32. + let oversized = Felt::from(u32::MAX) + Felt::from(1u32); + let elements = [ + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, + oversized, + ]; + let stack = StackOutputs::new(&elements).unwrap(); + + assert!(matches!( + BatchOutputs::parse(&stack), + Err(BatchOutputError::ExpirationBlockNumberTooLarge(_)) + )); + } +} diff --git a/crates/miden-protocol/src/batch/proposed_batch.rs b/crates/miden-protocol/src/batch/proposed_batch.rs index 38026961cd..f7447bccb8 100644 --- a/crates/miden-protocol/src/batch/proposed_batch.rs +++ b/crates/miden-protocol/src/batch/proposed_batch.rs @@ -4,7 +4,8 @@ use alloc::sync::Arc; use alloc::vec::Vec; use crate::account::AccountId; -use crate::batch::{BatchAccountUpdate, BatchId, InputOutputNoteTracker}; +use crate::batch::note_tracker::{NoteTracker, TrackerOutput}; +use crate::batch::{BatchAccountUpdate, BatchId}; use crate::block::{BlockHeader, BlockNumber}; use crate::errors::ProposedBatchError; use crate::note::{NoteId, NoteInclusionProof}; @@ -16,6 +17,7 @@ use crate::transaction::{ PartialBlockchain, ProvenTransaction, TransactionHeader, + TransactionVerifier, }; use crate::utils::serde::{ ByteReader, @@ -104,6 +106,8 @@ impl ProposedBatch { /// notes do not count. /// - Any note is consumed more than once. /// - Any note is created more than once. + /// - An unauthenticated note is consumed before it is created (as determined by the order in + /// which transactions are given). /// - The number of account updates exceeds [`MAX_ACCOUNTS_PER_BATCH`]. /// - Note that any number of transactions against the same account count as one update. /// - The partial blockchains chain length does not match the block header's block number. This @@ -122,7 +126,7 @@ impl ProposedBatch { /// - There are duplicate transactions. /// - If any transaction's expiration block number is less than or equal to the batch's /// reference block. - pub fn new( + fn new_batch_inner( transactions: Vec>, reference_block_header: BlockHeader, partial_blockchain: PartialBlockchain, @@ -290,12 +294,20 @@ impl ProposedBatch { // Check for duplicate output notes and remove all output notes from the batch output note // set that are consumed by transactions. - let (input_notes, output_notes) = InputOutputNoteTracker::from_transactions( - transactions.iter().map(AsRef::as_ref), - &unauthenticated_note_proofs, + let mut tracker = NoteTracker::new( &partial_blockchain, &reference_block_header, - )?; + &unauthenticated_note_proofs, + ); + for tx in transactions.iter() { + tracker.push(tx.as_ref()).map_err(ProposedBatchError::from)?; + } + let TrackerOutput { input_notes, output_notes, .. } = + tracker.finalize().map_err(ProposedBatchError::from)?; + + // Collect the remaining (non-erased) output notes into the final set of output notes. + let output_notes: Vec = + output_notes.into_values().map(|(_, output_note)| output_note).collect(); if input_notes.len() > MAX_INPUT_NOTES_PER_BATCH { return Err(ProposedBatchError::TooManyInputNotes(input_notes.len())); @@ -326,6 +338,59 @@ impl ProposedBatch { }) } + /// Creates a new [`ProposedBatch`] from the provided parts, verifying every transaction's + /// execution proof against the transaction kernel. + /// + /// # Errors + /// + /// Returns an error for any of the batch-validation conditions documented on `new_batch_inner`, + /// or if a transaction's proof fails to verify or does not meet `proof_security_level`. + pub fn new( + transactions: Vec>, + reference_block_header: BlockHeader, + partial_blockchain: PartialBlockchain, + unauthenticated_note_proofs: BTreeMap, + proof_security_level: u32, + ) -> Result { + let batch = Self::new_batch_inner( + transactions, + reference_block_header, + partial_blockchain, + unauthenticated_note_proofs, + )?; + + let verifier = TransactionVerifier::new(proof_security_level); + for tx in batch.transactions() { + verifier.verify(tx).map_err(|source| { + ProposedBatchError::TransactionVerificationFailed { + transaction_id: tx.id(), + source, + } + })?; + } + + Ok(batch) + } + + /// Creates a new [`ProposedBatch`] **without verifying the transactions' execution proofs**. + /// + /// Runs the same batch validation as [`Self::new`] but skips proof verification. Exposed for + /// tests that build batches from mock transactions carrying dummy proofs. + #[cfg(any(test, feature = "testing"))] + pub fn new_unverified( + transactions: Vec>, + reference_block_header: BlockHeader, + partial_blockchain: PartialBlockchain, + unauthenticated_note_proofs: BTreeMap, + ) -> Result { + Self::new_batch_inner( + transactions, + reference_block_header, + partial_blockchain, + unauthenticated_note_proofs, + ) + } + // PUBLIC ACCESSORS // -------------------------------------------------------------------------------------------- @@ -364,6 +429,11 @@ impl ProposedBatch { self.id } + /// Returns the header of the reference block this batch is proposed for. + pub fn reference_block_header(&self) -> &BlockHeader { + &self.reference_block_header + } + /// Returns the block number at which the batch will expire. pub fn batch_expiration_block_num(&self) -> BlockNumber { self.batch_expiration_block_num @@ -440,7 +510,8 @@ impl Deserializable for ProposedBatch { let unauthenticated_note_proofs = BTreeMap::::read_from(source)?; - ProposedBatch::new( + // Reconstruct structurally without verifying the transactions' proofs. + ProposedBatch::new_batch_inner( transactions, block_header, partial_blockchain, @@ -461,9 +532,7 @@ mod tests { use super::*; use crate::Word; - use crate::account::delta::AccountUpdateDetails; - use crate::account::{AccountIdVersion, AccountType}; - use crate::asset::FungibleAsset; + use crate::account::{AccountIdVersion, AccountType, AccountUpdateDetails}; use crate::transaction::{InputNoteCommitment, OutputNote, ProvenTransaction, TxAccountUpdate}; #[test] @@ -495,8 +564,8 @@ mod tests { [2; 32].try_into().expect("failed to create initial account commitment"); let final_account_commitment = [3; 32].try_into().expect("failed to create final account commitment"); - let account_delta_commitment = - [4; 32].try_into().expect("failed to create account delta commitment"); + let account_patch_commitment = + [4; 32].try_into().expect("failed to create account patch commitment"); let block_num = reference_block_header.block_num(); let block_ref = reference_block_header.commitment(); let expiration_block_num = reference_block_header.block_num() + 1; @@ -506,7 +575,7 @@ mod tests { account_id, initial_account_commitment, final_account_commitment, - account_delta_commitment, + account_patch_commitment, AccountUpdateDetails::Private, ) .context("failed to build account update")?; @@ -517,13 +586,12 @@ mod tests { Vec::::new(), block_num, block_ref, - FungibleAsset::mock(100).unwrap_fungible(), expiration_block_num, proof, ) .context("failed to build proven transaction")?; - let batch = ProposedBatch::new( + let batch = ProposedBatch::new_unverified( vec![Arc::new(tx)], reference_block_header, partial_blockchain, diff --git a/crates/miden-protocol/src/batch/proven_batch.rs b/crates/miden-protocol/src/batch/proven_batch.rs index 32a0bf9d18..c8af8c1cab 100644 --- a/crates/miden-protocol/src/batch/proven_batch.rs +++ b/crates/miden-protocol/src/batch/proven_batch.rs @@ -15,11 +15,11 @@ use crate::utils::serde::{ DeserializationError, Serializable, }; +use crate::vm::ExecutionProof; use crate::{MIN_PROOF_SECURITY_LEVEL, Word}; /// A transaction batch with an execution proof. -/// Currently, there is no proof attached. Future versions will extend this structure to include -/// a proof artifact once recursive proving is implemented. +/// Currently, this only carries a skeleton proof which does not attest to anything meaningful. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProvenBatch { id: BatchId, @@ -30,6 +30,7 @@ pub struct ProvenBatch { output_notes: Vec, batch_expiration_block_num: BlockNumber, transactions: OrderedTransactionHeaders, + proof: ExecutionProof, } impl ProvenBatch { @@ -45,6 +46,7 @@ impl ProvenBatch { /// /// Returns an error if the batch expiration block number is not greater than the reference /// block number. + #[allow(clippy::too_many_arguments)] pub fn new_unchecked( id: BatchId, reference_block_commitment: Word, @@ -54,6 +56,7 @@ impl ProvenBatch { output_notes: Vec, batch_expiration_block_num: BlockNumber, transactions: OrderedTransactionHeaders, + proof: ExecutionProof, ) -> Result { // Check that the batch expiration block number is greater than the reference block number. if batch_expiration_block_num <= reference_block_num { @@ -72,6 +75,7 @@ impl ProvenBatch { output_notes, batch_expiration_block_num, transactions, + proof, }) } @@ -144,6 +148,11 @@ impl ProvenBatch { &self.transactions } + /// Returns the execution proof attached to this batch. + pub fn proof(&self) -> &ExecutionProof { + &self.proof + } + // MUTATORS // -------------------------------------------------------------------------------------------- @@ -158,7 +167,6 @@ impl ProvenBatch { impl Serializable for ProvenBatch { fn write_into(&self, target: &mut W) { - self.id.write_into(target); self.reference_block_commitment.write_into(target); self.reference_block_num.write_into(target); self.account_updates.write_into(target); @@ -166,12 +174,12 @@ impl Serializable for ProvenBatch { self.output_notes.write_into(target); self.batch_expiration_block_num.write_into(target); self.transactions.write_into(target); + self.proof.write_into(target); } } impl Deserializable for ProvenBatch { fn read_from(source: &mut R) -> Result { - let id = BatchId::read_from(source)?; let reference_block_commitment = Word::read_from(source)?; let reference_block_num = BlockNumber::read_from(source)?; let account_updates = BTreeMap::read_from(source)?; @@ -179,6 +187,10 @@ impl Deserializable for ProvenBatch { let output_notes = Vec::::read_from(source)?; let batch_expiration_block_num = BlockNumber::read_from(source)?; let transactions = OrderedTransactionHeaders::read_from(source)?; + let proof = ExecutionProof::read_from(source)?; + + let id = + BatchId::from_ids(transactions.as_slice().iter().map(|tx| (tx.id(), tx.account_id()))); Self::new_unchecked( id, @@ -189,6 +201,7 @@ impl Deserializable for ProvenBatch { output_notes, batch_expiration_block_num, transactions, + proof, ) .map_err(|e| DeserializationError::UnknownError(e.to_string())) } diff --git a/crates/miden-protocol/src/block/account_tree/account_id_key.rs b/crates/miden-protocol/src/block/account_tree/account_id_key.rs index 4dca3054f2..5b9f550786 100644 --- a/crates/miden-protocol/src/block/account_tree/account_id_key.rs +++ b/crates/miden-protocol/src/block/account_tree/account_id_key.rs @@ -2,6 +2,7 @@ use miden_crypto::merkle::smt::LeafIndex; use super::AccountId; use crate::Word; +use crate::account::AccountIdPrefix; use crate::crypto::merkle::smt::SMT_DEPTH; use crate::errors::AccountIdError; @@ -49,6 +50,13 @@ impl AccountIdKey { AccountId::try_from_elements(word[Self::KEY_SUFFIX_IDX], word[Self::KEY_PREFIX_IDX]) } + /// Returns the SMT key for an account ID prefix, with only the prefix field set. + pub(crate) fn id_prefix_to_smt_key(prefix: AccountIdPrefix) -> Word { + let mut key = Word::empty(); + key[Self::KEY_PREFIX_IDX] = prefix.as_felt(); + key + } + // LEAF INDEX //--------------------------------------------------------------------------------------------------- diff --git a/crates/miden-protocol/src/block/account_tree/mod.rs b/crates/miden-protocol/src/block/account_tree/mod.rs index e21220831a..eaab82ad26 100644 --- a/crates/miden-protocol/src/block/account_tree/mod.rs +++ b/crates/miden-protocol/src/block/account_tree/mod.rs @@ -1,8 +1,12 @@ use alloc::string::ToString; use alloc::vec::Vec; +#[cfg(feature = "std")] +use miden_crypto::merkle::smt::{LargeSmt, LargeSmtError, SmtStorage}; + use crate::Word; use crate::account::{AccountId, AccountIdPrefix}; +use crate::block::{SmtBackend, SmtBackendReader}; use crate::crypto::merkle::MerkleError; use crate::crypto::merkle::smt::{MutationSet, SMT_DEPTH, Smt, SmtLeaf}; use crate::errors::AccountTreeError; @@ -20,9 +24,6 @@ pub use partial::PartialAccountTree; mod witness; pub use witness::AccountWitness; -mod backend; -pub use backend::AccountTreeBackend; - mod account_id_key; pub use account_id_key::AccountIdKey; @@ -53,7 +54,7 @@ where impl AccountTree where - S: AccountTreeBackend, + S: SmtBackendReader, { // CONSTANTS // -------------------------------------------------------------------------------------------- @@ -61,11 +62,6 @@ where /// The depth of the account tree. pub const DEPTH: u8 = SMT_DEPTH; - /// The index of the account ID suffix in the SMT key. - pub(super) const KEY_SUFFIX_IDX: usize = 2; - /// The index of the account ID prefix in the SMT key. - pub(super) const KEY_PREFIX_IDX: usize = 3; - // CONSTRUCTORS // -------------------------------------------------------------------------------------------- @@ -157,7 +153,7 @@ where /// Returns true if the tree contains a leaf for the given account ID prefix. pub fn contains_account_id_prefix(&self, account_id_prefix: AccountIdPrefix) -> bool { - let key = Self::id_prefix_to_smt_key(account_id_prefix); + let key = AccountIdKey::id_prefix_to_smt_key(account_id_prefix); let is_empty = matches!(self.smt.get_leaf(&key), SmtLeaf::Empty(_)); !is_empty } @@ -181,12 +177,20 @@ where ( // SAFETY: By construction, the tree only contains valid IDs. - AccountId::try_from_elements(key[Self::KEY_SUFFIX_IDX], key[Self::KEY_PREFIX_IDX]) + AccountIdKey::try_from_word(key) .expect("account tree should only contain valid IDs"), commitment, ) }) } +} + +impl AccountTree +where + S: SmtBackend, +{ + // PUBLIC MUTATORS + // -------------------------------------------------------------------------------------------- /// Computes the necessary changes to insert the specified (account ID, state commitment) pairs /// into this tree, allowing for validation before applying those changes. @@ -246,9 +250,6 @@ where Ok(AccountMutationSet::new(mutation_set)) } - // PUBLIC MUTATORS - // -------------------------------------------------------------------------------------------- - /// Inserts the state commitment for the given account ID, returning the previous state /// commitment associated with that ID. /// @@ -315,18 +316,94 @@ where .map_err(AccountTreeError::ApplyMutations)?; Ok(AccountMutationSet::new(reversion)) } +} - // HELPERS - // -------------------------------------------------------------------------------------------- +impl AccountTree { + /// Creates a new [`AccountTree`] with the provided entries. + /// + /// This is a convenience method for testing that creates an SMT backend with the provided + /// entries and wraps it in an AccountTree. It validates that the entries don't contain + /// duplicate prefixes. + /// + /// # Errors + /// + /// Returns an error if: + /// - The provided entries contain duplicate account ID prefixes + /// - The backend fails to create the SMT with the entries + pub fn with_entries( + entries: impl IntoIterator, + ) -> Result + where + I: ExactSizeIterator, + { + // Create the SMT with the entries + let smt = Smt::with_entries( + entries + .into_iter() + .map(|(id, commitment)| (AccountIdKey::from(id).as_word(), commitment)), + ) + .map_err(duplicate_state_commitment_error)?; + + AccountTree::new(smt) + } +} + +#[cfg(feature = "std")] +impl AccountTree> +where + Backend: SmtStorage, +{ + /// Creates a new account tree from the provided entries using the given storage backend. + /// + /// This is a convenience method that creates an SMT on the provided storage backend using the + /// provided entries and wraps it in an AccountTree. + /// + /// # Errors + /// + /// Returns an error if the provided entries contain duplicate account ID prefixes or duplicate + /// state commitments for the same account ID. + /// + /// # Panics + /// + /// Panics if a storage error is encountered. + pub fn with_storage_from_entries( + storage: Backend, + entries: impl IntoIterator, + ) -> Result { + use crate::block::smt_backend::large_smt_error_to_merkle_error; + + let leaves = entries + .into_iter() + .map(|(id, commitment)| (AccountIdKey::from(id).as_word(), commitment)); + + let smt = LargeSmt::::with_entries(storage, leaves) + .map_err(large_smt_error_to_merkle_error) + .map_err(duplicate_state_commitment_error)?; + + AccountTree::new(smt) + } + + /// Returns a read-only account tree backed by a reader view of this tree's storage. + pub fn reader(&self) -> Result>, LargeSmtError> { + Ok(AccountTree::new_unchecked(self.smt.reader()?)) + } +} - /// Returns the SMT key of the given account ID prefix. - fn id_prefix_to_smt_key(account_id: AccountIdPrefix) -> Word { - // We construct this in such a way that we're forced to use the constants, so that when - // they're updated, the other usages of the constants are also updated. - let mut key = Word::empty(); - key[Self::KEY_PREFIX_IDX] = account_id.as_felt(); +// HELPER FUNCTIONS +// ================================================================================================ - key +/// Maps the duplicate-key error returned by the SMT constructors to an [`AccountTreeError`]. +fn duplicate_state_commitment_error(err: MerkleError) -> AccountTreeError { + let MerkleError::DuplicateValuesForIndex(leaf_idx) = err else { + unreachable!("the only error returned by the SMT constructors is a duplicate-value error"); + }; + + // SAFETY: Since we only inserted account IDs into the SMT, it is guaranteed that the leaf_idx + // is a valid Felt as well as a valid account ID prefix. + AccountTreeError::DuplicateStateCommitments { + prefix: AccountIdPrefix::new_unchecked( + crate::Felt::try_from(leaf_idx).expect("leaf index should be a valid felt"), + ), } } @@ -702,4 +779,27 @@ pub(super) mod tests { assert_eq!(large_commitments, regular_commitments); } + + #[cfg(feature = "std")] + #[test] + fn with_storage_from_entries_rejects_duplicate_state_commitments() { + use miden_crypto::merkle::smt::MemoryStorage; + + let id = AccountIdBuilder::new().build_with_seed([5; 32]); + let commitment0 = Word::from([0, 0, 0, 1u32]); + let commitment1 = Word::from([0, 0, 0, 2u32]); + + // The same account ID appears twice, which must surface the same structured error as the + // `Smt`-backed `with_entries` path. + let err = AccountTree::with_storage_from_entries( + MemoryStorage::default(), + [(id, commitment0), (id, commitment1)], + ) + .unwrap_err(); + + assert_matches!( + err, + AccountTreeError::DuplicateStateCommitments { prefix } if prefix == id.prefix() + ); + } } diff --git a/crates/miden-protocol/src/block/account_update_witness.rs b/crates/miden-protocol/src/block/account_update_witness.rs index 359784d3b7..afa6728219 100644 --- a/crates/miden-protocol/src/block/account_update_witness.rs +++ b/crates/miden-protocol/src/block/account_update_witness.rs @@ -1,5 +1,5 @@ use crate::Word; -use crate::account::delta::AccountUpdateDetails; +use crate::account::AccountUpdateDetails; use crate::block::account_tree::AccountWitness; use crate::utils::serde::{ ByteReader, @@ -14,8 +14,7 @@ use crate::utils::serde::{ /// - The witness is an smt proof of the initial state commitment of the account before the block in /// which the witness is included, that is, in the account tree at the state of the previous block /// header. -/// - The account update details represent the delta between the state of the account before the -/// block and the state after this block. +/// - The [`AccountUpdateDetails`] representing the change applied to the account by the block. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AccountUpdateWitness { /// The state commitment before the update. diff --git a/crates/miden-protocol/src/block/block_account_update.rs b/crates/miden-protocol/src/block/block_account_update.rs index 8b809151d6..97b7084bba 100644 --- a/crates/miden-protocol/src/block/block_account_update.rs +++ b/crates/miden-protocol/src/block/block_account_update.rs @@ -1,6 +1,5 @@ use crate::Word; -use crate::account::AccountId; -use crate::account::delta::AccountUpdateDetails; +use crate::account::{AccountId, AccountUpdateDetails}; use crate::utils::serde::{ ByteReader, ByteWriter, diff --git a/crates/miden-protocol/src/block/header.rs b/crates/miden-protocol/src/block/header.rs index 81574c16bd..a7a0d1f84b 100644 --- a/crates/miden-protocol/src/block/header.rs +++ b/crates/miden-protocol/src/block/header.rs @@ -1,5 +1,7 @@ use alloc::vec::Vec; +use miden_crypto::dsa::ecdsa_k256_keccak::Signature; + use crate::account::AccountId; use crate::block::BlockNumber; use crate::crypto::dsa::ecdsa_k256_keccak::PublicKey; @@ -30,7 +32,7 @@ use crate::{Felt, Hasher, Word, ZERO}; /// - `tx_commitment` is a commitment to the set of transaction IDs which affected accounts in the /// block. /// - `tx_kernel_commitment` a commitment to all transaction kernels supported by this block. -/// - `validator_key` is the public key of the validator that is expected to sign the block. +/// - `validator_key` is the public key of the validator authorized to sign the *next* block. /// - `fee_parameters` are the parameters defining the base fees and the fee faucet ID, see /// [`FeeParameters`] for more details. /// - `timestamp` is the time when the block was created, in seconds since UNIX epoch. Current @@ -171,7 +173,10 @@ impl BlockHeader { self.note_root } - /// Returns the public key of the block's validator. + /// Returns the public key of the validator authorized to sign the *next* block. + /// + /// A block's signature is verified against the `validator_key` committed to by its parent + /// block, not against this field. See the [`BlockHeader`] docs for details. pub fn validator_key(&self) -> &PublicKey { &self.validator_key } @@ -208,6 +213,56 @@ impl BlockHeader { BlockNumber::from_epoch(self.block_epoch()) } + // VALIDATION + // -------------------------------------------------------------------------------------------- + + /// Validates that `parent` precedes and authorizes this block. + /// + /// The `signature` is the signature of the validator defined by the parent block against this + /// header. + /// + /// # Errors + /// + /// Returns an error if the block is the genesis block (no parent), the parent's number or + /// commitment do not match, or the signature does not verify against the parent's validator + /// key. + pub(crate) fn validate_against_parent( + &self, + parent: &BlockHeader, + signature: &Signature, + ) -> Result<(), ParentValidationError> { + // Block 0 does not have a parent. + let Some(expected_parent_num) = self.block_num().checked_sub(1) else { + return Err(ParentValidationError::GenesisBlockHasNoParent { + parent: parent.block_num(), + }); + }; + + // Check block numbers. + if expected_parent_num != parent.block_num() { + return Err(ParentValidationError::ParentNumberMismatch { + expected: expected_parent_num, + parent: parent.block_num(), + }); + } + + // Check commitments. + let expected_prev_commitment = self.prev_block_commitment(); + if expected_prev_commitment != parent.commitment() { + return Err(ParentValidationError::ParentCommitmentMismatch { + expected: expected_prev_commitment, + parent: parent.commitment(), + }); + } + + // Verify the signature against the validator key authorized by the parent block. + if !signature.verify(self.commitment(), parent.validator_key()) { + return Err(ParentValidationError::InvalidSignature); + } + + Ok(()) + } + // HELPERS // -------------------------------------------------------------------------------------------- @@ -249,6 +304,52 @@ impl BlockHeader { elements.extend([ZERO, ZERO, ZERO, ZERO]); Hasher::hash_elements(&elements) } + + // TEST HELPERS + // -------------------------------------------------------------------------------------------- + + /// Builds a minimal block header with a controllable block number, previous-block commitment, + /// and validator key. + /// + /// The remaining roots are zeroed except the note root and transaction commitment, which match + /// the empty [`BlockBody`](super::BlockBody) the block tests pair this header with, so the + /// self-consistency checks in `SignedBlock::validate` and `ProvenBlock::validate` pass. + #[cfg(test)] + pub(crate) fn new_dummy( + block_num: u32, + prev_block_commitment: Word, + validator_key: miden_crypto::dsa::ecdsa_k256_keccak::PublicKey, + ) -> Self { + use crate::block::{BlockBody, FeeParameters}; + use crate::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET; + use crate::transaction::OrderedTransactionHeaders; + + let body = BlockBody::new_unchecked( + Vec::new(), + Vec::new(), + Vec::new(), + OrderedTransactionHeaders::new_unchecked(Vec::new()), + ); + let note_root = body.compute_block_note_tree().root(); + let tx_commitment = body.transactions().commitment(); + + let fee_parameters = + FeeParameters::new(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap(), 500); + BlockHeader::new( + 0, + prev_block_commitment, + BlockNumber::from(block_num), + Word::empty(), + Word::empty(), + Word::empty(), + note_root, + tx_commitment, + Word::empty(), + validator_key, + fee_parameters, + 0, + ) + } } // SERIALIZATION @@ -364,9 +465,6 @@ impl FeeParameters { } } -// SERIALIZATION -// ================================================================================================ - impl Serializable for FeeParameters { fn write_into(&self, target: &mut W) { self.fee_faucet_id.write_into(target); @@ -383,6 +481,30 @@ impl Deserializable for FeeParameters { } } +// PARENT VALIDATION ERROR +// ================================================================================================ + +/// Error returned when a block fails validation against its parent block. +/// +/// This is the shared, block-type-agnostic error produced by +/// [`BlockHeader::validate_against_parent`]. Each block type maps it into its own error enum via +/// `From`, which preserves that type's specific error messages. +#[derive(Debug)] +pub(crate) enum ParentValidationError { + InvalidSignature, + ParentNumberMismatch { + expected: BlockNumber, + parent: BlockNumber, + }, + ParentCommitmentMismatch { + expected: Word, + parent: Word, + }, + GenesisBlockHasNoParent { + parent: BlockNumber, + }, +} + // TESTS // ================================================================================================ @@ -392,6 +514,7 @@ mod tests { use miden_crypto::rand::test_utils::rand_value; use super::*; + use crate::testing::random_secret_key::random_secret_key; #[test] fn test_serde() { @@ -410,4 +533,78 @@ mod tests { assert_eq!(deserialized, header); } + + /// Expected outcome of a [`BlockHeader::validate_against_parent`] case. + enum Expect { + Ok, + InvalidSignature, + Genesis, + ParentNumber, + ParentCommitment, + } + + /// Exercises `validate_against_parent` against a parent that commits `validator` as the signer + /// of the child. Each case tweaks one input: the child's block number, whether it links to the + /// parent, and whether it is signed by the committed key. + #[rstest::rstest] + // Signed by the committed key and correctly linked: accepted. The next key it commits is free. + #[case::accepts(1, true, true, Expect::Ok)] + // The rotation bug: self-signed with a key the parent never committed. + #[case::self_signed_uncommitted_key(1, true, false, Expect::InvalidSignature)] + // Genesis has no parent to anchor against. + #[case::genesis(0, true, true, Expect::Genesis)] + // Child claims to be block 2, but the parent is block 0. + #[case::wrong_parent_number(2, true, true, Expect::ParentNumber)] + // Child's prev_block_commitment does not match the parent's commitment. + #[case::wrong_parent_commitment(1, false, true, Expect::ParentCommitment)] + fn validate_against_parent_cases( + #[case] child_num: u32, + #[case] link_to_parent: bool, + #[case] sign_with_committed_key: bool, + #[case] expected: Expect, + ) { + let validator = random_secret_key(); + let parent = BlockHeader::new_dummy(0, Word::empty(), validator.public_key()); + + let prev_commitment = if link_to_parent { + parent.commitment() + } else { + Word::empty() + }; + + // The signer and the key the child commits as the next block's signer. + let (signer, child_validator_key) = if sign_with_committed_key { + (validator, random_secret_key().public_key()) + } else { + let impostor = random_secret_key(); + let key = impostor.public_key(); + (impostor, key) + }; + + let child = BlockHeader::new_dummy(child_num, prev_commitment, child_validator_key); + let signature = signer.sign(child.commitment()); + let result = child.validate_against_parent(&parent, &signature); + + match expected { + Expect::Ok => result.unwrap(), + Expect::InvalidSignature => { + assert!(matches!(result, Err(ParentValidationError::InvalidSignature))); + }, + Expect::Genesis => { + assert!(matches!( + result, + Err(ParentValidationError::GenesisBlockHasNoParent { .. }) + )); + }, + Expect::ParentNumber => { + assert!(matches!(result, Err(ParentValidationError::ParentNumberMismatch { .. }))); + }, + Expect::ParentCommitment => { + assert!(matches!( + result, + Err(ParentValidationError::ParentCommitmentMismatch { .. }) + )); + }, + } + } } diff --git a/crates/miden-protocol/src/block/mod.rs b/crates/miden-protocol/src/block/mod.rs index cb2df1a489..5d5b20f40c 100644 --- a/crates/miden-protocol/src/block/mod.rs +++ b/crates/miden-protocol/src/block/mod.rs @@ -22,6 +22,9 @@ pub use proven_block::ProvenBlock; pub mod account_tree; pub mod nullifier_tree; +mod smt_backend; +pub use smt_backend::{SmtBackend, SmtBackendReader}; + mod blockchain; pub use blockchain::Blockchain; diff --git a/crates/miden-protocol/src/block/nullifier_tree/backend.rs b/crates/miden-protocol/src/block/nullifier_tree/backend.rs deleted file mode 100644 index 90f0955046..0000000000 --- a/crates/miden-protocol/src/block/nullifier_tree/backend.rs +++ /dev/null @@ -1,237 +0,0 @@ -use alloc::boxed::Box; - -use super::{BlockNumber, Nullifier, NullifierBlock, NullifierTree, NullifierTreeError}; -use crate::Word; -use crate::crypto::merkle::MerkleError; -#[cfg(feature = "std")] -use crate::crypto::merkle::smt::{LargeSmt, LargeSmtError, SmtStorage}; -use crate::crypto::merkle::smt::{MutationSet, SMT_DEPTH, Smt, SmtProof}; - -// NULLIFIER TREE BACKEND -// ================================================================================================ - -/// This trait abstracts over different SMT backends (e.g., `Smt` and `LargeSmt`) to allow -/// the `NullifierTree` to work with either implementation transparently. -/// -/// Users should instantiate the backend directly (potentially with entries) and then -/// pass it to [`NullifierTree::new_unchecked`]. -/// -/// # Invariants -/// -/// Assumes the provided SMT upholds the guarantees of the [`NullifierTree`]. Specifically: -/// - Nullifiers are only spent once and their block numbers do not change. -/// - Nullifier leaf values must be valid according to [`NullifierBlock`]. -pub trait NullifierTreeBackend: Sized { - type Error: core::error::Error + Send + 'static; - - /// Returns the number of entries in the SMT. - fn num_entries(&self) -> usize; - - /// Returns all entries in the SMT as an iterator over key-value pairs. - fn entries(&self) -> Box + '_>; - - /// Opens the leaf at the given key, returning a Merkle proof. - fn open(&self, key: &Word) -> SmtProof; - - /// Applies the given mutation set to the SMT. - fn apply_mutations( - &mut self, - set: MutationSet, - ) -> Result<(), Self::Error>; - - /// Computes the mutation set required to apply the given updates to the SMT. - fn compute_mutations( - &self, - updates: impl IntoIterator, - ) -> Result, Self::Error>; - - /// Inserts a key-value pair into the SMT, returning the previous value at that key. - fn insert(&mut self, key: Word, value: NullifierBlock) -> Result; - - /// Returns the value associated with the given key. - fn get_value(&self, key: &Word) -> NullifierBlock; - - /// Returns the root of the SMT. - fn root(&self) -> Word; -} - -// BACKEND IMPLEMENTATION FOR SMT -// ================================================================================================ - -impl NullifierTreeBackend for Smt { - type Error = MerkleError; - - fn num_entries(&self) -> usize { - Smt::num_entries(self) - } - - fn entries(&self) -> Box + '_> { - Box::new(Smt::entries(self).map(|(k, v)| (*k, *v))) - } - - fn open(&self, key: &Word) -> SmtProof { - Smt::open(self, key) - } - - fn apply_mutations( - &mut self, - set: MutationSet, - ) -> Result<(), Self::Error> { - Smt::apply_mutations(self, set) - } - - fn compute_mutations( - &self, - updates: impl IntoIterator, - ) -> Result, Self::Error> { - Smt::compute_mutations(self, updates) - } - - fn insert(&mut self, key: Word, value: NullifierBlock) -> Result { - Smt::insert(self, key, value.into()).map(|word| { - NullifierBlock::try_from(word).expect("SMT should only store valid NullifierBlocks") - }) - } - - fn get_value(&self, key: &Word) -> NullifierBlock { - NullifierBlock::new(Smt::get_value(self, key)) - .expect("SMT should only store valid NullifierBlocks") - } - - fn root(&self) -> Word { - Smt::root(self) - } -} - -// NULLIFIER TREE BACKEND FOR LARGE SMT -// ================================================================================================ - -#[cfg(feature = "std")] -impl NullifierTreeBackend for LargeSmt -where - Backend: SmtStorage, -{ - type Error = MerkleError; - - fn num_entries(&self) -> usize { - LargeSmt::num_entries(self) - } - - fn entries(&self) -> Box + '_> { - // SAFETY: We expect here as only I/O errors can occur. Storage failures are considered - // unrecoverable at this layer. See issue #2010 for future error handling improvements. - Box::new(LargeSmt::entries(self).expect("Storage I/O error accessing entries")) - } - - fn open(&self, key: &Word) -> SmtProof { - LargeSmt::open(self, key) - } - - fn apply_mutations( - &mut self, - set: MutationSet, - ) -> Result<(), Self::Error> { - LargeSmt::apply_mutations(self, set).map_err(large_smt_error_to_merkle_error) - } - - fn compute_mutations( - &self, - updates: impl IntoIterator, - ) -> Result, Self::Error> { - LargeSmt::compute_mutations(self, updates).map_err(large_smt_error_to_merkle_error) - } - - fn insert(&mut self, key: Word, value: NullifierBlock) -> Result { - LargeSmt::insert(self, key, value.into()).map(|word| { - NullifierBlock::try_from(word).expect("SMT should only store valid NullifierBlocks") - }) - } - - fn get_value(&self, key: &Word) -> NullifierBlock { - LargeSmt::get_value(self, key) - .try_into() - .expect("unable to create NullifierBlock") - } - - fn root(&self) -> Word { - LargeSmt::root(self) - } -} - -// CONVENIENCE METHODS -// ================================================================================================ - -impl NullifierTree { - /// Creates a new nullifier tree from the provided entries. - /// - /// This is a convenience method that creates an SMT backend with the provided entries and - /// wraps it in a NullifierTree. - /// - /// # Errors - /// - /// Returns an error if: - /// - the provided entries contain multiple block numbers for the same nullifier. - pub fn with_entries( - entries: impl IntoIterator, - ) -> Result { - let leaves = entries.into_iter().map(|(nullifier, block_num)| { - (nullifier.as_word(), NullifierBlock::from(block_num).into()) - }); - - let smt = Smt::with_entries(leaves) - .map_err(NullifierTreeError::DuplicateNullifierBlockNumbers)?; - - Ok(Self::new_unchecked(smt)) - } -} - -#[cfg(feature = "std")] -impl NullifierTree> -where - Backend: SmtStorage, -{ - /// Creates a new nullifier tree from the provided entries using the given storage backend - /// - /// This is a convenience method that creates an SMT on the provided storage backend using the - /// provided entries and wraps it in a NullifierTree. - /// - /// # Errors - /// - /// Returns an error if: - /// - the provided entries contain multiple block numbers for the same nullifier. - /// - a storage error is encountered. - pub fn with_storage_from_entries( - storage: Backend, - entries: impl IntoIterator, - ) -> Result { - let leaves = entries.into_iter().map(|(nullifier, block_num)| { - (nullifier.as_word(), NullifierBlock::from(block_num).into()) - }); - - let smt = LargeSmt::::with_entries(storage, leaves) - .map_err(large_smt_error_to_merkle_error) - .map_err(NullifierTreeError::DuplicateNullifierBlockNumbers)?; - - Ok(Self::new_unchecked(smt)) - } -} - -// HELPER FUNCTIONS -// ================================================================================================ - -#[cfg(feature = "std")] -pub(super) fn large_smt_error_to_merkle_error(err: LargeSmtError) -> MerkleError { - match err { - LargeSmtError::Storage(storage_err) => { - panic!("Storage error encountered: {:?}", storage_err) - }, - LargeSmtError::StorageNotEmpty => { - panic!("StorageNotEmpty error encountered: {:?}", err) - }, - LargeSmtError::Merkle(merkle_err) => merkle_err, - LargeSmtError::RootMismatch { expected, actual } => MerkleError::ConflictingRoots { - expected_root: expected, - actual_root: actual, - }, - } -} diff --git a/crates/miden-protocol/src/block/nullifier_tree/mod.rs b/crates/miden-protocol/src/block/nullifier_tree/mod.rs index 48d058d086..18576f4458 100644 --- a/crates/miden-protocol/src/block/nullifier_tree/mod.rs +++ b/crates/miden-protocol/src/block/nullifier_tree/mod.rs @@ -1,7 +1,10 @@ use alloc::string::ToString; use alloc::vec::Vec; -use crate::block::BlockNumber; +#[cfg(feature = "std")] +use miden_crypto::merkle::smt::{LargeSmt, LargeSmtError, SmtStorage}; + +use crate::block::{BlockNumber, SmtBackend, SmtBackendReader}; use crate::crypto::merkle::MerkleError; use crate::crypto::merkle::smt::{MutationSet, SMT_DEPTH, Smt}; use crate::errors::NullifierTreeError; @@ -15,9 +18,6 @@ use crate::utils::serde::{ }; use crate::{Felt, Word}; -mod backend; -pub use backend::NullifierTreeBackend; - mod witness; pub use witness::NullifierWitness; @@ -51,7 +51,7 @@ where impl NullifierTree where - Backend: NullifierTreeBackend, + Backend: SmtBackendReader, { // CONSTANTS // -------------------------------------------------------------------------------------------- @@ -66,7 +66,9 @@ where /// /// # Invariants /// - /// See the documentation on [`NullifierTreeBackend`] trait documentation. + /// Assumes the provided SMT upholds the guarantees of the [`NullifierTree`]. Specifically: + /// - Nullifiers are only spent once and their block numbers do not change. + /// - Nullifier leaf values must be valid according to [`NullifierBlock`]. pub fn new_unchecked(backend: Backend) -> Self { NullifierTree { smt: backend } } @@ -109,13 +111,22 @@ where /// Returns the block number for the given nullifier or `None` if the nullifier wasn't spent /// yet. pub fn get_block_num(&self, nullifier: &Nullifier) -> Option { - let nullifier_block = self.smt.get_value(&nullifier.as_word()); + let nullifier_block = NullifierBlock::new(self.smt.get_value(&nullifier.as_word())) + .expect("SMT should only store valid NullifierBlocks"); if nullifier_block.is_unspent() { return None; } Some(nullifier_block.into()) } +} + +impl NullifierTree +where + Backend: SmtBackend, +{ + // PUBLIC MUTATORS + // -------------------------------------------------------------------------------------------- /// Computes a mutation set resulting from inserting the provided nullifiers into this nullifier /// tree. @@ -154,9 +165,6 @@ where Ok(NullifierMutationSet::new(mutation_set)) } - // PUBLIC MUTATORS - // -------------------------------------------------------------------------------------------- - /// Marks the given nullifier as spent at the given block number. /// /// # Errors @@ -168,10 +176,12 @@ where nullifier: Nullifier, block_num: BlockNumber, ) -> Result<(), NullifierTreeError> { - let prev_nullifier_value = self + let prev_value = self .smt - .insert(nullifier.as_word(), NullifierBlock::from(block_num)) + .insert(nullifier.as_word(), NullifierBlock::from(block_num).into()) .map_err(NullifierTreeError::MaxLeafEntriesExceeded)?; + let prev_nullifier_value = NullifierBlock::try_from(prev_value) + .expect("SMT should only store valid NullifierBlocks"); if prev_nullifier_value.is_spent() { Err(NullifierTreeError::NullifierAlreadySpent(nullifier)) @@ -196,6 +206,75 @@ where } } +impl NullifierTree { + /// Creates a new nullifier tree from the provided entries. + /// + /// This is a convenience method that creates an SMT backend with the provided entries and + /// wraps it in a NullifierTree. + /// + /// # Errors + /// + /// Returns an error if: + /// - the provided entries contain multiple block numbers for the same nullifier. + pub fn with_entries( + entries: impl IntoIterator, + ) -> Result { + let leaves = entries.into_iter().map(|(nullifier, block_num)| { + (nullifier.as_word(), NullifierBlock::from(block_num).into()) + }); + + let smt = Smt::with_entries(leaves) + .map_err(NullifierTreeError::DuplicateNullifierBlockNumbers)?; + + Ok(Self::new_unchecked(smt)) + } +} + +#[cfg(feature = "std")] +impl NullifierTree> +where + Backend: SmtStorage, +{ + /// Creates a new nullifier tree from the provided entries using the given storage backend + /// + /// This is a convenience method that creates an SMT on the provided storage backend using the + /// provided entries and wraps it in a NullifierTree. + /// + /// # Errors + /// + /// Returns an error if: + /// - the provided entries contain multiple block numbers for the same nullifier. + /// + /// # Panics + /// + /// Panics if a storage error is encountered. + pub fn with_storage_from_entries( + storage: Backend, + entries: impl IntoIterator, + ) -> Result { + use crate::block::smt_backend::large_smt_error_to_merkle_error; + + let leaves = entries.into_iter().map(|(nullifier, block_num)| { + (nullifier.as_word(), NullifierBlock::from(block_num).into()) + }); + + let smt = LargeSmt::::with_entries(storage, leaves) + .map_err(large_smt_error_to_merkle_error) + .map_err(NullifierTreeError::DuplicateNullifierBlockNumbers)?; + + Ok(Self::new_unchecked(smt)) + } + + /// Returns a read-only nullifier tree backed by a reader view of this tree's storage. + /// + /// The returned tree shares the same root and entries as `self`, but its storage is a + /// read-only snapshot produced by [`SmtStorage::reader`]. The returned tree cannot be + /// mutated. + pub fn reader(&self) -> Result>, LargeSmtError> { + Ok(NullifierTree::new_unchecked(self.smt.reader()?)) + } +} + // SERIALIZATION // ================================================================================================ diff --git a/crates/miden-protocol/src/block/proposed_block.rs b/crates/miden-protocol/src/block/proposed_block.rs index 6dd28fdbe6..5a862b0b4b 100644 --- a/crates/miden-protocol/src/block/proposed_block.rs +++ b/crates/miden-protocol/src/block/proposed_block.rs @@ -2,15 +2,9 @@ use alloc::boxed::Box; use alloc::collections::{BTreeMap, BTreeSet}; use alloc::vec::Vec; -use crate::account::AccountId; -use crate::account::delta::AccountUpdateDetails; -use crate::batch::{ - BatchAccountUpdate, - BatchId, - InputOutputNoteTracker, - OrderedBatches, - ProvenBatch, -}; +use crate::account::{AccountId, AccountUpdateDetails}; +use crate::batch::note_tracker::{NoteTracker, TrackerOutput}; +use crate::batch::{BatchAccountUpdate, BatchId, OrderedBatches, ProvenBatch}; use crate::block::account_tree::{AccountWitness, PartialAccountTree}; use crate::block::block_inputs::BlockInputs; use crate::block::nullifier_tree::{NullifierWitness, PartialNullifierTree}; @@ -23,6 +17,7 @@ use crate::block::{ BlockNumber, OutputNoteBatch, }; +use crate::crypto::dsa::ecdsa_k256_keccak::PublicKey; use crate::errors::ProposedBlockError; use crate::note::{NoteId, Nullifier}; use crate::transaction::{ @@ -78,6 +73,12 @@ pub struct ProposedBlock { /// /// As part of proving the block, this header will be added to the next partial blockchain. prev_block_header: BlockHeader, + /// The validator public key authorized to sign the *next* block, which is committed to in this + /// block's header. + /// + /// Defaults to the previous block's `validator_key` (i.e. no rotation). Set a different key + /// via [`ProposedBlock::with_next_validator_key`] to rotate the validator key. + next_validator_key: PublicKey, } impl ProposedBlock { @@ -116,8 +117,8 @@ impl ProposedBlock { /// /// - The union of all input notes across all batches contain duplicates. /// - The union of all output notes across all batches contain duplicates. - /// - There is an unauthenticated input note and an output note with the same note ID but their - /// note commitments are different (i.e. their metadata is different). + /// - An unauthenticated note is consumed before it is created (as determined by the order in + /// which batches are given). /// - There is a note inclusion proof for an unauthenticated note whose referenced block is not /// in the [`PartialBlockchain`]. /// - The note inclusion proof for an unauthenticated is invalid. @@ -191,13 +192,19 @@ impl ProposedBlock { // authenticating unauthenticated notes. // -------------------------------------------------------------------------------------------- - let (block_input_notes, block_erased_notes, block_output_notes) = - InputOutputNoteTracker::from_batches( - batches.iter(), - block_inputs.unauthenticated_note_proofs(), - block_inputs.partial_blockchain(), - block_inputs.prev_block_header(), - )?; + let mut tracker = NoteTracker::new( + block_inputs.partial_blockchain(), + block_inputs.prev_block_header(), + block_inputs.unauthenticated_note_proofs(), + ); + for batch in batches.iter() { + tracker.push(batch)?; + } + let TrackerOutput { + input_notes: block_input_notes, + erased_notes: block_erased_notes, + output_notes: block_output_notes, + } = tracker.finalize()?; // All unauthenticated notes must be erased or authenticated by now. if let Some(nullifier) = block_input_notes @@ -238,6 +245,8 @@ impl ProposedBlock { // Build proposed blocks from parts. // -------------------------------------------------------------------------------------------- + let next_validator_key = prev_block_header.validator_key().clone(); + Ok(Self { batches: OrderedBatches::new(batches), timestamp, @@ -246,6 +255,7 @@ impl ProposedBlock { created_nullifiers: nullifier_witnesses, partial_blockchain, prev_block_header, + next_validator_key, }) } @@ -273,6 +283,21 @@ impl ProposedBlock { Self::new_at(block_inputs, batches, timestamp) } + // BUILDERS + // -------------------------------------------------------------------------------------------- + + /// Sets the validator key that this block commits to as the signer of the *next* block, + /// rotating away from the previous block's validator key. + /// + /// The block this proposed block produces is still signed by the current validator (the key + /// committed to by the previous block); the provided key only takes effect for the following + /// block. + #[must_use] + pub fn with_next_validator_key(mut self, next_validator_key: PublicKey) -> Self { + self.next_validator_key = next_validator_key; + self + } + // ACCESSORS // -------------------------------------------------------------------------------------------- @@ -326,6 +351,11 @@ impl ProposedBlock { self.timestamp } + /// Returns the validator key committed to by this block as the signer of the next block. + pub fn next_validator_key(&self) -> &PublicKey { + &self.next_validator_key + } + // COMMITMENT COMPUTATIONS // -------------------------------------------------------------------------------------------- @@ -456,9 +486,6 @@ impl ProposedBlock { /// - the transaction commitment; and /// - the chain commitment. /// - /// The returned block header contains the same validator public key as the previous block, as - /// provided by the proposed block. - /// /// # Errors /// /// Returns an error if any of the following conditions are met. @@ -483,6 +510,7 @@ impl ProposedBlock { let block_num = self.block_num(); let timestamp = self.timestamp(); let prev_block_header = self.prev_block_header().clone(); + let next_validator_key = self.next_validator_key.clone(); // Insert the state commitments of updated accounts into the account tree to compute its new // root. @@ -527,7 +555,7 @@ impl ProposedBlock { note_root, tx_commitment, tx_kernel_commitment, - prev_block_header.validator_key().clone(), + next_validator_key, fee_parameters, timestamp, ); @@ -570,6 +598,7 @@ impl Serializable for ProposedBlock { self.created_nullifiers.write_into(target); self.partial_blockchain.write_into(target); self.prev_block_header.write_into(target); + self.next_validator_key.write_into(target); } } @@ -583,6 +612,7 @@ impl Deserializable for ProposedBlock { created_nullifiers: >::read_from(source)?, partial_blockchain: PartialBlockchain::read_from(source)?, prev_block_header: BlockHeader::read_from(source)?, + next_validator_key: PublicKey::read_from(source)?, }; Ok(block) diff --git a/crates/miden-protocol/src/block/proven_block.rs b/crates/miden-protocol/src/block/proven_block.rs index 68abc97d23..8e3b3a9b58 100644 --- a/crates/miden-protocol/src/block/proven_block.rs +++ b/crates/miden-protocol/src/block/proven_block.rs @@ -2,7 +2,8 @@ use miden_core::Word; use miden_crypto::dsa::ecdsa_k256_keccak::Signature; use crate::MIN_PROOF_SECURITY_LEVEL; -use crate::block::{BlockBody, BlockHeader, BlockProof}; +use crate::block::header::ParentValidationError; +use crate::block::{BlockBody, BlockHeader, BlockNumber, BlockProof}; use crate::utils::serde::{ ByteReader, ByteWriter, @@ -17,7 +18,7 @@ use crate::utils::serde::{ #[derive(Debug, thiserror::Error)] pub enum ProvenBlockError { #[error( - "ECDSA signature verification failed based on the proven block's header commitment, validator public key and signature" + "ECDSA signature verification failed based on the proven block's header commitment, the parent block's validator public key and signature" )] InvalidSignature, #[error( @@ -31,6 +32,34 @@ pub enum ProvenBlockError { "proven block header note root ({header_root}) does not match the corresponding body's note root ({body_root})" )] NoteRootMismatch { header_root: Word, body_root: Word }, + #[error( + "proven block previous block commitment ({expected}) does not match expected parent's block commitment ({parent})" + )] + ParentCommitmentMismatch { expected: Word, parent: Word }, + #[error("parent block number ({parent}) is not proven block number - 1 ({expected})")] + ParentNumberMismatch { + expected: BlockNumber, + parent: BlockNumber, + }, + #[error("supplied parent block ({parent}) cannot be parent to genesis block")] + GenesisBlockHasNoParent { parent: BlockNumber }, +} + +impl From for ProvenBlockError { + fn from(err: ParentValidationError) -> Self { + match err { + ParentValidationError::InvalidSignature => Self::InvalidSignature, + ParentValidationError::ParentNumberMismatch { expected, parent } => { + Self::ParentNumberMismatch { expected, parent } + }, + ParentValidationError::ParentCommitmentMismatch { expected, parent } => { + Self::ParentCommitmentMismatch { expected, parent } + }, + ParentValidationError::GenesisBlockHasNoParent { parent } => { + Self::GenesisBlockHasNoParent { parent } + }, + } + } } // PROVEN BLOCK @@ -60,8 +89,10 @@ pub struct ProvenBlock { impl ProvenBlock { /// Returns a new [`ProvenBlock`] instantiated from the provided components. /// - /// Validates that the provided components correspond to each other by verifying the signature, - /// and checking for matching transaction commitments and note roots. + /// Validates that the header and body correspond by checking the transaction commitment and + /// note root. This does NOT verify the validator signature, which can only be checked against + /// the parent block's validator key; call [`Self::validate`] with the parent header to + /// authenticate the block. /// /// Involves non-trivial computation. Use [`Self::new_unchecked`] if the validation is not /// necessary. @@ -77,8 +108,6 @@ impl ProvenBlock { /// /// # Errors /// Returns an error if: - /// - If the validator signature does not verify against the block header commitment and the - /// validator key. /// - If the transaction commitment in the block header is inconsistent with the transactions /// included in the block body. /// - If the note root in the block header is inconsistent with the notes included in the block @@ -91,7 +120,7 @@ impl ProvenBlock { ) -> Result { let proven_block = Self { header, signature, body, proof }; - proven_block.validate()?; + proven_block.validate(None)?; Ok(proven_block) } @@ -111,8 +140,15 @@ impl ProvenBlock { Self { header, signature, body, proof } } - /// Validates that the components of the proven block correspond to each other by verifying the - /// signature, and checking for matching transaction commitments and note roots. + /// Validates that the components of the proven block correspond by checking the transaction + /// commitment and note root, and -- when `parent` is provided -- authenticates the block + /// against its parent. + /// + /// Pass `Some(parent)` to additionally authenticate the block against its parent; pass `None` + /// for the genesis block, which has no parent, or when only self-consistency is required. + /// + /// `parent` MUST come from already-trusted chain state. Because `prev_block_commitment` is + /// attacker-controlled, passing an untrusted parent would let a forged block self-authorize. /// /// Validation involves non-trivial computation, and depending on the size of the block may /// take non-negligible amount of time. @@ -128,22 +164,25 @@ impl ProvenBlock { /// /// # Errors /// Returns an error if: - /// - If the validator signature does not verify against the block header commitment and the - /// validator key. - /// - If the transaction commitment in the block header is inconsistent with the transactions - /// included in the block body. - /// - If the note root in the block header is inconsistent with the notes included in the block - /// body. - pub fn validate(&self) -> Result<(), ProvenBlockError> { - // Verify signature. - self.validate_signature()?; - + /// - the transaction commitment in the block header is inconsistent with the transactions + /// included in the block body; + /// - the note root in the block header is inconsistent with the notes included in the block + /// body; or + /// - a `parent` is provided and the block is not authorized by it: the block is the genesis + /// block (which has no parent), the parent's number or commitment do not match, or the + /// signature does not verify against the parent's validator key. + pub fn validate(&self, parent: Option<&BlockHeader>) -> Result<(), ProvenBlockError> { // Validate that header / body transaction commitments match. self.validate_tx_commitment()?; // Validate that header / body note roots match. self.validate_note_root()?; + // When a trusted parent is provided, authenticate the block against it. + if let Some(parent) = parent { + self.header.validate_against_parent(parent, &self.signature)?; + } + Ok(()) } @@ -180,15 +219,6 @@ impl ProvenBlock { // HELPER METHODS // -------------------------------------------------------------------------------------------- - /// Performs ECDSA signature verification against the header commitment and validator key. - fn validate_signature(&self) -> Result<(), ProvenBlockError> { - if !self.signature.verify(self.header.commitment(), self.header.validator_key()) { - Err(ProvenBlockError::InvalidSignature) - } else { - Ok(()) - } - } - /// Validates that the transaction commitments between the header and body match for this proven /// block. /// @@ -241,3 +271,49 @@ impl Deserializable for ProvenBlock { Ok(block) } } + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use alloc::vec::Vec; + + use miden_crypto::dsa::ecdsa_k256_keccak::SigningKey; + + use super::*; + use crate::Word; + use crate::testing::random_secret_key::random_secret_key; + use crate::transaction::OrderedTransactionHeaders; + + /// Builds block 1 signed by `signer` and linked to `parent`. The exhaustive matrix of failure + /// modes lives in `block::validation`; here we only confirm `ProvenBlock::validate` wires the + /// signature and parent header through to the shared check. + fn block_one(parent: &BlockHeader, signer: &SigningKey) -> ProvenBlock { + let header = + BlockHeader::new_dummy(1, parent.commitment(), random_secret_key().public_key()); + let signature = signer.sign(header.commitment()); + let body = BlockBody::new_unchecked( + Vec::new(), + Vec::new(), + Vec::new(), + OrderedTransactionHeaders::new_unchecked(Vec::new()), + ); + ProvenBlock::new_unchecked(header, body, signature, BlockProof::new_dummy()) + } + + #[test] + fn validate_accepts_committed_signer() { + let validator = random_secret_key(); + let parent = BlockHeader::new_dummy(0, Word::empty(), validator.public_key()); + block_one(&parent, &validator).validate(Some(&parent)).unwrap(); + } + + #[test] + fn validate_rejects_uncommitted_signer() { + let parent = BlockHeader::new_dummy(0, Word::empty(), random_secret_key().public_key()); + let impostor = random_secret_key(); + let result = block_one(&parent, &impostor).validate(Some(&parent)); + assert!(matches!(result, Err(ProvenBlockError::InvalidSignature))); + } +} diff --git a/crates/miden-protocol/src/block/signed_block.rs b/crates/miden-protocol/src/block/signed_block.rs index 12aead9af5..46c61aef1a 100644 --- a/crates/miden-protocol/src/block/signed_block.rs +++ b/crates/miden-protocol/src/block/signed_block.rs @@ -1,6 +1,7 @@ use miden_core::Word; use miden_crypto::dsa::ecdsa_k256_keccak::Signature; +use crate::block::header::ParentValidationError; use crate::block::{BlockBody, BlockHeader, BlockNumber}; use crate::utils::serde::{ ByteReader, @@ -16,7 +17,7 @@ use crate::utils::serde::{ #[derive(Debug, thiserror::Error)] pub enum SignedBlockError { #[error( - "ECDSA signature verification failed based on the signed block's header commitment, validator public key and signature" + "ECDSA signature verification failed based on the signed block's header commitment, the parent block's validator public key and signature" )] InvalidSignature, #[error( @@ -43,6 +44,23 @@ pub enum SignedBlockError { GenesisBlockHasNoParent { parent: BlockNumber }, } +impl From for SignedBlockError { + fn from(err: ParentValidationError) -> Self { + match err { + ParentValidationError::InvalidSignature => Self::InvalidSignature, + ParentValidationError::ParentNumberMismatch { expected, parent } => { + Self::ParentNumberMismatch { expected, parent } + }, + ParentValidationError::ParentCommitmentMismatch { expected, parent } => { + Self::ParentCommitmentMismatch { expected, parent } + }, + ParentValidationError::GenesisBlockHasNoParent { parent } => { + Self::GenesisBlockHasNoParent { parent } + }, + } + } +} + // SIGNED BLOCK // ================================================================================================ @@ -64,8 +82,10 @@ pub struct SignedBlock { impl SignedBlock { /// Returns a new [`SignedBlock`] instantiated from the provided components. /// - /// Validates that the provided components correspond to each other by verifying the signature, - /// and checking for matching commitments and note roots. + /// Validates that the header and body correspond by checking the transaction commitment and + /// note root. This does NOT verify the validator signature, which can only be checked against + /// the parent block's validator key; call [`Self::validate`] with the parent header to + /// authenticate the block. /// /// Involves non-trivial computation. Use [`Self::new_unchecked`] if the validation is not /// necessary. @@ -76,14 +96,7 @@ impl SignedBlock { ) -> Result { let signed_block = Self { header, body, signature }; - // Verify signature. - signed_block.validate_signature()?; - - // Validate that header / body transaction commitments match. - signed_block.validate_tx_commitment()?; - - // Validate that header / body note roots match. - signed_block.validate_note_root()?; + signed_block.validate(None)?; Ok(signed_block) } @@ -118,13 +131,37 @@ impl SignedBlock { (self.header, self.body, self.signature) } - /// Performs ECDSA signature verification against the header commitment and validator key. - fn validate_signature(&self) -> Result<(), SignedBlockError> { - if !self.signature.verify(self.header.commitment(), self.header.validator_key()) { - Err(SignedBlockError::InvalidSignature) - } else { - Ok(()) + /// Validates that the header and body correspond by checking the transaction commitment and + /// note root, and -- when `parent` is provided -- authenticates the block against its parent. + /// + /// Pass `Some(parent)` to additionally authenticate the block against its parent; pass `None` + /// for the genesis block, which has no parent, or when only self-consistency is required. + /// + /// `parent` MUST come from already-trusted chain state. Because `prev_block_commitment` is + /// attacker-controlled, passing an untrusted parent would let a forged block self-authorize. + /// + /// # Errors + /// Returns an error if: + /// - the transaction commitment in the block header is inconsistent with the transactions + /// included in the block body; + /// - the note root in the block header is inconsistent with the notes included in the block + /// body; or + /// - a `parent` is provided and the block is not authorized by it: the block is the genesis + /// block (which has no parent), the parent's number or commitment do not match, or the + /// signature does not verify against the parent's validator key. + pub fn validate(&self, parent: Option<&BlockHeader>) -> Result<(), SignedBlockError> { + // Validate that header / body transaction commitments match. + self.validate_tx_commitment()?; + + // Validate that header / body note roots match. + self.validate_note_root()?; + + // When a trusted parent is provided, authenticate the block against it. + if let Some(parent) = parent { + self.header.validate_against_parent(parent, &self.signature)?; } + + Ok(()) } /// Validates that the transaction commitments between the header and body match for this signed @@ -153,39 +190,6 @@ impl SignedBlock { Ok(()) } } - - /// Validates that the provided parent block's commitment and number correctly corresponds to - /// the signed block. - /// - /// # Errors - /// - /// Returns an error if: - /// - The signed block is the genesis block. - /// - The parent block number is not the signed block number - 1. - /// - The parent block's commitment is not equal to the signed block's previous block - /// commitment. - pub fn validate_parent(&self, parent_block: &BlockHeader) -> Result<(), SignedBlockError> { - // Check block numbers. - if let Some(expected) = self.header.block_num().checked_sub(1) { - let parent = parent_block.block_num(); - if expected != parent { - return Err(SignedBlockError::ParentNumberMismatch { expected, parent }); - } - - // Check commitments. - let expected = self.header.prev_block_commitment(); - let parent = parent_block.commitment(); - if expected != parent { - return Err(SignedBlockError::ParentCommitmentMismatch { expected, parent }); - } - - Ok(()) - } else { - // Block 0 does not have a parent. - let parent = parent_block.block_num(); - Err(SignedBlockError::GenesisBlockHasNoParent { parent }) - } - } } // SERIALIZATION @@ -210,3 +214,49 @@ impl Deserializable for SignedBlock { Ok(block) } } + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use alloc::vec::Vec; + + use miden_crypto::dsa::ecdsa_k256_keccak::SigningKey; + + use super::*; + use crate::Word; + use crate::testing::random_secret_key::random_secret_key; + use crate::transaction::OrderedTransactionHeaders; + + /// Builds block 1 signed by `signer` and linked to `parent`. The exhaustive matrix of failure + /// modes lives in `block::validation`; here we only confirm `SignedBlock::validate` wires the + /// signature and parent header through to the shared check. + fn block_one(parent: &BlockHeader, signer: &SigningKey) -> SignedBlock { + let header = + BlockHeader::new_dummy(1, parent.commitment(), random_secret_key().public_key()); + let signature = signer.sign(header.commitment()); + let body = BlockBody::new_unchecked( + Vec::new(), + Vec::new(), + Vec::new(), + OrderedTransactionHeaders::new_unchecked(Vec::new()), + ); + SignedBlock::new_unchecked(header, body, signature) + } + + #[test] + fn validate_accepts_committed_signer() { + let validator = random_secret_key(); + let parent = BlockHeader::new_dummy(0, Word::empty(), validator.public_key()); + block_one(&parent, &validator).validate(Some(&parent)).unwrap(); + } + + #[test] + fn validate_rejects_uncommitted_signer() { + let parent = BlockHeader::new_dummy(0, Word::empty(), random_secret_key().public_key()); + let impostor = random_secret_key(); + let result = block_one(&parent, &impostor).validate(Some(&parent)); + assert!(matches!(result, Err(SignedBlockError::InvalidSignature))); + } +} diff --git a/crates/miden-protocol/src/block/account_tree/backend.rs b/crates/miden-protocol/src/block/smt_backend.rs similarity index 64% rename from crates/miden-protocol/src/block/account_tree/backend.rs rename to crates/miden-protocol/src/block/smt_backend.rs index 58963f0e44..a906261f44 100644 --- a/crates/miden-protocol/src/block/account_tree/backend.rs +++ b/crates/miden-protocol/src/block/smt_backend.rs @@ -1,34 +1,68 @@ use alloc::boxed::Box; use alloc::vec::Vec; -use super::{AccountId, AccountIdKey, AccountIdPrefix, AccountTree, AccountTreeError}; use crate::Word; use crate::crypto::merkle::MerkleError; #[cfg(feature = "std")] -use crate::crypto::merkle::smt::{LargeSmt, LargeSmtError, SmtStorage}; +use crate::crypto::merkle::smt::{LargeSmt, LargeSmtError, SmtStorage, SmtStorageReader}; use crate::crypto::merkle::smt::{LeafIndex, MutationSet, SMT_DEPTH, Smt, SmtLeaf, SmtProof}; -// ACCOUNT TREE BACKEND +// SMT BACKEND READER // ================================================================================================ -/// This trait abstracts over different SMT backends (e.g., `Smt` and `LargeSmt`) to allow -/// the `AccountTree` to work with either implementation transparently. +/// Abstracts over the read-only operations of the different SMT backends (e.g. [`Smt`] and +/// [`LargeSmt`]), so that the block trees can work with either implementation transparently. /// -/// Implementors must provide `Default` for creating empty instances. Users should -/// instantiate the backend directly (potentially with entries) and then pass it to -/// [`AccountTree::new`]. -pub trait AccountTreeBackend: Sized { +/// This trait is value-agnostic: leaves are stored as raw [`Word`]s. Trees that wrap a more +/// specific value type (such as the nullifier tree) are responsible for converting to and from +/// [`Word`] in their own accessors. +/// +/// The method set is intentionally the superset required by both the account and nullifier trees, +/// so a given tree only uses the subset relevant to it (e.g. the account tree iterates +/// [`leaves`](Self::leaves), the nullifier tree iterates [`entries`](Self::entries)). +/// +/// This trait contains only read-only methods. For write methods, see [`SmtBackend`]. +pub trait SmtBackendReader: Sized { type Error: core::error::Error + Send + 'static; /// Returns the number of leaves in the SMT. fn num_leaves(&self) -> usize; + /// Returns the number of key-value entries in the SMT. + /// + /// This can exceed [`Self::num_leaves`] when keys collide into the same leaf. + fn num_entries(&self) -> usize; + /// Returns all leaves in the SMT as an iterator over leaf index and leaf pairs. - fn leaves<'a>(&'a self) -> Box, SmtLeaf)>>; + fn leaves(&self) -> Box, SmtLeaf)> + '_>; + + /// Returns all key-value entries in the SMT. + fn entries(&self) -> Box + '_>; /// Opens the leaf at the given key, returning a Merkle proof. fn open(&self, key: &Word) -> SmtProof; + /// Returns the value associated with the given key. + fn get_value(&self, key: &Word) -> Word; + + /// Returns the leaf at the given key. + fn get_leaf(&self, key: &Word) -> SmtLeaf; + + /// Returns the root of the SMT. + fn root(&self) -> Word; +} + +// SMT BACKEND +// ================================================================================================ + +/// Extension trait for [`SmtBackendReader`] that provides write methods. +pub trait SmtBackend: SmtBackendReader { + /// Computes the mutation set required to apply the given updates to the SMT. + fn compute_mutations( + &self, + updates: Vec<(Word, Word)>, + ) -> Result, Self::Error>; + /// Applies the given mutation set to the SMT. fn apply_mutations( &mut self, @@ -43,43 +77,60 @@ pub trait AccountTreeBackend: Sized { set: MutationSet, ) -> Result, Self::Error>; - /// Computes the mutation set required to apply the given updates to the SMT. - fn compute_mutations( - &self, - updates: Vec<(Word, Word)>, - ) -> Result, Self::Error>; - /// Inserts a key-value pair into the SMT, returning the previous value at that key. fn insert(&mut self, key: Word, value: Word) -> Result; - - /// Returns the value associated with the given key. - fn get_value(&self, key: &Word) -> Word; - - /// Returns the leaf at the given key. - fn get_leaf(&self, key: &Word) -> SmtLeaf; - - /// Returns the root of the SMT. - fn root(&self) -> Word; } -// BACKEND IMPLEMENTATION FOR SMT +// BACKEND READER IMPLEMENTATION FOR SMT // ================================================================================================ -impl AccountTreeBackend for Smt { +impl SmtBackendReader for Smt { type Error = MerkleError; fn num_leaves(&self) -> usize { Smt::num_leaves(self) } - fn leaves<'a>(&'a self) -> Box, SmtLeaf)>> { + fn num_entries(&self) -> usize { + Smt::num_entries(self) + } + + fn leaves(&self) -> Box, SmtLeaf)> + '_> { Box::new(Smt::leaves(self).map(|(idx, leaf)| (idx, leaf.clone()))) } + fn entries(&self) -> Box + '_> { + Box::new(Smt::entries(self).map(|(k, v)| (*k, *v))) + } + fn open(&self, key: &Word) -> SmtProof { Smt::open(self, key) } + fn get_value(&self, key: &Word) -> Word { + Smt::get_value(self, key) + } + + fn get_leaf(&self, key: &Word) -> SmtLeaf { + Smt::get_leaf(self, key) + } + + fn root(&self) -> Word { + Smt::root(self) + } +} + +// BACKEND WRITER IMPLEMENTATION FOR SMT +// ================================================================================================ + +impl SmtBackend for Smt { + fn compute_mutations( + &self, + updates: Vec<(Word, Word)>, + ) -> Result, Self::Error> { + Smt::compute_mutations(self, updates) + } + fn apply_mutations( &mut self, set: MutationSet, @@ -94,37 +145,18 @@ impl AccountTreeBackend for Smt { Smt::apply_mutations_with_reversion(self, set) } - fn compute_mutations( - &self, - updates: Vec<(Word, Word)>, - ) -> Result, Self::Error> { - Smt::compute_mutations(self, updates) - } - fn insert(&mut self, key: Word, value: Word) -> Result { Smt::insert(self, key, value) } - - fn get_value(&self, key: &Word) -> Word { - Smt::get_value(self, key) - } - - fn get_leaf(&self, key: &Word) -> SmtLeaf { - Smt::get_leaf(self, key) - } - - fn root(&self) -> Word { - Smt::root(self) - } } -// BACKEND IMPLEMENTATION FOR LARGE SMT +// BACKEND READER IMPLEMENTATION FOR LARGE SMT // ================================================================================================ #[cfg(feature = "std")] -impl AccountTreeBackend for LargeSmt +impl SmtBackendReader for LargeSmt where - Backend: SmtStorage, + Backend: SmtStorageReader, { type Error = MerkleError; @@ -132,37 +164,22 @@ where LargeSmt::num_leaves(self) } - fn leaves<'a>(&'a self) -> Box, SmtLeaf)>> { - Box::new(LargeSmt::leaves(self).expect("Only IO can error out here")) - } - - fn open(&self, key: &Word) -> SmtProof { - LargeSmt::open(self, key) - } - - fn apply_mutations( - &mut self, - set: MutationSet, - ) -> Result<(), Self::Error> { - LargeSmt::apply_mutations(self, set).map_err(large_smt_error_to_merkle_error) + fn num_entries(&self) -> usize { + LargeSmt::num_entries(self) } - fn apply_mutations_with_reversion( - &mut self, - set: MutationSet, - ) -> Result, Self::Error> { - LargeSmt::apply_mutations_with_reversion(self, set).map_err(large_smt_error_to_merkle_error) + fn leaves(&self) -> Box, SmtLeaf)> + '_> { + Box::new(LargeSmt::leaves(self).expect("Only IO can error out here")) } - fn compute_mutations( - &self, - updates: Vec<(Word, Word)>, - ) -> Result, Self::Error> { - LargeSmt::compute_mutations(self, updates).map_err(large_smt_error_to_merkle_error) + fn entries(&self) -> Box + '_> { + // SAFETY: We expect here as only I/O errors can occur. Storage failures are considered + // unrecoverable at this layer. See issue #2010 for future error handling improvements. + Box::new(LargeSmt::entries(self).expect("Storage I/O error accessing entries")) } - fn insert(&mut self, key: Word, value: Word) -> Result { - LargeSmt::insert(self, key, value) + fn open(&self, key: &Word) -> SmtProof { + LargeSmt::open(self, key) } fn get_value(&self, key: &Word) -> Word { @@ -178,56 +195,49 @@ where } } -// CONVENIENCE METHODS +// BACKEND WRITER IMPLEMENTATION FOR LARGE SMT // ================================================================================================ -impl AccountTree { - /// Creates a new [`AccountTree`] with the provided entries. - /// - /// This is a convenience method for testing that creates an SMT backend with the provided - /// entries and wraps it in an AccountTree. It validates that the entries don't contain - /// duplicate prefixes. - /// - /// # Errors - /// - /// Returns an error if: - /// - The provided entries contain duplicate account ID prefixes - /// - The backend fails to create the SMT with the entries - pub fn with_entries( - entries: impl IntoIterator, - ) -> Result - where - I: ExactSizeIterator, - { - // Create the SMT with the entries - let smt = Smt::with_entries( - entries - .into_iter() - .map(|(id, commitment)| (AccountIdKey::from(id).as_word(), commitment)), - ) - .map_err(|err| { - let MerkleError::DuplicateValuesForIndex(leaf_idx) = err else { - unreachable!("the only error returned by Smt::with_entries is of this type"); - }; - - // SAFETY: Since we only inserted account IDs into the SMT, it is guaranteed that - // the leaf_idx is a valid Felt as well as a valid account ID prefix. - AccountTreeError::DuplicateStateCommitments { - prefix: AccountIdPrefix::new_unchecked( - crate::Felt::try_from(leaf_idx).expect("leaf index should be a valid felt"), - ), - } - })?; - - AccountTree::new(smt) +#[cfg(feature = "std")] +impl SmtBackend for LargeSmt +where + Backend: SmtStorage, +{ + fn compute_mutations( + &self, + updates: Vec<(Word, Word)>, + ) -> Result, Self::Error> { + LargeSmt::compute_mutations(self, updates).map_err(large_smt_error_to_merkle_error) + } + + fn apply_mutations( + &mut self, + set: MutationSet, + ) -> Result<(), Self::Error> { + LargeSmt::apply_mutations(self, set).map_err(large_smt_error_to_merkle_error) + } + + fn apply_mutations_with_reversion( + &mut self, + set: MutationSet, + ) -> Result, Self::Error> { + LargeSmt::apply_mutations_with_reversion(self, set).map_err(large_smt_error_to_merkle_error) + } + + fn insert(&mut self, key: Word, value: Word) -> Result { + LargeSmt::insert(self, key, value) } } // HELPER FUNCTIONS // ================================================================================================ +/// Converts a [`LargeSmtError`] into a [`MerkleError`]. +/// +/// Storage failures are treated as unrecoverable at this layer and cause a panic. See issue #2010 +/// for future error handling improvements. #[cfg(feature = "std")] -fn large_smt_error_to_merkle_error(err: LargeSmtError) -> MerkleError { +pub(crate) fn large_smt_error_to_merkle_error(err: LargeSmtError) -> MerkleError { match err { LargeSmtError::Storage(storage_err) => { panic!("Storage error encountered: {:?}", storage_err) diff --git a/crates/miden-protocol/src/errors/masm_error.rs b/crates/miden-protocol/src/errors/masm_error.rs index 43c55f8797..c715eb4e75 100644 --- a/crates/miden-protocol/src/errors/masm_error.rs +++ b/crates/miden-protocol/src/errors/masm_error.rs @@ -1,5 +1,8 @@ use alloc::borrow::Cow; +use miden_processor::ExecutionError; +use miden_processor::operation::OperationError; + use crate::Felt; /// A convenience wrapper around an error extracted from Miden Assembly source files. @@ -29,6 +32,30 @@ impl MasmError { pub fn code(&self) -> Felt { crate::assembly::mast::error_code_from_msg(&self.message) } + + /// Returns `true` if `error` is an `OperationError::FailedAssertion` whose `err_code` equals + /// this error's [code](Self::code). + /// + /// If the actual error carries an `err_msg`, it must also equal this error's + /// [message](Self::message); an absent `err_msg` is accepted. + pub fn matches_execution_error(&self, error: &ExecutionError) -> bool { + let ExecutionError::OperationError { + err: OperationError::FailedAssertion { err_code, err_msg }, + .. + } = error + else { + return false; + }; + + if *err_code != self.code() { + return false; + } + + match err_msg { + Some(msg) => msg.as_ref() == self.message(), + None => true, + } + } } impl core::fmt::Display for MasmError { diff --git a/crates/miden-protocol/src/errors/mod.rs b/crates/miden-protocol/src/errors/mod.rs index 618c8649a9..da63392796 100644 --- a/crates/miden-protocol/src/errors/mod.rs +++ b/crates/miden-protocol/src/errors/mod.rs @@ -9,6 +9,8 @@ use miden_core::mast::MastForestError; use miden_crypto::merkle::mmr::MmrError; use miden_crypto::merkle::smt::{SmtLeafError, SmtProofError}; use miden_crypto::utils::HexParseError; +use miden_processor::ExecutionError; +use miden_verifier::VerificationError; use thiserror::Error; use super::account::{AccountId, RoleSymbol}; @@ -129,12 +131,8 @@ pub enum AccountError { FinalAccountHeaderIdParsingFailed(#[source] AccountIdError), #[error("account header data has length {actual} but it must be of length {expected}")] HeaderDataIncorrectLength { actual: usize, expected: usize }, - #[error("active account nonce {current} plus increment {increment} overflows a felt to {new}")] - NonceOverflow { - current: Felt, - increment: Felt, - new: Felt, - }, + #[error("final nonce {new} is not strictly greater than current account nonce {current}")] + NonceMustIncrease { current: Felt, new: Felt }, #[error( "digest of the seed has {actual} trailing zeroes but must have at least {expected} trailing zeroes" )] @@ -149,6 +147,10 @@ pub enum AccountError { "an account with a seed cannot be converted into a delta since it represents an unregistered account" )] DeltaFromAccountWithSeed, + #[error( + "an account with a seed cannot be converted into a patch since it represents an unregistered account" + )] + PatchFromAccountWithSeed, #[error("seed converts to an invalid account ID")] SeedConvertsToInvalidAccountId(#[source] AccountIdError), #[error("storage map root {0} not found in the account storage")] @@ -168,11 +170,20 @@ pub enum AccountError { #[error("number of storage slots is {0} but max possible number is {max}", max = AccountStorage::MAX_NUM_STORAGE_SLOTS)] StorageTooManySlots(u64), #[error( - "failed to apply full state delta to existing account; full state deltas can be converted to accounts directly" + "failed to apply full state patch to existing account; full state patches can be converted to accounts directly" )] - ApplyFullStateDeltaToAccount, + ApplyFullStatePatchToAccount, + #[error("patch is for account ID {patch_id} but is being applied to account {account_id}")] + PatchAccountIdMismatch { + account_id: AccountId, + patch_id: AccountId, + }, #[error("only account deltas representing a full account can be converted to a full account")] PartialStateDeltaToAccount, + #[error("assets cannot be removed from a new account with an empty asset vault")] + AssetsRemovedFromNewAccount, + #[error("only account patches representing a full account can be converted to a full account")] + PartialStatePatchToAccount, #[error("maximum number of storage map leaves exceeded")] MaxNumStorageMapLeavesExceeded(#[source] MerkleError), /// This variant can be used by methods that are not inherent to the account but want to return @@ -249,6 +260,23 @@ pub enum StorageSlotNameError { TooLong, } +// ACCOUNT CODE INTERFACE ERROR +// ================================================================================================ + +#[derive(Debug, Error)] +pub enum AccountCodeInterfaceError { + #[error( + "account code interface must contain at least {} procedures, but only {actual} were given", + AccountCode::MIN_NUM_PROCEDURES + )] + TooFewProcedures { actual: usize }, + #[error( + "account code interface contains {actual} procedures but it may contain at most {} procedures", + AccountCode::MAX_NUM_PROCEDURES + )] + TooManyProcedures { actual: usize }, +} + // ACCOUNT COMPONENT NAME ERROR // ================================================================================================ @@ -406,14 +434,6 @@ pub enum AccountDeltaError { }, #[error("non-empty account storage or vault delta with zero nonce delta is not allowed")] NonEmptyStorageOrVaultDeltaWithZeroNonceDelta, - #[error( - "account nonce increment {current} plus the other nonce increment {increment} overflows a felt to {new}" - )] - NonceIncrementOverflow { - current: Felt, - increment: Felt, - new: Felt, - }, #[error( "asset issued by faucet {0} in fungible asset delta does not have fungible composition" )] @@ -422,6 +442,44 @@ pub enum AccountDeltaError { MergingFullStateDeltas, } +#[derive(Debug, Error)] +pub enum AccountPatchError { + #[error("final nonce can never be set to zero")] + FinalNonceIsZero, + + #[error( + "state change to an account (store, vault or code) require that the final nonce is incremented" + )] + StateChangeRequiresNonceUpdate, + + #[error("account code must be provided for new accounts (with nonce = 1)")] + CodeMustBeProvidedForNewAccounts, + + #[error("storage slot {0} was used as different slot types")] + StorageSlotUsedAsDifferentTypes(StorageSlotName), + + #[error("cannot merge two full state patches")] + MergingFullStatePatches, + + #[error( + "nonce in the patch being merged is {new} which is not exactly one greater than current patch nonce {current}" + )] + NonceMustIncrementByOne { current: Felt, new: Felt }, + + #[error( + "patch is for account ID {actual} but is being merged into patch for account {expected}" + )] + AccountIdMismatch { expected: AccountId, actual: AccountId }, + + #[error( + "account update of type `{left_update_type}` cannot be merged with account update of type `{right_update_type}`" + )] + IncompatibleAccountUpdates { + left_update_type: &'static str, + right_update_type: &'static str, + }, +} + // STORAGE MAP ERROR // ================================================================================================ @@ -454,8 +512,8 @@ pub enum BatchAccountUpdateError { "final state commitment in account update from transaction {0} does not match initial state of current update" )] AccountUpdateInitialStateMismatch(TransactionId), - #[error("failed to merge account delta from transaction {0}")] - TransactionUpdateMergeError(TransactionId, #[source] Box), + #[error("failed to merge account patch from transaction {0}")] + TransactionUpdateMergeError(TransactionId, #[source] Box), } // ASSET ERROR @@ -491,10 +549,14 @@ pub enum AssetError { FungibleAssetValueMostSignificantElementsMustBeZero(Word), #[error("smt proof in asset witness contains invalid key or value")] AssetWitnessInvalid(#[source] Box), + #[error("vault key {key} is not present in the provided asset witness SMT proof")] + AssetWitnessMissingKey { key: AssetVaultKey }, #[error("unknown native asset callbacks encoding: {0}")] UnknownAssetCallbackFlag(u8), #[error("unknown asset composition encoding: {0}")] UnknownAssetComposition(u8), + #[error("unknown asset delta operation encoding: {0}")] + UnknownAssetDeltaOperation(u8), #[error("asset composition {0:?} is not supported at this operational site")] UnsupportedAssetComposition(AssetComposition), #[error( @@ -615,8 +677,13 @@ pub enum AssetVaultError { #[derive(Debug, Error)] pub enum PartialAssetVaultError { - #[error("provided SMT entry {entry} is not a valid asset")] - InvalidAssetInSmt { entry: Word, source: AssetError }, + #[error("partial vault contains invalid asset value {value} at key {key}")] + InvalidAssetForKey { + key: AssetVaultKey, + value: Word, + #[source] + source: AssetError, + }, #[error("failed to add asset proof")] FailedToAddProof(#[source] MerkleError), #[error("asset is not tracked in the partial vault")] @@ -853,8 +920,6 @@ pub enum TransactionOutputError { DuplicateOutputNote(NoteId), #[error("final account commitment is not in the advice map")] FinalAccountCommitmentMissingInAdviceMap, - #[error("fee asset is not a fungible asset")] - FeeAssetNotFungibleAsset(#[source] AssetError), #[error("failed to parse final account header")] FinalAccountHeaderParseFailure(#[source] AccountError), #[error( @@ -932,8 +997,8 @@ pub enum ProvenTransactionError { PrivateAccountWithDetails(AccountId), #[error("account {0} with public state is missing its account details")] PublicStateAccountMissingDetails(AccountId), - #[error("new account {id} with public state must be accompanied by a full state delta")] - NewPublicStateAccountRequiresFullStateDelta { id: AccountId, source: AccountError }, + #[error("new account {id} with public state must be accompanied by a full state patch")] + NewPublicStateAccountRequiresFullStatePatch { id: AccountId, source: AccountError }, #[error( "existing account {0} with public state should only provide delta updates instead of full details" )] @@ -949,8 +1014,10 @@ pub enum ProvenTransactionError { }, #[error("proven transaction neither changed the account state, nor consumed any notes")] EmptyTransaction, - #[error("failed to validate account delta in transaction account update")] - AccountDeltaCommitmentMismatch(#[source] Box), + #[error("failed to validate account patch in transaction account update")] + AccountPatchCommitmentMismatch(#[source] Box), + #[error("note with id {0} is both created and consumed by the transaction")] + NoteCreatedAndConsumed(NoteId), } // PROPOSED BATCH ERROR @@ -958,6 +1025,12 @@ pub enum ProvenTransactionError { #[derive(Debug, Error)] pub enum ProposedBatchError { + #[error("failed to verify transaction {transaction_id} in transaction batch")] + TransactionVerificationFailed { + transaction_id: TransactionId, + source: TransactionVerifierError, + }, + #[error( "transaction batch has {0} input notes but at most {MAX_INPUT_NOTES_PER_BATCH} are allowed" )] @@ -1007,15 +1080,15 @@ pub enum ProposedBatchError { }, #[error( - "note commitment mismatch for note {id}: (input: {input_commitment}, output: {output_commitment})" + "transaction {consumed_by} that consumes the note with ID {note_id} must be ordered before transaction {created_by} that creates the note" )] - NoteCommitmentMismatch { - id: NoteId, - input_commitment: Word, - output_commitment: Word, + NoteConsumedBeforeCreated { + note_id: NoteId, + consumed_by: TransactionId, + created_by: TransactionId, }, - #[error("failed to merge transaction delta into account {account_id}")] + #[error("failed to merge transaction patch into account {account_id}")] AccountUpdateError { account_id: AccountId, source: BatchAccountUpdateError, @@ -1073,11 +1146,6 @@ pub enum ProposedBatchError { #[derive(Debug, Error)] pub enum ProvenBatchError { - #[error("failed to verify transaction {transaction_id} in transaction batch")] - TransactionVerificationFailed { - transaction_id: TransactionId, - source: Box, - }, #[error( "batch expiration block number {batch_expiration_block_num} is not greater than the reference block number {reference_block_num}" )] @@ -1085,6 +1153,21 @@ pub enum ProvenBatchError { batch_expiration_block_num: BlockNumber, reference_block_num: BlockNumber, }, + #[error("batch kernel execution failed")] + BatchKernelExecutionFailed(#[source] ExecutionError), + #[error("batch kernel produced an invalid output stack")] + BatchKernelOutputInvalid(#[source] BatchOutputError), +} + +// BATCH OUTPUT ERROR +// ================================================================================================ + +#[derive(Debug, Error)] +pub enum BatchOutputError { + #[error("batch kernel output stack is invalid: {0}")] + OutputStackInvalid(String), + #[error("batch expiration block number {0} does not fit into a u32")] + ExpirationBlockNumberTooLarge(Felt), } // PROPOSED BLOCK ERROR @@ -1128,6 +1211,15 @@ pub enum ProposedBlockError { second_batch_id: BatchId, }, + #[error( + "batch {consumed_by} that consumes the note with ID {note_id} must be ordered before batch {created_by} that creates the note" + )] + NoteConsumedBeforeCreated { + note_id: NoteId, + consumed_by: BatchId, + created_by: BatchId, + }, + #[error( "timestamp {provided_timestamp} does not increase monotonically compared to timestamp {previous_timestamp} from the previous block header" )] @@ -1171,15 +1263,6 @@ pub enum ProposedBlockError { batch_id: BatchId, }, - #[error( - "note commitment mismatch for note {id}: (input: {input_commitment}, output: {output_commitment})" - )] - NoteCommitmentMismatch { - id: NoteId, - input_commitment: Word, - output_commitment: Word, - }, - #[error( "failed to prove unauthenticated note inclusion because block {block_number} in which note with id {note_id} was created is not in partial blockchain" )] @@ -1221,10 +1304,10 @@ pub enum ProposedBlockError { #[error("note with nullifier {0} is already spent")] NullifierSpent(Nullifier), - #[error("failed to merge transaction delta into account {account_id}")] + #[error("failed to merge transaction patch into account {account_id}")] AccountUpdateError { account_id: AccountId, - source: Box, + source: Box, }, #[error("failed to track account witness")] @@ -1293,3 +1376,14 @@ pub enum AuthSchemeError { #[error("auth scheme identifier `{0}` is not valid")] InvalidAuthSchemeIdentifier(String), } + +// TRANSACTION VERIFIER ERROR +// ================================================================================================ + +#[derive(Debug, Error)] +pub enum TransactionVerifierError { + #[error("failed to verify transaction")] + TransactionVerificationFailed(#[source] VerificationError), + #[error("transaction proof security level is {actual} but must be at least {expected_minimum}")] + InsufficientProofSecurityLevel { actual: u32, expected_minimum: u32 }, +} diff --git a/crates/miden-protocol/src/note/assets.rs b/crates/miden-protocol/src/note/assets.rs index 9daa28fe68..0c5cf5ee23 100644 --- a/crates/miden-protocol/src/note/assets.rs +++ b/crates/miden-protocol/src/note/assets.rs @@ -191,7 +191,13 @@ mod tests { use super::NoteAssets; use crate::account::AccountId; - use crate::asset::{Asset, FungibleAsset, NonFungibleAsset, NonFungibleAssetDetails}; + use crate::asset::{ + Asset, + AssetCallbackFlag, + FungibleAsset, + NonFungibleAsset, + NonFungibleAssetDetails, + }; use crate::errors::NoteError; use crate::testing::account_id::{ ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET, @@ -206,7 +212,8 @@ mod tests { .map(|i| { // Use the index bytes to create unique asset data. let data = (i as u64).to_le_bytes().to_vec(); - let details = NonFungibleAssetDetails::new(faucet_id, data); + let details = + NonFungibleAssetDetails::new(faucet_id, data, AssetCallbackFlag::Disabled); Asset::NonFungible(NonFungibleAsset::new(&details)) }) .collect() @@ -217,10 +224,15 @@ mod tests { let faucet_id_1 = AccountId::try_from(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET).unwrap(); let faucet_id_2 = AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET).unwrap(); let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_NON_FUNGIBLE_FAUCET).unwrap(); - let details = NonFungibleAssetDetails::new(account_id, vec![1, 2, 3]); - - let asset1 = Asset::Fungible(FungibleAsset::new(faucet_id_1, 100).unwrap()); - let asset2 = Asset::Fungible(FungibleAsset::new(faucet_id_2, 50).unwrap()); + let details = + NonFungibleAssetDetails::new(account_id, vec![1, 2, 3], AssetCallbackFlag::Disabled); + + let asset1 = Asset::Fungible( + FungibleAsset::new(faucet_id_1, 100, AssetCallbackFlag::Disabled).unwrap(), + ); + let asset2 = Asset::Fungible( + FungibleAsset::new(faucet_id_2, 50, AssetCallbackFlag::Disabled).unwrap(), + ); let non_fungible_asset = Asset::NonFungible(NonFungibleAsset::new(&details)); // Create NoteAsset from assets diff --git a/crates/miden-protocol/src/note/attachment/mod.rs b/crates/miden-protocol/src/note/attachment/mod.rs index fc066718f3..d03c51ffdd 100644 --- a/crates/miden-protocol/src/note/attachment/mod.rs +++ b/crates/miden-protocol/src/note/attachment/mod.rs @@ -525,6 +525,9 @@ impl NoteAttachments { } /// Returns the first attachment with the provided scheme, if any. + /// + /// Schemes are not required to be unique within a note. If multiple attachments share the + /// provided scheme, the first one is treated as the canonical one and returned. pub fn find(&self, scheme: NoteAttachmentScheme) -> Option<&NoteAttachment> { self.attachments .iter() diff --git a/crates/miden-protocol/src/note/file.rs b/crates/miden-protocol/src/note/file.rs index 2db7e4521c..be762c5a96 100644 --- a/crates/miden-protocol/src/note/file.rs +++ b/crates/miden-protocol/src/note/file.rs @@ -144,7 +144,7 @@ mod tests { use crate::Word; use crate::account::AccountId; - use crate::asset::{Asset, FungibleAsset}; + use crate::asset::{Asset, AssetCallbackFlag, FungibleAsset}; use crate::block::BlockNumber; use crate::note::{ Note, @@ -174,7 +174,8 @@ mod tests { let note_storage = NoteStorage::new(vec![target.prefix().into()]).unwrap(); let recipient = NoteRecipient::new(serial_num, script, note_storage); - let asset = Asset::Fungible(FungibleAsset::new(faucet, 100).unwrap()); + let asset = + Asset::Fungible(FungibleAsset::new(faucet, 100, AssetCallbackFlag::Disabled).unwrap()); let metadata = PartialNoteMetadata::new(faucet, NoteType::Public).with_tag(NoteTag::from(123)); diff --git a/crates/miden-protocol/src/note/metadata.rs b/crates/miden-protocol/src/note/metadata.rs index 4acac325c6..048bebc584 100644 --- a/crates/miden-protocol/src/note/metadata.rs +++ b/crates/miden-protocol/src/note/metadata.rs @@ -226,6 +226,14 @@ impl NoteMetadata { self.attachments_commitment } + /// Returns `true` if the metadata advertises at least one attachment. + /// + /// The metadata carries only attachment scheme markers, not their content, so this does not + /// imply the content is available locally. + pub fn has_attachments(&self) -> bool { + self.attachment_headers.iter().any(|header| !header.is_absent()) + } + /// Returns `true` if the note is private, `false` otherwise. pub fn is_private(&self) -> bool { self.partial_metadata.is_private() diff --git a/crates/miden-protocol/src/note/mod.rs b/crates/miden-protocol/src/note/mod.rs index 14c9e2ce04..3b6ef4ac1f 100644 --- a/crates/miden-protocol/src/note/mod.rs +++ b/crates/miden-protocol/src/note/mod.rs @@ -184,6 +184,11 @@ impl Note { &self.attachments } + /// Returns `true` if the note has at least one attachment. + pub fn has_attachments(&self) -> bool { + !self.attachments.is_empty() + } + /// Returns a reference to the note's metadata. pub fn metadata(&self) -> &NoteMetadata { self.header.metadata() diff --git a/crates/miden-protocol/src/testing/account.rs b/crates/miden-protocol/src/testing/account.rs index 036ce0513f..2336ece6ce 100644 --- a/crates/miden-protocol/src/testing/account.rs +++ b/crates/miden-protocol/src/testing/account.rs @@ -1,7 +1,7 @@ use super::constants::{FUNGIBLE_ASSET_AMOUNT, NON_FUNGIBLE_ASSET_DATA}; use crate::Felt; use crate::account::{Account, AccountCode, AccountId, AccountStorage}; -use crate::asset::{Asset, AssetVault, FungibleAsset, NonFungibleAsset}; +use crate::asset::{Asset, AssetCallbackFlag, AssetVault, FungibleAsset, NonFungibleAsset}; use crate::testing::account_id::{ ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1, @@ -41,16 +41,22 @@ impl AssetVault { /// - ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET pub fn mock() -> Self { let faucet_id: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap(); - let fungible_asset = - Asset::Fungible(FungibleAsset::new(faucet_id, FUNGIBLE_ASSET_AMOUNT).unwrap()); + let fungible_asset = Asset::Fungible( + FungibleAsset::new(faucet_id, FUNGIBLE_ASSET_AMOUNT, AssetCallbackFlag::Disabled) + .unwrap(), + ); let faucet_id_1: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1.try_into().unwrap(); - let fungible_asset_1 = - Asset::Fungible(FungibleAsset::new(faucet_id_1, FUNGIBLE_ASSET_AMOUNT).unwrap()); + let fungible_asset_1 = Asset::Fungible( + FungibleAsset::new(faucet_id_1, FUNGIBLE_ASSET_AMOUNT, AssetCallbackFlag::Disabled) + .unwrap(), + ); let faucet_id_2: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2.try_into().unwrap(); - let fungible_asset_2 = - Asset::Fungible(FungibleAsset::new(faucet_id_2, FUNGIBLE_ASSET_AMOUNT).unwrap()); + let fungible_asset_2 = Asset::Fungible( + FungibleAsset::new(faucet_id_2, FUNGIBLE_ASSET_AMOUNT, AssetCallbackFlag::Disabled) + .unwrap(), + ); let non_fungible_asset = NonFungibleAsset::mock(&NON_FUNGIBLE_ASSET_DATA); AssetVault::new(&[fungible_asset, fungible_asset_1, fungible_asset_2, non_fungible_asset]) diff --git a/crates/miden-protocol/src/testing/asset.rs b/crates/miden-protocol/src/testing/asset.rs index 1a48b103e6..b27d980dac 100644 --- a/crates/miden-protocol/src/testing/asset.rs +++ b/crates/miden-protocol/src/testing/asset.rs @@ -1,5 +1,12 @@ use crate::account::AccountId; -use crate::asset::{Asset, FungibleAsset, NonFungibleAsset, NonFungibleAssetDetails}; +use crate::asset::{ + Asset, + AssetCallbackFlag, + FungibleAsset, + NonFungibleAsset, + NonFungibleAssetDetails, +}; +use crate::errors::AssetError; use crate::testing::account_id::{ ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET, @@ -11,6 +18,7 @@ impl NonFungibleAsset { let non_fungible_asset_details = NonFungibleAssetDetails::new( AccountId::try_from(ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET).unwrap(), asset_data.to_vec(), + AssetCallbackFlag::Disabled, ); let non_fungible_asset = NonFungibleAsset::new(&non_fungible_asset_details); Asset::NonFungible(non_fungible_asset) @@ -30,11 +38,28 @@ impl FungibleAsset { FungibleAsset::new( AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET).expect("id should be valid"), amount, + AssetCallbackFlag::Disabled, ) .expect("asset is valid"), ) } + /// Returns a fungible asset with the provided faucet ID and amount and callbacks enabled. + /// + /// This is a convenience constructor for testing; production code should pass the + /// [`AssetCallbackFlag`] explicitly to [`FungibleAsset::new`]. + pub fn with_callbacks(faucet_id: AccountId, amount: u64) -> Result { + FungibleAsset::new(faucet_id, amount, AssetCallbackFlag::Enabled) + } + + /// Returns a fungible asset with the provided faucet ID and amount and callbacks disabled. + /// + /// This is a convenience constructor for testing; production code should pass the + /// [`AssetCallbackFlag`] explicitly to [`FungibleAsset::new`]. + pub fn without_callbacks(faucet_id: AccountId, amount: u64) -> Result { + FungibleAsset::new(faucet_id, amount, AssetCallbackFlag::Disabled) + } + /// Returns a mocked asset account ID ([ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET]). pub fn mock_issuer() -> AccountId { AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET).unwrap() diff --git a/crates/miden-protocol/src/testing/mod.rs b/crates/miden-protocol/src/testing/mod.rs index 8ea171fad9..d36b268591 100644 --- a/crates/miden-protocol/src/testing/mod.rs +++ b/crates/miden-protocol/src/testing/mod.rs @@ -16,3 +16,5 @@ pub mod slot_name; pub mod storage; pub mod storage_map_key; pub mod tx; +pub mod update_details; +pub mod vault_patch; diff --git a/crates/miden-protocol/src/testing/storage.rs b/crates/miden-protocol/src/testing/storage.rs index 22acb8ff0e..404edcc795 100644 --- a/crates/miden-protocol/src/testing/storage.rs +++ b/crates/miden-protocol/src/testing/storage.rs @@ -4,39 +4,39 @@ use miden_core::{Felt, Word}; use crate::account::{ AccountStorage, - AccountStorageDelta, + AccountStoragePatch, StorageMap, - StorageMapDelta, StorageMapKey, + StorageMapPatch, StorageSlot, - StorageSlotDelta, StorageSlotName, + StorageSlotPatch, }; use crate::utils::sync::LazyLock; -// ACCOUNT STORAGE DELTA +// ACCOUNT STORAGE PATCH // ================================================================================================ -impl AccountStorageDelta { +impl AccountStoragePatch { // CONSTRUCTORS // ---------------------------------------------------------------------------------------- - /// Creates an [`AccountStorageDelta`] from the given iterators. + /// Creates an [`AccountStoragePatch`] from the given iterators. pub fn from_iters( cleared_values: impl IntoIterator, updated_values: impl IntoIterator, - updated_maps: impl IntoIterator, + updated_maps: impl IntoIterator, ) -> Self { let deltas = cleared_values .into_iter() - .map(|slot_name| (slot_name, StorageSlotDelta::with_empty_value())) + .map(|slot_name| (slot_name, StorageSlotPatch::with_empty_value())) .chain(updated_values.into_iter().map(|(slot_name, slot_value)| { - (slot_name, StorageSlotDelta::Value(slot_value)) + (slot_name, StorageSlotPatch::Value(slot_value)) })) .chain( - updated_maps.into_iter().map(|(slot_name, map_delta)| { - (slot_name, StorageSlotDelta::Map(map_delta)) + updated_maps.into_iter().map(|(slot_name, map_patch)| { + (slot_name, StorageSlotPatch::Map(map_patch)) }), ) .collect(); @@ -44,6 +44,37 @@ impl AccountStorageDelta { Self::from_raw(deltas) } + // ACCESSORS + // ------------------------------------------------------------------------------------------- + + /// Returns the updated value for the given slot, or `None` if the slot was not updated. + /// + /// # Panics + /// Panics if the slot patch is a map. + pub fn get_value(&self, slot_name: &StorageSlotName) -> Option { + self.get(slot_name).cloned().map(StorageSlotPatch::unwrap_value) + } + + /// Returns the map patch for the given slot, or `None` if the slot was not updated. + /// + /// # Panics + /// Panics if the slot patch is a value. + pub fn get_map(&self, slot_name: &StorageSlotName) -> Option<&StorageMapPatch> { + self.get(slot_name).map(|patch| match patch { + StorageSlotPatch::Map(map_patch) => map_patch, + StorageSlotPatch::Value(_) => panic!("called get_map on a value slot patch"), + }) + } + + /// Returns the updated value for the given map entry, or `None` if the slot or key was not + /// updated. + /// + /// # Panics + /// Panics if the slot patch is a value. + pub fn get_map_value(&self, slot_name: &StorageSlotName, key: &StorageMapKey) -> Option { + self.get_map(slot_name)?.entries().get(key).copied() + } + // MUTATORS // ------------------------------------------------------------------------------------------- @@ -68,10 +99,10 @@ impl AccountStorageDelta { pub fn add_updated_maps( mut self, - items: impl IntoIterator, + items: impl IntoIterator, ) -> Self { - items.into_iter().for_each(|(slot_name, map_delta)| { - for (key, value) in map_delta.entries() { + items.into_iter().for_each(|(slot_name, map_patch)| { + for (key, value) in map_patch.entries() { self.set_map_item(slot_name.clone(), *key, *value).expect("TODO") } }); diff --git a/crates/miden-protocol/src/testing/update_details.rs b/crates/miden-protocol/src/testing/update_details.rs new file mode 100644 index 0000000000..983e0bc120 --- /dev/null +++ b/crates/miden-protocol/src/testing/update_details.rs @@ -0,0 +1,16 @@ +use crate::account::{AccountPatch, AccountUpdateDetails}; + +impl AccountUpdateDetails { + /// Returns the [`AccountPatch`] contained in this update. + /// + /// # Panics + /// + /// Panics if the update is not [`AccountUpdateDetails::Public`]. + #[track_caller] + pub fn unwrap_public(&self) -> &AccountPatch { + match self { + AccountUpdateDetails::Private => panic!("expected public, got private"), + AccountUpdateDetails::Public(patch) => patch, + } + } +} diff --git a/crates/miden-protocol/src/testing/vault_patch.rs b/crates/miden-protocol/src/testing/vault_patch.rs new file mode 100644 index 0000000000..338133cd08 --- /dev/null +++ b/crates/miden-protocol/src/testing/vault_patch.rs @@ -0,0 +1,34 @@ +use miden_core::Word; + +use crate::account::AccountVaultPatch; +use crate::asset::Asset; + +impl AccountVaultPatch { + // CONSTRUCTORS + // ---------------------------------------------------------------------------------------- + + /// Creates an [`AccountVaultPatch`] that represents a vault update to the given assets. + pub fn with_assets(entries: impl IntoIterator) -> Self { + Self::new( + entries + .into_iter() + .map(|asset| (asset.vault_key(), asset.to_value_word())) + .collect(), + ) + .expect("assets should be valid") + } + + pub fn from_iters( + added_assets: impl IntoIterator, + removed_assets: impl IntoIterator, + ) -> Self { + Self::new( + added_assets + .into_iter() + .map(|asset| (asset.vault_key(), asset.to_value_word())) + .chain(removed_assets.into_iter().map(|asset| (asset.vault_key(), Word::empty()))) + .collect(), + ) + .expect("assets should be valid") + } +} diff --git a/crates/miden-protocol/src/transaction/executed_tx.rs b/crates/miden-protocol/src/transaction/executed_tx.rs index a3077d456f..9c1f890632 100644 --- a/crates/miden-protocol/src/transaction/executed_tx.rs +++ b/crates/miden-protocol/src/transaction/executed_tx.rs @@ -1,7 +1,6 @@ use alloc::vec::Vec; use super::{ - AccountDelta, AccountHeader, AccountId, AdviceInputs, @@ -13,8 +12,7 @@ use super::{ TransactionId, TransactionOutputs, }; -use crate::account::PartialAccount; -use crate::asset::FungibleAsset; +use crate::account::{AccountPatch, PartialAccount}; use crate::block::{BlockHeader, BlockNumber}; use crate::transaction::TransactionInputs; use crate::utils::serde::{ @@ -43,7 +41,7 @@ pub struct ExecutedTransaction { id: TransactionId, tx_inputs: TransactionInputs, tx_outputs: TransactionOutputs, - account_delta: AccountDelta, + account_patch: AccountPatch, tx_measurements: TransactionMeasurements, } @@ -58,7 +56,7 @@ impl ExecutedTransaction { pub fn new( tx_inputs: TransactionInputs, tx_outputs: TransactionOutputs, - account_delta: AccountDelta, + account_patch: AccountPatch, tx_measurements: TransactionMeasurements, ) -> Self { // make sure account IDs are consistent across transaction inputs and outputs @@ -71,14 +69,13 @@ impl ExecutedTransaction { tx_outputs.account().to_commitment(), tx_inputs.input_notes().commitment(), tx_outputs.output_notes().commitment(), - tx_outputs.fee(), ); Self { id, tx_inputs, tx_outputs, - account_delta, + account_patch, tx_measurements, } } @@ -116,11 +113,6 @@ impl ExecutedTransaction { self.tx_outputs.output_notes() } - /// Returns the fee of the transaction. - pub fn fee(&self) -> FungibleAsset { - self.tx_outputs.fee() - } - /// Returns the block number at which the transaction will expire. pub fn expiration_block_num(&self) -> BlockNumber { self.tx_outputs.expiration_block_num() @@ -136,9 +128,10 @@ impl ExecutedTransaction { self.tx_inputs.block_header() } - /// Returns a description of changes between the initial and final account states. - pub fn account_delta(&self) -> &AccountDelta { - &self.account_delta + /// Returns the patch of the transaction that describes the update from the initial to the final + /// account state. + pub fn account_patch(&self) -> &AccountPatch { + &self.account_patch } /// Returns a reference to the inputs for this transaction. @@ -164,8 +157,8 @@ impl ExecutedTransaction { /// Returns individual components of this transaction. pub fn into_parts( self, - ) -> (TransactionInputs, TransactionOutputs, AccountDelta, TransactionMeasurements) { - (self.tx_inputs, self.tx_outputs, self.account_delta, self.tx_measurements) + ) -> (TransactionInputs, TransactionOutputs, AccountPatch, TransactionMeasurements) { + (self.tx_inputs, self.tx_outputs, self.account_patch, self.tx_measurements) } } @@ -186,7 +179,7 @@ impl Serializable for ExecutedTransaction { fn write_into(&self, target: &mut W) { self.tx_inputs.write_into(target); self.tx_outputs.write_into(target); - self.account_delta.write_into(target); + self.account_patch.write_into(target); self.tx_measurements.write_into(target); } } @@ -195,10 +188,10 @@ impl Deserializable for ExecutedTransaction { fn read_from(source: &mut R) -> Result { let tx_inputs = TransactionInputs::read_from(source)?; let tx_outputs = TransactionOutputs::read_from(source)?; - let account_delta = AccountDelta::read_from(source)?; + let account_patch = AccountPatch::read_from(source)?; let tx_measurements = TransactionMeasurements::read_from(source)?; - Ok(Self::new(tx_inputs, tx_outputs, account_delta, tx_measurements)) + Ok(Self::new(tx_inputs, tx_outputs, account_patch, tx_measurements)) } } diff --git a/crates/miden-protocol/src/transaction/inputs/mod.rs b/crates/miden-protocol/src/transaction/inputs/mod.rs index 1c3500ec83..283d9b1504 100644 --- a/crates/miden-protocol/src/transaction/inputs/mod.rs +++ b/crates/miden-protocol/src/transaction/inputs/mod.rs @@ -3,8 +3,8 @@ use alloc::sync::Arc; use alloc::vec::Vec; use core::fmt::Debug; +use miden_crypto::merkle::NodeIndex; use miden_crypto::merkle::smt::{SmtLeaf, SmtProof}; -use miden_crypto::merkle::{MerkleError, NodeIndex}; use super::PartialBlockchain; use crate::account::{ @@ -284,7 +284,7 @@ impl TransactionInputs { ) -> Result, TransactionInputsExtractionError> { let mut asset_witnesses = Vec::new(); for vault_key in vault_keys { - let smt_index = vault_key.to_leaf_index(); + let smt_index = vault_key.hash().to_leaf_index(); // Construct sparse Merkle path. let merkle_path = self.advice_inputs.store.get_path(vault_root, smt_index.into())?; let sparse_path = SparseMerklePath::from_sized_iter(merkle_path.path)?; @@ -300,7 +300,7 @@ impl TransactionInputs { // Construct SMT proof and witness. let smt_proof = SmtProof::new(sparse_path, smt_leaf)?; - let asset_witness = AssetWitness::new(smt_proof)?; + let asset_witness = AssetWitness::new(smt_proof, [vault_key])?; asset_witnesses.push(asset_witness); } Ok(asset_witnesses) @@ -311,7 +311,7 @@ impl TransactionInputs { /// Note that this does not verify the witness' validity (i.e., that the witness is for a valid /// asset). pub fn has_vault_asset_witness(&self, vault_root: Word, asset_key: &AssetVaultKey) -> bool { - let smt_index: NodeIndex = asset_key.to_leaf_index().into(); + let smt_index: NodeIndex = asset_key.hash().to_leaf_index().into(); // make sure the path is in the Merkle store if !self.advice_inputs.store.has_path(vault_root, smt_index) { @@ -325,43 +325,27 @@ impl TransactionInputs { } } - /// Reads the asset from the specified vault under the specified key; returns `None` if the - /// specified asset is not present in these inputs. + /// Reads the asset stored under `asset_key` in the vault with the specified root. + /// + /// Returns `Ok(None)` when the key's leaf is tracked but holds no asset. /// /// # Errors /// Returns an error if: - /// - A Merkle tree with the specified root is not present in the advice data of these inputs. + /// - A Merkle tree with the specified root, or the key's Merkle path, is not present in the + /// advice data of these inputs. /// - Construction of the leaf node or the asset fails. pub fn read_vault_asset( &self, vault_root: Word, asset_key: AssetVaultKey, ) -> Result, TransactionInputsExtractionError> { - // Get the node corresponding to the asset_key; if not found return None - let smt_index = asset_key.to_leaf_index(); - let merkle_node = match self.advice_inputs.store.get_node(vault_root, smt_index.into()) { - Ok(node) => node, - Err(MerkleError::NodeIndexNotFoundInStore(..)) => return Ok(None), - Err(err) => return Err(err.into()), - }; - - // Construct SMT leaf for this asset key - let smt_leaf_elements = self - .advice_inputs - .map - .get(&merkle_node) - .ok_or(TransactionInputsExtractionError::MissingVaultRoot)?; - let smt_leaf = SmtLeaf::try_from_elements(smt_leaf_elements, smt_index)?; - - // Find the asset in the SMT leaf - let asset = smt_leaf - .entries() - .iter() - .find(|(key, _value)| key == &asset_key.to_word()) - .map(|(_key, value)| Asset::from_key_value(asset_key, *value)) - .transpose()?; - - Ok(asset) + let witnesses = + self.read_vault_asset_witnesses(vault_root, BTreeSet::from_iter([asset_key]))?; + let witness = witnesses + .into_iter() + .next() + .expect("one key requested should yield exactly one witness"); + Ok(witness.find(asset_key)) } /// Reads `AccountInputs` for a foreign account from the advice inputs. diff --git a/crates/miden-protocol/src/transaction/kernel/advice_inputs.rs b/crates/miden-protocol/src/transaction/kernel/advice_inputs.rs index cd0bdaeec0..6c692e1248 100644 --- a/crates/miden-protocol/src/transaction/kernel/advice_inputs.rs +++ b/crates/miden-protocol/src/transaction/kernel/advice_inputs.rs @@ -197,7 +197,9 @@ impl TransactionAdviceInputs { // --- number of notes, script root and args -------------------------- self.extend_stack([Felt::from(tx_inputs.input_notes().num_notes())]); let tx_args = tx_inputs.tx_args(); - self.extend_stack(tx_args.tx_script().map_or(Word::empty(), |script| script.root())); + self.extend_stack( + tx_args.tx_script().map_or(Word::empty(), |script| script.root().as_word()), + ); self.extend_stack(tx_args.tx_script_args()); // --- auth procedure args -------------------------------------------- diff --git a/crates/miden-protocol/src/transaction/kernel/memory.rs b/crates/miden-protocol/src/transaction/kernel/memory.rs index 28f2b948ba..9c61295b83 100644 --- a/crates/miden-protocol/src/transaction/kernel/memory.rs +++ b/crates/miden-protocol/src/transaction/kernel/memory.rs @@ -54,7 +54,7 @@ pub type StorageSlot = u8; // | ---------------------------- | ------------- | ---------------- | ----------------------------------- | // | Fungible Asset Delta Ptr | 0 | 4 | | // | Non-Fungible Asset Delta Ptr | 4 | 4 | | -// | Storage Map Delta Ptrs | 8 | 256 | Max 255 storage map deltas | +// | Storage Map Patch Ptrs | 8 | 256 | Max 255 storage map patches | // BOOKKEEPING // ------------------------------------------------------------------------------------------------ diff --git a/crates/miden-protocol/src/transaction/kernel/mod.rs b/crates/miden-protocol/src/transaction/kernel/mod.rs index f49e980462..046433a69c 100644 --- a/crates/miden-protocol/src/transaction/kernel/mod.rs +++ b/crates/miden-protocol/src/transaction/kernel/mod.rs @@ -8,7 +8,6 @@ use crate::account::{AccountHeader, AccountId}; use crate::assembly::Library; use crate::assembly::debuginfo::SourceManagerSync; use crate::assembly::{Assembler, DefaultSourceManager, KernelLibrary}; -use crate::asset::FungibleAsset; use crate::block::BlockNumber; use crate::crypto::SequentialCommit; use crate::errors::TransactionOutputError; @@ -199,7 +198,7 @@ impl TransactionKernel { /// [ /// OUTPUT_NOTES_COMMITMENT, /// ACCOUNT_UPDATE_COMMITMENT, - /// fee_faucet_id_suffix, fee_faucet_id_prefix, fee_amount, expiration_block_num + /// expiration_block_num /// ] /// ``` /// @@ -207,24 +206,19 @@ impl TransactionKernel { /// - OUTPUT_NOTES_COMMITMENT is a commitment to the output notes. /// - ACCOUNT_UPDATE_COMMITMENT is the hash of the the final account commitment and account /// delta commitment. - /// - FEE_ASSET is the fungible asset used as the transaction fee. /// - expiration_block_num is the block number at which the transaction will expire. pub fn build_output_stack( final_account_commitment: Word, account_delta_commitment: Word, output_notes_commitment: Word, - fee: FungibleAsset, expiration_block_num: BlockNumber, ) -> StackOutputs { let account_update_commitment = Hasher::merge(&[final_account_commitment, account_delta_commitment]); - let mut outputs: Vec = Vec::with_capacity(12); + let mut outputs: Vec = Vec::with_capacity(9); outputs.extend(output_notes_commitment); outputs.extend(account_update_commitment); - outputs.push(fee.faucet_id().suffix()); - outputs.push(fee.faucet_id().prefix().as_felt()); - outputs.push(Felt::from(fee.amount())); outputs.push(Felt::from(expiration_block_num)); StackOutputs::new(&outputs).expect("number of stack inputs should be <= 16") @@ -238,7 +232,6 @@ impl TransactionKernel { /// [ /// OUTPUT_NOTES_COMMITMENT, /// ACCOUNT_UPDATE_COMMITMENT, - /// FEE_ASSET, /// expiration_block_num, /// ] /// ``` @@ -247,7 +240,6 @@ impl TransactionKernel { /// - OUTPUT_NOTES_COMMITMENT is the commitment of the output notes. /// - ACCOUNT_UPDATE_COMMITMENT is the hash of the the final account commitment and account /// delta commitment. - /// - FEE_ASSET is the fungible asset used as the transaction fee. /// - tx_expiration_block_num is the block height at which the transaction will become expired, /// defined by the sum of the execution block ref and the transaction's block expiration delta /// (if set during transaction execution). @@ -255,11 +247,11 @@ impl TransactionKernel { /// # Errors /// /// Returns an error if: - /// - Indices 13..16 on the stack are not zeroes. + /// - Indices 9..16 on the stack are not zeroes. /// - Overflow addresses are not empty. pub fn parse_output_stack( stack: &StackOutputs, // FIXME TODO add an extension trait for this one - ) -> Result<(Word, Word, FungibleAsset, BlockNumber), TransactionOutputError> { + ) -> Result<(Word, Word, BlockNumber), TransactionOutputError> { let output_notes_commitment = stack .get_word(TransactionOutputs::OUTPUT_NOTES_COMMITMENT_WORD_IDX) .expect("output_notes_commitment (first word) missing"); @@ -268,16 +260,6 @@ impl TransactionKernel { .get_word(TransactionOutputs::ACCOUNT_UPDATE_COMMITMENT_WORD_IDX) .expect("account_update_commitment (second word) missing"); - let fee_faucet_id_prefix = stack - .get_element(TransactionOutputs::FEE_FAUCET_ID_PREFIX_ELEMENT_IDX) - .expect("fee_faucet_id_prefix missing"); - let fee_faucet_id_suffix = stack - .get_element(TransactionOutputs::FEE_FAUCET_ID_SUFFIX_ELEMENT_IDX) - .expect("fee_faucet_id_suffix missing"); - let fee_amount = stack - .get_element(TransactionOutputs::FEE_AMOUNT_ELEMENT_IDX) - .expect("fee_amount missing"); - let expiration_block_num = stack .get_element(TransactionOutputs::EXPIRATION_BLOCK_ELEMENT_IDX) .expect("tx_expiration_block_num missing"); @@ -290,23 +272,14 @@ impl TransactionKernel { })? .into(); - // Make sure that indices 13, 14 and 15 are zeroes (i.e. the fourth word without the - // expiration block number). - if stack.get_word(12).expect("fourth word missing").as_elements()[..3] - != Word::empty().as_elements()[..3] - { + // Make sure that indices 9..16 are zeros. + if stack.as_slice()[9..].iter().any(|element| *element != Felt::ZERO) { return Err(TransactionOutputError::OutputStackInvalid( - "indices 13, 14 and 15 on the output stack should be ZERO".into(), + "indices 9..16 on the output stack should be ZERO".into(), )); } - let fee_faucet_id = - AccountId::try_from_elements(fee_faucet_id_suffix, fee_faucet_id_prefix) - .expect("fee faucet ID should be validated by the tx kernel"); - let fee = FungibleAsset::new(fee_faucet_id, fee_amount.as_canonical_u64()) - .map_err(TransactionOutputError::FeeAssetNotFungibleAsset)?; - - Ok((output_notes_commitment, account_update_commitment, fee, expiration_block_num)) + Ok((output_notes_commitment, account_update_commitment, expiration_block_num)) } // TRANSACTION OUTPUT PARSER @@ -320,7 +293,6 @@ impl TransactionKernel { /// [ /// OUTPUT_NOTES_COMMITMENT, /// ACCOUNT_UPDATE_COMMITMENT, - /// FEE_ASSET, /// expiration_block_num, /// ] /// ``` @@ -329,7 +301,6 @@ impl TransactionKernel { /// - OUTPUT_NOTES_COMMITMENT is the commitment of the output notes. /// - ACCOUNT_UPDATE_COMMITMENT is the hash of the final account commitment and the account /// delta commitment of the account that the transaction is being executed against. - /// - FEE_ASSET is the fungible asset used as the transaction fee. /// - tx_expiration_block_num is the block height at which the transaction will become expired, /// defined by the sum of the execution block ref and the transaction's block expiration delta /// (if set during transaction execution). @@ -343,10 +314,10 @@ impl TransactionKernel { advice_inputs: &AdviceInputs, output_notes: Vec, ) -> Result { - let (output_notes_commitment, account_update_commitment, fee, expiration_block_num) = + let (output_notes_commitment, account_update_commitment, expiration_block_num) = Self::parse_output_stack(stack)?; - let (final_account_commitment, account_delta_commitment) = + let (final_account_commitment, account_patch_commitment) = Self::parse_account_update_commitment(account_update_commitment, advice_inputs)?; // parse final account state @@ -369,14 +340,13 @@ impl TransactionKernel { Ok(TransactionOutputs::new( account, - account_delta_commitment, + account_patch_commitment, output_notes, - fee, expiration_block_num, )) } - /// Returns the final account commitment and account delta commitment extracted from the account + /// Returns the final account commitment and account patch commitment extracted from the account /// update commitment. fn parse_account_update_commitment( account_update_commitment: Word, @@ -402,13 +372,13 @@ impl TransactionKernel { <[Felt; 4]>::try_from(&account_update_data[0..4]) .expect("we should have sliced off exactly four elements"), ); - let account_delta_commitment = Word::from( + let account_patch_commitment = Word::from( <[Felt; 4]>::try_from(&account_update_data[4..8]) .expect("we should have sliced off exactly four elements"), ); let computed_account_update_commitment = - Hasher::merge(&[final_account_commitment, account_delta_commitment]); + Hasher::merge(&[final_account_commitment, account_patch_commitment]); if computed_account_update_commitment != account_update_commitment { let err_message = format!( @@ -417,7 +387,7 @@ impl TransactionKernel { return Err(TransactionOutputError::AccountUpdateCommitment(err_message.into())); } - Ok((final_account_commitment, account_delta_commitment)) + Ok((final_account_commitment, account_patch_commitment)) } // UTILITY METHODS diff --git a/crates/miden-protocol/src/transaction/kernel/tx_event_id.rs b/crates/miden-protocol/src/transaction/kernel/tx_event_id.rs index f07e79bd71..35efca00ec 100644 --- a/crates/miden-protocol/src/transaction/kernel/tx_event_id.rs +++ b/crates/miden-protocol/src/transaction/kernel/tx_event_id.rs @@ -27,6 +27,9 @@ pub enum TransactionEventId { AccountVaultBeforeRemoveAsset = ACCOUNT_VAULT_BEFORE_REMOVE_ASSET_ID, AccountVaultAfterRemoveAsset = ACCOUNT_VAULT_AFTER_REMOVE_ASSET_ID, + AccountBeforeAssetDeltaComputation = ACCOUNT_BEFORE_ASSET_DELTA_COMPUTATION_ID, + AccountOnAssetDeltaComputation = ACCOUNT_ON_ASSET_DELTA_COMPUTATION_ID, + AccountVaultBeforeGetAsset = ACCOUNT_VAULT_BEFORE_GET_ASSET_ID, AccountStorageBeforeSetItem = ACCOUNT_STORAGE_BEFORE_SET_ITEM_ID, @@ -71,7 +74,6 @@ pub enum TransactionEventId { EpilogueAuthProcEnd = EPILOGUE_AUTH_PROC_END_ID, EpilogueAfterTxCyclesObtained = EPILOGUE_AFTER_TX_CYCLES_OBTAINED_ID, - EpilogueBeforeTxFeeRemovedFromAccount = EPILOGUE_BEFORE_TX_FEE_REMOVED_FROM_ACCOUNT_ID, LinkMapSet = LINK_MAP_SET_ID, LinkMapGet = LINK_MAP_GET_ID, @@ -100,6 +102,10 @@ impl TransactionEventId { Self::AccountVaultAfterAddAsset => &ACCOUNT_VAULT_AFTER_ADD_ASSET_NAME, Self::AccountVaultBeforeRemoveAsset => &ACCOUNT_VAULT_BEFORE_REMOVE_ASSET_NAME, Self::AccountVaultAfterRemoveAsset => &ACCOUNT_VAULT_AFTER_REMOVE_ASSET_NAME, + Self::AccountBeforeAssetDeltaComputation => { + &ACCOUNT_BEFORE_ASSET_DELTA_COMPUTATION_NAME + }, + Self::AccountOnAssetDeltaComputation => &ACCOUNT_ON_ASSET_DELTA_COMPUTATION_NAME, Self::AccountVaultBeforeGetAsset => &ACCOUNT_VAULT_BEFORE_GET_ASSET_NAME, Self::AccountStorageBeforeSetItem => &ACCOUNT_STORAGE_BEFORE_SET_ITEM_NAME, Self::AccountStorageAfterSetItem => &ACCOUNT_STORAGE_AFTER_SET_ITEM_NAME, @@ -128,9 +134,6 @@ impl TransactionEventId { Self::EpilogueAuthProcStart => &EPILOGUE_AUTH_PROC_START_NAME, Self::EpilogueAuthProcEnd => &EPILOGUE_AUTH_PROC_END_NAME, Self::EpilogueAfterTxCyclesObtained => &EPILOGUE_AFTER_TX_CYCLES_OBTAINED_NAME, - Self::EpilogueBeforeTxFeeRemovedFromAccount => { - &EPILOGUE_BEFORE_TX_FEE_REMOVED_FROM_ACCOUNT_NAME - }, Self::LinkMapSet => &LINK_MAP_SET_NAME, Self::LinkMapGet => &LINK_MAP_GET_NAME, Self::Unauthorized => &AUTH_UNAUTHORIZED_NAME, @@ -163,6 +166,13 @@ impl TryFrom for TransactionEventId { Ok(TransactionEventId::AccountVaultAfterRemoveAsset) }, + ACCOUNT_ON_ASSET_DELTA_COMPUTATION_ID => { + Ok(TransactionEventId::AccountOnAssetDeltaComputation) + }, + ACCOUNT_BEFORE_ASSET_DELTA_COMPUTATION_ID => { + Ok(TransactionEventId::AccountBeforeAssetDeltaComputation) + }, + ACCOUNT_VAULT_BEFORE_GET_ASSET_ID => Ok(TransactionEventId::AccountVaultBeforeGetAsset), ACCOUNT_STORAGE_BEFORE_SET_ITEM_ID => { @@ -216,9 +226,6 @@ impl TryFrom for TransactionEventId { EPILOGUE_AFTER_TX_CYCLES_OBTAINED_ID => { Ok(TransactionEventId::EpilogueAfterTxCyclesObtained) }, - EPILOGUE_BEFORE_TX_FEE_REMOVED_FROM_ACCOUNT_ID => { - Ok(TransactionEventId::EpilogueBeforeTxFeeRemovedFromAccount) - }, EPILOGUE_END_ID => Ok(TransactionEventId::EpilogueEnd), LINK_MAP_SET_ID => Ok(TransactionEventId::LinkMapSet), diff --git a/crates/miden-protocol/src/transaction/mod.rs b/crates/miden-protocol/src/transaction/mod.rs index f52042a805..2046b14bd8 100644 --- a/crates/miden-protocol/src/transaction/mod.rs +++ b/crates/miden-protocol/src/transaction/mod.rs @@ -1,4 +1,4 @@ -use super::account::{AccountDelta, AccountHeader, AccountId}; +use super::account::{AccountHeader, AccountId}; use super::note::{NoteId, Nullifier}; use super::vm::AdviceInputs; use super::{Felt, Hasher, WORD_SIZE, Word, ZERO}; @@ -14,6 +14,7 @@ mod transaction_id; mod tx_args; mod tx_header; mod tx_summary; +mod verifier; pub use executed_tx::{ExecutedTransaction, TransactionMeasurements}; pub use inputs::{AccountInputs, InputNote, InputNotes, ToInputNoteCommitments, TransactionInputs}; @@ -32,6 +33,7 @@ pub use outputs::{ pub use partial_blockchain::PartialBlockchain; pub use proven_tx::{InputNoteCommitment, ProvenTransaction, TxAccountUpdate}; pub use transaction_id::TransactionId; -pub use tx_args::{TransactionArgs, TransactionScript}; +pub use tx_args::{TransactionArgs, TransactionScript, TransactionScriptRoot}; pub use tx_header::TransactionHeader; pub use tx_summary::TransactionSummary; +pub use verifier::TransactionVerifier; diff --git a/crates/miden-protocol/src/transaction/outputs/mod.rs b/crates/miden-protocol/src/transaction/outputs/mod.rs index de572055e0..bf3c39ecb7 100644 --- a/crates/miden-protocol/src/transaction/outputs/mod.rs +++ b/crates/miden-protocol/src/transaction/outputs/mod.rs @@ -2,7 +2,6 @@ use core::fmt::Debug; use crate::Word; use crate::account::AccountHeader; -use crate::asset::FungibleAsset; use crate::block::BlockNumber; use crate::utils::serde::{ ByteReader, @@ -34,12 +33,11 @@ mod tests; pub struct TransactionOutputs { /// Information related to the account's final state. account: AccountHeader, - /// The commitment to the delta computed by the transaction kernel. - account_delta_commitment: Word, + /// The commitment to the [`AccountPatch`](crate::account::AccountPatch) computed by the + /// transaction kernel. + account_patch_commitment: Word, /// Set of output notes created by the transaction. output_notes: RawOutputNotes, - /// The fee of the transaction. - fee: FungibleAsset, /// Defines up to which block the transaction is considered valid. expiration_block_num: BlockNumber, } @@ -56,19 +54,8 @@ impl TransactionOutputs { /// output stack. pub const ACCOUNT_UPDATE_COMMITMENT_WORD_IDX: usize = 4; - /// The index of the element at which the ID suffix of the fee faucet is stored on the output - /// stack. - pub const FEE_FAUCET_ID_SUFFIX_ELEMENT_IDX: usize = 8; - - /// The index of the element at which the ID prefix of the fee faucet is stored on the output - /// stack. - pub const FEE_FAUCET_ID_PREFIX_ELEMENT_IDX: usize = 9; - - /// The index of the element at which the fee amount is stored on the output stack. - pub const FEE_AMOUNT_ELEMENT_IDX: usize = 10; - /// The index of the item at which the expiration block height is stored on the output stack. - pub const EXPIRATION_BLOCK_ELEMENT_IDX: usize = 11; + pub const EXPIRATION_BLOCK_ELEMENT_IDX: usize = 8; // CONSTRUCTOR // -------------------------------------------------------------------------------------------- @@ -76,16 +63,14 @@ impl TransactionOutputs { /// Returns a new [`TransactionOutputs`] instantiated from the provided data. pub fn new( account: AccountHeader, - account_delta_commitment: Word, + account_patch_commitment: Word, output_notes: RawOutputNotes, - fee: FungibleAsset, expiration_block_num: BlockNumber, ) -> Self { Self { account, - account_delta_commitment, + account_patch_commitment, output_notes, - fee, expiration_block_num, } } @@ -98,9 +83,9 @@ impl TransactionOutputs { &self.account } - /// Returns the commitment to the delta computed by the transaction kernel. - pub fn account_delta_commitment(&self) -> Word { - self.account_delta_commitment + /// Returns the commitment to the patch computed by the transaction kernel. + pub fn account_patch_commitment(&self) -> Word { + self.account_patch_commitment } /// Returns the set of output notes created by the transaction. @@ -108,11 +93,6 @@ impl TransactionOutputs { &self.output_notes } - /// Returns the fee of the transaction. - pub fn fee(&self) -> FungibleAsset { - self.fee - } - /// Returns the block number at which the transaction will expire. pub fn expiration_block_num(&self) -> BlockNumber { self.expiration_block_num @@ -130,9 +110,8 @@ impl TransactionOutputs { impl Serializable for TransactionOutputs { fn write_into(&self, target: &mut W) { self.account.write_into(target); - self.account_delta_commitment.write_into(target); + self.account_patch_commitment.write_into(target); self.output_notes.write_into(target); - self.fee.write_into(target); self.expiration_block_num.write_into(target); } } @@ -140,16 +119,14 @@ impl Serializable for TransactionOutputs { impl Deserializable for TransactionOutputs { fn read_from(source: &mut R) -> Result { let account = AccountHeader::read_from(source)?; - let account_delta_commitment = Word::read_from(source)?; + let account_patch_commitment = Word::read_from(source)?; let output_notes = RawOutputNotes::read_from(source)?; - let fee = FungibleAsset::read_from(source)?; let expiration_block_num = BlockNumber::read_from(source)?; Ok(Self { account, - account_delta_commitment, + account_patch_commitment, output_notes, - fee, expiration_block_num, }) } diff --git a/crates/miden-protocol/src/transaction/outputs/tests.rs b/crates/miden-protocol/src/transaction/outputs/tests.rs index f2ce0d055f..5f076fe0d7 100644 --- a/crates/miden-protocol/src/transaction/outputs/tests.rs +++ b/crates/miden-protocol/src/transaction/outputs/tests.rs @@ -5,7 +5,7 @@ use assert_matches::assert_matches; use super::{PublicOutputNote, RawOutputNote, RawOutputNotes}; use crate::account::AccountId; use crate::assembly::mast::{ExternalNodeBuilder, MastForest, MastForestContributor}; -use crate::asset::FungibleAsset; +use crate::asset::{AssetCallbackFlag, FungibleAsset}; use crate::constants::NOTE_MAX_SIZE; use crate::errors::{OutputNoteError, TransactionOutputError}; use crate::note::{ @@ -51,8 +51,8 @@ fn output_note_size_hint_matches_serialized_length() -> anyhow::Result<()> { let faucet_id_1 = AccountId::try_from(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET).unwrap(); let faucet_id_2 = AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET).unwrap(); - let asset_1 = FungibleAsset::new(faucet_id_1, 100)?.into(); - let asset_2 = FungibleAsset::new(faucet_id_2, 200)?.into(); + let asset_1 = FungibleAsset::new(faucet_id_1, 100, AssetCallbackFlag::Disabled)?.into(); + let asset_2 = FungibleAsset::new(faucet_id_2, 200, AssetCallbackFlag::Disabled)?.into(); let assets = NoteAssets::new(vec![asset_1, asset_2])?; @@ -105,7 +105,7 @@ fn oversized_public_note_triggers_size_limit_error() -> anyhow::Result<()> { // Create a public note (NoteType::Public is required for PublicOutputNote) let faucet_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET).unwrap(); - let asset = FungibleAsset::new(faucet_id, 100)?.into(); + let asset = FungibleAsset::new(faucet_id, 100, AssetCallbackFlag::Disabled)?.into(); let assets = NoteAssets::new(vec![asset])?; let metadata = PartialNoteMetadata::new(sender_id, NoteType::Public) diff --git a/crates/miden-protocol/src/transaction/proven_tx.rs b/crates/miden-protocol/src/transaction/proven_tx.rs index 2af128182c..4ed5fea84b 100644 --- a/crates/miden-protocol/src/transaction/proven_tx.rs +++ b/crates/miden-protocol/src/transaction/proven_tx.rs @@ -3,9 +3,7 @@ use alloc::string::ToString; use alloc::vec::Vec; use super::{InputNote, ToInputNoteCommitments}; -use crate::account::Account; -use crate::account::delta::AccountUpdateDetails; -use crate::asset::FungibleAsset; +use crate::account::{Account, AccountUpdateDetails}; use crate::block::BlockNumber; use crate::errors::ProvenTransactionError; use crate::note::{NoteHeader, NoteId}; @@ -60,9 +58,6 @@ pub struct ProvenTransaction { /// The block commitment of the transaction's reference block. ref_block_commitment: Word, - /// The fee of the transaction. - fee: FungibleAsset, - /// The block number by which the transaction will expire, as defined by the executed scripts. expiration_block_num: BlockNumber, @@ -85,6 +80,7 @@ impl ProvenTransaction { /// - The total number of output notes is greater than /// [`MAX_OUTPUT_NOTES_PER_TX`](crate::constants::MAX_OUTPUT_NOTES_PER_TX). /// - The vector of output notes contains duplicates. + /// - The set of input and output notes contains the same note. /// - The transaction is empty, which is the case if the account state is unchanged or the /// number of input notes is zero. /// - The commitment computed on the actual account delta contained in [`TxAccountUpdate`] does @@ -95,7 +91,6 @@ impl ProvenTransaction { output_notes: impl IntoIterator>, ref_block_num: BlockNumber, ref_block_commitment: Word, - fee: FungibleAsset, expiration_block_num: BlockNumber, proof: ExecutionProof, ) -> Result { @@ -108,12 +103,21 @@ impl ProvenTransaction { let output_notes = OutputNotes::new(output_notes).map_err(ProvenTransactionError::OutputNotesError)?; + // Disallow creating and consuming notes with the same ID in a transaction. This is a + // circular dependency that can be abused (see https://github.com/0xMiden/protocol/issues/2796). + // This is only relevant for unauthenticated notes (notes with a header), since only these + // can be erased at batch or block level. Authenticated notes don't exhibit this issue. + for input_note in input_notes.iter().filter_map(InputNoteCommitment::header) { + if output_notes.iter().any(|output_note| output_note.id() == input_note.id()) { + return Err(ProvenTransactionError::NoteCreatedAndConsumed(input_note.id())); + } + } + let id = TransactionId::new( account_update.initial_state_commitment(), account_update.final_state_commitment(), input_notes.commitment(), output_notes.commitment(), - fee, ); let proven_transaction = Self { @@ -123,7 +127,6 @@ impl ProvenTransaction { output_notes, ref_block_num, ref_block_commitment, - fee, expiration_block_num, proof, }; @@ -174,11 +177,6 @@ impl ProvenTransaction { self.ref_block_commitment } - /// Returns the fee of the transaction. - pub fn fee(&self) -> FungibleAsset { - self.fee - } - /// Returns an iterator of the headers of unauthenticated input notes in this transaction. pub fn unauthenticated_notes(&self) -> impl Iterator { self.input_notes.iter().filter_map(|note| note.header()) @@ -208,7 +206,7 @@ impl ProvenTransaction { /// number of input notes is zero. /// - The commitment computed on the actual account delta contained in [`TxAccountUpdate`] does /// not match its declared account delta commitment. - fn validate(mut self) -> Result { + fn validate(self) -> Result { // Check that either the account state was changed or at least one note was consumed, // otherwise this transaction is considered empty. if self.account_update.initial_state_commitment() @@ -218,31 +216,20 @@ impl ProvenTransaction { return Err(ProvenTransactionError::EmptyTransaction); } - match &mut self.account_update.details { - // The delta commitment cannot be validated for private account updates. It will be + match &self.account_update.details { + // The patch commitment cannot be validated for private account updates. It will be // validated as part of transaction proof verification implicitly. AccountUpdateDetails::Private => (), - AccountUpdateDetails::Delta(post_fee_account_delta) => { - // Add the removed fee to the post fee delta to get the pre-fee delta, against which - // the delta commitment needs to be validated. - post_fee_account_delta.vault_mut().add_asset(self.fee.into()).map_err(|err| { - ProvenTransactionError::AccountDeltaCommitmentMismatch(Box::from(err)) - })?; - - let expected_commitment = self.account_update.account_delta_commitment; - let actual_commitment = post_fee_account_delta.to_commitment(); + AccountUpdateDetails::Public(account_patch) => { + let expected_commitment = self.account_update.account_patch_commitment; + let actual_commitment = account_patch.to_commitment(); if expected_commitment != actual_commitment { - return Err(ProvenTransactionError::AccountDeltaCommitmentMismatch(Box::from( + return Err(ProvenTransactionError::AccountPatchCommitmentMismatch(Box::from( format!( - "expected account delta commitment {expected_commitment} but found {actual_commitment}" + "expected account patch commitment {expected_commitment} but found {actual_commitment}" ), ))); } - - // Remove the added fee again to recreate the post fee delta. - post_fee_account_delta.vault_mut().remove_asset(self.fee.into()).map_err( - |err| ProvenTransactionError::AccountDeltaCommitmentMismatch(Box::from(err)), - )?; }, } @@ -257,7 +244,6 @@ impl Serializable for ProvenTransaction { self.output_notes.write_into(target); self.ref_block_num.write_into(target); self.ref_block_commitment.write_into(target); - self.fee.write_into(target); self.expiration_block_num.write_into(target); self.proof.write_into(target); } @@ -272,7 +258,6 @@ impl Deserializable for ProvenTransaction { let ref_block_num = BlockNumber::read_from(source)?; let ref_block_commitment = Word::read_from(source)?; - let fee = FungibleAsset::read_from(source)?; let expiration_block_num = BlockNumber::read_from(source)?; let proof = ExecutionProof::read_from(source)?; @@ -281,7 +266,6 @@ impl Deserializable for ProvenTransaction { account_update.final_state_commitment(), input_notes.commitment(), output_notes.commitment(), - fee, ); let proven_transaction = Self { @@ -291,7 +275,6 @@ impl Deserializable for ProvenTransaction { output_notes, ref_block_num, ref_block_commitment, - fee, expiration_block_num, proof, }; @@ -319,20 +302,18 @@ pub struct TxAccountUpdate { /// The commitment of the account state after the transaction was executed. final_state_commitment: Word, - /// The commitment to the account delta resulting from the execution of the transaction. - /// - /// This must be the commitment to the account delta as computed by the transaction kernel in - /// the epilogue (the "pre-fee" delta). Notably, this _excludes_ the automatically removed fee - /// asset. The account delta possibly contained in [`AccountUpdateDetails`] _includes_ the - /// _removed_ fee asset, so that it represents the full account delta of the transaction - /// (the "post-fee" delta). This mismatch means that in order to validate the delta, the - /// fee asset must be _added_ to the delta before checking its commitment against this - /// field. - account_delta_commitment: Word, - - /// A set of changes which can be applied the account's state prior to the transaction to - /// get the account state after the transaction. For private accounts this is set to - /// [AccountUpdateDetails::Private]. + /// The commitment to the [`AccountPatch`](crate::account::AccountPatch) resulting from the + /// execution of the transaction, as computed by the transaction kernel in the epilogue. This + /// commitment is always set regardless of whether the account is public or private. + /// - When `details` is [`AccountUpdateDetails::Public`], it must equal the commitment of the + /// patch carried in that variant. + /// - When `details` is [`AccountUpdateDetails::Private`], the patch itself is not transmitted + /// and the commitment is validated implicitly as part of transaction verification. + account_patch_commitment: Word, + + /// A description of the changes to the account that produces the post-transaction state when + /// applied to the pre-transaction state. For private accounts this is set to + /// [`AccountUpdateDetails::Private`]. details: AccountUpdateDetails, } @@ -341,12 +322,12 @@ impl TxAccountUpdate { /// /// Returns an error if: /// - The size of the serialized account update exceeds [`ACCOUNT_UPDATE_MAX_SIZE`]. - /// - The transaction was executed against a _new_ account with public state and its account ID - /// does not match the ID in the account update. + /// - The transaction was executed against an account with public state and its account ID does + /// not match the ID of the patch in the account update. /// - The transaction was executed against a _new_ account with public state and its commitment /// does not match the final state commitment of the account update. /// - The transaction creates a _new_ account with public state and the update is of type - /// [`AccountUpdateDetails::Delta`] but the account delta is not a full state delta. + /// [`AccountUpdateDetails::Public`] but the account patch is not a full state patch. /// - The transaction was executed against a private account and the account update is _not_ of /// type [`AccountUpdateDetails::Private`]. /// - The transaction was executed against an account with public state and the update is of @@ -355,14 +336,14 @@ impl TxAccountUpdate { account_id: AccountId, init_state_commitment: Word, final_state_commitment: Word, - account_delta_commitment: Word, + account_patch_commitment: Word, details: AccountUpdateDetails, ) -> Result { let account_update = Self { account_id, init_state_commitment, final_state_commitment, - account_delta_commitment, + account_patch_commitment, details, }; @@ -388,25 +369,25 @@ impl TxAccountUpdate { account_update.account_id(), )); }, - AccountUpdateDetails::Delta(delta) => { + AccountUpdateDetails::Public(patch) => { + if patch.id() != account_id { + return Err(ProvenTransactionError::AccountIdMismatch { + tx_account_id: account_id, + details_account_id: patch.id(), + }); + } + let is_new_account = account_update.initial_state_commitment().is_empty(); if is_new_account { // Validate that for new accounts, the full account state can be constructed - // from the delta. This will fail if it is not such a full state delta. - let account = Account::try_from(delta).map_err(|err| { - ProvenTransactionError::NewPublicStateAccountRequiresFullStateDelta { - id: delta.id(), + // from the patch. This will fail if it is not such a full state patch. + let account = Account::try_from(patch).map_err(|err| { + ProvenTransactionError::NewPublicStateAccountRequiresFullStatePatch { + id: patch.id(), source: err, } })?; - if account.id() != account_id { - return Err(ProvenTransactionError::AccountIdMismatch { - tx_account_id: account_id, - details_account_id: account.id(), - }); - } - if account.to_commitment() != account_update.final_state_commitment { return Err(ProvenTransactionError::AccountFinalCommitmentMismatch { tx_final_commitment: account_update.final_state_commitment, @@ -435,9 +416,10 @@ impl TxAccountUpdate { self.final_state_commitment } - /// Returns the commitment to the account delta resulting from the execution of the transaction. - pub fn account_delta_commitment(&self) -> Word { - self.account_delta_commitment + /// Returns the commitment to the [`AccountPatch`](crate::account::AccountPatch) resulting from + /// the execution of the transaction. + pub fn account_patch_commitment(&self) -> Word { + self.account_patch_commitment } /// Returns the description of the updates for public accounts. @@ -459,7 +441,7 @@ impl Serializable for TxAccountUpdate { self.account_id.write_into(target); self.init_state_commitment.write_into(target); self.final_state_commitment.write_into(target); - self.account_delta_commitment.write_into(target); + self.account_patch_commitment.write_into(target); self.details.write_into(target); } } @@ -469,14 +451,14 @@ impl Deserializable for TxAccountUpdate { let account_id = AccountId::read_from(source)?; let init_state_commitment = Word::read_from(source)?; let final_state_commitment = Word::read_from(source)?; - let account_delta_commitment = Word::read_from(source)?; + let account_patch_commitment = Word::read_from(source)?; let details = AccountUpdateDetails::read_from(source)?; Self::new( account_id, init_state_commitment, final_state_commitment, - account_delta_commitment, + account_patch_commitment, details, ) .map_err(|err| DeserializationError::InvalidValue(err.to_string())) @@ -596,24 +578,24 @@ mod tests { use alloc::vec::Vec; use anyhow::Context; + use assert_matches::assert_matches; use miden_crypto::rand::test_utils::rand_value; use miden_verifier::ExecutionProof; use super::ProvenTransaction; - use crate::account::delta::AccountUpdateDetails; use crate::account::{ Account, - AccountDelta, AccountId, AccountIdVersion, - AccountStorageDelta, + AccountPatch, + AccountStoragePatch, AccountType, - AccountVaultDelta, - StorageMapDelta, + AccountUpdateDetails, + AccountVaultPatch, StorageMapKey, + StorageMapPatch, StorageSlotName, }; - use crate::asset::FungibleAsset; use crate::block::BlockNumber; use crate::errors::ProvenTransactionError; use crate::testing::account_id::{ @@ -624,7 +606,7 @@ mod tests { use crate::testing::noop_auth_component::NoopAuthComponent; use crate::transaction::{InputNoteCommitment, OutputNote, TxAccountUpdate}; use crate::utils::serde::{Deserializable, Serializable}; - use crate::{ACCOUNT_UPDATE_MAX_SIZE, EMPTY_WORD, ONE, Word}; + use crate::{ACCOUNT_UPDATE_MAX_SIZE, EMPTY_WORD, Felt, Word}; fn check_if_sync() {} fn check_if_send() {} @@ -651,9 +633,9 @@ mod tests { .with_auth_component(NoopAuthComponent) .with_component(AddComponent) .build_existing()?; - let delta = AccountDelta::try_from(account.clone())?; + let patch = AccountPatch::try_from(account.clone())?; - let details = AccountUpdateDetails::Delta(delta); + let details = AccountUpdateDetails::Public(patch); TxAccountUpdate::new( account.id(), @@ -677,14 +659,20 @@ mod tests { for _ in 0..required_entries { map.insert(StorageMapKey::from_raw(rand_value()), rand_value::()); } - let storage_delta = StorageMapDelta::new(map); - - // A delta that exceeds the limit returns an error. - let storage_delta = - AccountStorageDelta::from_iters([], [], [(StorageSlotName::mock(4), storage_delta)]); - let delta = AccountDelta::new(account_id, storage_delta, AccountVaultDelta::default(), ONE) - .unwrap(); - let details = AccountUpdateDetails::Delta(delta); + let storage_patch = StorageMapPatch::new(map); + + // A patch that exceeds the limit returns an error. + let storage_patch = + AccountStoragePatch::from_iters([], [], [(StorageSlotName::mock(4), storage_patch)]); + let patch = AccountPatch::new( + account_id, + storage_patch, + AccountVaultPatch::default(), + None, + Some(Felt::from(2u32)), + ) + .unwrap(); + let details = AccountUpdateDetails::Public(patch); let details_size = details.get_size_hint(); let err = TxAccountUpdate::new( @@ -701,6 +689,44 @@ mod tests { ); } + /// Building a [`TxAccountUpdate`] for a public account fails if the account ID in the patch + /// does not match the account ID passed to the constructor. + #[test] + fn account_update_id_mismatch_between_account_id_and_patch() -> anyhow::Result<()> { + let patch_account = Account::builder([9; 32]) + .account_type(AccountType::Public) + .with_auth_component(NoopAuthComponent) + .with_component(AddComponent) + .build_existing()?; + let patch = AccountPatch::try_from(patch_account.clone())?; + + let other_account_id = + AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE)?; + assert_ne!(patch_account.id(), other_account_id); + + let err = TxAccountUpdate::new( + other_account_id, + patch_account.to_commitment(), + patch_account.to_commitment(), + Word::empty(), + AccountUpdateDetails::Public(patch), + ) + .unwrap_err(); + + assert_matches!( + err, + ProvenTransactionError::AccountIdMismatch { + tx_account_id, + details_account_id, + } => { + assert_eq!(tx_account_id, other_account_id); + assert_eq!(details_account_id, patch_account.id()); + } + ); + + Ok(()) + } + #[test] fn test_proven_tx_serde_roundtrip() -> anyhow::Result<()> { let account_id = @@ -709,8 +735,8 @@ mod tests { [2; 32].try_into().expect("failed to create initial account commitment"); let final_account_commitment = [3; 32].try_into().expect("failed to create final account commitment"); - let account_delta_commitment = - [4; 32].try_into().expect("failed to create account delta commitment"); + let account_patch_commitment = + [4; 32].try_into().expect("failed to create account patch commitment"); let ref_block_num = BlockNumber::from(1); let ref_block_commitment = Word::empty(); let expiration_block_num = BlockNumber::from(2); @@ -720,7 +746,7 @@ mod tests { account_id, initial_account_commitment, final_account_commitment, - account_delta_commitment, + account_patch_commitment, AccountUpdateDetails::Private, ) .context("failed to build account update")?; @@ -731,7 +757,6 @@ mod tests { Vec::::new(), ref_block_num, ref_block_commitment, - FungibleAsset::mock(42).unwrap_fungible(), expiration_block_num, proof, ) diff --git a/crates/miden-protocol/src/transaction/transaction_id.rs b/crates/miden-protocol/src/transaction/transaction_id.rs index 4313e6c465..30c07664ae 100644 --- a/crates/miden-protocol/src/transaction/transaction_id.rs +++ b/crates/miden-protocol/src/transaction/transaction_id.rs @@ -4,7 +4,6 @@ use core::fmt::{Debug, Display}; use miden_crypto_derive::WordWrapper; use super::{Felt, Hasher, ProvenTransaction, WORD_SIZE, Word, ZERO}; -use crate::asset::{Asset, FungibleAsset}; use crate::utils::serde::{ ByteReader, ByteWriter, @@ -25,7 +24,6 @@ use crate::utils::serde::{ /// FINAL_ACCOUNT_COMMITMENT, /// INPUT_NOTES_COMMITMENT, /// OUTPUT_NOTES_COMMITMENT, -/// FEE_ASSET, /// ) /// /// This achieves the following properties: @@ -41,14 +39,12 @@ impl TransactionId { final_account_commitment: Word, input_notes_commitment: Word, output_notes_commitment: Word, - fee_asset: FungibleAsset, ) -> Self { - let mut elements = [ZERO; 6 * WORD_SIZE]; + let mut elements = [ZERO; 4 * WORD_SIZE]; elements[..4].copy_from_slice(init_account_commitment.as_elements()); elements[4..8].copy_from_slice(final_account_commitment.as_elements()); elements[8..12].copy_from_slice(input_notes_commitment.as_elements()); elements[12..16].copy_from_slice(output_notes_commitment.as_elements()); - elements[16..].copy_from_slice(&Asset::from(fee_asset).as_elements()); Self(Hasher::hash_elements(&elements)) } } @@ -75,7 +71,6 @@ impl From<&ProvenTransaction> for TransactionId { tx.account_update().final_state_commitment(), tx.input_notes().commitment(), tx.output_notes().commitment(), - tx.fee(), ) } } diff --git a/crates/miden-protocol/src/transaction/tx_args.rs b/crates/miden-protocol/src/transaction/tx_args.rs index c18b2eb996..0ffc8eeec5 100644 --- a/crates/miden-protocol/src/transaction/tx_args.rs +++ b/crates/miden-protocol/src/transaction/tx_args.rs @@ -1,9 +1,12 @@ use alloc::collections::BTreeMap; +use alloc::string::String; use alloc::sync::Arc; use alloc::vec::Vec; +use core::fmt::Display; use miden_core::mast::MastNodeExt; use miden_crypto::merkle::InnerNodeInfo; +use miden_crypto_derive::WordWrapper; use miden_mast_package::Package; use super::{Felt, Hasher, Word}; @@ -276,6 +279,42 @@ impl Deserializable for TransactionArgs { } } +// TRANSACTION SCRIPT ROOT +// ================================================================================================ + +/// The MAST root of a [`TransactionScript`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, WordWrapper)] +pub struct TransactionScriptRoot(Word); + +impl From for Word { + fn from(root: TransactionScriptRoot) -> Self { + root.0 + } +} + +impl Display for TransactionScriptRoot { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + Display::fmt(&self.0, f) + } +} + +impl Serializable for TransactionScriptRoot { + fn write_into(&self, target: &mut W) { + target.write(self.0); + } + + fn get_size_hint(&self) -> usize { + self.0.get_size_hint() + } +} + +impl Deserializable for TransactionScriptRoot { + fn read_from(source: &mut R) -> Result { + let word: Word = source.read()?; + Ok(Self::from_raw(word)) + } +} + // TRANSACTION SCRIPT // ================================================================================================ @@ -334,8 +373,8 @@ impl TransactionScript { } /// Returns the commitment of this transaction script (i.e., the script's MAST root). - pub fn root(&self) -> Word { - self.mast[self.entrypoint].digest() + pub fn root(&self) -> TransactionScriptRoot { + TransactionScriptRoot::from_raw(self.mast[self.entrypoint].digest()) } /// Returns a new [TransactionScript] with the provided advice map entries merged into the diff --git a/crates/miden-protocol/src/transaction/tx_header.rs b/crates/miden-protocol/src/transaction/tx_header.rs index 2a9a0c1a65..0a6a561281 100644 --- a/crates/miden-protocol/src/transaction/tx_header.rs +++ b/crates/miden-protocol/src/transaction/tx_header.rs @@ -1,7 +1,6 @@ use alloc::vec::Vec; use crate::Word; -use crate::asset::FungibleAsset; use crate::note::NoteHeader; use crate::transaction::{ AccountId, @@ -25,9 +24,8 @@ use crate::utils::serde::{ /// /// The header is essentially a direct copy of the transaction's public commitments, in particular /// the initial and final account state commitment as well as all nullifiers of consumed notes and -/// all note IDs of created notes together with the fee asset. While account updates may be -/// aggregated and notes may be erased as part of batch and block building, the header retains the -/// original transaction's data. +/// all note IDs of created notes. While account updates may be aggregated and notes may be erased +/// as part of batch and block building, the header retains the original transaction's data. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TransactionHeader { id: TransactionId, @@ -36,7 +34,6 @@ pub struct TransactionHeader { final_state_commitment: Word, input_notes: InputNotes, output_notes: Vec, - fee: FungibleAsset, } impl TransactionHeader { @@ -46,20 +43,19 @@ impl TransactionHeader { /// Constructs a new [`TransactionHeader`] from the provided parameters. /// /// The [`TransactionId`] is computed from the provided parameters, committing to the initial - /// and final account commitments, input and output note commitments, and the fee asset. + /// and final account commitments and the input and output note commitments. /// /// The input notes and output notes must be in the same order as they appeared in the /// transaction that this header represents, otherwise an incorrect ID will be computed. /// - /// Note that this cannot validate that the [`AccountId`] or the fee asset is valid with respect - /// to the other data. This must be validated outside of this type. + /// Note that this cannot validate that the [`AccountId`] is valid with respect to the other + /// data. This must be validated outside of this type. pub fn new( account_id: AccountId, initial_state_commitment: Word, final_state_commitment: Word, input_notes: InputNotes, output_notes: Vec, - fee: FungibleAsset, ) -> Self { let input_notes_commitment = input_notes.commitment(); let output_notes_commitment = RawOutputNotes::compute_commitment(output_notes.iter()); @@ -69,7 +65,6 @@ impl TransactionHeader { final_state_commitment, input_notes_commitment, output_notes_commitment, - fee, ); Self { @@ -79,7 +74,6 @@ impl TransactionHeader { final_state_commitment, input_notes, output_notes, - fee, } } @@ -96,7 +90,6 @@ impl TransactionHeader { final_state_commitment: Word, input_notes: InputNotes, output_notes: Vec, - fee: FungibleAsset, ) -> Self { Self { id, @@ -105,7 +98,6 @@ impl TransactionHeader { final_state_commitment, input_notes, output_notes, - fee, } } @@ -155,11 +147,6 @@ impl TransactionHeader { pub fn output_notes(&self) -> &[NoteHeader] { &self.output_notes } - - /// Returns the fee paid by this transaction. - pub fn fee(&self) -> FungibleAsset { - self.fee - } } impl From<&ProvenTransaction> for TransactionHeader { @@ -174,7 +161,6 @@ impl From<&ProvenTransaction> for TransactionHeader { tx.account_update().final_state_commitment(), tx.input_notes().clone(), tx.output_notes().iter().map(<&NoteHeader>::from).cloned().collect(), - tx.fee(), ) } } @@ -189,7 +175,6 @@ impl From<&ExecutedTransaction> for TransactionHeader { tx.final_account().to_commitment(), tx.input_notes().to_commitments(), tx.output_notes().iter().map(|n| *n.header()).collect(), - tx.fee(), ) } } @@ -206,7 +191,6 @@ impl Serializable for TransactionHeader { final_state_commitment, input_notes, output_notes, - fee, } = self; account_id.write_into(target); @@ -214,7 +198,6 @@ impl Serializable for TransactionHeader { final_state_commitment.write_into(target); input_notes.write_into(target); output_notes.write_into(target); - fee.write_into(target); } } @@ -225,7 +208,6 @@ impl Deserializable for TransactionHeader { let final_state_commitment = ::read_from(source)?; let input_notes = >::read_from(source)?; let output_notes = >::read_from(source)?; - let fee = FungibleAsset::read_from(source)?; let tx_header = Self::new( account_id, @@ -233,7 +215,6 @@ impl Deserializable for TransactionHeader { final_state_commitment, input_notes, output_notes, - fee, ); Ok(tx_header) diff --git a/crates/miden-tx/src/verifier/mod.rs b/crates/miden-protocol/src/transaction/verifier.rs similarity index 90% rename from crates/miden-tx/src/verifier/mod.rs rename to crates/miden-protocol/src/transaction/verifier.rs index 9855151dec..5635660b2d 100644 --- a/crates/miden-tx/src/verifier/mod.rs +++ b/crates/miden-protocol/src/transaction/verifier.rs @@ -1,9 +1,9 @@ -use miden_protocol::CoreLibrary; -use miden_protocol::transaction::{ProvenTransaction, TransactionKernel}; -use miden_protocol::vm::ProgramInfo; use miden_verifier::verify_with_precompiles; -use super::TransactionVerifierError; +use crate::CoreLibrary; +use crate::errors::TransactionVerifierError; +use crate::transaction::{ProvenTransaction, TransactionKernel}; +use crate::vm::ProgramInfo; // TRANSACTION VERIFIER // ================================================================================================ @@ -42,9 +42,8 @@ impl TransactionVerifier { ); let stack_outputs = TransactionKernel::build_output_stack( transaction.account_update().final_state_commitment(), - transaction.account_update().account_delta_commitment(), + transaction.account_update().account_patch_commitment(), transaction.output_notes().commitment(), - transaction.fee(), transaction.expiration_block_num(), ); diff --git a/crates/miden-standards/asm/account_components/access/authority.masm b/crates/miden-standards/asm/account_components/access/authority.masm index bac025aa0c..f660bb559b 100644 --- a/crates/miden-standards/asm/account_components/access/authority.masm +++ b/crates/miden-standards/asm/account_components/access/authority.masm @@ -4,11 +4,15 @@ # state-mutating operations. The actual authorization check (`assert_authorized`) lives at # `miden::standards::access::authority` and is `exec`'d inline by gating procedures within # the active account's context. This component exposes `get_authority` as a call-padded -# accessor so other accounts can read this account's authority. +# accessor so other accounts can read this account's authority, and re-exports the owner-gated +# emergency switch (`freeze` / `unfreeze`) as `call` entrypoints. use miden::protocol::active_account use miden::standards::access::authority::AUTHORITY_SLOT +pub use ::miden::standards::access::authority::freeze +pub use ::miden::standards::access::authority::unfreeze + #! Returns the authority discriminator stored on the account. #! #! Inputs: [pad(16)] diff --git a/crates/miden-standards/asm/account_components/access/pausable/manager.masm b/crates/miden-standards/asm/account_components/access/pausable/manager.masm index b7e74886f0..ed3d462b92 100644 --- a/crates/miden-standards/asm/account_components/access/pausable/manager.masm +++ b/crates/miden-standards/asm/account_components/access/pausable/manager.masm @@ -4,8 +4,8 @@ # `Authority` component via `exec.authority::assert_authorized`. # # Companion components required: -# - `Authority` (installed via `AccessControl::Ownable2Step` / `AccessControl::Rbac` / -# `AccessControl::AuthControlled`). +# - `Authority` (installed via `AccessControl::Ownable2Step` / `AccessControl::Rbac`, or +# `Authority::AuthControlled` directly by `create_user_fungible_faucet`). # - `Pausable` — provides the `is_paused` storage slot. pub use ::miden::standards::access::pausable::manager::pause diff --git a/crates/miden-standards/asm/account_components/access/rbac.masm b/crates/miden-standards/asm/account_components/access/rbac.masm index e4483d75a1..1478b4f575 100644 --- a/crates/miden-standards/asm/account_components/access/rbac.masm +++ b/crates/miden-standards/asm/account_components/access/rbac.masm @@ -2,7 +2,6 @@ # # See the `RoleBasedAccessControl` Rust type's documentation for more details. -pub use ::miden::standards::access::rbac::assert_sender_has_role pub use ::miden::standards::access::rbac::has_role pub use ::miden::standards::access::rbac::get_role_admin pub use ::miden::standards::access::rbac::get_role_member_count diff --git a/crates/miden-standards/asm/account_components/auth/network_account.masm b/crates/miden-standards/asm/account_components/auth/network_account.masm index cd6b716a7f..0237e8996e 100644 --- a/crates/miden-standards/asm/account_components/auth/network_account.masm +++ b/crates/miden-standards/asm/account_components/auth/network_account.masm @@ -6,6 +6,7 @@ use miden::protocol::active_account use miden::protocol::native_account use miden::core::word use miden::standards::auth::note_script_allowlist +use miden::standards::auth::tx_script_allowlist # CONSTANTS # ================================================================================================= @@ -14,13 +15,18 @@ use miden::standards::auth::note_script_allowlist # (defined as Word); any non-empty value marks a root as allowed. const ALLOWED_NOTE_SCRIPTS_SLOT = word("miden::standards::auth::network_account::allowed_note_scripts") +# The slot holding the map of allowed tx script roots. Keys are tx script roots (defined as Word); +# any non-empty value marks a root as allowed. +const ALLOWED_TX_SCRIPTS_SLOT = word("miden::standards::auth::network_account::allowed_tx_scripts") + # AUTH PROCEDURE # ================================================================================================= #! Authenticates a transaction against an `AuthNetworkAccount` component. #! #! Enforces two invariants: -#! 1. No transaction script was executed in this transaction. +#! 1. The transaction script root, if any, must be present in the allowlist stored at +#! `ALLOWED_TX_SCRIPTS_SLOT` (a transaction that executed no tx script is always allowed). #! 2. Every consumed input note must have a script root present in the allowlist stored at #! `ALLOWED_NOTE_SCRIPTS_SLOT`. #! @@ -36,8 +42,11 @@ pub proc auth_network_transaction(auth_args: word) dropw # => [pad(16)] - # ---- Reject transactions that executed a tx script ---- - exec.note_script_allowlist::assert_no_tx_script + # ---- Reject any tx script whose root is not allowlisted ---- + push.ALLOWED_TX_SCRIPTS_SLOT[0..2] + # => [slot_id_suffix, slot_id_prefix, pad(16)] + + exec.tx_script_allowlist::assert_tx_script_allowed # => [pad(16)] # ---- Reject any input note whose script root is not allowlisted ---- diff --git a/crates/miden-standards/asm/account_components/faucets/policies/burn/min_burn_amount.masm b/crates/miden-standards/asm/account_components/faucets/policies/burn/min_burn_amount.masm new file mode 100644 index 0000000000..55e3ff6e96 --- /dev/null +++ b/crates/miden-standards/asm/account_components/faucets/policies/burn/min_burn_amount.masm @@ -0,0 +1,9 @@ +# The MASM code of the `min_burn_amount` Burn Policy Account Component. +# +# Exposes `check_policy` so its MAST root can be registered as the active (or allowed) policy on +# a `TokenPolicyManager`, plus the owner-gated `set_min_burn_amount` / `get_min_burn_amount` +# accessors for the configurable minimum burn amount stored in this component's storage slot. + +pub use ::miden::standards::faucets::policies::burn::min_burn_amount::check_policy +pub use ::miden::standards::faucets::policies::burn::min_burn_amount::set_min_burn_amount +pub use ::miden::standards::faucets::policies::burn::min_burn_amount::get_min_burn_amount diff --git a/crates/miden-standards/asm/account_components/faucets/policies/policy_manager.masm b/crates/miden-standards/asm/account_components/faucets/policies/policy_manager.masm index eba92641b2..470abbd73c 100644 --- a/crates/miden-standards/asm/account_components/faucets/policies/policy_manager.masm +++ b/crates/miden-standards/asm/account_components/faucets/policies/policy_manager.masm @@ -1,21 +1,12 @@ # The MASM code of the Token Policy Manager Account Component. # -# Owns one `active_*_policy` slot per mint / burn kind (send and receive policy roots live -# directly in the protocol-reserved callback slots -# `miden::protocol::faucet::callback::on_before_asset_added_to_*`), plus one -# `allowed_*_policies` map slot per kind for set-time validation. When any transfer policy is -# registered (including `AllowAll`), the protocol-level asset-callback storage slots are also -# installed and populated with the initial active send / receive policy roots, so every minted -# asset carries the callback flag and future `set_send_policy` / `set_receive_policy` switches -# apply uniformly to the whole circulating supply. Pair with policy components whose procedure -# roots are registered in the relevant allowed-policies map. -# -# `execute_*_policy` procedures are intentionally not exposed here: they are `exec`-invocation -# helpers used inline by other MASM (e.g. `faucets::fungible::mint_and_send`), which `use` -# the standards-side library directly. Only `call`-invocation procedures (`set_*_policy` and -# `get_*_policy`) are part of this component's public API. Send and receive policies are -# invoked directly by the kernel via the protocol callback slots — the manager no longer wraps -# them. +# Owns one `active_*_policy` slot per kind, plus one `allowed_*_policies` map slot per kind for +# set-time validation. When any transfer policy is registered (including `AllowAll`), the +# protocol-level asset-callback storage slots are also installed and populated with the fixed +# `invoke_send_policy` / `invoke_receive_policy` wrapper roots, so every minted asset carries +# the callback flag and future `set_send_policy` / `set_receive_policy` switches apply uniformly +# to the whole circulating supply. Pair with policy components whose procedure roots are +# registered in the relevant allowed-policies map. pub use ::miden::standards::faucets::policies::policy_manager::set_mint_policy pub use ::miden::standards::faucets::policies::policy_manager::get_mint_policy @@ -25,3 +16,5 @@ pub use ::miden::standards::faucets::policies::policy_manager::set_send_policy pub use ::miden::standards::faucets::policies::policy_manager::get_send_policy pub use ::miden::standards::faucets::policies::policy_manager::set_receive_policy pub use ::miden::standards::faucets::policies::policy_manager::get_receive_policy +pub use ::miden::standards::faucets::policies::policy_manager::invoke_send_policy +pub use ::miden::standards::faucets::policies::policy_manager::invoke_receive_policy diff --git a/crates/miden-standards/asm/standards/access/authority.masm b/crates/miden-standards/asm/standards/access/authority.masm index a284c5106d..24a67ae715 100644 --- a/crates/miden-standards/asm/standards/access/authority.masm +++ b/crates/miden-standards/asm/standards/access/authority.masm @@ -5,22 +5,36 @@ # future NFT metadata setters, ...) all consult this slot via `assert_authorized`. use miden::protocol::active_account +use miden::protocol::native_account use miden::standards::access::ownable2step use miden::standards::access::rbac # CONSTANTS # ================================================================================================= +# Value slot holding the authority configuration: [authority, is_frozen, 0, 0]. +# authority is the discriminator (0 = AuthControlled, 1 = OwnerControlled, 2 = RbacControlled). +# is_frozen is the global emergency switch (0 = not frozen, 1 = frozen). While frozen, +# `assert_authorized` panics for every gated procedure regardless of role or owner membership. pub const AUTHORITY_SLOT = word("miden::standards::access::authority") +# Map slot assigning a role to individual gated procedures. Only present under RbacControlled. +# Map entries: [PROCEDURE_ROOT] -> [role_symbol, 0, 0, 0]. +pub const AUTHORITY_PROCEDURE_ROLES_SLOT = word("miden::standards::access::authority::procedure_roles") + const AUTH_CONTROLLED = 0 const OWNER_CONTROLLED = 1 const RBAC_CONTROLLED = 2 +# Emergency-switch states for the `is_frozen` flag of `AUTHORITY_SLOT`. +const UNFROZEN = 0 +const FROZEN = 1 + # ERRORS # ================================================================================================= const ERR_UNSUPPORTED_AUTHORITY = "authority is not supported" +const ERR_AUTHORITY_FROZEN = "authority is frozen" # PUBLIC PROCEDURES # ================================================================================================= @@ -29,25 +43,35 @@ const ERR_UNSUPPORTED_AUTHORITY = "authority is not supported" #! #! - AuthControlled (0) → no-op (the account's auth component already gated the call). #! - OwnerControlled (1) → calls `ownable2step::assert_sender_is_owner`. -#! - RbacControlled (2) → loads the role symbol from the authority slot and calls -#! `rbac::assert_sender_has_role`. Requires the +#! - RbacControlled (2) → if the calling procedure has a role configured in the authority +#! procedure-roles map (keyed by the procedure root obtained via `caller`), asserts the sender +#! holds that role; otherwise falls back to `ownable2step::assert_sender_is_owner`. Requires the #! [`RoleBasedAccessControl`][crate::account::access::RoleBasedAccessControl] component to be #! installed on the account; otherwise linking the account fails. #! +#! Because the calling procedure is identified via `caller`, `assert_authorized` MUST be invoked +#! with `exec` (inlined) from the gated procedure, and the gated procedure MUST be a `call` +#! entrypoint so that `caller` resolves to the gated procedure's own root. +#! #! Inputs: [] #! Outputs: [] #! #! Panics if: #! - the authority is OwnerControlled and the sender is not the registered owner. -#! - the authority is RbacControlled and the sender does not hold the configured role. +#! - the authority is RbacControlled, a role is configured for the procedure, and the sender does +#! not hold it. +#! - the authority is RbacControlled, no role is configured for the procedure, and the sender is +#! not the owner. #! - the authority is an unknown value. #! #! Invocation: exec pub proc assert_authorized - # Note: invariant — role_symbol felt is 0 unless authority == RBAC_CONTROLLED. - # Enforced by `From for Word` in Rust. push.AUTHORITY_SLOT[0..2] exec.active_account::get_item - # => [authority, role_symbol, 0, 0] + # => [authority, is_frozen, 0, 0] + + # Emergency switch: while frozen, block every gated procedure before any role/owner dispatch. + dup.1 assertz.err=ERR_AUTHORITY_FROZEN + # => [authority, is_frozen, 0, 0] dup eq.AUTH_CONTROLLED if.true @@ -58,16 +82,19 @@ pub proc assert_authorized dup eq.OWNER_CONTROLLED if.true exec.ownable2step::assert_sender_is_owner - # => [authority, role_symbol, 0, 0] + # => [authority, is_frozen, 0, 0] + dropw # => [] else eq.RBAC_CONTROLLED if.true - # => [role_symbol, 0, 0] - exec.rbac::assert_sender_has_role - # => [0, 0] - drop drop + # => [is_frozen, 0, 0] + + drop drop drop + # => [] + + exec.assert_authorized_rbac # => [] else # Unknown authority — panic. Stack state irrelevant. @@ -76,3 +103,124 @@ pub proc assert_authorized end end end + +#! Freezes the account's authority-gated surface as an emergency switch. +#! +#! While frozen, `assert_authorized` panics for every gated procedure regardless of role or owner +#! membership. Gated directly on the Ownable2Step owner check rather than `assert_authorized`, so +#! it bypasses the frozen flag itself and the owner can always toggle it. Only meaningful on +#! accounts that install Ownable2Step (OwnerControlled / RbacControlled), on AuthControlled +#! accounts there is no owner slot and the call panics. +#! +#! Inputs: [pad(16)] +#! Outputs: [pad(16)] +#! +#! Panics if: +#! - the note sender is not the owner. +#! +#! Invocation: call +pub proc freeze + exec.ownable2step::assert_sender_is_owner + # => [pad(16)] + + push.FROZEN exec.write_frozen_flag + # => [pad(16)] +end + +#! Unfreezes the account's authority-gated surface, clearing the emergency switch. +#! +#! Gated directly on the Ownable2Step owner check rather than `assert_authorized`, so it bypasses +#! the frozen flag and the owner can always unfreeze a frozen account. Only meaningful on accounts +#! that install Ownable2Step (OwnerControlled / RbacControlled), on AuthControlled accounts there +#! is no owner slot and the call panics. +#! +#! Inputs: [pad(16)] +#! Outputs: [pad(16)] +#! +#! Panics if: +#! - the note sender is not the owner. +#! +#! Invocation: call +pub proc unfreeze + exec.ownable2step::assert_sender_is_owner + # => [pad(16)] + + push.UNFROZEN exec.write_frozen_flag + # => [pad(16)] +end + +# PRIVATE PROCEDURES +# ================================================================================================= + +#! Writes the is_frozen flag into the authority slot, preserving the authority discriminator. +#! +#! Inputs: [frozen_flag] +#! Outputs: [] +#! +#! Where: +#! - frozen_flag is the new emergency-switch state (0 = not frozen, 1 = frozen). +#! +#! Invocation: exec +proc write_frozen_flag + push.AUTHORITY_SLOT[0..2] exec.active_account::get_item + # => [authority, old_is_frozen, 0, 0, frozen_flag] + + movup.4 swap + # => [authority, frozen_flag, old_is_frozen, 0, 0] + + movup.2 drop + # => [authority, frozen_flag, 0, 0] + + push.AUTHORITY_SLOT[0..2] exec.native_account::set_item + # => [OLD_AUTHORITY_WORD] + + dropw + # => [] +end + +#! Asserts the sender is authorized under the RbacControlled authority. +#! +#! Resolves the calling procedure's root via `caller` and looks up its assigned role in the +#! procedure-roles map. If a role is configured, asserts the sender holds it; otherwise falls back +#! to the Ownable2Step owner check. +#! +#! Inputs: [] +#! Outputs: [] +#! +#! Panics if: +#! - a role is configured for the procedure and the sender does not hold it. +#! - no role is configured for the procedure and the sender is not the owner. +#! +#! Invocation: exec +proc assert_authorized_rbac + # Resolve the calling procedure's root and look up its assigned role in the map. + padw caller + # => [CALLER_ROOT] + + push.AUTHORITY_PROCEDURE_ROLES_SLOT[0..2] + # => [slot_suffix, slot_prefix, CALLER_ROOT] + + exec.active_account::get_map_item + # => [mapped_role, 0, 0, 0] + + movdn.3 drop drop drop + # => [mapped_role] + + dup eq.0 + # => [has_no_mapped_role, mapped_role] + + if.true + # No role configured for this procedure, require the owner. + drop + # => [] + + exec.ownable2step::assert_sender_is_owner + # => [] + else + # Assert the sender holds the configured role. + # => [mapped_role] + + exec.rbac::assert_sender_has_role + # => [] + end +end diff --git a/crates/miden-standards/asm/standards/access/ownable2step.masm b/crates/miden-standards/asm/standards/access/ownable2step.masm index 01ae9f06d1..d17c39e94f 100644 --- a/crates/miden-standards/asm/standards/access/ownable2step.masm +++ b/crates/miden-standards/asm/standards/access/ownable2step.masm @@ -147,7 +147,7 @@ end #! - is_sender_owner is 1 if the note sender is the current owner, otherwise 0. #! #! Invocation: exec -pub proc is_sender_owner_internal +pub proc is_sender_owner exec.active_note::get_sender # => [sender_suffix, sender_prefix] @@ -164,8 +164,8 @@ end #! - the note sender is not the owner. #! #! Invocation: exec -pub proc assert_sender_is_owner_internal - exec.is_sender_owner_internal +pub proc assert_sender_is_owner + exec.is_sender_owner # => [is_sender_owner] assert.err=ERR_SENDER_NOT_OWNER @@ -175,20 +175,6 @@ end # PUBLIC INTERFACE # ================================================================================================ -#! Checks if the note sender is the owner and panics if not. -#! -#! Inputs: [pad(16)] -#! Outputs: [pad(16)] -#! -#! Panics if: -#! - the note sender is not the owner. -#! -#! Invocation: call -pub proc assert_sender_is_owner - exec.assert_sender_is_owner_internal - # => [pad(16)] -end - #! Returns the owner account ID. #! #! Inputs: [pad(16)] @@ -245,7 +231,7 @@ end #! #! Invocation: call pub proc transfer_ownership - exec.assert_sender_is_owner_internal + exec.assert_sender_is_owner # => [new_owner_suffix, new_owner_prefix, pad(14)] # Detect explicit cancel via the zero address. The zero address is not a valid @@ -253,27 +239,17 @@ pub proc transfer_ownership dup.1 eq.0 dup.1 eq.0 and # => [is_zero_address, new_owner_suffix, new_owner_prefix, pad(14)] - if.true - # Cancel via zero address: drop the (0, 0) inputs and write [owner, 0, 0]. - drop drop - # => [pad(14)] - - exec.get_owner_internal - # => [owner_suffix, owner_prefix, pad(14)] - - push.0.0 movup.3 movup.3 - # => [owner_suffix, owner_prefix, 0, 0, pad(14)] - else - # Non-zero new owner: validate and store as the nominated owner. + if.false + # Non-zero new owner: validate before storing as the nominated owner. dup.1 dup.1 exec.account_id::validate - # => [new_owner_suffix, new_owner_prefix, pad(14)] - - exec.get_owner_internal - # => [owner_suffix, owner_prefix, new_owner_suffix, new_owner_prefix, pad(14)] end + # => [new_owner_suffix, new_owner_prefix, pad(14)] + + exec.get_owner_internal + # => [owner_suffix, owner_prefix, new_owner_suffix, new_owner_prefix, pad(14)] exec.save_ownership_info - # => [pad(14)] + # => [pad(16)] end #! Accepts a nominated ownership transfer. The nominated owner becomes the new owner @@ -342,7 +318,7 @@ end #! #! Invocation: call pub proc renounce_ownership - exec.assert_sender_is_owner_internal + exec.assert_sender_is_owner # => [pad(16)] exec.get_nominated_owner_internal diff --git a/crates/miden-standards/asm/standards/access/pausable/manager.masm b/crates/miden-standards/asm/standards/access/pausable/manager.masm index 7a6bb7a466..d8cbfca894 100644 --- a/crates/miden-standards/asm/standards/access/pausable/manager.masm +++ b/crates/miden-standards/asm/standards/access/pausable/manager.masm @@ -4,8 +4,8 @@ # the account-wide `Authority` component via `exec.authority::assert_authorized` — the same # pattern that `TokenPolicyManager::set_*_policy` uses. This makes a single PausableManager # work uniformly with `Authority::OwnerControlled` (Ownable2Step owner), `Authority::AuthControlled` -# (account auth scheme), and `Authority::RbacControlled { role }` (a single role gates both -# pause and unpause). +# (account auth scheme), and `Authority::RbacControlled` (per-procedure roles: `pause` and +# `unpause` can be gated by distinct roles, e.g. PAUSER / UNPAUSER, or share one role). # # Companion components required on the account: # - `Authority` — provides the auth dispatch the body calls into. diff --git a/crates/miden-standards/asm/standards/access/pausable/mod.masm b/crates/miden-standards/asm/standards/access/pausable/mod.masm index 0087e5cc74..c1a1e91ae9 100644 --- a/crates/miden-standards/asm/standards/access/pausable/mod.masm +++ b/crates/miden-standards/asm/standards/access/pausable/mod.masm @@ -102,31 +102,36 @@ end #! Requires the contract to be unpaused (storage word is the zero word). #! -#! Reads [`IS_PAUSED_SLOT`] on the active account and applies [`word::eqz`]. If the stored word -#! is non-zero (paused), panics with [`ERR_PAUSABLE_IS_PAUSED`]. +#! If [`IS_PAUSED_SLOT`] is not installed on the active account, the check is skipped and the +#! account is treated as "unpaused" — so the assertion is a no-op for accounts that did not +#! install the Pausable component. #! -#! If the `IS_PAUSED_SLOT` is not installed on the account, `active_account:: get_item` -#! returns the zero word, which is treated as "unpaused" — so the assertion is a no-op -#! for accounts that did not install the Pausable component. This is the canonical way for -#! cross-cutting consumers (TokenPolicyManager dispatch, asset callbacks, metadata setters) to -#! gate their logic on pause state without making Pausable a hard dependency. +#! Otherwise reads [`IS_PAUSED_SLOT`] and applies [`word::eqz`]. If the stored word is non-zero +#! (paused), panics with [`ERR_PAUSABLE_IS_PAUSED`]. #! #! Inputs: [] #! Outputs: [] #! #! Panics if: -#! - the paused-state word is not the zero word. +#! - the slot is installed and the paused-state word is not the zero word. #! #! Invocation: exec pub proc assert_not_paused push.IS_PAUSED_SLOT[0..2] - exec.active_account::get_item - # => [is_paused, 0, 0, 0] + exec.active_account::has_storage_slot + # => [has_slot] + + if.true + push.IS_PAUSED_SLOT[0..2] + exec.active_account::get_item + # => [is_paused, 0, 0, 0] - exec.word::eqz - # => [is_unpaused] + exec.word::eqz + # => [is_unpaused] - assert.err=ERR_PAUSABLE_IS_PAUSED + assert.err=ERR_PAUSABLE_IS_PAUSED + # => [] + end # => [] end diff --git a/crates/miden-standards/asm/standards/access/rbac.masm b/crates/miden-standards/asm/standards/access/rbac.masm index d8507b96ad..92ea8c016e 100644 --- a/crates/miden-standards/asm/standards/access/rbac.masm +++ b/crates/miden-standards/asm/standards/access/rbac.masm @@ -58,8 +58,8 @@ const ERR_MEMBER_COUNT_OVERFLOW = "role member count overflowed u32" #! Checks that the note sender holds the given role. #! -#! Inputs: [role_symbol, pad(15)] -#! Outputs: [pad(16)] +#! Inputs: [role_symbol] +#! Outputs: [] #! #! Where: #! - role_symbol is the encoded role symbol. @@ -68,16 +68,16 @@ const ERR_MEMBER_COUNT_OVERFLOW = "role member count overflowed u32" #! - role_symbol is zero. #! - the note sender does not hold the given role. #! -#! Invocation: call +#! Invocation: exec pub proc assert_sender_has_role - exec.assert_role_symbol_non_zero - # => [role_symbol, pad(15)] + dup exec.assert_role_symbol_non_zero + # => [role_symbol] exec.is_sender_in_role - # => [has_role, pad(15)] + # => [has_role] assert.err=ERR_SENDER_LACKS_ROLE - # => [pad(16)] + # => [] end #! Returns whether an account holds a role. @@ -161,8 +161,8 @@ end #! #! Invocation: call pub proc set_role_admin - exec.ownable2step::assert_sender_is_owner_internal - exec.assert_role_symbol_non_zero + exec.ownable2step::assert_sender_is_owner + dup exec.assert_role_symbol_non_zero # => [role_symbol, new_admin_role_symbol, pad(14)] dup exec.get_role_member_count_internal @@ -195,7 +195,7 @@ end #! #! Invocation: call pub proc grant_role - exec.assert_role_symbol_non_zero + dup exec.assert_role_symbol_non_zero # => [role_symbol, account_suffix, account_prefix, pad(13)] dup exec.assert_sender_is_owner_or_role_admin @@ -226,7 +226,7 @@ end #! #! Invocation: call pub proc revoke_role - exec.assert_role_symbol_non_zero + dup exec.assert_role_symbol_non_zero # => [role_symbol, account_suffix, account_prefix, pad(13)] dup exec.assert_sender_is_owner_or_role_admin @@ -251,7 +251,7 @@ end #! #! Invocation: call pub proc renounce_role - exec.assert_role_symbol_non_zero + dup exec.assert_role_symbol_non_zero # => [role_symbol, pad(15)] exec.active_note::get_sender @@ -270,16 +270,15 @@ end #! Asserts that a role symbol is non-zero. #! #! Inputs: [role_symbol] -#! Outputs: [role_symbol] +#! Outputs: [] #! #! Panics if: #! - role_symbol is zero. #! #! Invocation: exec proc assert_role_symbol_non_zero - dup eq.0 - assertz.err=ERR_ROLE_SYMBOL_ZERO - # => [role_symbol] + eq.0 assertz.err=ERR_ROLE_SYMBOL_ZERO + # => [] end #! Returns the config for a role. @@ -409,7 +408,7 @@ end #! #! Invocation: exec proc assert_sender_is_owner_or_role_admin - exec.ownable2step::is_sender_owner_internal + exec.ownable2step::is_sender_owner # => [is_owner, role_symbol] if.true @@ -435,8 +434,8 @@ end #! Internal helper used by `grant_role` to assign a role to an account. #! -#! Validates the account ID, then no-ops if the account is already a member. -#! Otherwise, sets the membership flag and increments the role's member count. +#! Validates the account ID, sets the membership flag, and increments the role's +#! member count only when the account did not already hold the role. #! #! Inputs: [role_symbol, account_suffix, account_prefix] #! Outputs: [] @@ -446,41 +445,37 @@ proc grant_role_internal dup.2 dup.2 exec.account_id::validate # => [role_symbol, account_suffix, account_prefix] - dup.2 dup.2 dup.2 exec.has_role_internal - # => [has_role, role_symbol, account_suffix, account_prefix] - - if.true - # Already a member — no-op. - drop drop drop - # => [] - else - # Save a copy of role_symbol at the bottom of the stack so it survives the - # membership map write and is available for the role-config update. - dup movdn.3 - # => [role_symbol, account_suffix, account_prefix, role_symbol] + # Save a copy of role_symbol at the bottom of the stack so it survives the + # membership map write and is available for the role-config update. + dup movdn.3 + # => [role_symbol, account_suffix, account_prefix, role_symbol] - # Write membership[role, account] = [1, 0, 0, 0]. - push.SET_MEMBERSHIP - # => [1, 0, 0, 0, role_symbol, account_suffix, account_prefix, role_symbol] + push.0 + # => [0, role_symbol, account_suffix, account_prefix, role_symbol] - # Bring role/suffix/prefix back in order. - movup.6 movup.6 movup.6 - # => [role_symbol, account_suffix, account_prefix, 1, 0, 0, 0, role_symbol] + push.SET_MEMBERSHIP + # => [1, 0, 0, 0, 0, role_symbol, account_suffix, account_prefix, role_symbol] - push.0 - # => [0, role_symbol, account_suffix, account_prefix, 1, 0, 0, 0, role_symbol] + swapw + # => [0, role_symbol, account_suffix, account_prefix, 1, 0, 0, 0, role_symbol] - push.ROLE_MEMBERSHIP_SLOT[0..2] - # => [slot_suffix, slot_prefix, 0, role_symbol, account_suffix, account_prefix, - # 1, 0, 0, 0, role_symbol] + push.ROLE_MEMBERSHIP_SLOT[0..2] + # => [slot_suffix, slot_prefix, 0, role_symbol, account_suffix, account_prefix, + # 1, 0, 0, 0, role_symbol] - exec.native_account::set_map_item - # => [OLD_MEMBERSHIP_WORD, role_symbol] + exec.native_account::set_map_item + # => [OLD_MEMBERSHIP_WORD, role_symbol] - dropw - # => [role_symbol] + # Reduce OLD_MEMBERSHIP_WORD to its first element, the previous membership flag. + movdn.3 drop drop drop + # => [old_is_member, role_symbol] - # Increment the role's member count. + if.true + # Role was already held — the write above was idempotent, count unchanged. + drop + # => [] + else + # New grant — increment the role's member count. dup exec.get_role_config # => [member_count, admin_role_symbol, role_symbol] @@ -498,8 +493,8 @@ end #! Internal helper used by `revoke_role` and `renounce_role` to remove a role from #! an account. #! -#! Validates the account ID, asserts that the account currently holds the role, -#! clears the membership flag, and decrements the role's member count. +#! Validates the account ID, clears the membership flag, asserts that the account +#! previously held the role, and decrements the role's member count. #! #! Inputs: [role_symbol, account_suffix, account_prefix] #! Outputs: [] @@ -513,27 +508,18 @@ proc revoke_role_internal dup.2 dup.2 exec.account_id::validate # => [role_symbol, account_suffix, account_prefix] - # Assert the account currently holds the role, preserving the inputs. - dup.2 dup.2 dup.2 exec.has_role_internal - # => [has_role, role_symbol, account_suffix, account_prefix] - - assert.err=ERR_ACCOUNT_NOT_IN_ROLE - # => [role_symbol, account_suffix, account_prefix] - # Save a copy of role_symbol at the bottom of the stack so it survives the # membership map write and is available for the role-config update. dup movdn.3 # => [role_symbol, account_suffix, account_prefix, role_symbol] - # Clear membership[role, account] = [0, 0, 0, 0]. - push.CLEAR_MEMBERSHIP - # => [0, 0, 0, 0, role_symbol, account_suffix, account_prefix, role_symbol] + push.0 + # => [0, role_symbol, account_suffix, account_prefix, role_symbol] - # Bring role/suffix/prefix back above the value word, in order. - movup.6 movup.6 movup.6 - # => [role_symbol, account_suffix, account_prefix, 0, 0, 0, 0, role_symbol] + push.CLEAR_MEMBERSHIP + # => [0, 0, 0, 0, 0, role_symbol, account_suffix, account_prefix, role_symbol] - push.0 + swapw # => [0, role_symbol, account_suffix, account_prefix, 0, 0, 0, 0, role_symbol] push.ROLE_MEMBERSHIP_SLOT[0..2] @@ -543,7 +529,10 @@ proc revoke_role_internal exec.native_account::set_map_item # => [OLD_MEMBERSHIP_WORD, role_symbol] - dropw + movdn.3 drop drop drop + # => [old_is_member, role_symbol] + + assert.err=ERR_ACCOUNT_NOT_IN_ROLE # => [role_symbol] # Decrement the role's member count. diff --git a/crates/miden-standards/asm/standards/auth/note_script_allowlist.masm b/crates/miden-standards/asm/standards/auth/note_script_allowlist.masm index 32371667e1..3164c43324 100644 --- a/crates/miden-standards/asm/standards/auth/note_script_allowlist.masm +++ b/crates/miden-standards/asm/standards/auth/note_script_allowlist.masm @@ -1,11 +1,10 @@ -# Reusable note-script allowlist primitives. +# Reusable note-script allowlist primitive. # -# Provides two checks used to restrict what an account can do during a transaction: -# - `assert_no_tx_script` rejects transactions that executed a tx script. +# Provides one check used to restrict what an account can do during a transaction: # - `assert_all_input_notes_allowed` rejects transactions that consume an input note whose script # root is not present in a storage map at the given slot id. # -# These are designed to be composed into auth components. The caller owns the storage map and +# This is designed to be composed into auth components. The caller owns the storage map and # passes the slot id (suffix, prefix) so the same logic can back multiple components, each with # their own allowlist. @@ -17,29 +16,11 @@ use miden::core::word # ERRORS # ================================================================================================= -const ERR_NOTE_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED="a transaction script cannot be executed against an account guarded by a note script allowlist" const ERR_NOTE_SCRIPT_ALLOWLIST_NOTE_NOT_ALLOWED="input note script root is not in the note script allowlist" # PROCEDURES # ================================================================================================= -#! Asserts that no transaction script was executed in the current transaction. -#! -#! Inputs: [] -#! Outputs: [] -#! -#! Invocation: exec -pub proc assert_no_tx_script - exec.tx::get_tx_script_root - # => [TX_SCRIPT_ROOT] - - exec.word::eqz - # => [has_no_tx_script] - - assert.err=ERR_NOTE_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED - # => [] -end - #! Asserts that every input note consumed by this transaction has a script root present in the #! storage map at the given slot id. #! diff --git a/crates/miden-standards/asm/standards/auth/tx_script_allowlist.masm b/crates/miden-standards/asm/standards/auth/tx_script_allowlist.masm new file mode 100644 index 0000000000..e829d54ac0 --- /dev/null +++ b/crates/miden-standards/asm/standards/auth/tx_script_allowlist.masm @@ -0,0 +1,66 @@ +# Reusable tx-script allowlist primitive. +# +# Provides a single check used to restrict which transaction scripts an account will execute: +# - `assert_tx_script_allowed` accepts transactions that executed no tx script, and otherwise +# rejects transactions whose tx script root is not present in a storage map at the given slot id. +# +# This is designed to be composed into auth components. The caller owns the storage map and passes +# the slot id (suffix, prefix) so the same logic can back multiple components, each with their own +# allowlist. + +use miden::protocol::active_account +use miden::protocol::tx +use miden::core::word + +# ERRORS +# ================================================================================================= + +const ERR_TX_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED="transaction script root is not in the tx script allowlist" + +# PROCEDURES +# ================================================================================================= + +#! Asserts that the transaction script root is present in the storage map at the given slot id. +#! +#! A transaction that executed no tx script (empty root) is always allowed. Any other tx script must +#! have its root present in the allowlist. +#! +#! Map convention: keys are tx script roots (defined as Word), and any non-empty value marks a root +#! as allowed. Empty values (the default for absent keys) cause this procedure to fail. +#! +#! Inputs: [allowlist_slot_id_suffix, allowlist_slot_id_prefix] +#! Outputs: [] +#! +#! Where: +#! - allowlist_slot_id_{suffix, prefix} are the suffix and prefix felts of the slot identifier +#! pointing at the allowlist storage map. +#! +#! Invocation: exec +pub proc assert_tx_script_allowed + # => [slot_id_suffix, slot_id_prefix] + + exec.tx::get_tx_script_root + # => [TX_SCRIPT_ROOT, slot_id_suffix, slot_id_prefix] + + exec.word::testz + # => [no_tx_script, TX_SCRIPT_ROOT, slot_id_suffix, slot_id_prefix] + + if.true + # No tx script was executed, which is always allowed. + dropw drop drop + # => [] + else + movup.5 movup.5 + # => [slot_id_suffix, slot_id_prefix, TX_SCRIPT_ROOT] + + exec.active_account::get_map_item + # => [VALUE] + + exec.word::eqz not + # => [is_allowed] + + assert.err=ERR_TX_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED + # => [] + end + # => [] +end diff --git a/crates/miden-standards/asm/standards/faucets/fungible.masm b/crates/miden-standards/asm/standards/faucets/fungible.masm index f147ef5fff..20e0e7ea3d 100644 --- a/crates/miden-standards/asm/standards/faucets/fungible.masm +++ b/crates/miden-standards/asm/standards/faucets/fungible.masm @@ -16,7 +16,7 @@ use miden::protocol::asset use miden::protocol::asset::FUNGIBLE_ASSET_MAX_AMOUNT use miden::standards::access::authority use miden::standards::faucets::policies::policy_manager -use miden::standards::faucets::MUTABILITY_CONFIG_SLOT +use miden::standards::faucets use miden::standards::access::pausable # ================================================================================================= @@ -73,8 +73,7 @@ end #! #! Invocation: exec proc is_max_supply_mutable_internal - push.MUTABILITY_CONFIG_SLOT[0..2] - exec.active_account::get_item + exec.faucets::get_mutability_config_word # => [is_description_mutable, is_logo_uri_mutable, is_external_link_mutable, is_max_supply_mutable] drop drop drop # => [is_max_supply_mutable] @@ -174,6 +173,7 @@ end #! Panics if: #! - the max supply mutability flag is not 1. #! - the caller is not authorized per the installed [`Authority`]. +#! - the new max supply exceeds the maximum representable fungible asset amount. #! - the new max supply is less than the current token supply. #! #! Invocation: call @@ -190,20 +190,26 @@ pub proc set_max_supply exec.pausable::assert_not_paused # => [new_max_supply, pad(15)] - # 3. Read current token config word + # 3. Validate that the new max supply does not exceed the maximum representable fungible + # asset amount, so the stored cap stays consistent with the bound enforced at mint time. + dup lte.FUNGIBLE_ASSET_MAX_AMOUNT + assert.err=ERR_FUNGIBLE_ASSET_MAX_SUPPLY_EXCEEDS_FUNGIBLE_ASSET_MAX_AMOUNT + # => [new_max_supply, pad(15)] + + # 4. Read current token config word exec.get_token_config_internal # => [token_supply, max_supply, decimals, token_symbol, new_max_supply, pad(15)] - # 4. Validate: token_supply <= new_max_supply + # 5. Validate: token_supply <= new_max_supply dup dup.5 lte assert.err=ERR_NEW_MAX_SUPPLY_BELOW_TOKEN_SUPPLY # => [token_supply, max_supply, decimals, token_symbol, new_max_supply, pad(15)] - # 5. Replace max_supply (word[1]) with new_max_supply + # 6. Replace max_supply (word[1]) with new_max_supply movup.4 swap movup.2 drop # => [token_supply, new_max_supply, decimals, token_symbol, pad(15)] - # 6. Write updated token config word back to storage + # 7. Write updated token config word back to storage push.TOKEN_CONFIG_SLOT[0..2] exec.native_account::set_item dropw @@ -262,12 +268,12 @@ pub proc mint_and_send # --------------------------------------------------------------------------------------------- push.TOKEN_CONFIG_SLOT[0..2] exec.active_account::get_item - # => [token_supply, max_supply, decimals, token_symbol, amount, tag, note_type, RECIPIENT] + # => [token_supply, max_supply, decimals, token_symbol, new_amount, new_tag, new_note_type, NEW_RECIPIENT] # store a copy of the current slot content for the token_supply update later loc_storew_le.TOKEN_CONFIG_SLOT_LOCAL swap movup.2 drop movup.2 drop - # => [max_supply, token_supply, amount, tag, note_type, RECIPIENT] + # => [max_supply, token_supply, new_amount, new_tag, new_note_type, NEW_RECIPIENT] # Assert that minting does not violate any supply constraints. # @@ -286,55 +292,55 @@ pub proc mint_and_send # --------------------------------------------------------------------------------------------- dup.1 dup.1 - # => [max_supply, token_supply, max_supply, token_supply, amount, tag, note_type, RECIPIENT] + # => [max_supply, token_supply, max_supply, token_supply, new_amount, new_tag, new_note_type, NEW_RECIPIENT] # assert that token_supply <= max_supply lte assert.err=ERR_FUNGIBLE_ASSET_TOKEN_SUPPLY_EXCEEDS_MAX_SUPPLY - # => [max_supply, token_supply, amount, tag, note_type, RECIPIENT] + # => [max_supply, token_supply, new_amount, new_tag, new_note_type, NEW_RECIPIENT] # assert max_supply <= FUNGIBLE_ASSET_MAX_AMOUNT dup lte.FUNGIBLE_ASSET_MAX_AMOUNT assert.err=ERR_FUNGIBLE_ASSET_MAX_SUPPLY_EXCEEDS_FUNGIBLE_ASSET_MAX_AMOUNT - # => [max_supply, token_supply, amount, tag, note_type, RECIPIENT] + # => [max_supply, token_supply, new_amount, new_tag, new_note_type, NEW_RECIPIENT] dup.2 swap dup.2 - # => [token_supply, max_supply, amount, token_supply, amount, tag, note_type, RECIPIENT] + # => [token_supply, max_supply, new_amount, token_supply, new_amount, new_tag, new_note_type, NEW_RECIPIENT] # compute maximum amount that can be minted, max_mint_amount = max_supply - token_supply sub - # => [max_mint_amount, amount, token_supply, amount, tag, note_type, RECIPIENT] + # => [max_mint_amount, new_amount, token_supply, new_amount, new_tag, new_note_type, NEW_RECIPIENT] # assert amount <= max_mint_amount lte assert.err=ERR_FUNGIBLE_ASSET_DISTRIBUTE_AMOUNT_EXCEEDS_MAX_SUPPLY - # => [token_supply, amount, tag, note_type, RECIPIENT] + # => [token_supply, new_amount, new_tag, new_note_type, NEW_RECIPIENT] # Compute the new token_supply and update in storage. # --------------------------------------------------------------------------------------------- dup.1 add - # => [new_token_supply, amount, tag, note_type, RECIPIENT] + # => [new_token_supply, new_amount, new_tag, new_note_type, NEW_RECIPIENT] padw loc_loadw_le.TOKEN_CONFIG_SLOT_LOCAL - # => [[token_supply, max_supply, decimals, token_symbol], new_token_supply, amount, tag, note_type, RECIPIENT] + # => [[token_supply, max_supply, decimals, token_symbol], new_token_supply, new_amount, new_tag, new_note_type, NEW_RECIPIENT] drop movup.3 - # => [[new_token_supply, max_supply, decimals, token_symbol], amount, tag, note_type, RECIPIENT] + # => [[new_token_supply, max_supply, decimals, token_symbol], new_amount, new_tag, new_note_type, NEW_RECIPIENT] # update the token config slot with the new supply push.TOKEN_CONFIG_SLOT[0..2] exec.native_account::set_item dropw - # => [amount, tag, note_type, RECIPIENT] + # => [new_amount, new_tag, new_note_type, NEW_RECIPIENT] # Create a new note. # --------------------------------------------------------------------------------------------- movdn.6 exec.output_note::create - # => [note_idx, amount] + # => [note_idx, new_amount] # Mint the asset. # --------------------------------------------------------------------------------------------- dup movup.2 - # => [amount, note_idx, note_idx] + # => [new_amount, note_idx, note_idx] # derive the asset to mint for the active faucet from the (possibly policy-adjusted) amount exec.faucet::create_fungible_asset diff --git a/crates/miden-standards/asm/standards/faucets/mod.masm b/crates/miden-standards/asm/standards/faucets/mod.masm index ddf14fdf38..de18582a42 100644 --- a/crates/miden-standards/asm/standards/faucets/mod.masm +++ b/crates/miden-standards/asm/standards/faucets/mod.masm @@ -231,7 +231,7 @@ end #! Updates the description (7 Words) if the description mutability flag is 1 #! and the caller satisfies the account-wide [`Authority`] configuration. #! -#! The caller passes the Poseidon hash of the new description on the stack and provides +#! The caller passes the Poseidon2 hash of the new description on the stack and provides #! the actual 7 Words in the advice map under that hash. The hash is verified against #! the preimage during loading. #! @@ -311,7 +311,7 @@ end #! Updates the logo URI (7 Words) if the logo URI mutability flag is 1 #! and the caller satisfies the account-wide [`Authority`] configuration. #! -#! The caller passes the Poseidon hash of the new logo URI on the stack and provides +#! The caller passes the Poseidon2 hash of the new logo URI on the stack and provides #! the actual 7 Words in the advice map under that hash. The hash is verified against #! the preimage during loading. #! @@ -390,7 +390,7 @@ end #! Updates the external link (7 Words) if the external link mutability flag is 1 #! and the caller satisfies the account-wide [`Authority`] configuration. #! -#! The caller passes the Poseidon hash of the new external link on the stack and provides +#! The caller passes the Poseidon2 hash of the new external link on the stack and provides #! the actual 7 Words in the advice map under that hash. The hash is verified against #! the preimage during loading. #! diff --git a/crates/miden-standards/asm/standards/faucets/policies/burn/min_burn_amount.masm b/crates/miden-standards/asm/standards/faucets/policies/burn/min_burn_amount.masm new file mode 100644 index 0000000000..86387bfb17 --- /dev/null +++ b/crates/miden-standards/asm/standards/faucets/policies/burn/min_burn_amount.masm @@ -0,0 +1,97 @@ +# miden::standards::faucets::policies::burn::min_burn_amount +# +# `min_burn_amount` burn policy: rejects burns whose amount is below a configurable minimum. +# +# The minimum burn amount lives in a single value slot owned by this component and is updated +# through the authority-gated `set_min_burn_amount` setter, which authorizes the caller via the +# account-wide `Authority` component (the same gate used by the policy-switching setters). + +use miden::protocol::active_account +use miden::protocol::native_account +use miden::protocol::asset +use miden::standards::access::authority + +# CONSTANTS — slot names +# ================================================================================================= + +# Minimum burn amount slot. Layout: [min_burn_amount, 0, 0, 0] +pub const MIN_BURN_AMOUNT_SLOT = word("miden::standards::faucets::policies::burn::min_burn_amount::min_burn_amount") + +# CONSTANTS — errors +# ================================================================================================= + +const ERR_BURN_AMOUNT_BELOW_MIN_BURN_AMOUNT = "amount to be burned must exceed specified minimum burn amount" + +# POLICY PROCEDURES +# ================================================================================================= + +#! Burn policy that rejects burns whose amount is below the configured minimum burn amount. +#! +#! WARNING: The policy assumes the asset is fungible. +#! +#! Inputs: [ASSET_KEY, ASSET_VALUE] +#! Outputs: [] +#! +#! Where: +#! - ASSET_VALUE is the fungible asset value word `[amount, 0, 0, 0]`. +#! +#! Panics if: +#! - the burn amount is below the configured minimum burn amount. +#! +#! Invocation: dynexec +pub proc check_policy + dropw + # => [ASSET_VALUE] + + exec.asset::fungible_value_into_amount + # => [amount] + + push.MIN_BURN_AMOUNT_SLOT[0..2] exec.active_account::get_item + # => [min_burn_amount, 0, 0, 0, amount] + + movdn.4 drop drop drop + # => [amount, min_burn_amount] + + # burn amount must meet or exceed the configured minimum + lte assert.err=ERR_BURN_AMOUNT_BELOW_MIN_BURN_AMOUNT + # => [] +end + +# CONFIGURATION PROCEDURES +# ================================================================================================= + +#! Returns the configured minimum burn amount. +#! +#! Inputs: [pad(16)] +#! Outputs: [min_burn_amount, pad(15)] +#! +#! Invocation: call +pub proc get_min_burn_amount + push.MIN_BURN_AMOUNT_SLOT[0..2] exec.active_account::get_item + # => [min_burn_amount, 0, 0, 0, pad(12)] + + # discard the three zero felts of the value word, returning only the amount + movdn.3 drop drop drop + # => [min_burn_amount, pad(15)] +end + +#! Sets the minimum burn amount. +#! +#! Inputs: [new_min_burn_amount, pad(15)] +#! Outputs: [pad(16)] +#! +#! Panics if: +#! - the caller is not authorized per the installed `Authority`. +#! +#! Invocation: call +pub proc set_min_burn_amount + exec.authority::assert_authorized + # => [new_min_burn_amount, pad(15)] + + # build the value word [new_min_burn_amount, 0, 0, 0] + push.0.0.0 movup.3 + # => [new_min_burn_amount, 0, 0, 0, pad(12)] + + push.MIN_BURN_AMOUNT_SLOT[0..2] exec.native_account::set_item dropw + # => [pad(16)] +end diff --git a/crates/miden-standards/asm/standards/faucets/policies/burn/owner_controlled/owner_only.masm b/crates/miden-standards/asm/standards/faucets/policies/burn/owner_controlled/owner_only.masm index 06c2e78549..2a0eebe3e7 100644 --- a/crates/miden-standards/asm/standards/faucets/policies/burn/owner_controlled/owner_only.masm +++ b/crates/miden-standards/asm/standards/faucets/policies/burn/owner_controlled/owner_only.masm @@ -1,3 +1,12 @@ +# miden::standards::faucets::policies::burn::owner_controlled::owner_only +# +# Owner-only burn policy: gates burning on the Ownable2Step owner via +# `ownable2step::assert_sender_is_owner`, rejecting the burn when the note sender is not the +# current owner. +# +# Companion components required on the faucet: +# - `Ownable2Step` — provides the owner storage slot the auth check reads. + use miden::standards::access::ownable2step # POLICY PROCEDURES diff --git a/crates/miden-standards/asm/standards/faucets/policies/mint/owner_controlled/owner_only.masm b/crates/miden-standards/asm/standards/faucets/policies/mint/owner_controlled/owner_only.masm index 904aa1b741..f226a395a0 100644 --- a/crates/miden-standards/asm/standards/faucets/policies/mint/owner_controlled/owner_only.masm +++ b/crates/miden-standards/asm/standards/faucets/policies/mint/owner_controlled/owner_only.masm @@ -1,3 +1,12 @@ +# miden::standards::faucets::policies::mint::owner_controlled::owner_only +# +# Owner-only mint policy: gates minting on the Ownable2Step owner via +# `ownable2step::assert_sender_is_owner`, rejecting the mint when the note sender is not the +# current owner. +# +# Companion components required on the faucet: +# - `Ownable2Step` — provides the owner storage slot the auth check reads. + use miden::standards::access::ownable2step # POLICY PROCEDURES diff --git a/crates/miden-standards/asm/standards/faucets/policies/policy_manager.masm b/crates/miden-standards/asm/standards/faucets/policies/policy_manager.masm index d71e3257cd..974c78d594 100644 --- a/crates/miden-standards/asm/standards/faucets/policies/policy_manager.masm +++ b/crates/miden-standards/asm/standards/faucets/policies/policy_manager.masm @@ -10,12 +10,6 @@ use miden::standards::access::pausable # separately) via `exec.authority::assert_authorized`, which dispatches between # `AuthControlled`, `OwnerControlled`, and `RbacControlled` modes. The manager itself stores no # authority discriminator. -# -# Mint and burn policies are dispatched internally via `dynexec` from `execute_mint_policy` / -# `execute_burn_policy`. Send and receive policies are flattened: the active policy root is -# written directly into the protocol-reserved callback slots -# (`miden::protocol::faucet::callback::on_before_asset_added_to_account` and -# `..._to_note`) and the kernel invokes them via `call`. # CONSTANTS # ================================================================================================ @@ -26,13 +20,11 @@ const ACTIVE_MINT_POLICY_PROC_ROOT_SLOT=word("miden::standards::faucets::policie # Active burn policy root slot. Layout: [PROC_ROOT] const ACTIVE_BURN_POLICY_PROC_ROOT_SLOT=word("miden::standards::faucets::policies::policy_manager::active_burn_policy_proc_root") -# Protocol-reserved callback slot holding the active receive policy proc root. Written by -# `set_receive_policy`, read by the kernel when dispatching `on_before_asset_added_to_account`. -const ON_BEFORE_ASSET_ADDED_TO_ACCOUNT_SLOT=word("miden::protocol::faucet::callback::on_before_asset_added_to_account") +# Active send policy root slot. Layout: [PROC_ROOT] +const ACTIVE_SEND_POLICY_PROC_ROOT_SLOT=word("miden::standards::faucets::policies::policy_manager::active_send_policy_proc_root") -# Protocol-reserved callback slot holding the active send policy proc root. Written by -# `set_send_policy`, read by the kernel when dispatching `on_before_asset_added_to_note`. -const ON_BEFORE_ASSET_ADDED_TO_NOTE_SLOT=word("miden::protocol::faucet::callback::on_before_asset_added_to_note") +# Active receive policy root slot. Layout: [PROC_ROOT] +const ACTIVE_RECEIVE_POLICY_PROC_ROOT_SLOT=word("miden::standards::faucets::policies::policy_manager::active_receive_policy_proc_root") # Allowlist map slot for mint policy roots. Map entries: [PROC_ROOT] -> [1, 0, 0, 0] const ALLOWED_MINT_POLICY_PROC_ROOTS_SLOT=word("miden::standards::faucets::policies::policy_manager::allowed_mint_policy_proc_roots") @@ -55,10 +47,12 @@ const POLICY_PROC_ROOT_PTR=0 const ERR_MINT_POLICY_ROOT_IS_ZERO="mint policy root is zero" const ERR_MINT_POLICY_ROOT_NOT_IN_ACCOUNT="mint policy root is not a procedure of this account" const ERR_MINT_POLICY_ROOT_NOT_ALLOWED="mint policy root is not allowed" +const ERR_ACTIVE_MINT_POLICY_NOT_SET="active mint policy is not set" const ERR_BURN_POLICY_ROOT_IS_ZERO="burn policy root is zero" const ERR_BURN_POLICY_ROOT_NOT_IN_ACCOUNT="burn policy root is not a procedure of this account" const ERR_BURN_POLICY_ROOT_NOT_ALLOWED="burn policy root is not allowed" +const ERR_ACTIVE_BURN_POLICY_NOT_SET="active burn policy is not set" const ERR_SEND_POLICY_ROOT_IS_ZERO="send policy root is zero" const ERR_SEND_POLICY_ROOT_NOT_IN_ACCOUNT="send policy root is not a procedure of this account" @@ -271,14 +265,11 @@ end #! Outputs: [amount, tag, note_type, RECIPIENT] #! #! Panics if: -#! - the account is paused (`exec.pausable::assert_not_paused` — TokenPolicyManager requires the -#! [`Pausable`] component to be installed; the slot lookup panics on accounts that did not -#! install it). +#! - the account is paused (`exec.pausable::assert_not_paused` is a no-op when the [`Pausable`] +#! component is not installed). +#! - the active mint policy root is zero. #! - active mint policy predicate fails. #! -#! The active policy root is validated at config time (in `set_mint_policy` and on initial -#! storage construction) so we trust it here without re-checking existence / allowed-ness. -#! #! Invocation: exec @locals(4) pub proc execute_mint_policy @@ -288,6 +279,10 @@ pub proc execute_mint_policy exec.get_mint_policy_root # => [MINT_POLICY_ROOT, amount, tag, note_type, RECIPIENT] + exec.word::testz + assertz.err=ERR_ACTIVE_MINT_POLICY_NOT_SET + # => [MINT_POLICY_ROOT, amount, tag, note_type, RECIPIENT] + loc_storew_le.POLICY_PROC_ROOT_PTR dropw # => [amount, tag, note_type, RECIPIENT] @@ -306,6 +301,9 @@ end #! Invocation: call pub proc get_mint_policy exec.get_mint_policy_root + # => [MINT_POLICY_ROOT, pad(16)] + + swapw dropw # => [MINT_POLICY_ROOT, pad(12)] end @@ -345,11 +343,9 @@ end #! #! Panics if: #! - the account is paused. +#! - the active burn policy root is zero. #! - active burn policy predicate fails. #! -#! The active policy root is validated at config time (in `set_burn_policy` and on initial -#! storage construction) so we trust it here without re-checking existence / allowed-ness. -#! #! Invocation: exec @locals(4) pub proc execute_burn_policy @@ -359,6 +355,10 @@ pub proc execute_burn_policy exec.get_burn_policy_root # => [BURN_POLICY_ROOT, ASSET_KEY, ASSET_VALUE] + exec.word::testz + assertz.err=ERR_ACTIVE_BURN_POLICY_NOT_SET + # => [BURN_POLICY_ROOT, ASSET_KEY, ASSET_VALUE] + loc_storew_le.POLICY_PROC_ROOT_PTR dropw # => [ASSET_KEY, ASSET_VALUE] @@ -377,6 +377,9 @@ end #! Invocation: call pub proc get_burn_policy exec.get_burn_policy_root + # => [BURN_POLICY_ROOT, pad(16)] + + swapw dropw # => [BURN_POLICY_ROOT, pad(12)] end @@ -406,24 +409,107 @@ pub proc set_burn_policy # => [pad(16)] end +# TRANSFER POLICY FOR PAUSE CHECK +# ================================================================================================ + +#! Reads the active transfer policy root from the provided slot, applies the pause check, and +#! invokes the policy via `dyncall`. Shared by `invoke_send_policy` / `invoke_receive_policy`, +#! which differ only in which slot they bind. +#! +#! If the active root is the empty word (no policy configured for this kind), the transfer is +#! accepted unchanged and the pause check is skipped — mirroring the kernel's behavior when a +#! callback slot holds the empty word. Otherwise the account-wide pause flag is asserted (a no-op +#! when the [`Pausable`] component is not installed) and the policy is invoked via `dyncall`. +#! +#! Inputs: [slot_id_suffix, slot_id_prefix, ASSET_KEY, ASSET_VALUE, custom_data, pad(7)] +#! Outputs: [PROCESSED_ASSET_VALUE, pad(12)] +#! +#! Where: +#! - slot_id_{suffix, prefix} identify the storage slot holding the active policy root. +#! - custom_data is `0` for the receive callback and the output note index for the send callback. +#! - PROCESSED_ASSET_VALUE is the asset value returned by the policy, or the original ASSET_VALUE +#! if no policy is configured. +#! +#! Panics if: +#! - the account is paused. +#! - the invoked policy predicate fails. +#! +#! Invocation: exec +@locals(4) +proc invoke_transfer_policy + exec.active_account::get_item + # => [POLICY_ROOT, ASSET_KEY, ASSET_VALUE, custom_data, pad(7)] + + exec.word::testz + # => [is_empty, POLICY_ROOT, ASSET_KEY, ASSET_VALUE, custom_data, pad(7)] + + if.true + # No policy configured: drop the empty root and asset key, return the asset value + # unchanged. `movup.4 drop` removes custom_data so only ASSET_VALUE is returned. + dropw dropw movup.4 drop + # => [ASSET_VALUE, pad(7)] + else + exec.pausable::assert_not_paused + # => [POLICY_ROOT, ASSET_KEY, ASSET_VALUE, custom_data, pad(7)] + + loc_storew_le.POLICY_PROC_ROOT_PTR dropw + # => [ASSET_KEY, ASSET_VALUE, custom_data, pad(7)] + + locaddr.POLICY_PROC_ROOT_PTR + # => [policy_root_ptr, ASSET_KEY, ASSET_VALUE, custom_data, pad(7)] + + dyncall + # => [PROCESSED_ASSET_VALUE, pad(12)] + end +end + # SEND PUBLIC INTERFACE # ================================================================================================ -#! Returns active send policy root (reads the protocol-reserved callback slot). +#! Invokes the active send policy. Stored in the protocol-reserved `on_before_asset_added_to_note` +#! callback slot and `dyncall`-invoked by the kernel when an asset with the callback flag is added +#! to an output note. +#! +#! Binds [`ACTIVE_SEND_POLICY_PROC_ROOT_SLOT`] and delegates to [`invoke_transfer_policy`], which +#! applies the pause check and invokes the active send policy. +#! +#! Inputs: [ASSET_KEY, ASSET_VALUE, note_idx, pad(7)] +#! Outputs: [PROCESSED_ASSET_VALUE, pad(12)] +#! +#! Where: +#! - PROCESSED_ASSET_VALUE is the asset value returned by the policy, or the original ASSET_VALUE +#! if no send policy is configured. +#! +#! Panics if: +#! - the account is paused. +#! - the active send policy predicate fails. +#! +#! Invocation: call +pub proc invoke_send_policy + push.ACTIVE_SEND_POLICY_PROC_ROOT_SLOT[0..2] + # => [slot_id_suffix, slot_id_prefix, ASSET_KEY, ASSET_VALUE, note_idx, pad(7)] + + exec.invoke_transfer_policy + # => [PROCESSED_ASSET_VALUE, pad(12)] +end + +#! Returns active send policy root. #! #! Inputs: [pad(16)] #! Outputs: [SEND_POLICY_ROOT, pad(12)] #! #! Invocation: call pub proc get_send_policy - push.ON_BEFORE_ASSET_ADDED_TO_NOTE_SLOT[0..2] exec.active_account::get_item + push.ACTIVE_SEND_POLICY_PROC_ROOT_SLOT[0..2] exec.active_account::get_item + # => [SEND_POLICY_ROOT, pad(16)] + + swapw dropw # => [SEND_POLICY_ROOT, pad(12)] end -#! Sets active send policy root by writing directly into the protocol-reserved callback slot -#! `miden::protocol::faucet::callback::on_before_asset_added_to_note`. The kernel will dispatch -#! to whatever root is stored there via `call` the next time an asset with the callback flag is -#! added to an output note. +#! Sets active send policy root. +#! +#! Writes into [`ACTIVE_SEND_POLICY_PROC_ROOT_SLOT`]. #! #! Inputs: [NEW_POLICY_ROOT, pad(12)] #! Outputs: [pad(16)] @@ -445,28 +531,59 @@ pub proc set_send_policy exec.assert_allowed_send_policy_root # => [NEW_POLICY_ROOT, pad(12)] - push.ON_BEFORE_ASSET_ADDED_TO_NOTE_SLOT[0..2] exec.native_account::set_item dropw + push.ACTIVE_SEND_POLICY_PROC_ROOT_SLOT[0..2] exec.native_account::set_item dropw # => [pad(16)] end # RECEIVE PUBLIC INTERFACE # ================================================================================================ -#! Returns active receive policy root (reads the protocol-reserved callback slot). +#! Invokes the active receive policy. Stored in the protocol-reserved +#! `on_before_asset_added_to_account` callback slot and `dyncall`-invoked by the kernel when an +#! asset with the callback flag is added to an account vault. +#! +#! Binds [`ACTIVE_RECEIVE_POLICY_PROC_ROOT_SLOT`] and delegates to [`invoke_transfer_policy`], +#! which applies the pause check and invokes the active receive policy (skipping both when no +#! receive policy is configured). +#! +#! Inputs: [ASSET_KEY, ASSET_VALUE, custom_data, pad(7)] +#! Outputs: [PROCESSED_ASSET_VALUE, pad(12)] +#! +#! Where: +#! - custom_data is `0` for the receive callback. +#! - PROCESSED_ASSET_VALUE is the asset value returned by the policy, or the original ASSET_VALUE +#! if no receive policy is configured. +#! +#! Panics if: +#! - the account is paused. +#! - the active receive policy predicate fails. +#! +#! Invocation: call +pub proc invoke_receive_policy + push.ACTIVE_RECEIVE_POLICY_PROC_ROOT_SLOT[0..2] + # => [slot_id_suffix, slot_id_prefix, ASSET_KEY, ASSET_VALUE, custom_data, pad(7)] + + exec.invoke_transfer_policy + # => [PROCESSED_ASSET_VALUE, pad(12)] +end + +#! Returns active receive policy root. #! #! Inputs: [pad(16)] #! Outputs: [RECEIVE_POLICY_ROOT, pad(12)] #! #! Invocation: call pub proc get_receive_policy - push.ON_BEFORE_ASSET_ADDED_TO_ACCOUNT_SLOT[0..2] exec.active_account::get_item + push.ACTIVE_RECEIVE_POLICY_PROC_ROOT_SLOT[0..2] exec.active_account::get_item + # => [RECEIVE_POLICY_ROOT, pad(16)] + + swapw dropw # => [RECEIVE_POLICY_ROOT, pad(12)] end -#! Sets active receive policy root by writing directly into the protocol-reserved callback slot -#! `miden::protocol::faucet::callback::on_before_asset_added_to_account`. The kernel will -#! dispatch to whatever root is stored there via `call` the next time an asset with the callback -#! flag is added to an account vault. +#! Sets active receive policy root. +#! +#! Writes into [`ACTIVE_RECEIVE_POLICY_PROC_ROOT_SLOT`]. #! #! Inputs: [NEW_POLICY_ROOT, pad(12)] #! Outputs: [pad(16)] @@ -488,6 +605,6 @@ pub proc set_receive_policy exec.assert_allowed_receive_policy_root # => [NEW_POLICY_ROOT, pad(12)] - push.ON_BEFORE_ASSET_ADDED_TO_ACCOUNT_SLOT[0..2] exec.native_account::set_item dropw + push.ACTIVE_RECEIVE_POLICY_PROC_ROOT_SLOT[0..2] exec.native_account::set_item dropw # => [pad(16)] end diff --git a/crates/miden-standards/asm/standards/faucets/policies/transfer/allow_all.masm b/crates/miden-standards/asm/standards/faucets/policies/transfer/allow_all.masm index 53c7dac64e..3cc7fa998e 100644 --- a/crates/miden-standards/asm/standards/faucets/policies/transfer/allow_all.masm +++ b/crates/miden-standards/asm/standards/faucets/policies/transfer/allow_all.masm @@ -1,22 +1,22 @@ # Generic transfer policy procedures shared by policy manager flows. -use miden::standards::access::pausable - # POLICY PROCEDURES # ================================================================================================ -#! Transfer policy that accepts every non-paused transfer. +#! Transfer policy that accepts every transfer. #! -#! Invoked as the active send or receive policy via `TokenPolicyManager`. +#! Invoked as the active send or receive policy via `TokenPolicyManager`, which applies the +#! account-wide pause check. #! -#! Calls `exec.pausable::assert_not_paused` before allowing the transfer. The `Pausable` component -#! is REQUIRED on accounts using TokenPolicyManager. +#! Inputs: [ASSET_KEY, ASSET_VALUE, custom_data, pad(7)] +#! Outputs: [ASSET_VALUE, pad(12)] #! -#! Inputs: [ASSET_KEY, ASSET_VALUE] -#! Outputs: [ASSET_VALUE] +#! Where: +#! - custom_data is `0` for the receive (account) callback and the output note index for the +#! send (note) callback. This policy does not inspect it; it passes through unchanged. #! #! Invocation: call pub proc check_policy - exec.pausable::assert_not_paused dropw + # => [ASSET_VALUE, pad(12)] end diff --git a/crates/miden-standards/asm/standards/faucets/policies/transfer/allowlist/owner_controlled.masm b/crates/miden-standards/asm/standards/faucets/policies/transfer/allowlist/owner_controlled.masm index d300c62b3b..c83b784808 100644 --- a/crates/miden-standards/asm/standards/faucets/policies/transfer/allowlist/owner_controlled.masm +++ b/crates/miden-standards/asm/standards/faucets/policies/transfer/allowlist/owner_controlled.masm @@ -29,7 +29,7 @@ pub proc allow_account # => [account_id_suffix, account_id_prefix, pad(14)] exec.allowlist::allow_account - # => [pad(14)] + # => [pad(16)] end #! Disallow an account, gated by the Ownable2Step owner. @@ -46,5 +46,5 @@ pub proc disallow_account # => [account_id_suffix, account_id_prefix, pad(14)] exec.allowlist::disallow_account - # => [pad(14)] + # => [pad(16)] end diff --git a/crates/miden-standards/asm/standards/faucets/policies/transfer/basic_allowlist.masm b/crates/miden-standards/asm/standards/faucets/policies/transfer/basic_allowlist.masm index c671b16949..c8409ad4c2 100644 --- a/crates/miden-standards/asm/standards/faucets/policies/transfer/basic_allowlist.masm +++ b/crates/miden-standards/asm/standards/faucets/policies/transfer/basic_allowlist.masm @@ -6,7 +6,6 @@ use miden::protocol::native_account use miden::standards::faucets::policies::transfer::allowlist -use miden::standards::access::pausable # POLICY PROCEDURES # ================================================================================================ @@ -14,32 +13,27 @@ use miden::standards::access::pausable #! Transfer policy that rejects transfers whose native account is not allowed on the issuing #! faucet. #! -#! Invoked as the active send or receive policy via `TokenPolicyManager`. Reads the native -#! account ID (asset recipient when invoked from `on_before_asset_added_to_account`, note -#! creator when invoked from `on_before_asset_added_to_note`) and asserts it is allowed -#! against the issuing faucet's `allowed_accounts` storage. -#! #! The same procedure root is reusable as both send and receive policy because it only #! consumes the top eight felts (`ASSET_KEY`, `ASSET_VALUE`) and leaves the rest of the call #! frame untouched — any `note_idx` carried in the send signature passes through unchanged. #! -#! Inputs: [ASSET_KEY, ASSET_VALUE, pad(8)] +#! Inputs: [ASSET_KEY, ASSET_VALUE, custom_data, pad(7)] #! Outputs: [ASSET_VALUE, pad(12)] #! +#! Where: +#! - custom_data is `0` for the receive (account) callback and the output note index for the +#! send (note) callback. This policy does not inspect it; it passes through unchanged. +#! #! Panics if: -#! - the account is paused (`Pausable` component required). #! - the native account is not allowed on the issuing faucet. #! #! Invocation: call pub proc check_policy - exec.pausable::assert_not_paused - # => [ASSET_KEY, ASSET_VALUE, pad(8)] - exec.native_account::get_id - # => [account_id_suffix, account_id_prefix, ASSET_KEY, ASSET_VALUE, pad(8)] + # => [account_id_suffix, account_id_prefix, ASSET_KEY, ASSET_VALUE, custom_data, pad(7)] exec.allowlist::assert_allowed - # => [ASSET_KEY, ASSET_VALUE, pad(8)] + # => [ASSET_KEY, ASSET_VALUE, custom_data, pad(7)] dropw # => [ASSET_VALUE, pad(12)] diff --git a/crates/miden-standards/asm/standards/faucets/policies/transfer/basic_blocklist.masm b/crates/miden-standards/asm/standards/faucets/policies/transfer/basic_blocklist.masm index 51e2cc9f9e..c96544ad1c 100644 --- a/crates/miden-standards/asm/standards/faucets/policies/transfer/basic_blocklist.masm +++ b/crates/miden-standards/asm/standards/faucets/policies/transfer/basic_blocklist.masm @@ -6,39 +6,33 @@ use miden::protocol::native_account use miden::standards::faucets::policies::transfer::blocklist -use miden::standards::access::pausable # POLICY PROCEDURES # ================================================================================================ #! Transfer policy that rejects transfers whose native account is blocked on the issuing faucet. #! -#! Invoked as the active send or receive policy via `TokenPolicyManager`. Reads the native -#! account ID (asset recipient when invoked from `on_before_asset_added_to_account`, note -#! creator when invoked from `on_before_asset_added_to_note`) and asserts it is not blocked -#! against the issuing faucet's `blocked_accounts` storage. -#! #! The same procedure root is reusable as both send and receive policy because it only #! consumes the top eight felts (`ASSET_KEY`, `ASSET_VALUE`) and leaves the rest of the call #! frame untouched — any `note_idx` carried in the send signature passes through unchanged. #! -#! Inputs: [ASSET_KEY, ASSET_VALUE, pad(8)] +#! Inputs: [ASSET_KEY, ASSET_VALUE, custom_data, pad(7)] #! Outputs: [ASSET_VALUE, pad(12)] #! +#! Where: +#! - custom_data is `0` for the receive (account) callback and the output note index for the +#! send (note) callback. This policy does not inspect it; it passes through unchanged. +#! #! Panics if: -#! - the account is paused (`Pausable` component required). #! - the native account is blocked on the issuing faucet. #! #! Invocation: call pub proc check_policy - exec.pausable::assert_not_paused - # => [ASSET_KEY, ASSET_VALUE, pad(8)] - exec.native_account::get_id - # => [account_id_suffix, account_id_prefix, ASSET_KEY, ASSET_VALUE, pad(8)] + # => [account_id_suffix, account_id_prefix, ASSET_KEY, ASSET_VALUE, custom_data, pad(7)] exec.blocklist::assert_not_blocked - # => [ASSET_KEY, ASSET_VALUE, pad(8)] + # => [ASSET_KEY, ASSET_VALUE, custom_data, pad(7)] dropw # => [ASSET_VALUE, pad(12)] diff --git a/crates/miden-standards/asm/standards/faucets/policies/transfer/blocklist/owner_controlled.masm b/crates/miden-standards/asm/standards/faucets/policies/transfer/blocklist/owner_controlled.masm index dc56911f5a..c7077b37fc 100644 --- a/crates/miden-standards/asm/standards/faucets/policies/transfer/blocklist/owner_controlled.masm +++ b/crates/miden-standards/asm/standards/faucets/policies/transfer/blocklist/owner_controlled.masm @@ -29,7 +29,7 @@ pub proc block_account # => [account_id_suffix, account_id_prefix, pad(14)] exec.blocklist::block_account - # => [pad(14)] + # => [pad(16)] end #! Unblock an account, gated by the Ownable2Step owner. @@ -46,5 +46,5 @@ pub proc unblock_account # => [account_id_suffix, account_id_prefix, pad(14)] exec.blocklist::unblock_account - # => [pad(14)] + # => [pad(16)] end diff --git a/crates/miden-standards/asm/standards/wallets/basic.masm b/crates/miden-standards/asm/standards/wallets/basic.masm index aeeb366c87..1fba824af8 100644 --- a/crates/miden-standards/asm/standards/wallets/basic.masm +++ b/crates/miden-standards/asm/standards/wallets/basic.masm @@ -24,7 +24,7 @@ use miden::protocol::active_note #! Invocation: call pub proc receive_asset exec.native_account::add_asset - # => [ASSET_VALUE', pad(12)] + # => [FINAL_ASSET_VALUE, pad(12)] # drop the final asset dropw diff --git a/crates/miden-standards/src/account/access/authority.rs b/crates/miden-standards/src/account/access/authority.rs index a58c9d874b..8e497e527a 100644 --- a/crates/miden-standards/src/account/access/authority.rs +++ b/crates/miden-standards/src/account/access/authority.rs @@ -1,15 +1,23 @@ +use alloc::collections::BTreeMap; +use alloc::vec; + use miden_protocol::account::component::{ AccountComponentCode, AccountComponentMetadata, FeltSchema, + SchemaType, StorageSchema, StorageSlotSchema, }; use miden_protocol::account::{ AccountComponent, + AccountProcedureRoot, AccountStorage, RoleSymbol, + StorageMap, + StorageMapKey, StorageSlot, + StorageSlotContent, StorageSlotName, }; use miden_protocol::errors::{AccountError, RoleSymbolError}; @@ -18,17 +26,37 @@ use miden_protocol::{Felt, Word}; use thiserror::Error; use crate::account::account_component_code; +use crate::procedure_root; // CONSTANTS // ================================================================================================ account_component_code!(AUTHORITY_CODE, "access/authority.masl"); +procedure_root!( + AUTHORITY_FREEZE, + Authority::NAME, + Authority::FREEZE_PROC_NAME, + Authority::code() +); + +procedure_root!( + AUTHORITY_UNFREEZE, + Authority::NAME, + Authority::UNFREEZE_PROC_NAME, + Authority::code() +); + static AUTHORITY_SLOT_NAME: LazyLock = LazyLock::new(|| { StorageSlotName::new("miden::standards::access::authority") .expect("storage slot name should be valid") }); +static AUTHORITY_PROCEDURE_ROLES_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::access::authority::procedure_roles") + .expect("storage slot name should be valid") +}); + /// Authority value written to the storage slot for [`Authority::AuthControlled`]. const AUTH_CONTROLLED: u8 = 0; /// Authority value written to the storage slot for [`Authority::OwnerControlled`]. @@ -43,9 +71,9 @@ const RBAC_CONTROLLED: u8 = 2; /// /// Components that gate state-mutating procedures (such as /// [`TokenPolicyManager`][crate::account::policies::TokenPolicyManager] for `set_mint_policy` / -/// `set_burn_policy`, or the fungible token metadata setters) consult this single shared slot via -/// the MASM helper `authority::assert_authorized`. Installing the [`Authority`] component on an -/// account thus selects the gating mode for *all* such procedures in one place. +/// `set_burn_policy`, or the fungible token metadata setters) consult this shared slot via the +/// MASM helper `authority::assert_authorized`. Installing the [`Authority`] component on an account +/// thus selects the gating mode for *all* such procedures in one place. /// /// # Safety invariant for [`Authority::AuthControlled`] /// @@ -53,31 +81,57 @@ const RBAC_CONTROLLED: u8 = 2; /// is the **sole** gate for every authority-gated setter. The auth component MUST therefore /// authenticate every such setter root, otherwise the setters become permissionless. /// -/// Storage layout: `[authority, role_symbol_or_zero, 0, 0]` — single Word. +/// # Per-procedure roles under [`Authority::RbacControlled`] +/// +/// Under RBAC, each gated procedure can be assigned its own role via `roles`, keyed by the +/// procedure's [`AccountProcedureRoot`] (e.g. `pause` → `PAUSER`, `unpause` → `UNPAUSER`). At +/// runtime `assert_authorized` identifies the calling procedure via the `caller` instruction and +/// looks up its role. A procedure without a mapping falls back to the +/// [`Ownable2Step`][crate::account::access::Ownable2Step] owner check. +/// +/// # Emergency switch (`is_frozen`) +/// +/// The component includes an `is_frozen` flag. If it is `true`, all procedures that call +/// `assert_authorized` would panic, effectively freezing them. Accounts are always constructed +/// unfrozen. +/// +/// The flag can be toggled by the configured +/// [`Ownable2Step`][crate::account::access::Ownable2Step] owner via `freeze` / `unfreeze`. +/// +/// This flag is only meaningful when [`Ownable2Step`][crate::account::access::Ownable2Step] is +/// installed and has no effect under [`Authority::AuthControlled`]. +/// +/// Storage layout: +/// - Value slot: `[authority, is_frozen, 0, 0]`. +/// - Map slot (only under RBAC): `procedure_root` → `[role_symbol, 0, 0, 0]`. #[repr(u8)] #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub enum Authority { - /// Authority is the account's auth component; no extra check is performed by - /// `authority::assert_authorized`. + /// Authority is the account's auth component. AuthControlled = AUTH_CONTROLLED, - /// Authority is the [`Ownable2Step`][crate::account::access::Ownable2Step] owner; the call - /// must be sent by the registered owner. + /// Authority is the [`Ownable2Step`][crate::account::access::Ownable2Step] owner. OwnerControlled = OWNER_CONTROLLED, - /// Authority is membership in a specific RBAC role. The call must be sent by an account that - /// holds `role` in the - /// [`RoleBasedAccessControl`][crate::account::access::RoleBasedAccessControl] component. + /// Authority is membership in an RBAC role, resolved per gated procedure. /// + /// `roles` maps a gated procedure's [`AccountProcedureRoot`] to the role required to invoke it. /// Requires the [`RoleBasedAccessControl`][crate::account::access::RoleBasedAccessControl] - /// component to be installed on the account; the MASM helper calls into + /// component to be installed on the account. the MASM helper calls into /// `rbac::assert_sender_has_role` and will fail to link otherwise. - RbacControlled { role: RoleSymbol } = RBAC_CONTROLLED, + RbacControlled { + roles: BTreeMap, + } = RBAC_CONTROLLED, } impl Authority { /// The name of the component. pub const NAME: &'static str = "miden::standards::components::access::authority"; + /// Name of the owner-gated procedure that freezes the authority-gated surface. + const FREEZE_PROC_NAME: &'static str = "freeze"; + /// Name of the owner-gated procedure that unfreezes the authority-gated surface. + const UNFREEZE_PROC_NAME: &'static str = "unfreeze"; + /// Returns the [`AccountComponentCode`] of this component. pub fn code() -> &'static AccountComponentCode { &AUTHORITY_CODE @@ -86,34 +140,93 @@ impl Authority { // PUBLIC ACCESSORS // -------------------------------------------------------------------------------------------- + /// Returns the procedure root of the owner-gated `freeze` emergency switch. + /// + /// This procedure is always gated on the owner check directly, so unlike role-assignable + /// procedures it must not be placed in the [`Authority::RbacControlled`] role map. + pub fn freeze_root() -> AccountProcedureRoot { + *AUTHORITY_FREEZE + } + + /// Returns the procedure root of the owner-gated `unfreeze` emergency switch. + /// + /// This procedure is always gated on the owner check directly, so unlike role-assignable + /// procedures it must not be placed in the [`Authority::RbacControlled`] role map. + pub fn unfreeze_root() -> AccountProcedureRoot { + *AUTHORITY_UNFREEZE + } + /// Returns the [`StorageSlotName`] holding the authority configuration. pub fn authority_slot() -> &'static StorageSlotName { &AUTHORITY_SLOT_NAME } + /// Returns the [`StorageSlotName`] holding the per-procedure role map (RBAC only). + pub fn procedure_roles_slot() -> &'static StorageSlotName { + &AUTHORITY_PROCEDURE_ROLES_SLOT_NAME + } + /// Reads the authority configuration from account storage. pub fn try_from_storage(storage: &AccountStorage) -> Result { let word = storage .get_item(Self::authority_slot()) .map_err(AuthorityError::MissingStorageSlot)?; - Self::try_from(word) + + let discriminant: u8 = word[0] + .as_canonical_u64() + .try_into() + .map_err(|_| AuthorityError::InvalidAuthority(word[0].as_canonical_u64()))?; + + match discriminant { + AUTH_CONTROLLED => Ok(Self::AuthControlled), + OWNER_CONTROLLED => Ok(Self::OwnerControlled), + RBAC_CONTROLLED => { + let roles = Self::read_roles_from_storage(storage)?; + Ok(Self::RbacControlled { roles }) + }, + other => Err(AuthorityError::InvalidAuthority(other.into())), + } + } + + /// Reads the `is_frozen` emergency-switch flag from account storage. + /// + /// Returns `true` if the account's authority-gated surface is currently frozen (every + /// procedure that calls `assert_authorized` panics until it is unfrozen). + pub fn try_read_frozen(storage: &AccountStorage) -> Result { + let word = storage + .get_item(Self::authority_slot()) + .map_err(AuthorityError::MissingStorageSlot)?; + + Ok(word[1] != Felt::ZERO) } - /// Returns the [`AccountComponentMetadata`] for this component. - pub fn component_metadata() -> AccountComponentMetadata { - let storage_schema = StorageSchema::new(vec![( + /// Returns the [`AccountComponentMetadata`] for this configuration. + pub fn component_metadata(&self) -> AccountComponentMetadata { + let mut slots = vec![( AUTHORITY_SLOT_NAME.clone(), StorageSlotSchema::value( "Authority configuration", [ FeltSchema::u8("authority"), - FeltSchema::felt("role_symbol"), + FeltSchema::u8("is_frozen"), FeltSchema::new_void(), FeltSchema::new_void(), ], ), - )]) - .expect("storage schema should be valid"); + )]; + + if matches!(self, Authority::RbacControlled { .. }) { + slots.push(( + AUTHORITY_PROCEDURE_ROLES_SLOT_NAME.clone(), + StorageSlotSchema::map( + "Per-procedure role assignment (procedure root -> role symbol)", + SchemaType::native_word(), + SchemaType::role_symbol(), + ), + )); + } + + let storage_schema = StorageSchema::new(slots).expect("storage schema should be valid"); AccountComponentMetadata::new(Self::NAME) .with_description( @@ -122,60 +235,80 @@ impl Authority { ) .with_storage_schema(storage_schema) } -} -// TRAIT IMPLEMENTATIONS -// ================================================================================================ + // PRIVATE HELPERS + // -------------------------------------------------------------------------------------------- -impl From for Word { - fn from(value: Authority) -> Self { - match value { - Authority::AuthControlled => { - Word::new([Felt::from(AUTH_CONTROLLED), Felt::ZERO, Felt::ZERO, Felt::ZERO]) - }, - Authority::OwnerControlled => { - Word::new([Felt::from(OWNER_CONTROLLED), Felt::ZERO, Felt::ZERO, Felt::ZERO]) - }, - Authority::RbacControlled { role } => { - Word::new([Felt::from(RBAC_CONTROLLED), role.into(), Felt::ZERO, Felt::ZERO]) - }, + /// Returns the discriminant byte written to `word[0]` of the authority slot. + fn as_u8(&self) -> u8 { + match self { + Authority::AuthControlled => AUTH_CONTROLLED, + Authority::OwnerControlled => OWNER_CONTROLLED, + Authority::RbacControlled { .. } => RBAC_CONTROLLED, } } -} -impl TryFrom for Authority { - type Error = AuthorityError; + /// Encodes the authority configuration value slot word: `[authority, is_frozen, 0, 0]`. + fn to_word(&self) -> Word { + Word::new([Felt::from(self.as_u8()), Felt::ZERO, Felt::ZERO, Felt::ZERO]) + } - fn try_from(word: Word) -> Result { - let authority: u8 = word[0] - .as_canonical_u64() - .try_into() - .map_err(|_| AuthorityError::InvalidAuthority(word[0].as_canonical_u64()))?; - match authority { - AUTH_CONTROLLED => Ok(Self::AuthControlled), - OWNER_CONTROLLED => Ok(Self::OwnerControlled), - RBAC_CONTROLLED => { - let role = - RoleSymbol::try_from(word[1]).map_err(AuthorityError::InvalidRoleSymbol)?; - Ok(Self::RbacControlled { role }) - }, - other => Err(AuthorityError::InvalidAuthority(other.into())), + /// Reconstructs the per-procedure role map from the procedure-roles storage slot. + fn read_roles_from_storage( + storage: &AccountStorage, + ) -> Result, AuthorityError> { + let slot = storage + .slots() + .iter() + .find(|slot| slot.name().id() == AUTHORITY_PROCEDURE_ROLES_SLOT_NAME.id()) + .ok_or(AuthorityError::MissingProcedureRolesSlot)?; + + let StorageSlotContent::Map(map) = slot.content() else { + return Err(AuthorityError::MissingProcedureRolesSlot); + }; + + let mut roles = BTreeMap::new(); + for (key, value) in map.entries() { + let proc_root = AccountProcedureRoot::from_raw(key.as_word()); + let role = RoleSymbol::try_from(value[0]).map_err(AuthorityError::InvalidRoleSymbol)?; + roles.insert(proc_root, role); } + + Ok(roles) } } +// TRAIT IMPLEMENTATIONS +// ================================================================================================ + impl From for AccountComponent { fn from(value: Authority) -> Self { - let slot = StorageSlot::with_value(AUTHORITY_SLOT_NAME.clone(), Word::from(value)); - AccountComponent::new( - Authority::code().clone(), - vec![slot], - Authority::component_metadata(), + let metadata = value.component_metadata(); + + let mut slots = vec![StorageSlot::with_value(AUTHORITY_SLOT_NAME.clone(), value.to_word())]; + + if let Authority::RbacControlled { roles } = value { + let entries = roles.into_iter().map(|(proc_root, role)| { + (StorageMapKey::new(proc_root.as_word()), role_value_word(&role)) + }); + slots.push(StorageSlot::with_map( + AUTHORITY_PROCEDURE_ROLES_SLOT_NAME.clone(), + StorageMap::with_entries(entries) + .expect("authority procedure-roles map should be valid"), + )); + } + + AccountComponent::new(Authority::code().clone(), slots, metadata).expect( + "authority component should satisfy the requirements of a valid account component", ) - .expect("authority component should satisfy the requirements of a valid account component") } } +/// Encodes a role symbol as a map value word: `[role_symbol, 0, 0, 0]`. +fn role_value_word(role: &RoleSymbol) -> Word { + Word::new([role.into(), Felt::ZERO, Felt::ZERO, Felt::ZERO]) +} + // AUTHORITY ERROR // ================================================================================================ @@ -184,8 +317,10 @@ impl From for AccountComponent { pub enum AuthorityError { #[error("invalid authority value: {0}")] InvalidAuthority(u64), - #[error("invalid role symbol in authority slot")] + #[error("invalid role symbol in authority storage")] InvalidRoleSymbol(#[source] RoleSymbolError), #[error("failed to read authority slot from storage")] MissingStorageSlot(#[source] AccountError), + #[error("authority procedure-roles slot is missing or not a map")] + MissingProcedureRolesSlot, } diff --git a/crates/miden-standards/src/account/access/mod.rs b/crates/miden-standards/src/account/access/mod.rs index 599cd9fbd2..53d245ee57 100644 --- a/crates/miden-standards/src/account/access/mod.rs +++ b/crates/miden-standards/src/account/access/mod.rs @@ -1,38 +1,40 @@ +use alloc::collections::BTreeMap; use alloc::vec; -use miden_protocol::account::{AccountComponent, AccountId, RoleSymbol}; +use miden_protocol::account::{AccountComponent, AccountId, AccountProcedureRoot, RoleSymbol}; pub mod authority; pub mod ownable2step; pub mod pausable; pub mod rbac; -/// Access control configuration for account components. +/// Access control configuration for network-style accounts whose authority-gated setters are +/// gated by an owner / role check rather than by the account's auth component. /// -/// Each variant expands into the set of [`AccountComponent`]s that implement that access -/// control choice **plus** the matching [`Authority`] component. The [`Authority`] is -/// auto-yielded so callers don't need to remember to install it separately and so that the -/// authority discriminator stays in sync with the chosen access mode. +/// User-account faucets (where the auth component is itself the setter gate) install +/// [`Authority::AuthControlled`] directly via factories like +/// [`create_user_fungible_faucet`][crate::account::faucets::create_user_fungible_faucet]; they +/// do not need this enum. /// -/// - [`AccessControl::AuthControlled`] yields just [`Authority::AuthControlled`]. -/// - [`AccessControl::Ownable2Step`] yields [`Ownable2Step`] + [`Authority::OwnerControlled`]. -/// - [`AccessControl::Rbac`] yields [`Ownable2Step`] + [`RoleBasedAccessControl`] + an -/// [`Authority`]. The `authority_role` field selects which authority kind is installed: -/// - `None` → [`Authority::OwnerControlled`] (the top-level owner gates `set_*` operations). -/// - `Some(role)` → [`Authority::RbacControlled { role }`] (any holder of `role` gates `set_*` -/// operations). +/// - [`AccessControl::Ownable2Step`] → [`Ownable2Step`] + [`Authority::OwnerControlled`]. The +/// setter gate enforces `sender == owner`. +/// - [`AccessControl::Rbac`] → [`Ownable2Step`] + [`RoleBasedAccessControl`] + +/// [`Authority::RbacControlled`]. The `roles` map assigns a role to individual gated procedures +/// (keyed by procedure root); procedures without a mapping fall back to the `owner` check. /// /// Pass to /// [`AccountBuilder::with_components`][miden_protocol::account::AccountBuilder::with_components] /// to install the access control components on the account: /// /// ```no_run +/// use std::collections::BTreeMap; +/// /// use miden_protocol::account::AccountBuilder; /// use miden_standards::account::access::AccessControl; /// # let owner: miden_protocol::account::AccountId = unimplemented!(); /// # let init_seed = [0u8; 32]; /// AccountBuilder::new(init_seed) -/// .with_components(AccessControl::Rbac { owner, authority_role: None }); +/// .with_components(AccessControl::Rbac { owner, roles: BTreeMap::new() }); /// ``` /// /// For accounts that don't use the [`AccessControl`] convenience but want to install the @@ -40,26 +42,19 @@ pub mod rbac; /// [`AccountBuilder::with_component`][miden_protocol::account::AccountBuilder::with_component]. #[derive(Debug, Clone, PartialEq, Eq)] pub enum AccessControl { - /// No external access control component is installed; access decisions are gated solely - /// by the account's auth component. - AuthControlled, - /// Two-step ownership transfer with the provided initial owner. Authority for `set_*` - /// operations is fixed to the registered owner. + /// Two-step ownership transfer with the provided initial owner. The setter gate enforces + /// `sender == owner`. Ownable2Step { owner: AccountId }, - /// Role-based access control. Includes [`Ownable2Step`] internally; the provided `owner` + /// Role-based access control. Includes [`Ownable2Step`] internally. The provided `owner` /// becomes the top-level RBAC authority (the account's owner). /// - /// `authority_role` controls which authority is installed alongside RBAC: - /// - `None` (default) → [`Authority::OwnerControlled`]: the top-level `owner` is the sole - /// authority for `set_*` operations (`set_mint_policy`, `set_burn_policy`, metadata setters). - /// RBAC roles can still be granted/revoked but they do not directly gate the - /// authority-protected procedures. - /// - `Some(role)` → [`Authority::RbacControlled { role }`]: any account holding `role` becomes - /// a valid authority for `set_*` operations. Role membership is managed through the standard - /// RBAC API on the [`RoleBasedAccessControl`] component. + /// `roles` assigns a role to individual authority-gated procedures, keyed by procedure root + /// (e.g. `PausableManager::pause_root()` → `PAUSER`, `unpause_root()` → `UNPAUSER`). A gated + /// procedure without an entry in `roles` falls back to the `owner` check. Role membership is + /// managed through the standard RBAC API on the [`RoleBasedAccessControl`] component. Rbac { owner: AccountId, - authority_role: Option, + roles: BTreeMap, }, } @@ -72,20 +67,13 @@ impl IntoIterator for AccessControl { /// always included. fn into_iter(self) -> Self::IntoIter { match self { - AccessControl::AuthControlled => vec![Authority::AuthControlled.into()].into_iter(), AccessControl::Ownable2Step { owner } => { vec![Ownable2Step::new(owner).into(), Authority::OwnerControlled.into()].into_iter() }, - AccessControl::Rbac { owner, authority_role: None } => vec![ - Ownable2Step::new(owner).into(), - RoleBasedAccessControl::empty().into(), - Authority::OwnerControlled.into(), - ] - .into_iter(), - AccessControl::Rbac { owner, authority_role: Some(role) } => vec![ + AccessControl::Rbac { owner, roles } => vec![ Ownable2Step::new(owner).into(), RoleBasedAccessControl::empty().into(), - Authority::RbacControlled { role }.into(), + Authority::RbacControlled { roles }.into(), ] .into_iter(), } diff --git a/crates/miden-standards/src/account/access/pausable/manager.rs b/crates/miden-standards/src/account/access/pausable/manager.rs index ee3ee06c2c..40d8eff20e 100644 --- a/crates/miden-standards/src/account/access/pausable/manager.rs +++ b/crates/miden-standards/src/account/access/pausable/manager.rs @@ -27,19 +27,17 @@ procedure_root!( /// [`crate::account::access::Authority`] component via `exec.authority::assert_authorized`. /// /// `PausableManager` works uniformly with every standard access scheme: -/// - [`crate::account::access::AccessControl::AuthControlled`] → -/// [`crate::account::access::Authority::AuthControlled`] gates pause / unpause via the account's -/// own auth component. +/// - [`crate::account::access::Authority::AuthControlled`] — installed directly by +/// [`crate::account::faucets::create_user_fungible_faucet`]; gates pause / unpause via the +/// account's own auth component. /// - [`crate::account::access::AccessControl::Ownable2Step`] → /// [`crate::account::access::Authority::OwnerControlled`] requires the Ownable2Step owner. /// - [`crate::account::access::AccessControl::Rbac`] → -/// [`crate::account::access::Authority::RbacControlled { role }`] requires the single configured -/// role for both pause and unpause (no PAUSER / UNPAUSER separation — emergency pause is a -/// coarse-grained capability). +/// [`crate::account::access::Authority::RbacControlled`] for roles per procedure. /// /// Companion components required: /// - [`crate::account::access::Authority`] — installed automatically by the -/// [`crate::account::access::AccessControl`] enum. +/// [`crate::account::access::AccessControl`] enum (or directly by user-faucet factories). /// - [`super::Pausable`] — provides the `is_paused` storage slot that pause / unpause write to. #[derive(Debug, Clone, Copy, Default)] pub struct PausableManager; @@ -48,8 +46,8 @@ impl PausableManager { /// The name of the component. pub const NAME: &'static str = "miden::standards::components::access::pausable::manager"; - pub const PAUSE_PROC_NAME: &'static str = "pause"; - pub const UNPAUSE_PROC_NAME: &'static str = "unpause"; + const PAUSE_PROC_NAME: &'static str = "pause"; + const UNPAUSE_PROC_NAME: &'static str = "unpause"; /// Returns the [`AccountComponentCode`] of this component. pub fn code() -> &'static AccountComponentCode { diff --git a/crates/miden-standards/src/account/access/pausable/mod.rs b/crates/miden-standards/src/account/access/pausable/mod.rs index fd4198dbbb..eea1b42c55 100644 --- a/crates/miden-standards/src/account/access/pausable/mod.rs +++ b/crates/miden-standards/src/account/access/pausable/mod.rs @@ -122,7 +122,7 @@ impl Pausable { /// The name of the component. pub const NAME: &'static str = "miden::standards::components::access::pausable"; - pub const IS_PAUSED_PROC_NAME: &'static str = "is_paused"; + const IS_PAUSED_PROC_NAME: &'static str = "is_paused"; /// Creates a [`Pausable`] component with the given pause state. pub const fn new(state: bool) -> Self { diff --git a/crates/miden-standards/src/account/auth/guarded_multisig.rs b/crates/miden-standards/src/account/auth/guarded_multisig.rs index b509a4cf33..3807ce04dc 100644 --- a/crates/miden-standards/src/account/auth/guarded_multisig.rs +++ b/crates/miden-standards/src/account/auth/guarded_multisig.rs @@ -406,7 +406,7 @@ mod tests { .storage() .get_map_item( AuthGuardedMultisig::approver_public_keys_slot(), - Word::from([i as u32, 0, 0, 0]), + StorageMapKey::from_index(i as u32), ) .expect("approver public key storage map access failed"); assert_eq!(stored_pub_key, Word::from(*expected_pub_key)); @@ -418,7 +418,7 @@ mod tests { .storage() .get_map_item( AuthGuardedMultisig::approver_scheme_ids_slot(), - Word::from([i as u32, 0, 0, 0]), + StorageMapKey::from_index(i as u32), ) .expect("approver scheme ID storage map access failed"); assert_eq!(stored_scheme_id, Word::from([*expected_auth_scheme as u32, 0, 0, 0])); @@ -429,7 +429,7 @@ mod tests { .storage() .get_map_item( AuthGuardedMultisig::guardian_public_key_slot(), - Word::from([0u32, 0, 0, 0]), + StorageMapKey::from_index(0), ) .expect("guardian public key storage map access failed"); assert_eq!(guardian_public_key, Word::from(guardian_key.public_key().to_commitment())); @@ -438,7 +438,7 @@ mod tests { .storage() .get_map_item( AuthGuardedMultisig::guardian_scheme_id_slot(), - Word::from([0u32, 0, 0, 0]), + StorageMapKey::from_index(0), ) .expect("guardian scheme ID storage map access failed"); assert_eq!(guardian_scheme_id, Word::from([guardian_key.auth_scheme() as u32, 0, 0, 0])); @@ -482,7 +482,7 @@ mod tests { .storage() .get_map_item( AuthGuardedMultisig::approver_public_keys_slot(), - Word::from([0u32, 0, 0, 0]), + StorageMapKey::from_index(0), ) .expect("approver pub keys storage map access failed"); assert_eq!(stored_pub_key, Word::from(pub_key)); @@ -491,7 +491,7 @@ mod tests { .storage() .get_map_item( AuthGuardedMultisig::approver_scheme_ids_slot(), - Word::from([0u32, 0, 0, 0]), + StorageMapKey::from_index(0), ) .expect("approver scheme IDs storage map access failed"); assert_eq!(stored_scheme_id, Word::from([AuthScheme::EcdsaK256Keccak as u32, 0, 0, 0])); @@ -532,7 +532,7 @@ mod tests { .storage() .get_map_item( AuthGuardedMultisig::guardian_public_key_slot(), - Word::from([0u32, 0, 0, 0]), + StorageMapKey::from_index(0), ) .expect("guardian public key storage map access failed"); assert_eq!(guardian_public_key, Word::from(guardian_key.public_key().to_commitment())); @@ -541,7 +541,7 @@ mod tests { .storage() .get_map_item( AuthGuardedMultisig::guardian_scheme_id_slot(), - Word::from([0u32, 0, 0, 0]), + StorageMapKey::from_index(0), ) .expect("guardian scheme ID storage map access failed"); assert_eq!(guardian_scheme_id, Word::from([guardian_key.auth_scheme() as u32, 0, 0, 0])); diff --git a/crates/miden-standards/src/account/auth/mod.rs b/crates/miden-standards/src/account/auth/mod.rs index e6caf01607..6bd23d4b63 100644 --- a/crates/miden-standards/src/account/auth/mod.rs +++ b/crates/miden-standards/src/account/auth/mod.rs @@ -22,4 +22,6 @@ pub use network_account::{ NetworkAccount, NetworkAccountNoteAllowlist, NetworkAccountNoteAllowlistError, + NetworkAccountTxScriptAllowlist, + NetworkAccountTxScriptAllowlistError, }; diff --git a/crates/miden-standards/src/account/auth/multisig.rs b/crates/miden-standards/src/account/auth/multisig.rs index fadbbbf407..d275c1de0f 100644 --- a/crates/miden-standards/src/account/auth/multisig.rs +++ b/crates/miden-standards/src/account/auth/multisig.rs @@ -386,7 +386,7 @@ mod tests { .storage() .get_map_item( AuthMultisig::approver_public_keys_slot(), - Word::from([i as u32, 0, 0, 0]), + StorageMapKey::from_index(i as u32), ) .expect("approver public key storage map access failed"); assert_eq!(stored_pub_key, Word::from(*expected_pub_key)); @@ -398,7 +398,7 @@ mod tests { .storage() .get_map_item( AuthMultisig::approver_scheme_ids_slot(), - Word::from([i as u32, 0, 0, 0]), + StorageMapKey::from_index(i as u32), ) .expect("approver scheme ID storage map access failed"); assert_eq!(stored_scheme_id, Word::from([*expected_auth_scheme as u32, 0, 0, 0])); @@ -432,13 +432,13 @@ mod tests { let stored_pub_key = account .storage() - .get_map_item(AuthMultisig::approver_public_keys_slot(), Word::from([0u32, 0, 0, 0])) + .get_map_item(AuthMultisig::approver_public_keys_slot(), StorageMapKey::from_index(0)) .expect("approver pub keys storage map access failed"); assert_eq!(stored_pub_key, Word::from(pub_key)); let stored_scheme_id = account .storage() - .get_map_item(AuthMultisig::approver_scheme_ids_slot(), Word::from([0u32, 0, 0, 0])) + .get_map_item(AuthMultisig::approver_scheme_ids_slot(), StorageMapKey::from_index(0)) .expect("approver scheme IDs storage map access failed"); assert_eq!( stored_scheme_id, diff --git a/crates/miden-standards/src/account/auth/multisig_smart/component.rs b/crates/miden-standards/src/account/auth/multisig_smart/component.rs index 1e1b9e8a79..79344533d0 100644 --- a/crates/miden-standards/src/account/auth/multisig_smart/component.rs +++ b/crates/miden-standards/src/account/auth/multisig_smart/component.rs @@ -330,7 +330,7 @@ mod tests { .storage() .get_map_item( AuthMultisigSmart::procedure_policies_slot(), - BasicWallet::receive_asset_root().as_word(), + StorageMapKey::from_raw(BasicWallet::receive_asset_root().as_word()), ) .expect("receive_asset policy should be present"); assert_eq!( diff --git a/crates/miden-standards/src/account/auth/network_account/auth_network_account.rs b/crates/miden-standards/src/account/auth/network_account/auth_network_account.rs index 324c4efbbf..56322f66cc 100644 --- a/crates/miden-standards/src/account/auth/network_account/auth_network_account.rs +++ b/crates/miden-standards/src/account/auth/network_account/auth_network_account.rs @@ -9,8 +9,13 @@ use miden_protocol::account::component::{ }; use miden_protocol::account::{AccountComponent, AccountComponentName, StorageSlotName}; use miden_protocol::note::NoteScriptRoot; +use miden_protocol::transaction::TransactionScriptRoot; -use super::{NetworkAccountNoteAllowlist, NetworkAccountNoteAllowlistError}; +use super::{ + NetworkAccountNoteAllowlist, + NetworkAccountNoteAllowlistError, + NetworkAccountTxScriptAllowlist, +}; use crate::account::account_component_code; account_component_code!(NETWORK_ACCOUNT_AUTH_CODE, "auth/network_account.masl"); @@ -19,24 +24,44 @@ account_component_code!(NETWORK_ACCOUNT_AUTH_CODE, "auth/network_account.masl"); // ================================================================================================ /// An [`AccountComponent`] implementing an authentication scheme that restricts what notes an -/// account can consume to a fixed allowlist of note script roots, and forbids transaction scripts -/// from running against the account. +/// account can consume to a fixed allowlist of note script roots, and what transaction scripts may +/// run against the account to a fixed allowlist of tx script roots. /// /// This is intended for network-owned accounts (e.g. the AggLayer bridge or a network faucet) -/// whose only legitimate inputs are a known, finite set of system-issued notes. +/// whose only legitimate inputs are a known, finite set of system-issued notes and scripts. /// /// The component exports a single auth procedure, `auth_network_transaction`, that rejects the /// transaction unless: -/// - no transaction script was executed, and -/// - every consumed input note has a script root present in the component's allowlist. +/// - the transaction script root, if any, is present in the component's tx-script allowlist, and +/// - every consumed input note has a script root present in the component's note-script allowlist. +/// +/// Because a network account has no signature gate by default, a transaction script is an +/// unconstrained code path that could call the account's procedures directly. The tx-script +/// allowlist constrains this to a fixed set of owner-approved scripts; an empty tx-script allowlist +/// permits no transaction scripts at all. +/// +/// IMPORTANT: an allowlisted root pins a script's *code* (its MAST root), not the inputs it runs +/// on. A tx script still receives caller-controlled `TX_SCRIPT_ARGS` and advice-provider inputs, +/// and a note script receives caller-controlled `NOTE_ARGS`; on an open network account anyone can +/// supply those. A root should therefore only be allowlisted when the script's effect is safe for +/// *every* possible input. The canonical example is a tx script that sets the transaction +/// expiration delta to a hardcoded constant: its effect is fixed regardless of caller or inputs, +/// and the kernel only ever lets a script tighten the current transaction's expiration window +/// (never extend it), so the worst a caller can do is make their own transaction expire sooner. +/// Allowlisting a script whose effect depends on its inputs re-opens the very code path the +/// allowlist exists to constrain. /// -/// The allowlist is stored in the standardized [`NetworkAccountNoteAllowlist`] slot so off-chain -/// services can identify a network account by checking for this slot. +/// The note allowlist is stored in the standardized [`NetworkAccountNoteAllowlist`] slot so +/// off-chain services can identify a network account by checking for this slot. /// -/// The allowlist is fixed at account creation; there is intentionally no procedure to mutate it -/// after deployment. +/// Both allowlists are fixed at account creation: this component intentionally exports no procedure +/// to mutate them after deployment. That is a limitation of this component rather than a safety +/// requirement, and a user who needs a mutable allowlist can write their own component today. Note +/// that the node would likely not yet respect updates made to the list after deployment, but there +/// is in principle nothing preventing us from supporting mutation in the future. pub struct AuthNetworkAccount { - allowlist: NetworkAccountNoteAllowlist, + allowed_notes: NetworkAccountNoteAllowlist, + allowed_tx_scripts: NetworkAccountTxScriptAllowlist, } impl AuthNetworkAccount { @@ -60,33 +85,64 @@ impl AuthNetworkAccount { /// /// Returns an error if `allowed_script_roots` is empty since the account could not consume any /// notes. - pub fn with_allowlist( + pub fn with_allowed_notes( allowed_script_roots: BTreeSet, ) -> Result { Ok(Self { - allowlist: NetworkAccountNoteAllowlist::new(allowed_script_roots)?, + allowed_notes: NetworkAccountNoteAllowlist::new(allowed_script_roots)?, + allowed_tx_scripts: NetworkAccountTxScriptAllowlist::default(), }) } + /// Sets the allowlist of transaction script roots this account will execute, replacing any + /// previously configured tx-script allowlist. + /// + /// An empty set (the default) means the account permits no transaction scripts. + /// + /// Only scripts whose effect is safe for every possible input should be allowlisted: a root + /// pins the script's code but not its `TX_SCRIPT_ARGS` or advice inputs, which the + /// (arbitrary) transaction submitter controls. See the [`AuthNetworkAccount`] type docs for + /// the full rationale. + pub fn with_allowed_tx_scripts( + mut self, + allowed_tx_script_roots: BTreeSet, + ) -> Self { + self.allowed_tx_scripts = NetworkAccountTxScriptAllowlist::new(allowed_tx_script_roots); + self + } + /// Returns the storage slot holding the allowlist of allowed input-note script roots. pub fn allowed_note_scripts_slot() -> &'static StorageSlotName { NetworkAccountNoteAllowlist::slot_name() } - /// Returns the storage slot schema for the allowlist slot. + /// Returns the storage slot schema for the note-script allowlist slot. pub fn allowed_note_scripts_slot_schema() -> (StorageSlotName, StorageSlotSchema) { NetworkAccountNoteAllowlist::slot_schema() } + /// Returns the storage slot holding the allowlist of allowed transaction script roots. + pub fn allowed_tx_scripts_slot() -> &'static StorageSlotName { + NetworkAccountTxScriptAllowlist::slot_name() + } + + /// Returns the storage slot schema for the tx-script allowlist slot. + pub fn allowed_tx_scripts_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + NetworkAccountTxScriptAllowlist::slot_schema() + } + /// Returns the [`AccountComponentMetadata`] for this component. pub fn component_metadata() -> AccountComponentMetadata { - let storage_schema = StorageSchema::new(vec![NetworkAccountNoteAllowlist::slot_schema()]) - .expect("storage schema should be valid"); + let storage_schema = StorageSchema::new(vec![ + NetworkAccountNoteAllowlist::slot_schema(), + NetworkAccountTxScriptAllowlist::slot_schema(), + ]) + .expect("storage schema should be valid"); AccountComponentMetadata::new(Self::NAME) .with_description( - "Authentication component that restricts input notes to a fixed allowlist of \ - note script roots and forbids tx scripts", + "Authentication component that restricts input notes and transaction scripts to \ + fixed allowlists of script roots", ) .with_storage_schema(storage_schema) } @@ -94,7 +150,10 @@ impl AuthNetworkAccount { impl From for AccountComponent { fn from(component: AuthNetworkAccount) -> Self { - let storage_slots = vec![component.allowlist.into_storage_slot()]; + let storage_slots = vec![ + component.allowed_notes.into_storage_slot(), + component.allowed_tx_scripts.into_storage_slot(), + ]; let metadata = AuthNetworkAccount::component_metadata(); AccountComponent::new(AuthNetworkAccount::code().clone(), storage_slots, metadata).expect( @@ -121,7 +180,7 @@ mod tests { let _account = AccountBuilder::new([0; 32]) .with_auth_component( - AuthNetworkAccount::with_allowlist(BTreeSet::from_iter([root_a, root_b])) + AuthNetworkAccount::with_allowed_notes(BTreeSet::from_iter([root_a, root_b])) .expect("non-empty allowlist should construct"), ) .with_component(BasicWallet) @@ -131,7 +190,7 @@ mod tests { #[test] fn auth_network_account_with_empty_allowlist_is_rejected() { - let result = AuthNetworkAccount::with_allowlist(BTreeSet::new()); + let result = AuthNetworkAccount::with_allowed_notes(BTreeSet::new()); assert!(matches!(result, Err(NetworkAccountNoteAllowlistError::EmptyAllowlist))); } @@ -139,16 +198,19 @@ mod tests { fn auth_network_account_uses_standardized_allowlist_slot() { let root_a = NoteScriptRoot::from_array([1, 2, 3, 4]); let component: AccountComponent = - AuthNetworkAccount::with_allowlist(BTreeSet::from_iter([root_a])) + AuthNetworkAccount::with_allowed_notes(BTreeSet::from_iter([root_a])) .expect("non-empty allowlist should construct") .into(); let storage_slots = component.storage_slots(); - assert_eq!(storage_slots.len(), 1); + assert_eq!(storage_slots.len(), 2); assert_eq!(storage_slots[0].name(), NetworkAccountNoteAllowlist::slot_name()); + assert_eq!(storage_slots[1].name(), NetworkAccountTxScriptAllowlist::slot_name()); - let StorageSlotContent::Map(_) = storage_slots[0].content() else { - panic!("allowlist slot must be a map"); - }; + for slot in storage_slots { + let StorageSlotContent::Map(_) = slot.content() else { + panic!("allowlist slots must be maps"); + }; + } } } diff --git a/crates/miden-standards/src/account/auth/network_account/mod.rs b/crates/miden-standards/src/account/auth/network_account/mod.rs index c3c773ca2a..d1362af9ab 100644 --- a/crates/miden-standards/src/account/auth/network_account/mod.rs +++ b/crates/miden-standards/src/account/auth/network_account/mod.rs @@ -7,3 +7,9 @@ pub use network_account::NetworkAccount; mod note_allowlist; pub use note_allowlist::{NetworkAccountNoteAllowlist, NetworkAccountNoteAllowlistError}; + +mod tx_script_allowlist; +pub use tx_script_allowlist::{ + NetworkAccountTxScriptAllowlist, + NetworkAccountTxScriptAllowlistError, +}; diff --git a/crates/miden-standards/src/account/auth/network_account/network_account.rs b/crates/miden-standards/src/account/auth/network_account/network_account.rs index dff81eae76..9f3e9aef7b 100644 --- a/crates/miden-standards/src/account/auth/network_account/network_account.rs +++ b/crates/miden-standards/src/account/auth/network_account/network_account.rs @@ -101,7 +101,7 @@ mod tests { AccountBuilder::new([0; 32]) .account_type(account_type) .with_auth_component( - AuthNetworkAccount::with_allowlist(roots).expect("non-empty allowlist"), + AuthNetworkAccount::with_allowed_notes(roots).expect("non-empty allowlist"), ) .with_component(BasicWallet) .build() diff --git a/crates/miden-standards/src/account/auth/network_account/note_allowlist.rs b/crates/miden-standards/src/account/auth/network_account/note_allowlist.rs index 68bb7519a2..5d458e39ff 100644 --- a/crates/miden-standards/src/account/auth/network_account/note_allowlist.rs +++ b/crates/miden-standards/src/account/auth/network_account/note_allowlist.rs @@ -217,7 +217,7 @@ mod tests { let account = AccountBuilder::new([0; 32]) .with_auth_component( - AuthNetworkAccount::with_allowlist(original_roots.clone()) + AuthNetworkAccount::with_allowed_notes(original_roots.clone()) .expect("non-empty allowlist should construct"), ) .with_component(BasicWallet) diff --git a/crates/miden-standards/src/account/auth/network_account/tx_script_allowlist.rs b/crates/miden-standards/src/account/auth/network_account/tx_script_allowlist.rs new file mode 100644 index 0000000000..58d78b6340 --- /dev/null +++ b/crates/miden-standards/src/account/auth/network_account/tx_script_allowlist.rs @@ -0,0 +1,253 @@ +use alloc::collections::BTreeSet; + +use miden_protocol::account::component::{SchemaType, StorageSlotSchema}; +use miden_protocol::account::{ + AccountStorage, + StorageMap, + StorageMapKey, + StorageSlot, + StorageSlotContent, + StorageSlotName, +}; +use miden_protocol::transaction::TransactionScriptRoot; +use miden_protocol::utils::sync::LazyLock; +use miden_protocol::{Felt, Word}; + +// CONSTANTS +// ================================================================================================ + +static SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::auth::network_account::allowed_tx_scripts") + .expect("storage slot name should be valid") +}); + +// A flag value used as the storage map entry for each allowed script root. Its only job is to be +// distinguishable from the storage map's default empty word, letting the MASM allowlist check +// detect "this key is present" without caring about its contents. Any non-empty word would serve; +// we pick `[1, 0, 0, 0]` for readability when inspecting storage. +const ALLOWED_FLAG: Word = Word::new([Felt::ONE, Felt::ZERO, Felt::ZERO, Felt::ZERO]); + +// NETWORK ACCOUNT TX SCRIPT ALLOWLIST +// ================================================================================================ + +/// A standardized storage slot holding the allowlist of transaction script roots that a network +/// account is willing to execute. +/// +/// A network account has no signature gate, so any transaction script that runs against it is a +/// code path the account owner must have pre-approved. This allowlist is that approval: a +/// transaction that executes no tx script is always allowed, but any other tx script must have its +/// root present here. An empty allowlist therefore reproduces the strictest behavior of permitting +/// no transaction scripts at all. +/// +/// A root pins the script's code but not its `TX_SCRIPT_ARGS` or advice inputs, which the +/// transaction submitter controls; only scripts whose effect is safe for every possible input +/// should be allowlisted (see the [`AuthNetworkAccount`](super::AuthNetworkAccount) docs). +/// +/// The slot is a [`StorageMap`] keyed by tx script root; any non-empty value marks a root as +/// allowed. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct NetworkAccountTxScriptAllowlist { + allowed_script_roots: BTreeSet, +} + +impl NetworkAccountTxScriptAllowlist { + /// Creates a new allowlist from the provided list of allowed transaction script roots. + /// + /// An empty set is permitted and means the account allows no transaction scripts. + pub fn new(allowed_script_roots: BTreeSet) -> Self { + Self { allowed_script_roots } + } + + /// Returns the [`StorageSlotName`] of the standardized allowlist slot. + pub fn slot_name() -> &'static StorageSlotName { + &SLOT_NAME + } + + /// Returns the allowed transaction script roots in this allowlist. + pub fn allowed_script_roots(&self) -> &BTreeSet { + &self.allowed_script_roots + } + + /// Consumes this allowlist and returns the allowed transaction script roots. + pub fn into_allowed_script_roots(self) -> BTreeSet { + self.allowed_script_roots + } + + /// Returns the schema entry for the allowlist slot. + pub fn slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::slot_name().clone(), + StorageSlotSchema::map( + "Allowed transaction script roots", + SchemaType::native_word(), + SchemaType::native_word(), + ), + ) + } + + /// Consumes this allowlist and returns the [`StorageSlot`] suitable for inclusion in an + /// [`AccountComponent`](miden_protocol::account::AccountComponent)'s storage layout. + pub fn into_storage_slot(self) -> StorageSlot { + let entries = self + .allowed_script_roots + .into_iter() + .map(|root| (StorageMapKey::new(root.as_word()), ALLOWED_FLAG)); + + let storage_map = StorageMap::with_entries(entries) + .expect("allowlist entries should produce a valid storage map"); + + StorageSlot::with_map(Self::slot_name().clone(), storage_map) + } +} + +// TRAIT IMPLEMENTATIONS +// ================================================================================================ + +impl TryFrom<&AccountStorage> for NetworkAccountTxScriptAllowlist { + type Error = NetworkAccountTxScriptAllowlistError; + + /// Reconstructs a [`NetworkAccountTxScriptAllowlist`] from account storage by reading the + /// allowlist slot and collecting its keys. + /// + /// # Errors + /// Returns an error if: + /// - The standardized allowlist slot is not present in storage. + /// - The slot is present but is not a [`StorageSlotContent::Map`]. + fn try_from(storage: &AccountStorage) -> Result { + let slot = storage + .get(Self::slot_name()) + .ok_or(NetworkAccountTxScriptAllowlistError::SlotNotFound)?; + + let StorageSlotContent::Map(map) = slot.content() else { + return Err(NetworkAccountTxScriptAllowlistError::UnexpectedSlotType); + }; + + // Only entries with a non-empty value mark a root as allowed, matching the MASM check + // (`word::eqz`), so the reconstructed view agrees with on-chain enforcement. + let allowed_script_roots = map + .entries() + .filter(|(_key, value)| **value != Word::empty()) + .map(|(key, _value)| TransactionScriptRoot::from_raw(key.as_word())) + .collect(); + + Ok(Self::new(allowed_script_roots)) + } +} + +// NETWORK ACCOUNT TX SCRIPT ALLOWLIST ERROR +// ================================================================================================ + +/// Errors that can occur when reconstructing a [`NetworkAccountTxScriptAllowlist`] from storage. +#[derive(Debug, thiserror::Error)] +pub enum NetworkAccountTxScriptAllowlistError { + #[error( + "network account tx script allowlist storage slot {} not found in account storage", + NetworkAccountTxScriptAllowlist::slot_name() + )] + SlotNotFound, + #[error( + "network account tx script allowlist storage slot {} must be a map", + NetworkAccountTxScriptAllowlist::slot_name() + )] + UnexpectedSlotType, +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use miden_protocol::account::{AccountBuilder, StorageSlotContent}; + + use super::*; + use crate::account::auth::network_account::AuthNetworkAccount; + use crate::account::wallets::BasicWallet; + + #[test] + fn allowlist_storage_slot_contains_expected_entries() { + let root_a = TransactionScriptRoot::from_raw(Word::from([1, 2, 3, 4u32])); + let root_b = TransactionScriptRoot::from_raw(Word::from([5, 6, 7, 8u32])); + + let slot = NetworkAccountTxScriptAllowlist::new(BTreeSet::from_iter([root_a, root_b])) + .into_storage_slot(); + + assert_eq!(slot.name(), NetworkAccountTxScriptAllowlist::slot_name()); + + let StorageSlotContent::Map(map) = slot.content() else { + panic!("allowlist slot must be a map"); + }; + + assert_eq!( + map.get(&StorageMapKey::new(root_a.as_word())), + ALLOWED_FLAG, + "root_a should resolve to the flag value" + ); + assert_eq!( + map.get(&StorageMapKey::new(root_b.as_word())), + ALLOWED_FLAG, + "root_b should resolve to the flag value" + ); + } + + #[test] + fn empty_allowlist_is_allowed() { + let slot = NetworkAccountTxScriptAllowlist::new(BTreeSet::new()).into_storage_slot(); + let StorageSlotContent::Map(map) = slot.content() else { + panic!("allowlist slot must be a map"); + }; + assert_eq!(map.entries().count(), 0); + } + + #[test] + fn allowlist_round_trips_through_account_storage() { + let root_a = TransactionScriptRoot::from_raw(Word::from([1, 2, 3, 4u32])); + let root_b = TransactionScriptRoot::from_raw(Word::from([5, 6, 7, 8u32])); + let original_roots = BTreeSet::from_iter([root_a, root_b]); + + let account = AccountBuilder::new([0; 32]) + .with_auth_component( + AuthNetworkAccount::with_allowed_notes(BTreeSet::from_iter([ + miden_protocol::note::NoteScriptRoot::from_array([9, 9, 9, 9]), + ])) + .expect("non-empty note allowlist should construct") + .with_allowed_tx_scripts(original_roots.clone()), + ) + .with_component(BasicWallet) + .build() + .expect("account building with AuthNetworkAccount failed"); + + let allowlist = NetworkAccountTxScriptAllowlist::try_from(account.storage()) + .expect("allowlist should be reconstructable from account storage"); + + let actual: BTreeSet = + allowlist.allowed_script_roots().iter().copied().collect(); + + assert_eq!(actual, original_roots); + } + + #[test] + fn try_from_fails_when_slot_missing() { + // Storage that contains an unrelated slot but not the tx-script allowlist slot. + let other_slot = StorageSlot::with_value( + StorageSlotName::new("miden::standards::test::unrelated").expect("valid slot name"), + Word::empty(), + ); + let storage = AccountStorage::new(vec![other_slot]).expect("storage should be valid"); + + let result = NetworkAccountTxScriptAllowlist::try_from(&storage); + assert!(matches!(result, Err(NetworkAccountTxScriptAllowlistError::SlotNotFound))); + } + + #[test] + fn try_from_fails_when_slot_is_not_a_map() { + // The allowlist slot is present but is a value slot rather than the expected map. + let value_slot = StorageSlot::with_value( + NetworkAccountTxScriptAllowlist::slot_name().clone(), + Word::empty(), + ); + let storage = AccountStorage::new(vec![value_slot]).expect("storage should be valid"); + + let result = NetworkAccountTxScriptAllowlist::try_from(&storage); + assert!(matches!(result, Err(NetworkAccountTxScriptAllowlistError::UnexpectedSlotType))); + } +} diff --git a/crates/miden-standards/src/account/auth/singlesig_acl.rs b/crates/miden-standards/src/account/auth/singlesig_acl.rs index 884b66ee73..af3c22ecdf 100644 --- a/crates/miden-standards/src/account/auth/singlesig_acl.rs +++ b/crates/miden-standards/src/account/auth/singlesig_acl.rs @@ -408,7 +408,7 @@ mod tests { .storage() .get_map_item( AuthSingleSigAcl::trigger_procedure_roots_slot(), - Word::from([i as u32, 0, 0, 0]), + StorageMapKey::from_index(i as u32), ) .expect("storage map access failed"); assert_eq!(proc_root, expected_proc_root.as_word()); @@ -417,7 +417,10 @@ mod tests { // When no procedures, the map should return empty for key [0,0,0,0] let proc_root = account .storage() - .get_map_item(AuthSingleSigAcl::trigger_procedure_roots_slot(), Word::empty()) + .get_map_item( + AuthSingleSigAcl::trigger_procedure_roots_slot(), + StorageMapKey::empty(), + ) .expect("storage map access failed"); assert_eq!(proc_root, Word::empty()); } diff --git a/crates/miden-standards/src/account/faucets/fungible/mod.rs b/crates/miden-standards/src/account/faucets/fungible/mod.rs index 9dd256bc2a..ea9fd84809 100644 --- a/crates/miden-standards/src/account/faucets/fungible/mod.rs +++ b/crates/miden-standards/src/account/faucets/fungible/mod.rs @@ -11,6 +11,7 @@ use miden_protocol::account::component::{ use miden_protocol::account::{ Account, AccountBuilder, + AccountCodeInterface, AccountComponent, AccountComponentName, AccountProcedureRoot, @@ -32,12 +33,12 @@ use super::{ TokenMetadataError, TokenName, }; -use crate::account::access::{AccessControl, PausableManager}; +use crate::account::access::{AccessControl, Authority, Pausable, PausableManager}; use crate::account::account_component_code; -use crate::account::auth::{AuthNetworkAccount, AuthSingleSigAcl, AuthSingleSigAclConfig, NoAuth}; -use crate::account::interface::{AccountComponentInterface, AccountInterface, AccountInterfaceExt}; +use crate::account::auth::{AuthNetworkAccount, AuthSingleSigAcl}; use crate::account::policies::TokenPolicyManager; -use crate::{AuthMethod, procedure_root}; +use crate::note::{BurnNote, MintNote}; +use crate::procedure_root; #[cfg(test)] mod tests; @@ -283,22 +284,25 @@ impl FungibleFaucet { *FUNGIBLE_FAUCET_RECEIVE_AND_BURN } - /// Returns the procedure root of the `set_max_supply` account procedure. + /// Returns the procedure root of the `set_max_supply` account procedure. This is an + /// authority-gated setter; when paired with `Authority::AuthControlled` (via + /// [`create_user_fungible_faucet`]) it must appear in the auth component's trigger + /// procedure list. pub fn set_max_supply_root() -> AccountProcedureRoot { *FUNGIBLE_FAUCET_SET_MAX_SUPPLY } - /// Returns the procedure root of the `set_description` account procedure. + /// Returns the procedure root of the `set_description` account procedure. Authority-gated. pub fn set_description_root() -> AccountProcedureRoot { *FUNGIBLE_FAUCET_SET_DESCRIPTION } - /// Returns the procedure root of the `set_logo_uri` account procedure. + /// Returns the procedure root of the `set_logo_uri` account procedure. Authority-gated. pub fn set_logo_uri_root() -> AccountProcedureRoot { *FUNGIBLE_FAUCET_SET_LOGO_URI } - /// Returns the procedure root of the `set_external_link` account procedure. + /// Returns the procedure root of the `set_external_link` account procedure. Authority-gated. pub fn set_external_link_root() -> AccountProcedureRoot { *FUNGIBLE_FAUCET_SET_EXTERNAL_LINK } @@ -382,19 +386,11 @@ impl FungibleFaucet { } /// Returns the storage slots produced by this faucet (token config word + name + mutability - /// config + description + logo URI + external link + Pausable's `is_paused` flag). - /// - /// The `is_paused` slot is installed by FungibleFaucet itself (initial value: unpaused, zero - /// word) so that the transversal pause guards baked into `execute_mint_policy`, - /// `execute_burn_policy`, `check_policy` (allow_all / blocklist / allowlist) and the metadata - /// setters can read it without panicking. Pause / unpause administration is exposed by the - /// [`crate::account::access::pausable::PausableManager`] component, which is bundled by - /// [`create_fungible_faucet`] alongside this faucet so the slot is always actionable. + /// config + description + logo URI + external link). pub fn into_storage_slots(self) -> Vec { let mut slots: Vec = Vec::new(); slots.push(self.token_config_slot_value()); slots.extend(self.metadata.into_storage_slots()); - slots.push(crate::account::access::pausable::PausableStorage::default().into_slot()); slots } @@ -463,10 +459,10 @@ impl FungibleFaucet { /// Checks that the account contains the fungible faucet interface. fn try_from_interface( - interface: AccountInterface, + interface: AccountCodeInterface, storage: &AccountStorage, ) -> Result { - if !interface.components().contains(&AccountComponentInterface::FungibleFaucet) { + if !interface.contains(FungibleFaucet::code().procedure_roots()) { return Err(FungibleFaucetError::MissingFungibleFaucetInterface); } @@ -541,9 +537,7 @@ impl TryFrom for FungibleFaucet { type Error = FungibleFaucetError; fn try_from(account: Account) -> Result { - let account_interface = AccountInterface::from_account(&account); - - FungibleFaucet::try_from_interface(account_interface, account.storage()) + FungibleFaucet::try_from_interface(account.code_interface(), account.storage()) } } @@ -551,157 +545,70 @@ impl TryFrom<&Account> for FungibleFaucet { type Error = FungibleFaucetError; fn try_from(account: &Account) -> Result { - let account_interface = AccountInterface::from_account(account); - - FungibleFaucet::try_from_interface(account_interface, account.storage()) + FungibleFaucet::try_from_interface(account.code_interface(), account.storage()) } } // FACTORY // ================================================================================================ -/// Every authority-gated procedure root that must require a signature when -/// [`AccessControl::AuthControlled`] is paired with [`AuthMethod::SingleSig`]. Includes -/// `mint_and_send` so that minting always requires a signature regardless of the access -/// control variant. -fn all_authority_gated_setter_roots() -> Vec { - vec![ - FungibleFaucet::mint_and_send_root(), - FungibleFaucet::set_max_supply_root(), - FungibleFaucet::set_description_root(), - FungibleFaucet::set_logo_uri_root(), - FungibleFaucet::set_external_link_root(), - TokenPolicyManager::set_mint_policy_root(), - TokenPolicyManager::set_burn_policy_root(), - TokenPolicyManager::set_send_policy_root(), - TokenPolicyManager::set_receive_policy_root(), - PausableManager::pause_root(), - PausableManager::unpause_root(), - ] +/// Creates a new **user-account** fungible faucet. The account's auth component is the sole +/// gate for authority-protected setters ([`Authority::AuthControlled`] is installed directly). +/// +/// Caller passes a fully-configured [`AuthSingleSigAcl`] — its trigger procedure list must +/// cover every authority-gated setter on the faucet (`mint_and_send`, the metadata setters, +/// the policy setters, and `pause` / `unpause`), otherwise those procedures become +/// permissionless under [`Authority::AuthControlled`]. +pub fn create_user_fungible_faucet( + init_seed: [u8; 32], + faucet: FungibleFaucet, + auth_component: AuthSingleSigAcl, + token_policy_manager: TokenPolicyManager, + account_type: AccountType, +) -> Result { + AccountBuilder::new(init_seed) + .account_type(account_type) + .with_auth_component(auth_component) + .with_component(faucet) + .with_component(Authority::AuthControlled) + .with_components(token_policy_manager) + .with_component(Pausable::unpaused()) + .with_component(PausableManager) + .build() + .map_err(FungibleFaucetError::AccountError) } -/// Creates a new fungible faucet account by composing the required components. -/// -/// In addition to the explicit parameters, [`PausableManager`] is always bundled so the -/// `is_paused` slot installed by [`FungibleFaucet::into_storage_slots`] is actionable via -/// `pause` / `unpause` admin procedures (gated by the same `Authority` component installed by -/// `access_control`). +/// Creates a new **network-style** fungible faucet. The account is always +/// [`AccountType::Public`] (network accounts cannot be private). Setter gating is enforced +/// in-procedure by the owner / role check installed via `access_control` +/// ([`AccessControl::Ownable2Step`] or [`AccessControl::Rbac`]). /// -/// Only specific `(access_control, auth_method)` combinations are supported; everything else -/// is rejected at the factory level. The valid combinations are: +/// The factory builds the [`AuthNetworkAccount`] auth component internally with a note +/// allowlist covering the faucet's own [`MintNote`] and [`BurnNote`] scripts and an empty +/// tx-script allowlist (network faucets are consumed via notes, not tx scripts). Callers +/// that need a custom allowlist (additional note scripts or tx scripts) should use +/// [`AccountBuilder`] directly. /// -/// - [`AccessControl::AuthControlled`] + [`AuthMethod::SingleSig`] — user-account faucet whose auth -/// component is the sole gate for every authority-protected setter. -/// - [`AccessControl::Ownable2Step`] / [`AccessControl::Rbac`] + [`AuthMethod::NetworkAccount`] or -/// [`AuthMethod::NoAuth`] — network-style faucet whose setter gate is enforced in-procedure by -/// the owner/role check. -/// -/// All other pairings return a typed error: -/// [`FungibleFaucetError::IncompatibleAuthControlledAuth`] for `AuthControlled + NoAuth`, and -/// [`FungibleFaucetError::UnsupportedAccessControlAuthCombination`] for `AuthControlled + -/// NetworkAccount` and for `Ownable2Step`/`Rbac` + `SingleSig`. `Multisig` and `Unknown` -/// remain rejected for every variant via [`FungibleFaucetError::UnsupportedAuthMethod`]. -pub fn create_fungible_faucet( +/// In addition to the explicit parameters, [`Pausable`] (slot + `is_paused` view) and +/// [`PausableManager`] (admin `pause` / `unpause` gated by `access_control`) are bundled. +pub fn create_network_fungible_faucet( init_seed: [u8; 32], faucet: FungibleFaucet, - account_type: AccountType, - auth_method: AuthMethod, access_control: AccessControl, token_policy_manager: TokenPolicyManager, ) -> Result { - let auth_component = build_auth_component(&access_control, auth_method)?; + let note_allowlist = [MintNote::script_root(), BurnNote::script_root()].into_iter().collect(); + let auth_component = AuthNetworkAccount::with_allowed_notes(note_allowlist) + .expect("MintNote + BurnNote allowlist is non-empty"); - let account = AccountBuilder::new(init_seed) - .account_type(account_type) + AccountBuilder::new(init_seed) + .account_type(AccountType::Public) .with_auth_component(auth_component) .with_component(faucet) .with_components(access_control) .with_components(token_policy_manager) + .with_component(Pausable::unpaused()) .with_component(PausableManager) .build() - .map_err(FungibleFaucetError::AccountError)?; - - Ok(account) -} - -/// Builds the account-level auth component, validating the `(access_control, auth_method)` -/// pair. See [`create_fungible_faucet`] for the list of supported combinations. -fn build_auth_component( - access_control: &AccessControl, - auth_method: AuthMethod, -) -> Result { - match (access_control, auth_method) { - // AuthControlled + SingleSig: the auth component is the sole setter gate, so it - // must authenticate every authority-gated setter root. - ( - AccessControl::AuthControlled, - AuthMethod::SingleSig { approver: (pub_key, auth_scheme) }, - ) => Ok(AuthSingleSigAcl::new( - pub_key, - auth_scheme, - AuthSingleSigAclConfig::new() - .with_auth_trigger_procedures(all_authority_gated_setter_roots()) - .with_allow_unauthorized_input_notes(true), - ) - .map_err(FungibleFaucetError::AccountError)? - .into()), - - // AuthControlled + NetworkAccount: rejected. - (AccessControl::AuthControlled, AuthMethod::NetworkAccount { .. }) => { - Err(FungibleFaucetError::UnsupportedAccessControlAuthCombination( - "NetworkAccount is only supported with AccessControl::Ownable2Step or \ - AccessControl::Rbac (network-style faucets)" - .into(), - )) - }, - - // AuthControlled + NoAuth: rejected. NoAuth cannot authenticate setters; under - // AuthControlled the auth component is the sole gate, so this would leave every - // authority-gated setter permissionless. - (AccessControl::AuthControlled, AuthMethod::NoAuth) => { - Err(FungibleFaucetError::IncompatibleAuthControlledAuth( - "NoAuth cannot authenticate authority-gated setters".into(), - )) - }, - - // Ownable2Step / Rbac + NetworkAccount: typical network-style faucet. Setter gating - // is enforced in-procedure; the auth component restricts which note scripts can be - // consumed against the faucet. - ( - AccessControl::Ownable2Step { .. } | AccessControl::Rbac { .. }, - AuthMethod::NetworkAccount { allowed_script_roots }, - ) => Ok(AuthNetworkAccount::with_allowlist(allowed_script_roots) - .map_err(|err| { - FungibleFaucetError::UnsupportedAuthMethod(alloc::format!( - "invalid network account allowlist: {err}" - )) - })? - .into()), - - // Ownable2Step / Rbac + NoAuth: valid; the setter gate is the in-procedure owner / - // role check, so the account-level auth can legitimately be NoAuth. - (AccessControl::Ownable2Step { .. } | AccessControl::Rbac { .. }, AuthMethod::NoAuth) => { - Ok(NoAuth::new().into()) - }, - - // Ownable2Step / Rbac + SingleSig: rejected. SingleSig is for user-account faucets - // (AuthControlled); under owner/role-gated faucets it duplicates the setter check - // with a per-tx signature that doesn't add security. - ( - AccessControl::Ownable2Step { .. } | AccessControl::Rbac { .. }, - AuthMethod::SingleSig { .. }, - ) => Err(FungibleFaucetError::UnsupportedAccessControlAuthCombination( - "SingleSig is only supported with AccessControl::AuthControlled; pair \ - Ownable2Step / Rbac with NetworkAccount or NoAuth instead" - .into(), - )), - - // Multisig and Unknown are not supported for any access control variant. - (_, AuthMethod::Multisig { .. }) => Err(FungibleFaucetError::UnsupportedAuthMethod( - "fungible faucets do not support Multisig authentication".into(), - )), - (_, AuthMethod::Unknown) => Err(FungibleFaucetError::UnsupportedAuthMethod( - "fungible faucets cannot be created with Unknown authentication method".into(), - )), - } + .map_err(FungibleFaucetError::AccountError) } diff --git a/crates/miden-standards/src/account/faucets/fungible/tests.rs b/crates/miden-standards/src/account/faucets/fungible/tests.rs index 96c3174aea..460f634852 100644 --- a/crates/miden-standards/src/account/faucets/fungible/tests.rs +++ b/crates/miden-standards/src/account/faucets/fungible/tests.rs @@ -2,35 +2,26 @@ use alloc::collections::BTreeSet; use assert_matches::assert_matches; use miden_protocol::account::auth::{AuthScheme, PublicKeyCommitment}; -use miden_protocol::account::{AccountBuilder, AccountType}; +use miden_protocol::account::{AccountBuilder, AccountType, StorageMapKey}; use miden_protocol::asset::{AssetAmount, TokenSymbol}; use miden_protocol::{Felt, Word}; -use super::{FungibleFaucet, create_fungible_faucet}; -use crate::AuthMethod; +use super::{FungibleFaucet, create_network_fungible_faucet, create_user_fungible_faucet}; use crate::account::access::{AccessControl, PausableManager}; use crate::account::auth::{AuthSingleSig, AuthSingleSigAcl}; use crate::account::faucets::{Description, FungibleFaucetError, TokenMetadata, TokenName}; -use crate::account::policies::{ - BurnPolicyConfig, - MintPolicyConfig, - PolicyRegistration, - TokenPolicyManager, - TransferPolicy, -}; +use crate::account::policies::{BurnPolicy, MintPolicy, TokenPolicyManager, TransferPolicy}; use crate::account::wallets::BasicWallet; +use crate::testing::faucet::user_faucet_single_sig_acl; /// Builds a minimal policy manager with AllowAll on every kind, used by the construction tests. fn allow_all_policy_manager() -> TokenPolicyManager { - TokenPolicyManager::new() - .with_mint_policy(MintPolicyConfig::AllowAll, PolicyRegistration::Active) - .unwrap() - .with_burn_policy(BurnPolicyConfig::AllowAll, PolicyRegistration::Active) - .unwrap() - .with_send_policy(TransferPolicy::AllowAll, PolicyRegistration::Active) - .unwrap() - .with_receive_policy(TransferPolicy::AllowAll, PolicyRegistration::Active) - .unwrap() + TokenPolicyManager::builder() + .active_mint_policy(MintPolicy::allow_all()) + .active_burn_policy(BurnPolicy::allow_all()) + .active_send_policy(TransferPolicy::allow_all()) + .active_receive_policy(TransferPolicy::allow_all()) + .build() } /// Builds a sample `FungibleFaucet` shared by construction tests. @@ -56,7 +47,7 @@ fn read_trigger_procedure_roots( .storage() .get_map_item( AuthSingleSigAcl::trigger_procedure_roots_slot(), - [Felt::from(i), Felt::ZERO, Felt::ZERO, Felt::ZERO].into(), + StorageMapKey::from_index(i), ) .unwrap() }) @@ -64,13 +55,8 @@ fn read_trigger_procedure_roots( } #[test] -fn faucet_contract_creation() { +fn user_fungible_faucet_with_single_sig_acl() { let pub_key_word = Word::new([Felt::ONE; 4]); - let auth_method = AuthMethod::SingleSig { - approver: (pub_key_word.into(), AuthScheme::Falcon512Poseidon2), - }; - - // we need to use an initial seed to create the wallet account let init_seed: [u8; 32] = [ 90, 110, 209, 94, 84, 105, 250, 242, 223, 203, 216, 124, 22, 159, 14, 132, 215, 85, 183, 204, 149, 90, 166, 68, 100, 73, 106, 168, 125, 237, 138, 16, @@ -81,35 +67,31 @@ fn faucet_contract_creation() { let token_name_string = "polygon"; let description_string = "A polygon token"; - let faucet = sample_faucet(); - let faucet_account = create_fungible_faucet( + let auth_component = + user_faucet_single_sig_acl(pub_key_word.into(), AuthScheme::Falcon512Poseidon2).unwrap(); + + let faucet_account = create_user_fungible_faucet( init_seed, - faucet, - AccountType::Private, - auth_method, - AccessControl::AuthControlled, + sample_faucet(), + auth_component, allow_all_policy_manager(), + AccountType::Private, ) .unwrap(); - // The falcon auth component's public key should be present. + // The auth component's public key should be present. assert_eq!( faucet_account.storage().get_item(AuthSingleSigAcl::public_key_slot()).unwrap(), pub_key_word ); - // The config slot of the auth component stores: - // [num_trigger_procs, allow_unauthorized_output_notes, allow_unauthorized_input_notes, 0]. - // - // With 11 authority-gated trigger procedures (mint_and_send + 4 token metadata setters + - // 4 policy setters + pause + unpause), allow_unauthorized_output_notes=false, and - // allow_unauthorized_input_notes=true, this should be [11, 0, 1, 0]. + // Config slot: 11 trigger procedures (mint_and_send + 4 token metadata setters + 4 policy + // setters + pause + unpause), allow_unauthorized_input_notes=true → [11, 0, 1, 0]. assert_eq!( faucet_account.storage().get_item(AuthSingleSigAcl::config_slot()).unwrap(), [Felt::from(11_u32), Felt::ZERO, Felt::ONE, Felt::ZERO].into() ); - // The trigger procedure root map should contain every authority-gated setter root. let stored_roots = read_trigger_procedure_roots(&faucet_account, 11); let expected_roots: BTreeSet = [ FungibleFaucet::mint_and_send_root(), @@ -129,14 +111,12 @@ fn faucet_contract_creation() { .collect(); assert_eq!(stored_roots, expected_roots); - // Check that faucet metadata was initialized to the given values. - // Storage layout: [token_supply, max_supply, decimals, symbol] + // Token config slot layout: [token_supply, max_supply, decimals, symbol] assert_eq!( faucet_account.storage().get_item(FungibleFaucet::token_config_slot()).unwrap(), [Felt::ZERO, Felt::from(123_u32), Felt::from(2_u32), token_symbol.into()].into() ); - // Check that name was stored let name_0 = faucet_account.storage().get_item(TokenMetadata::name_chunk_0_slot()).unwrap(); let name_1 = faucet_account.storage().get_item(TokenMetadata::name_chunk_1_slot()).unwrap(); let decoded_name = TokenName::try_from_words(&[name_0, name_1]).unwrap(); @@ -147,74 +127,14 @@ fn faucet_contract_creation() { assert_eq!(chunk, *expected); } - // Verify the faucet component can be extracted let _faucet_component = FungibleFaucet::try_from(faucet_account.clone()).unwrap(); } +/// `create_network_fungible_faucet` with `Ownable2Step` builds a valid account. The factory +/// constructs `AuthNetworkAccount` internally; the setter gate is enforced in-procedure by +/// `assert_sender_is_owner`. #[test] -fn auth_controlled_rejects_no_auth() { - let err = create_fungible_faucet( - [7u8; 32], - sample_faucet(), - AccountType::Private, - AuthMethod::NoAuth, - AccessControl::AuthControlled, - allow_all_policy_manager(), - ) - .expect_err("AuthControlled+NoAuth should be rejected"); - assert_matches!(err, FungibleFaucetError::IncompatibleAuthControlledAuth(_)); -} - -/// `(Ownable2Step / Rbac, SingleSig)` must be rejected: SingleSig is intended for -/// user-account faucets gated by `AuthControlled`; under owner/role-gated faucets it -/// duplicates the setter check with a per-tx signature that doesn't add security. -#[test] -fn ownable2step_rejects_single_sig() { - use miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE; - - let owner = miden_protocol::account::AccountId::try_from( - ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE, - ) - .unwrap(); - let auth_method = AuthMethod::SingleSig { - approver: (Word::new([Felt::ONE; 4]).into(), AuthScheme::Falcon512Poseidon2), - }; - - let err = create_fungible_faucet( - [7u8; 32], - sample_faucet(), - AccountType::Public, - auth_method, - AccessControl::Ownable2Step { owner }, - allow_all_policy_manager(), - ) - .expect_err("Ownable2Step+SingleSig should be rejected"); - assert_matches!(err, FungibleFaucetError::UnsupportedAccessControlAuthCombination(_)); -} - -/// `(AuthControlled, NetworkAccount)` must be rejected: `NetworkAccount` is the auth scheme -/// for network-style faucets, which pair with owner / role-based setter gating -/// (`Ownable2Step` / `Rbac`), not the auth-component-as-gate model of `AuthControlled`. -#[test] -fn auth_controlled_rejects_network_account() { - use alloc::collections::BTreeSet; - - let allowed_script_roots: BTreeSet = BTreeSet::new(); - - let err = create_fungible_faucet( - [7u8; 32], - sample_faucet(), - AccountType::Private, - AuthMethod::NetworkAccount { allowed_script_roots }, - AccessControl::AuthControlled, - allow_all_policy_manager(), - ) - .expect_err("AuthControlled+NetworkAccount should be rejected"); - assert_matches!(err, FungibleFaucetError::UnsupportedAccessControlAuthCombination(_)); -} - -#[test] -fn ownable2step_with_no_auth_is_accepted() { +fn network_fungible_faucet_with_ownable2step() { use miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE; let owner = miden_protocol::account::AccountId::try_from( @@ -222,25 +142,21 @@ fn ownable2step_with_no_auth_is_accepted() { ) .unwrap(); - let _account = create_fungible_faucet( + let _account = create_network_fungible_faucet( [7u8; 32], sample_faucet(), - AccountType::Public, - AuthMethod::NoAuth, AccessControl::Ownable2Step { owner }, allow_all_policy_manager(), ) - .expect("Ownable2Step+NoAuth should be accepted"); + .expect("Ownable2Step network faucet should be accepted"); } #[test] fn faucet_create_from_account() { - // prepare the test data let mock_word = Word::from([0, 1, 2, 3u32]); let mock_public_key = PublicKeyCommitment::from(mock_word); let mock_seed = mock_word.as_bytes(); - // valid account let token_symbol = TokenSymbol::new("POL").expect("invalid token symbol"); let faucet = FungibleFaucet::builder() .name(TokenName::new("POL").unwrap()) @@ -262,7 +178,6 @@ fn faucet_create_from_account() { // invalid account: fungible faucet component is missing let invalid_faucet_account = AccountBuilder::new(mock_seed) .with_auth_component(AuthSingleSig::new(mock_public_key, AuthScheme::Falcon512Poseidon2)) - // we need to add some other component so the builder doesn't fail .with_component(BasicWallet) .build_existing() .expect("failed to create wallet account"); diff --git a/crates/miden-standards/src/account/faucets/mod.rs b/crates/miden-standards/src/account/faucets/mod.rs index 4e8294ded5..d2e3aa7b1d 100644 --- a/crates/miden-standards/src/account/faucets/mod.rs +++ b/crates/miden-standards/src/account/faucets/mod.rs @@ -1,5 +1,3 @@ -use alloc::string::String; - use miden_protocol::account::StorageSlotName; use miden_protocol::errors::{AccountError, TokenSymbolError}; use thiserror::Error; @@ -10,7 +8,12 @@ use crate::utils::FixedWidthStringError; mod fungible; mod token_metadata; -pub use fungible::{FungibleFaucet, FungibleFaucetBuilder, create_fungible_faucet}; +pub use fungible::{ + FungibleFaucet, + FungibleFaucetBuilder, + create_network_fungible_faucet, + create_user_fungible_faucet, +}; pub use token_metadata::{Description, ExternalLink, LogoURI, TokenMetadata, TokenName}; // TOKEN METADATA ERROR @@ -57,12 +60,6 @@ pub enum FungibleFaucetError { "account interface does not have the procedures of the basic fungible faucet component" )] MissingFungibleFaucetInterface, - #[error("unsupported authentication method: {0}")] - UnsupportedAuthMethod(String), - #[error("AccessControl::AuthControlled is incompatible with the chosen auth method: {0}")] - IncompatibleAuthControlledAuth(String), - #[error("unsupported combination of AccessControl and AuthMethod: {0}")] - UnsupportedAccessControlAuthCombination(String), #[error("account creation failed")] AccountError(#[source] AccountError), #[error("account is not a fungible faucet account")] diff --git a/crates/miden-standards/src/account/interface/component.rs b/crates/miden-standards/src/account/interface/component.rs index a386fa0c0c..18b8179219 100644 --- a/crates/miden-standards/src/account/interface/component.rs +++ b/crates/miden-standards/src/account/interface/component.rs @@ -1,21 +1,7 @@ use alloc::string::{String, ToString}; use alloc::vec::Vec; -use miden_protocol::account::auth::{AuthScheme, PublicKeyCommitment}; -use miden_protocol::account::{AccountId, AccountProcedureRoot, AccountStorage, StorageSlotName}; -use miden_protocol::note::PartialNote; -use miden_protocol::{Felt, Word}; - -use crate::AuthMethod; -use crate::account::auth::{ - AuthGuardedMultisig, - AuthMultisig, - AuthMultisigSmart, - AuthSingleSig, - AuthSingleSigAcl, - NetworkAccountNoteAllowlist, -}; -use crate::account::interface::AccountInterfaceError; +use miden_protocol::account::AccountProcedureRoot; // ACCOUNT COMPONENT INTERFACE // ================================================================================================ @@ -120,300 +106,4 @@ impl AccountComponentInterface { | AccountComponentInterface::AuthNetworkAccount ) } - - /// Returns the authentication schemes associated with this component interface. - pub fn get_auth_methods(&self, storage: &AccountStorage) -> Vec { - match self { - AccountComponentInterface::AuthSingleSig => vec![extract_singlesig_auth_method( - storage, - AuthSingleSig::public_key_slot(), - AuthSingleSig::scheme_id_slot(), - )], - AccountComponentInterface::AuthSingleSigAcl => vec![extract_singlesig_auth_method( - storage, - AuthSingleSigAcl::public_key_slot(), - AuthSingleSigAcl::scheme_id_slot(), - )], - AccountComponentInterface::AuthMultisig => { - vec![extract_multisig_auth_method( - storage, - AuthMultisig::threshold_config_slot(), - AuthMultisig::approver_public_keys_slot(), - AuthMultisig::approver_scheme_ids_slot(), - )] - }, - AccountComponentInterface::AuthGuardedMultisig => { - vec![extract_multisig_auth_method( - storage, - AuthGuardedMultisig::threshold_config_slot(), - AuthGuardedMultisig::approver_public_keys_slot(), - AuthGuardedMultisig::approver_scheme_ids_slot(), - )] - }, - AccountComponentInterface::AuthMultisigSmart => { - vec![extract_multisig_auth_method( - storage, - AuthMultisigSmart::threshold_config_slot(), - AuthMultisigSmart::approver_public_keys_slot(), - AuthMultisigSmart::approver_scheme_ids_slot(), - )] - }, - AccountComponentInterface::AuthNoAuth => vec![AuthMethod::NoAuth], - AccountComponentInterface::AuthNetworkAccount => { - vec![extract_network_account_auth_method(storage)] - }, - _ => vec![], // Non-auth components return empty vector - } - } - - /// Generates a body for the note creation of the `send_note` transaction script. The resulting - /// code could use different procedures for note creation, which depends on the used interface. - /// - /// The body consists of two sections: - /// - Pushing the note information on the stack. - /// - Creating a note: - /// - For basic fungible faucet: pushing the amount of assets and distributing them. - /// - For basic wallet: creating a note, pushing the assets on the stack and moving them to - /// the created note. - /// - /// # Examples - /// - /// Example script for the [`AccountComponentInterface::BasicWallet`] with one note: - /// - /// ```masm - /// push.{note_information} - /// call.::miden::protocol::output_note::create - /// - /// push.{note asset} - /// call.::miden::standards::wallets::basic::move_asset_to_note dropw - /// dropw dropw dropw drop - /// ``` - /// - /// Example script for the [`AccountComponentInterface::FungibleFaucet`] with one note: - /// - /// ```masm - /// push.{note information} - /// - /// push.{ASSET_VALUE} push.{ASSET_KEY} - /// call.::miden::standards::faucets::fungible::mint_and_send - /// swapdw dropw dropw swapdw dropw dropw - /// ``` - /// - /// # Errors: - /// Returns an error if: - /// - the interface does not support the generation of the standard `send_note` procedure. - /// - the sender of the note isn't the account for which the script is being built. - /// - the note created by the faucet doesn't contain exactly one asset. - /// - a faucet tries to mint an asset with a different faucet ID. - pub(crate) fn send_note_body( - &self, - sender_account_id: AccountId, - notes: &[PartialNote], - ) -> Result { - let mut body = String::new(); - - for partial_note in notes { - if partial_note.metadata().sender() != sender_account_id { - return Err(AccountInterfaceError::InvalidSenderAccount( - partial_note.metadata().sender(), - )); - } - - body.push_str(&format!( - " - push.{recipient} - push.{note_type} - push.{tag} - # => [tag, note_type, RECIPIENT, pad(16)] - ", - recipient = partial_note.recipient_digest(), - note_type = Felt::from(partial_note.metadata().note_type()), - tag = Felt::from(partial_note.metadata().tag()), - )); - - match self { - AccountComponentInterface::FungibleFaucet => { - if partial_note.assets().num_assets() != 1 { - return Err(AccountInterfaceError::FaucetNoteWithoutAsset); - } - - // SAFETY: We checked that the note contains exactly one asset - let asset = - partial_note.assets().iter().next().expect("note should contain an asset"); - - if asset.faucet_id() != sender_account_id { - return Err(AccountInterfaceError::IssuanceFaucetMismatch( - asset.faucet_id(), - )); - } - - body.push_str(&format!( - " - push.{ASSET_VALUE} - push.{ASSET_KEY} - # => [ASSET_KEY, ASSET_VALUE, tag, note_type, RECIPIENT, pad(16)] - - call.::miden::standards::faucets::fungible::mint_and_send - # => [note_idx, pad(29)] - - swapdw dropw dropw swapdw dropw dropw - # => [note_idx, pad(13)]\n - ", - ASSET_KEY = asset.to_key_word(), - ASSET_VALUE = asset.to_value_word(), - )); - }, - AccountComponentInterface::BasicWallet => { - body.push_str( - " - exec.::miden::protocol::output_note::create - # => [note_idx, pad(16)]\n - ", - ); - - for asset in partial_note.assets().iter() { - body.push_str(&format!( - " - # duplicate note index - padw push.0 push.0 push.0 dup.7 - # => [note_idx, pad(7), note_idx, pad(16)] - - push.{ASSET_VALUE} - push.{ASSET_KEY} - # => [ASSET_KEY, ASSET_VALUE, note_idx, pad(7), note_idx, pad(16)] - - call.::miden::standards::wallets::basic::move_asset_to_note - # => [pad(16), note_idx, pad(16)] - - dropw dropw dropw dropw - # => [note_idx, pad(16)]\n - ", - ASSET_KEY = asset.to_key_word(), - ASSET_VALUE = asset.to_value_word(), - )); - } - }, - _ => { - return Err(AccountInterfaceError::UnsupportedInterface { - interface: self.clone(), - }); - }, - } - - for attachment in partial_note.attachments().iter() { - let attachment_scheme = attachment.attachment_scheme().as_u16(); - let attachment_commitment = attachment.content().to_commitment(); - - body.push_str(&format!( - " - dup - push.{attachment_commitment} - push.{attachment_scheme} - # => [attachment_scheme, ATTACHMENT_COMMITMENT, note_idx, note_idx, pad(16)] - exec.::miden::protocol::output_note::add_attachment - # => [note_idx, pad(16)] - ", - )); - } - - body.push_str( - " - # drop the note idx - drop - # => [pad(16)] - ", - ); - } - - Ok(body) - } -} - -// HELPER FUNCTIONS -// ================================================================================================ - -/// Extracts authentication method from a single-signature component. -fn extract_singlesig_auth_method( - storage: &AccountStorage, - public_key_slot: &StorageSlotName, - scheme_id_slot: &StorageSlotName, -) -> AuthMethod { - let pub_key = PublicKeyCommitment::from( - storage - .get_item(public_key_slot) - .expect("invalid storage index of the public key"), - ); - - let scheme_id = storage - .get_item(scheme_id_slot) - .expect("invalid storage index of the scheme id")[0] - .as_canonical_u64() as u8; - - let auth_scheme = - AuthScheme::try_from(scheme_id).expect("invalid auth scheme id in the scheme id slot"); - - AuthMethod::SingleSig { approver: (pub_key, auth_scheme) } -} - -/// Extracts authentication method from a multisig component. -fn extract_multisig_auth_method( - storage: &AccountStorage, - config_slot: &StorageSlotName, - approver_public_keys_slot: &StorageSlotName, - approver_scheme_ids_slot: &StorageSlotName, -) -> AuthMethod { - // Read the multisig configuration from the config slot - // Format: [threshold, num_approvers, 0, 0] - let config = storage - .get_item(config_slot) - .expect("invalid slot name of the multisig configuration"); - - let threshold = config[0].as_canonical_u64() as u32; - let num_approvers = config[1].as_canonical_u64() as u8; - - let mut approvers = Vec::new(); - - // Read each public key from the map - for key_index in 0..num_approvers { - // The multisig component stores keys and scheme IDs using pattern [index, 0, 0, 0] - let map_key = Word::from([key_index as u32, 0, 0, 0]); - - let pub_key_word = - storage.get_map_item(approver_public_keys_slot, map_key).unwrap_or_else(|_| { - panic!( - "Failed to read public key {} from multisig configuration at storage slot {}. \ - Expected key pattern [index, 0, 0, 0].", - key_index, approver_public_keys_slot - ) - }); - - let pub_key = PublicKeyCommitment::from(pub_key_word); - - let scheme_word = storage - .get_map_item(approver_scheme_ids_slot, map_key) - .unwrap_or_else(|_| { - panic!( - "Failed to read scheme id for approver {} from multisig configuration at storage slot {}. \ - Expected key pattern [index, 0, 0, 0].", - key_index, approver_scheme_ids_slot - ) - }); - - let scheme_id = scheme_word[0].as_canonical_u64() as u8; - let auth_scheme = - AuthScheme::try_from(scheme_id).expect("invalid auth scheme id in the scheme id slot"); - approvers.push((pub_key, auth_scheme)); - } - - AuthMethod::Multisig { threshold, approvers } -} - -/// Extracts authentication method from a network-account component. -fn extract_network_account_auth_method(storage: &AccountStorage) -> AuthMethod { - let allowlist = NetworkAccountNoteAllowlist::try_from(storage) - .expect("network account allowlist slot should be present and valid"); - - AuthMethod::NetworkAccount { - allowed_script_roots: allowlist.into_allowed_script_roots(), - } } diff --git a/crates/miden-standards/src/account/interface/extension.rs b/crates/miden-standards/src/account/interface/extension.rs index 0d3c04b734..aab4ea694e 100644 --- a/crates/miden-standards/src/account/interface/extension.rs +++ b/crates/miden-standards/src/account/interface/extension.rs @@ -3,7 +3,6 @@ use alloc::vec::Vec; use miden_protocol::account::{Account, AccountCode, AccountId, AccountProcedureRoot}; -use crate::AuthMethod; use crate::account::components::StandardAccountComponent; use crate::account::interface::{AccountComponentInterface, AccountInterface}; @@ -12,35 +11,22 @@ use crate::account::interface::{AccountComponentInterface, AccountInterface}; /// An extension for [`AccountInterface`] that allows instantiation from higher-level types. pub trait AccountInterfaceExt { - /// Creates a new [`AccountInterface`] instance from the provided account ID, authentication - /// methods and account code. - fn from_code(account_id: AccountId, auth: Vec, code: &AccountCode) -> Self; + /// Creates a new [`AccountInterface`] instance from the provided account ID and account code. + fn from_code(account_id: AccountId, code: &AccountCode) -> Self; /// Creates a new [`AccountInterface`] instance from the provided [`Account`]. fn from_account(account: &Account) -> Self; } impl AccountInterfaceExt for AccountInterface { - fn from_code(account_id: AccountId, auth: Vec, code: &AccountCode) -> Self { + fn from_code(account_id: AccountId, code: &AccountCode) -> Self { let components = AccountComponentInterface::from_procedures(code.procedures()); - - Self::new(account_id, auth, components) + Self::new(account_id, components) } fn from_account(account: &Account) -> Self { let components = AccountComponentInterface::from_procedures(account.code().procedures()); - let mut auth = Vec::new(); - - // Find the auth component and extract all auth methods from it - // An account should have only one auth component - for component in components.iter() { - if component.is_auth_component() { - auth = component.get_auth_methods(account.storage()); - break; - } - } - - Self::new(account.id(), auth, components) + Self::new(account.id(), components) } } diff --git a/crates/miden-standards/src/account/interface/mod.rs b/crates/miden-standards/src/account/interface/mod.rs index 50a27e2b0c..fbcd73d9a0 100644 --- a/crates/miden-standards/src/account/interface/mod.rs +++ b/crates/miden-standards/src/account/interface/mod.rs @@ -1,14 +1,6 @@ -use alloc::string::String; use alloc::vec::Vec; use miden_protocol::account::AccountId; -use miden_protocol::note::PartialNote; -use miden_protocol::transaction::TransactionScript; -use thiserror::Error; - -use crate::AuthMethod; -use crate::code_builder::CodeBuilder; -use crate::errors::CodeBuilderError; #[cfg(test)] mod test; @@ -25,7 +17,6 @@ pub use extension::{AccountComponentInterfaceExt, AccountInterfaceExt}; /// An [`AccountInterface`] describes the exported, callable procedures of an account. pub struct AccountInterface { account_id: AccountId, - auth: Vec, components: Vec, } @@ -35,21 +26,20 @@ impl AccountInterface { // CONSTRUCTORS // -------------------------------------------------------------------------------------------- - /// Creates a new [`AccountInterface`] instance from the provided account ID, authentication - /// schemes and account component interfaces. - pub fn new( - account_id: AccountId, - auth: Vec, - components: Vec, - ) -> Self { - Self { account_id, auth, components } - } - - /// Returns `true` if the account installs an [`AccountComponentInterface::Ownable2Step`] - /// access component. Since [`AccountComponentInterface::RoleBasedAccessControl`] always - /// includes Ownable2Step, this also covers RBAC-controlled accounts. - pub fn is_owner_controlled(&self) -> bool { - self.components.contains(&AccountComponentInterface::Ownable2Step) + /// Creates a new [`AccountInterface`] instance from the provided account ID and account + /// component interfaces. + /// + /// # Panics + /// + /// Panics if `components` does not contain exactly one auth component. Every account installs + /// a single auth component. Zero or multiple auth components is a malformed account. + pub fn new(account_id: AccountId, components: Vec) -> Self { + let auth_count = components.iter().filter(|c| c.is_auth_component()).count(); + assert_eq!( + auth_count, 1, + "account interface must contain exactly one auth component, found {auth_count}" + ); + Self { account_id, components } } // PUBLIC ACCESSORS @@ -70,154 +60,18 @@ impl AccountInterface { self.account_id.is_public() } - /// Returns a reference to the vector of used authentication methods. - pub fn auth(&self) -> &Vec { - &self.auth - } - /// Returns a reference to the set of used component interfaces. pub fn components(&self) -> &Vec { &self.components } -} -// ------------------------------------------------------------------------------------------------ -/// Code generation -impl AccountInterface { - /// Returns a transaction script which sends the specified notes using the procedures available - /// in the current interface. - /// - /// Provided `expiration_delta` parameter is used to specify how close to the transaction's - /// reference block the transaction must be included into the chain. For example, if the - /// transaction's reference block is 100 and transaction expiration delta is 10, the transaction - /// can be included into the chain by block 110. If this does not happen, the transaction is - /// considered expired and cannot be included into the chain. - /// - /// Currently only [`AccountComponentInterface::BasicWallet`] and - /// [`AccountComponentInterface::FungibleFaucet`] interfaces are supported for the - /// `send_note` script creation. Attempt to generate the script using some other interface will - /// lead to an error. In case both supported interfaces are available in the account, the script - /// will be generated for the [`AccountComponentInterface::FungibleFaucet`] interface. - /// - /// # Example - /// - /// Example of the `send_note` script with specified expiration delta and one output note: - /// - /// ```masm - /// begin - /// push.{expiration_delta} exec.::miden::protocol::tx::update_expiration_block_delta - /// - /// push.{note information} + /// Returns a reference to the single auth component installed on this account. /// - /// push.{ASSET_VALUE} push.{ASSET_KEY} - /// call.::miden::standards::faucets::fungible::mint_and_send - /// swapdw dropw dropw swapdw dropw dropw - /// end - /// ``` - /// - /// # Errors: - /// Returns an error if: - /// - the available interfaces does not support the generation of the standard `send_note` - /// procedure. - /// - the sender of the note isn't the account for which the script is being built. - /// - the note created by the faucet doesn't contain exactly one asset. - /// - a faucet tries to mint an asset with a different faucet ID. - /// - /// [wallet]: crate::account::interface::AccountComponentInterface::BasicWallet - /// [faucet]: crate::account::interface::AccountComponentInterface::FungibleFaucet - pub fn build_send_notes_script( - &self, - output_notes: &[PartialNote], - expiration_delta: Option, - ) -> Result { - let note_creation_source = self.build_create_notes_section(output_notes)?; - - let script = format!( - "begin\n{}\n{}\nend", - self.build_set_tx_expiration_section(expiration_delta), - note_creation_source, - ); - - // Add attachment entries to the code builder's advice map. - // The commitment is used as key and the elements as value. - let mut code_builder = CodeBuilder::new(); - for note in output_notes { - for attachment in note.attachments().iter() { - code_builder - .add_advice_map_entry(attachment.to_commitment(), attachment.to_elements()); - } - } - - let tx_script = code_builder - .compile_tx_script(script) - .map_err(AccountInterfaceError::InvalidTransactionScript)?; - - Ok(tx_script) - } - - /// Generates a note creation code required for the `send_note` transaction script. - /// - /// For the example of the resulting code see [AccountComponentInterface::send_note_body] - /// description. - /// - /// # Errors: - /// Returns an error if: - /// - the available interfaces does not support the generation of the standard `send_note` - /// procedure. - /// - the sender of the note isn't the account for which the script is being built. - /// - the note created by the faucet doesn't contain exactly one asset. - /// - a faucet tries to mint an asset with a different faucet ID. - fn build_create_notes_section( - &self, - output_notes: &[PartialNote], - ) -> Result { - if let Some(fungible_faucet) = self.components().iter().find(|component_interface| { - matches!(component_interface, AccountComponentInterface::FungibleFaucet) - }) { - // Owner-controlled faucets (network-style) mint exclusively via MINT notes; refuse to - // generate a tx-script `send_note` flow that would fail at runtime under the - // OwnerOnly mint policy. - if self.is_owner_controlled() { - return Err(AccountInterfaceError::UnsupportedAccountInterface); - } - fungible_faucet.send_note_body(*self.id(), output_notes) - } else if self.components().contains(&AccountComponentInterface::BasicWallet) { - AccountComponentInterface::BasicWallet.send_note_body(*self.id(), output_notes) - } else { - Err(AccountInterfaceError::UnsupportedAccountInterface) - } - } - - /// Returns a string with the expiration delta update procedure call for the script. - fn build_set_tx_expiration_section(&self, expiration_delta: Option) -> String { - if let Some(expiration_delta) = expiration_delta { - format!( - "push.{expiration_delta} exec.::miden::protocol::tx::update_expiration_block_delta\n" - ) - } else { - String::new() - } + /// Every account installs exactly one auth component (validated in [`Self::new`]). + pub fn auth_component(&self) -> &AccountComponentInterface { + self.components + .iter() + .find(|c| c.is_auth_component()) + .expect("AccountInterface invariant: exactly one auth component present") } } - -// ACCOUNT INTERFACE ERROR -// ============================================================================================ - -/// Account interface related errors. -#[derive(Debug, Error)] -pub enum AccountInterfaceError { - #[error("note asset is not issued by faucet {0}")] - IssuanceFaucetMismatch(AccountId), - #[error("note created by the basic fungible faucet doesn't contain exactly one asset")] - FaucetNoteWithoutAsset, - #[error("invalid transaction script")] - InvalidTransactionScript(#[source] CodeBuilderError), - #[error("invalid sender account: {0}")] - InvalidSenderAccount(AccountId), - #[error("{} interface does not support the generation of the standard send_note script", interface.name())] - UnsupportedInterface { interface: AccountComponentInterface }, - #[error( - "account does not contain the basic fungible faucet or basic wallet interfaces which are needed to support the send_note script generation" - )] - UnsupportedAccountInterface, -} diff --git a/crates/miden-standards/src/account/interface/test.rs b/crates/miden-standards/src/account/interface/test.rs index 0ed4606b1f..97442d38da 100644 --- a/crates/miden-standards/src/account/interface/test.rs +++ b/crates/miden-standards/src/account/interface/test.rs @@ -8,7 +8,6 @@ use miden_protocol::errors::NoteError; use miden_protocol::note::{NoteAttachments, NoteType}; use miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE; -use crate::AuthMethod; use crate::account::auth::{AuthMultisig, AuthMultisigConfig, AuthSingleSig, NoAuth}; use crate::account::interface::{AccountComponentInterface, AccountInterface, AccountInterfaceExt}; use crate::account::wallets::BasicWallet; @@ -38,56 +37,17 @@ fn test_required_asset_same_as_offered() { // HELPERS // ================================================================================================ -/// Helper function to create a mock auth component for testing fn get_mock_falcon_auth_component() -> AuthSingleSig { let mock_word = Word::from([0, 1, 2, 3u32]); let mock_public_key = PublicKeyCommitment::from(mock_word); AuthSingleSig::new(mock_public_key, auth::AuthScheme::Falcon512Poseidon2) } -/// Helper function to create a mock Ecdsa auth component for testing -fn get_mock_ecdsa_auth_component() -> AuthSingleSig { - let mock_word = Word::from([0, 1, 2, 3u32]); - let mock_public_key = PublicKeyCommitment::from(mock_word); - AuthSingleSig::new(mock_public_key, auth::AuthScheme::EcdsaK256Keccak) -} - -// GET AUTH SCHEME TESTS +// AUTH COMPONENT IDENTIFICATION TESTS // ================================================================================================ #[test] -fn test_get_auth_scheme_ecdsa_k256_keccak() { - let mock_seed = Word::from([0, 1, 2, 3u32]).as_bytes(); - let wallet_account = AccountBuilder::new(mock_seed) - .with_auth_component(get_mock_ecdsa_auth_component()) - .with_component(BasicWallet) - .build_existing() - .expect("failed to create wallet account"); - - let wallet_account_interface = AccountInterface::from_account(&wallet_account); - - // Find the EcdsaK256Keccak component interface - let ecdsa_k256_keccak_component = wallet_account_interface - .components() - .iter() - .find(|component| matches!(component, AccountComponentInterface::AuthSingleSig)) - .expect("should have EcdsaK256Keccak component"); - - // Test get_auth_methods method - let auth_methods = ecdsa_k256_keccak_component.get_auth_methods(wallet_account.storage()); - assert_eq!(auth_methods.len(), 1); - let auth_method = &auth_methods[0]; - match auth_method { - AuthMethod::SingleSig { approver: (pub_key, auth_scheme) } => { - assert_eq!(*pub_key, PublicKeyCommitment::from(Word::from([0, 1, 2, 3u32]))); - assert_eq!(*auth_scheme, auth::AuthScheme::EcdsaK256Keccak); - }, - _ => panic!("Expected EcdsaK256Keccak auth scheme"), - } -} - -#[test] -fn test_get_auth_scheme_falcon512_poseidon2() { +fn test_account_interface_identifies_single_sig_auth() { let mock_seed = Word::from([0, 1, 2, 3u32]).as_bytes(); let wallet_account = AccountBuilder::new(mock_seed) .with_auth_component(get_mock_falcon_auth_component()) @@ -97,94 +57,15 @@ fn test_get_auth_scheme_falcon512_poseidon2() { let wallet_account_interface = AccountInterface::from_account(&wallet_account); - // Find the single sig component interface - let rpo_falcon_component = wallet_account_interface - .components() - .iter() - .find(|component| matches!(component, AccountComponentInterface::AuthSingleSig)) - .expect("should have single sig component"); - - // Test get_auth_methods method - let auth_methods = rpo_falcon_component.get_auth_methods(wallet_account.storage()); - assert_eq!(auth_methods.len(), 1); - let auth_method = &auth_methods[0]; - match auth_method { - AuthMethod::SingleSig { approver: (pub_key, auth_scheme) } => { - assert_eq!(*pub_key, PublicKeyCommitment::from(Word::from([0, 1, 2, 3u32]))); - assert_eq!(*auth_scheme, auth::AuthScheme::Falcon512Poseidon2); - }, - _ => panic!("Expected Falcon512Poseidon2 auth scheme"), - } -} - -#[test] -fn test_get_auth_scheme_no_auth() { - let mock_seed = Word::from([0, 1, 2, 3u32]).as_bytes(); - let no_auth_account = AccountBuilder::new(mock_seed) - .with_auth_component(NoAuth) - .with_component(BasicWallet) - .build_existing() - .expect("failed to create no-auth account"); - - let no_auth_account_interface = AccountInterface::from_account(&no_auth_account); - - // Find the NoAuth component interface - let no_auth_component = no_auth_account_interface - .components() - .iter() - .find(|component| matches!(component, AccountComponentInterface::AuthNoAuth)) - .expect("should have NoAuth component"); - - // Test get_auth_methods method - let auth_methods = no_auth_component.get_auth_methods(no_auth_account.storage()); - assert_eq!(auth_methods.len(), 1); - let auth_method = &auth_methods[0]; - match auth_method { - AuthMethod::NoAuth => {}, - _ => panic!("Expected NoAuth auth method"), - } -} - -/// Test that non-auth components return None -#[test] -fn test_get_auth_scheme_non_auth_component() { - let basic_wallet_component = AccountComponentInterface::BasicWallet; - let mock_seed = Word::from([0, 1, 2, 3u32]).as_bytes(); - let wallet_account = AccountBuilder::new(mock_seed) - .with_auth_component(get_mock_falcon_auth_component()) - .with_component(BasicWallet) - .build_existing() - .expect("failed to create wallet account"); - - let auth_methods = basic_wallet_component.get_auth_methods(wallet_account.storage()); - assert!(auth_methods.is_empty()); + assert!(matches!( + wallet_account_interface.auth_component(), + AccountComponentInterface::AuthSingleSig + )); } -/// Test that the From<&Account> implementation correctly uses get_auth_scheme #[test] -fn test_account_interface_from_account_uses_get_auth_scheme() { +fn test_account_interface_identifies_no_auth() { let mock_seed = Word::from([0, 1, 2, 3u32]).as_bytes(); - let wallet_account = AccountBuilder::new(mock_seed) - .with_auth_component(get_mock_falcon_auth_component()) - .with_component(BasicWallet) - .build_existing() - .expect("failed to create wallet account"); - - let wallet_account_interface = AccountInterface::from_account(&wallet_account); - - // Should have exactly one auth scheme - assert_eq!(wallet_account_interface.auth().len(), 1); - - match &wallet_account_interface.auth()[0] { - AuthMethod::SingleSig { approver: (pub_key, auth_scheme) } => { - let expected_pub_key = PublicKeyCommitment::from(Word::from([0, 1, 2, 3u32])); - assert_eq!(*pub_key, expected_pub_key); - assert_eq!(*auth_scheme, auth::AuthScheme::Falcon512Poseidon2); - }, - _ => panic!("Expected SingleSig auth method"), - } - - // Test with NoAuth let no_auth_account = AccountBuilder::new(mock_seed) .with_auth_component(NoAuth) .with_component(BasicWallet) @@ -193,55 +74,17 @@ fn test_account_interface_from_account_uses_get_auth_scheme() { let no_auth_account_interface = AccountInterface::from_account(&no_auth_account); - // Should have exactly one auth scheme - assert_eq!(no_auth_account_interface.auth().len(), 1); - - match &no_auth_account_interface.auth()[0] { - AuthMethod::NoAuth => {}, - _ => panic!("Expected NoAuth auth method"), - } + assert!(matches!( + no_auth_account_interface.auth_component(), + AccountComponentInterface::AuthNoAuth + )); } -/// Test AccountInterface.get_auth_scheme() method with Falcon512Poseidon2 and NoAuth #[test] -fn test_account_interface_get_auth_scheme() { - let mock_seed = Word::from([0, 1, 2, 3u32]).as_bytes(); - let wallet_account = AccountBuilder::new(mock_seed) - .with_auth_component(get_mock_falcon_auth_component()) - .with_component(BasicWallet) - .build_existing() - .expect("failed to create wallet account"); - - let wallet_account_interface = AccountInterface::from_account(&wallet_account); - - // Test that auth() method provides the authentication schemes - assert_eq!(wallet_account_interface.auth().len(), 1); - match &wallet_account_interface.auth()[0] { - AuthMethod::SingleSig { approver: (pub_key, auth_scheme) } => { - assert_eq!(*pub_key, PublicKeyCommitment::from(Word::from([0, 1, 2, 3u32]))); - assert_eq!(*auth_scheme, auth::AuthScheme::Falcon512Poseidon2); - }, - _ => panic!("Expected SingleSig auth method"), - } - - // Test AccountInterface.get_auth_scheme() method with NoAuth - let no_auth_account = AccountBuilder::new(mock_seed) - .with_auth_component(NoAuth) - .with_component(BasicWallet) - .build_existing() - .expect("failed to create no-auth account"); - - let no_auth_account_interface = AccountInterface::from_account(&no_auth_account); - - // Test that auth() method provides the authentication schemes - assert_eq!(no_auth_account_interface.auth().len(), 1); - match &no_auth_account_interface.auth()[0] { - AuthMethod::NoAuth => {}, - _ => panic!("Expected NoAuth auth method"), - } - - // Note: We don't test the case where an account has no auth components because - // accounts are required to have auth components in the current system design +fn test_basic_wallet_is_not_an_auth_component() { + assert!(!AccountComponentInterface::BasicWallet.is_auth_component()); + assert!(AccountComponentInterface::AuthSingleSig.is_auth_component()); + assert!(AccountComponentInterface::AuthNoAuth.is_auth_component()); } #[test] diff --git a/crates/miden-standards/src/account/mod.rs b/crates/miden-standards/src/account/mod.rs index 0dcafe14c3..fb928f2801 100644 --- a/crates/miden-standards/src/account/mod.rs +++ b/crates/miden-standards/src/account/mod.rs @@ -1,5 +1,3 @@ -use super::auth_method::AuthMethod; - pub mod access; pub mod auth; pub mod components; diff --git a/crates/miden-standards/src/account/policies/burn/min_burn_amount.rs b/crates/miden-standards/src/account/policies/burn/min_burn_amount.rs new file mode 100644 index 0000000000..c3498d1357 --- /dev/null +++ b/crates/miden-standards/src/account/policies/burn/min_burn_amount.rs @@ -0,0 +1,163 @@ +use miden_protocol::account::component::{ + AccountComponentCode, + AccountComponentMetadata, + SchemaType, + StorageSchema, + StorageSlotSchema, +}; +use miden_protocol::account::{ + AccountComponent, + AccountComponentName, + AccountProcedureRoot, + StorageSlot, + StorageSlotName, +}; +use miden_protocol::asset::AssetAmount; +use miden_protocol::utils::sync::LazyLock; +use miden_protocol::{Felt, Word}; + +use crate::account::account_component_code; +use crate::procedure_root; + +// MIN-BURN-AMOUNT BURN POLICY +// ================================================================================================ + +account_component_code!( + MIN_BURN_AMOUNT_BURN_POLICY_CODE, + "faucets/policies/burn/min_burn_amount.masl" +); + +procedure_root!( + MIN_BURN_AMOUNT_POLICY_ROOT, + MinBurnAmount::NAME, + MinBurnAmount::PROC_NAME, + MinBurnAmount::code() +); + +procedure_root!( + MIN_BURN_AMOUNT_SET_ROOT, + MinBurnAmount::NAME, + MinBurnAmount::SET_PROC_NAME, + MinBurnAmount::code() +); + +procedure_root!( + MIN_BURN_AMOUNT_GET_ROOT, + MinBurnAmount::NAME, + MinBurnAmount::GET_PROC_NAME, + MinBurnAmount::code() +); + +static MIN_BURN_AMOUNT_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new( + "miden::standards::faucets::policies::burn::min_burn_amount::min_burn_amount", + ) + .expect("storage slot name should be valid") +}); + +/// The `min_burn_amount` burn policy account component. +/// +/// Pair with a [`crate::account::policies::TokenPolicyManager`] whose allowed burn-policies map +/// includes [`MinBurnAmount::root`]. When active, a burn is rejected unless its amount meets or +/// exceeds the configured minimum burn amount, which is stored in this component's value slot +/// ([`MinBurnAmount::slot_name`]) and can be updated through the authority-gated +/// `set_min_burn_amount` procedure (authorized via the account-wide +/// [`Authority`][crate::account::access::Authority] component). +#[derive(Debug, Clone, Copy)] +pub struct MinBurnAmount { + min_burn_amount: AssetAmount, +} + +impl MinBurnAmount { + /// The name of the component. + pub const NAME: &'static str = + "miden::standards::components::faucets::policies::burn::min_burn_amount"; + + pub(crate) const PROC_NAME: &str = "check_policy"; + const SET_PROC_NAME: &str = "set_min_burn_amount"; + const GET_PROC_NAME: &str = "get_min_burn_amount"; + + /// Creates a new `min_burn_amount` burn policy with the given initial minimum burn amount. + pub fn new(min_burn_amount: AssetAmount) -> Self { + Self { min_burn_amount } + } + + /// Returns the canonical [`AccountComponentName`] of this component. + pub const fn name() -> AccountComponentName { + AccountComponentName::from_static_str(Self::NAME) + } + + /// Returns the [`AccountComponentCode`] of this component. + pub fn code() -> &'static AccountComponentCode { + &MIN_BURN_AMOUNT_BURN_POLICY_CODE + } + + /// Returns the procedure root of the `check_policy` burn policy procedure. + pub fn root() -> AccountProcedureRoot { + *MIN_BURN_AMOUNT_POLICY_ROOT + } + + /// Returns the procedure root of the `set_min_burn_amount` account procedure. + pub fn set_min_burn_amount_root() -> AccountProcedureRoot { + *MIN_BURN_AMOUNT_SET_ROOT + } + + /// Returns the procedure root of the `get_min_burn_amount` account procedure. + pub fn get_min_burn_amount_root() -> AccountProcedureRoot { + *MIN_BURN_AMOUNT_GET_ROOT + } + + /// Returns the [`StorageSlotName`] where the minimum burn amount is stored. + pub fn slot_name() -> &'static StorageSlotName { + &MIN_BURN_AMOUNT_SLOT_NAME + } + + /// Returns the configured minimum burn amount. + pub fn min_burn_amount(&self) -> AssetAmount { + self.min_burn_amount + } + + /// Returns the storage slot schema for the minimum burn amount slot. + pub fn slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::slot_name().clone(), + StorageSlotSchema::value("Minimum burn amount", SchemaType::native_word()), + ) + } + + /// Converts the configured minimum burn amount into its storage value word + /// `[min_burn_amount, 0, 0, 0]`. + fn to_word(self) -> Word { + Word::from([Felt::from(self.min_burn_amount), Felt::ZERO, Felt::ZERO, Felt::ZERO]) + } + + /// Converts the configured minimum burn amount into a [`StorageSlot`]. + fn to_storage_slot(self) -> StorageSlot { + StorageSlot::with_value(Self::slot_name().clone(), self.to_word()) + } + + /// Returns the [`AccountComponentMetadata`] for this component. + pub fn component_metadata() -> AccountComponentMetadata { + let storage_schema = + StorageSchema::new([Self::slot_schema()]).expect("storage schema should be valid"); + + AccountComponentMetadata::new(Self::NAME) + .with_description("`min_burn_amount` burn policy for fungible faucets") + .with_storage_schema(storage_schema) + } +} + +impl From for AccountComponent { + fn from(policy: MinBurnAmount) -> Self { + let storage_slot = policy.to_storage_slot(); + + AccountComponent::new( + MinBurnAmount::code().clone(), + vec![storage_slot], + MinBurnAmount::component_metadata(), + ) + .expect( + "`min_burn_amount` burn policy component should satisfy the requirements of a valid account component", + ) + } +} diff --git a/crates/miden-standards/src/account/policies/burn/mod.rs b/crates/miden-standards/src/account/policies/burn/mod.rs index effb645b8a..02abfcc34d 100644 --- a/crates/miden-standards/src/account/policies/burn/mod.rs +++ b/crates/miden-standards/src/account/policies/burn/mod.rs @@ -1,56 +1,117 @@ -//! Burn policy components and the burn policy configuration enum used by +//! Burn policy components and the burn policy descriptor used by //! [`super::TokenPolicyManager`]. use alloc::vec::Vec; -use miden_protocol::Word; -use miden_protocol::account::AccountComponent; +use miden_protocol::account::{AccountComponent, AccountProcedureRoot}; +use miden_protocol::asset::AssetAmount; +use thiserror::Error; mod allow_all; +mod min_burn_amount; mod owner_only; pub use allow_all::BurnAllowAll; +pub use min_burn_amount::MinBurnAmount; pub use owner_only::BurnOwnerOnly; -// CONFIG +// BURN POLICY ERROR // ================================================================================================ -/// Selects which burn policy is registered with a [`super::TokenPolicyManager`]. +/// Errors returned by [`BurnPolicy::custom`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum BurnPolicyError { + /// The procedure root supplied to [`BurnPolicy::custom`] is not exported by any of the + /// provided components. + #[error( + "custom burn policy root must match a procedure root in one of the provided components" + )] + RootNotInComponents, +} + +// BURN POLICY +// ================================================================================================ + +/// Descriptor for the burn policy registered with a [`super::TokenPolicyManager`]. +/// +/// Binds the procedure root the manager dispatches to (via `dynexec`) with any companion +/// [`AccountComponent`]s that must be installed for the procedure to work. /// -/// Pass to [`super::TokenPolicyManager::with_burn_policy`] together with a -/// [`super::PolicyRegistration`] to register the policy as either active or as a reserved -/// alternative. -#[derive(Debug, Clone, Copy, Default)] -pub enum BurnPolicyConfig { - /// Policy root = [`BurnAllowAll::root`] (burns open to anyone). - #[default] - AllowAll, - /// Policy root = [`BurnOwnerOnly::root`] (burns gated by the account owner). - OwnerOnly, - /// Policy root = the provided word. The corresponding component must be installed by the - /// caller separately; resolving this variant into built-in components yields an empty list. - Custom(Word), +/// Construct via [`Self::allow_all`], [`Self::owner_only`], [`Self::min_burn_amount`], or +/// [`Self::custom`]. Pass to the [`super::TokenPolicyManager`] builder via `active_burn_policy` +/// or `allowed_burn_policy`. +#[derive(Debug, Clone)] +pub struct BurnPolicy { + root: AccountProcedureRoot, + components: Vec, } -impl BurnPolicyConfig { - /// Returns the procedure root of the policy this variant resolves to. - pub fn root(self) -> Word { - match self { - Self::AllowAll => BurnAllowAll::root().as_word(), - Self::OwnerOnly => BurnOwnerOnly::root().as_word(), - Self::Custom(root) => root, +impl BurnPolicy { + /// Returns a burn policy that accepts every burn unconditionally. + pub fn allow_all() -> Self { + Self { + root: BurnAllowAll::root(), + components: vec![BurnAllowAll.into()], + } + } + + /// Returns a burn policy gated by the account owner. + pub fn owner_only() -> Self { + Self { + root: BurnOwnerOnly::root(), + components: vec![BurnOwnerOnly.into()], + } + } + + /// Returns a burn policy that rejects burns below `min_burn_amount`. + /// + /// The threshold is written to the [`MinBurnAmount`] component's storage slot and can be + /// updated at runtime through the owner-gated `set_min_burn_amount` procedure. + pub fn min_burn_amount(min_burn_amount: AssetAmount) -> Self { + Self { + root: MinBurnAmount::root(), + components: vec![MinBurnAmount::new(min_burn_amount).into()], } } - /// Returns the [`AccountComponent`]s that must accompany this burn policy variant. + /// Returns a burn policy resolving to `root` and shipping the provided companion + /// `components` (anything that can be converted into an [`AccountComponent`]). + /// + /// # Errors /// - /// For [`Self::Custom`] this is empty — the caller installs whatever the chosen root - /// requires. - pub(crate) fn into_components(self) -> Vec { - match self { - Self::AllowAll => vec![BurnAllowAll.into()], - Self::OwnerOnly => vec![BurnOwnerOnly.into()], - Self::Custom(_) => Vec::new(), + /// Returns [`BurnPolicyError::RootNotInComponents`] if `root` is not the procedure root of + /// any procedure exported by the provided components. + pub fn custom(root: AccountProcedureRoot, components: I) -> Result + where + I: IntoIterator, + I::Item: Into, + { + let components: Vec = components.into_iter().map(Into::into).collect(); + if !components.iter().any(|component| component.has_procedure(root)) { + return Err(BurnPolicyError::RootNotInComponents); } + Ok(Self { root, components }) + } + + /// Returns the procedure root of the policy this descriptor resolves to. + pub fn root(&self) -> AccountProcedureRoot { + self.root + } +} + +impl Default for BurnPolicy { + fn default() -> Self { + Self::allow_all() + } +} + +impl IntoIterator for BurnPolicy { + type Item = AccountComponent; + type IntoIter = alloc::vec::IntoIter; + + /// Yields the [`AccountComponent`]s carried by this burn policy descriptor in installation + /// order. + fn into_iter(self) -> Self::IntoIter { + self.components.into_iter() } } diff --git a/crates/miden-standards/src/account/policies/burn/owner_only.rs b/crates/miden-standards/src/account/policies/burn/owner_only.rs index e0e78bc5c0..4b11793acd 100644 --- a/crates/miden-standards/src/account/policies/burn/owner_only.rs +++ b/crates/miden-standards/src/account/policies/burn/owner_only.rs @@ -24,6 +24,10 @@ procedure_root!( /// Pair with a [`crate::account::policies::TokenPolicyManager`] whose allowed burn-policies /// map includes [`BurnOwnerOnly::root`]. When active, only the account owner (as recorded by /// the `Ownable2Step` component) may trigger burn operations. +/// +/// Companion components required: +/// - [`crate::account::access::Ownable2Step`] — provides the owner storage slot the auth check +/// reads. Without it, the faucet builds successfully but every burn reverts. #[derive(Debug, Clone, Copy, Default)] pub struct BurnOwnerOnly; diff --git a/crates/miden-standards/src/account/policies/manager.rs b/crates/miden-standards/src/account/policies/manager.rs index e96c7583b9..22a0e971a4 100644 --- a/crates/miden-standards/src/account/policies/manager.rs +++ b/crates/miden-standards/src/account/policies/manager.rs @@ -1,12 +1,14 @@ //! Unified token policy manager. //! -//! [`TokenPolicyManager`] owns the policy state for fungible faucets. Mint and burn use one -//! `active_*_policy_proc_root` slot each plus an `allowed_*_policies` map slot; send and -//! receive are flattened — their active policy roots live directly in the protocol-reserved -//! callback slots (`miden::protocol::faucet::callback::on_before_asset_added_to_account` and -//! `..._to_note`) so the kernel dispatches to them via `call` without a manager-side wrapper. -//! Each kind also has an `allowed_*_policies` map slot for validating policy-switching at -//! set time. +//! [`TokenPolicyManager`] owns the policy state for fungible faucets. All four kinds use one +//! `active_*_policy_proc_root` slot each plus an `allowed_*_policies` map slot for validating +//! policy-switching at set time. Mint and burn are dispatched by `exec`-invoked +//! `execute_*_policy` wrappers from the faucet flow. Send and receive are dispatched by +//! `invoke_send_policy` / `invoke_receive_policy` wrappers whose roots live in the +//! protocol-reserved callback slots +//! (`miden::protocol::faucet::callback::on_before_asset_added_to_account` and `..._to_note`); the +//! kernel `dyncall`s the wrapper, which applies the account-wide pause check and then dispatches to +//! the active policy root. use alloc::collections::{BTreeMap, BTreeSet}; use alloc::vec::Vec; @@ -30,27 +32,13 @@ use miden_protocol::account::{ }; use miden_protocol::asset::AssetCallbacks; use miden_protocol::utils::sync::LazyLock; -use thiserror::Error; -use super::PolicyRegistration; -use super::burn::BurnPolicyConfig; -use super::mint::MintPolicyConfig; +use super::burn::BurnPolicy; +use super::mint::MintPolicy; use super::transfer::TransferPolicy; use crate::account::account_component_code; use crate::procedure_root; -// ERRORS -// ================================================================================================ - -/// Errors returned when building a [`TokenPolicyManager`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] -pub enum TokenPolicyManagerError { - /// Returned when [`PolicyRegistration::Active`] is supplied for a kind that already has an - /// active policy registered. At most one active policy per kind is permitted. - #[error("token policy manager: more than one active {kind} policy registered")] - DuplicateActivePolicy { kind: &'static str }, -} - account_component_code!(POLICY_MANAGER_CODE, "faucets/policies/policy_manager.masl"); // PROCEDURE ROOTS @@ -89,6 +77,20 @@ procedure_root!( TokenPolicyManager::code() ); +procedure_root!( + POLICY_MANAGER_INVOKE_SEND_POLICY, + POLICY_MANAGER_LIBRARY_PATH, + TokenPolicyManager::INVOKE_SEND_POLICY_PROC_NAME, + TokenPolicyManager::code() +); + +procedure_root!( + POLICY_MANAGER_INVOKE_RECEIVE_POLICY, + POLICY_MANAGER_LIBRARY_PATH, + TokenPolicyManager::INVOKE_RECEIVE_POLICY_PROC_NAME, + TokenPolicyManager::code() +); + // STORAGE SLOT NAMES // ================================================================================================ @@ -106,6 +108,20 @@ static ACTIVE_BURN_POLICY_PROC_ROOT_SLOT_NAME: LazyLock = LazyL .expect("storage slot name should be valid") }); +static ACTIVE_SEND_POLICY_PROC_ROOT_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new( + "miden::standards::faucets::policies::policy_manager::active_send_policy_proc_root", + ) + .expect("storage slot name should be valid") +}); + +static ACTIVE_RECEIVE_POLICY_PROC_ROOT_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new( + "miden::standards::faucets::policies::policy_manager::active_receive_policy_proc_root", + ) + .expect("storage slot name should be valid") +}); + static ALLOWED_MINT_POLICY_PROC_ROOTS_SLOT_NAME: LazyLock = LazyLock::new(|| { StorageSlotName::new( "miden::standards::faucets::policies::policy_manager::allowed_mint_policy_proc_roots", @@ -170,42 +186,49 @@ struct PolicyConfig { /// An [`AccountComponent`] that owns the policy-manager storage slots and the manager /// procedures for the four policy kinds (mint, burn, send, receive). /// -/// The component exposes `set_*_policy`, `get_*_policy`, and `execute_*_policy` procedures for -/// each kind, plus the protocol-level `on_before_asset_added_to_*` asset callbacks (which -/// dispatch to the active send / receive policy). Authorization for switching the active policies -/// is delegated to the account-wide [`Authority`][crate::account::access::Authority] component, -/// which must be installed alongside this manager. +/// The component exposes `set_*_policy` and `get_*_policy` for each kind, `execute_*_policy` for +/// mint / burn, and `invoke_send_policy` / `invoke_receive_policy` for the transfer kinds. The +/// transfer wrappers double as the protocol-level `on_before_asset_added_to_*` asset callbacks: +/// the kernel `dyncall`s the wrapper, which applies the account-wide pause check and then +/// dispatches to the active send / receive policy. +/// Authorization for switching the active policies is delegated to the account-wide +/// [`Authority`][crate::account::access::Authority] component, which must be installed alongside +/// this manager. /// -/// Construct via [`Self::new`] and chain the per-kind builders -/// ([`Self::with_mint_policy`] / [`Self::with_burn_policy`] / [`Self::with_send_policy`] / -/// [`Self::with_receive_policy`]). Each accepts a typed config plus a [`PolicyRegistration`] -/// flag to register the policy as either the active one or as a reserved alternative for -/// runtime switching via the matching `set_*_policy` procedure. Each builder returns -/// `Result` — registering more than one -/// [`PolicyRegistration::Active`] entry per kind returns -/// [`TokenPolicyManagerError::DuplicateActivePolicy`]. -/// -/// Pass the manager directly to [`miden_protocol::account::AccountBuilder::with_components`] -/// (the type implements [`IntoIterator`]). Iteration yields the -/// manager itself plus the companion components contributed by every registered policy -/// (deduplicated by procedure root — a policy installed under both send and receive only -/// contributes its companion components once). `Custom` variants on any kind contribute no -/// built-in components — the caller installs the matching components on the account -/// separately. +/// Construct via [`Self::builder`]. The builder requires the active mint and burn policy +/// ([`TokenPolicyManagerBuilder::active_mint_policy`] / +/// [`TokenPolicyManagerBuilder::active_burn_policy`]). Send / receive policies are optional and may +/// be registered as active ([`TokenPolicyManagerBuilder::active_send_policy`] / +/// [`TokenPolicyManagerBuilder::active_receive_policy`]) and/or as reserved alternatives +/// ([`TokenPolicyManagerBuilder::allowed_send_policy`] / +/// [`TokenPolicyManagerBuilder::allowed_receive_policy`]) for runtime switching. The +/// protocol-reserved asset-callback slots (see the storage layout below) are installed whenever at +/// least one send or receive policy of either kind is registered - active or reserved - so every +/// minted asset carries +/// [`AssetCallbackFlag::Enabled`][miden_protocol::asset::AssetCallbackFlag::Enabled] from creation, +/// even when only reserved policies exist and no active root is set yet. This keeps `has_callbacks` +/// true for the faucet's entire lifetime, so promoting a reserved policy later via +/// `set_send_policy` / `set_receive_policy` enforces it against the whole circulating supply rather +/// than only assets minted after the switch. The slots are omitted only when no send or receive +/// policy of any kind is registered, in which case minted assets carry +/// [`AssetCallbackFlag::Disabled`][miden_protocol::asset::AssetCallbackFlag::Disabled]. /// /// ## Storage layout /// /// - [`Self::active_mint_policy_slot`]: procedure root of the active mint policy. /// - [`Self::active_burn_policy_slot`]: procedure root of the active burn policy. +/// - [`Self::active_send_policy_slot`]: procedure root of the active send policy. +/// - [`Self::active_receive_policy_slot`]: procedure root of the active receive policy. /// - [`Self::allowed_mint_policies_slot`]: map of allowed mint policy roots. /// - [`Self::allowed_burn_policies_slot`]: map of allowed burn policy roots. /// - [`Self::allowed_send_policies_slot`]: map of allowed send policy roots. /// - [`Self::allowed_receive_policies_slot`]: map of allowed receive policy roots. -/// - Asset-callback storage slots (registered via [`AssetCallbacks`]) hold the active send and -/// receive policy procedure roots directly so the kernel dispatches to them via `call`. They are -/// installed whenever any transfer policy is registered with this manager — including `AllowAll` -/// — so that every minted asset carries -/// [`AssetCallbackFlag::Enabled`][miden_protocol::asset::AssetCallbackFlag::Enabled] uniformly +/// - Asset-callback storage slots (registered via [`AssetCallbacks`]) hold the fixed +/// `invoke_send_policy` / `invoke_receive_policy` wrapper roots, so the kernel dispatches to the +/// wrapper (which then dispatches to the active policy in the slot above). They are installed +/// only when at least one transfer policy is configured, so a manager with transfer policies +/// mints assets carrying +/// [`AssetCallbackFlag::Enabled`][miden_protocol::asset::AssetCallbackFlag::Enabled] uniformly, /// and future policy switches via `set_send_policy` / `set_receive_policy` apply to the entire /// circulating supply rather than only to assets minted after the switch. #[derive(Debug, Clone)] @@ -217,6 +240,123 @@ pub struct TokenPolicyManager { policies: BTreeMap, } +#[bon::bon] +impl TokenPolicyManager { + /// Builder constructor for [`TokenPolicyManager`]. + /// + /// Each `active_*_policy` setter is required and registers the policy as the active one + /// for its kind. Each `allowed_*_policy` setter registers an additional reserved alternative + /// for runtime switching via the matching `set_*_policy` procedure. + #[builder] + pub fn new( + #[builder(field)] allowed_mint_policies: BTreeMap, + #[builder(field)] allowed_burn_policies: BTreeMap, + #[builder(field)] allowed_send_policies: BTreeMap, + #[builder(field)] allowed_receive_policies: BTreeMap, + active_mint_policy: MintPolicy, + active_burn_policy: BurnPolicy, + active_send_policy: Option, + active_receive_policy: Option, + ) -> Self { + let active_mint_policy_root = active_mint_policy.root(); + let active_burn_policy_root = active_burn_policy.root(); + let active_send_policy_root = active_send_policy + .as_ref() + .map(TransferPolicy::root) + .unwrap_or_else(|| AccountProcedureRoot::from_raw(Word::empty())); + let active_receive_policy_root = active_receive_policy + .as_ref() + .map(TransferPolicy::root) + .unwrap_or_else(|| AccountProcedureRoot::from_raw(Word::empty())); + + let mut policies: BTreeMap = BTreeMap::new(); + + insert_policy( + &mut policies, + active_mint_policy_root, + active_mint_policy.into_iter().collect(), + PolicyKind::Mint, + ); + insert_policy( + &mut policies, + active_burn_policy_root, + active_burn_policy.into_iter().collect(), + PolicyKind::Burn, + ); + if let Some(policy) = active_send_policy { + insert_policy( + &mut policies, + active_send_policy_root, + policy.into_iter().collect(), + PolicyKind::Send, + ); + } + if let Some(policy) = active_receive_policy { + insert_policy( + &mut policies, + active_receive_policy_root, + policy.into_iter().collect(), + PolicyKind::Receive, + ); + } + + for (root, policy) in allowed_mint_policies { + insert_policy(&mut policies, root, policy.into_iter().collect(), PolicyKind::Mint); + } + for (root, policy) in allowed_burn_policies { + insert_policy(&mut policies, root, policy.into_iter().collect(), PolicyKind::Burn); + } + for (root, policy) in allowed_send_policies { + insert_policy(&mut policies, root, policy.into_iter().collect(), PolicyKind::Send); + } + for (root, policy) in allowed_receive_policies { + insert_policy(&mut policies, root, policy.into_iter().collect(), PolicyKind::Receive); + } + + Self { + active_mint_policy_root, + active_burn_policy_root, + active_send_policy_root, + active_receive_policy_root, + policies, + } + } +} + +impl TokenPolicyManagerBuilder { + /// Registers a reserved mint policy in the `allowed_mint_policy_proc_roots` map. May be + /// activated at runtime via `set_mint_policy`. Allowed entries are deduplicated by + /// procedure root. + pub fn allowed_mint_policy(mut self, policy: MintPolicy) -> Self { + self.allowed_mint_policies.insert(policy.root(), policy); + self + } + + /// Registers a reserved burn policy in the `allowed_burn_policy_proc_roots` map. May be + /// activated at runtime via `set_burn_policy`. Allowed entries are deduplicated by + /// procedure root. + pub fn allowed_burn_policy(mut self, policy: BurnPolicy) -> Self { + self.allowed_burn_policies.insert(policy.root(), policy); + self + } + + /// Registers a reserved send policy in the `allowed_send_policy_proc_roots` map. May be + /// activated at runtime via `set_send_policy`. Allowed entries are deduplicated by + /// procedure root. + pub fn allowed_send_policy(mut self, policy: TransferPolicy) -> Self { + self.allowed_send_policies.insert(policy.root(), policy); + self + } + + /// Registers a reserved receive policy in the `allowed_receive_policy_proc_roots` map. + /// May be activated at runtime via `set_receive_policy`. Allowed entries are deduplicated + /// by procedure root. + pub fn allowed_receive_policy(mut self, policy: TransferPolicy) -> Self { + self.allowed_receive_policies.insert(policy.root(), policy); + self + } +} + impl TokenPolicyManager { // CONSTANTS // -------------------------------------------------------------------------------------------- @@ -231,162 +371,34 @@ impl TokenPolicyManager { const SET_BURN_POLICY_PROC_NAME: &'static str = "set_burn_policy"; const SET_SEND_POLICY_PROC_NAME: &'static str = "set_send_policy"; const SET_RECEIVE_POLICY_PROC_NAME: &'static str = "set_receive_policy"; + const INVOKE_SEND_POLICY_PROC_NAME: &'static str = "invoke_send_policy"; + const INVOKE_RECEIVE_POLICY_PROC_NAME: &'static str = "invoke_receive_policy"; /// Returns the canonical [`AccountComponentName`] of this component. pub const fn name() -> AccountComponentName { AccountComponentName::from_static_str(Self::NAME) } - // CONSTRUCTORS - // -------------------------------------------------------------------------------------------- - - /// Creates an empty token policy manager. Use the per-kind builders (`with_mint_policy`, - /// `with_burn_policy`, `with_send_policy`, `with_receive_policy`) to register policies. - /// - /// Every kind should end up with exactly one [`PolicyRegistration::Active`] entry by the - /// time the manager is converted into account components. Missing active entries leave the - /// corresponding `active_*_policy_proc_root` storage slot at the zero word. - pub fn new() -> Self { - Self::default() - } - - /// Registers a mint policy. The `registration` flag decides whether the policy becomes the - /// active one (written to `active_mint_policy_proc_root`) or a reserved alternative (added - /// to the `allowed_mint_policy_proc_roots` map for runtime switching via `set_mint_policy`). - /// - /// # Errors - /// - /// Returns [`TokenPolicyManagerError::DuplicateActivePolicy`] if `registration` is - /// [`PolicyRegistration::Active`] and an active mint policy is already registered. - pub fn with_mint_policy( - mut self, - policy: MintPolicyConfig, - registration: PolicyRegistration, - ) -> Result { - let root = AccountProcedureRoot::from_raw(policy.root()); - if registration == PolicyRegistration::Active { - if !self.active_mint_policy_root.as_word().is_empty() { - return Err(TokenPolicyManagerError::DuplicateActivePolicy { kind: "mint" }); - } - self.active_mint_policy_root = root; - } - self.insert_policy(root, policy.into_components(), PolicyKind::Mint); - Ok(self) - } - - /// Registers a burn policy. See [`Self::with_mint_policy`] for `registration` semantics. - /// - /// # Errors - /// - /// Returns [`TokenPolicyManagerError::DuplicateActivePolicy`] if `registration` is - /// [`PolicyRegistration::Active`] and an active burn policy is already registered. - pub fn with_burn_policy( - mut self, - policy: BurnPolicyConfig, - registration: PolicyRegistration, - ) -> Result { - let root = AccountProcedureRoot::from_raw(policy.root()); - if registration == PolicyRegistration::Active { - if !self.active_burn_policy_root.as_word().is_empty() { - return Err(TokenPolicyManagerError::DuplicateActivePolicy { kind: "burn" }); - } - self.active_burn_policy_root = root; - } - self.insert_policy(root, policy.into_components(), PolicyKind::Burn); - Ok(self) - } - - /// Registers a send policy (fired by the `on_before_asset_added_to_note` callback). See - /// [`Self::with_mint_policy`] for `registration` semantics. - /// - /// # Errors - /// - /// Returns [`TokenPolicyManagerError::DuplicateActivePolicy`] if `registration` is - /// [`PolicyRegistration::Active`] and an active send policy is already registered. - pub fn with_send_policy( - mut self, - policy: TransferPolicy, - registration: PolicyRegistration, - ) -> Result { - let root = policy.root(); - if registration == PolicyRegistration::Active { - if !self.active_send_policy_root.as_word().is_empty() { - return Err(TokenPolicyManagerError::DuplicateActivePolicy { kind: "send" }); - } - self.active_send_policy_root = root; - } - self.insert_policy(root, policy.into_components(), PolicyKind::Send); - Ok(self) - } - - /// Registers a receive policy (fired by the `on_before_asset_added_to_account` callback). - /// See [`Self::with_mint_policy`] for `registration` semantics. - /// - /// # Errors - /// - /// Returns [`TokenPolicyManagerError::DuplicateActivePolicy`] if `registration` is - /// [`PolicyRegistration::Active`] and an active receive policy is already registered. - pub fn with_receive_policy( - mut self, - policy: TransferPolicy, - registration: PolicyRegistration, - ) -> Result { - let root = policy.root(); - if registration == PolicyRegistration::Active { - if !self.active_receive_policy_root.as_word().is_empty() { - return Err(TokenPolicyManagerError::DuplicateActivePolicy { kind: "receive" }); - } - self.active_receive_policy_root = root; - } - self.insert_policy(root, policy.into_components(), PolicyKind::Receive); - Ok(self) - } - - /// Inserts (or merges, if the root is already present) a policy entry into the unified - /// `policies` map. The new kind is appended to the entry's kind set. The first call wins - /// for the components, which guarantees a given root's companion components are not - /// duplicated across kinds. - fn insert_policy( - &mut self, - root: AccountProcedureRoot, - components: Vec, - kind: PolicyKind, - ) { - self.policies - .entry(root) - .and_modify(|cfg| { - cfg.kinds.insert(kind); - }) - .or_insert_with(|| { - let mut kinds = BTreeSet::new(); - kinds.insert(kind); - PolicyConfig { components, kinds } - }); - } - // ACCESSORS // -------------------------------------------------------------------------------------------- - /// Returns the active mint policy procedure root, or [`None`] if no active mint policy has - /// been registered. - pub fn active_mint_policy(&self) -> Option { - (!self.active_mint_policy_root.as_word().is_empty()).then_some(self.active_mint_policy_root) + /// Returns the active mint policy procedure root. + pub fn active_mint_policy(&self) -> AccountProcedureRoot { + self.active_mint_policy_root } - /// Returns the active burn policy procedure root, or [`None`] if no active burn policy has - /// been registered. - pub fn active_burn_policy(&self) -> Option { - (!self.active_burn_policy_root.as_word().is_empty()).then_some(self.active_burn_policy_root) + /// Returns the active burn policy procedure root. + pub fn active_burn_policy(&self) -> AccountProcedureRoot { + self.active_burn_policy_root } - /// Returns the active send policy procedure root, or [`None`] if no active send policy has - /// been registered. + /// Returns the active send policy procedure root, or [`None`] if no send policy was set. pub fn active_send_policy(&self) -> Option { (!self.active_send_policy_root.as_word().is_empty()).then_some(self.active_send_policy_root) } - /// Returns the active receive policy procedure root, or [`None`] if no active receive - /// policy has been registered. + /// Returns the active receive policy procedure root, or [`None`] if no receive policy was + /// set. pub fn active_receive_policy(&self) -> Option { (!self.active_receive_policy_root.as_word().is_empty()) .then_some(self.active_receive_policy_root) @@ -450,6 +462,28 @@ impl TokenPolicyManager { &ACTIVE_BURN_POLICY_PROC_ROOT_SLOT_NAME } + /// Returns the [`StorageSlotName`] where the active send policy procedure root is stored. + pub fn active_send_policy_slot() -> &'static StorageSlotName { + &ACTIVE_SEND_POLICY_PROC_ROOT_SLOT_NAME + } + + /// Returns the [`StorageSlotName`] where the active receive policy procedure root is stored. + pub fn active_receive_policy_slot() -> &'static StorageSlotName { + &ACTIVE_RECEIVE_POLICY_PROC_ROOT_SLOT_NAME + } + + /// Returns the procedure root of the `invoke_send_policy` wrapper stored in the + /// `on_before_asset_added_to_note` callback slot. + pub fn invoke_send_policy_root() -> AccountProcedureRoot { + *POLICY_MANAGER_INVOKE_SEND_POLICY + } + + /// Returns the procedure root of the `invoke_receive_policy` wrapper stored in the + /// `on_before_asset_added_to_account` callback slot. + pub fn invoke_receive_policy_root() -> AccountProcedureRoot { + *POLICY_MANAGER_INVOKE_RECEIVE_POLICY + } + /// Returns the [`StorageSlotName`] where allowed mint policy roots are stored. pub fn allowed_mint_policies_slot() -> &'static StorageSlotName { &ALLOWED_MINT_POLICY_PROC_ROOTS_SLOT_NAME @@ -492,6 +526,20 @@ impl TokenPolicyManager { SchemaType::native_word(), ), ), + ( + ACTIVE_SEND_POLICY_PROC_ROOT_SLOT_NAME.clone(), + StorageSlotSchema::value( + "Active send policy procedure root", + SchemaType::native_word(), + ), + ), + ( + ACTIVE_RECEIVE_POLICY_PROC_ROOT_SLOT_NAME.clone(), + StorageSlotSchema::value( + "Active receive policy procedure root", + SchemaType::native_word(), + ), + ), ( ALLOWED_MINT_POLICY_PROC_ROOTS_SLOT_NAME.clone(), StorageSlotSchema::map( @@ -533,10 +581,6 @@ impl TokenPolicyManager { } fn manager_storage_slots(&self) -> Vec { - // Raw active-root fields are written directly: an unset (default) root corresponds to - // the zero word, which the MASM treats as "no policy installed" and will trap on at - // first invocation. Callers that want a build-time check can inspect the - // `active_*_policy()` accessors before passing the manager to `AccountBuilder`. let mut slots = vec![ StorageSlot::with_value( ACTIVE_MINT_POLICY_PROC_ROOT_SLOT_NAME.clone(), @@ -546,6 +590,14 @@ impl TokenPolicyManager { ACTIVE_BURN_POLICY_PROC_ROOT_SLOT_NAME.clone(), self.active_burn_policy_root.as_word(), ), + StorageSlot::with_value( + ACTIVE_SEND_POLICY_PROC_ROOT_SLOT_NAME.clone(), + self.active_send_policy_root.as_word(), + ), + StorageSlot::with_value( + ACTIVE_RECEIVE_POLICY_PROC_ROOT_SLOT_NAME.clone(), + self.active_receive_policy_root.as_word(), + ), StorageSlot::with_map( ALLOWED_MINT_POLICY_PROC_ROOTS_SLOT_NAME.clone(), self.build_allowed_map(PolicyKind::Mint), @@ -564,28 +616,22 @@ impl TokenPolicyManager { ), ]; - // Register the protocol-reserved asset-callback slots whenever any transfer policy is - // configured on this manager. - // - // Registering the slots whenever transfer policies are present stamps - // `AssetCallbackFlag::Enabled` on every asset minted by this faucet. Without this, a - // faucet that ships with `AllowAll` for transfer would mint callback-less assets that - // are permanently exempt from any transfer policy installed later. This would fragment - // the circulating supply into enforceable and exempt asset sets. - // - // When no transfer policy is set, the callback slots are not added, meaning all minted - // assets have callbacks disabled. + // Register the protocol-reserved asset-callback slots only when at least one transfer + // policy is configured. The slots hold the fixed `invoke_*_policy` wrapper roots (not the + // active policy roots): the kernel `dyncall`s the wrapper, which applies the pause check + // and then dispatches to whatever active root lives in the `active_*_policy` slot above. + // This indirection lets `set_send_policy` / `set_receive_policy` switch the active policy + // for the entire circulating supply without touching the callback slots. let has_transfer_policy = self.policies.iter().any(|(_, cfg)| { cfg.kinds.contains(&PolicyKind::Send) || cfg.kinds.contains(&PolicyKind::Receive) }); if has_transfer_policy { let callback_slots = AssetCallbacks::new() - .on_before_asset_added_to_account(self.active_receive_policy_root.as_word()) - .on_before_asset_added_to_note(self.active_send_policy_root.as_word()) + .on_before_asset_added_to_account(Self::invoke_receive_policy_root().as_word()) + .on_before_asset_added_to_note(Self::invoke_send_policy_root().as_word()) .into_storage_slots(); slots.extend(callback_slots); } - slots } @@ -617,16 +663,25 @@ impl TokenPolicyManager { } } -impl Default for TokenPolicyManager { - fn default() -> Self { - Self { - active_mint_policy_root: AccountProcedureRoot::from_raw(Word::empty()), - active_burn_policy_root: AccountProcedureRoot::from_raw(Word::empty()), - active_send_policy_root: AccountProcedureRoot::from_raw(Word::empty()), - active_receive_policy_root: AccountProcedureRoot::from_raw(Word::empty()), - policies: BTreeMap::new(), - } - } +/// Inserts a policy entry into the unified `policies` map. The new kind is appended to the +/// entry's kind set. The first call wins for the companion components, which guarantees a +/// given root's companion components are not duplicated across kinds. +fn insert_policy( + policies: &mut BTreeMap, + root: AccountProcedureRoot, + components: Vec, + kind: PolicyKind, +) { + policies + .entry(root) + .and_modify(|cfg| { + cfg.kinds.insert(kind); + }) + .or_insert_with(|| { + let mut kinds = BTreeSet::new(); + kinds.insert(kind); + PolicyConfig { components, kinds } + }); } impl IntoIterator for TokenPolicyManager { @@ -637,9 +692,7 @@ impl IntoIterator for TokenPolicyManager { /// manager itself first, then the companion components contributed by every registered /// policy. Deduplication by procedure root is implicit (the manager's internal `policies` /// map is keyed by root), so a policy installed under both send and receive only - /// contributes its companion components once. `Custom` variants on any kind contribute no - /// built-in components — the caller installs the matching components on the account - /// separately. + /// contributes its companion components once. fn into_iter(self) -> Self::IntoIter { let manager_component = self.to_manager_component(); let mut components = vec![manager_component]; @@ -669,20 +722,17 @@ mod tests { component.storage_slots().iter().find(|slot| slot.name() == slot_name) } - /// Checks that a manager configured with `TransferAllowAll` for both transfer kinds - /// registers the protocol-reserved asset-callback slots, populated with - /// `TransferAllowAll`'s procedure root. + /// Checks that a manager configured with a transfer policy for both kinds registers the + /// protocol-reserved asset-callback slots populated with the fixed `invoke_*_policy` wrapper + /// roots (the active `TransferAllowAll` root lives in the `active_*_policy` slots instead). #[test] fn allow_all_transfer_policy_registers_protocol_callback_slots() { - let manager = TokenPolicyManager::new() - .with_mint_policy(MintPolicyConfig::AllowAll, PolicyRegistration::Active) - .unwrap() - .with_burn_policy(BurnPolicyConfig::AllowAll, PolicyRegistration::Active) - .unwrap() - .with_send_policy(TransferPolicy::AllowAll, PolicyRegistration::Active) - .unwrap() - .with_receive_policy(TransferPolicy::AllowAll, PolicyRegistration::Active) - .unwrap(); + let manager = TokenPolicyManager::builder() + .active_mint_policy(MintPolicy::allow_all()) + .active_burn_policy(BurnPolicy::allow_all()) + .active_send_policy(TransferPolicy::allow_all()) + .active_receive_policy(TransferPolicy::allow_all()) + .build(); let manager_component = manager.to_manager_component(); @@ -701,21 +751,84 @@ mod tests { callback slot", ); - // Both slots must hold the AllowAll procedure root (not zero). - assert_eq!(on_account_slot.value(), allow_all_root); - assert_eq!(on_note_slot.value(), allow_all_root); + // The callback slots must hold the wrapper roots, not the active policy root. + assert_eq!( + on_account_slot.value(), + TokenPolicyManager::invoke_receive_policy_root().as_word() + ); + assert_eq!(on_note_slot.value(), TokenPolicyManager::invoke_send_policy_root().as_word()); + + // The active TransferAllowAll root lives in the dedicated active-policy slots. + let active_send_slot = + find_slot(&manager_component, TokenPolicyManager::active_send_policy_slot()) + .expect("active send policy slot must be registered"); + let active_receive_slot = + find_slot(&manager_component, TokenPolicyManager::active_receive_policy_slot()) + .expect("active receive policy slot must be registered"); + assert_eq!(active_send_slot.value(), allow_all_root); + assert_eq!(active_receive_slot.value(), allow_all_root); + } + + /// Checks that a manager whose send / receive policies are registered only as reserved + /// alternatives (no active transfer policy yet) still installs the protocol-reserved callback + /// slots with the fixed `invoke_*_policy` wrapper roots, so `has_callbacks` is true from + /// creation. + #[test] + fn reserved_only_transfer_policy_registers_protocol_callback_slots() { + let manager = TokenPolicyManager::builder() + .active_mint_policy(MintPolicy::allow_all()) + .active_burn_policy(BurnPolicy::allow_all()) + .allowed_send_policy(TransferPolicy::allow_all()) + .allowed_receive_policy(TransferPolicy::allow_all()) + .build(); + + let manager_component = manager.to_manager_component(); + + // Both callback slots are installed even though no transfer policy is active yet. + let on_account_slot = + find_slot(&manager_component, AssetCallbacks::on_before_asset_added_to_account_slot()) + .expect( + "reserved receive policy must register the on_before_asset_added_to_account \ + protocol callback slot", + ); + let on_note_slot = + find_slot(&manager_component, AssetCallbacks::on_before_asset_added_to_note_slot()) + .expect( + "reserved send policy must register the on_before_asset_added_to_note protocol \ + callback slot", + ); + + // They hold the fixed wrapper roots, identical to the active-policy case. + assert_eq!( + on_account_slot.value(), + TokenPolicyManager::invoke_receive_policy_root().as_word() + ); + assert_eq!(on_note_slot.value(), TokenPolicyManager::invoke_send_policy_root().as_word()); + + // No policy has been activated yet, so the active-policy slots still hold the empty word. + let active_send_slot = + find_slot(&manager_component, TokenPolicyManager::active_send_policy_slot()) + .expect("active send policy slot must be registered"); + let active_receive_slot = + find_slot(&manager_component, TokenPolicyManager::active_receive_policy_slot()) + .expect("active receive policy slot must be registered"); + assert_eq!(active_send_slot.value(), Word::empty()); + assert_eq!(active_receive_slot.value(), Word::empty()); + + // The reserved roots are recorded in the allowed-roots maps so they can be promoted later. + assert!(manager.allowed_send_policies().contains(&TransferPolicy::allow_all().root())); + assert!(manager.allowed_receive_policies().contains(&TransferPolicy::allow_all().root())); } - /// A manager configured without any send / receive policy must NOT register the + /// A manager configured without send / receive policies must NOT register the /// protocol callback slots — otherwise it would always needlessly mint assets with /// callbacks enabled. #[test] fn manager_without_transfer_policies_omits_protocol_callback_slots() { - let manager = TokenPolicyManager::new() - .with_mint_policy(MintPolicyConfig::AllowAll, PolicyRegistration::Active) - .unwrap() - .with_burn_policy(BurnPolicyConfig::AllowAll, PolicyRegistration::Active) - .unwrap(); + let manager = TokenPolicyManager::builder() + .active_mint_policy(MintPolicy::allow_all()) + .active_burn_policy(BurnPolicy::allow_all()) + .build(); let manager_component = manager.to_manager_component(); @@ -732,4 +845,21 @@ mod tests { to a separate component", ); } + + /// Allowed entries registered via the builder land in the `allowed_*_policies` storage map + /// alongside the active one. + #[test] + fn allowed_burn_policy_is_registered_in_allowed_map() { + let manager = TokenPolicyManager::builder() + .active_mint_policy(MintPolicy::owner_only()) + .active_burn_policy(BurnPolicy::owner_only()) + .allowed_burn_policy(BurnPolicy::allow_all()) + .active_send_policy(TransferPolicy::allow_all()) + .active_receive_policy(TransferPolicy::allow_all()) + .build(); + + let allowed = manager.allowed_burn_policies(); + assert!(allowed.contains(&BurnPolicy::owner_only().root())); + assert!(allowed.contains(&BurnPolicy::allow_all().root())); + } } diff --git a/crates/miden-standards/src/account/policies/mint/mod.rs b/crates/miden-standards/src/account/policies/mint/mod.rs index 56e634b808..d8a7a47f31 100644 --- a/crates/miden-standards/src/account/policies/mint/mod.rs +++ b/crates/miden-standards/src/account/policies/mint/mod.rs @@ -1,10 +1,10 @@ -//! Mint policy components and the mint policy configuration enum used by +//! Mint policy components and the mint policy descriptor used by //! [`super::TokenPolicyManager`]. use alloc::vec::Vec; -use miden_protocol::Word; -use miden_protocol::account::AccountComponent; +use miden_protocol::account::{AccountComponent, AccountProcedureRoot}; +use thiserror::Error; mod allow_all; mod owner_only; @@ -12,45 +12,91 @@ mod owner_only; pub use allow_all::MintAllowAll; pub use owner_only::MintOwnerOnly; -// CONFIG +// MINT POLICY ERROR // ================================================================================================ -/// Selects which mint policy is registered with a [`super::TokenPolicyManager`]. +/// Errors returned by [`MintPolicy::custom`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum MintPolicyError { + /// The procedure root supplied to [`MintPolicy::custom`] is not exported by any of the + /// provided components. + #[error( + "custom mint policy root must match a procedure root in one of the provided components" + )] + RootNotInComponents, +} + +// MINT POLICY +// ================================================================================================ + +/// Descriptor for the mint policy registered with a [`super::TokenPolicyManager`]. +/// +/// Binds the procedure root the manager dispatches to (via `dynexec`) with any companion +/// [`AccountComponent`]s that must be installed for the procedure to work. /// -/// Pass to [`super::TokenPolicyManager::with_mint_policy`] together with a -/// [`super::PolicyRegistration`] to register the policy as either active or as a reserved -/// alternative. -#[derive(Debug, Clone, Copy, Default)] -pub enum MintPolicyConfig { - /// Policy root = [`MintAllowAll::root`] (mint open to anyone). - AllowAll, - /// Policy root = [`MintOwnerOnly::root`] (mint gated by the account owner). - #[default] - OwnerOnly, - /// Policy root = the provided word. The corresponding component must be installed by the - /// caller separately; resolving this variant into built-in components yields an empty list. - Custom(Word), +/// Construct via [`Self::allow_all`], [`Self::owner_only`], or [`Self::custom`]. Pass to the +/// [`super::TokenPolicyManager`] builder via `active_mint_policy` or `allowed_mint_policy`. +#[derive(Debug, Clone)] +pub struct MintPolicy { + root: AccountProcedureRoot, + components: Vec, } -impl MintPolicyConfig { - /// Returns the procedure root of the policy this variant resolves to. - pub fn root(self) -> Word { - match self { - Self::AllowAll => MintAllowAll::root().as_word(), - Self::OwnerOnly => MintOwnerOnly::root().as_word(), - Self::Custom(root) => root, +impl MintPolicy { + /// Returns a mint policy that accepts every mint unconditionally. + pub fn allow_all() -> Self { + Self { + root: MintAllowAll::root(), + components: vec![MintAllowAll.into()], } } - /// Returns the [`AccountComponent`]s that must accompany this mint policy variant. + /// Returns a mint policy gated by the account owner. + pub fn owner_only() -> Self { + Self { + root: MintOwnerOnly::root(), + components: vec![MintOwnerOnly.into()], + } + } + + /// Returns a mint policy resolving to `root` and shipping the provided companion + /// `components` (anything that can be converted into an [`AccountComponent`]). + /// + /// # Errors /// - /// For [`Self::Custom`] this is empty — the caller installs whatever the chosen root - /// requires. - pub(crate) fn into_components(self) -> Vec { - match self { - Self::AllowAll => vec![MintAllowAll.into()], - Self::OwnerOnly => vec![MintOwnerOnly.into()], - Self::Custom(_) => Vec::new(), + /// Returns [`MintPolicyError::RootNotInComponents`] if `root` is not the procedure root of + /// any procedure exported by the provided components. + pub fn custom(root: AccountProcedureRoot, components: I) -> Result + where + I: IntoIterator, + I::Item: Into, + { + let components: Vec = components.into_iter().map(Into::into).collect(); + if !components.iter().any(|component| component.has_procedure(root)) { + return Err(MintPolicyError::RootNotInComponents); } + Ok(Self { root, components }) + } + + /// Returns the procedure root of the policy this descriptor resolves to. + pub fn root(&self) -> AccountProcedureRoot { + self.root + } +} + +impl Default for MintPolicy { + fn default() -> Self { + Self::owner_only() + } +} + +impl IntoIterator for MintPolicy { + type Item = AccountComponent; + type IntoIter = alloc::vec::IntoIter; + + /// Yields the [`AccountComponent`]s carried by this mint policy descriptor in installation + /// order. + fn into_iter(self) -> Self::IntoIter { + self.components.into_iter() } } diff --git a/crates/miden-standards/src/account/policies/mint/owner_only.rs b/crates/miden-standards/src/account/policies/mint/owner_only.rs index e62adf4f4b..f94f16c0ac 100644 --- a/crates/miden-standards/src/account/policies/mint/owner_only.rs +++ b/crates/miden-standards/src/account/policies/mint/owner_only.rs @@ -24,6 +24,10 @@ procedure_root!( /// Pair with a [`crate::account::policies::TokenPolicyManager`] whose allowed mint-policies /// map includes [`MintOwnerOnly::root`]. When active, only the account owner (as recorded by /// the `Ownable2Step` component) may trigger mint operations. +/// +/// Companion components required: +/// - [`crate::account::access::Ownable2Step`] — provides the owner storage slot the auth check +/// reads. Without it, the faucet builds successfully but every mint reverts. #[derive(Debug, Clone, Copy, Default)] pub struct MintOwnerOnly; diff --git a/crates/miden-standards/src/account/policies/mod.rs b/crates/miden-standards/src/account/policies/mod.rs index 57c3ae0b4f..88d6026045 100644 --- a/crates/miden-standards/src/account/policies/mod.rs +++ b/crates/miden-standards/src/account/policies/mod.rs @@ -10,10 +10,12 @@ //! - **receive** — fired by the protocol's `on_before_asset_added_to_account` callback when the //! issuing faucet's asset is added to an account vault (transfer "to" side) //! -//! The manager owns an `active_*_policy` slot per mint / burn kind (and dispatches them via -//! `dynexec`) plus an `allowed_*_policies` map per kind for set-time validation. The active roots -//! for send and receive policies reside directly in the protocol-reserved -//! callback slots so the kernel dispatches to them via `call`. +//! The manager owns an `active_*_policy` slot per kind plus an `allowed_*_policies` map per kind +//! for set-time validation. Mint and burn are dispatched via `dynexec` by `exec`-invoked +//! wrappers; send and receive are dispatched by `invoke_send_policy` / `invoke_receive_policy` +//! wrappers whose roots live in +//! the protocol-reserved callback slots, so the kernel `dyncall`s the wrapper, which applies the +//! pause check and then dispatches to the active policy. //! //! Authority for switching policies is provided by the separate //! [`Authority`][crate::account::access::Authority] component, which must be installed on the @@ -24,19 +26,19 @@ //! [`TransferAllowAll`]) install a specific policy procedure on the account so that the //! manager's `dynexec` can dispatch to it. //! -//! A faucet installs the manager via the chained builder -//! [`TokenPolicyManager::with_mint_policy`] / [`TokenPolicyManager::with_burn_policy`] / -//! [`TokenPolicyManager::with_send_policy`] / [`TokenPolicyManager::with_receive_policy`] and -//! passes it directly to [`miden_protocol::account::AccountBuilder::with_components`]. +//! A faucet constructs the manager via [`TokenPolicyManager::builder`], setting the required +//! `active_*_policy` for each kind (and optionally any number of reserved `allowed_*_policy` +//! entries), then passes the built manager directly to +//! [`miden_protocol::account::AccountBuilder::with_components`]. mod burn; mod manager; mod mint; mod transfer; -pub use burn::{BurnAllowAll, BurnOwnerOnly, BurnPolicyConfig}; -pub use manager::{TokenPolicyManager, TokenPolicyManagerError}; -pub use mint::{MintAllowAll, MintOwnerOnly, MintPolicyConfig}; +pub use burn::{BurnAllowAll, BurnOwnerOnly, BurnPolicy, BurnPolicyError, MinBurnAmount}; +pub use manager::{TokenPolicyManager, TokenPolicyManagerBuilder}; +pub use mint::{MintAllowAll, MintOwnerOnly, MintPolicy, MintPolicyError}; pub use transfer::{ AllowlistOwnerControlled, AllowlistStorage, @@ -46,20 +48,5 @@ pub use transfer::{ BlocklistStorage, TransferAllowAll, TransferPolicy, + TransferPolicyError, }; - -// POLICY REGISTRATION -// ================================================================================================ - -/// Indicates whether a policy entry is the currently active one (written into the -/// `active_*_policy` slot) or a reserved alternative (kept in the `allowed_*_policies` map for -/// future activation via `set_*_policy`). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PolicyRegistration { - /// Becomes the policy stored in the `active_*_policy` slot for its kind (mint, burn, send, - /// or receive). Exactly one `Active` entry is allowed per kind. - Active, - /// Registered in the `allowed_*_policies` map for its kind. Can be promoted to active - /// later by calling the matching `set_*_policy` procedure. - Reserved, -} diff --git a/crates/miden-standards/src/account/policies/transfer/basic_blocklist.rs b/crates/miden-standards/src/account/policies/transfer/basic_blocklist.rs index 79163c9864..0606652b0d 100644 --- a/crates/miden-standards/src/account/policies/transfer/basic_blocklist.rs +++ b/crates/miden-standards/src/account/policies/transfer/basic_blocklist.rs @@ -34,8 +34,8 @@ procedure_root!( /// [`BasicBlocklist::root`]. When active, transfers fail if the native account (asset /// recipient or note creator) is currently blocked on the issuing faucet. /// -/// The wrapped [`BTreeSet`] captures the initial blocklist contents (it can be -/// empty for a faucet that starts unblocked). Use [`Default`] for an empty blocklist or +/// The wrapped [`BlocklistStorage`] captures the initial blocklist contents (it can be empty +/// for a faucet that starts unblocked). Use [`Default`] for an empty blocklist or /// [`Self::with_blocked_accounts`] to seed the storage map at component construction time. /// /// Block / unblock administration is intentionally not part of this component. The @@ -43,7 +43,7 @@ procedure_root!( /// auth-wrapped admin component (see [`super::BlocklistOwnerControlled`]) to be safely exposed /// on a production faucet. #[derive(Debug, Clone, Default)] -pub struct BasicBlocklist(BTreeSet); +pub struct BasicBlocklist(BlocklistStorage); impl BasicBlocklist { /// The name of the component. @@ -57,12 +57,12 @@ impl BasicBlocklist { where I: IntoIterator, { - Self(blocked_accounts.into_iter().collect()) + Self(BlocklistStorage::with_blocked_accounts(blocked_accounts)) } /// Returns the initial blocked accounts captured in this component. pub fn blocked_accounts(&self) -> &BTreeSet { - &self.0 + self.0.blocked_accounts() } /// Returns the [`AccountComponentCode`] of this component. @@ -76,9 +76,14 @@ impl BasicBlocklist { } } +impl From for BasicBlocklist { + fn from(storage: BlocklistStorage) -> Self { + Self(storage) + } +} + impl From for AccountComponent { fn from(blocklist: BasicBlocklist) -> Self { - let storage = BlocklistStorage::with_blocked_accounts(blocklist.0); let storage_schema = StorageSchema::new([BlocklistStorage::blocked_accounts_slot_schema()]) .expect("storage schema should be valid"); @@ -89,7 +94,7 @@ impl From for AccountComponent { ) .with_storage_schema(storage_schema); - AccountComponent::new(BasicBlocklist::code().clone(), vec![storage.into_slot()], metadata) + AccountComponent::new(BasicBlocklist::code().clone(), vec![blocklist.0.into_slot()], metadata) .expect( "basic blocklist transfer policy component should satisfy the requirements of a valid account component", ) diff --git a/crates/miden-standards/src/account/policies/transfer/mod.rs b/crates/miden-standards/src/account/policies/transfer/mod.rs index e3cc573388..3e44e23eb9 100644 --- a/crates/miden-standards/src/account/policies/transfer/mod.rs +++ b/crates/miden-standards/src/account/policies/transfer/mod.rs @@ -1,17 +1,19 @@ -//! Transfer policy components and the transfer policy enum used by +//! Transfer policy components and the transfer policy descriptor used by //! [`super::TokenPolicyManager`] for both the send and receive policy kinds. //! //! Layout convention inside this module: //! - File at the root (e.g. `allow_all`, `basic_blocklist`, `basic_allowlist`) = a transfer policy -//! variant. Each exports a `check_policy` procedure that the kernel invokes via `call` through -//! the protocol-reserved callback slots. +//! variant. Each exports a `check_policy` procedure that the manager's `invoke_send_policy` / +//! `invoke_receive_policy` wrapper dispatches to via `dyncall` (after the pause check) once the +//! active policy is set. //! - Folder at the root (e.g. `blocklist`, `allowlist`) = a primitive bundle: storage namespace + //! helpers + auth-gated admin component(s) that maintain the storage. Primitives are not transfer //! policies by themselves; they are consumed by policy variants. use alloc::vec::Vec; -use miden_protocol::account::{AccountComponent, AccountProcedureRoot}; +use miden_protocol::account::{AccountComponent, AccountId, AccountProcedureRoot}; +use thiserror::Error; mod allow_all; mod allowlist; @@ -25,58 +27,134 @@ pub use basic_allowlist::BasicAllowlist; pub use basic_blocklist::BasicBlocklist; pub use blocklist::{BlocklistOwnerControlled, BlocklistStorage}; +// TRANSFER POLICY ERROR +// ================================================================================================ + +/// Errors returned by [`TransferPolicy::custom`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum TransferPolicyError { + /// The procedure root supplied to [`TransferPolicy::custom`] is not exported by any of + /// the provided components. + #[error( + "custom transfer policy root must match a procedure root in one of the provided components" + )] + RootNotInComponents, +} + // TRANSFER POLICY // ================================================================================================ -/// Selects a transfer policy variant for the send or receive kind on a -/// [`super::TokenPolicyManager`]. +/// Descriptor for the transfer policy registered with a [`super::TokenPolicyManager`] for either +/// the send or the receive kind. /// -/// The same variants apply to both send (`on_before_asset_added_to_note`) and receive +/// A transfer policy binds together the procedure root that the manager's `invoke_send_policy` / +/// `invoke_receive_policy` wrapper dispatches to (via `dyncall`, after the account-wide pause +/// check) with any companion [`AccountComponent`]s that must be installed on the account for that +/// procedure root to work. +/// +/// The same descriptor applies to both send (`on_before_asset_added_to_note`) and receive /// (`on_before_asset_added_to_account`) callbacks — the policy procedure receives no direction /// parameter and reads the relevant account context via `native_account::get_id`. -#[derive(Debug, Clone, Default)] -#[non_exhaustive] -pub enum TransferPolicy { - /// Active policy = [`TransferAllowAll::root`] (the callback predicate accepts unconditionally). - #[default] - AllowAll, - /// Active policy = [`BasicBlocklist::root`]. Resolves into a [`BasicBlocklist`] component - /// with an empty initial blocklist; to seed initial entries, install [`BasicBlocklist`] - /// explicitly via [`BasicBlocklist::with_blocked_accounts`] and select the policy via - /// [`TransferPolicy::Custom`] with [`BasicBlocklist::root`]. - Blocklist, - /// Active policy = [`BasicAllowlist::root`]. Carries the [`AllowlistStorage`] used to seed - /// the per-faucet `allowed_accounts` map at component-construction time. - Allowlist { allow_list: AllowlistStorage }, - /// Active policy = the provided root. The corresponding component(s) must be installed by - /// the caller separately; resolving this variant into built-in components yields an empty - /// list. - Custom(AccountProcedureRoot), +/// +/// The companion components carried by the descriptor are inlined into the account by the +/// [`super::TokenPolicyManager`] when it is converted into account components. +#[derive(Debug, Clone)] +pub struct TransferPolicy { + root: AccountProcedureRoot, + components: Vec, } impl TransferPolicy { - /// Returns the procedure root of the policy this variant resolves to. - pub fn root(&self) -> AccountProcedureRoot { - match self { - Self::AllowAll => TransferAllowAll::root(), - Self::Blocklist => BasicBlocklist::root(), - Self::Allowlist { .. } => BasicAllowlist::root(), - Self::Custom(root) => *root, + /// Returns a transfer policy that accepts every transfer unconditionally. + /// + /// Resolves to [`TransferAllowAll::root`] and ships the companion [`TransferAllowAll`] + /// component. + pub fn allow_all() -> Self { + Self { + root: TransferAllowAll::root(), + components: vec![TransferAllowAll.into()], } } - /// Returns the [`AccountComponent`]s that must accompany this transfer policy variant. + /// Returns a transfer policy that rejects transfers whose native account is in the + /// `blocked_accounts` map, starting with an empty blocklist. To seed initial entries use + /// [`Self::with_basic_blocklist`]. + pub fn empty_basic_blocklist() -> Self { + Self { + root: BasicBlocklist::root(), + components: vec![BasicBlocklist::default().into()], + } + } + + /// Returns a basic-blocklist transfer policy seeded with the given initial blocked accounts. + pub fn with_basic_blocklist(blocked_accounts: I) -> Self + where + I: IntoIterator, + { + Self { + root: BasicBlocklist::root(), + components: vec![BasicBlocklist::with_blocked_accounts(blocked_accounts).into()], + } + } + + /// Returns a transfer policy that rejects transfers whose native account is not in the + /// `allowed_accounts` map, starting with an empty allowlist (every transfer rejected). To + /// seed initial entries use [`Self::with_basic_allowlist`]. + pub fn empty_basic_allowlist() -> Self { + Self { + root: BasicAllowlist::root(), + components: vec![BasicAllowlist::default().into()], + } + } + + /// Returns a transfer policy that rejects transfers whose native account is not in the + /// `allowed_accounts` map. The provided [`AllowlistStorage`] seeds the initial allowlist + /// entries at component-construction time. + pub fn with_basic_allowlist(allow_list: AllowlistStorage) -> Self { + Self { + root: BasicAllowlist::root(), + components: vec![BasicAllowlist::from(allow_list).into()], + } + } + + /// Returns a transfer policy resolving to `root` and shipping the provided companion + /// `components` (anything that can be converted into an [`AccountComponent`]). /// - /// For [`Self::Blocklist`] this is a [`BasicBlocklist`] component with no initial blocked - /// accounts. For [`Self::Allowlist`] this is a [`BasicAllowlist`] component built from - /// the carried [`AllowlistStorage`]. For [`Self::Custom`] this is empty — the caller - /// installs whatever the chosen root requires. - pub(crate) fn into_components(self) -> Vec { - match self { - Self::AllowAll => vec![TransferAllowAll.into()], - Self::Blocklist => vec![BasicBlocklist::default().into()], - Self::Allowlist { allow_list } => vec![BasicAllowlist::from(allow_list).into()], - Self::Custom(_) => Vec::new(), + /// # Errors + /// + /// Returns [`TransferPolicyError::RootNotInComponents`] if `root` is not the procedure + /// root of any procedure exported by the provided components. + pub fn custom(root: AccountProcedureRoot, components: I) -> Result + where + I: IntoIterator, + I::Item: Into, + { + let components: Vec = components.into_iter().map(Into::into).collect(); + if !components.iter().any(|component| component.has_procedure(root)) { + return Err(TransferPolicyError::RootNotInComponents); } + Ok(Self { root, components }) + } + + /// Returns the procedure root of the policy this descriptor resolves to. + pub fn root(&self) -> AccountProcedureRoot { + self.root + } +} + +impl Default for TransferPolicy { + fn default() -> Self { + Self::allow_all() + } +} + +impl IntoIterator for TransferPolicy { + type Item = AccountComponent; + type IntoIter = alloc::vec::IntoIter; + + /// Yields the [`AccountComponent`]s carried by this transfer policy descriptor in + /// installation order. + fn into_iter(self) -> Self::IntoIter { + self.components.into_iter() } } diff --git a/crates/miden-standards/src/account/wallets/mod.rs b/crates/miden-standards/src/account/wallets/mod.rs index 9b9c7a14b7..159b7f2345 100644 --- a/crates/miden-standards/src/account/wallets/mod.rs +++ b/crates/miden-standards/src/account/wallets/mod.rs @@ -1,5 +1,3 @@ -use alloc::string::String; - use miden_protocol::account::component::{AccountComponentCode, AccountComponentMetadata}; use miden_protocol::account::{ Account, @@ -10,11 +8,9 @@ use miden_protocol::account::{ AccountType, }; use miden_protocol::errors::AccountError; -use thiserror::Error; -use super::AuthMethod; use crate::account::account_component_code; -use crate::account::auth::{AuthMultisig, AuthMultisigConfig, AuthSingleSig}; +use crate::account::auth::{AuthGuardedMultisig, AuthMultisig, AuthSingleSig}; use crate::procedure_root; // BASIC WALLET @@ -105,70 +101,54 @@ impl From for AccountComponent { } } -// BASIC WALLET ERROR -// ================================================================================================ - -/// Basic wallet related errors. -#[derive(Debug, Error)] -pub enum BasicWalletError { - #[error("unsupported authentication method: {0}")] - UnsupportedAuthMethod(String), - #[error("account creation failed")] - AccountError(#[source] AccountError), -} - -/// Creates a new account with basic wallet interface, the specified authentication scheme and the -/// account storage type. Basic wallets can be specified to have either mutable or immutable code. +/// Creates a new account with the basic wallet interface authenticated by the provided +/// [`AuthSingleSig`] component. /// -/// The basic wallet interface exposes three procedures: +/// The basic wallet interface exposes two procedures: /// - `receive_asset`, which can be used to add an asset to the account. /// - `move_asset_to_note`, which can be used to remove the specified asset from the account and add /// it to the output note with the specified index. /// -/// All methods require authentication. The authentication procedure is defined by the specified -/// authentication scheme. +/// For multisig-authenticated basic wallets, use [`create_multisig_wallet`] or +/// [`create_guarded_wallet`]. For anything else, use [`AccountBuilder`] directly. pub fn create_basic_wallet( init_seed: [u8; 32], - auth_method: AuthMethod, - account_storage_mode: AccountType, -) -> Result { - let auth_component: AccountComponent = match auth_method { - AuthMethod::SingleSig { approver: (pub_key, auth_scheme) } => { - AuthSingleSig::new(pub_key, auth_scheme).into() - }, - AuthMethod::Multisig { threshold, approvers } => { - let config = AuthMultisigConfig::new(approvers, threshold) - .and_then(|cfg| { - cfg.with_proc_thresholds(vec![(BasicWallet::receive_asset_root(), 1)]) - }) - .map_err(BasicWalletError::AccountError)?; - AuthMultisig::new(config).map_err(BasicWalletError::AccountError)?.into() - }, - AuthMethod::NoAuth => { - return Err(BasicWalletError::UnsupportedAuthMethod( - "basic wallets cannot be created with NoAuth authentication method".into(), - )); - }, - AuthMethod::NetworkAccount { .. } => { - return Err(BasicWalletError::UnsupportedAuthMethod( - "basic wallets cannot be created with NetworkAccount authentication method".into(), - )); - }, - AuthMethod::Unknown => { - return Err(BasicWalletError::UnsupportedAuthMethod( - "basic wallets cannot be created with Unknown authentication method".into(), - )); - }, - }; + auth_component: AuthSingleSig, + account_type: AccountType, +) -> Result { + AccountBuilder::new(init_seed) + .account_type(account_type) + .with_auth_component(auth_component) + .with_component(BasicWallet) + .build() +} - let account = AccountBuilder::new(init_seed) - .account_type(account_storage_mode) +/// Creates a new account with the basic wallet interface authenticated by the provided +/// [`AuthMultisig`] component. Same procedures as [`create_basic_wallet`]. +pub fn create_multisig_wallet( + init_seed: [u8; 32], + auth_component: AuthMultisig, + account_type: AccountType, +) -> Result { + AccountBuilder::new(init_seed) + .account_type(account_type) .with_auth_component(auth_component) .with_component(BasicWallet) .build() - .map_err(BasicWalletError::AccountError)?; +} - Ok(account) +/// Creates a new account with the basic wallet interface authenticated by the provided +/// [`AuthGuardedMultisig`] component. Same procedures as [`create_basic_wallet`]. +pub fn create_guarded_wallet( + init_seed: [u8; 32], + auth_component: AuthGuardedMultisig, + account_type: AccountType, +) -> Result { + AccountBuilder::new(init_seed) + .account_type(account_type) + .with_auth_component(auth_component) + .with_component(BasicWallet) + .build() } // TESTS @@ -180,7 +160,17 @@ mod tests { use miden_protocol::utils::serde::{Deserializable, Serializable}; use miden_protocol::{ONE, Word}; - use super::{Account, AccountType, AuthMethod, create_basic_wallet}; + use super::{ + Account, + AccountType, + AuthGuardedMultisig, + AuthMultisig, + AuthSingleSig, + create_basic_wallet, + create_guarded_wallet, + create_multisig_wallet, + }; + use crate::account::auth::{AuthGuardedMultisigConfig, AuthMultisigConfig, GuardianConfig}; use crate::account::wallets::BasicWallet; #[test] @@ -189,7 +179,7 @@ mod tests { let auth_scheme = auth::AuthScheme::Falcon512Poseidon2; let wallet = create_basic_wallet( [1; 32], - AuthMethod::SingleSig { approver: (pub_key, auth_scheme) }, + AuthSingleSig::new(pub_key, auth_scheme), AccountType::Public, ); @@ -204,7 +194,7 @@ mod tests { let auth_scheme = auth::AuthScheme::EcdsaK256Keccak; let wallet = create_basic_wallet( [1; 32], - AuthMethod::SingleSig { approver: (pub_key, auth_scheme) }, + AuthSingleSig::new(pub_key, auth_scheme), AccountType::Public, ) .unwrap(); @@ -220,4 +210,37 @@ mod tests { let _receive_asset_root = BasicWallet::receive_asset_root(); let _move_asset_to_note_root = BasicWallet::move_asset_to_note_root(); } + + #[test] + fn test_create_multisig_wallet() { + let pub_key_1 = PublicKeyCommitment::from(Word::from([1u32, 0, 0, 0])); + let pub_key_2 = PublicKeyCommitment::from(Word::from([2u32, 0, 0, 0])); + let approvers = vec![ + (pub_key_1, auth::AuthScheme::Falcon512Poseidon2), + (pub_key_2, auth::AuthScheme::Falcon512Poseidon2), + ]; + let config = AuthMultisigConfig::new(approvers, 2).unwrap(); + let auth = AuthMultisig::new(config).unwrap(); + + let wallet = create_multisig_wallet([2; 32], auth, AccountType::Private); + wallet.unwrap_or_else(|err| panic!("{}", err)); + } + + #[test] + fn test_create_guarded_wallet() { + let pub_key_1 = PublicKeyCommitment::from(Word::from([1u32, 0, 0, 0])); + let pub_key_2 = PublicKeyCommitment::from(Word::from([2u32, 0, 0, 0])); + let guardian_key = PublicKeyCommitment::from(Word::from([3u32, 0, 0, 0])); + let approvers = vec![ + (pub_key_1, auth::AuthScheme::Falcon512Poseidon2), + (pub_key_2, auth::AuthScheme::Falcon512Poseidon2), + ]; + let guardian_config = + GuardianConfig::new(guardian_key, auth::AuthScheme::Falcon512Poseidon2); + let config = AuthGuardedMultisigConfig::new(approvers, 2, guardian_config).unwrap(); + let auth = AuthGuardedMultisig::new(config).unwrap(); + + let wallet = create_guarded_wallet([3; 32], auth, AccountType::Private); + wallet.unwrap_or_else(|err| panic!("{}", err)); + } } diff --git a/crates/miden-standards/src/auth_method.rs b/crates/miden-standards/src/auth_method.rs deleted file mode 100644 index 1cb97350e7..0000000000 --- a/crates/miden-standards/src/auth_method.rs +++ /dev/null @@ -1,55 +0,0 @@ -use alloc::collections::BTreeSet; -use alloc::vec::Vec; - -use miden_protocol::account::auth::{AuthScheme, PublicKeyCommitment}; -use miden_protocol::note::NoteScriptRoot; - -/// Defines standard authentication methods supported by account auth components. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum AuthMethod { - /// A minimal authentication method that provides no cryptographic authentication. - /// - /// It only increments the nonce if the account state has actually changed during transaction - /// execution, avoiding unnecessary nonce increments for transactions that don't modify the - /// account state. - NoAuth, - /// A single-key authentication method which relies on either ECDSA or Falcon512Poseidon2 - /// signatures. - SingleSig { - approver: (PublicKeyCommitment, AuthScheme), - }, - /// A multi-signature authentication method using either ECDSA or Falcon512Poseidon2 signatures. - /// - /// Requires a threshold number of signatures from the provided public keys. - Multisig { - threshold: u32, - approvers: Vec<(PublicKeyCommitment, AuthScheme)>, - }, - /// An authentication method intended for network-owned accounts. - /// - /// It restricts the account to consuming only notes whose script roots are in - /// `allowed_script_roots`, and forbids transaction scripts from running against the account. - /// The allowlist must be non-empty. - NetworkAccount { - allowed_script_roots: BTreeSet, - }, - /// A non-standard authentication method. - Unknown, -} - -impl AuthMethod { - /// Returns all public key commitments associated with this authentication method. - /// - /// For unknown methods, an empty vector is returned. - pub fn get_public_key_commitments(&self) -> Vec { - match self { - AuthMethod::NoAuth => Vec::new(), - AuthMethod::SingleSig { approver: (pub_key, _) } => vec![*pub_key], - AuthMethod::Multisig { approvers, .. } => { - approvers.iter().map(|(pub_key, _)| *pub_key).collect() - }, - AuthMethod::NetworkAccount { .. } => Vec::new(), - AuthMethod::Unknown => Vec::new(), - } - } -} diff --git a/crates/miden-standards/src/lib.rs b/crates/miden-standards/src/lib.rs index 0a811b2ccf..5b9972d5ec 100644 --- a/crates/miden-standards/src/lib.rs +++ b/crates/miden-standards/src/lib.rs @@ -6,14 +6,12 @@ extern crate alloc; #[cfg(feature = "std")] extern crate std; -mod auth_method; -pub use auth_method::AuthMethod; - pub mod account; pub mod code_builder; pub mod errors; pub mod note; mod standards_lib; +pub mod tx_script; pub mod utils; pub use standards_lib::StandardsLib; diff --git a/crates/miden-standards/src/note/pswap.rs b/crates/miden-standards/src/note/pswap.rs index 14ea70ea7b..e52d5ef478 100644 --- a/crates/miden-standards/src/note/pswap.rs +++ b/crates/miden-standards/src/note/pswap.rs @@ -165,9 +165,8 @@ impl TryFrom<&[Felt]> for PswapNoteStorage { .map_err(|e| NoteError::other_with_source("failed to parse requested faucet ID", e))?; let amount = note_storage[3].as_canonical_u64(); - let requested_asset = FungibleAsset::new(faucet_id, amount) - .map_err(|e| NoteError::other_with_source("failed to create requested asset", e))? - .with_callbacks(callbacks); + let requested_asset = FungibleAsset::new(faucet_id, amount, callbacks) + .map_err(|e| NoteError::other_with_source("failed to create requested asset", e))?; // [4] = payback_note_type let payback_note_type = NoteType::try_from( @@ -412,9 +411,12 @@ impl PswapNote { let requested_faucet_id = self.storage.requested_faucet_id(); let total_requested_amount = self.storage.requested_asset_amount(); - let fill_asset = FungibleAsset::new(requested_faucet_id, total_requested_amount) - .map_err(|e| NoteError::other_with_source("failed to create full fill asset", e))? - .with_callbacks(self.storage.requested_asset().callbacks()); + let fill_asset = FungibleAsset::new( + requested_faucet_id, + total_requested_amount, + self.storage.requested_asset().callbacks(), + ) + .map_err(|e| NoteError::other_with_source("failed to create full fill asset", e))?; self.create_payback_note(consumer_account_id, fill_asset, total_requested_amount) } @@ -499,22 +501,21 @@ impl PswapNote { let remaining_offered = total_offered_amount - offered_amount_for_fill; let remaining_requested = total_requested_amount - fill_amount; - let remaining_offered_asset = - FungibleAsset::new(self.offered_asset.faucet_id(), remaining_offered) - .map_err(|e| { - NoteError::other_with_source("failed to create remainder asset", e) - })? - .with_callbacks(self.offered_asset.callbacks()); - - let remaining_requested_asset = - FungibleAsset::new(requested_faucet_id, remaining_requested) - .map_err(|e| { - NoteError::other_with_source( - "failed to create remaining requested asset", - e, - ) - })? - .with_callbacks(self.storage.requested_asset().callbacks()); + let remaining_offered_asset = FungibleAsset::new( + self.offered_asset.faucet_id(), + remaining_offered, + self.offered_asset.callbacks(), + ) + .map_err(|e| NoteError::other_with_source("failed to create remainder asset", e))?; + + let remaining_requested_asset = FungibleAsset::new( + requested_faucet_id, + remaining_requested, + self.storage.requested_asset().callbacks(), + ) + .map_err(|e| { + NoteError::other_with_source("failed to create remaining requested asset", e) + })?; Some(self.create_remainder_pswap_note( consumer_account_id, @@ -576,10 +577,12 @@ impl PswapNote { let recipient = P2idNoteStorage::new(self.storage.creator_account_id).into_recipient(p2id_serial); - let fill_asset = - FungibleAsset::new(self.storage.requested_faucet_id(), u64::from(attachment.amount())) - .map_err(|e| NoteError::other_with_source("invalid fill amount", e))? - .with_callbacks(self.storage.requested_asset().callbacks()); + let fill_asset = FungibleAsset::new( + self.storage.requested_faucet_id(), + u64::from(attachment.amount()), + self.storage.requested_asset().callbacks(), + ) + .map_err(|e| NoteError::other_with_source("invalid fill amount", e))?; let assets = NoteAssets::new(vec![fill_asset.into()])?; let metadata = @@ -629,14 +632,18 @@ impl PswapNote { self.serial_number[3] + Felt::from(depth), ]); - let requested_asset = - FungibleAsset::new(self.storage.requested_faucet_id(), u64::from(remaining_requested)) - .map_err(|e| NoteError::other_with_source("invalid remaining_requested amount", e))? - .with_callbacks(self.storage.requested_asset().callbacks()); - let offered_asset = - FungibleAsset::new(self.offered_asset.faucet_id(), u64::from(remaining_offered)) - .map_err(|e| NoteError::other_with_source("invalid remaining_offered amount", e))? - .with_callbacks(self.offered_asset.callbacks()); + let requested_asset = FungibleAsset::new( + self.storage.requested_faucet_id(), + u64::from(remaining_requested), + self.storage.requested_asset().callbacks(), + ) + .map_err(|e| NoteError::other_with_source("invalid remaining_requested amount", e))?; + let offered_asset = FungibleAsset::new( + self.offered_asset.faucet_id(), + u64::from(remaining_offered), + self.offered_asset.callbacks(), + ) + .map_err(|e| NoteError::other_with_source("invalid remaining_offered amount", e))?; let new_storage = PswapNoteStorage::builder() .requested_asset(requested_asset) @@ -949,8 +956,10 @@ mod tests { #[test] fn pswap_note_creation_and_script() { let creator_id = dummy_creator_id(); - let offered_asset = FungibleAsset::new(dummy_faucet_id(0xaa), 1000).unwrap(); - let requested_asset = FungibleAsset::new(dummy_faucet_id(0xbb), 500).unwrap(); + let offered_asset = + FungibleAsset::new(dummy_faucet_id(0xaa), 1000, AssetCallbackFlag::Disabled).unwrap(); + let requested_asset = + FungibleAsset::new(dummy_faucet_id(0xbb), 500, AssetCallbackFlag::Disabled).unwrap(); let (pswap, note) = build_pswap_note(offered_asset, requested_asset, creator_id); @@ -972,8 +981,10 @@ mod tests { #[test] fn pswap_note_builder() { let creator_id = dummy_creator_id(); - let offered_asset = FungibleAsset::new(dummy_faucet_id(0xaa), 1000).unwrap(); - let requested_asset = FungibleAsset::new(dummy_faucet_id(0xbb), 500).unwrap(); + let offered_asset = + FungibleAsset::new(dummy_faucet_id(0xaa), 1000, AssetCallbackFlag::Disabled).unwrap(); + let requested_asset = + FungibleAsset::new(dummy_faucet_id(0xbb), 500, AssetCallbackFlag::Disabled).unwrap(); let (pswap, note) = build_pswap_note(offered_asset, requested_asset, creator_id); @@ -1001,6 +1012,7 @@ mod tests { let offered_asset = FungibleAsset::new( AccountId::dummy(offered_faucet_bytes, AccountIdVersion::Version1, AccountType::Public), 100, + AssetCallbackFlag::Disabled, ) .unwrap(); let requested_asset = FungibleAsset::new( @@ -1010,6 +1022,7 @@ mod tests { AccountType::Public, ), 200, + AssetCallbackFlag::Disabled, ) .unwrap(); @@ -1035,7 +1048,8 @@ mod tests { #[test] fn pswap_note_storage_try_from() { let creator_id = dummy_creator_id(); - let requested_asset = FungibleAsset::new(dummy_faucet_id(0xaa), 500).unwrap(); + let requested_asset = + FungibleAsset::new(dummy_faucet_id(0xaa), 500, AssetCallbackFlag::Disabled).unwrap(); let storage_items = vec![ Felt::from(requested_asset.callbacks().as_u8()), @@ -1055,7 +1069,8 @@ mod tests { #[test] fn pswap_note_storage_roundtrip() { let creator_id = dummy_creator_id(); - let requested_asset = FungibleAsset::new(dummy_faucet_id(0xaa), 500).unwrap(); + let requested_asset = + FungibleAsset::new(dummy_faucet_id(0xaa), 500, AssetCallbackFlag::Disabled).unwrap(); let storage = PswapNoteStorage::builder() .requested_asset(requested_asset) @@ -1081,13 +1096,17 @@ mod tests { let requested_faucet = dummy_faucet_id(0xbb); // Offer 100 offered, request 50 requested → 2:1 ratio. - let offered_asset = FungibleAsset::new(offered_faucet, 100).unwrap(); - let requested_asset = FungibleAsset::new(requested_faucet, 50).unwrap(); + let offered_asset = + FungibleAsset::new(offered_faucet, 100, AssetCallbackFlag::Disabled).unwrap(); + let requested_asset = + FungibleAsset::new(requested_faucet, 50, AssetCallbackFlag::Disabled).unwrap(); let (pswap, _) = build_pswap_note(offered_asset, requested_asset, creator_id); // Account fill = 10, note fill = 20 → total fill = 30 (< 50, so partial). - let account_fill = FungibleAsset::new(requested_faucet, 10).unwrap(); - let note_fill = FungibleAsset::new(requested_faucet, 20).unwrap(); + let account_fill = + FungibleAsset::new(requested_faucet, 10, AssetCallbackFlag::Disabled).unwrap(); + let note_fill = + FungibleAsset::new(requested_faucet, 20, AssetCallbackFlag::Disabled).unwrap(); let (payback, remainder) = pswap.execute(consumer_id, Some(account_fill), Some(note_fill)).unwrap(); @@ -1119,13 +1138,17 @@ mod tests { let offered_faucet = dummy_faucet_id(0xaa); let requested_faucet = dummy_faucet_id(0xbb); - let offered_asset = FungibleAsset::new(offered_faucet, 100).unwrap(); - let requested_asset = FungibleAsset::new(requested_faucet, 50).unwrap(); + let offered_asset = + FungibleAsset::new(offered_faucet, 100, AssetCallbackFlag::Disabled).unwrap(); + let requested_asset = + FungibleAsset::new(requested_faucet, 50, AssetCallbackFlag::Disabled).unwrap(); let (pswap, _) = build_pswap_note(offered_asset, requested_asset, creator_id); // Account fill = 30, note fill = 20 → total fill = 50 (exactly requested). - let account_fill = FungibleAsset::new(requested_faucet, 30).unwrap(); - let note_fill = FungibleAsset::new(requested_faucet, 20).unwrap(); + let account_fill = + FungibleAsset::new(requested_faucet, 30, AssetCallbackFlag::Disabled).unwrap(); + let note_fill = + FungibleAsset::new(requested_faucet, 20, AssetCallbackFlag::Disabled).unwrap(); let (payback, remainder) = pswap.execute(consumer_id, Some(account_fill), Some(note_fill)).unwrap(); @@ -1155,18 +1178,15 @@ mod tests { let offered_faucet = dummy_faucet_id(0xaa); let requested_faucet = dummy_faucet_id(0xbb); - let offered_asset = FungibleAsset::new(offered_faucet, 100) - .unwrap() - .with_callbacks(AssetCallbackFlag::Enabled); - let requested_asset = FungibleAsset::new(requested_faucet, 50) - .unwrap() - .with_callbacks(AssetCallbackFlag::Enabled); + let offered_asset = + FungibleAsset::new(offered_faucet, 100, AssetCallbackFlag::Enabled).unwrap(); + let requested_asset = + FungibleAsset::new(requested_faucet, 50, AssetCallbackFlag::Enabled).unwrap(); let (pswap, _) = build_pswap_note(offered_asset, requested_asset, creator_id); // --- execute() (partial fill) --- - let account_fill = FungibleAsset::new(requested_faucet, 20) - .unwrap() - .with_callbacks(AssetCallbackFlag::Enabled); + let account_fill = + FungibleAsset::new(requested_faucet, 20, AssetCallbackFlag::Enabled).unwrap(); let (payback, remainder) = pswap.execute(consumer_id, Some(account_fill), None).unwrap(); let Asset::Fungible(fa) = payback.assets().iter().next().unwrap() else { diff --git a/crates/miden-standards/src/note/swap.rs b/crates/miden-standards/src/note/swap.rs index 17a7347af6..5985b40fac 100644 --- a/crates/miden-standards/src/note/swap.rs +++ b/crates/miden-standards/src/note/swap.rs @@ -261,7 +261,12 @@ impl From for NoteStorage { mod tests { use miden_protocol::account::{AccountIdVersion, AccountType}; - use miden_protocol::asset::{FungibleAsset, NonFungibleAsset, NonFungibleAssetDetails}; + use miden_protocol::asset::{ + AssetCallbackFlag, + FungibleAsset, + NonFungibleAsset, + NonFungibleAssetDetails, + }; use miden_protocol::note::{NoteStorage, NoteTag, NoteType}; use miden_protocol::testing::account_id::{ ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, @@ -279,11 +284,17 @@ mod tests { } fn fungible_asset() -> Asset { - Asset::Fungible(FungibleAsset::new(fungible_faucet(), 1000).unwrap()) + Asset::Fungible( + FungibleAsset::new(fungible_faucet(), 1000, AssetCallbackFlag::Disabled).unwrap(), + ) } fn non_fungible_asset() -> Asset { - let details = NonFungibleAssetDetails::new(non_fungible_faucet(), vec![0xaa, 0xbb]); + let details = NonFungibleAssetDetails::new( + non_fungible_faucet(), + vec![0xaa, 0xbb], + AssetCallbackFlag::Disabled, + ); Asset::NonFungible(NonFungibleAsset::new(&details)) } @@ -352,6 +363,7 @@ mod tests { AccountType::Public, ), 2500, + AssetCallbackFlag::Disabled, ) .unwrap(), ); @@ -364,6 +376,7 @@ mod tests { AccountType::Public, ), vec![0xaa, 0xbb, 0xcc, 0xdd], + AssetCallbackFlag::Disabled, ))); // The fungible ID starts with 0xcdb1. diff --git a/crates/miden-standards/src/testing/account_interface.rs b/crates/miden-standards/src/testing/account_interface.rs index 932c03ee4f..fb066973df 100644 --- a/crates/miden-standards/src/testing/account_interface.rs +++ b/crates/miden-standards/src/testing/account_interface.rs @@ -3,16 +3,76 @@ use alloc::vec::Vec; use miden_protocol::Word; use miden_protocol::account::Account; -use crate::account::interface::{AccountInterface, AccountInterfaceExt}; +use crate::account::auth::{ + AuthGuardedMultisig, + AuthMultisig, + AuthMultisigSmart, + AuthSingleSig, + AuthSingleSigAcl, +}; +use crate::account::interface::{AccountComponentInterface, AccountInterface, AccountInterfaceExt}; -/// Helper function to extract public keys from an account +/// Helper function to extract public key commitments from every standard auth component +/// installed on `account`. Reads storage directly via the component's slot accessors. pub fn get_public_keys_from_account(account: &Account) -> Vec { let interface = AccountInterface::from_account(account); + let storage = account.storage(); - interface - .auth() - .iter() - .flat_map(|auth| auth.get_public_key_commitments()) - .map(Word::from) + let mut keys = Vec::new(); + for component in interface.components() { + match component { + AccountComponentInterface::AuthSingleSig => { + if let Ok(key) = storage.get_item(AuthSingleSig::public_key_slot()) { + keys.push(key); + } + }, + AccountComponentInterface::AuthSingleSigAcl => { + if let Ok(key) = storage.get_item(AuthSingleSigAcl::public_key_slot()) { + keys.push(key); + } + }, + AccountComponentInterface::AuthMultisig => { + keys.extend(read_multisig_public_keys( + storage, + AuthMultisig::threshold_config_slot(), + AuthMultisig::approver_public_keys_slot(), + )); + }, + AccountComponentInterface::AuthGuardedMultisig => { + keys.extend(read_multisig_public_keys( + storage, + AuthGuardedMultisig::threshold_config_slot(), + AuthGuardedMultisig::approver_public_keys_slot(), + )); + }, + AccountComponentInterface::AuthMultisigSmart => { + keys.extend(read_multisig_public_keys( + storage, + AuthMultisigSmart::threshold_config_slot(), + AuthMultisigSmart::approver_public_keys_slot(), + )); + }, + _ => {}, + } + } + keys +} + +fn read_multisig_public_keys( + storage: &miden_protocol::account::AccountStorage, + config_slot: &miden_protocol::account::StorageSlotName, + keys_slot: &miden_protocol::account::StorageSlotName, +) -> Vec { + let config = match storage.get_item(config_slot) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + let num_approvers = config[1].as_canonical_u64() as u32; + (0..num_approvers) + .filter_map(|i| { + storage + .get_map_item(keys_slot, miden_protocol::account::StorageMapKey::from_index(i)) + .ok() + }) .collect() } diff --git a/crates/miden-standards/src/testing/faucet.rs b/crates/miden-standards/src/testing/faucet.rs new file mode 100644 index 0000000000..ead462d191 --- /dev/null +++ b/crates/miden-standards/src/testing/faucet.rs @@ -0,0 +1,53 @@ +use alloc::vec; +use alloc::vec::Vec; + +use miden_protocol::account::AccountProcedureRoot; +use miden_protocol::account::auth::{AuthScheme, PublicKeyCommitment}; +use miden_protocol::errors::AccountError; + +use crate::account::access::PausableManager; +use crate::account::auth::{AuthSingleSigAcl, AuthSingleSigAclConfig}; +use crate::account::faucets::FungibleFaucet; +use crate::account::policies::TokenPolicyManager; + +/// Returns every authority-gated setter procedure root exported by a fungible faucet account +/// (`mint_and_send`, the metadata setters, the policy setters, and `pause` / `unpause`). +/// +/// Under `Authority::AuthControlled` the auth component must authenticate calls to every +/// procedure in this list, otherwise the setters become permissionless. Use this when +/// constructing a custom [`AuthSingleSigAcl`] trigger procedure list; for the canonical +/// configuration, prefer [`user_faucet_single_sig_acl`]. +pub fn all_authority_gated_setter_roots() -> Vec { + vec![ + FungibleFaucet::mint_and_send_root(), + FungibleFaucet::set_max_supply_root(), + FungibleFaucet::set_description_root(), + FungibleFaucet::set_logo_uri_root(), + FungibleFaucet::set_external_link_root(), + TokenPolicyManager::set_mint_policy_root(), + TokenPolicyManager::set_burn_policy_root(), + TokenPolicyManager::set_send_policy_root(), + TokenPolicyManager::set_receive_policy_root(), + PausableManager::pause_root(), + PausableManager::unpause_root(), + ] +} + +/// Convenience constructor for the typical user-account fungible faucet auth component: an +/// [`AuthSingleSigAcl`] with the trigger procedure list set to +/// [`all_authority_gated_setter_roots`] and `allow_unauthorized_input_notes=true`. +/// +/// Production callers that need a different ACL shape should construct [`AuthSingleSigAcl`] +/// directly, optionally seeding the trigger list with [`all_authority_gated_setter_roots`]. +pub fn user_faucet_single_sig_acl( + pub_key: PublicKeyCommitment, + scheme: AuthScheme, +) -> Result { + AuthSingleSigAcl::new( + pub_key, + scheme, + AuthSingleSigAclConfig::new() + .with_auth_trigger_procedures(all_authority_gated_setter_roots()) + .with_allow_unauthorized_input_notes(true), + ) +} diff --git a/crates/miden-standards/src/testing/mock_account_code.rs b/crates/miden-standards/src/testing/mock_account_code.rs index 83a65dc149..d0e365d1ef 100644 --- a/crates/miden-standards/src/testing/mock_account_code.rs +++ b/crates/miden-standards/src/testing/mock_account_code.rs @@ -52,6 +52,13 @@ const MOCK_ACCOUNT_CODE: &str = " # => [VALUE, pad(12)] end + #! Inputs: [slot_id_prefix, slot_id_suffix, pad(14)] + #! Outputs: [has_slot, pad(15)] + pub proc has_storage_slot + exec.active_account::has_storage_slot + # => [has_slot, pad(15)] + end + #! Inputs: [slot_id_prefix, slot_id_suffix, pad(14)] #! Outputs: [VALUE, pad(12)] pub proc get_initial_item @@ -106,17 +113,17 @@ const MOCK_ACCOUNT_CODE: &str = " end #! Inputs: [ASSET_KEY, ASSET_VALUE, pad(8)] - #! Outputs: [ASSET_VALUE', pad(12)] + #! Outputs: [FINAL_ASSET_VALUE, pad(12)] pub proc add_asset exec.native_account::add_asset - # => [ASSET_VALUE', pad(12)] + # => [FINAL_ASSET_VALUE, pad(12)] end #! Inputs: [ASSET_KEY, ASSET_VALUE, pad(8)] - #! Outputs: [REMAINING_ASSET_VALUE, pad(12)] + #! Outputs: [FINAL_ASSET_VALUE, pad(12)] pub proc remove_asset exec.native_account::remove_asset - # => [REMAINING_ASSET_VALUE, pad(12)] + # => [FINAL_ASSET_VALUE, pad(12)] end #! Inputs: [pad(16)] diff --git a/crates/miden-standards/src/testing/mod.rs b/crates/miden-standards/src/testing/mod.rs index 01cf73f63c..bf5cbcc069 100644 --- a/crates/miden-standards/src/testing/mod.rs +++ b/crates/miden-standards/src/testing/mod.rs @@ -1,6 +1,8 @@ pub mod account_component; pub mod account_interface; +pub mod faucet; + pub mod mock_account; pub mod mock_account_code; diff --git a/crates/miden-standards/src/tx_script/expiration_script.rs b/crates/miden-standards/src/tx_script/expiration_script.rs new file mode 100644 index 0000000000..8dea281ec5 --- /dev/null +++ b/crates/miden-standards/src/tx_script/expiration_script.rs @@ -0,0 +1,97 @@ +use core::num::NonZeroU16; + +use miden_protocol::transaction::{TransactionScript, TransactionScriptRoot}; +use miden_protocol::utils::sync::LazyLock; +use miden_protocol::{Felt, Word}; + +use crate::code_builder::CodeBuilder; + +// EXPIRATION TRANSACTION SCRIPT +// ================================================================================================ + +/// Transaction script that sets the expiration delta. +const EXPIRATION_TX_SCRIPT_SOURCE: &str = "\ +use miden::protocol::tx + +#! Set the transaction's expiration delta. +#! +#! Inputs: [[delta, 0, 0, 0], pad(12)] +#! Outputs: [pad(16)] +#! +#! Panics if: +#! - delta is 0 or not a u32 in the range 1..=0xFFFF (ERR_TX_INVALID_EXPIRATION_DELTA). +#! +#! Invocation: call +begin + exec.tx::update_expiration_block_delta + # => [pad(16)] +end +"; + +static EXPIRATION_TX_SCRIPT: LazyLock = LazyLock::new(|| { + CodeBuilder::default() + .compile_tx_script(EXPIRATION_TX_SCRIPT_SOURCE) + .expect("canonical expiration tx script should compile") +}); + +/// The canonical transaction script that sets the transaction's expiration delta to the value +/// supplied in the first element of `TX_SCRIPT_ARGS`. +/// +/// This is the standard tx script a network account allowlists so that the network transaction +/// builder can bound how long a submitted transaction stays valid. Because the delta is an +/// input rather than hardcoded, the single [`ExpirationTransactionScript::script_root`] covers +/// every delta. It is safe to allowlist on an open network account even though an arbitrary +/// submitter controls the argument: the delta only bounds the inclusion window of the submitter's +/// own transaction - it cannot touch the account's nonce, state, or assets - and the kernel +/// hard-caps it at `0xFFFF` blocks. So the only thing the submitter decides is how soon their own +/// transaction must be included before it expires, within that fixed bound. +/// +/// The type pairs the script (via [`From`]) with the matching +/// `TX_SCRIPT_ARGS` ([`ExpirationTransactionScript::tx_script_args`]), so callers do not assemble +/// the argument word by hand: +/// +/// ```ignore +/// let script = ExpirationTransactionScript::new(delta); +/// let context = build_tx_context(/* .. */) +/// .tx_script(script.into()) +/// .tx_script_args(script.tx_script_args()); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ExpirationTransactionScript { + delta: NonZeroU16, +} + +impl ExpirationTransactionScript { + /// Creates an expiration script that sets the transaction's expiration block delta to `delta`. + /// + /// `delta` is a [`NonZeroU16`] because the kernel's `update_expiration_block_delta` only + /// accepts a delta in `1..=0xFFFF` and otherwise fails the transaction with + /// `ERR_TX_INVALID_EXPIRATION_DELTA`. Encoding that bound in the type keeps this constructor + /// infallible and guarantees the delta produced by [`Self::tx_script_args`] is always in + /// range. + pub fn new(delta: NonZeroU16) -> Self { + Self { delta } + } + + /// The `TX_SCRIPT_ARGS` word the script reads its delta from: `[delta, 0, 0, 0]`. + /// + /// Since `delta` is a [`NonZeroU16`], this word always carries an in-range delta, so the + /// script never triggers the kernel's range check. A caller that bypasses this type and + /// hand-crafts an out-of-range first element makes the kernel reject the transaction with + /// `ERR_TX_INVALID_EXPIRATION_DELTA`; it does not panic the host. + pub fn tx_script_args(&self) -> Word { + Word::from([Felt::from(self.delta.get()), Felt::ZERO, Felt::ZERO, Felt::ZERO]) + } + + /// The [`TransactionScriptRoot`] of the canonical script, to be allowlisted on a network + /// account via `AuthNetworkAccount::with_allowed_tx_scripts`. + pub fn script_root() -> TransactionScriptRoot { + EXPIRATION_TX_SCRIPT.root() + } +} + +impl From for TransactionScript { + fn from(_script: ExpirationTransactionScript) -> Self { + EXPIRATION_TX_SCRIPT.clone() + } +} diff --git a/crates/miden-standards/src/tx_script/mod.rs b/crates/miden-standards/src/tx_script/mod.rs new file mode 100644 index 0000000000..ef38377b2e --- /dev/null +++ b/crates/miden-standards/src/tx_script/mod.rs @@ -0,0 +1,5 @@ +mod expiration_script; +pub use expiration_script::ExpirationTransactionScript; + +mod send_notes_script; +pub use send_notes_script::{SendNotesTransactionScript, SendNotesTransactionScriptError}; diff --git a/crates/miden-standards/src/tx_script/send_notes_script.rs b/crates/miden-standards/src/tx_script/send_notes_script.rs new file mode 100644 index 0000000000..e80c0b9e66 --- /dev/null +++ b/crates/miden-standards/src/tx_script/send_notes_script.rs @@ -0,0 +1,285 @@ +use alloc::string::String; +use core::num::NonZeroU16; + +use miden_protocol::Felt; +use miden_protocol::account::{AccountCodeInterface, AccountId}; +use miden_protocol::note::PartialNote; +use miden_protocol::transaction::TransactionScript; +use thiserror::Error; + +use crate::account::access::Ownable2Step; +use crate::account::faucets::FungibleFaucet; +use crate::account::wallets::BasicWallet; +use crate::code_builder::CodeBuilder; +use crate::errors::CodeBuilderError; + +// SEND NOTES TRANSACTION SCRIPT +// ================================================================================================ + +/// A [`TransactionScript`] that sends the specified notes from an account whose code interface +/// exposes either the [`BasicWallet`] or [`FungibleFaucet`] procedures. +/// +/// Construction is fallible (see [`SendNotesTransactionScriptError`]); converting the wrapper into +/// the underlying [`TransactionScript`] via the [`From`] impl is infallible. +/// +/// Provided `expiration_delta` specifies how close to the transaction's reference block the +/// transaction must be included into the chain. For example, with a reference block of 100 and a +/// delta of 10, the transaction must be included by block 110. +/// +/// When the account exposes both [`BasicWallet`] and [`FungibleFaucet`] procedures, the faucet +/// branch is preferred. Owner-controlled faucets (those exposing [`Ownable2Step`]) mint +/// exclusively via MINT notes, so the standard `send_note` flow is rejected at script-build time +/// to avoid runtime failures under the OwnerOnly mint policy. +/// +/// # Example +/// +/// Example of the generated script with one output note and an expiration delta against a +/// [`FungibleFaucet`]: +/// +/// ```masm +/// begin +/// push.{expiration_delta} exec.::miden::protocol::tx::update_expiration_block_delta +/// +/// push.{note information} +/// +/// push.{ASSET_VALUE} push.{ASSET_KEY} +/// call.::miden::standards::faucets::fungible::mint_and_send +/// swapdw dropw dropw swapdw dropw dropw +/// end +/// ``` +#[derive(Debug, Clone)] +pub struct SendNotesTransactionScript(TransactionScript); + +impl SendNotesTransactionScript { + /// Builds a `send_notes` transaction script for the account described by `interface`, + /// without an expiration delta. + /// + /// See [`Self::with_expiration_delta`] for the variant that pins the transaction to a + /// reference-block delta. See the [type-level docs](Self) for the full list of error + /// conditions. + pub fn new( + interface: &AccountCodeInterface, + output_notes: &[PartialNote], + ) -> Result { + Self::build(interface, output_notes, "") + } + + /// Builds a `send_notes` transaction script for the account described by `interface`, + /// with the given non-zero expiration delta. + /// + /// See the [type-level docs](Self) for the full list of error conditions. + pub fn with_expiration_delta( + interface: &AccountCodeInterface, + output_notes: &[PartialNote], + expiration_delta: NonZeroU16, + ) -> Result { + let prelude = format!( + "push.{expiration_delta} exec.::miden::protocol::tx::update_expiration_block_delta\n" + ); + Self::build(interface, output_notes, &prelude) + } + + fn build( + interface: &AccountCodeInterface, + output_notes: &[PartialNote], + expiration_prelude: &str, + ) -> Result { + let sender = interface.id(); + + let has_mint_and_send = interface.contains([FungibleFaucet::mint_and_send_root()]); + let has_move_asset_to_note = interface.contains([BasicWallet::move_asset_to_note_root()]); + let is_owner_controlled = interface.contains(Ownable2Step::code().procedure_roots()); + + let body = if has_mint_and_send { + if is_owner_controlled { + return Err(SendNotesTransactionScriptError::UnsupportedAccountInterface); + } + mint_and_send_note_body(sender, output_notes)? + } else if has_move_asset_to_note { + move_asset_to_note_body(sender, output_notes)? + } else { + return Err(SendNotesTransactionScriptError::UnsupportedAccountInterface); + }; + + let script = format!("begin\n{expiration_prelude}\n{body}\nend"); + + let mut code_builder = CodeBuilder::new(); + for note in output_notes { + for attachment in note.attachments().iter() { + code_builder + .add_advice_map_entry(attachment.to_commitment(), attachment.to_elements()); + } + } + + let tx_script = code_builder + .compile_tx_script(script) + .map_err(SendNotesTransactionScriptError::InvalidTransactionScript)?; + + Ok(Self(tx_script)) + } +} + +impl From for TransactionScript { + fn from(value: SendNotesTransactionScript) -> Self { + value.0 + } +} + +// SEND NOTES SCRIPT ERROR +// ================================================================================================ + +/// Errors that can occur while building a [`SendNotesTransactionScript`]. +#[derive(Debug, Error)] +pub enum SendNotesTransactionScriptError { + #[error("note asset is not issued by faucet {0}")] + IssuanceFaucetMismatch(AccountId), + #[error("note created by the basic fungible faucet doesn't contain exactly one asset")] + FaucetNoteWithoutAsset, + #[error("invalid transaction script")] + InvalidTransactionScript(#[source] CodeBuilderError), + #[error("invalid sender account: {0}")] + InvalidSenderAccount(AccountId), + #[error( + "account does not contain the basic fungible faucet or basic wallet interfaces \ + which are needed to support the send_notes script generation" + )] + UnsupportedAccountInterface, +} + +// HELPER FUNCTIONS +// ================================================================================================ + +fn move_asset_to_note_body( + sender: AccountId, + notes: &[PartialNote], +) -> Result { + let mut body = String::new(); + for note in notes { + push_note_header(&mut body, sender, note)?; + + body.push_str( + " + exec.::miden::protocol::output_note::create + # => [note_idx, pad(16)]\n + ", + ); + + for asset in note.assets().iter() { + body.push_str(&format!( + " + # duplicate note index + padw push.0 push.0 push.0 dup.7 + # => [note_idx, pad(7), note_idx, pad(16)] + + push.{ASSET_VALUE} + push.{ASSET_KEY} + # => [ASSET_KEY, ASSET_VALUE, note_idx, pad(7), note_idx, pad(16)] + + call.::miden::standards::wallets::basic::move_asset_to_note + # => [pad(16), note_idx, pad(16)] + + dropw dropw dropw dropw + # => [note_idx, pad(16)]\n + ", + ASSET_KEY = asset.to_key_word(), + ASSET_VALUE = asset.to_value_word(), + )); + } + + push_attachments(&mut body, note); + finalize_note(&mut body); + } + Ok(body) +} + +fn mint_and_send_note_body( + sender: AccountId, + notes: &[PartialNote], +) -> Result { + let mut body = String::new(); + for note in notes { + push_note_header(&mut body, sender, note)?; + + if note.assets().num_assets() != 1 { + return Err(SendNotesTransactionScriptError::FaucetNoteWithoutAsset); + } + let asset = note.assets().iter().next().expect("note should contain an asset"); + if asset.faucet_id() != sender { + return Err(SendNotesTransactionScriptError::IssuanceFaucetMismatch(asset.faucet_id())); + } + + body.push_str(&format!( + " + push.{ASSET_VALUE} + push.{ASSET_KEY} + # => [ASSET_KEY, ASSET_VALUE, tag, note_type, RECIPIENT, pad(16)] + + call.::miden::standards::faucets::fungible::mint_and_send + # => [note_idx, pad(29)] + + swapdw dropw dropw swapdw dropw dropw + # => [note_idx, pad(13)]\n + ", + ASSET_KEY = asset.to_key_word(), + ASSET_VALUE = asset.to_value_word(), + )); + + push_attachments(&mut body, note); + finalize_note(&mut body); + } + Ok(body) +} + +fn push_note_header( + body: &mut String, + sender: AccountId, + note: &PartialNote, +) -> Result<(), SendNotesTransactionScriptError> { + if note.metadata().sender() != sender { + return Err(SendNotesTransactionScriptError::InvalidSenderAccount( + note.metadata().sender(), + )); + } + + body.push_str(&format!( + " + push.{recipient} + push.{note_type} + push.{tag} + # => [tag, note_type, RECIPIENT, pad(16)] + ", + recipient = note.recipient_digest(), + note_type = Felt::from(note.metadata().note_type()), + tag = Felt::from(note.metadata().tag()), + )); + + Ok(()) +} + +fn push_attachments(body: &mut String, note: &PartialNote) { + for attachment in note.attachments().iter() { + let attachment_scheme = attachment.attachment_scheme().as_u16(); + let attachment_commitment = attachment.content().to_commitment(); + + body.push_str(&format!( + " + dup + push.{attachment_commitment} + push.{attachment_scheme} + # => [attachment_scheme, ATTACHMENT_COMMITMENT, note_idx, note_idx, pad(16)] + exec.::miden::protocol::output_note::add_attachment + # => [note_idx, pad(16)] + ", + )); + } +} + +fn finalize_note(body: &mut String) { + body.push_str( + " + # drop the note idx + drop + # => [pad(16)] + ", + ); +} diff --git a/crates/miden-testing/Cargo.toml b/crates/miden-testing/Cargo.toml index bf31b773ba..bafbdb051a 100644 --- a/crates/miden-testing/Cargo.toml +++ b/crates/miden-testing/Cargo.toml @@ -22,11 +22,11 @@ tx_context_debug = [] [dependencies] # Workspace dependencies -miden-block-prover = { features = ["testing"], workspace = true } -miden-protocol = { features = ["testing"], workspace = true } -miden-standards = { features = ["testing"], workspace = true } -miden-tx = { features = ["testing"], workspace = true } -miden-tx-batch-prover = { features = ["testing"], workspace = true } +miden-block-prover = { features = ["testing"], workspace = true } +miden-protocol = { features = ["testing"], workspace = true } +miden-standards = { features = ["testing"], workspace = true } +miden-tx = { features = ["testing"], workspace = true } +miden-tx-batch = { features = ["testing"], workspace = true } # Miden dependencies miden-core-lib = { workspace = true } diff --git a/crates/miden-testing/src/assertion/mod.rs b/crates/miden-testing/src/assertion/mod.rs new file mode 100644 index 0000000000..185e618c8f --- /dev/null +++ b/crates/miden-testing/src/assertion/mod.rs @@ -0,0 +1,4 @@ +//! Tests for the execution-error assertion macros (`assert_execution_error!` and +//! `assert_transaction_executor_error!`), defined in [`crate::utils`]. + +mod test_assertion_macros; diff --git a/crates/miden-testing/src/assertion/test_assertion_macros.rs b/crates/miden-testing/src/assertion/test_assertion_macros.rs new file mode 100644 index 0000000000..f8a9cb6b0f --- /dev/null +++ b/crates/miden-testing/src/assertion/test_assertion_macros.rs @@ -0,0 +1,331 @@ +//! Tests for `assert_execution_error!` and `assert_transaction_executor_error!`. +//! +//! - Sync tests build errors directly to exercise the macro grammar (each arm + `#[should_panic]` +//! for failure paths). +//! - Async tests run small MASM programs on a [`CodeExecutor`] to cover real `ExecutionError` +//! variants end-to-end. + +use miden_assembly::SourceSpan; +use miden_processor::advice::AdviceError; +use miden_processor::operation::OperationError; +use miden_processor::{ExecutionError, ExecutionOptions, ExecutionOutput, Felt, MemoryError}; +use miden_tx::TransactionExecutorError; + +use crate::executor::CodeExecutor; +use crate::{ExecError, assert_execution_error, assert_transaction_executor_error}; + +// HELPERS +// ================================================================================================ + +fn op_error(err: OperationError) -> ExecutionError { + ExecutionError::OperationError { + label: SourceSpan::default(), + source_file: None, + err, + } +} + +fn failed_assertion(err_code: u64) -> ExecutionError { + op_error(OperationError::FailedAssertion { + err_code: Felt::new_unchecked(err_code), + err_msg: None, + }) +} + +fn exec_err(err: ExecutionError) -> Result<(), ExecError> { + Err(ExecError::new(err)) +} + +fn tx_err(err: ExecutionError) -> Result<(), TransactionExecutorError> { + Err(TransactionExecutorError::TransactionProgramExecutionFailed(err)) +} + +/// Assembles and runs `src` on a [`CodeExecutor`] with the default host and an empty stack. +/// Returned errors are wrapped in [`ExecError`]. +async fn run_masm(src: &str) -> Result { + CodeExecutor::with_default_host().run(src).await +} + +/// Same as [`run_masm`] but allows overriding [`ExecutionOptions`]. +async fn run_masm_with_options( + src: &str, + options: ExecutionOptions, +) -> Result { + CodeExecutor::with_default_host().execution_options(options).run(src).await +} + +// EXECUTION-ERROR ASSERTION TESTS — direct construction +// ================================================================================================ + +// `matches` arm — outer ExecutionError variants +#[test] +fn assert_execution_error_matches_outer_variant() { + let r = exec_err(ExecutionError::CycleLimitExceeded(42)); + assert_execution_error!(r, matches ExecutionError::CycleLimitExceeded(_)); + + let r = exec_err(ExecutionError::OutputStackOverflow(7)); + assert_execution_error!(r, matches ExecutionError::OutputStackOverflow(_)); +} + +// `matches` arm — inner OperationError variants +#[test] +fn assert_execution_error_matches_inner_operation_variant() { + let r = exec_err(op_error(OperationError::DivideByZero)); + assert_execution_error!( + r, + matches ExecutionError::OperationError { err: OperationError::DivideByZero, .. } + ); + + let r = exec_err(op_error(OperationError::LogArgumentZero)); + assert_execution_error!( + r, + matches ExecutionError::OperationError { err: OperationError::LogArgumentZero, .. } + ); +} + +// `matches` arm — pattern guard on FailedAssertion err_code +#[test] +fn assert_execution_error_matches_with_guard() { + let r = exec_err(failed_assertion(0x1234)); + assert_execution_error!( + r, + matches ExecutionError::OperationError { + err: OperationError::FailedAssertion { err_code, .. }, + .. + } if err_code == Felt::new_unchecked(0x1234) + ); +} + +#[test] +#[should_panic(expected = "Execution was unexpectedly successful")] +fn assert_execution_error_matches_panics_on_ok() { + let r: Result<(), ExecError> = Ok(()); + assert_execution_error!(r, matches ExecutionError::CycleLimitExceeded(_)); +} + +#[test] +#[should_panic(expected = "did not match")] +fn assert_execution_error_matches_panics_on_pattern_mismatch() { + let r = exec_err(ExecutionError::CycleLimitExceeded(1)); + assert_execution_error!(r, matches ExecutionError::OutputStackOverflow(_)); +} + +#[test] +#[should_panic(expected = "did not match")] +fn assert_execution_error_matches_panics_on_guard_mismatch() { + let r = exec_err(failed_assertion(0x1234)); + assert_execution_error!( + r, + matches ExecutionError::OperationError { + err: OperationError::FailedAssertion { err_code, .. }, + .. + } if err_code == Felt::new_unchecked(0xdead) + ); +} + +// TRANSACTION-EXECUTOR-ERROR ASSERTION TESTS — direct construction +// ================================================================================================ + +#[test] +fn assert_transaction_executor_error_matches_outer_variant() { + let r = tx_err(ExecutionError::CycleLimitExceeded(42)); + assert_transaction_executor_error!(r, matches ExecutionError::CycleLimitExceeded(_)); +} + +#[test] +fn assert_transaction_executor_error_matches_inner_operation_variant() { + let r = tx_err(op_error(OperationError::DivideByZero)); + assert_transaction_executor_error!( + r, + matches ExecutionError::OperationError { err: OperationError::DivideByZero, .. } + ); +} + +#[test] +fn assert_transaction_executor_error_matches_with_guard() { + let r = tx_err(failed_assertion(0xabcd)); + assert_transaction_executor_error!( + r, + matches ExecutionError::OperationError { + err: OperationError::FailedAssertion { err_code, .. }, + .. + } if err_code == Felt::new_unchecked(0xabcd) + ); +} + +#[test] +#[should_panic(expected = "did not match")] +fn assert_transaction_executor_error_matches_panics_on_pattern_mismatch() { + let r = tx_err(ExecutionError::CycleLimitExceeded(1)); + assert_transaction_executor_error!(r, matches ExecutionError::OutputStackOverflow(_)); +} + +// VM-DRIVEN VARIANT COVERAGE +// ================================================================================================ + +#[tokio::test] +async fn divide_by_zero() { + // 5 / 0 — top of stack is the divisor. + let r = run_masm("begin push.5 push.0 div end").await; + assert_execution_error!( + r, + matches ExecutionError::OperationError { + err: OperationError::DivideByZero, + .. + } + ); +} + +#[tokio::test] +async fn log_argument_zero() { + let r = run_masm("begin push.0 ilog2 end").await; + assert_execution_error!( + r, + matches ExecutionError::OperationError { + err: OperationError::LogArgumentZero, + .. + } + ); +} + +#[tokio::test] +async fn not_binary_value() { + let r = run_masm("begin push.2 push.1 and end").await; + assert_execution_error!( + r, + matches ExecutionError::OperationError { + err: OperationError::NotBinaryValue { .. }, + .. + } + ); +} + +#[tokio::test] +async fn not_binary_value_if() { + let r = run_masm("begin push.2 if.true push.0 drop else push.0 drop end end").await; + assert_execution_error!( + r, + matches ExecutionError::OperationError { + err: OperationError::NotBinaryValueIf { .. }, + .. + } + ); +} + +#[tokio::test] +async fn not_binary_value_loop() { + let r = run_masm("begin push.2 while.true push.0 end end").await; + assert_execution_error!( + r, + matches ExecutionError::OperationError { + err: OperationError::NotBinaryValueLoop { .. }, + .. + } + ); +} + +#[tokio::test] +async fn not_u32_values() { + let r = run_masm("begin push.4294967296 u32assert end").await; + assert_execution_error!( + r, + matches ExecutionError::OperationError { + err: OperationError::NotU32Values { .. }, + .. + } + ); +} + +#[tokio::test] +async fn vm_failed_assertion() { + let r = run_masm(r#"begin push.0 assert.err="custom failure" end"#).await; + assert_execution_error!( + r, + matches ExecutionError::OperationError { + err: OperationError::FailedAssertion { .. }, + .. + } + ); +} + +#[tokio::test] +async fn output_stack_overflow() { + // Default stack starts at depth 16; push 5 extras → 21 at end. + let r = run_masm("begin push.1 push.1 push.1 push.1 push.1 end").await; + assert_execution_error!(r, matches ExecutionError::OutputStackOverflow(_)); +} + +#[tokio::test] +async fn cycle_limit_exceeded() { + // Set max_cycles to MIN_TRACE_LEN (64); 100×push body trips it. + let body = "push.0 ".repeat(100); + let src = format!("begin {body} end"); + let options = ExecutionOptions::new(Some(64), 64, 4096, false, false) + .expect("max_cycles=64 satisfies MIN_TRACE_LEN"); + let r = run_masm_with_options(&src, options).await; + assert_execution_error!(r, matches ExecutionError::CycleLimitExceeded(_)); +} + +#[tokio::test] +async fn memory_unaligned_word_access() { + let r = run_masm("begin push.1 mem_loadw_be end").await; + assert_execution_error!( + r, + matches ExecutionError::MemoryError { + err: MemoryError::UnalignedWordAccess { .. }, + .. + } + ); +} + +#[tokio::test] +async fn advice_error_empty_stack() { + let r = run_masm("begin adv_push end").await; + assert_execution_error!( + r, + matches ExecutionError::AdviceError { + err: AdviceError::StackReadFailed, + .. + } + ); +} + +#[tokio::test] +async fn invalid_stack_depth_on_return() { + let src = " + proc bad + push.1 push.2 push.3 + end + begin + call.bad + end + "; + let r = run_masm(src).await; + assert_execution_error!( + r, + matches ExecutionError::OperationError { + err: OperationError::InvalidStackDepthOnReturn { .. }, + .. + } + ); +} + +#[tokio::test] +async fn event_error_unregistered() { + let src = r#" + const MY_EVENT=event("miden::testing::asserts::unregistered") + begin + emit.MY_EVENT + end + "#; + let r = run_masm(src).await; + assert_execution_error!(r, matches ExecutionError::EventError { .. }); +} + +#[tokio::test] +async fn procedure_not_found_via_dynexec() { + // `dynexec` looks up the digest popped from the stack in the host's MAST + // forest; a digest of zeros is not a real procedure root. + let r = run_masm("begin push.0.0.0.0 dynexec end").await; + assert_execution_error!(r, matches ExecutionError::ProcedureNotFound { .. }); +} diff --git a/crates/miden-testing/src/executor.rs b/crates/miden-testing/src/executor.rs index 346922abdd..b40ed17f6e 100644 --- a/crates/miden-testing/src/executor.rs +++ b/crates/miden-testing/src/executor.rs @@ -1,7 +1,15 @@ #[cfg(test)] use miden_processor::DefaultHost; use miden_processor::advice::AdviceInputs; -use miden_processor::{ExecutionError, ExecutionOutput, FastProcessor, Host, Program, StackInputs}; +use miden_processor::{ + ExecutionError, + ExecutionOptions, + ExecutionOutput, + FastProcessor, + Host, + Program, + StackInputs, +}; #[cfg(test)] use miden_protocol::assembly::Assembler; @@ -15,6 +23,7 @@ pub(crate) struct CodeExecutor { host: H, stack_inputs: Option, advice_inputs: AdviceInputs, + execution_options: Option, } impl CodeExecutor { @@ -25,6 +34,7 @@ impl CodeExecutor { host, stack_inputs: None, advice_inputs: AdviceInputs::default(), + execution_options: None, } } @@ -38,6 +48,13 @@ impl CodeExecutor { self } + /// Overrides the [`ExecutionOptions`] used to run the program (e.g. to cap `max_cycles`). + #[cfg(test)] + pub fn execution_options(mut self, options: ExecutionOptions) -> Self { + self.execution_options = Some(options); + self + } + /// Compiles and runs the desired code in the host and returns the [`Process`] state. #[cfg(test)] pub async fn run(self, code: &str) -> Result { @@ -70,6 +87,9 @@ impl CodeExecutor { .with_advice(self.advice_inputs) .map_err(ExecutionError::advice_error_no_context) .map_err(ExecError::new)? + .with_options(self.execution_options.unwrap_or_default()) + .map_err(ExecutionError::advice_error_no_context) + .map_err(ExecError::new)? .with_debugging(true); let execution_output = diff --git a/crates/miden-testing/src/kernel_tests/batch/batch_verifier.rs b/crates/miden-testing/src/kernel_tests/batch/batch_verifier.rs new file mode 100644 index 0000000000..90aeda65ec --- /dev/null +++ b/crates/miden-testing/src/kernel_tests/batch/batch_verifier.rs @@ -0,0 +1,47 @@ +use anyhow::Context; +use assert_matches::assert_matches; +use miden_tx_batch::{BatchExecutor, BatchVerifier, BatchVerifierError, LocalBatchProver}; + +use super::proposed_batch::setup_chain; +use super::test_batch_kernel::two_tx_batch; + +// BATCH VERIFIER TESTS +// ================================================================================================ + +/// A batch proven by the batch kernel verifies against the kernel program, and requiring a higher +/// security level than the proof provides is rejected. +#[test] +fn batch_verifier_accepts_freshly_proven_batch() -> anyhow::Result<()> { + let mut setup = setup_chain(); + let batch = two_tx_batch(&mut setup)?; + + let executed = BatchExecutor::new().execute(batch).context("batch execution failed")?; + let proven = LocalBatchProver::new().prove(executed).context("batch proving failed")?; + + let security_level = proven.proof_security_level(); + + // Requiring exactly the security level the proof provides must succeed. + BatchVerifier::new(security_level) + .verify(&proven) + .context("verifying the proven batch should succeed")?; + + // Requiring even one more bit than the proof provides must fail. + let err = BatchVerifier::new(security_level + 1).verify(&proven).unwrap_err(); + assert_matches!(err, BatchVerifierError::InsufficientProofSecurityLevel { .. }); + + Ok(()) +} + +/// The dummy proof carried by `prove_dummy` is not a valid batch-kernel proof and must be rejected. +#[test] +fn batch_verifier_rejects_dummy_proof() -> anyhow::Result<()> { + let mut setup = setup_chain(); + let batch = two_tx_batch(&mut setup)?; + + let proven = LocalBatchProver::new().prove_dummy(batch)?; + + let err = BatchVerifier::new(0).verify(&proven).unwrap_err(); + assert_matches!(err, BatchVerifierError::BatchVerificationFailed(_)); + + Ok(()) +} diff --git a/crates/miden-testing/src/kernel_tests/batch/mod.rs b/crates/miden-testing/src/kernel_tests/batch/mod.rs index b7dcf5b03d..3228ffeec0 100644 --- a/crates/miden-testing/src/kernel_tests/batch/mod.rs +++ b/crates/miden-testing/src/kernel_tests/batch/mod.rs @@ -1,2 +1,4 @@ -mod proposed_batch; +mod batch_verifier; +pub(super) mod proposed_batch; mod proven_tx_builder; +mod test_batch_kernel; diff --git a/crates/miden-testing/src/kernel_tests/batch/proposed_batch.rs b/crates/miden-testing/src/kernel_tests/batch/proposed_batch.rs index 7dedd8ec34..c9b0c57905 100644 --- a/crates/miden-testing/src/kernel_tests/batch/proposed_batch.rs +++ b/crates/miden-testing/src/kernel_tests/batch/proposed_batch.rs @@ -1,10 +1,13 @@ use alloc::sync::Arc; +use core::slice; use std::collections::BTreeMap; use anyhow::Context; use assert_matches::assert_matches; +use miden_crypto::rand::RandomCoin; use miden_protocol::Word; use miden_protocol::account::{Account, AccountId, AccountType}; +use miden_protocol::asset::NonFungibleAsset; use miden_protocol::batch::ProposedBatch; use miden_protocol::block::BlockNumber; use miden_protocol::crypto::merkle::MerkleError; @@ -15,6 +18,7 @@ use miden_protocol::note::{ NoteAttachments, NoteTag, NoteType, + PartialNote, PartialNoteMetadata, }; use miden_protocol::testing::account_id::AccountIdBuilder; @@ -23,11 +27,15 @@ use miden_protocol::transaction::{ InputNoteCommitment, OutputNote, PartialBlockchain, + ProvenTransaction, RawOutputNote, + TransactionScript, }; use miden_standards::note::P2idNoteStorage; use miden_standards::testing::account_component::MockAccountComponent; use miden_standards::testing::note::NoteBuilder; +use miden_standards::tx_script::SendNotesTransactionScript; +use miden_tx::LocalTransactionProver; use rand::rngs::SmallRng; use rand::{Rng, SeedableRng}; @@ -48,14 +56,14 @@ pub fn mock_output_note(num: u8) -> OutputNote { RawOutputNote::Full(mock_note(num)).into_output_note().unwrap() } -struct TestSetup { - chain: MockChain, - account1: Account, - account2: Account, - note1: Note, +pub struct TestSetup { + pub chain: MockChain, + pub account1: Account, + pub account2: Account, + pub note1: Note, } -fn setup_chain() -> TestSetup { +pub fn setup_chain() -> TestSetup { let mut builder = MockChain::builder(); let account1 = generate_account(&mut builder); let account2 = generate_account(&mut builder); @@ -77,6 +85,64 @@ fn generate_account(chain: &mut MockChainBuilder) -> Account { .expect("failed to add pending account from builder") } +/// Instantiates a chain and sets up the following transactions: +/// +/// TX 1: Inputs [X] -> Outputs [Y] +/// TX 2: Inputs [Y] -> Outputs [X] +pub async fn setup_circular_note_dependency_test() +-> anyhow::Result<(MockChain, ProvenTransaction, ProvenTransaction)> { + // Use a non-fungible asset whose faucet is different from the executing account. + let asset = NonFungibleAsset::mock(&[42]); + + let mut builder = MockChain::builder(); + let account = builder.add_existing_wallet_with_assets(Auth::IncrNonce, [])?; + let chain = builder.build()?; + + // Create two distinct p2any notes carrying the same arbitrary asset. + let mut rng = RandomCoin::new(Word::from([1u32; 4])); + let note_x = create_p2any_note(account.id(), NoteType::Public, [asset], &mut rng); + let note_y = create_p2any_note(account.id(), NoteType::Public, [asset], &mut rng); + + // The notes share the same sender but have distinct IDs. + assert_eq!(note_x.metadata().sender(), note_y.metadata().sender()); + assert_ne!(note_x.id(), note_y.id()); + + let tx_script_y = TransactionScript::from(SendNotesTransactionScript::new( + &account.code_interface(), + &[PartialNote::from(note_y.clone())], + )?); + // TX 1: consume note_x -> create note_y. + // The tx script creates note_y with the asset out of thin air. + let executed_tx1 = chain + .build_tx_context(account.clone(), &[], slice::from_ref(¬e_x))? + .tx_script(tx_script_y) + .extend_expected_output_notes(vec![RawOutputNote::Full(note_y.clone())]) + .build()? + .execute() + .await?; + let proven_tx1 = LocalTransactionProver::default().prove_dummy(executed_tx1.clone())?; + + // Apply the account delta from TX1 to obtain the updated account state for TX2. + let mut updated_account = account.clone(); + updated_account.apply_patch(executed_tx1.account_patch())?; + + let tx_script_x = TransactionScript::from(SendNotesTransactionScript::new( + &account.code_interface(), + &[PartialNote::from(note_x.clone())], + )?); + // TX 2: consume note_y -> create note_x (output via tx script). + let executed_tx2 = chain + .build_tx_context(updated_account, &[], slice::from_ref(¬e_y))? + .tx_script(tx_script_x) + .extend_expected_output_notes(vec![RawOutputNote::Full(note_x.clone())]) + .build()? + .execute() + .await?; + let proven_tx2 = LocalTransactionProver::default().prove_dummy(executed_tx2)?; + + Ok((chain, proven_tx1, proven_tx2)) +} + /// Tests that a note created and consumed in the same batch are erased from the input and /// output note commitments. #[test] @@ -84,15 +150,57 @@ fn empty_transaction_batch() -> anyhow::Result<()> { let TestSetup { chain, .. } = setup_chain(); let block1 = chain.block_header(1); - let error = - ProposedBatch::new(vec![], block1, chain.latest_partial_blockchain(), BTreeMap::default()) - .unwrap_err(); + let error = ProposedBatch::new_unverified( + vec![], + block1, + chain.latest_partial_blockchain(), + BTreeMap::default(), + ) + .unwrap_err(); assert_matches!(error, ProposedBatchError::EmptyTransactionBatch); Ok(()) } +/// Tests that unauthenticated notes created and consumed in transactions in a batch are rejected +/// when provided in an incorrect order. +#[test] +fn incorrectly_ordered_txs_rejected() -> anyhow::Result<()> { + let TestSetup { mut chain, account1, account2, .. } = setup_chain(); + let block1 = chain.block_header(1); + let block2 = chain.prove_next_block()?; + + let note = mock_note(40); + let tx1 = + MockProvenTxBuilder::with_account(account1.id(), Word::empty(), account1.to_commitment()) + .reference_block(&block1) + .output_notes(vec![RawOutputNote::Full(note.clone()).into_output_note().unwrap()]) + .build()?; + let tx2 = + MockProvenTxBuilder::with_account(account2.id(), Word::empty(), account2.to_commitment()) + .reference_block(&block1) + .unauthenticated_notes(vec![note.clone()]) + .build()?; + + // Provide the transactions in the wrong order, should be tx1, tx2. + let error = ProposedBatch::new_unverified( + [tx2.clone(), tx1.clone()].into_iter().map(Arc::new).collect(), + block2.header().clone(), + chain.latest_partial_blockchain(), + BTreeMap::default(), + ) + .unwrap_err(); + + assert_matches!(error, ProposedBatchError::NoteConsumedBeforeCreated { note_id, consumed_by, created_by } => { + assert_eq!(note_id, note.id()); + assert_eq!(consumed_by, tx2.id()); + assert_eq!(created_by, tx1.id()); + }); + + Ok(()) +} + /// Tests that a note created and consumed in the same batch are erased from the input and /// output note commitments. #[test] @@ -113,7 +221,7 @@ fn note_created_and_consumed_in_same_batch() -> anyhow::Result<()> { .unauthenticated_notes(vec![note.clone()]) .build()?; - let batch = ProposedBatch::new( + let batch = ProposedBatch::new_unverified( [tx1, tx2].into_iter().map(Arc::new).collect(), block2.header().clone(), chain.latest_partial_blockchain(), @@ -163,7 +271,7 @@ fn same_details_different_metadata_not_erased_from_batch() -> anyhow::Result<()> .unauthenticated_notes(vec![input_note.clone()]) .build()?; - let batch = ProposedBatch::new( + let batch = ProposedBatch::new_unverified( [tx1, tx2].into_iter().map(Arc::new).collect(), block2.header().clone(), chain.latest_partial_blockchain(), @@ -215,7 +323,7 @@ fn two_p2id_inputs_same_details_different_metadata_in_same_batch() -> anyhow::Re .authenticated_notes(vec![note_300.clone(), note_301.clone()]) .build()?; - let batch = ProposedBatch::new( + let batch = ProposedBatch::new_unverified( vec![Arc::new(tx)], block2.header().clone(), chain.latest_partial_blockchain(), @@ -246,7 +354,7 @@ fn duplicate_unauthenticated_input_notes() -> anyhow::Result<()> { .unauthenticated_notes(vec![note.clone()]) .build()?; - let error = ProposedBatch::new( + let error = ProposedBatch::new_unverified( [tx1.clone(), tx2.clone()].into_iter().map(Arc::new).collect(), block1, chain.latest_partial_blockchain(), @@ -285,7 +393,7 @@ fn duplicate_authenticated_input_notes() -> anyhow::Result<()> { .authenticated_notes(vec![note1.clone()]) .build()?; - let error = ProposedBatch::new( + let error = ProposedBatch::new_unverified( [tx1.clone(), tx2.clone()].into_iter().map(Arc::new).collect(), block2.header().clone(), chain.latest_partial_blockchain(), @@ -324,7 +432,7 @@ fn duplicate_mixed_input_notes() -> anyhow::Result<()> { .authenticated_notes(vec![note1.clone()]) .build()?; - let error = ProposedBatch::new( + let error = ProposedBatch::new_unverified( [tx1.clone(), tx2.clone()].into_iter().map(Arc::new).collect(), block2.header().clone(), chain.latest_partial_blockchain(), @@ -363,7 +471,7 @@ fn duplicate_output_notes() -> anyhow::Result<()> { .output_notes(vec![note0.clone()]) .build()?; - let error = ProposedBatch::new( + let error = ProposedBatch::new_unverified( [tx1.clone(), tx2.clone()].into_iter().map(Arc::new).collect(), block1, chain.latest_partial_blockchain(), @@ -443,7 +551,7 @@ async fn unauthenticated_note_converted_to_authenticated() -> anyhow::Result<()> // Case 1: Error: A wrong proof is passed. // -------------------------------------------------------------------------------------------- - let error = ProposedBatch::new( + let error = ProposedBatch::new_unverified( [tx1.clone()].into_iter().map(Arc::new).collect(), block3.header().clone(), partial_blockchain.clone(), @@ -472,7 +580,7 @@ async fn unauthenticated_note_converted_to_authenticated() -> anyhow::Result<()> .filter(|header| header.block_num() != block1.header().block_num()) .cloned(); - let error = ProposedBatch::new( + let error = ProposedBatch::new_unverified( [tx1.clone()].into_iter().map(Arc::new).collect(), block3.header().clone(), PartialBlockchain::new(mmr, blocks) @@ -495,7 +603,7 @@ async fn unauthenticated_note_converted_to_authenticated() -> anyhow::Result<()> // Case 3: Success: The correct proof is passed. // -------------------------------------------------------------------------------------------- - let batch = ProposedBatch::new( + let batch = ProposedBatch::new_unverified( [tx1].into_iter().map(Arc::new).collect(), block3.header().clone(), partial_blockchain, @@ -544,7 +652,7 @@ fn authenticated_note_created_in_same_batch() -> anyhow::Result<()> { .authenticated_notes(vec![note1.clone()]) .build()?; - let batch = ProposedBatch::new( + let batch = ProposedBatch::new_unverified( [tx1, tx2].into_iter().map(Arc::new).collect(), block2.header().clone(), chain.latest_partial_blockchain(), @@ -587,7 +695,7 @@ fn multiple_transactions_against_same_account() -> anyhow::Result<()> { .build()?; // Success: Transactions are correctly ordered. - let batch = ProposedBatch::new( + let batch = ProposedBatch::new_unverified( [tx1.clone(), tx2.clone()].into_iter().map(Arc::new).collect(), block1.clone(), chain.latest_partial_blockchain(), @@ -607,7 +715,7 @@ fn multiple_transactions_against_same_account() -> anyhow::Result<()> { ); // Error: Transactions are incorrectly ordered. - let error = ProposedBatch::new( + let error = ProposedBatch::new_unverified( [tx2.clone(), tx1.clone()].into_iter().map(Arc::new).collect(), block1, chain.latest_partial_blockchain(), @@ -652,21 +760,20 @@ fn input_and_output_notes_commitment() -> anyhow::Result<()> { let tx1 = MockProvenTxBuilder::with_account(account1.id(), Word::empty(), account1.to_commitment()) .reference_block(&block1) - .unauthenticated_notes(vec![note1.clone(), note5.clone()]) - .output_notes(vec![note0.clone()]) + .unauthenticated_notes(vec![note5.clone()]) + .output_notes(vec![ + RawOutputNote::Full(note1.clone()).into_output_note().unwrap(), + note0.clone(), + ]) .build()?; let tx2 = MockProvenTxBuilder::with_account(account2.id(), Word::empty(), account2.to_commitment()) .reference_block(&block1) - .unauthenticated_notes(vec![note4.clone(), note6.clone()]) - .output_notes(vec![ - RawOutputNote::Full(note1.clone()).into_output_note().unwrap(), - note2.clone(), - note3.clone(), - ]) + .unauthenticated_notes(vec![note1, note4.clone(), note6.clone()]) + .output_notes(vec![note2.clone(), note3.clone()]) .build()?; - let batch = ProposedBatch::new( + let batch = ProposedBatch::new_unverified( [tx1.clone(), tx2.clone()].into_iter().map(Arc::new).collect(), block1, chain.latest_partial_blockchain(), @@ -687,10 +794,9 @@ fn input_and_output_notes_commitment() -> anyhow::Result<()> { InputNoteCommitment::from(&InputNote::unauthenticated(note5)), InputNoteCommitment::from(&InputNote::unauthenticated(note6)), ]; - // We expect a vector sorted by Nullifier (since InputOutputNoteTracker is set up that way). + // We expect a vector sorted by Nullifier (since input_output_note_tracker is set up that way). expected_input_notes.sort_unstable_by_key(InputNoteCommitment::nullifier); - // Input notes are sorted by the order in which they appeared in the batch. assert_eq!(batch.input_notes().num_notes(), 3); assert_eq!(batch.input_notes().clone().into_vec(), &expected_input_notes); @@ -716,7 +822,7 @@ fn batch_expiration() -> anyhow::Result<()> { .expiration_block_num(block1.block_num() + 1) .build()?; - let batch = ProposedBatch::new( + let batch = ProposedBatch::new_unverified( [tx1, tx2].into_iter().map(Arc::new).collect(), block1.clone(), chain.latest_partial_blockchain(), @@ -740,7 +846,7 @@ fn duplicate_transaction() -> anyhow::Result<()> { .expiration_block_num(BlockNumber::from(35)) .build()?; - let error = ProposedBatch::new( + let error = ProposedBatch::new_unverified( [tx1.clone(), tx1.clone()].into_iter().map(Arc::new).collect(), block1, chain.latest_partial_blockchain(), @@ -753,39 +859,94 @@ fn duplicate_transaction() -> anyhow::Result<()> { Ok(()) } -/// Tests that transactions with a circular dependency between notes are accepted: +/// Tests that transactions with a circular dependency between notes are rejected. +/// +/// Both transactions execute against the same account. The two p2any notes share the same +/// sender (the executing account) and carry the same asset, but have different serial numbers +/// so that they are distinct notes with different IDs. +/// /// TX 1: Inputs [X] -> Outputs [Y] /// TX 2: Inputs [Y] -> Outputs [X] -#[test] -fn circular_note_dependency() -> anyhow::Result<()> { - let TestSetup { chain, account1, account2, .. } = setup_chain(); - let block1 = chain.block_header(1); +#[tokio::test] +async fn cross_tx_circular_note_dependency_is_rejected() -> anyhow::Result<()> { + let (chain, proven_tx1, proven_tx2) = setup_circular_note_dependency_test().await?; + + let error = ProposedBatch::new_unverified( + [proven_tx1, proven_tx2].into_iter().map(Arc::new).collect(), + chain.latest_block_header(), + chain.latest_partial_blockchain(), + BTreeMap::default(), + ) + .unwrap_err(); - let note_x = mock_note(20); - let note_y = mock_note(30); + // A circular dependency is detected as consumption-before-creation. + assert_matches!(error, ProposedBatchError::NoteConsumedBeforeCreated { .. }); - let tx1 = - MockProvenTxBuilder::with_account(account1.id(), Word::empty(), account1.to_commitment()) - .reference_block(&block1) - .unauthenticated_notes(vec![note_x.clone()]) - .output_notes(vec![RawOutputNote::Full(note_y.clone()).into_output_note().unwrap()]) - .build()?; - let tx2 = - MockProvenTxBuilder::with_account(account2.id(), Word::empty(), account2.to_commitment()) - .reference_block(&block1) - .unauthenticated_notes(vec![note_y.clone()]) - .output_notes(vec![RawOutputNote::Full(note_x.clone()).into_output_note().unwrap()]) - .build()?; + Ok(()) +} - let batch = ProposedBatch::new( - [tx1, tx2].into_iter().map(Arc::new).collect(), - block1, +/// Tests that a non-fungible asset minted out of thin air cannot be used by an account in valid +/// ways. +/// +/// TX 1: Inputs [X] -> Outputs [ ] +/// TX 2: Inputs [ ] -> Outputs [X] +#[tokio::test] +async fn cross_tx_circular_note_dependency_is_rejected_2() -> anyhow::Result<()> { + // Use a non-fungible asset whose faucet is different from the executing account. + let asset = NonFungibleAsset::mock(&[42]); + + let mut builder = MockChain::builder(); + let account = builder.add_existing_wallet_with_assets(Auth::IncrNonce, [])?; + let chain = builder.build()?; + + // Create two distinct p2any notes carrying the same arbitrary asset. + let mut rng = RandomCoin::new(Word::from([1u32; 4])); + let note_x = create_p2any_note(account.id(), NoteType::Public, [asset], &mut rng); + + // TX 1: consume note_x to move the asset to the vault. + let executed_tx1 = chain + .build_tx_context(account.clone(), &[], slice::from_ref(¬e_x))? + .build()? + .execute() + .await?; + let proven_tx1 = LocalTransactionProver::default().prove_dummy(executed_tx1.clone())?; + + // Apply the account delta from TX1 to obtain the updated account state for TX2. + let mut updated_account = account.clone(); + updated_account.apply_patch(executed_tx1.account_patch())?; + + assert_eq!(updated_account.vault().get(asset.vault_key()).unwrap(), asset); + + let tx_script_x = TransactionScript::from(SendNotesTransactionScript::new( + &account.code_interface(), + &[PartialNote::from(note_x.clone())], + )?); + // TX 2: create note_x with the asset from the account vault. + let executed_tx2 = chain + .build_tx_context(updated_account, &[], &[])? + .tx_script(tx_script_x) + .extend_expected_output_notes(vec![RawOutputNote::Full(note_x.clone())]) + .build()? + .execute() + .await?; + assert_eq!( + executed_tx2.output_notes().get_note(0).assets().iter().next().unwrap(), + &asset, + "asset should have been moved to the note" + ); + let proven_tx2 = LocalTransactionProver::default().prove_dummy(executed_tx2)?; + + let error = ProposedBatch::new_unverified( + [proven_tx1, proven_tx2].into_iter().map(Arc::new).collect(), + chain.latest_block_header(), chain.latest_partial_blockchain(), BTreeMap::default(), - )?; + ) + .unwrap_err(); - assert_eq!(batch.input_notes().num_notes(), 0); - assert_eq!(batch.output_notes().len(), 0); + // Tx1 cannot depend on an input note that is created by transaction 2. This circular dependency + // is detected as consumption-before-creation. + assert_matches!(error, ProposedBatchError::NoteConsumedBeforeCreated { .. }); Ok(()) } @@ -808,7 +969,7 @@ fn expired_transaction() -> anyhow::Result<()> { .expiration_block_num(block1.block_num() + 3) .build()?; - let error = ProposedBatch::new( + let error = ProposedBatch::new_unverified( [tx1.clone(), tx2].into_iter().map(Arc::new).collect(), block1.clone(), chain.latest_partial_blockchain(), @@ -867,7 +1028,7 @@ fn noop_tx_before_state_updating_tx_against_same_account() -> anyhow::Result<()> .unauthenticated_notes(vec![note.clone()]) .build()?; - let batch = ProposedBatch::new( + let batch = ProposedBatch::new_unverified( [noop_tx1, tx2].into_iter().map(Arc::new).collect(), block2.header().clone(), chain.latest_partial_blockchain(), @@ -936,7 +1097,7 @@ fn mismatched_ref_block_commitment_rejected() -> anyhow::Result<()> { // chain_b's partial blockchain contains block 1, but with chain_b's commitment - not chain_a's. // ProposedBatch::new should reject this transaction because its ref_block_commitment doesn't // match the commitment of block 1 in the partial blockchain. - let result = ProposedBatch::new( + let result = ProposedBatch::new_unverified( vec![Arc::new(tx.clone())], chain_b_block2.header().clone(), chain_b.latest_partial_blockchain(), @@ -968,7 +1129,7 @@ fn mismatched_ref_block_commitment_rejected() -> anyhow::Result<()> { "tx and batch ref block num should match" ); - let result = ProposedBatch::new( + let result = ProposedBatch::new_unverified( vec![Arc::new(tx.clone())], ref_block.clone(), partial_blockchain, @@ -1009,10 +1170,10 @@ fn noop_tx_after_state_updating_tx_against_same_account() -> anyhow::Result<()> random_final_state_commitment, ) .reference_block(&block1) - .unauthenticated_notes(vec![note.clone()]) + .output_notes(vec![RawOutputNote::Full(note.clone()).into_output_note().unwrap()]) .build()?; - // consume a random note to make the transaction non-empty + // consume random notes to make the transaction non-empty let noop_tx2 = MockProvenTxBuilder::with_account( account1.id(), random_final_state_commitment, @@ -1020,7 +1181,7 @@ fn noop_tx_after_state_updating_tx_against_same_account() -> anyhow::Result<()> ) .reference_block(&block1) .authenticated_notes(vec![note1]) - .output_notes(vec![RawOutputNote::Full(note.clone()).into_output_note().unwrap()]) + .unauthenticated_notes(vec![note.clone()]) .build()?; // sanity check @@ -1029,7 +1190,7 @@ fn noop_tx_after_state_updating_tx_against_same_account() -> anyhow::Result<()> noop_tx2.account_update().final_state_commitment() ); - let batch = ProposedBatch::new( + let batch = ProposedBatch::new_unverified( [tx1, noop_tx2].into_iter().map(Arc::new).collect(), block2.header().clone(), chain.latest_partial_blockchain(), diff --git a/crates/miden-testing/src/kernel_tests/batch/proven_tx_builder.rs b/crates/miden-testing/src/kernel_tests/batch/proven_tx_builder.rs index 62429a20d1..6a450864ad 100644 --- a/crates/miden-testing/src/kernel_tests/batch/proven_tx_builder.rs +++ b/crates/miden-testing/src/kernel_tests/batch/proven_tx_builder.rs @@ -2,9 +2,7 @@ use alloc::vec::Vec; use anyhow::Context; use miden_protocol::Word; -use miden_protocol::account::AccountId; -use miden_protocol::account::delta::AccountUpdateDetails; -use miden_protocol::asset::FungibleAsset; +use miden_protocol::account::{AccountId, AccountUpdateDetails}; use miden_protocol::block::{BlockHeader, BlockNumber}; use miden_protocol::crypto::merkle::SparseMerklePath; use miden_protocol::note::{Note, NoteInclusionProof, Nullifier}; @@ -23,7 +21,6 @@ pub struct MockProvenTxBuilder { initial_account_commitment: Word, final_account_commitment: Word, reference_block: Option<(BlockNumber, Word)>, - fee: FungibleAsset, expiration_block_num: BlockNumber, output_notes: Option>, input_notes: Option>, @@ -43,7 +40,6 @@ impl MockProvenTxBuilder { initial_account_commitment, final_account_commitment, reference_block: None, - fee: FungibleAsset::mock(50).unwrap_fungible(), expiration_block_num: BlockNumber::from(u32::MAX), output_notes: None, input_notes: None, @@ -133,7 +129,6 @@ impl MockProvenTxBuilder { self.output_notes.unwrap_or_default(), ref_block_num, ref_block_commitment, - self.fee, self.expiration_block_num, ExecutionProof::new_dummy(), ) diff --git a/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs b/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs new file mode 100644 index 0000000000..f5d3fea06a --- /dev/null +++ b/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs @@ -0,0 +1,89 @@ +use alloc::sync::Arc; +use std::collections::BTreeMap; + +use anyhow::Context; +use miden_protocol::Word; +use miden_protocol::batch::ProposedBatch; +use miden_protocol::block::BlockNumber; +use miden_tx_batch::{BatchExecutor, LocalBatchProver}; + +use super::proposed_batch::{TestSetup, mock_note, mock_output_note, setup_chain}; +use super::proven_tx_builder::MockProvenTxBuilder; + +// SETUP HELPERS +// ================================================================================================ + +/// Builds a two-transaction batch with realistic inputs and outputs. The skeleton kernel does not +/// inspect any of this data, but the batch is built end-to-end so the smoke test exercises the +/// real `prepare_inputs` path that the verification PR will eventually consume. +pub(super) fn two_tx_batch(setup: &mut TestSetup) -> anyhow::Result { + let block1 = setup.chain.block_header(1); + let block2 = setup.chain.prove_next_block()?; + + let tx1 = MockProvenTxBuilder::with_account( + setup.account1.id(), + Word::empty(), + setup.account1.to_commitment(), + ) + .reference_block(&block1) + .authenticated_notes(vec![setup.note1.clone()]) + .output_notes(vec![mock_output_note(80)]) + .expiration_block_num(BlockNumber::from(1234u32)) + .build()?; + + let tx2_input = mock_note(81); + let tx2 = MockProvenTxBuilder::with_account( + setup.account2.id(), + Word::empty(), + setup.account2.to_commitment(), + ) + .reference_block(&block1) + .unauthenticated_notes(vec![tx2_input]) + .output_notes(vec![mock_output_note(82), mock_output_note(83)]) + .expiration_block_num(BlockNumber::from(800u32)) + .build()?; + + Ok(ProposedBatch::new_unverified( + [tx1, tx2].into_iter().map(Arc::new).collect(), + block2.header().clone(), + setup.chain.latest_partial_blockchain(), + BTreeMap::default(), + )?) +} + +// TESTS +// ================================================================================================ + +/// The skeleton batch kernel drops its public inputs and exits, leaving the all-zero word output +/// region. This test exercises the full plumbing path (build a realistic `ProposedBatch`, execute +/// the batch kernel via `BatchExecutor`, parse the outputs) and asserts that the contract holds: +/// the kernel runs to completion and emits the empty word shape. +#[test] +fn batch_kernel_skeleton_emits_empty_outputs() -> anyhow::Result<()> { + let mut setup = setup_chain(); + let batch = two_tx_batch(&mut setup)?; + + let executed = BatchExecutor::new().execute(batch).context("batch execution failed")?; + let output = executed.batch_outputs(); + + assert_eq!(output.input_notes_commitment(), Word::empty()); + assert_eq!(output.batch_note_tree_root(), Word::empty()); + assert_eq!(output.batch_expiration_block_num(), BlockNumber::from(0u32)); + + Ok(()) +} + +/// Executing a batch and then proving it produces a [`ProvenBatch`] carrying the kernel's proof. +#[test] +fn batch_executor_then_prover_produces_proven_batch() -> anyhow::Result<()> { + let mut setup = setup_chain(); + let batch = two_tx_batch(&mut setup)?; + let expected_id = batch.id(); + + let executed = BatchExecutor::new().execute(batch).context("batch execution failed")?; + let proven = LocalBatchProver::new().prove(executed).context("batch proving failed")?; + + assert_eq!(proven.id(), expected_id); + + Ok(()) +} diff --git a/crates/miden-testing/src/kernel_tests/block/header_errors.rs b/crates/miden-testing/src/kernel_tests/block/header_errors.rs index ffc2e1d2c6..95516c30bd 100644 --- a/crates/miden-testing/src/kernel_tests/block/header_errors.rs +++ b/crates/miden-testing/src/kernel_tests/block/header_errors.rs @@ -3,12 +3,12 @@ use alloc::vec::Vec; use anyhow::Context; use assert_matches::assert_matches; use miden_protocol::Word; -use miden_protocol::account::delta::AccountUpdateDetails; use miden_protocol::account::{ Account, AccountBuilder, AccountComponent, AccountId, + AccountUpdateDetails, StorageSlot, StorageSlotName, }; @@ -403,7 +403,6 @@ async fn block_building_fails_on_creating_account_with_duplicate_account_id_pref Vec::::new(), genesis_block.block_num(), genesis_block.commitment(), - FungibleAsset::mock(500).unwrap_fungible(), BlockNumber::from(u32::MAX), ExecutionProof::new_dummy(), ) diff --git a/crates/miden-testing/src/kernel_tests/block/proposed_block_errors.rs b/crates/miden-testing/src/kernel_tests/block/proposed_block_errors.rs index 4cdb62da64..7998f7d3ff 100644 --- a/crates/miden-testing/src/kernel_tests/block/proposed_block_errors.rs +++ b/crates/miden-testing/src/kernel_tests/block/proposed_block_errors.rs @@ -13,6 +13,7 @@ use miden_protocol::note::{NoteAttachments, NoteInclusionProof, NoteType}; use miden_standards::note::P2idNote; use miden_tx::LocalTransactionProver; +use crate::kernel_tests::batch::proposed_batch::setup_circular_note_dependency_test; use crate::kernel_tests::block::utils::MockChainBlockExt; use crate::utils::create_p2any_note; use crate::{Auth, MockChain}; @@ -630,11 +631,11 @@ async fn proposed_block_fails_on_inconsistent_account_state_transition() -> anyh // Create three transactions on the same account that build on top of each other. let executed_tx0 = chain.create_authenticated_notes_tx(account.clone(), [note0.id()]).await?; - account.apply_delta(executed_tx0.account_delta())?; + account.apply_patch(executed_tx0.account_patch())?; // Builds a tx on top of the account state from tx0. let executed_tx1 = chain.create_authenticated_notes_tx(account.clone(), [note1.id()]).await?; - account.apply_delta(executed_tx1.account_delta())?; + account.apply_patch(executed_tx1.account_patch())?; // Builds a tx on top of the account state from tx1. let executed_tx2 = chain.create_authenticated_notes_tx(account.clone(), [note2.id()]).await?; @@ -661,3 +662,30 @@ async fn proposed_block_fails_on_inconsistent_account_state_transition() -> anyh Ok(()) } + +/// Tests that batches with a circular dependency between notes are rejected at the block level. +/// +/// Both transactions execute against the same account but are placed into separate batches. The +/// two p2any notes share the same sender (the executing account) and carry the same asset, but +/// have different serial numbers so that they are distinct notes with different IDs. +/// +/// Batch 0 (TX 1): Inputs [X] -> Outputs [Y] +/// Batch 1 (TX 2): Inputs [Y] -> Outputs [X] +#[tokio::test] +async fn proposed_block_fails_on_cross_batch_circular_note_dependency() -> anyhow::Result<()> { + let (chain, proven_tx1, proven_tx2) = setup_circular_note_dependency_test().await?; + + // Place each transaction into its own batch so the cycle spans across batches. + let batch0 = chain.create_batch(vec![proven_tx1])?; + let batch1 = chain.create_batch(vec![proven_tx2])?; + let batches = vec![batch0, batch1]; + + let block_inputs = chain.get_block_inputs(&batches)?; + + let error = ProposedBlock::new(block_inputs, batches).unwrap_err(); + + // A circular dependency is detected as consumption-before-creation. + assert_matches!(error, ProposedBlockError::NoteConsumedBeforeCreated { .. }); + + Ok(()) +} diff --git a/crates/miden-testing/src/kernel_tests/block/proposed_block_success.rs b/crates/miden-testing/src/kernel_tests/block/proposed_block_success.rs index c12958795a..9bc4fee198 100644 --- a/crates/miden-testing/src/kernel_tests/block/proposed_block_success.rs +++ b/crates/miden-testing/src/kernel_tests/block/proposed_block_success.rs @@ -5,8 +5,7 @@ use std::vec::Vec; use anyhow::Context; use assert_matches::assert_matches; use miden_protocol::Felt; -use miden_protocol::account::delta::AccountUpdateDetails; -use miden_protocol::account::{Account, AccountId, AccountType}; +use miden_protocol::account::{Account, AccountId, AccountType, AccountUpdateDetails}; use miden_protocol::asset::FungibleAsset; use miden_protocol::block::{BlockInputs, ProposedBlock}; use miden_protocol::note::{Note, NoteType}; @@ -114,14 +113,17 @@ async fn proposed_block_basic_success() -> anyhow::Result<()> { /// Tests that account updates are correctly aggregated into a block-level account update. #[tokio::test] async fn proposed_block_aggregates_account_state_transition() -> anyhow::Result<()> { - let asset = FungibleAsset::mock(100); + let asset = FungibleAsset::mock(100).unwrap_fungible(); let sender_id = AccountId::try_from(ACCOUNT_ID_SENDER)?; let mut builder = MockChain::builder(); let mut account1 = builder.add_existing_mock_account(Auth::IncrNonce)?; - let note0 = builder.add_p2id_note(sender_id, account1.id(), &[asset], NoteType::Private)?; - let note1 = builder.add_p2id_note(sender_id, account1.id(), &[asset], NoteType::Public)?; - let note2 = builder.add_p2id_note(sender_id, account1.id(), &[asset], NoteType::Public)?; + let note0 = + builder.add_p2id_note(sender_id, account1.id(), &[asset.into()], NoteType::Private)?; + let note1 = + builder.add_p2id_note(sender_id, account1.id(), &[asset.into()], NoteType::Public)?; + let note2 = + builder.add_p2id_note(sender_id, account1.id(), &[asset.into()], NoteType::Public)?; let mut chain = builder.build()?; // Add notes to the chain. @@ -130,10 +132,10 @@ async fn proposed_block_aggregates_account_state_transition() -> anyhow::Result< // Create three transactions on the same account that build on top of each other. let executed_tx0 = chain.create_authenticated_notes_tx(account1.id(), [note0.id()]).await?; - account1.apply_delta(executed_tx0.account_delta())?; + account1.apply_patch(executed_tx0.account_patch())?; let executed_tx1 = chain.create_authenticated_notes_tx(account1.clone(), [note1.id()]).await?; - account1.apply_delta(executed_tx1.account_delta())?; + account1.apply_patch(executed_tx1.account_patch())?; let executed_tx2 = chain.create_authenticated_notes_tx(account1.clone(), [note2.id()]).await?; let [tx0, tx1, tx2] = [executed_tx0, executed_tx1, executed_tx2] @@ -169,9 +171,11 @@ async fn proposed_block_aggregates_account_state_transition() -> anyhow::Result< [tx2.id(), tx0.id(), tx1.id()] ); - assert_matches!(account_update.details(), AccountUpdateDetails::Delta(delta) => { - assert_eq!(delta.vault().fungible().num_assets(), 1); - assert_eq!(delta.vault().fungible().amount(&asset.unwrap_fungible().vault_key()).unwrap(), 300); + let expected_asset = asset.add(asset)?.add(asset)?; + + assert_matches!(account_update.details(), AccountUpdateDetails::Public(patch) => { + assert_eq!(patch.vault().num_assets(), 1); + assert_eq!(patch.vault().as_map().get(&asset.vault_key()), Some(&expected_asset.to_value_word())); }); Ok(()) @@ -284,7 +288,7 @@ async fn noop_tx_and_state_updating_tx_against_same_account_in_same_block() -> a let mut chain = builder.build()?; let noop_tx = generate_conditional_tx(&mut chain, account0.id(), noop_note0, false).await; - account0.apply_delta(noop_tx.account_delta())?; + account0.apply_patch(noop_tx.account_patch())?; let state_updating_tx = generate_conditional_tx(&mut chain, account0.clone(), noop_note1, true).await; diff --git a/crates/miden-testing/src/kernel_tests/mod.rs b/crates/miden-testing/src/kernel_tests/mod.rs index c6d1b504ec..446b0f3bed 100644 --- a/crates/miden-testing/src/kernel_tests/mod.rs +++ b/crates/miden-testing/src/kernel_tests/mod.rs @@ -1,3 +1,3 @@ -mod batch; +pub(super) mod batch; mod block; mod tx; diff --git a/crates/miden-testing/src/kernel_tests/tx/mod.rs b/crates/miden-testing/src/kernel_tests/tx/mod.rs index e8fa628883..8c3e4119c6 100644 --- a/crates/miden-testing/src/kernel_tests/tx/mod.rs +++ b/crates/miden-testing/src/kernel_tests/tx/mod.rs @@ -2,7 +2,7 @@ use anyhow::Context; use miden_processor::{ContextId, ExecutionOutput}; use miden_protocol::account::auth::AuthScheme; use miden_protocol::account::{Account, AccountId}; -use miden_protocol::asset::{Asset, FungibleAsset}; +use miden_protocol::asset::{Asset, AssetCallbackFlag, FungibleAsset}; use miden_protocol::note::{Note, NoteType}; use miden_protocol::testing::account_id::{ ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, @@ -16,8 +16,8 @@ use miden_protocol::{Felt, Word, ZERO}; use crate::MockChain; mod test_account; -mod test_account_delta; mod test_account_interface; +mod test_account_update; mod test_active_note; mod test_array; mod test_asset; @@ -26,7 +26,6 @@ mod test_auth; mod test_callbacks; mod test_epilogue; mod test_faucet; -mod test_fee; mod test_fpi; mod test_input_note; mod test_lazy_loading; @@ -116,6 +115,7 @@ fn setup_test() -> anyhow::Result { FungibleAsset::new( AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET).context("id should be valid")?, 10, + AssetCallbackFlag::Disabled, ) .context("fungible_asset_0 is invalid")?, ); @@ -125,6 +125,7 @@ fn setup_test() -> anyhow::Result { FungibleAsset::new( AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET).context("id should be valid")?, 5, + AssetCallbackFlag::Disabled, ) .context("fungible_asset_0 is invalid")?, ); @@ -133,6 +134,7 @@ fn setup_test() -> anyhow::Result { AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1) .context("id should be valid")?, 10, + AssetCallbackFlag::Disabled, ) .context("fungible_asset_1 is invalid")?, ); diff --git a/crates/miden-testing/src/kernel_tests/tx/test_account.rs b/crates/miden-testing/src/kernel_tests/tx/test_account.rs index 503d67755d..e88d28ecdb 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_account.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_account.rs @@ -9,7 +9,6 @@ use miden_crypto::rand::test_utils::rand_value; use miden_processor::{ExecutionError, Word}; use miden_protocol::account::auth::AuthScheme; use miden_protocol::account::component::AccountComponentMetadata; -use miden_protocol::account::delta::AccountUpdateDetails; use miden_protocol::account::{ Account, AccountBuilder, @@ -22,9 +21,9 @@ use miden_protocol::account::{ StorageMapKey, StorageSlot, StorageSlotContent, - StorageSlotDelta, StorageSlotId, StorageSlotName, + StorageSlotPatch, StorageSlotType, }; use miden_protocol::assembly::diagnostics::NamedSource; @@ -59,6 +58,7 @@ use miden_protocol::testing::account_id::{ use miden_protocol::testing::storage::{MOCK_MAP_SLOT, MOCK_VALUE_SLOT0, MOCK_VALUE_SLOT1}; use miden_protocol::transaction::{RawOutputNote, TransactionKernel}; use miden_protocol::utils::sync::LazyLock; +use miden_standards::account::access::Pausable; use miden_standards::account::faucets::{FungibleFaucet, TokenName}; use miden_standards::code_builder::CodeBuilder; use miden_standards::testing::account_component::MockAccountComponent; @@ -825,38 +825,40 @@ async fn prove_account_creation_with_non_empty_storage() -> anyhow::Result<()> { .await .context("failed to execute account-creating transaction")?; - assert_eq!(tx.account_delta().nonce_delta(), Felt::ONE); + assert_eq!( + tx.account_patch().final_nonce(), + Some(Felt::ONE), + "new account should have nonce 1" + ); assert_matches!( - tx.account_delta().storage().get(&slot_name0).unwrap(), - StorageSlotDelta::Value(value) => { + tx.account_patch().storage().get(&slot_name0).unwrap(), + StorageSlotPatch::Value(value) => { assert_eq!(*value, slot0.value()) } ); assert_matches!( - tx.account_delta().storage().get(&slot_name1).unwrap(), - StorageSlotDelta::Value(value) => { + tx.account_patch().storage().get(&slot_name1).unwrap(), + StorageSlotPatch::Value(value) => { assert_eq!(*value, slot1.value()) } ); assert_matches!( - tx.account_delta().storage().get(&slot_name2).unwrap(), - StorageSlotDelta::Map(map_delta) => { + tx.account_patch().storage().get(&slot_name2).unwrap(), + StorageSlotPatch::Map(map_patch) => { let expected = &BTreeMap::from_iter(map_entries); - assert_eq!(expected, map_delta.entries()) + assert_eq!(expected, map_patch.entries()) } ); - assert!(tx.account_delta().vault().is_empty()); + assert!(tx.account_patch().vault().is_empty()); assert_eq!(tx.final_account().nonce(), Felt::ONE); let proven_tx = LocalTransactionProver::default().prove(tx.clone()).await?; - // The delta should be present on the proven tx. - let AccountUpdateDetails::Delta(delta) = proven_tx.account_update().details() else { - panic!("expected delta"); - }; - assert_eq!(delta, tx.account_delta()); + // The patch should be present on the proven tx. + let patch = proven_tx.account_update().details().unwrap_public(); + assert_eq!(patch, tx.account_patch()); Ok(()) } @@ -874,6 +876,7 @@ async fn test_get_vault_root() -> anyhow::Result<()> { FungibleAsset::new( AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET).context("id should be valid")?, 5, + AssetCallbackFlag::Disabled, ) .context("fungible_asset_0 is invalid")?, ); @@ -950,7 +953,8 @@ async fn test_get_init_balance_addition() -> anyhow::Result<()> { AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1).context("id should be valid")?; let fungible_asset_for_account = Asset::Fungible( - FungibleAsset::new(faucet_existing_asset, 10).context("fungible_asset_0 is invalid")?, + FungibleAsset::new(faucet_existing_asset, 10, AssetCallbackFlag::Disabled) + .context("fungible_asset_0 is invalid")?, ); let account = builder.add_existing_wallet_with_assets( crate::Auth::BasicAuth { @@ -960,11 +964,13 @@ async fn test_get_init_balance_addition() -> anyhow::Result<()> { )?; let fungible_asset_for_note_existing = Asset::Fungible( - FungibleAsset::new(faucet_existing_asset, 7).context("fungible_asset_0 is invalid")?, + FungibleAsset::new(faucet_existing_asset, 7, AssetCallbackFlag::Disabled) + .context("fungible_asset_0 is invalid")?, ); let fungible_asset_for_note_new = Asset::Fungible( - FungibleAsset::new(faucet_new_asset, 20).context("fungible_asset_1 is invalid")?, + FungibleAsset::new(faucet_new_asset, 20, AssetCallbackFlag::Disabled) + .context("fungible_asset_1 is invalid")?, ); let p2id_note_existing_asset = builder.add_p2id_note( @@ -1093,7 +1099,8 @@ async fn test_get_init_balance_subtraction() -> anyhow::Result<()> { AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET).context("id should be valid")?; let fungible_asset_for_account = Asset::Fungible( - FungibleAsset::new(faucet_existing_asset, 10).context("fungible_asset_0 is invalid")?, + FungibleAsset::new(faucet_existing_asset, 10, AssetCallbackFlag::Disabled) + .context("fungible_asset_0 is invalid")?, ); let account = builder.add_existing_wallet_with_assets( crate::Auth::BasicAuth { @@ -1103,7 +1110,8 @@ async fn test_get_init_balance_subtraction() -> anyhow::Result<()> { )?; let fungible_asset_for_note_existing = Asset::Fungible( - FungibleAsset::new(faucet_existing_asset, 7).context("fungible_asset_0 is invalid")?, + FungibleAsset::new(faucet_existing_asset, 7, AssetCallbackFlag::Disabled) + .context("fungible_asset_0 is invalid")?, ); let mut mock_chain = builder.build()?; @@ -1184,7 +1192,8 @@ async fn test_get_init_asset() -> anyhow::Result<()> { AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET).context("id should be valid")?; let fungible_asset_for_account = Asset::Fungible( - FungibleAsset::new(faucet_existing_asset, 10).context("fungible_asset_0 is invalid")?, + FungibleAsset::new(faucet_existing_asset, 10, AssetCallbackFlag::Disabled) + .context("fungible_asset_0 is invalid")?, ); let account = builder.add_existing_wallet_with_assets( crate::Auth::BasicAuth { @@ -1194,7 +1203,8 @@ async fn test_get_init_asset() -> anyhow::Result<()> { )?; let fungible_asset_for_note_existing = Asset::Fungible( - FungibleAsset::new(faucet_existing_asset, 7).context("fungible_asset_0 is invalid")?, + FungibleAsset::new(faucet_existing_asset, 7, AssetCallbackFlag::Disabled) + .context("fungible_asset_0 is invalid")?, ); let mut mock_chain = builder.build()?; @@ -1465,13 +1475,16 @@ async fn transaction_executor_account_code_using_custom_library() -> anyhow::Res let executed_tx = tx_context.execute().await?; // Account's initial nonce of 1 should have been incremented by 1. - assert_eq!(executed_tx.account_delta().nonce_delta(), Felt::ONE); + assert_eq!( + executed_tx.account_patch().final_nonce(), + Some(native_account.nonce() + Felt::ONE) + ); // Make sure that account storage has been updated as per the tx script call. - assert_eq!(executed_tx.account_delta().storage().values().count(), 1); + assert_eq!(executed_tx.account_patch().storage().values().count(), 1); assert_eq!( - executed_tx.account_delta().storage().get(&MOCK_VALUE_SLOT0).unwrap(), - &StorageSlotDelta::Value(slot_value), + executed_tx.account_patch().storage().get(&MOCK_VALUE_SLOT0).unwrap(), + &StorageSlotPatch::Value(slot_value), ); Ok(()) } @@ -1561,6 +1574,55 @@ async fn test_has_procedure() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn test_has_storage_slot() -> anyhow::Result<()> { + let existing_slot_name = format!("{}", AccountStorage::mock_value_slot0().name()); + + // (slot name, whether a slot with that name is expected to exist on the account) + let test_cases = [(existing_slot_name.as_str(), true), ("unknown::slot::name", false)]; + + for (slot_name, expected_to_exist) in test_cases { + let tx_context = TransactionContextBuilder::with_existing_mock_account().build().unwrap(); + + let assertion = if expected_to_exist { + r#"assert.err="installed storage slot should be reported as present""# + } else { + r#"assertz.err="unknown storage slot should be reported as absent""# + }; + + let code = format!( + r#" + use miden::core::sys + + use $kernel::prologue + use mock::account->mock_account + + const SLOT_NAME = word("{slot_name}") + + begin + exec.prologue::prepare_transaction + + # pad the stack for the call + push.SLOT_NAME[0..2] + repeat.14 push.0 movdn.2 end + # => [slot_id_suffix, slot_id_prefix, pad(14)] + + call.mock_account::has_storage_slot + # => [has_slot, pad(15)] + + {assertion} + + exec.sys::truncate_stack + end + "#, + ); + + tx_context.execute_code(&code).await?; + } + + Ok(()) +} + /// Tests that the `has_callbacks` faucet procedure correctly reports whether a faucet defines /// callbacks. /// @@ -1598,6 +1660,7 @@ async fn test_faucet_has_callbacks( .account_type(AccountType::Public) .with_component(faucet) .with_component(MockAccountComponent::with_slots(callback_slots)) + .with_component(Pausable::unpaused()) .with_auth_component(Auth::IncrNonce) .build_existing()?; diff --git a/crates/miden-testing/src/kernel_tests/tx/test_account_delta.rs b/crates/miden-testing/src/kernel_tests/tx/test_account_delta.rs deleted file mode 100644 index ea737c16a5..0000000000 --- a/crates/miden-testing/src/kernel_tests/tx/test_account_delta.rs +++ /dev/null @@ -1,1171 +0,0 @@ -use alloc::vec::Vec; -use std::collections::BTreeMap; -use std::string::String; - -use anyhow::Context; -use miden_crypto::rand::test_utils::rand_value; -use miden_protocol::account::delta::AccountUpdateDetails; -use miden_protocol::account::{ - Account, - AccountBuilder, - AccountDelta, - AccountId, - AccountStorage, - AccountType, - StorageMap, - StorageMapKey, - StorageSlot, - StorageSlotDelta, - StorageSlotName, -}; -use miden_protocol::asset::{ - Asset, - AssetVault, - FungibleAsset, - NonFungibleAsset, - NonFungibleAssetDetails, -}; -use miden_protocol::note::{Note, NoteTag, NoteType}; -use miden_protocol::testing::account_id::{ - ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1, - ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2, - ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_3, - ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE, - ACCOUNT_ID_SENDER, - AccountIdBuilder, -}; -use miden_protocol::testing::constants::{ - CONSUMED_ASSET_1_AMOUNT, - CONSUMED_ASSET_3_AMOUNT, - FUNGIBLE_ASSET_AMOUNT, - NON_FUNGIBLE_ASSET_DATA, - NON_FUNGIBLE_ASSET_DATA_2, -}; -use miden_protocol::testing::storage::{MOCK_MAP_SLOT, MOCK_VALUE_SLOT0}; -use miden_protocol::transaction::TransactionScript; -use miden_protocol::{EMPTY_WORD, Felt, Word, ZERO}; -use miden_standards::code_builder::CodeBuilder; -use miden_standards::testing::account_component::MockAccountComponent; -use miden_tx::LocalTransactionProver; -use rand::{Rng, SeedableRng}; -use rand_chacha::ChaCha20Rng; - -use crate::utils::create_public_p2any_note; -use crate::{Auth, MockChain, TransactionContextBuilder}; - -// ACCOUNT DELTA TESTS -// -// Note that in all of these tests, the transaction executor will ensure that the account delta -// commitment computed in-kernel and in the host match. -// ================================================================================================ - -/// Tests that a noop transaction with [`Auth::Noop`] results in an empty nonce delta with an empty -/// word as its commitment. -/// -/// In order to make the account delta empty but the transaction still legal, we consume a note -/// without assets. -#[tokio::test] -async fn empty_account_delta_commitment_is_empty_word() -> anyhow::Result<()> { - let mut builder = MockChain::builder(); - let account = builder.add_existing_mock_account(Auth::Noop)?; - let p2any_note = - builder.add_p2any_note(AccountId::try_from(ACCOUNT_ID_SENDER)?, NoteType::Public, [])?; - let mock_chain = builder.build()?; - - let executed_tx = mock_chain - .build_tx_context(account.id(), &[p2any_note.id()], &[]) - .expect("failed to build tx context") - .build()? - .execute() - .await - .context("failed to execute transaction")?; - - assert_eq!(executed_tx.account_delta().nonce_delta(), ZERO); - assert!(executed_tx.account_delta().is_empty()); - assert_eq!(executed_tx.account_delta().to_commitment(), Word::empty()); - - Ok(()) -} - -/// Tests that a noop transaction with [`Auth::IncrNonce`] results in a nonce delta of 1. -#[tokio::test] -async fn delta_nonce() -> anyhow::Result<()> { - let TestSetup { mock_chain, account_id, .. } = setup_test([], [], [])?; - - let executed_tx = mock_chain - .build_tx_context(account_id, &[], &[]) - .expect("failed to build tx context") - .build()? - .execute() - .await - .context("failed to execute transaction")?; - - assert_eq!(executed_tx.account_delta().nonce_delta(), Felt::ONE); - - Ok(()) -} - -/// Tests that setting new values for value storage slots results in the correct delta. -/// -/// - Slot 0: [2,4,6,8] -> [3,4,5,6] -> EMPTY_WORD -> Delta: EMPTY_WORD -/// - Slot 1: EMPTY_WORD -> [3,4,5,6] -> Delta: [3,4,5,6] -/// - Slot 2: [1,3,5,7] -> [1,3,5,7] -> Delta: None -/// - Slot 3: [1,3,5,7] -> [2,3,4,5] -> [1,3,5,7] -> Delta: None -#[tokio::test] -async fn storage_delta_for_value_slots() -> anyhow::Result<()> { - let slot_0_name = StorageSlotName::mock(0); - let slot_0_init_value = Word::from([2, 4, 6, 8u32]); - let slot_0_tmp_value = Word::from([3, 4, 5, 6u32]); - let slot_0_final_value = EMPTY_WORD; - - let slot_1_name = StorageSlotName::mock(1); - let slot_1_init_value = EMPTY_WORD; - let slot_1_final_value = Word::from([3, 4, 5, 6u32]); - - let slot_2_name = StorageSlotName::mock(2); - let slot_2_init_value = Word::from([1, 3, 5, 7u32]); - let slot_2_final_value = slot_2_init_value; - - let slot_3_name = StorageSlotName::mock(3); - let slot_3_init_value = Word::from([1, 3, 5, 7u32]); - let slot_3_tmp_value = Word::from([2, 3, 4, 5u32]); - let slot_3_final_value = slot_3_init_value; - - let TestSetup { mock_chain, account_id, .. } = setup_test( - vec![ - StorageSlot::with_value(slot_0_name.clone(), slot_0_init_value), - StorageSlot::with_value(slot_1_name.clone(), slot_1_init_value), - StorageSlot::with_value(slot_2_name.clone(), slot_2_init_value), - StorageSlot::with_value(slot_3_name.clone(), slot_3_init_value), - ], - [], - [], - )?; - - let tx_script = parse_tx_script(format!( - r#" - const SLOT_0_NAME = word("{slot_0_name}") - const SLOT_1_NAME = word("{slot_1_name}") - const SLOT_2_NAME = word("{slot_2_name}") - const SLOT_3_NAME = word("{slot_3_name}") - - begin - push.{slot_0_tmp_value} - push.SLOT_0_NAME[0..2] - # => [slot_id_suffix, slot_id_prefix, VALUE] - exec.set_item - # => [] - - push.{slot_0_final_value} - push.SLOT_0_NAME[0..2] - # => [slot_id_suffix, slot_id_prefix, VALUE] - exec.set_item - # => [] - - push.{slot_1_final_value} - push.SLOT_1_NAME[0..2] - # => [slot_id_suffix, slot_id_prefix, VALUE] - exec.set_item - # => [] - - push.{slot_2_final_value} - push.SLOT_2_NAME[0..2] - # => [slot_id_suffix, slot_id_prefix, VALUE] - exec.set_item - # => [] - - push.{slot_3_tmp_value} - push.SLOT_3_NAME[0..2] - # => [slot_id_suffix, slot_id_prefix, VALUE] - exec.set_item - # => [] - - push.{slot_3_final_value} - push.SLOT_3_NAME[0..2] - # => [slot_id_suffix, slot_id_prefix, VALUE] - exec.set_item - # => [] - end - "# - ))?; - - let executed_tx = mock_chain - .build_tx_context(account_id, &[], &[]) - .expect("failed to build tx context") - .tx_script(tx_script) - .build()? - .execute() - .await - .context("failed to execute transaction")?; - - let storage_values_delta = executed_tx - .account_delta() - .storage() - .values() - .map(|(slot_name, value)| (slot_name.clone(), *value)) - .collect::>(); - - // Note that slots 2 and 3 are absent because their values haven't effectively changed. - assert_eq!( - storage_values_delta, - BTreeMap::from_iter([(slot_0_name, slot_0_final_value), (slot_1_name, slot_1_final_value)]) - ); - - Ok(()) -} - -/// Tests that setting new values for map storage slots results in the correct delta. -/// -/// - Slot 0: key0: EMPTY_WORD -> [1,2,3,4] -> Delta: [1,2,3,4] -/// - Slot 0: key1: EMPTY_WORD -> [1,2,3,4] -> [2,3,4,5] -> Delta: [2,3,4,5] -/// - Slot 1: key2: [1,2,3,4] -> [1,2,3,4] -> Delta: None -/// - Slot 1: key3: [1,2,3,4] -> EMPTY_WORD -> Delta: EMPTY_WORD -/// - Slot 1: key4: [1,2,3,4] -> [2,3,4,5] -> [1,2,3,4] -> Delta: None -/// - Slot 2: key5: [1,2,3,4] -> [2,3,4,5] -> [1,2,3,4] -> Delta: None -/// - key5 and key4 are the same scenario, but in different slots. In particular, slot 2's delta -/// map will be empty after normalization and so it shouldn't be present in the delta at all. -#[tokio::test] -async fn storage_delta_for_map_slots() -> anyhow::Result<()> { - // Test with random keys to make sure the ordering in the MASM and Rust implementations - // matches. - let key0 = StorageMapKey::from_raw(rand_value::()); - let key1 = StorageMapKey::from_raw(rand_value::()); - let key2 = StorageMapKey::from_raw(rand_value::()); - let key3 = StorageMapKey::from_raw(rand_value::()); - let key4 = StorageMapKey::from_raw(rand_value::()); - let key5 = StorageMapKey::from_raw(rand_value::()); - - let key0_init_value = EMPTY_WORD; - let key1_init_value = EMPTY_WORD; - let key2_init_value = Word::from([1, 2, 3, 4u32]); - let key3_init_value = Word::from([1, 2, 3, 4u32]); - let key4_init_value = Word::from([1, 2, 3, 4u32]); - let key5_init_value = Word::from([1, 2, 3, 4u32]); - - let key0_final_value = Word::from([1, 2, 3, 4u32]); - let key1_tmp_value = Word::from([1, 2, 3, 4u32]); - let key1_final_value = Word::from([2, 3, 4, 5u32]); - let key2_final_value = key2_init_value; - let key3_final_value = EMPTY_WORD; - let key4_tmp_value = Word::from([2, 3, 4, 5u32]); - let key4_final_value = Word::from([1, 2, 3, 4u32]); - let key5_tmp_value = Word::from([2, 3, 4, 5u32]); - let key5_final_value = Word::from([1, 2, 3, 4u32]); - - let slot_0_name = StorageSlotName::mock(0); - let mut map0 = StorageMap::new(); - map0.insert(key0, key0_init_value).unwrap(); - map0.insert(key1, key1_init_value).unwrap(); - - let slot_1_name = StorageSlotName::mock(1); - let mut map1 = StorageMap::new(); - map1.insert(key2, key2_init_value).unwrap(); - map1.insert(key3, key3_init_value).unwrap(); - map1.insert(key4, key4_init_value).unwrap(); - - let slot_2_name = StorageSlotName::mock(2); - let mut map2 = StorageMap::new(); - map2.insert(key5, key5_init_value).unwrap(); - - let TestSetup { mock_chain, account_id, .. } = setup_test( - vec![ - StorageSlot::with_map(slot_0_name.clone(), map0), - StorageSlot::with_map(slot_1_name.clone(), map1), - StorageSlot::with_map(slot_2_name.clone(), map2), - // Include an empty map which does not receive any updates, to test that the "metadata - // header" in the delta commitment is not appended if there are no updates to a map - // slot. - StorageSlot::with_map(StorageSlotName::mock(3), StorageMap::new()), - ], - [], - [], - )?; - - let tx_script = parse_tx_script(format!( - r#" - const SLOT_0_NAME = word("{slot_0_name}") - const SLOT_1_NAME = word("{slot_1_name}") - const SLOT_2_NAME = word("{slot_2_name}") - - begin - push.{key0_final_value} push.{key0} - push.SLOT_0_NAME[0..2] - # => [slot_id_suffix, slot_id_prefix, KEY, VALUE] - exec.set_map_item - # => [] - - push.{key1_tmp_value} push.{key1} - push.SLOT_0_NAME[0..2] - # => [slot_id_suffix, slot_id_prefix, KEY, VALUE] - exec.set_map_item - # => [] - - push.{key1_final_value} push.{key1} - push.SLOT_0_NAME[0..2] - # => [slot_id_suffix, slot_id_prefix, KEY, VALUE] - exec.set_map_item - # => [] - - push.{key2_final_value} push.{key2} - push.SLOT_1_NAME[0..2] - # => [slot_id_suffix, slot_id_prefix, KEY, VALUE] - exec.set_map_item - # => [] - - push.{key3_final_value} push.{key3} - push.SLOT_1_NAME[0..2] - # => [slot_id_suffix, slot_id_prefix, KEY, VALUE] - exec.set_map_item - # => [] - - push.{key4_tmp_value} push.{key4} - push.SLOT_1_NAME[0..2] - # => [slot_id_suffix, slot_id_prefix, KEY, VALUE] - exec.set_map_item - # => [] - - push.{key4_final_value} push.{key4} - push.SLOT_1_NAME[0..2] - # => [slot_id_suffix, slot_id_prefix, KEY, VALUE] - exec.set_map_item - # => [] - - push.{key5_tmp_value} push.{key5} - push.SLOT_2_NAME[0..2] - # => [slot_id_suffix, slot_id_prefix, KEY, VALUE] - exec.set_map_item - # => [] - - push.{key5_final_value} push.{key5} - push.SLOT_2_NAME[0..2] - # => [slot_id_suffix, slot_id_prefix, KEY, VALUE] - exec.set_map_item - # => [] - end - "# - ))?; - - let executed_tx = mock_chain - .build_tx_context(account_id, &[], &[])? - .tx_script(tx_script) - .build()? - .execute() - .await - .context("failed to execute transaction")?; - let maps_delta = executed_tx.account_delta().storage().maps().collect::>(); - - // Note that there should be no delta for map2 since it was normalized to an empty map which - // should be removed. - assert_eq!(maps_delta.len(), 2); - assert!(!maps_delta.contains_key(&slot_2_name), "map2 should not have a delta"); - - let mut map0_delta = maps_delta - .get(&slot_0_name) - .map(|map_delta| (*map_delta).clone()) - .expect("delta for map 0 should exist") - .into_map(); - - let mut map1_delta = maps_delta - .get(&slot_1_name) - .map(|map_delta| (*map_delta).clone()) - .expect("delta for map 1 should exist") - .clone() - .into_map(); - - assert_eq!(map0_delta.len(), 2); - assert_eq!(map0_delta.remove(&key0).unwrap(), key0_final_value); - assert_eq!(map0_delta.remove(&key1).unwrap(), key1_final_value); - - assert_eq!(map1_delta.len(), 1); - assert_eq!(map1_delta.remove(&key3).unwrap(), key3_final_value); - - Ok(()) -} - -/// Tests that increasing, decreasing the amount of a fungible asset results in the correct delta. -/// - Asset0 is increased by 100 and decreased by 200 -> Delta: -100. -/// - Asset1 is increased by 100 and decreased by 100 -> Delta: 0. -/// - Asset2 is increased by 200 and decreased by 100 -> Delta: 100. -/// - Asset3 is decreased by [`FungibleAsset::MAX_AMOUNT`] -> Delta: -MAX_AMOUNT. -/// - Asset4 is increased by [`FungibleAsset::MAX_AMOUNT`] -> Delta: MAX_AMOUNT. -#[tokio::test] -async fn fungible_asset_delta() -> anyhow::Result<()> { - // Test with random IDs to make sure the ordering in the MASM and Rust implementations - // matches. - let faucet0: AccountId = AccountIdBuilder::new() - .account_type(AccountType::Private) - .build_with_seed(rand::random()); - let faucet1: AccountId = AccountIdBuilder::new() - .account_type(AccountType::Public) - .build_with_seed(rand::random()); - let faucet2: AccountId = AccountIdBuilder::new().build_with_seed(rand::random()); - let faucet3: AccountId = AccountIdBuilder::new().build_with_seed(rand::random()); - let faucet4: AccountId = AccountIdBuilder::new().build_with_seed(rand::random()); - - let original_asset0 = FungibleAsset::new(faucet0, 300)?; - let original_asset1 = FungibleAsset::new(faucet1, 200)?; - let original_asset2 = FungibleAsset::new(faucet2, 100)?; - let original_asset3 = FungibleAsset::new(faucet3, FungibleAsset::MAX_AMOUNT.as_u64())?; - - let added_asset0 = FungibleAsset::new(faucet0, 100)?; - let added_asset1 = FungibleAsset::new(faucet1, 100)?; - let added_asset2 = FungibleAsset::new(faucet2, 200)?; - let added_asset4 = FungibleAsset::new(faucet4, FungibleAsset::MAX_AMOUNT.as_u64())?; - - let removed_asset0 = FungibleAsset::new(faucet0, 200)?; - let removed_asset1 = FungibleAsset::new(faucet1, 100)?; - let removed_asset2 = FungibleAsset::new(faucet2, 100)?; - let removed_asset3 = FungibleAsset::new(faucet3, FungibleAsset::MAX_AMOUNT.as_u64())?; - - let TestSetup { mock_chain, account_id, notes } = setup_test( - [], - [original_asset0, original_asset1, original_asset2, original_asset3].map(Asset::from), - [added_asset0, added_asset1, added_asset2, added_asset4].map(Asset::from), - )?; - - let tx_script = parse_tx_script(format!( - " - begin - push.{ASSET0_VALUE} push.{ASSET0_KEY} - exec.util::create_default_note_with_moved_asset - # => [] - - push.{ASSET1_VALUE} push.{ASSET1_KEY} - exec.util::create_default_note_with_moved_asset - # => [] - - push.{ASSET2_VALUE} push.{ASSET2_KEY} - exec.util::create_default_note_with_moved_asset - # => [] - - push.{ASSET3_VALUE} push.{ASSET3_KEY} - exec.util::create_default_note_with_moved_asset - # => [] - end - ", - ASSET0_KEY = removed_asset0.to_key_word(), - ASSET0_VALUE = removed_asset0.to_value_word(), - ASSET1_KEY = removed_asset1.to_key_word(), - ASSET1_VALUE = removed_asset1.to_value_word(), - ASSET2_KEY = removed_asset2.to_key_word(), - ASSET2_VALUE = removed_asset2.to_value_word(), - ASSET3_KEY = removed_asset3.to_key_word(), - ASSET3_VALUE = removed_asset3.to_value_word(), - ))?; - - let executed_tx = mock_chain - .build_tx_context(account_id, ¬es.iter().map(Note::id).collect::>(), &[])? - .tx_script(tx_script) - .build()? - .execute() - .await - .context("failed to execute transaction")?; - - let mut added_assets = executed_tx - .account_delta() - .vault() - .added_assets() - .map(|asset| { - (asset.unwrap_fungible().faucet_id(), asset.unwrap_fungible().amount().as_u64()) - }) - .collect::>(); - let mut removed_assets = executed_tx - .account_delta() - .vault() - .removed_assets() - .map(|asset| { - (asset.unwrap_fungible().faucet_id(), asset.unwrap_fungible().amount().as_u64()) - }) - .collect::>(); - - assert_eq!(added_assets.len(), 2); - assert_eq!(removed_assets.len(), 2); - - assert_eq!( - added_assets.remove(&original_asset2.faucet_id()).unwrap(), - added_asset2.amount().as_u64() - removed_asset2.amount().as_u64() - ); - assert_eq!( - added_assets.remove(&added_asset4.faucet_id()).unwrap(), - added_asset4.amount().as_u64() - ); - - assert_eq!( - removed_assets.remove(&original_asset0.faucet_id()).unwrap(), - removed_asset0.amount().as_u64() - added_asset0.amount().as_u64() - ); - assert_eq!( - removed_assets.remove(&original_asset3.faucet_id()).unwrap(), - removed_asset3.amount().as_u64() - ); - - Ok(()) -} - -/// Tests that adding, removing non-fungible assets results in the correct delta. -/// - Asset0 is added to the vault -> Delta: Add. -/// - Asset1 is removed from the vault -> Delta: Remove. -/// - Asset2 is added and removed -> Delta: No Change. -/// - Asset3 is removed and added -> Delta: No Change. -#[tokio::test] -async fn non_fungible_asset_delta() -> anyhow::Result<()> { - let mut rng = rand::rng(); - // Test with random IDs to make sure the ordering in the MASM and Rust implementations - // matches. - let faucet0: AccountId = AccountIdBuilder::new().build_with_seed(rng.random()); - let faucet1: AccountId = AccountIdBuilder::new().build_with_seed(rng.random()); - let faucet2: AccountId = AccountIdBuilder::new() - .account_type(AccountType::Public) - .build_with_seed(rng.random()); - let faucet3: AccountId = AccountIdBuilder::new() - .account_type(AccountType::Private) - .build_with_seed(rng.random()); - - let asset0 = NonFungibleAsset::new(&NonFungibleAssetDetails::new( - faucet0, - rng.random::<[u8; 32]>().to_vec(), - )); - let asset1 = NonFungibleAsset::new(&NonFungibleAssetDetails::new( - faucet1, - rng.random::<[u8; 32]>().to_vec(), - )); - let asset2 = NonFungibleAsset::new(&NonFungibleAssetDetails::new( - faucet2, - rng.random::<[u8; 32]>().to_vec(), - )); - let asset3 = NonFungibleAsset::new(&NonFungibleAssetDetails::new( - faucet3, - rng.random::<[u8; 32]>().to_vec(), - )); - - let TestSetup { mock_chain, account_id, notes } = - setup_test([], [asset1, asset3].map(Asset::from), [asset0, asset2].map(Asset::from))?; - - let tx_script = parse_tx_script(format!( - " - begin - push.{ASSET1_VALUE} push.{ASSET1_KEY} - exec.util::create_default_note_with_moved_asset - # => [] - - push.{ASSET2_VALUE} push.{ASSET2_KEY} - exec.util::create_default_note_with_moved_asset - # => [] - - # remove asset 3 - push.{ASSET3_VALUE} - push.{ASSET3_KEY} - exec.remove_asset - # => [REMAINING_ASSET_VALUE] - dropw - - # re-add asset 3 - push.{ASSET3_VALUE} - push.{ASSET3_KEY} - # => [ASSET_KEY, ASSET_VALUE] - exec.add_asset dropw - # => [] - end - ", - ASSET1_KEY = asset1.to_key_word(), - ASSET1_VALUE = asset1.to_value_word(), - ASSET2_KEY = asset2.to_key_word(), - ASSET2_VALUE = asset2.to_value_word(), - ASSET3_KEY = asset3.to_key_word(), - ASSET3_VALUE = asset3.to_value_word(), - ))?; - - let executed_tx = mock_chain - .build_tx_context(account_id, ¬es.iter().map(Note::id).collect::>(), &[])? - .tx_script(tx_script) - .build()? - .execute() - .await - .context("failed to execute transaction")?; - - let mut added_assets = executed_tx - .account_delta() - .vault() - .added_assets() - .map(|asset| (asset.faucet_id(), asset.unwrap_non_fungible())) - .collect::>(); - let mut removed_assets = executed_tx - .account_delta() - .vault() - .removed_assets() - .map(|asset| (asset.faucet_id(), asset.unwrap_non_fungible())) - .collect::>(); - - assert_eq!(added_assets.len(), 1); - assert_eq!(removed_assets.len(), 1); - - assert_eq!(added_assets.remove(&asset0.faucet_id()).unwrap(), asset0); - assert_eq!(removed_assets.remove(&asset1.faucet_id()).unwrap(), asset1); - - Ok(()) -} - -/// Tests that adding and removing assets and updating value and map storage slots results in the -/// correct delta. -#[tokio::test] -async fn asset_and_storage_delta() -> anyhow::Result<()> { - let account_assets = AssetVault::mock().assets().collect::>(); - - let account = AccountBuilder::new(ChaCha20Rng::from_os_rng().random()) - .with_auth_component(Auth::IncrNonce) - .with_component(MockAccountComponent::with_slots(AccountStorage::mock_storage_slots())) - .with_assets(account_assets) - .build_existing()?; - - // updated storage - let updated_slot_value = Word::from([7, 9, 11, 13u32]); - - // updated storage map - let updated_map_key = StorageMapKey::from_array([14, 15, 16, 17u32]); - let updated_map_value = Word::from([18, 19, 20, 21u32]); - - // removed assets - let removed_asset_1 = FungibleAsset::mock(FUNGIBLE_ASSET_AMOUNT / 2); - let removed_asset_2 = Asset::Fungible( - FungibleAsset::new( - ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2.try_into().expect("id is valid"), - FUNGIBLE_ASSET_AMOUNT, - ) - .expect("asset is valid"), - ); - let removed_asset_3 = NonFungibleAsset::mock(&NON_FUNGIBLE_ASSET_DATA); - let removed_assets = [removed_asset_1, removed_asset_2, removed_asset_3]; - - let tag1 = - NoteTag::with_account_target(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE.try_into()?); - let tag2 = NoteTag::default(); - let tag3 = NoteTag::default(); - let tags = [tag1, tag2, tag3]; - - let note_types = [NoteType::Private; 3]; - - let mut send_asset_script = String::new(); - for i in 0..3 { - send_asset_script.push_str(&format!( - " - ### note {i} - # prepare the stack for a new note creation - push.0.1.2.3 # recipient - push.{NOTETYPE} # note_type - push.{tag} # tag - # => [tag, note_type, RECIPIENT] - - # create the note - exec.output_note::create - # => [note_idx, pad(15)] - - # move an asset to the created note to partially deplete fungible asset balance - swapw dropw - push.{REMOVED_ASSET_VALUE} - push.{REMOVED_ASSET_KEY} - call.::miden::standards::wallets::basic::move_asset_to_note - # => [pad(16)] - - # clear the stack - dropw dropw dropw dropw - ", - NOTETYPE = note_types[i] as u8, - tag = tags[i], - REMOVED_ASSET_KEY = removed_assets[i].to_key_word(), - REMOVED_ASSET_VALUE = removed_assets[i].to_value_word(), - )); - } - - let tx_script_src = format!( - r#" - use mock::account - use miden::protocol::output_note - - const MOCK_VALUE_SLOT0 = word("{mock_value_slot0}") - const MOCK_MAP_SLOT = word("{mock_map_slot}") - - ## TRANSACTION SCRIPT - ## ======================================================================================== - begin - ## Update account storage item - ## ------------------------------------------------------------------------------------ - # push a new value for the storage slot onto the stack - push.{updated_slot_value} - # => [13, 11, 9, 7] - - # get the index of account storage slot - push.MOCK_VALUE_SLOT0[0..2] - # => [slot_id_suffix, slot_id_prefix, 13, 11, 9, 7] - # update the storage value - call.account::set_item dropw - # => [] - - ## Update account storage map - ## ------------------------------------------------------------------------------------ - # push a new VALUE for the storage map onto the stack - push.{updated_map_value} - # => [18, 19, 20, 21] - - # push a new KEY for the storage map onto the stack - push.{updated_map_key} - # => [14, 15, 16, 17, 18, 19, 20, 21] - - # get the index of account storage slot - push.MOCK_MAP_SLOT[0..2] - # => [slot_id_suffix, slot_id_prefix, 14, 15, 16, 17, 18, 19, 20, 21] - - # update the storage value - call.account::set_map_item dropw dropw dropw - # => [] - - ## Send some assets from the account vault - ## ------------------------------------------------------------------------------------ - {send_asset_script} - - dropw dropw dropw dropw - end - "#, - mock_value_slot0 = &*MOCK_VALUE_SLOT0, - mock_map_slot = &*MOCK_MAP_SLOT, - ); - - let tx_script = CodeBuilder::with_mock_libraries().compile_tx_script(tx_script_src)?; - - // Create the input note that carries the assets that we will assert later - let input_note = { - let faucet_id_1 = AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1)?; - let faucet_id_3 = AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_3)?; - - let fungible_asset_1: Asset = - FungibleAsset::new(faucet_id_1, CONSUMED_ASSET_1_AMOUNT)?.into(); - let fungible_asset_3: Asset = - FungibleAsset::new(faucet_id_3, CONSUMED_ASSET_3_AMOUNT)?.into(); - let nonfungible_asset_1: Asset = NonFungibleAsset::mock(&NON_FUNGIBLE_ASSET_DATA_2); - - create_public_p2any_note( - account.id(), - [fungible_asset_1, fungible_asset_3, nonfungible_asset_1], - ) - }; - - let tx_context = TransactionContextBuilder::new(account) - .extend_input_notes(vec![input_note.clone()]) - .tx_script(tx_script) - .build()?; - - // Storing assets that will be added to assert correctness later - let added_assets = input_note.assets().iter().cloned().collect::>(); - - // expected delta - // -------------------------------------------------------------------------------------------- - // execute the transaction and get the witness - let executed_transaction = tx_context.execute().await?; - - // nonce delta - // -------------------------------------------------------------------------------------------- - - assert_eq!(executed_transaction.account_delta().nonce_delta(), Felt::ONE); - - // storage delta - // -------------------------------------------------------------------------------------------- - // We expect one updated item and one updated map - assert_eq!(executed_transaction.account_delta().storage().values().count(), 1); - assert_eq!( - executed_transaction - .account_delta() - .storage() - .get(&MOCK_VALUE_SLOT0) - .cloned() - .map(StorageSlotDelta::unwrap_value), - Some(updated_slot_value) - ); - - assert_eq!(executed_transaction.account_delta().storage().maps().count(), 1); - let map_delta = executed_transaction - .account_delta() - .storage() - .get(&MOCK_MAP_SLOT) - .cloned() - .map(StorageSlotDelta::unwrap_map) - .unwrap(); - assert_eq!(*map_delta.entries().get(&updated_map_key).unwrap(), updated_map_value); - - // vault delta - // -------------------------------------------------------------------------------------------- - // assert that added assets are tracked - assert!( - executed_transaction - .account_delta() - .vault() - .added_assets() - .all(|x| added_assets.contains(&x)) - ); - assert_eq!( - added_assets.len(), - executed_transaction.account_delta().vault().added_assets().count() - ); - - // assert that removed assets are tracked - assert!( - executed_transaction - .account_delta() - .vault() - .removed_assets() - .all(|x| removed_assets.contains(&x)) - ); - assert_eq!( - removed_assets.len(), - executed_transaction.account_delta().vault().removed_assets().count() - ); - Ok(()) -} - -/// Tests that the storage map updates for a _new public_ account in an executed and proven -/// transaction match up. -/// -/// This is an interesting test case because: -/// - for new accounts in general, the storage map entries must be available in the advice provider -/// and the resulting delta must be convertible to a full account. -/// - it creates an account with two identical storage maps. -/// - The prover mutates the delta to account for fee logic. -#[tokio::test] -async fn proven_tx_storage_maps_matches_executed_tx_for_new_account() -> anyhow::Result<()> { - // Use two identical maps to test that they are properly handled - // (see also https://github.com/0xMiden/protocol/issues/2037). - let map0 = StorageMap::with_entries([(StorageMapKey::from_raw(rand_value()), rand_value())])?; - let map1 = map0.clone(); - let mut map2 = StorageMap::with_entries([ - (StorageMapKey::from_raw(rand_value()), rand_value()), - (StorageMapKey::from_raw(rand_value()), rand_value()), - (StorageMapKey::from_raw(rand_value()), rand_value()), - (StorageMapKey::from_raw(rand_value()), rand_value()), - ])?; - - let map0_slot_name = StorageSlotName::mock(1); - let map1_slot_name = StorageSlotName::mock(2); - let map2_slot_name = StorageSlotName::mock(4); - - // Build a public account so the proven transaction includes the account update. - let account = AccountBuilder::new([1; 32]) - .account_type(AccountType::Public) - .with_auth_component(Auth::IncrNonce) - .with_component(MockAccountComponent::with_slots(vec![ - AccountStorage::mock_value_slot0(), - StorageSlot::with_map(map0_slot_name.clone(), map0.clone()), - StorageSlot::with_map(map1_slot_name.clone(), map1.clone()), - AccountStorage::mock_value_slot1(), - StorageSlot::with_map(map2_slot_name.clone(), map2.clone()), - ])) - .build()?; - - // Fetch a random existing key from the map. - let existing_key = *map2.entries().next().unwrap().0; - let value0 = Word::from([3, 4, 5, 6u32]); - - let code = format!( - r#" - use mock::account - - const MAP_SLOT=word("{map2_slot_name}") - - begin - # Update an existing key. - push.{value0} - push.{existing_key} - push.MAP_SLOT[0..2] - # => [slot_id_suffix, slot_id_prefix, KEY, VALUE] - call.account::set_map_item - - exec.::miden::core::sys::truncate_stack - end - "# - ); - - let builder = CodeBuilder::with_mock_libraries(); - let source_manager = builder.source_manager(); - let tx_script = builder.compile_tx_script(code)?; - - let tx = TransactionContextBuilder::new(account.clone()) - .tx_script(tx_script) - .with_source_manager(source_manager) - .build()? - .execute() - .await?; - - map2.insert(existing_key, value0)?; - - for (slot_name, expected_map) in - [(map0_slot_name, map0), (map1_slot_name, map1), (map2_slot_name, map2)] - { - let map_delta = tx - .account_delta() - .storage() - .get(&slot_name) - .cloned() - .map(StorageSlotDelta::unwrap_map) - .unwrap(); - assert_eq!( - map_delta.entries().iter().collect::>(), - expected_map.entries().collect(), - "map delta does not match for slot {slot_name}", - ); - } - - let proven_tx = LocalTransactionProver::default().prove_dummy(tx.clone())?; - - let AccountUpdateDetails::Delta(proven_tx_delta) = proven_tx.account_update().details() else { - panic!("expected delta"); - }; - - let proven_tx_account = Account::try_from(proven_tx_delta)?; - let exec_tx_account = Account::try_from(tx.account_delta())?; - - assert_eq!(proven_tx_account.storage(), exec_tx_account.storage()); - - // Check the conversion back into a full-state delta works correctly. - let proven_tx_delta_converted = AccountDelta::try_from(proven_tx_account)?; - let exec_tx_delta_converted = AccountDelta::try_from(exec_tx_account)?; - - // Check that the deltas from proven and executed tx, which were converted from accounts are - // identical. This is essentially a roundtrip test. - assert_eq!(&proven_tx_delta_converted, proven_tx_delta); - assert_eq!(&exec_tx_delta_converted, tx.account_delta()); - assert_eq!(&proven_tx_delta_converted, tx.account_delta()); - - // The commitments should match as well. - assert_eq!(proven_tx_delta_converted.to_commitment(), proven_tx_delta.to_commitment()); - assert_eq!(exec_tx_delta_converted.to_commitment(), tx.account_delta().to_commitment()); - assert_eq!(proven_tx_delta_converted.to_commitment(), tx.account_delta().to_commitment()); - - Ok(()) -} - -/// Tests that creating a new account with a slot whose value is empty is correctly included in the -/// delta and not normalized away. -#[tokio::test] -async fn delta_for_new_account_retains_empty_value_storage_slots() -> anyhow::Result<()> { - let slot_name0 = StorageSlotName::mock(0); - let slot_name1 = StorageSlotName::mock(1); - - let slot_value2 = Word::from([1, 2, 3, 4u32]); - let mut account = AccountBuilder::new(rand::random()) - .account_type(AccountType::Public) - .with_component(MockAccountComponent::with_slots(vec![ - StorageSlot::with_empty_value(slot_name0.clone()), - StorageSlot::with_value(slot_name1.clone(), slot_value2), - ])) - .with_auth_component(Auth::IncrNonce) - .build()?; - - let tx = TransactionContextBuilder::new(account.clone()).build()?.execute().await?; - - let proven_tx = LocalTransactionProver::default().prove_dummy(tx.clone())?; - - let AccountUpdateDetails::Delta(delta) = proven_tx.account_update().details() else { - panic!("expected delta"); - }; - - assert_eq!(delta.storage().values().count(), 2); - assert_eq!( - delta - .storage() - .get(&slot_name0) - .cloned() - .map(StorageSlotDelta::unwrap_value) - .unwrap(), - Word::empty() - ); - assert_eq!( - delta - .storage() - .get(&slot_name1) - .cloned() - .map(StorageSlotDelta::unwrap_value) - .unwrap(), - slot_value2 - ); - - let recreated_account = Account::try_from(delta)?; - // The recreated account should match the original account with the nonce incremented (and the - // seed removed). - account.increment_nonce(Felt::ONE)?; - assert_eq!(recreated_account, account); - - Ok(()) -} - -/// Tests that creating a new account with a slot whose map is empty is correctly included in the -/// delta. -#[tokio::test] -async fn delta_for_new_account_retains_empty_map_storage_slots() -> anyhow::Result<()> { - let slot_name0 = StorageSlotName::mock(0); - - let mut account = AccountBuilder::new(rand::random()) - .account_type(AccountType::Public) - .with_component(MockAccountComponent::with_slots(vec![StorageSlot::with_empty_map( - slot_name0.clone(), - )])) - .with_auth_component(Auth::IncrNonce) - .build()?; - - let tx = TransactionContextBuilder::new(account.clone()).build()?.execute().await?; - - let proven_tx = LocalTransactionProver::default().prove_dummy(tx.clone())?; - - let AccountUpdateDetails::Delta(delta) = proven_tx.account_update().details() else { - panic!("expected delta"); - }; - - assert_eq!(delta.storage().maps().count(), 1); - assert!( - delta - .storage() - .get(&slot_name0) - .cloned() - .map(StorageSlotDelta::unwrap_map) - .unwrap() - .is_empty() - ); - - let recreated_account = Account::try_from(delta)?; - // The recreated account should match the original account with the nonce incremented (and the - // seed removed). - account.increment_nonce(Felt::ONE)?; - assert_eq!(recreated_account, account); - - Ok(()) -} - -/// Tests that adding a fungible asset with amount zero to the account vault works and does not -/// result in an account delta entry. -#[tokio::test] -async fn adding_amount_zero_fungible_asset_to_account_vault_works() -> anyhow::Result<()> { - let mut builder = MockChain::builder(); - let account = builder.add_existing_mock_account(Auth::IncrNonce)?; - let input_note = builder.add_p2id_note( - account.id(), - account.id(), - &[FungibleAsset::mock(0)], - NoteType::Private, - )?; - let chain = builder.build()?; - - let tx = chain - .build_tx_context(account, &[input_note.id()], &[])? - .build()? - .execute() - .await?; - - assert!(tx.account_delta().vault().is_empty()); - - Ok(()) -} - -// TEST HELPERS -// ================================================================================================ - -struct TestSetup { - mock_chain: MockChain, - account_id: AccountId, - notes: Vec, -} - -fn setup_test( - storage_slots: impl IntoIterator, - vault_assets: impl IntoIterator, - note_assets: impl IntoIterator, -) -> anyhow::Result { - let mut builder = MockChain::builder(); - let account = builder.add_existing_mock_account_with_storage_and_assets( - Auth::IncrNonce, - storage_slots, - vault_assets, - )?; - - let mut notes = vec![]; - for note_asset in note_assets { - let added_note = builder - .add_p2id_note(account.id(), account.id(), &[note_asset], NoteType::Public) - .context("failed to add note with asset")?; - notes.push(added_note); - } - - let mock_chain = builder.build()?; - - Ok(TestSetup { - mock_chain, - account_id: account.id(), - notes, - }) -} - -fn parse_tx_script(code: impl AsRef) -> anyhow::Result { - let code = format!( - " - {TEST_ACCOUNT_CONVENIENCE_WRAPPERS} - {code} - ", - code = code.as_ref() - ); - - CodeBuilder::with_mock_libraries() - .compile_tx_script(&code) - .context("failed to parse tx script") -} - -const TEST_ACCOUNT_CONVENIENCE_WRAPPERS: &str = " - use mock::account - use mock::util - use miden::protocol::output_note - - #! Inputs: [slot_id_suffix, slot_id_prefix, VALUE] - #! Outputs: [] - proc set_item - repeat.10 push.0 movdn.6 end - # => [slot_id_suffix, slot_id_prefix, VALUE, pad(10)] - - call.account::set_item - # => [OLD_VALUE, pad(12)] - - dropw dropw dropw dropw - end - - #! Inputs: [slot_id_suffix, slot_id_prefix, KEY, VALUE] - #! Outputs: [] - proc set_map_item - repeat.6 push.0 movdn.10 end - # => [index, KEY, VALUE, pad(6)] - - call.account::set_map_item - # => [OLD_VALUE, pad(12)] - - dropw dropw dropw dropw - # => [] - end - - #! Inputs: [ASSET_KEY, ASSET_VALUE] - #! Outputs: [ASSET_VALUE'] - proc add_asset - repeat.8 push.0 movdn.8 end - # => [ASSET_KEY, ASSET_VALUE, pad(8)] - - call.account::add_asset - # => [ASSET_VALUE', pad(12)] - - repeat.12 movup.4 drop end - # => [ASSET_VALUE'] - end - - #! Inputs: [ASSET_KEY, ASSET_VALUE] - #! Outputs: [ASSET_VALUE] - proc remove_asset - padw padw swapdw - # => [ASSET_KEY, ASSET_VALUE, pad(8)] - - call.account::remove_asset - # => [ASSET_VALUE, pad(12)] - - repeat.12 movup.4 drop end - # => [ASSET_VALUE] - end -"; diff --git a/crates/miden-testing/src/kernel_tests/tx/test_account_update.rs b/crates/miden-testing/src/kernel_tests/tx/test_account_update.rs new file mode 100644 index 0000000000..f8f42404f7 --- /dev/null +++ b/crates/miden-testing/src/kernel_tests/tx/test_account_update.rs @@ -0,0 +1,1276 @@ +use alloc::vec::Vec; +use std::collections::BTreeMap; +use std::string::String; +use std::sync::LazyLock; + +use anyhow::Context; +use miden_crypto::rand::test_utils::rand_value; +use miden_protocol::account::{ + Account, + AccountBuilder, + AccountCode, + AccountComponent, + AccountComponentCode, + AccountComponentMetadata, + AccountDelta, + AccountId, + AccountPatch, + AccountStorage, + AccountStoragePatch, + AccountType, + AccountVaultDelta, + AccountVaultPatch, + StorageMap, + StorageMapKey, + StorageSlot, + StorageSlotName, +}; +use miden_protocol::asset::{ + Asset, + AssetCallbackFlag, + FungibleAsset, + NonFungibleAsset, + NonFungibleAssetDetails, +}; +use miden_protocol::note::{NoteTag, NoteType}; +use miden_protocol::testing::account_id::AccountIdBuilder; +use miden_protocol::testing::storage::{MOCK_MAP_SLOT, MOCK_VALUE_SLOT0}; +use miden_protocol::transaction::TransactionScript; +use miden_protocol::{EMPTY_WORD, Felt, Word, ZERO}; +use miden_standards::code_builder::CodeBuilder; +use miden_standards::testing::account_component::MockAccountComponent; +use miden_tx::{LocalTransactionProver, TransactionExecutorError}; +use rand::Rng; + +use crate::{Auth, MockChain, TransactionContextBuilder}; + +// ACCOUNT DELTA TESTS +// +// For the approach to these tests, see `AccountUpdateTest::execute`. +// ================================================================================================ + +/// Tests that an empty account delta commits to the empty word. +#[tokio::test] +async fn empty_account_delta_commitment_is_empty_word() -> anyhow::Result<()> { + let tx_script = CodeBuilder::with_mock_libraries() + .compile_tx_script( + r#" + use miden::protocol::native_account + + begin + exec.native_account::compute_delta_commitment + # => [DELTA_COMMITMENT] + + padw assert_eqw.err="empty account delta should commit to the empty word" + end + "#, + ) + .context("failed to compile tx script")?; + + let mut builder = MockChain::builder(); + // Use IncrNonce to make the transaction non-empty. + let account = builder.add_existing_mock_account(Auth::IncrNonce)?; + let mock_chain = builder.build()?; + + mock_chain + .build_tx_context(account.id(), &[], &[]) + .expect("failed to build tx context") + .tx_script(tx_script) + .build()? + .execute() + .await + .context("failed to execute transaction")?; + + Ok(()) +} + +/// Tests that a noop transaction with a nonce-incrementing auth results in a nonce delta of 1 +/// and a patch whose only change is the bumped final nonce. +#[tokio::test] +async fn delta_nonce() -> anyhow::Result<()> { + AccountUpdateTest { + initial_storage_slots: vec![], + initial_vault_assets: vec![], + input_notes_assets: vec![], + tx_script: None, + expected_storage_patch: AccountStoragePatch::new(), + expected_vault_delta: AccountVaultDelta::default(), + expected_vault_patch: AccountVaultPatch::default(), + expected_code: None, + } + .execute() + .await +} + +/// Tests that setting new values for value storage slots results in the correct patch. +/// +/// - Slot 0: [2,4,6,8] -> [3,4,5,6] -> EMPTY_WORD -> Patch: EMPTY_WORD +/// - Slot 1: EMPTY_WORD -> [3,4,5,6] -> Patch: [3,4,5,6] +/// - Slot 2: [1,3,5,7] -> [1,3,5,7] -> Patch: None +/// - Slot 3: [1,3,5,7] -> [2,3,4,5] -> [1,3,5,7] -> Patch: None +#[tokio::test] +async fn storage_patch_for_value_slots() -> anyhow::Result<()> { + let slot_0_name = StorageSlotName::mock(0); + let slot_0_init_value = Word::from([2, 4, 6, 8u32]); + let slot_0_tmp_value = Word::from([3, 4, 5, 6u32]); + let slot_0_final_value = EMPTY_WORD; + + let slot_1_name = StorageSlotName::mock(1); + let slot_1_init_value = EMPTY_WORD; + let slot_1_final_value = Word::from([3, 4, 5, 6u32]); + + let slot_2_name = StorageSlotName::mock(2); + let slot_2_init_value = Word::from([1, 3, 5, 7u32]); + let slot_2_final_value = slot_2_init_value; + + let slot_3_name = StorageSlotName::mock(3); + let slot_3_init_value = Word::from([1, 3, 5, 7u32]); + let slot_3_tmp_value = Word::from([2, 3, 4, 5u32]); + let slot_3_final_value = slot_3_init_value; + + let tx_script = parse_tx_script(format!( + r#" + const SLOT_0_NAME = word("{slot_0_name}") + const SLOT_1_NAME = word("{slot_1_name}") + const SLOT_2_NAME = word("{slot_2_name}") + const SLOT_3_NAME = word("{slot_3_name}") + + begin + push.{slot_0_tmp_value} + push.SLOT_0_NAME[0..2] + # => [slot_id_suffix, slot_id_prefix, VALUE] + exec.set_item + # => [] + + push.{slot_0_final_value} + push.SLOT_0_NAME[0..2] + # => [slot_id_suffix, slot_id_prefix, VALUE] + exec.set_item + # => [] + + push.{slot_1_final_value} + push.SLOT_1_NAME[0..2] + # => [slot_id_suffix, slot_id_prefix, VALUE] + exec.set_item + # => [] + + push.{slot_2_final_value} + push.SLOT_2_NAME[0..2] + # => [slot_id_suffix, slot_id_prefix, VALUE] + exec.set_item + # => [] + + push.{slot_3_tmp_value} + push.SLOT_3_NAME[0..2] + # => [slot_id_suffix, slot_id_prefix, VALUE] + exec.set_item + # => [] + + push.{slot_3_final_value} + push.SLOT_3_NAME[0..2] + # => [slot_id_suffix, slot_id_prefix, VALUE] + exec.set_item + # => [] + end + "# + ))?; + + // Slots 2 and 3 are absent because their values haven't effectively changed. + let mut expected_storage_patch = AccountStoragePatch::new(); + expected_storage_patch.set_item(slot_0_name.clone(), slot_0_final_value)?; + expected_storage_patch.set_item(slot_1_name.clone(), slot_1_final_value)?; + + AccountUpdateTest { + initial_storage_slots: vec![ + StorageSlot::with_value(slot_0_name, slot_0_init_value), + StorageSlot::with_value(slot_1_name, slot_1_init_value), + StorageSlot::with_value(slot_2_name, slot_2_init_value), + StorageSlot::with_value(slot_3_name, slot_3_init_value), + ], + initial_vault_assets: vec![], + input_notes_assets: vec![], + tx_script: Some(tx_script), + expected_storage_patch, + expected_vault_delta: AccountVaultDelta::default(), + expected_vault_patch: AccountVaultPatch::default(), + expected_code: None, + } + .execute() + .await +} + +/// Tests that setting new values for map storage slots results in the correct patch. +/// +/// - Slot 0: key0: EMPTY_WORD -> [1,2,3,4] -> Patch: [1,2,3,4] +/// - Slot 0: key1: EMPTY_WORD -> [1,2,3,4] -> [2,3,4,5] -> Patch: [2,3,4,5] +/// - Slot 1: key2: [1,2,3,4] -> [1,2,3,4] -> Patch: None +/// - Slot 1: key3: [1,2,3,4] -> EMPTY_WORD -> Patch: EMPTY_WORD +/// - Slot 1: key4: [1,2,3,4] -> [2,3,4,5] -> [1,2,3,4] -> Patch: None +/// - Slot 2: key5: [1,2,3,4] -> [2,3,4,5] -> [1,2,3,4] -> Patch: None +/// - key5 and key4 are the same scenario, but in different slots. In particular, slot 2's patch +/// map will be empty after normalization and so it shouldn't be present in the patch at all. +#[tokio::test] +async fn storage_patch_for_map_slots() -> anyhow::Result<()> { + // Test with random keys to make sure the ordering in the MASM and Rust implementations + // matches. + let key0 = StorageMapKey::from_raw(rand_value::()); + let key1 = StorageMapKey::from_raw(rand_value::()); + let key2 = StorageMapKey::from_raw(rand_value::()); + let key3 = StorageMapKey::from_raw(rand_value::()); + let key4 = StorageMapKey::from_raw(rand_value::()); + let key5 = StorageMapKey::from_raw(rand_value::()); + + let key0_init_value = EMPTY_WORD; + let key1_init_value = EMPTY_WORD; + let key2_init_value = Word::from([1, 2, 3, 4u32]); + let key3_init_value = Word::from([1, 2, 3, 4u32]); + let key4_init_value = Word::from([1, 2, 3, 4u32]); + let key5_init_value = Word::from([1, 2, 3, 4u32]); + + let key0_final_value = Word::from([1, 2, 3, 4u32]); + let key1_tmp_value = Word::from([1, 2, 3, 4u32]); + let key1_final_value = Word::from([2, 3, 4, 5u32]); + let key2_final_value = key2_init_value; + let key3_final_value = EMPTY_WORD; + let key4_tmp_value = Word::from([2, 3, 4, 5u32]); + let key4_final_value = Word::from([1, 2, 3, 4u32]); + let key5_tmp_value = Word::from([2, 3, 4, 5u32]); + let key5_final_value = Word::from([1, 2, 3, 4u32]); + + let slot_0_name = StorageSlotName::mock(0); + let mut map0 = StorageMap::new(); + map0.insert(key0, key0_init_value).unwrap(); + map0.insert(key1, key1_init_value).unwrap(); + + let slot_1_name = StorageSlotName::mock(1); + let mut map1 = StorageMap::new(); + map1.insert(key2, key2_init_value).unwrap(); + map1.insert(key3, key3_init_value).unwrap(); + map1.insert(key4, key4_init_value).unwrap(); + + let slot_2_name = StorageSlotName::mock(2); + let mut map2 = StorageMap::new(); + map2.insert(key5, key5_init_value).unwrap(); + + let tx_script = parse_tx_script(format!( + r#" + const SLOT_0_NAME = word("{slot_0_name}") + const SLOT_1_NAME = word("{slot_1_name}") + const SLOT_2_NAME = word("{slot_2_name}") + + begin + push.{key0_final_value} push.{key0} + push.SLOT_0_NAME[0..2] + # => [slot_id_suffix, slot_id_prefix, KEY, VALUE] + exec.set_map_item + # => [] + + push.{key1_tmp_value} push.{key1} + push.SLOT_0_NAME[0..2] + # => [slot_id_suffix, slot_id_prefix, KEY, VALUE] + exec.set_map_item + # => [] + + push.{key1_final_value} push.{key1} + push.SLOT_0_NAME[0..2] + # => [slot_id_suffix, slot_id_prefix, KEY, VALUE] + exec.set_map_item + # => [] + + push.{key2_final_value} push.{key2} + push.SLOT_1_NAME[0..2] + # => [slot_id_suffix, slot_id_prefix, KEY, VALUE] + exec.set_map_item + # => [] + + push.{key3_final_value} push.{key3} + push.SLOT_1_NAME[0..2] + # => [slot_id_suffix, slot_id_prefix, KEY, VALUE] + exec.set_map_item + # => [] + + push.{key4_tmp_value} push.{key4} + push.SLOT_1_NAME[0..2] + # => [slot_id_suffix, slot_id_prefix, KEY, VALUE] + exec.set_map_item + # => [] + + push.{key4_final_value} push.{key4} + push.SLOT_1_NAME[0..2] + # => [slot_id_suffix, slot_id_prefix, KEY, VALUE] + exec.set_map_item + # => [] + + push.{key5_tmp_value} push.{key5} + push.SLOT_2_NAME[0..2] + # => [slot_id_suffix, slot_id_prefix, KEY, VALUE] + exec.set_map_item + # => [] + + push.{key5_final_value} push.{key5} + push.SLOT_2_NAME[0..2] + # => [slot_id_suffix, slot_id_prefix, KEY, VALUE] + exec.set_map_item + # => [] + end + "# + ))?; + + // map2 should not appear in the patch since its only change normalized to a no-op. + let mut expected_storage_patch = AccountStoragePatch::new(); + expected_storage_patch.set_map_item(slot_0_name.clone(), key0, key0_final_value)?; + expected_storage_patch.set_map_item(slot_0_name.clone(), key1, key1_final_value)?; + expected_storage_patch.set_map_item(slot_1_name.clone(), key3, key3_final_value)?; + + AccountUpdateTest { + initial_storage_slots: vec![ + StorageSlot::with_map(slot_0_name, map0), + StorageSlot::with_map(slot_1_name, map1), + StorageSlot::with_map(slot_2_name, map2), + // Include an empty map which does not receive any updates, to test that the "metadata + // header" in the delta commitment is not appended if there are no updates to a map + // slot. + StorageSlot::with_map(StorageSlotName::mock(3), StorageMap::new()), + ], + initial_vault_assets: vec![], + input_notes_assets: vec![], + tx_script: Some(tx_script), + expected_storage_patch, + expected_vault_delta: AccountVaultDelta::default(), + expected_vault_patch: AccountVaultPatch::default(), + expected_code: None, + } + .execute() + .await +} + +/// Tests that increasing, decreasing the amount of a fungible asset results in the correct update. +/// +/// - Asset0 starts at 300, is increased by 100 and decreased by 200 -> Delta: -100, Patch: 200. +/// - Asset1 starts at 200, is increased by 100 and decreased by 100 -> Delta: 0, Patch: None. +/// - Asset2 starts at 100, is increased by 200 and decreased by 100 -> Delta: 100, Patch: 200. +/// - Asset3 starts at MAX, is decreased by MAX -> Delta: -MAX, Patch: 0. +/// - Asset4 starts at 0, is increased by MAX -> Delta: MAX, Patch: MAX. +#[tokio::test] +async fn fungible_asset_update() -> anyhow::Result<()> { + // Test with random IDs to make sure the ordering in the MASM and Rust implementations + // matches. + let faucet0: AccountId = AccountIdBuilder::new() + .account_type(AccountType::Private) + .build_with_seed(rand::random()); + let faucet1: AccountId = AccountIdBuilder::new() + .account_type(AccountType::Public) + .build_with_seed(rand::random()); + let faucet2: AccountId = AccountIdBuilder::new().build_with_seed(rand::random()); + let faucet3: AccountId = AccountIdBuilder::new().build_with_seed(rand::random()); + let faucet4: AccountId = AccountIdBuilder::new().build_with_seed(rand::random()); + + let max_amount = FungibleAsset::MAX_AMOUNT.as_u64(); + + let original_asset0 = FungibleAsset::new(faucet0, 300, AssetCallbackFlag::Disabled)?; + let original_asset1 = FungibleAsset::new(faucet1, 200, AssetCallbackFlag::Disabled)?; + let original_asset2 = FungibleAsset::new(faucet2, 100, AssetCallbackFlag::Disabled)?; + let original_asset3 = FungibleAsset::new(faucet3, max_amount, AssetCallbackFlag::Disabled)?; + + let added_asset0 = FungibleAsset::new(faucet0, 100, AssetCallbackFlag::Disabled)?; + let added_asset1 = FungibleAsset::new(faucet1, 100, AssetCallbackFlag::Disabled)?; + let added_asset2 = FungibleAsset::new(faucet2, 200, AssetCallbackFlag::Disabled)?; + let added_asset4 = FungibleAsset::new(faucet4, max_amount, AssetCallbackFlag::Disabled)?; + + let removed_asset0 = FungibleAsset::new(faucet0, 200, AssetCallbackFlag::Disabled)?; + let removed_asset1 = FungibleAsset::new(faucet1, 100, AssetCallbackFlag::Disabled)?; + let removed_asset2 = FungibleAsset::new(faucet2, 100, AssetCallbackFlag::Disabled)?; + let removed_asset3 = FungibleAsset::new(faucet3, max_amount, AssetCallbackFlag::Disabled)?; + + let tx_script = parse_tx_script(format!( + " + begin + push.{ASSET0_VALUE} push.{ASSET0_KEY} + exec.util::create_default_note_with_moved_asset + # => [] + + push.{ASSET1_VALUE} push.{ASSET1_KEY} + exec.util::create_default_note_with_moved_asset + # => [] + + push.{ASSET2_VALUE} push.{ASSET2_KEY} + exec.util::create_default_note_with_moved_asset + # => [] + + push.{ASSET3_VALUE} push.{ASSET3_KEY} + exec.util::create_default_note_with_moved_asset + # => [] + end + ", + ASSET0_KEY = removed_asset0.to_key_word(), + ASSET0_VALUE = removed_asset0.to_value_word(), + ASSET1_KEY = removed_asset1.to_key_word(), + ASSET1_VALUE = removed_asset1.to_value_word(), + ASSET2_KEY = removed_asset2.to_key_word(), + ASSET2_VALUE = removed_asset2.to_value_word(), + ASSET3_KEY = removed_asset3.to_key_word(), + ASSET3_VALUE = removed_asset3.to_value_word(), + ))?; + + let expected_vault_delta = AccountVaultDelta::from_iters( + [Asset::from(added_asset2.sub(removed_asset2)?), Asset::from(added_asset4)], + [Asset::from(removed_asset0.sub(added_asset0)?), Asset::from(removed_asset3)], + ); + + let mut expected_vault_patch = AccountVaultPatch::default(); + expected_vault_patch + .insert_asset(original_asset0.add(added_asset0)?.sub(removed_asset0)?.into()); + expected_vault_patch + .insert_asset(original_asset2.add(added_asset2)?.sub(removed_asset2)?.into()); + expected_vault_patch.remove_asset(removed_asset3.vault_key()); + expected_vault_patch.insert_asset(added_asset4.into()); + + AccountUpdateTest { + initial_storage_slots: vec![], + initial_vault_assets: vec![ + original_asset0.into(), + original_asset1.into(), + original_asset2.into(), + original_asset3.into(), + ], + input_notes_assets: vec![ + added_asset0.into(), + added_asset1.into(), + added_asset2.into(), + added_asset4.into(), + ], + tx_script: Some(tx_script), + expected_storage_patch: AccountStoragePatch::new(), + expected_vault_delta, + expected_vault_patch, + expected_code: None, + } + .execute() + .await +} + +/// Tests that adding, removing non-fungible assets results in the correct update. +/// +/// - Asset0 is added to the vault -> Delta: Add, Patch: Asset0. +/// - Asset1 is removed from the vault -> Delta: Remove, Patch: EMPTY_WORD. +/// - Asset2 is added and removed -> Delta: No Change, Patch: None. +/// - Asset3 is removed and added -> Delta: No Change, Patch: None. +#[tokio::test] +async fn non_fungible_asset_delta() -> anyhow::Result<()> { + let mut rng = rand::rng(); + // Test with random IDs to make sure the ordering in the MASM and Rust implementations + // matches. + let faucet0: AccountId = AccountIdBuilder::new().build_with_seed(rng.random()); + let faucet1: AccountId = AccountIdBuilder::new().build_with_seed(rng.random()); + let faucet2: AccountId = AccountIdBuilder::new() + .account_type(AccountType::Public) + .build_with_seed(rng.random()); + let faucet3: AccountId = AccountIdBuilder::new() + .account_type(AccountType::Private) + .build_with_seed(rng.random()); + + let asset0 = NonFungibleAsset::new(&NonFungibleAssetDetails::new( + faucet0, + rng.random::<[u8; 32]>().to_vec(), + AssetCallbackFlag::Disabled, + )); + let asset1 = NonFungibleAsset::new(&NonFungibleAssetDetails::new( + faucet1, + rng.random::<[u8; 32]>().to_vec(), + AssetCallbackFlag::Disabled, + )); + let asset2 = NonFungibleAsset::new(&NonFungibleAssetDetails::new( + faucet2, + rng.random::<[u8; 32]>().to_vec(), + AssetCallbackFlag::Disabled, + )); + let asset3 = NonFungibleAsset::new(&NonFungibleAssetDetails::new( + faucet3, + rng.random::<[u8; 32]>().to_vec(), + AssetCallbackFlag::Disabled, + )); + + let tx_script = parse_tx_script(format!( + " + begin + push.{ASSET1_VALUE} push.{ASSET1_KEY} + exec.util::create_default_note_with_moved_asset + # => [] + + push.{ASSET2_VALUE} push.{ASSET2_KEY} + exec.util::create_default_note_with_moved_asset + # => [] + + # remove asset 3 + push.{ASSET3_VALUE} + push.{ASSET3_KEY} + exec.remove_asset + # => [FINAL_ASSET_VALUE] + dropw + + # re-add asset 3 + push.{ASSET3_VALUE} + push.{ASSET3_KEY} + # => [ASSET_KEY, ASSET_VALUE] + exec.add_asset dropw + # => [] + end + ", + ASSET1_KEY = asset1.to_key_word(), + ASSET1_VALUE = asset1.to_value_word(), + ASSET2_KEY = asset2.to_key_word(), + ASSET2_VALUE = asset2.to_value_word(), + ASSET3_KEY = asset3.to_key_word(), + ASSET3_VALUE = asset3.to_value_word(), + ))?; + + let expected_vault_delta = + AccountVaultDelta::from_iters([Asset::from(asset0)], [Asset::from(asset1)]); + + let mut expected_vault_patch = AccountVaultPatch::default(); + expected_vault_patch.insert_asset(asset0.into()); + expected_vault_patch.remove_asset(Asset::from(asset1).vault_key()); + + AccountUpdateTest { + initial_storage_slots: vec![], + initial_vault_assets: vec![asset1.into(), asset3.into()], + input_notes_assets: vec![asset0.into(), asset2.into()], + tx_script: Some(tx_script), + expected_storage_patch: AccountStoragePatch::new(), + expected_vault_delta, + expected_vault_patch, + expected_code: None, + } + .execute() + .await +} + +/// Tests that storage value/map updates combined with vault asset additions and removals are +/// reflected correctly in the resulting delta and patch. +/// +/// To keep the focus on the interplay between storage and vault sub-patches, the added and +/// removed assets are deliberately disjoint - multiple updates to same-faucet assets is covered by +/// other tests. +/// +/// Vault: +/// - asset_0 starts at 1000, moved out to note -> Delta: Remove asset_0, Patch: EMPTY_WORD. +/// - asset_1 is moved out to note -> Delta: Remove asset_1, Patch: EMPTY_WORD. +/// - asset_2 is moved in to vault -> Delta: Add asset_2, Patch: asset_2. +/// - asset_3 is moved in to vault -> Delta: Add asset_3, Patch: asset_3. +/// +/// Storage: +/// - MOCK_VALUE_SLOT0: STORAGE_VALUE_0 -> updated_slot_value -> Patch: updated_slot_value. +/// - MOCK_MAP_SLOT[updated_map_key]: EMPTY_WORD -> updated_map_value -> Patch: updated_map_value. +/// - MOCK_VALUE_SLOT1 and the other MOCK_MAP_SLOT entries are untouched -> Patch: None. +#[tokio::test] +async fn asset_and_storage_patch() -> anyhow::Result<()> { + let mut rng = rand::rng(); + + let faucet_0: AccountId = AccountIdBuilder::new().build_with_seed(rng.random()); + let faucet_1: AccountId = AccountIdBuilder::new().build_with_seed(rng.random()); + let faucet_2: AccountId = AccountIdBuilder::new().build_with_seed(rng.random()); + let faucet_3: AccountId = AccountIdBuilder::new().build_with_seed(rng.random()); + + let asset_0: Asset = FungibleAsset::new(faucet_0, 1000, AssetCallbackFlag::Disabled)?.into(); + let asset_1: Asset = NonFungibleAsset::new(&NonFungibleAssetDetails::new( + faucet_1, + rng.random::<[u8; 32]>().to_vec(), + AssetCallbackFlag::Disabled, + )) + .into(); + + let asset_2: Asset = FungibleAsset::new(faucet_2, 500, AssetCallbackFlag::Disabled)?.into(); + let asset_3: Asset = NonFungibleAsset::new(&NonFungibleAssetDetails::new( + faucet_3, + rng.random::<[u8; 32]>().to_vec(), + AssetCallbackFlag::Disabled, + )) + .into(); + + let updated_slot_value = Word::from([7, 9, 11, 13u32]); + let updated_map_key = StorageMapKey::from_array([14, 15, 16, 17u32]); + let updated_map_value = Word::from([18, 19, 20, 21u32]); + + let removed_assets = [asset_0, asset_1]; + let added_assets = [asset_2, asset_3]; + + let mut send_assets_script = String::new(); + for (i, removed_asset) in removed_assets.iter().enumerate() { + send_assets_script.push_str(&format!( + " + ### note {i} + # prepare the stack for a new note creation + push.0.1.2.3 # RECIPIENT + push.{note_type} # note_type + push.{tag} # tag + + # create the note + exec.output_note::create + # => [note_idx, pad(15)] + + # move the asset into the new note + swapw dropw + push.{ASSET_VALUE} push.{ASSET_KEY} + call.::miden::standards::wallets::basic::move_asset_to_note + # => [pad(16)] + + # clear the stack + dropw dropw dropw dropw + ", + note_type = NoteType::Private as u8, + tag = NoteTag::default(), + ASSET_KEY = removed_asset.to_key_word(), + ASSET_VALUE = removed_asset.to_value_word(), + )); + } + + let tx_script_src = format!( + r#" + use mock::account + use miden::protocol::output_note + + const MOCK_VALUE_SLOT0 = word("{mock_value_slot0}") + const MOCK_MAP_SLOT = word("{mock_map_slot}") + + begin + ## Update value storage slot + push.{updated_slot_value} + push.MOCK_VALUE_SLOT0[0..2] + call.account::set_item dropw + + ## Update map storage slot at a previously-unset key + push.{updated_map_value} + push.{updated_map_key} + push.MOCK_MAP_SLOT[0..2] + call.account::set_map_item dropw dropw dropw + + ## Move both initial vault assets out via newly created output notes + {send_assets_script} + + dropw dropw dropw dropw + end + "#, + mock_value_slot0 = &*MOCK_VALUE_SLOT0, + mock_map_slot = &*MOCK_MAP_SLOT, + ); + + let tx_script = CodeBuilder::with_mock_libraries().compile_tx_script(tx_script_src)?; + + let mut expected_storage_patch = AccountStoragePatch::new(); + expected_storage_patch.set_item(MOCK_VALUE_SLOT0.clone(), updated_slot_value)?; + expected_storage_patch.set_map_item( + MOCK_MAP_SLOT.clone(), + updated_map_key, + updated_map_value, + )?; + + let expected_vault_delta = AccountVaultDelta::from_iters(added_assets, removed_assets); + + let mut expected_vault_patch = AccountVaultPatch::default(); + expected_vault_patch.remove_asset(asset_0.vault_key()); + expected_vault_patch.remove_asset(asset_1.vault_key()); + expected_vault_patch.insert_asset(asset_2); + expected_vault_patch.insert_asset(asset_3); + + AccountUpdateTest { + initial_storage_slots: AccountStorage::mock_storage_slots(), + initial_vault_assets: vec![asset_0, asset_1], + input_notes_assets: vec![asset_2, asset_3], + tx_script: Some(tx_script), + expected_storage_patch, + expected_vault_delta, + expected_vault_patch, + expected_code: None, + } + .execute() + .await +} + +/// Tests that the storage map updates for a _new public_ account in an executed and proven +/// transaction match up. +/// +/// This is an interesting test case because: +/// - for new accounts in general, the storage map entries must be available in the advice provider +/// and the resulting delta must be convertible to a full account. +/// - it creates an account with two identical storage maps. +/// - The prover mutates the delta to account for fee logic. +#[tokio::test] +async fn proven_tx_storage_maps_matches_executed_tx_for_new_account() -> anyhow::Result<()> { + // Use two identical maps to test that they are properly handled + // (see also https://github.com/0xMiden/protocol/issues/2037). + let map0 = StorageMap::with_entries([(StorageMapKey::from_raw(rand_value()), rand_value())])?; + let map1 = map0.clone(); + let mut map2 = StorageMap::with_entries([ + (StorageMapKey::from_raw(rand_value()), rand_value()), + (StorageMapKey::from_raw(rand_value()), rand_value()), + (StorageMapKey::from_raw(rand_value()), rand_value()), + (StorageMapKey::from_raw(rand_value()), rand_value()), + ])?; + + let map0_slot_name = StorageSlotName::mock(1); + let map1_slot_name = StorageSlotName::mock(2); + let map2_slot_name = StorageSlotName::mock(4); + + // Build a public account so the proven transaction includes the account update. + let account = AccountBuilder::new([1; 32]) + .account_type(AccountType::Public) + .with_auth_component(delta_check_auth_component()) + .with_component(MockAccountComponent::with_slots(vec![ + AccountStorage::mock_value_slot0(), + StorageSlot::with_map(map0_slot_name.clone(), map0.clone()), + StorageSlot::with_map(map1_slot_name.clone(), map1.clone()), + AccountStorage::mock_value_slot1(), + StorageSlot::with_map(map2_slot_name.clone(), map2.clone()), + ])) + .build()?; + + // Fetch a random existing key from the map. + let existing_key = *map2.entries().next().unwrap().0; + let value0 = Word::from([3, 4, 5, 6u32]); + + let code = format!( + r#" + use mock::account + + const MAP_SLOT=word("{map2_slot_name}") + + begin + # Update an existing key. + push.{value0} + push.{existing_key} + push.MAP_SLOT[0..2] + # => [slot_id_suffix, slot_id_prefix, KEY, VALUE] + call.account::set_map_item + + exec.::miden::core::sys::truncate_stack + end + "# + ); + + let builder = CodeBuilder::with_mock_libraries(); + let source_manager = builder.source_manager(); + let tx_script = builder.compile_tx_script(code)?; + + let tx_builder = TransactionContextBuilder::new(account.clone()) + .tx_script(tx_script) + .with_source_manager(source_manager); + + let tx_summary = tx_builder + .clone() + .auth_args(emit_delta_args()) + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); + let tx = tx_builder.build()?.execute().await?; + + map2.insert(existing_key, value0)?; + + for (slot_name, expected_map) in + [(map0_slot_name, map0), (map1_slot_name, map1), (map2_slot_name, map2)] + { + let map_patch_entries = tx.account_patch().storage().get_map(&slot_name).unwrap().entries(); + let expected: BTreeMap<_, _> = expected_map.entries().map(|(k, v)| (*k, *v)).collect(); + assert_eq!(map_patch_entries, &expected, "map delta does not match for slot {slot_name}",); + } + + let proven_tx = LocalTransactionProver::default().prove_dummy(tx.clone())?; + + let proven_tx_patch = proven_tx.account_update().details().unwrap_public(); + + let proven_tx_account = Account::try_from(proven_tx_patch)?; + let exec_tx_account = Account::try_from(tx.account_patch())?; + let exec_tx_delta_account = Account::try_from(tx_summary.account_delta())?; + + assert_eq!(exec_tx_delta_account, exec_tx_account); + assert_eq!(proven_tx_account.storage(), exec_tx_account.storage()); + + // Check the conversion back into a full-state delta and patch works correctly. + let proven_tx_patch_converted = AccountPatch::try_from(proven_tx_account.clone())?; + let exec_tx_patch_converted = AccountPatch::try_from(exec_tx_account.clone())?; + + let proven_tx_delta_converted = AccountDelta::try_from(proven_tx_account)?; + let exec_tx_delta_converted = AccountDelta::try_from(exec_tx_account)?; + assert_eq!(proven_tx_delta_converted, exec_tx_delta_converted); + + // Check that the deltas and patches from proven and executed tx, which were converted from + // accounts are identical. This is essentially a roundtrip test. + assert_eq!(tx.account_patch(), proven_tx_patch); + assert_eq!(&proven_tx_patch_converted, tx.account_patch()); + assert_eq!(&exec_tx_patch_converted, tx.account_patch()); + + assert_eq!(&exec_tx_delta_converted, tx_summary.account_delta()); + assert_eq!(&proven_tx_delta_converted, tx_summary.account_delta()); + + // The commitments should match as well. + assert_eq!(exec_tx_patch_converted.to_commitment(), tx.account_patch().to_commitment()); + assert_eq!(proven_tx_patch_converted.to_commitment(), tx.account_patch().to_commitment()); + + assert_eq!( + exec_tx_delta_converted.to_commitment(), + tx_summary.account_delta().to_commitment() + ); + assert_eq!( + proven_tx_delta_converted.to_commitment(), + tx_summary.account_delta().to_commitment() + ); + + Ok(()) +} + +/// Tests that creating a new account with a slot whose value is empty is correctly included in the +/// delta and not normalized away. +#[tokio::test] +async fn delta_for_new_account_retains_empty_value_storage_slots() -> anyhow::Result<()> { + let slot_name0 = StorageSlotName::mock(0); + let slot_name1 = StorageSlotName::mock(1); + + let slot_value2 = Word::from([1, 2, 3, 4u32]); + let mut account = AccountBuilder::new(rand::random()) + .account_type(AccountType::Public) + .with_component(MockAccountComponent::with_slots(vec![ + StorageSlot::with_empty_value(slot_name0.clone()), + StorageSlot::with_value(slot_name1.clone(), slot_value2), + ])) + .with_auth_component(Auth::IncrNonce) + .build()?; + + let tx = TransactionContextBuilder::new(account.clone()).build()?.execute().await?; + + let proven_tx = LocalTransactionProver::default().prove_dummy(tx.clone())?; + + let patch = proven_tx.account_update().details().unwrap_public(); + + assert_eq!(patch.storage().values().count(), 2); + assert_eq!(patch.storage().get_value(&slot_name0), Some(Word::empty())); + assert_eq!(patch.storage().get_value(&slot_name1), Some(slot_value2)); + + let recreated_account = Account::try_from(patch)?; + // The recreated account should match the original account with the nonce incremented (and the + // seed removed). + account.increment_nonce(Felt::ONE)?; + assert_eq!(recreated_account, account); + + Ok(()) +} + +/// Tests that creating a new account with a slot whose map is empty is correctly included in the +/// delta. +#[tokio::test] +async fn delta_for_new_account_retains_empty_map_storage_slots() -> anyhow::Result<()> { + let slot_name0 = StorageSlotName::mock(0); + + let mut account = AccountBuilder::new(rand::random()) + .account_type(AccountType::Public) + .with_component(MockAccountComponent::with_slots(vec![StorageSlot::with_empty_map( + slot_name0.clone(), + )])) + .with_auth_component(Auth::IncrNonce) + .build()?; + + let tx = TransactionContextBuilder::new(account.clone()).build()?.execute().await?; + + let proven_tx = LocalTransactionProver::default().prove_dummy(tx.clone())?; + + let patch = proven_tx.account_update().details().unwrap_public(); + + assert_eq!(patch.storage().maps().count(), 1); + assert!(patch.storage().get_map(&slot_name0).unwrap().is_empty()); + + let recreated_account = Account::try_from(patch)?; + // The recreated account should match the original account with the nonce incremented (and the + // seed removed). + account.increment_nonce(Felt::ONE)?; + assert_eq!(recreated_account, account); + + Ok(()) +} + +/// Tests that adding a fungible asset with amount zero to the account vault works and does not +/// result in an account delta or patch entry. +#[tokio::test] +async fn adding_amount_zero_fungible_asset_to_account_vault_works() -> anyhow::Result<()> { + AccountUpdateTest { + initial_storage_slots: vec![], + initial_vault_assets: vec![], + input_notes_assets: vec![FungibleAsset::mock(0)], + tx_script: None, + expected_storage_patch: AccountStoragePatch::new(), + expected_vault_delta: AccountVaultDelta::default(), + expected_vault_patch: AccountVaultPatch::default(), + expected_code: None, + } + .execute() + .await +} + +/// Tests that recomputing a delta correctly resets the account delta tracked by the host. +/// +/// The auth procedure adds `asset0` to the vault, explicitly calls `compute_delta_commitment`, +/// and then removes `asset0` again. It then builds the tx summary (which calls +/// `compute_delta_commitment` a second time) and emits `AUTH_UNAUTHORIZED_EVENT`, so the can access +/// the delta via `TransactionSummary::account_delta`. +/// +/// Without the reset performed at the start of each `compute_delta_commitment`, this would fail: +/// - The explicit call iterates the asset delta link map, sees the added `asset0`, and fires +/// `on_asset_delta_computation`, which accumulates `asset0` into the host's tracked vault delta. +/// - Removing `asset0` afterwards turns its link map entry into a net-empty entry. +/// - The summary's call iterates the link map again, but the net-empty entry does NOT fire +/// `on_asset_delta_computation`, so the previously accumulated `asset0` would remain in the +/// host's vault delta forever, leaving it non-empty even though the actual change is zero. +/// +/// With the reset (the `before_asset_delta_computation` event emitted at the start of each +/// `compute_delta_commitment`), the host clears its vault delta before each iteration, so the +/// summary's call correctly produces an empty delta. +#[tokio::test] +async fn recomputing_delta_resets_host_delta() -> anyhow::Result<()> { + let mut rng = rand::rng(); + // Test with random IDs to make sure the ordering in the MASM and Rust implementations + // matches. + let faucet0: AccountId = AccountIdBuilder::new().build_with_seed(rng.random()); + let faucet1: AccountId = AccountIdBuilder::new().build_with_seed(rng.random()); + + let asset0 = NonFungibleAsset::new(&NonFungibleAssetDetails::new( + faucet0, + rng.random::<[u8; 32]>().to_vec(), + AssetCallbackFlag::Disabled, + )); + let asset1 = NonFungibleAsset::new(&NonFungibleAssetDetails::new( + faucet1, + rng.random::<[u8; 32]>().to_vec(), + AssetCallbackFlag::Disabled, + )); + + let auth_code = format!( + " + use miden::protocol::native_account + use miden::protocol::auth::AUTH_UNAUTHORIZED_EVENT + + {TEST_ACCOUNT_CONVENIENCE_WRAPPERS} + + #! Inputs: [[AUTH_ARGS], pad(12)] + #! Outputs: [pad(16)] + @auth_script + pub proc auth_test + exec.native_account::incr_nonce drop + # => [[AUTH_ARGS], pad(12)] + + # add asset 0 to the vault + push.{ASSET0_VALUE} push.{ASSET0_KEY} + exec.add_asset dropw + # => [[AUTH_ARGS], pad(12)] + + # compute the delta to trigger the host to add the assets to the vault delta + exec.native_account::compute_delta_commitment + dropw + # => [[AUTH_ARGS], pad(12)] + + # remove asset 0 for correct asset preservation + push.{ASSET0_VALUE} + push.{ASSET0_KEY} + exec.remove_asset dropw + # => [[AUTH_ARGS], pad(12)] + + # Build the tx summary. + # Replace AUTH_ARGS with an EMPTY_WORD salt for the tx summary. + dropw padw + # => [SALT, pad(12)] + + exec.::miden::standards::auth::create_tx_summary + # => [ACCOUNT_DELTA_COMMITMENT, INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, SALT, pad(12)] + + adv.insert_hqword + # => [ACCOUNT_DELTA_COMMITMENT, INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, SALT, pad(12)] + + exec.::miden::standards::auth::hash_tx_summary + # => [TX_SUMMARY_COMMITMENT, pad(12)] + + emit.AUTH_UNAUTHORIZED_EVENT + + push.0 assert.err=\"emitting the event should have aborted execution\" + end + ", + ASSET0_KEY = asset0.to_key_word(), + ASSET0_VALUE = asset0.to_value_word(), + ); + + let auth_component_code = + CodeBuilder::with_mock_libraries().compile_component_code("test::account", auth_code)?; + + let mut builder = MockChain::builder(); + let account = Account::builder(builder.rng_mut().random()) + .account_type(AccountType::Public) + .with_auth_component(AccountComponent::new( + auth_component_code, + vec![], + AccountComponentMetadata::new("test::account"), + )?) + .with_component(MockAccountComponent::with_slots(vec![])) + .with_assets(vec![asset1.into()]) + .build_existing()?; + builder.add_account(account.clone())?; + let mock_chain = builder.build()?; + + let tx_summary = mock_chain + .build_tx_context(account, &[], &[])? + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); + let account_delta = tx_summary.account_delta(); + + assert!(account_delta.vault().is_empty(), "vault delta should be effectively empty"); + assert!(account_delta.storage().is_empty(), "storage delta should be empty"); + assert_eq!( + account_delta.nonce_delta().as_canonical_u64(), + 1, + "nonce should have been incremented" + ); + + Ok(()) +} + +// TEST HELPERS +// ================================================================================================ + +fn parse_tx_script(code: impl AsRef) -> anyhow::Result { + let code = format!( + " + {TEST_ACCOUNT_CONVENIENCE_WRAPPERS} + {code} + ", + code = code.as_ref() + ); + + CodeBuilder::with_mock_libraries() + .compile_tx_script(&code) + .context("failed to parse tx script") +} + +const TEST_ACCOUNT_CONVENIENCE_WRAPPERS: &str = " + use mock::account + use mock::util + use miden::protocol::output_note + + #! Inputs: [slot_id_suffix, slot_id_prefix, VALUE] + #! Outputs: [] + proc set_item + repeat.10 push.0 movdn.6 end + # => [slot_id_suffix, slot_id_prefix, VALUE, pad(10)] + + call.account::set_item + # => [OLD_VALUE, pad(12)] + + dropw dropw dropw dropw + end + + #! Inputs: [slot_id_suffix, slot_id_prefix, KEY, VALUE] + #! Outputs: [] + proc set_map_item + repeat.6 push.0 movdn.10 end + # => [index, KEY, VALUE, pad(6)] + + call.account::set_map_item + # => [OLD_VALUE, pad(12)] + + dropw dropw dropw dropw + # => [] + end + + #! Inputs: [ASSET_KEY, ASSET_VALUE] + #! Outputs: [FINAL_ASSET_VALUE] + proc add_asset + repeat.8 push.0 movdn.8 end + # => [ASSET_KEY, ASSET_VALUE, pad(8)] + + call.account::add_asset + # => [FINAL_ASSET_VALUE, pad(12)] + + repeat.12 movup.4 drop end + # => [FINAL_ASSET_VALUE] + end + + #! Inputs: [ASSET_KEY, ASSET_VALUE] + #! Outputs: [ASSET_VALUE] + proc remove_asset + padw padw swapdw + # => [ASSET_KEY, ASSET_VALUE, pad(8)] + + call.account::remove_asset + # => [ASSET_VALUE, pad(12)] + + repeat.12 movup.4 drop end + # => [ASSET_VALUE] + end +"; + +// DELTA-CHECK AUTH COMPONENT +// ================================================================================================ + +// Auth procedure that increments the nonce and, when the first felt of `AUTH_ARGS` is non-zero, +// emits `AUTH_UNAUTHORIZED_EVENT` after building the tx summary. The unauthorized event drives +// `TransactionBaseHost::build_tx_summary` which cross-checks the host-tracked delta commitment +// against the kernel-computed one — re-establishing the implicit host/kernel match check that +// existed when the kernel epilogue still produced the delta commitment. +const DELTA_CHECK_AUTH_CODE: &str = r#" + use miden::protocol::native_account + use miden::protocol::auth::AUTH_UNAUTHORIZED_EVENT + + #! Inputs: [[should_emit, 0, 0, 0], pad(12)] + #! Outputs: [pad(16)] + @auth_script + pub proc auth_incr_nonce_with_delta_check + exec.native_account::incr_nonce drop + # => [[should_emit, 0, 0, 0], pad(12)] + + dup + if.true + # Replace AUTH_ARGS with an EMPTY_WORD salt for the tx summary. + dropw padw + # => [SALT, pad(12)] + + exec.::miden::standards::auth::create_tx_summary + # => [ACCOUNT_DELTA_COMMITMENT, INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, SALT, pad(12)] + + adv.insert_hqword + # => [ACCOUNT_DELTA_COMMITMENT, INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, SALT, pad(12)] + + exec.::miden::standards::auth::hash_tx_summary + # => [TX_SUMMARY_COMMITMENT, pad(12)] + + emit.AUTH_UNAUTHORIZED_EVENT + + push.0 assert.err="emitting the event should have aborted execution" + end + end +"#; + +static DELTA_CHECK_AUTH_LIBRARY: LazyLock = LazyLock::new(|| { + CodeBuilder::with_mock_libraries() + .compile_component_code("test::incr_nonce_with_delta_check_auth", DELTA_CHECK_AUTH_CODE) + .expect("delta-check auth code should compile") +}); + +fn delta_check_auth_component() -> AccountComponent { + AccountComponent::new( + DELTA_CHECK_AUTH_LIBRARY.clone(), + vec![], + AccountComponentMetadata::new("test::incr_nonce_with_delta_check_auth"), + ) + .expect("delta-check auth component should be valid") +} + +// EXECUTE ACCOUNT UPDATE TEST HELPER +// ================================================================================================ + +struct AccountUpdateTest { + pub initial_storage_slots: Vec, + pub initial_vault_assets: Vec, + pub input_notes_assets: Vec, + pub tx_script: Option, + pub expected_storage_patch: AccountStoragePatch, + pub expected_vault_delta: AccountVaultDelta, + pub expected_vault_patch: AccountVaultPatch, + pub expected_code: Option, +} + +impl AccountUpdateTest { + /// Runs a transaction against the same setup to validate: + /// - that commitments match in host and kernel. + /// - that the delta and patch match the expectation. + /// + /// - The first run drives the auth into emitting the unauthorized event. The host's + /// `build_tx_summary` checks the host-tracked delta commitment against the kernel-computed + /// one before surfacing [`TransactionExecutorError::Unauthorized`]. We assert that the + /// wrapped `TransactionSummary::account_delta` equals the delta built from the expected + /// parts. + /// - The second run completes the transaction normally. The transaction executor checks that + /// patch commitments in host and kernel match. We assert that + /// `ExecutedTransaction::account_patch` equals the patch built from the expected parts. + async fn execute(self) -> anyhow::Result<()> { + let Self { + initial_storage_slots, + initial_vault_assets, + input_notes_assets, + tx_script, + expected_storage_patch, + expected_vault_delta, + expected_vault_patch, + expected_code, + } = self; + + let mut builder = MockChain::builder(); + let account = Account::builder(builder.rng_mut().random()) + .account_type(AccountType::Public) + .with_auth_component(delta_check_auth_component()) + .with_component(MockAccountComponent::with_slots(initial_storage_slots)) + .with_assets(initial_vault_assets) + .build_existing()?; + builder.add_account(account.clone())?; + + let mut input_note_ids = Vec::with_capacity(input_notes_assets.len()); + for note_asset in input_notes_assets { + let input_note = builder + .add_p2id_note(account.id(), account.id(), &[note_asset], NoteType::Public) + .context("failed to add note with assets")?; + input_note_ids.push(input_note.id()); + } + + let mock_chain = builder.build()?; + + let expected_nonce_delta = Felt::ONE; + let expected_delta = AccountDelta::new( + account.id(), + expected_storage_patch.clone(), + expected_vault_delta, + expected_nonce_delta, + )?; + let expected_patch = AccountPatch::new( + account.id(), + expected_storage_patch, + expected_vault_patch, + expected_code, + Some(account.nonce() + expected_nonce_delta), + )?; + + // Delta path: emit unauthorized so the host's build_tx_summary cross-checks the delta. + let delta_run = { + let mut tx = mock_chain + .build_tx_context(account.id(), &input_note_ids, &[])? + .auth_args(emit_delta_args()); + if let Some(ref script) = tx_script { + tx = tx.tx_script(script.clone()); + } + tx.build()?.execute().await + }; + let summary = match delta_run { + Err(TransactionExecutorError::Unauthorized(summary)) => summary, + Err(other) => anyhow::bail!("expected Unauthorized error, got: {other}"), + Ok(_) => anyhow::bail!("expected Unauthorized error, got Ok"), + }; + assert_eq!(*summary.account_delta(), expected_delta); + + // Patch path: complete the tx normally and check the patch. + let patch_run_tx = { + let mut tx = mock_chain + .build_tx_context(account.id(), &input_note_ids, &[])? + .auth_args(EMPTY_WORD); + if let Some(script) = tx_script { + tx = tx.tx_script(script); + } + tx.build()? + .execute() + .await + .context("failed to execute transaction (patch run)")? + }; + assert_eq!(*patch_run_tx.account_patch(), expected_patch); + + Ok(()) + } +} + +fn emit_delta_args() -> Word { + Word::from([Felt::ONE, ZERO, ZERO, ZERO]) +} diff --git a/crates/miden-testing/src/kernel_tests/tx/test_active_note.rs b/crates/miden-testing/src/kernel_tests/tx/test_active_note.rs index d8621cc6e0..b729aaaaf6 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_active_note.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_active_note.rs @@ -24,7 +24,7 @@ use miden_protocol::testing::account_id::{ }; use miden_protocol::testing::note::DEFAULT_NOTE_SCRIPT; use miden_protocol::transaction::memory::{ASSET_SIZE, ASSET_VALUE_OFFSET}; -use miden_protocol::{EMPTY_WORD, Felt, ONE, WORD_SIZE, Word}; +use miden_protocol::{EMPTY_WORD, Felt, ONE, WORD_SIZE, Word, ZERO}; use miden_standards::code_builder::CodeBuilder; use miden_standards::testing::mock_account::MockAccountExt; use miden_standards::testing::note::NoteBuilder; @@ -32,7 +32,7 @@ use rstest::rstest; use super::StackInputs; use crate::kernel_tests::tx::ExecutionOutputExt; -use crate::utils::create_public_p2any_note; +use crate::utils::{create_p2any_note, create_public_p2any_note}; use crate::{ Auth, MockChain, @@ -128,6 +128,123 @@ async fn test_active_note_get_metadata() -> anyhow::Result<()> { Ok(()) } +/// Tests that `get_metadata` returns only a single word (the metadata) on the stack. +/// +/// We push a marker word before the call, then assert the metadata sits directly on top of it. +/// This catches a leaked word at either position: a word left above the metadata fails the first +/// `assert_eqw` (metadata mismatch), and a word left below the metadata pushes the marker one +/// word deeper and fails the second `assert_eqw`. +#[tokio::test] +async fn test_active_note_get_metadata_no_extra_word() -> anyhow::Result<()> { + let tx_context = { + let account = + Account::mock(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE, Auth::IncrNonce); + let input_note = create_public_p2any_note( + ACCOUNT_ID_SENDER.try_into().unwrap(), + [FungibleAsset::mock(100)], + ); + TransactionContextBuilder::new(account) + .extend_input_notes(vec![input_note]) + .build()? + }; + + let code = format!( + r#" + use $kernel::prologue + use $kernel::note->note_internal + use miden::protocol::active_note + + begin + exec.prologue::prepare_transaction + exec.note_internal::prepare_note + dropw dropw dropw dropw + # => [] + + # marker word to detect any extra word left by get_metadata. + # must match the verifying push below. + push.1.2.3.4 + # => [MARKER] + + exec.active_note::get_metadata + # => [METADATA, MARKER] (correct: exactly one word added) + + push.{METADATA} + assert_eqw.err="active note metadata mismatch" + # => [MARKER] + + # if get_metadata leaked a word, METADATA would be here instead of MARKER. + # must match the marker pushed above. + push.1.2.3.4 + assert_eqw.err="get_metadata left an extra word on the stack" + # => [] + + # truncate the stack + swapw dropw + end + "#, + METADATA = tx_context.input_notes().get_note(0).note().metadata().to_metadata_word(), + ); + + tx_context.execute_code(&code).await?; + + Ok(()) +} + +/// `is_public` / `is_private` return the correct flag for the active note's type. +#[rstest::rstest] +#[case::public(NoteType::Public)] +#[case::private(NoteType::Private)] +#[tokio::test] +async fn test_active_note_is_public_and_is_private( + #[case] note_type: NoteType, +) -> anyhow::Result<()> { + let tx_context = { + let account = + Account::mock(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE, Auth::IncrNonce); + let mut rng = RandomCoin::new(Word::default()); + let input_note = create_p2any_note( + ACCOUNT_ID_SENDER.try_into().unwrap(), + note_type, + [FungibleAsset::mock(100)], + &mut rng, + ); + TransactionContextBuilder::new(account) + .extend_input_notes(vec![input_note]) + .build()? + }; + + let (expected_public, expected_private) = match note_type { + NoteType::Public => (ONE, ZERO), + NoteType::Private => (ZERO, ONE), + }; + + let code = format!( + r#" + use $kernel::prologue + use $kernel::note->note_internal + use miden::protocol::active_note + + begin + exec.prologue::prepare_transaction + exec.note_internal::prepare_note + dropw dropw dropw dropw + + exec.active_note::is_public + push.{expected_public} + assert_eq.err="active note public flag did not match expected value" + + exec.active_note::is_private + push.{expected_private} + assert_eq.err="active note private flag did not match expected value" + end + "# + ); + + tx_context.execute_code(&code).await?; + + Ok(()) +} + #[tokio::test] async fn test_active_note_get_sender() -> anyhow::Result<()> { let tx_context = { diff --git a/crates/miden-testing/src/kernel_tests/tx/test_asset.rs b/crates/miden-testing/src/kernel_tests/tx/test_asset.rs index 5cbc4b351d..a11b6bf190 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_asset.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_asset.rs @@ -38,7 +38,11 @@ async fn test_create_fungible_asset_succeeds() -> anyhow::Result<()> { let tx_context = TransactionContextBuilder::with_fungible_faucet(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET) .build()?; - let expected_asset = FungibleAsset::new(tx_context.account().id(), FUNGIBLE_ASSET_AMOUNT)?; + let expected_asset = FungibleAsset::new( + tx_context.account().id(), + FUNGIBLE_ASSET_AMOUNT, + AssetCallbackFlag::Disabled, + )?; let code = format!( " @@ -76,6 +80,7 @@ async fn test_create_non_fungible_asset_succeeds() -> anyhow::Result<()> { let non_fungible_asset_details = NonFungibleAssetDetails::new( NonFungibleAsset::mock_issuer(), NON_FUNGIBLE_ASSET_DATA.to_vec(), + AssetCallbackFlag::Disabled, ); let non_fungible_asset = NonFungibleAsset::new(&non_fungible_asset_details); diff --git a/crates/miden-testing/src/kernel_tests/tx/test_asset_vault.rs b/crates/miden-testing/src/kernel_tests/tx/test_asset_vault.rs index d0ff5ea885..973373d1c4 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_asset_vault.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_asset_vault.rs @@ -120,8 +120,11 @@ async fn test_get_balance_non_fungible_fails() -> anyhow::Result<()> { .build()?; let faucet_id = AccountId::try_from(ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET).unwrap(); - let non_fungible_asset = - NonFungibleAsset::new(&NonFungibleAssetDetails::new(faucet_id, vec![1, 2, 3])); + let non_fungible_asset = NonFungibleAsset::new(&NonFungibleAssetDetails::new( + faucet_id, + vec![1, 2, 3], + AssetCallbackFlag::Disabled, + )); let code = format!( " use $kernel::prologue @@ -182,7 +185,7 @@ async fn test_add_fungible_asset_success() -> anyhow::Result<()> { let mut account_vault = tx_context.account().vault().clone(); let faucet_id: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap(); let amount = FungibleAsset::MAX_AMOUNT.as_u64() - FUNGIBLE_ASSET_AMOUNT; - let add_fungible_asset = FungibleAsset::new(faucet_id, amount)?; + let add_fungible_asset = FungibleAsset::new(faucet_id, amount, AssetCallbackFlag::Disabled)?; let code = format!( " @@ -228,7 +231,7 @@ async fn test_add_non_fungible_asset_fail_overflow() -> anyhow::Result<()> { let faucet_id: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap(); let amount = FungibleAsset::MAX_AMOUNT.as_u64() - FUNGIBLE_ASSET_AMOUNT + 1; - let add_fungible_asset = FungibleAsset::new(faucet_id, amount)?; + let add_fungible_asset = FungibleAsset::new(faucet_id, amount, AssetCallbackFlag::Disabled)?; let code = format!( " @@ -260,9 +263,12 @@ async fn test_add_non_fungible_asset_success() -> anyhow::Result<()> { let tx_context = TransactionContextBuilder::with_existing_mock_account().build()?; let faucet_id: AccountId = ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET.try_into()?; let mut account_vault = tx_context.account().vault().clone(); - let add_non_fungible_asset = Asset::NonFungible(NonFungibleAsset::new( - &NonFungibleAssetDetails::new(faucet_id, vec![1, 2, 3, 4, 5, 6, 7, 8]), - )); + let add_non_fungible_asset = + Asset::NonFungible(NonFungibleAsset::new(&NonFungibleAssetDetails::new( + faucet_id, + vec![1, 2, 3, 4, 5, 6, 7, 8], + AssetCallbackFlag::Disabled, + ))); let code = format!( " @@ -303,8 +309,11 @@ async fn test_add_non_fungible_asset_fail_duplicate() -> anyhow::Result<()> { let tx_context = TransactionContextBuilder::with_existing_mock_account().build()?; let faucet_id: AccountId = ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET.try_into().unwrap(); let mut account_vault = tx_context.account().vault().clone(); - let non_fungible_asset_details = - NonFungibleAssetDetails::new(faucet_id, NON_FUNGIBLE_ASSET_DATA.to_vec()); + let non_fungible_asset_details = NonFungibleAssetDetails::new( + faucet_id, + NON_FUNGIBLE_ASSET_DATA.to_vec(), + AssetCallbackFlag::Disabled, + ); let non_fungible_asset = Asset::NonFungible(NonFungibleAsset::new(&non_fungible_asset_details)); let code = format!( @@ -339,7 +348,7 @@ async fn test_remove_fungible_asset_success_no_balance_remaining() -> anyhow::Re let faucet_id: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap(); let amount = FUNGIBLE_ASSET_AMOUNT; - let remove_fungible_asset = FungibleAsset::new(faucet_id, amount)?; + let remove_fungible_asset = FungibleAsset::new(faucet_id, amount, AssetCallbackFlag::Disabled)?; let code = format!( " @@ -380,7 +389,7 @@ async fn test_remove_fungible_asset_fail_remove_too_much() -> anyhow::Result<()> let tx_context = TransactionContextBuilder::with_existing_mock_account().build()?; let faucet_id: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap(); let amount = FUNGIBLE_ASSET_AMOUNT + 1; - let remove_fungible_asset = FungibleAsset::new(faucet_id, amount)?; + let remove_fungible_asset = FungibleAsset::new(faucet_id, amount, AssetCallbackFlag::Disabled)?; let code = format!( " @@ -415,7 +424,7 @@ async fn test_remove_fungible_asset_success_balance_remaining() -> anyhow::Resul let faucet_id: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap(); let amount = FUNGIBLE_ASSET_AMOUNT - 1; - let remove_fungible_asset = FungibleAsset::new(faucet_id, amount)?; + let remove_fungible_asset = FungibleAsset::new(faucet_id, amount, AssetCallbackFlag::Disabled)?; let code = format!( " @@ -457,8 +466,11 @@ async fn test_remove_inexisting_non_fungible_asset_fails() -> anyhow::Result<()> let faucet_id: AccountId = ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET_1.try_into().unwrap(); let mut account_vault = tx_context.account().vault().clone(); - let non_fungible_asset_details = - NonFungibleAssetDetails::new(faucet_id, NON_FUNGIBLE_ASSET_DATA.to_vec()); + let non_fungible_asset_details = NonFungibleAssetDetails::new( + faucet_id, + NON_FUNGIBLE_ASSET_DATA.to_vec(), + AssetCallbackFlag::Disabled, + ); let nonfungible = NonFungibleAsset::new(&non_fungible_asset_details); let non_existent_non_fungible_asset = Asset::NonFungible(nonfungible); @@ -501,8 +513,11 @@ async fn test_remove_non_fungible_asset_success() -> anyhow::Result<()> { let tx_context = TransactionContextBuilder::with_existing_mock_account().build()?; let faucet_id: AccountId = ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET.try_into().unwrap(); let mut account_vault = tx_context.account().vault().clone(); - let non_fungible_asset_details = - NonFungibleAssetDetails::new(faucet_id, NON_FUNGIBLE_ASSET_DATA.to_vec()); + let non_fungible_asset_details = NonFungibleAssetDetails::new( + faucet_id, + NON_FUNGIBLE_ASSET_DATA.to_vec(), + AssetCallbackFlag::Disabled, + ); let non_fungible_asset = Asset::NonFungible(NonFungibleAsset::new(&non_fungible_asset_details)); let code = format!( @@ -703,8 +718,10 @@ async fn test_merge_different_fungible_assets_fails() -> anyhow::Result<()> { let faucet_id1: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap(); let faucet_id2: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1.try_into().unwrap(); - let asset0 = FungibleAsset::new(faucet_id1, FUNGIBLE_ASSET_AMOUNT)?; - let asset1 = FungibleAsset::new(faucet_id2, FUNGIBLE_ASSET_AMOUNT)?; + let asset0 = + FungibleAsset::new(faucet_id1, FUNGIBLE_ASSET_AMOUNT, AssetCallbackFlag::Disabled)?; + let asset1 = + FungibleAsset::new(faucet_id2, FUNGIBLE_ASSET_AMOUNT, AssetCallbackFlag::Disabled)?; // Sanity check that the Rust implementation errors when adding assets from different faucets. assert_matches!( diff --git a/crates/miden-testing/src/kernel_tests/tx/test_callbacks.rs b/crates/miden-testing/src/kernel_tests/tx/test_callbacks.rs index 5e0cb032db..0d97e29aec 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_callbacks.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_callbacks.rs @@ -33,14 +33,9 @@ use miden_protocol::errors::MasmError; use miden_protocol::note::{NoteTag, NoteType}; use miden_protocol::utils::sync::LazyLock; use miden_protocol::{Felt, Word}; -use miden_standards::account::access::Authority; +use miden_standards::account::access::{Authority, Pausable}; use miden_standards::account::faucets::{FungibleFaucet, TokenName}; -use miden_standards::account::policies::{ - BurnPolicyConfig, - MintPolicyConfig, - PolicyRegistration, - TokenPolicyManager, -}; +use miden_standards::account::policies::{BurnPolicy, MintPolicy, TokenPolicyManager}; use miden_standards::code_builder::CodeBuilder; use miden_standards::procedure_root; use miden_standards::testing::account_component::MockFaucetComponent; @@ -275,13 +270,14 @@ async fn test_faucet_without_callback_slot_skips_callback( // Create a P2ID note with a callbacks-enabled asset from this faucet. // The faucet does not have the callback slot, but the asset has callbacks enabled. let asset = match asset_composition { - AssetComposition::Fungible => Asset::from(FungibleAsset::new(faucet.id(), 100)?), - AssetComposition::None => { - Asset::from(NonFungibleAsset::new(&NonFungibleAssetDetails::new(faucet.id(), vec![1]))) + AssetComposition::Fungible => { + Asset::from(FungibleAsset::new(faucet.id(), 100, AssetCallbackFlag::Enabled)?) }, + AssetComposition::None => Asset::from(NonFungibleAsset::new( + &NonFungibleAssetDetails::new(faucet.id(), vec![1], AssetCallbackFlag::Enabled), + )), _ => unreachable!("test does not use custom composition"), - } - .with_callbacks(AssetCallbackFlag::Enabled); + }; let note = builder.add_p2id_note(faucet.id(), target_account.id(), &[asset], NoteType::Public)?; @@ -357,8 +353,7 @@ async fn test_on_before_asset_added_to_account_callback_receives_correct_inputs( let faucet = add_faucet_with_callbacks(&mut builder, Some(&account_callback_masm), None)?; // Create a P2ID note with a callbacks-enabled fungible asset. - let fungible_asset = - FungibleAsset::new(faucet.id(), amount)?.with_callbacks(AssetCallbackFlag::Enabled); + let fungible_asset = FungibleAsset::new(faucet.id(), amount, AssetCallbackFlag::Enabled)?; let note = builder.add_p2id_note( faucet.id(), target_account.id(), @@ -386,13 +381,14 @@ async fn test_on_before_asset_added_to_account_callback_receives_correct_inputs( #[rstest::rstest] #[case::fungible( |faucet_id| { - Ok(FungibleAsset::new(faucet_id, 100)?.with_callbacks(AssetCallbackFlag::Enabled).into()) + Ok(FungibleAsset::new(faucet_id, 100, AssetCallbackFlag::Enabled)?.into()) } )] #[case::non_fungible( |faucet_id| { - let details = NonFungibleAssetDetails::new(faucet_id, vec![1, 2, 3, 4]); - Ok(NonFungibleAsset::new(&details).with_callbacks(AssetCallbackFlag::Enabled).into()) + let details = + NonFungibleAssetDetails::new(faucet_id, vec![1, 2, 3, 4], AssetCallbackFlag::Enabled); + Ok(NonFungibleAsset::new(&details).into()) } )] #[tokio::test] @@ -435,13 +431,14 @@ async fn test_blocked_account_cannot_receive_asset( #[rstest::rstest] #[case::fungible( |faucet_id| { - Ok(FungibleAsset::new(faucet_id, 100)?.with_callbacks(AssetCallbackFlag::Enabled).into()) + Ok(FungibleAsset::new(faucet_id, 100, AssetCallbackFlag::Enabled)?.into()) } )] #[case::non_fungible( |faucet_id| { - let details = NonFungibleAssetDetails::new(faucet_id, vec![1, 2, 3, 4]); - Ok(NonFungibleAsset::new(&details).with_callbacks(AssetCallbackFlag::Enabled).into()) + let details = + NonFungibleAssetDetails::new(faucet_id, vec![1, 2, 3, 4], AssetCallbackFlag::Enabled); + Ok(NonFungibleAsset::new(&details).into()) } )] #[tokio::test] @@ -560,8 +557,7 @@ async fn test_on_before_asset_added_to_note_callback_receives_correct_inputs() - // Create a P2ID note with a callbacks-enabled fungible asset. // Consuming this note adds the asset to the wallet's vault. - let fungible_asset = - FungibleAsset::new(faucet.id(), amount)?.with_callbacks(AssetCallbackFlag::Enabled); + let fungible_asset = FungibleAsset::new(faucet.id(), amount, AssetCallbackFlag::Enabled)?; let asset = Asset::Fungible(fungible_asset); let note = builder.add_p2id_note(faucet.id(), target_account.id(), &[asset], NoteType::Public)?; @@ -773,10 +769,12 @@ fn add_faucet_with_callbacks( .with_component(faucet) .with_component(Authority::AuthControlled) .with_components( - TokenPolicyManager::new() - .with_mint_policy(MintPolicyConfig::AllowAll, PolicyRegistration::Active)? - .with_burn_policy(BurnPolicyConfig::AllowAll, PolicyRegistration::Active)?, + TokenPolicyManager::builder() + .active_mint_policy(MintPolicy::allow_all()) + .active_burn_policy(BurnPolicy::allow_all()) + .build(), ) + .with_component(Pausable::unpaused()) .with_component(callback_component); builder.add_account_from_builder( diff --git a/crates/miden-testing/src/kernel_tests/tx/test_epilogue.rs b/crates/miden-testing/src/kernel_tests/tx/test_epilogue.rs index 9bd3eee04e..df83eff9d4 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_epilogue.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_epilogue.rs @@ -3,10 +3,10 @@ use std::borrow::ToOwned; use miden_processor::crypto::random::RandomCoin; use miden_processor::{Felt, ONE}; -use miden_protocol::account::{Account, AccountDelta, AccountStorageDelta, AccountVaultDelta}; -use miden_protocol::asset::{Asset, FungibleAsset}; +use miden_protocol::account::{Account, AccountPatch, AccountStoragePatch, AccountVaultPatch}; +use miden_protocol::asset::{Asset, AssetCallbackFlag, FungibleAsset}; use miden_protocol::errors::tx_kernel::{ - ERR_ACCOUNT_DELTA_NONCE_MUST_BE_INCREMENTED_IF_VAULT_OR_STORAGE_CHANGED, + ERR_ACCOUNT_PATCH_NONCE_MUST_BE_INCREMENTED_IF_VAULT_OR_STORAGE_CHANGED, ERR_EPILOGUE_EXECUTED_TRANSACTION_IS_EMPTY, ERR_EPILOGUE_NONCE_CANNOT_BE_0, ERR_EPILOGUE_TOTAL_NUMBER_OF_ASSETS_MUST_STAY_THE_SAME, @@ -105,20 +105,17 @@ async fn test_transaction_epilogue() -> anyhow::Result<()> { .collect(), )?; - let account_delta_commitment = AccountDelta::new( + let account_patch_commitment = AccountPatch::new( tx_context.account().id(), - AccountStorageDelta::default(), - AccountVaultDelta::default(), - ONE, + AccountStoragePatch::default(), + AccountVaultPatch::default(), + None, + Some(final_account.nonce()), )? .to_commitment(); let account_update_commitment = - Hasher::merge(&[final_account.to_commitment(), account_delta_commitment]); - let fee_asset = FungibleAsset::new( - tx_context.tx_inputs().block_header().fee_parameters().fee_faucet_id(), - 0, - )?; + Hasher::merge(&[final_account.to_commitment(), account_patch_commitment]); assert_eq!( exec_output.get_stack_word(TransactionOutputs::OUTPUT_NOTES_COMMITMENT_WORD_IDX), @@ -128,26 +125,18 @@ async fn test_transaction_epilogue() -> anyhow::Result<()> { exec_output.get_stack_word(TransactionOutputs::ACCOUNT_UPDATE_COMMITMENT_WORD_IDX), account_update_commitment, ); - assert_eq!( - exec_output.get_stack_element(TransactionOutputs::FEE_FAUCET_ID_SUFFIX_ELEMENT_IDX), - fee_asset.faucet_id().suffix(), - ); - assert_eq!( - exec_output.get_stack_element(TransactionOutputs::FEE_FAUCET_ID_PREFIX_ELEMENT_IDX), - fee_asset.faucet_id().prefix().as_felt() - ); - assert_eq!( - exec_output - .get_stack_element(TransactionOutputs::FEE_AMOUNT_ELEMENT_IDX) - .as_canonical_u64(), - fee_asset.amount().as_u64() - ); assert_eq!( exec_output .get_stack_element(TransactionOutputs::EXPIRATION_BLOCK_ELEMENT_IDX) .as_canonical_u64(), u64::from(u32::MAX) ); + + // Everything after the expiration block number (index 8) must be zero. + assert_eq!( + exec_output.get_stack_word(8).as_elements()[1..], + Word::empty().as_elements()[1..], + ); assert_eq!(exec_output.get_stack_word(12), Word::empty()); assert_eq!( @@ -255,10 +244,16 @@ async fn epilogue_fails_when_assets_arent_preserved( #[case] input_amount: u64, #[case] output_amount: u64, ) -> anyhow::Result<()> { - let input_asset = - FungibleAsset::new(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1.try_into()?, input_amount)?; - let output_asset = - FungibleAsset::new(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1.try_into()?, output_amount)?; + let input_asset = FungibleAsset::new( + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1.try_into()?, + input_amount, + AssetCallbackFlag::Disabled, + )?; + let output_asset = FungibleAsset::new( + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1.try_into()?, + output_amount, + AssetCallbackFlag::Disabled, + )?; let mut builder = MockChain::builder(); let account = builder.add_existing_mock_account(Auth::IncrNonce)?; @@ -487,7 +482,7 @@ async fn epilogue_fails_on_account_state_change_without_nonce_increment() -> any assert_transaction_executor_error!( result, - ERR_ACCOUNT_DELTA_NONCE_MUST_BE_INCREMENTED_IF_VAULT_OR_STORAGE_CHANGED + ERR_ACCOUNT_PATCH_NONCE_MUST_BE_INCREMENTED_IF_VAULT_OR_STORAGE_CHANGED ); Ok(()) diff --git a/crates/miden-testing/src/kernel_tests/tx/test_faucet.rs b/crates/miden-testing/src/kernel_tests/tx/test_faucet.rs index a070d34fc6..49598e4e43 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_faucet.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_faucet.rs @@ -45,7 +45,7 @@ use crate::{TransactionContextBuilder, assert_execution_error, assert_transactio #[tokio::test] async fn test_mint_fungible_asset_succeeds() -> anyhow::Result<()> { let faucet_id = AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET).unwrap(); - let asset = FungibleAsset::new(faucet_id, FUNGIBLE_ASSET_AMOUNT)?; + let asset = FungibleAsset::new(faucet_id, FUNGIBLE_ASSET_AMOUNT, AssetCallbackFlag::Disabled)?; let code = format!( r#" @@ -338,7 +338,7 @@ async fn mint_non_fungible_asset_fails_on_non_faucet_account() -> anyhow::Result #[tokio::test] async fn test_mint_fungible_asset_with_callbacks_enabled() -> anyhow::Result<()> { let faucet_id = AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET).unwrap(); - let asset = FungibleAsset::new(faucet_id, FUNGIBLE_ASSET_AMOUNT)?; + let asset = FungibleAsset::new(faucet_id, FUNGIBLE_ASSET_AMOUNT, AssetCallbackFlag::Disabled)?; // Build a vault key with callbacks enabled. let vault_key = AssetVaultKey::new( @@ -381,7 +381,9 @@ async fn test_mint_fungible_asset_with_callbacks_enabled() -> anyhow::Result<()> #[tokio::test] async fn test_burn_fungible_asset_succeeds() -> anyhow::Result<()> { let account = Account::mock_fungible_faucet(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1); - let asset = FungibleAsset::new(account.id(), 100u64).unwrap().into(); + let asset = FungibleAsset::new(account.id(), 100u64, AssetCallbackFlag::Disabled) + .unwrap() + .into(); let note = create_public_p2any_note(ACCOUNT_ID_SENDER.try_into().unwrap(), [asset]); let tx_context = TransactionContextBuilder::new(account).extend_input_notes(vec![note]).build()?; @@ -467,7 +469,8 @@ async fn test_burn_fungible_asset_inconsistent_faucet_id() -> anyhow::Result<()> .build()?; let faucet_id = AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1).unwrap(); - let fungible_asset = FungibleAsset::new(faucet_id, FUNGIBLE_ASSET_AMOUNT)?; + let fungible_asset = + FungibleAsset::new(faucet_id, FUNGIBLE_ASSET_AMOUNT, AssetCallbackFlag::Disabled)?; let code = format!( " @@ -498,7 +501,8 @@ async fn test_burn_fungible_asset_insufficient_input_amount() -> anyhow::Result< .build()?; let faucet_id = AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1).unwrap(); - let fungible_asset = FungibleAsset::new(faucet_id, CONSUMED_ASSET_1_AMOUNT + 1)?; + let fungible_asset = + FungibleAsset::new(faucet_id, CONSUMED_ASSET_1_AMOUNT + 1, AssetCallbackFlag::Disabled)?; let code = format!( " @@ -550,7 +554,7 @@ async fn test_burn_non_fungible_asset_succeeds() -> anyhow::Result<()> { push.{INPUT_VAULT_ROOT_PTR} push.{NON_FUNGIBLE_ASSET_VALUE} push.{NON_FUNGIBLE_ASSET_KEY} - exec.asset_vault::add_non_fungible_asset dropw + exec.asset_vault::add_non_fungible_asset dropw dropw # check that the non-fungible asset is presented in the input vault push.{INPUT_VAULT_ROOT_PTR} diff --git a/crates/miden-testing/src/kernel_tests/tx/test_fee.rs b/crates/miden-testing/src/kernel_tests/tx/test_fee.rs deleted file mode 100644 index e07c755902..0000000000 --- a/crates/miden-testing/src/kernel_tests/tx/test_fee.rs +++ /dev/null @@ -1,194 +0,0 @@ -use anyhow::Context; -use assert_matches::assert_matches; -use miden_crypto::rand::test_utils::rand_value; -use miden_protocol::account::{AccountId, StorageMap, StorageMapKey, StorageSlot, StorageSlotName}; -use miden_protocol::asset::{Asset, FungibleAsset, NonFungibleAsset}; -use miden_protocol::note::NoteType; -use miden_protocol::testing::account_id::ACCOUNT_ID_FEE_FAUCET; -use miden_protocol::transaction::{ExecutedTransaction, RawOutputNote}; -use miden_protocol::{self, Felt, Word}; -use miden_tx::TransactionExecutorError; - -use crate::utils::create_public_p2any_note; -use crate::{Auth, MockChain}; - -// FEE TESTS -// ================================================================================================ - -/// Tests that a simple wallet account can be created with non-zero fees. -#[tokio::test] -async fn create_account_with_fees() -> anyhow::Result<()> { - let note_amount = 10_000; - - let mut builder = MockChain::builder().verification_base_fee(50); - let account = builder.create_new_wallet(Auth::IncrNonce)?; - let fee_note = builder.add_p2id_note_with_fee(account.id(), note_amount)?; - let chain = builder.build()?; - - let tx = chain - .build_tx_context(account, &[fee_note.id()], &[])? - .build()? - .execute() - .await - .context("failed to execute account-creating transaction")?; - - let expected_fee = tx.compute_fee(); - assert_eq!(expected_fee, tx.fee().amount()); - - // We expect that the new account contains the note_amount minus the paid fee. - let added_asset = FungibleAsset::new(chain.fee_faucet_id(), note_amount)?.sub(tx.fee())?; - - assert_eq!(tx.account_delta().nonce_delta(), Felt::ONE); - // except for the nonce, the storage delta should be empty - assert!(tx.account_delta().storage().is_empty()); - assert_eq!(tx.account_delta().vault().added_assets().count(), 1); - assert_eq!(tx.account_delta().vault().removed_assets().count(), 0); - assert_eq!(tx.account_delta().vault().added_assets().next().unwrap(), added_asset.into()); - assert_eq!(tx.final_account().nonce(), Felt::ONE); - // account commitment should not be the empty word - assert_ne!(tx.account_delta().to_commitment(), Word::empty()); - - Ok(()) -} - -/// Tests that the transaction executor host aborts the transaction if the balance of the fee -/// asset in the account does not cover the computed fee. -#[tokio::test] -async fn tx_host_aborts_if_account_balance_does_not_cover_fee() -> anyhow::Result<()> { - let account_amount = 100; - let note_amount = 100; - let fee_faucet_id = AccountId::try_from(ACCOUNT_ID_FEE_FAUCET)?; - - let mut builder = MockChain::builder().fee_faucet_id(fee_faucet_id).verification_base_fee(50); - let fee_asset = FungibleAsset::new(fee_faucet_id, account_amount)?; - let account = builder.add_existing_wallet_with_assets(Auth::IncrNonce, [fee_asset.into()])?; - let fee_note = builder.add_p2id_note_with_fee(account.id(), note_amount)?; - let chain = builder.build()?; - - let err = chain - .build_tx_context(account, &[fee_note.id()], &[])? - .build()? - .execute() - .await - .unwrap_err(); - - assert_matches!( - err, - TransactionExecutorError::InsufficientFee { account_balance, tx_fee: _ } => { - assert_eq!(account_balance, account_amount + note_amount); - } - ); - - Ok(()) -} - -/// Tests that the _actual_ number of cycles after compute_fee is called are less than the -/// _predicted_ number of cycles (based on the constants) across a diverse set of transactions. -/// -/// TODO: Once smt::set supports multiple leaves, this case should be tested explicitly here. -#[rstest::rstest] -#[case::create_account_no_storage(create_account_no_storage_no_fees().await?)] -#[case::mutate_account_with_storage(mutate_account_with_storage().await?)] -#[case::create_output_notes(create_output_notes().await?)] -#[tokio::test] -async fn num_tx_cycles_after_compute_fee_are_less_than_estimated( - #[case] tx: ExecutedTransaction, -) -> anyhow::Result<()> { - // These constants should always be updated together with the equivalent constants in - // epilogue.masm. - const SMT_SET_ADDITIONAL_CYCLES: usize = 250; - const NUM_POST_COMPUTE_FEE_CYCLES: usize = 608; - - assert!( - tx.measurements().after_tx_cycles_obtained - < NUM_POST_COMPUTE_FEE_CYCLES + SMT_SET_ADDITIONAL_CYCLES, - "estimated number of cycles is not larger than the measurements, so they need to be updated" - ); - - Ok(()) -} - -/// Returns a transaction that creates an account without storage and 0 fees. -async fn create_account_no_storage_no_fees() -> anyhow::Result { - let mut builder = MockChain::builder(); - let account = builder.create_new_wallet(Auth::IncrNonce)?; - builder - .build()? - .build_tx_context(account, &[], &[])? - .build()? - .execute() - .await - .map_err(From::from) -} - -/// Returns a transaction that mutates an account with storage and consumes a note. -async fn mutate_account_with_storage() -> anyhow::Result { - let fee_faucet_id = AccountId::try_from(ACCOUNT_ID_FEE_FAUCET)?; - let fee_asset = FungibleAsset::new(fee_faucet_id, 10_000)?; - let mut builder = MockChain::builder().fee_faucet_id(fee_faucet_id).verification_base_fee(100); - let account = builder.add_existing_mock_account_with_storage_and_assets( - Auth::IncrNonce, - [ - StorageSlot::with_value(StorageSlotName::mock(0), rand_value()), - StorageSlot::with_map( - StorageSlotName::mock(1), - StorageMap::with_entries([(StorageMapKey::from_raw(rand_value()), rand_value())])?, - ), - ], - [Asset::from(fee_asset), NonFungibleAsset::mock(&[1, 2, 3, 4])], - )?; - let p2id_note = builder.add_p2id_note( - account.id(), - account.id(), - &[FungibleAsset::mock(250)], - NoteType::Public, - )?; - builder - .build()? - .build_tx_context(account, &[p2id_note.id()], &[])? - .build()? - .execute() - .await - .map_err(From::from) -} - -/// Returns a transaction that consumes two notes and creates two notes. -async fn create_output_notes() -> anyhow::Result { - let fee_faucet_id = AccountId::try_from(ACCOUNT_ID_FEE_FAUCET)?; - let fee_asset = FungibleAsset::new(fee_faucet_id, 10_000)?; - let mut builder = MockChain::builder().fee_faucet_id(fee_faucet_id).verification_base_fee(20); - let account = builder.add_existing_mock_account_with_storage_and_assets( - Auth::IncrNonce, - [ - StorageSlot::with_map( - StorageSlotName::mock(0), - StorageMap::with_entries([(StorageMapKey::from_raw(rand_value()), rand_value())])?, - ), - StorageSlot::with_value(StorageSlotName::mock(1), rand_value()), - ], - [Asset::from(fee_asset), NonFungibleAsset::mock(&[1, 2, 3, 4])], - )?; - let note_asset0 = FungibleAsset::mock(200).unwrap_fungible(); - let note_asset1 = FungibleAsset::mock(500).unwrap_fungible(); - - // This creates a note that adds the given assets to the account vault. - let asset_note = - create_public_p2any_note(account.id(), [Asset::from(note_asset0.add(note_asset1)?)]); - builder.add_output_note(RawOutputNote::Full(asset_note.clone())); - - let output_note0 = create_public_p2any_note(account.id(), [note_asset0.into()]); - let output_note1 = create_public_p2any_note(account.id(), [note_asset1.into()]); - - let spawn_note = builder.add_spawn_note([&output_note0, &output_note1])?; - builder - .build()? - .build_tx_context(account, &[asset_note.id(), spawn_note.id()], &[])? - .extend_expected_output_notes(vec![ - RawOutputNote::Full(output_note0), - RawOutputNote::Full(output_note1), - ]) - .build()? - .execute() - .await - .map_err(From::from) -} diff --git a/crates/miden-testing/src/kernel_tests/tx/test_fpi.rs b/crates/miden-testing/src/kernel_tests/tx/test_fpi.rs index 4e425e0306..0c380d5020 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_fpi.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_fpi.rs @@ -746,10 +746,14 @@ async fn foreign_account_can_get_balance_and_presence_of_asset() -> anyhow::Resu let non_fungible_faucet_id = AccountId::try_from(ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET)?; // Create two different assets. - let fungible_asset = Asset::Fungible(FungibleAsset::new(fungible_faucet_id, 1)?); - let non_fungible_asset = Asset::NonFungible(NonFungibleAsset::new( - &NonFungibleAssetDetails::new(non_fungible_faucet_id, vec![1, 2, 3]), - )); + let fungible_asset = + Asset::Fungible(FungibleAsset::new(fungible_faucet_id, 1, AssetCallbackFlag::Disabled)?); + let non_fungible_asset = + Asset::NonFungible(NonFungibleAsset::new(&NonFungibleAssetDetails::new( + non_fungible_faucet_id, + vec![1, 2, 3], + AssetCallbackFlag::Disabled, + ))); let fungible_asset_key = AssetVaultKey::new_fungible(fungible_faucet_id, AssetCallbackFlag::Disabled); @@ -863,7 +867,8 @@ async fn foreign_account_can_get_balance_and_presence_of_asset() -> anyhow::Resu #[tokio::test] async fn foreign_account_get_initial_balance() -> anyhow::Result<()> { let fungible_faucet_id = AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1)?; - let fungible_asset = Asset::Fungible(FungibleAsset::new(fungible_faucet_id, 10)?); + let fungible_asset = + Asset::Fungible(FungibleAsset::new(fungible_faucet_id, 10, AssetCallbackFlag::Disabled)?); let fungible_asset_key = AssetVaultKey::new_fungible(fungible_faucet_id, AssetCallbackFlag::Disabled); diff --git a/crates/miden-testing/src/kernel_tests/tx/test_lazy_loading.rs b/crates/miden-testing/src/kernel_tests/tx/test_lazy_loading.rs index 25520f131d..75ad11240c 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_lazy_loading.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_lazy_loading.rs @@ -2,10 +2,9 @@ //! //! Once lazy loading is enabled generally, it can be removed and/or integrated into other tests. -use miden_protocol::account::{AccountId, AccountStorage, StorageMapKey, StorageSlotDelta}; -use miden_protocol::asset::{Asset, FungibleAsset}; +use miden_protocol::account::{AccountId, AccountStorage, StorageMapKey}; +use miden_protocol::asset::{Asset, AssetCallbackFlag, FungibleAsset}; use miden_protocol::testing::account_id::{ - ACCOUNT_ID_FEE_FAUCET, ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2, }; @@ -15,7 +14,7 @@ use miden_standards::code_builder::CodeBuilder; use miden_standards::testing::note::NoteBuilder; use super::Word; -use crate::{Auth, MockChain, TransactionContextBuilder}; +use crate::{MockChain, TransactionContextBuilder}; // ASSET LAZY LOADING // ================================================================================================ @@ -27,9 +26,13 @@ async fn adding_fungible_assets_with_lazy_loading_succeeds() -> anyhow::Result<( let faucet_id1: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap(); let faucet_id2: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2.try_into().unwrap(); - let fungible_asset1 = - FungibleAsset::new(faucet_id1, FungibleAsset::MAX_AMOUNT.as_u64() - FUNGIBLE_ASSET_AMOUNT)?; - let fungible_asset2 = FungibleAsset::new(faucet_id2, FUNGIBLE_ASSET_AMOUNT)?; + let fungible_asset1 = FungibleAsset::new( + faucet_id1, + FungibleAsset::MAX_AMOUNT.as_u64() - FUNGIBLE_ASSET_AMOUNT, + AssetCallbackFlag::Disabled, + )?; + let fungible_asset2 = + FungibleAsset::new(faucet_id2, FUNGIBLE_ASSET_AMOUNT, AssetCallbackFlag::Disabled)?; // Build a note that adds the assets to the input vault of the transaction. This is necessary // to adhere to asset preservation rules. @@ -84,9 +87,13 @@ async fn removing_fungible_assets_with_lazy_loading_succeeds() -> anyhow::Result let faucet_id1: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap(); let faucet_id2: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2.try_into().unwrap(); - let fungible_asset1 = - FungibleAsset::new(faucet_id1, FungibleAsset::MAX_AMOUNT.as_u64() - FUNGIBLE_ASSET_AMOUNT)?; - let fungible_asset2 = FungibleAsset::new(faucet_id2, FUNGIBLE_ASSET_AMOUNT)?; + let fungible_asset1 = FungibleAsset::new( + faucet_id1, + FungibleAsset::MAX_AMOUNT.as_u64() - FUNGIBLE_ASSET_AMOUNT, + AssetCallbackFlag::Disabled, + )?; + let fungible_asset2 = + FungibleAsset::new(faucet_id2, FUNGIBLE_ASSET_AMOUNT, AssetCallbackFlag::Disabled)?; let code = format!( " @@ -154,26 +161,6 @@ async fn removing_fungible_assets_with_lazy_loading_succeeds() -> anyhow::Result Ok(()) } -/// Tests that a transaction against an account with a non-empty vault successfully loads the fee -/// asset during the epilogue. -/// -/// The non-empty vault is important for the test because the advice provider's merkle store has all -/// merkle paths for an empty vault by default, and so there would be nothing to load. -#[tokio::test] -async fn loading_fee_asset_succeeds() -> anyhow::Result<()> { - let mut builder = MockChain::builder().fee_faucet_id(ACCOUNT_ID_FEE_FAUCET.try_into()?); - let account = builder.add_existing_mock_account_with_assets( - Auth::IncrNonce, - [ - FungibleAsset::mock(23), - FungibleAsset::new(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2.try_into()?, 50)?.into(), - ], - )?; - builder.build()?.build_tx_context(account, &[], &[])?.build()?.execute().await?; - - Ok(()) -} - // STORAGE LAZY LOADING // ================================================================================================ @@ -234,15 +221,9 @@ async fn setting_map_item_with_lazy_loading_succeeds() -> anyhow::Result<()> { .execute() .await?; - let map_delta = tx - .account_delta() - .storage() - .get(mock_map_slot) - .cloned() - .map(StorageSlotDelta::unwrap_map) - .unwrap(); - assert_eq!(map_delta.entries().get(&existing_key).unwrap(), &value0); - assert_eq!(map_delta.entries().get(&non_existent_key).unwrap(), &value1); + let map_patch = tx.account_patch().storage().get_map(mock_map_slot).unwrap(); + assert_eq!(map_patch.entries().get(&existing_key).unwrap(), &value0); + assert_eq!(map_patch.entries().get(&non_existent_key).unwrap(), &value1); Ok(()) } diff --git a/crates/miden-testing/src/kernel_tests/tx/test_output_note.rs b/crates/miden-testing/src/kernel_tests/tx/test_output_note.rs index d2997342aa..3754488e68 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_output_note.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_output_note.rs @@ -3,7 +3,7 @@ use alloc::vec::Vec; use miden_protocol::account::auth::AuthScheme; use miden_protocol::account::{Account, AccountId}; -use miden_protocol::asset::{Asset, FungibleAsset, NonFungibleAsset}; +use miden_protocol::asset::{Asset, AssetCallbackFlag, FungibleAsset, NonFungibleAsset}; use miden_protocol::crypto::rand::RandomCoin; use miden_protocol::errors::MasmError; use miden_protocol::errors::tx_kernel::{ @@ -428,7 +428,7 @@ async fn test_create_note_and_add_asset() -> anyhow::Result<()> { let faucet_id = AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?; let recipient = Word::from([0, 1, 2, 3u32]); let tag = NoteTag::with_account_target(faucet_id); - let asset = FungibleAsset::new(faucet_id, 10)?; + let asset = FungibleAsset::new(faucet_id, 10, AssetCallbackFlag::Disabled)?; let code = format!( " @@ -494,10 +494,10 @@ async fn test_create_note_and_add_multiple_assets() -> anyhow::Result<()> { let recipient = Word::from([0, 1, 2, 3u32]); let tag = NoteTag::with_account_target(faucet_2); - let asset = FungibleAsset::new(faucet, 10)?; - let asset_2 = FungibleAsset::new(faucet_2, 20)?; - let asset_3 = FungibleAsset::new(faucet_2, 30)?; - let asset_2_plus_3 = FungibleAsset::new(faucet_2, 50)?; + let asset = FungibleAsset::new(faucet, 10, AssetCallbackFlag::Disabled)?; + let asset_2 = FungibleAsset::new(faucet_2, 20, AssetCallbackFlag::Disabled)?; + let asset_3 = FungibleAsset::new(faucet_2, 30, AssetCallbackFlag::Disabled)?; + let asset_2_plus_3 = FungibleAsset::new(faucet_2, 50, AssetCallbackFlag::Disabled)?; let non_fungible_asset = NonFungibleAsset::mock(&NON_FUNGIBLE_ASSET_DATA_2); @@ -863,6 +863,7 @@ async fn test_get_asset_info() -> anyhow::Result<()> { FungibleAsset::new( AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET).expect("id should be valid"), 5, + AssetCallbackFlag::Disabled, ) .expect("asset is invalid"), ); @@ -873,6 +874,7 @@ async fn test_get_asset_info() -> anyhow::Result<()> { FungibleAsset::new( AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1).expect("id should be valid"), 5, + AssetCallbackFlag::Disabled, ) .expect("asset is invalid"), ); diff --git a/crates/miden-testing/src/kernel_tests/tx/test_prologue.rs b/crates/miden-testing/src/kernel_tests/tx/test_prologue.rs index 667eaea6fd..5128e8d335 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_prologue.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_prologue.rs @@ -207,7 +207,7 @@ fn global_input_memory_assertions(exec_output: &ExecutionOutput, inputs: &Transa assert_eq!( exec_output.get_kernel_mem_word(TX_SCRIPT_ROOT_PTR), - inputs.tx_args().tx_script().as_ref().unwrap().root(), + inputs.tx_args().tx_script().as_ref().unwrap().root().as_word(), "The transaction script root should be stored at the TX_SCRIPT_ROOT_PTR" ); } @@ -551,13 +551,13 @@ async fn create_simple_account() -> anyhow::Result<()> { .await .context("failed to execute account-creating transaction")?; - assert_eq!(tx.account_delta().nonce_delta(), Felt::ONE); + assert_eq!(tx.account_patch().final_nonce(), Some(Felt::ONE)); // except for the nonce, the delta should be empty - assert!(tx.account_delta().storage().is_empty()); - assert!(tx.account_delta().vault().is_empty()); + assert!(tx.account_patch().storage().is_empty()); + assert!(tx.account_patch().vault().is_empty()); assert_eq!(tx.final_account().nonce(), Felt::ONE); // account commitment should not be the empty word - assert_ne!(tx.account_delta().to_commitment(), EMPTY_WORD); + assert_ne!(tx.account_patch().to_commitment(), EMPTY_WORD); Ok(()) } diff --git a/crates/miden-testing/src/kernel_tests/tx/test_tx.rs b/crates/miden-testing/src/kernel_tests/tx/test_tx.rs index c82587ced1..648d814274 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_tx.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_tx.rs @@ -1,4 +1,5 @@ use alloc::sync::Arc; +use core::slice; use anyhow::Context; use assert_matches::assert_matches; @@ -10,15 +11,25 @@ use miden_protocol::account::{ AccountBuilder, AccountCode, AccountComponent, + AccountDelta, AccountStorage, + AccountStoragePatch, AccountType, + AccountVaultDelta, StorageSlot, StorageSlotName, }; use miden_protocol::assembly::DefaultSourceManager; use miden_protocol::assembly::diagnostics::NamedSource; -use miden_protocol::asset::{Asset, AssetVault, FungibleAsset, NonFungibleAsset}; +use miden_protocol::asset::{ + Asset, + AssetCallbackFlag, + AssetVault, + FungibleAsset, + NonFungibleAsset, +}; use miden_protocol::block::BlockNumber; +use miden_protocol::errors::ProvenTransactionError; use miden_protocol::note::{ Note, NoteAssets, @@ -31,6 +42,7 @@ use miden_protocol::note::{ NoteStorage, NoteTag, NoteType, + PartialNote, PartialNoteMetadata, }; use miden_protocol::testing::account_id::{ @@ -44,26 +56,39 @@ use miden_protocol::testing::account_id::{ use miden_protocol::testing::constants::{FUNGIBLE_ASSET_AMOUNT, NON_FUNGIBLE_ASSET_DATA}; use miden_protocol::testing::note::DEFAULT_NOTE_SCRIPT; use miden_protocol::transaction::{ + InputNote, InputNotes, RawOutputNote, RawOutputNotes, TransactionArgs, TransactionKernel, + TransactionScript, TransactionSummary, }; use miden_protocol::{Felt, Hasher, ONE, Word}; -use miden_standards::AuthMethod; -use miden_standards::account::interface::{AccountInterface, AccountInterfaceExt}; +use miden_standards::account::interface::{ + AccountComponentInterface, + AccountInterface, + AccountInterfaceExt, +}; use miden_standards::account::wallets::BasicWallet; use miden_standards::code_builder::CodeBuilder; use miden_standards::note::P2idNote; use miden_standards::testing::account_component::IncrNonceAuthComponent; +use miden_standards::testing::account_interface::get_public_keys_from_account; use miden_standards::testing::mock_account::MockAccountExt; +use miden_standards::tx_script::SendNotesTransactionScript; use miden_tx::auth::UnreachableAuth; -use miden_tx::{TransactionExecutor, TransactionExecutorError}; +use miden_tx::{ + LocalTransactionProver, + TransactionExecutor, + TransactionExecutorError, + TransactionProverError, +}; +use rstest::rstest; use crate::kernel_tests::tx::ExecutionOutputExt; -use crate::utils::{create_public_p2any_note, create_spawn_note}; +use crate::utils::{create_p2any_note, create_public_p2any_note, create_spawn_note}; use crate::{Auth, MockChain, TransactionContextBuilder}; /// Tests that consuming a note created in a block that is newer than the reference block of the @@ -198,6 +223,7 @@ async fn executed_transaction_output_notes() -> anyhow::Result<()> { FungibleAsset::new( ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().expect("id is valid"), FUNGIBLE_ASSET_AMOUNT, + AssetCallbackFlag::Disabled, ) .expect("asset is valid"), ); @@ -206,6 +232,7 @@ async fn executed_transaction_output_notes() -> anyhow::Result<()> { FungibleAsset::new( ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2.try_into().expect("id is valid"), FUNGIBLE_ASSET_AMOUNT / 2, + AssetCallbackFlag::Disabled, ) .expect("asset is valid"), ); @@ -508,12 +535,15 @@ async fn user_code_can_abort_transaction_with_summary() -> anyhow::Result<()> { /// Tests that a transaction consuming and creating one note with basic authentication correctly /// signs the transaction summary. +#[rstest] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] #[tokio::test] -async fn tx_summary_commitment_is_signed_by_falcon_auth() -> anyhow::Result<()> { +async fn tx_summary_commitment_is_signed_by_auth_singlesig( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { let mut builder = MockChain::builder(); - let account = builder.add_existing_mock_account(Auth::BasicAuth { - auth_scheme: AuthScheme::Falcon512Poseidon2, - })?; + let account = builder.add_existing_mock_account(Auth::BasicAuth { auth_scheme })?; let mut rng = RandomCoin::new(Word::empty()); let p2id_note = P2idNote::create( account.id(), @@ -526,103 +556,41 @@ async fn tx_summary_commitment_is_signed_by_falcon_auth() -> anyhow::Result<()> let spawn_note = builder.add_spawn_note([&p2id_note])?; let chain = builder.build()?; - let tx = chain - .build_tx_context(account.id(), &[spawn_note.id()], &[])? - .build()? - .execute() - .await?; - - let summary = TransactionSummary::new( - tx.account_delta().clone(), - tx.input_notes().clone(), - tx.output_notes().clone(), - Word::from([ - 0, - 0, - tx.block_header().block_num().as_u32(), - tx.final_account().nonce().as_canonical_u64() as u32, - ]), - ); - let summary_commitment = summary.to_commitment(); + let tx_builder = + chain.build_tx_context(account.id(), &[], core::slice::from_ref(&spawn_note))?; - let account_interface = AccountInterface::from_account(&account); - let pub_key = match account_interface.auth().first().unwrap() { - AuthMethod::SingleSig { approver: (pub_key, _) } => pub_key, - AuthMethod::NoAuth => panic!("Expected SingleSig auth scheme, got NoAuth"), - AuthMethod::Multisig { .. } => { - panic!("Expected SingleSig auth scheme, got Multisig") - }, - AuthMethod::NetworkAccount { .. } => { - panic!("Expected SingleSig auth scheme, got NetworkAccount") - }, - AuthMethod::Unknown => panic!("Expected SingleSig auth scheme, got Unknown"), - }; + let tx = tx_builder.clone().build()?; + let ref_block_num = tx.tx_inputs().block_header().block_num(); + let tx = tx.execute().await?; - // This is in an internal detail of the tx executor host, but this is the easiest way to check - // for the presence of the signature in the advice map. - let signature_key = Hasher::merge(&[Word::from(*pub_key), summary_commitment]); - - // The summary commitment should have been signed as part of transaction execution and inserted - // into the advice map. - tx.advice_witness().map.get(&signature_key).unwrap(); - - Ok(()) -} - -/// Tests that a transaction consuming and creating one note with EcdsaK256Keccak authentication -/// correctly signs the transaction summary. -#[tokio::test] -async fn tx_summary_commitment_is_signed_by_ecdsa_auth() -> anyhow::Result<()> { - let mut builder = MockChain::builder(); - let account = builder - .add_existing_mock_account(Auth::BasicAuth { auth_scheme: AuthScheme::EcdsaK256Keccak })?; - let mut rng = RandomCoin::new(Word::empty()); - let p2id_note = P2idNote::create( - account.id(), + let nonce_delta = Felt::ONE; + let final_nonce = account.nonce() + nonce_delta; + let account_delta = AccountDelta::new( account.id(), - vec![], - NoteType::Private, - NoteAttachments::default(), - &mut rng, + AccountStoragePatch::default(), + AccountVaultDelta::default(), + nonce_delta, )?; - let spawn_note = builder.add_spawn_note([&p2id_note])?; - let chain = builder.build()?; - - let tx = chain - .build_tx_context(account.id(), &[spawn_note.id()], &[])? - .build()? - .execute() - .await?; - - let summary = TransactionSummary::new( - tx.account_delta().clone(), - tx.input_notes().clone(), - tx.output_notes().clone(), - Word::from([ - 0, - 0, - tx.block_header().block_num().as_u32(), - tx.final_account().nonce().as_canonical_u64() as u32, - ]), + let expected_summary = TransactionSummary::new( + account_delta, + InputNotes::new(vec![InputNote::unauthenticated(spawn_note)])?, + RawOutputNotes::new(vec![RawOutputNote::Partial(PartialNote::from(p2id_note))])?, + Word::from([0, 0, ref_block_num.as_u32(), final_nonce.as_canonical_u64() as u32]), ); - let summary_commitment = summary.to_commitment(); + + let summary_commitment = expected_summary.to_commitment(); let account_interface = AccountInterface::from_account(&account); - let pub_key = match account_interface.auth().first().unwrap() { - AuthMethod::SingleSig { approver: (pub_key, _) } => pub_key, - AuthMethod::NoAuth => panic!("Expected SingleSig auth scheme, got NoAuth"), - AuthMethod::Multisig { .. } => { - panic!("Expected SingleSig auth scheme, got Multisig") - }, - AuthMethod::NetworkAccount { .. } => { - panic!("Expected SingleSig auth scheme, got NetworkAccount") - }, - AuthMethod::Unknown => panic!("Expected SingleSig auth scheme, got Unknown"), - }; + assert!(matches!( + account_interface.auth_component(), + AccountComponentInterface::AuthSingleSig + )); + let pub_keys = get_public_keys_from_account(&account); + let pub_key = pub_keys.first().expect("expected at least one public key"); // This is in an internal detail of the tx executor host, but this is the easiest way to check // for the presence of the signature in the advice map. - let signature_key = Hasher::merge(&[Word::from(*pub_key), summary_commitment]); + let signature_key = Hasher::merge(&[*pub_key, summary_commitment]); // The summary commitment should have been signed as part of transaction execution and inserted // into the advice map. @@ -918,3 +886,40 @@ async fn tx_can_be_reexecuted() -> anyhow::Result<()> { Ok(()) } + +/// Tests that creating and consuming the same note in a transaction fails. +/// +/// TX: Inputs [X] -> Outputs [X] +#[tokio::test] +async fn tx_circular_note_dependency_is_rejected() -> anyhow::Result<()> { + let asset = NonFungibleAsset::mock(&[42]); + + let mut builder = MockChain::builder(); + let account = builder.add_existing_wallet_with_assets(Auth::IncrNonce, [])?; + let chain = builder.build()?; + + let mut rng = RandomCoin::new(Word::from([1u32; 4])); + let note_x = create_p2any_note(account.id(), NoteType::Public, [asset], &mut rng); + + let script = TransactionScript::from(SendNotesTransactionScript::new( + &account.code_interface(), + &[PartialNote::from(note_x.clone())], + )?); + + // The tx script reconstructs note_x as an output note (same recipient + same asset). + let executed_tx = chain + .build_tx_context(account.clone(), &[], slice::from_ref(¬e_x))? + .tx_script(script) + .extend_expected_output_notes(vec![RawOutputNote::Full(note_x.clone())]) + .build()? + .execute() + .await?; + let error = LocalTransactionProver::default().prove_dummy(executed_tx).unwrap_err(); + + assert_matches!(error, TransactionProverError::ProvenTransactionBuildFailed( + ProvenTransactionError::NoteCreatedAndConsumed(note_id)) => { + assert_eq!(note_id, note_x.id()); + }); + + Ok(()) +} diff --git a/crates/miden-testing/src/lib.rs b/crates/miden-testing/src/lib.rs index c48e162f1e..9de9454792 100644 --- a/crates/miden-testing/src/lib.rs +++ b/crates/miden-testing/src/lib.rs @@ -27,6 +27,9 @@ mod mock_host; pub mod utils; +#[cfg(test)] +mod assertion; + #[cfg(test)] mod kernel_tests; diff --git a/crates/miden-testing/src/mock_chain/auth.rs b/crates/miden-testing/src/mock_chain/auth.rs index 732188d3ca..3c79e25c1d 100644 --- a/crates/miden-testing/src/mock_chain/auth.rs +++ b/crates/miden-testing/src/mock_chain/auth.rs @@ -8,6 +8,7 @@ use miden_protocol::account::auth::{AuthScheme, AuthSecretKey, PublicKeyCommitme use miden_protocol::account::{AccountComponent, AccountProcedureRoot}; use miden_protocol::note::NoteScriptRoot; use miden_protocol::testing::noop_auth_component::NoopAuthComponent; +use miden_protocol::transaction::TransactionScriptRoot; use miden_standards::account::auth::multisig_smart::ProcedurePolicy; use miden_standards::account::auth::{ AuthGuardedMultisig, @@ -83,12 +84,24 @@ pub enum Auth { Conditional, /// Network-account authentication that restricts the account to consuming only notes whose - /// script roots appear in `allowed_script_roots`. Must be non-empty. + /// script roots appear in `allowed_script_roots` (must be non-empty), and to executing only + /// transaction scripts whose roots appear in `allowed_tx_script_roots` (may be empty). NetworkAccount { allowed_script_roots: BTreeSet, + allowed_tx_script_roots: BTreeSet, }, } +impl Default for Auth { + /// Returns the most common authentication scheme used in tests: + /// [`Auth::BasicAuth`] with [`AuthScheme::Falcon512Poseidon2`]. + fn default() -> Self { + Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + } + } +} + impl Auth { /// Converts `self` into its corresponding authentication [`AccountComponent`] and an optional /// [`BasicAuthenticator`]. The component is always returned, but the authenticator is only @@ -170,10 +183,15 @@ impl Auth { Auth::IncrNonce => (IncrNonceAuthComponent.into(), None), Auth::Noop => (NoopAuthComponent.into(), None), Auth::Conditional => (ConditionalAuthComponent.into(), None), - Auth::NetworkAccount { allowed_script_roots } => { - let component = AuthNetworkAccount::with_allowlist(allowed_script_roots.clone()) - .expect("network account allowlist must be non-empty") - .into(); + Auth::NetworkAccount { + allowed_script_roots, + allowed_tx_script_roots, + } => { + let component = + AuthNetworkAccount::with_allowed_notes(allowed_script_roots.clone()) + .expect("network account allowlist must be non-empty") + .with_allowed_tx_scripts(allowed_tx_script_roots.clone()) + .into(); (component, None) }, } diff --git a/crates/miden-testing/src/mock_chain/chain.rs b/crates/miden-testing/src/mock_chain/chain.rs index d45fd81d73..e689954cdc 100644 --- a/crates/miden-testing/src/mock_chain/chain.rs +++ b/crates/miden-testing/src/mock_chain/chain.rs @@ -6,8 +6,7 @@ use miden_block_prover::LocalBlockProver; use miden_processor::serde::DeserializationError; use miden_protocol::MIN_PROOF_SECURITY_LEVEL; use miden_protocol::account::auth::{AuthSecretKey, PublicKey}; -use miden_protocol::account::delta::AccountUpdateDetails; -use miden_protocol::account::{Account, AccountId, PartialAccount}; +use miden_protocol::account::{Account, AccountId, AccountUpdateDetails, PartialAccount}; use miden_protocol::batch::{ProposedBatch, ProvenBatch}; use miden_protocol::block::account_tree::{AccountTree, AccountWitness}; use miden_protocol::block::nullifier_tree::{NullifierTree, NullifierWitness}; @@ -19,7 +18,7 @@ use miden_protocol::block::{ ProposedBlock, ProvenBlock, }; -use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; +use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{PublicKey as ValidatorKey, SigningKey}; use miden_protocol::note::{Note, NoteHeader, NoteId, NoteInclusionProof, Nullifier}; use miden_protocol::transaction::{ ExecutedTransaction, @@ -33,7 +32,7 @@ use miden_protocol::transaction::{ use miden_tx::LocalTransactionProver; use miden_tx::auth::BasicAuthenticator; use miden_tx::utils::serde::{ByteReader, ByteWriter, Deserializable, Serializable}; -use miden_tx_batch_prover::LocalBatchProver; +use miden_tx_batch::LocalBatchProver; use super::note::MockChainNote; use crate::{MockChainBuilder, TransactionContextBuilder}; @@ -133,7 +132,7 @@ use crate::{MockChainBuilder, TransactionContextBuilder}; /// # use miden_protocol::{ /// # Felt, /// # account::auth::AuthScheme, -/// # asset::{Asset, FungibleAsset}, +/// # asset::{Asset, AssetCallbackFlag, FungibleAsset}, /// # note::NoteType /// # }; /// # use miden_testing::{Auth, MockChain, TransactionContextBuilder}; @@ -149,7 +148,7 @@ use crate::{MockChainBuilder, TransactionContextBuilder}; /// "USDT", /// 100_000, /// )?; -/// let asset = Asset::from(FungibleAsset::new(faucet.id(), 10)?); +/// let asset = Asset::from(FungibleAsset::new(faucet.id(), 10, AssetCallbackFlag::Disabled)?); /// /// let sender = builder.create_new_wallet(Auth::BasicAuth { /// auth_scheme: AuthScheme::Falcon512Poseidon2, @@ -264,7 +263,7 @@ impl MockChain { // This is needed because apply_block only stores headers for private notes, // but tests need full note details to create input notes. for note in genesis_notes { - if let Some(MockChainNote::Private(_, _, inclusion_proof)) = + if let Some(MockChainNote::Private(_, _, _, inclusion_proof)) = chain.committed_notes.get(¬e.id()) { chain.committed_notes.insert( @@ -412,6 +411,11 @@ impl MockChain { self.blocks[chain_tip.as_usize()].header().clone() } + /// Returns the public key of the validator that signs the next block produced by this chain. + pub fn validator_key(&self) -> ValidatorKey { + self.validator_secret_key.public_key() + } + /// Returns the latest [`ProvenBlock`] in the chain. pub fn latest_block(&self) -> ProvenBlock { let chain_tip = @@ -516,7 +520,7 @@ impl MockChain { .flat_map(|tx| tx.unauthenticated_notes().map(NoteHeader::id)), )?; - Ok(ProposedBatch::new( + Ok(ProposedBatch::new_unverified( transactions, batch_reference_block, partial_blockchain, @@ -531,7 +535,7 @@ impl MockChain { &self, proposed_batch: ProposedBatch, ) -> anyhow::Result { - let batch_prover = LocalBatchProver::new(0); + let batch_prover = LocalBatchProver::new(); Ok(batch_prover.prove_dummy(proposed_batch)?) } @@ -825,14 +829,31 @@ impl MockChain { /// /// This will commit all the currently pending transactions into the chain state. pub fn prove_next_block(&mut self) -> anyhow::Result { - self.prove_and_apply_block(None) + self.prove_and_apply_block(None, None) + } + + /// Proves the next block in the mock chain, rotating the validator key. + /// + /// The produced block is still signed by the current validator key (the one committed to by + /// the previous block) but commits `new_validator_key.public_key()` as the validator key + /// authorized to sign the *following* block. After this block is applied, the chain signs + /// subsequent blocks with `new_validator_key`. + /// + /// This commits all currently pending transactions into the chain state. + pub fn prove_next_block_with_validator_key_rotation( + &mut self, + new_validator_key: SigningKey, + ) -> anyhow::Result { + let block = self.prove_and_apply_block(None, Some(new_validator_key.public_key()))?; + self.validator_secret_key = new_validator_key; + Ok(block) } /// Proves the next block in the mock chain at the given timestamp. /// /// This will commit all the currently pending transactions into the chain state. pub fn prove_next_block_at(&mut self, timestamp: u32) -> anyhow::Result { - self.prove_and_apply_block(Some(timestamp)) + self.prove_and_apply_block(Some(timestamp), None) } /// Proves new blocks until the block with the given target block number has been created. @@ -911,6 +932,15 @@ impl MockChain { /// - Consumed notes are removed from the committed notes. /// - The block is appended to the [`BlockChain`] and the list of proven blocks. fn apply_block(&mut self, proven_block: ProvenBlock) -> anyhow::Result<()> { + // Verify the block is correctly linked to and authorized by its parent. Genesis is the + // trust root and has no parent to anchor against, so it is skipped. + if proven_block.header().block_num() != BlockNumber::GENESIS { + let parent = self.latest_block_header(); + proven_block + .validate(Some(&parent)) + .context("block failed validation against its parent")?; + } + for account_update in proven_block.body().updated_accounts() { self.account_tree .insert(account_update.account_id(), account_update.final_state_commitment()) @@ -929,21 +959,21 @@ impl MockChain { for account_update in proven_block.body().updated_accounts() { match account_update.details() { - AccountUpdateDetails::Delta(account_delta) => { - if account_delta.is_full_state() { - let account = Account::try_from(account_delta) - .context("failed to convert full state delta into full account")?; + AccountUpdateDetails::Public(account_patch) => { + if account_patch.is_full_state() { + let account = Account::try_from(account_patch) + .context("failed to convert full state patch into full account")?; self.committed_accounts.insert(account.id(), account.clone()); } else { let committed_account = self .committed_accounts .get_mut(&account_update.account_id()) .ok_or_else(|| { - anyhow::anyhow!("account delta in block for non-existent account") + anyhow::anyhow!("account patch in block for non-existent account") })?; committed_account - .apply_delta(account_delta) - .context("failed to apply account delta")?; + .apply_patch(account_patch) + .context("failed to apply account patch")?; } }, // No state to keep for private accounts other than the commitment on the account @@ -962,20 +992,24 @@ impl MockChain { ) .context("failed to create inclusion proof for output note")?; - if let OutputNote::Public(public_note) = created_note { - self.committed_notes.insert( - public_note.id(), - MockChainNote::Public(public_note.as_note().clone(), note_inclusion_proof), - ); - } else { - self.committed_notes.insert( - created_note.id(), - MockChainNote::Private( - created_note.id(), - *created_note.metadata(), - note_inclusion_proof, - ), - ); + match created_note { + OutputNote::Public(public_note) => { + self.committed_notes.insert( + public_note.id(), + MockChainNote::Public(public_note.as_note().clone(), note_inclusion_proof), + ); + }, + OutputNote::Private(private_note) => { + self.committed_notes.insert( + private_note.id(), + MockChainNote::Private( + private_note.id(), + *private_note.metadata(), + private_note.attachments().clone(), + note_inclusion_proof, + ), + ); + }, } } @@ -1022,7 +1056,11 @@ impl MockChain { /// 2. Insert all the account updates, nullifiers and notes from the block into the chain state. /// /// If a `timestamp` is provided, it will be set on the block. - fn prove_and_apply_block(&mut self, timestamp: Option) -> anyhow::Result { + fn prove_and_apply_block( + &mut self, + timestamp: Option, + next_validator_key: Option, + ) -> anyhow::Result { // Create batches from pending transactions. // ---------------------------------------------------------------------------------------- @@ -1035,9 +1073,15 @@ impl MockChain { let block_timestamp = timestamp.unwrap_or(self.latest_block_header().timestamp() + Self::TIMESTAMP_STEP_SECS); - let proposed_block = self + let mut proposed_block = self .propose_block_at(batches.clone(), block_timestamp) .context("failed to create proposed block")?; + + // Commit to a rotated validator key for the next block, if requested. + if let Some(next_validator_key) = next_validator_key { + proposed_block = proposed_block.with_next_validator_key(next_validator_key); + } + let proven_block = self.prove_block(proposed_block.clone())?; // Apply block. @@ -1222,13 +1266,14 @@ impl From for TxContextInput { mod tests { use miden_protocol::account::auth::AuthScheme; use miden_protocol::account::{AccountBuilder, AccountType}; - use miden_protocol::asset::{Asset, FungibleAsset}; + use miden_protocol::asset::{Asset, AssetCallbackFlag, FungibleAsset}; use miden_protocol::note::NoteType; use miden_protocol::testing::account_id::{ ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET, ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, ACCOUNT_ID_SENDER, }; + use miden_protocol::testing::random_secret_key::random_secret_key; use miden_standards::account::wallets::BasicWallet; use super::*; @@ -1244,6 +1289,55 @@ mod tests { Ok(()) } + #[test] + fn validator_key_rotation_across_blocks() -> anyhow::Result<()> { + let mut chain = MockChain::new(); + let original_key = chain.validator_key(); + + // Build normal blocks. The parent-linkage and signature are verified inside `apply_block`, + // so these calls succeeding proves the chain validates against the previous block's key. + chain.prove_next_block()?; + chain.prove_next_block()?; + assert_eq!(chain.validator_key(), original_key); + + // Rotate to a new validator key. + let new_key = random_secret_key(); + let new_pub = new_key.public_key(); + let rotation_block = chain.prove_next_block_with_validator_key_rotation(new_key)?; + + // The rotation block is still signed by (and validates against) the original key, but + // commits the new key as the signer authorized for the next block. + assert_eq!(rotation_block.header().validator_key(), &new_pub); + assert_eq!(chain.validator_key(), new_pub); + + // The next block is signed by the rotated key and must validate against the rotation + // block's committed key; `apply_block` would error otherwise. + chain.prove_next_block()?; + assert_eq!(chain.validator_key(), new_pub); + + Ok(()) + } + + #[test] + fn proposed_block_serialization_round_trip() -> anyhow::Result<()> { + let chain = MockChain::new(); + let timestamp = chain.latest_block_header().timestamp() + 1; + let next_key = random_secret_key().public_key(); + let proposed = chain + .propose_block_at(Vec::::new(), timestamp)? + .with_next_validator_key(next_key.clone()); + + let bytes = proposed.to_bytes(); + let deserialized = ProposedBlock::read_from_bytes(&bytes).unwrap(); + + // `ProposedBlock` does not implement `PartialEq`, so compare via re-serialization and the + // round-tripped `next_validator_key` field added by this change. + assert_eq!(deserialized.to_bytes(), bytes); + assert_eq!(deserialized.next_validator_key(), &next_key); + + Ok(()) + } + #[tokio::test] async fn private_account_state_update() -> anyhow::Result<()> { let faucet_id = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into()?; @@ -1265,7 +1359,9 @@ mod tests { let note_1 = builder.add_p2id_note( ACCOUNT_ID_SENDER.try_into().unwrap(), account.id(), - &[Asset::Fungible(FungibleAsset::new(faucet_id, 1000u64).unwrap())], + &[Asset::Fungible( + FungibleAsset::new(faucet_id, 1000u64, AssetCallbackFlag::Disabled).unwrap(), + )], NoteType::Private, )?; @@ -1313,6 +1409,7 @@ mod tests { FungibleAsset::new( ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into().unwrap(), 1000u64, + AssetCallbackFlag::Disabled, ) .unwrap(), )], @@ -1356,28 +1453,31 @@ mod tests { builder.add_existing_mock_account(Auth::IncrNonce)?; let mut chain = builder.build()?; - // Verify the genesis block signature. + // The genesis block is the trust root: it is signed by the key it commits as the signer + // of block 1. let genesis_block = chain.latest_block(); + let genesis_validator_key = genesis_block.header().validator_key().clone(); assert!( - genesis_block.signature().verify( - genesis_block.header().commitment(), - genesis_block.header().validator_key() - ) + genesis_block + .signature() + .verify(genesis_block.header().commitment(), &genesis_validator_key) ); // Add another block. chain.prove_next_block()?; - // Verify the next block signature. + // The next block's signature must verify against the validator key committed to by its + // parent (the genesis block), not the key in its own header. let next_block = chain.latest_block(); assert!( next_block .signature() - .verify(next_block.header().commitment(), next_block.header().validator_key()) + .verify(next_block.header().commitment(), &genesis_validator_key) ); - // Public keys should be carried through from the genesis header to the next. - assert_eq!(next_block.header().validator_key(), next_block.header().validator_key()); + // Without rotation, the validator key is carried through from the genesis header to the + // next. + assert_eq!(next_block.header().validator_key(), &genesis_validator_key); Ok(()) } diff --git a/crates/miden-testing/src/mock_chain/chain_builder.rs b/crates/miden-testing/src/mock_chain/chain_builder.rs index 4f5aa42276..ba25c8aac3 100644 --- a/crates/miden-testing/src/mock_chain/chain_builder.rs +++ b/crates/miden-testing/src/mock_chain/chain_builder.rs @@ -1,4 +1,4 @@ -use alloc::collections::BTreeMap; +use alloc::collections::{BTreeMap, BTreeSet}; use alloc::vec::Vec; use anyhow::Context; @@ -14,17 +14,17 @@ const DEFAULT_FAUCET_DECIMALS: u8 = 10; use itertools::Itertools; use miden_processor::crypto::random::RandomCoin; -use miden_protocol::account::delta::AccountUpdateDetails; use miden_protocol::account::{ Account, AccountBuilder, AccountComponent, - AccountDelta, AccountId, + AccountPatch, AccountType, + AccountUpdateDetails, StorageSlot, }; -use miden_protocol::asset::{Asset, AssetAmount, FungibleAsset, TokenSymbol}; +use miden_protocol::asset::{Asset, AssetAmount, AssetCallbackFlag, FungibleAsset, TokenSymbol}; use miden_protocol::block::account_tree::AccountTree; use miden_protocol::block::nullifier_tree::NullifierTree; use miden_protocol::block::{ @@ -46,12 +46,11 @@ use miden_protocol::testing::account_id::ACCOUNT_ID_FEE_FAUCET; use miden_protocol::testing::random_secret_key::random_secret_key; use miden_protocol::transaction::{OrderedTransactionHeaders, RawOutputNote, TransactionKernel}; use miden_protocol::{MAX_OUTPUT_NOTES_PER_BATCH, Word}; -use miden_standards::account::access::AccessControl; +use miden_standards::account::access::{AccessControl, Authority, Pausable, PausableManager}; use miden_standards::account::faucets::{FungibleFaucet, TokenName}; use miden_standards::account::policies::{ - BurnPolicyConfig, - MintPolicyConfig, - PolicyRegistration, + BurnPolicy, + MintPolicy, TokenPolicyManager, TransferPolicy, }; @@ -188,9 +187,9 @@ impl MockChainBuilder { .map(|account| { let account_id = account.id(); let account_commitment = account.to_commitment(); - let account_delta = AccountDelta::try_from(account) + let account_patch = AccountPatch::try_from(account) .expect("chain builder should only store existing accounts without seeds"); - let update_details = AccountUpdateDetails::Delta(account_delta); + let update_details = AccountUpdateDetails::Public(account_patch); BlockAccountUpdate::new(account_id, account_commitment, update_details) }) @@ -316,43 +315,9 @@ impl MockChainBuilder { self.add_account_from_builder(auth_method, account_builder, AccountState::Exists) } - /// Creates a new public [`FungibleFaucet`] account and registers the authenticator (if - /// any) for it. - /// - /// This does not add the account to the chain state, but it can still be used to call - /// [`MockChain::build_tx_context`] to automatically add the authenticator. - fn create_new_fungible_faucet( - &mut self, - auth_method: Auth, - faucet: FungibleFaucet, - account_type: AccountType, - access_control: AccessControl, - token_policy_manager: TokenPolicyManager, - ) -> anyhow::Result { - let account_builder = AccountBuilder::new(self.rng.random()) - .account_type(account_type) - .with_component(faucet) - .with_components(access_control) - .with_components(token_policy_manager); - - self.add_account_from_builder(auth_method, account_builder, AccountState::New) - } - - /// Adds an existing fungible faucet account to the initial chain state and registers the - /// authenticator (if any). - /// - /// The behaviour of the faucet (basic vs network-style) is determined entirely by the - /// combination of arguments: - /// - `account_type`: [`AccountType::Public`] for basic faucets, or [`AccountType::Private`] for - /// off-chain accounts. - /// - `auth_method`: typically a [`Auth::BasicAuth`] for basic faucets, or [`Auth::IncrNonce`] - /// for network-style faucets. - /// - `access_control`: [`AccessControl::AuthControlled`] for basic faucets; - /// [`AccessControl::Ownable2Step`] / [`AccessControl::Rbac`] for owner-controlled faucets. - /// The matching `Authority` component is auto-installed by `AccessControl`. - /// - `token_policy_manager`: the unified [`TokenPolicyManager`] holding both mint and burn - /// policy. - fn add_existing_fungible_faucet( + /// Internal helper: adds an existing network-style fungible faucet (Ownable2Step / Rbac). + /// Bundles [`PausableManager`] to match the `create_network_fungible_faucet` factory. + fn add_existing_network_fungible_faucet( &mut self, auth_method: Auth, faucet: FungibleFaucet, @@ -364,17 +329,19 @@ impl MockChainBuilder { .account_type(account_type) .with_component(faucet) .with_components(access_control) - .with_components(token_policy_manager); + .with_components(token_policy_manager) + .with_component(Pausable::unpaused()) + .with_component(PausableManager); self.add_account_from_builder(auth_method, account_builder, AccountState::Exists) } /// Convenience: builds a basic auth-controlled fungible faucet from a token-symbol shorthand - /// using default decimals and `AllowAll` policies, then adds it via - /// `Self::add_existing_fungible_faucet`. + /// using default decimals and `AllowAll` policies, then adds it as an existing account with + /// [`Authority::AuthControlled`]. /// /// For full control over the faucet's metadata, decimals, and policies, construct a - /// [`FungibleFaucet`] manually and call `Self::add_existing_fungible_faucet`. + /// [`FungibleFaucet`] manually and use [`AccountBuilder`] directly. pub fn add_existing_basic_faucet( &mut self, auth_method: Auth, @@ -397,19 +364,22 @@ impl MockChainBuilder { .build() .context("failed to build FungibleFaucet")?; - let token_policy_manager = TokenPolicyManager::new() - .with_mint_policy(MintPolicyConfig::AllowAll, PolicyRegistration::Active)? - .with_burn_policy(BurnPolicyConfig::AllowAll, PolicyRegistration::Active)? - .with_send_policy(TransferPolicy::AllowAll, PolicyRegistration::Active)? - .with_receive_policy(TransferPolicy::AllowAll, PolicyRegistration::Active)?; + let token_policy_manager = TokenPolicyManager::builder() + .active_mint_policy(MintPolicy::allow_all()) + .active_burn_policy(BurnPolicy::allow_all()) + .active_send_policy(TransferPolicy::allow_all()) + .active_receive_policy(TransferPolicy::allow_all()) + .build(); - self.add_existing_fungible_faucet( - auth_method, - faucet, - AccountType::Public, - AccessControl::AuthControlled, - token_policy_manager, - ) + let account_builder = AccountBuilder::new(self.rng.random()) + .account_type(AccountType::Public) + .with_component(faucet) + .with_component(Authority::AuthControlled) + .with_components(token_policy_manager) + .with_component(Pausable::unpaused()) + .with_component(PausableManager); + + self.add_account_from_builder(auth_method, account_builder, AccountState::Exists) } /// Convenience: builds an owner-controlled (network-style) fungible faucet from a @@ -428,7 +398,7 @@ impl MockChainBuilder { max_supply: u64, owner_account_id: AccountId, token_supply: Option, - mint_policy: MintPolicyConfig, + mint_policy: MintPolicy, allowed_script_roots: impl IntoIterator, ) -> anyhow::Result { let token_supply = token_supply.unwrap_or(0); @@ -446,19 +416,23 @@ impl MockChainBuilder { .build() .context("failed to build FungibleFaucet")?; - let token_policy_manager = TokenPolicyManager::new() - .with_mint_policy(mint_policy, PolicyRegistration::Active)? - .with_burn_policy(BurnPolicyConfig::AllowAll, PolicyRegistration::Active)? - .with_send_policy(TransferPolicy::AllowAll, PolicyRegistration::Active)? - .with_receive_policy(TransferPolicy::AllowAll, PolicyRegistration::Active)?; + let token_policy_manager = TokenPolicyManager::builder() + .active_mint_policy(mint_policy) + .active_burn_policy(BurnPolicy::allow_all()) + .active_send_policy(TransferPolicy::allow_all()) + .active_receive_policy(TransferPolicy::allow_all()) + .build(); - let allowed_script_roots = allowed_script_roots + let allowed_script_roots: BTreeSet = allowed_script_roots .into_iter() .chain([MintNote::script_root(), BurnNote::script_root()]) .collect(); - self.add_existing_fungible_faucet( - Auth::NetworkAccount { allowed_script_roots }, + self.add_existing_network_fungible_faucet( + Auth::NetworkAccount { + allowed_script_roots, + allowed_tx_script_roots: BTreeSet::new(), + }, faucet, AccountType::Public, AccessControl::Ownable2Step { owner: owner_account_id }, @@ -478,19 +452,23 @@ impl MockChainBuilder { faucet: FungibleFaucet, allowed_script_roots: impl IntoIterator, ) -> anyhow::Result { - let token_policy_manager = TokenPolicyManager::new() - .with_mint_policy(MintPolicyConfig::OwnerOnly, PolicyRegistration::Active)? - .with_burn_policy(BurnPolicyConfig::AllowAll, PolicyRegistration::Active)? - .with_send_policy(TransferPolicy::AllowAll, PolicyRegistration::Active)? - .with_receive_policy(TransferPolicy::AllowAll, PolicyRegistration::Active)?; - - let allowed_script_roots = allowed_script_roots + let token_policy_manager = TokenPolicyManager::builder() + .active_mint_policy(MintPolicy::owner_only()) + .active_burn_policy(BurnPolicy::allow_all()) + .active_send_policy(TransferPolicy::allow_all()) + .active_receive_policy(TransferPolicy::allow_all()) + .build(); + + let allowed_script_roots: BTreeSet = allowed_script_roots .into_iter() .chain([MintNote::script_root(), BurnNote::script_root()]) .collect(); - self.add_existing_fungible_faucet( - Auth::NetworkAccount { allowed_script_roots }, + self.add_existing_network_fungible_faucet( + Auth::NetworkAccount { + allowed_script_roots, + allowed_tx_script_roots: BTreeSet::new(), + }, faucet, AccountType::Public, AccessControl::Ownable2Step { owner: owner_account_id }, @@ -518,19 +496,22 @@ impl MockChainBuilder { .build() .context("failed to build FungibleFaucet")?; - let token_policy_manager = TokenPolicyManager::new() - .with_mint_policy(MintPolicyConfig::AllowAll, PolicyRegistration::Active)? - .with_burn_policy(BurnPolicyConfig::AllowAll, PolicyRegistration::Active)? - .with_send_policy(TransferPolicy::AllowAll, PolicyRegistration::Active)? - .with_receive_policy(TransferPolicy::AllowAll, PolicyRegistration::Active)?; + let token_policy_manager = TokenPolicyManager::builder() + .active_mint_policy(MintPolicy::allow_all()) + .active_burn_policy(BurnPolicy::allow_all()) + .active_send_policy(TransferPolicy::allow_all()) + .active_receive_policy(TransferPolicy::allow_all()) + .build(); - self.create_new_fungible_faucet( - auth_method, - faucet, - AccountType::Public, - AccessControl::AuthControlled, - token_policy_manager, - ) + let account_builder = AccountBuilder::new(self.rng.random()) + .account_type(AccountType::Public) + .with_component(faucet) + .with_component(Authority::AuthControlled) + .with_components(token_policy_manager) + .with_component(Pausable::unpaused()) + .with_component(PausableManager); + + self.add_account_from_builder(auth_method, account_builder, AccountState::New) } /// Creates a new public account with an [`MockAccountComponent`] and registers the @@ -811,7 +792,8 @@ impl MockChainBuilder { /// Constructs a fungible asset based on the fee faucet ID and the provided amount. fn fee_asset(&self, amount: u64) -> anyhow::Result { - FungibleAsset::new(self.fee_faucet_id, amount).context("failed to create fee asset") + FungibleAsset::new(self.fee_faucet_id, amount, AssetCallbackFlag::Disabled) + .context("failed to create fee asset") } } diff --git a/crates/miden-testing/src/mock_chain/note.rs b/crates/miden-testing/src/mock_chain/note.rs index 233c751f15..a524e3308f 100644 --- a/crates/miden-testing/src/mock_chain/note.rs +++ b/crates/miden-testing/src/mock_chain/note.rs @@ -1,5 +1,5 @@ use miden_processor::serde::DeserializationError; -use miden_protocol::note::{Note, NoteId, NoteInclusionProof, NoteMetadata}; +use miden_protocol::note::{Note, NoteAttachments, NoteId, NoteInclusionProof, NoteMetadata}; use miden_protocol::transaction::InputNote; use miden_tx::utils::serde::{ByteReader, ByteWriter, Deserializable, Serializable}; @@ -10,9 +10,10 @@ use miden_tx::utils::serde::{ByteReader, ByteWriter, Deserializable, Serializabl #[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, PartialEq, Eq)] pub enum MockChainNote { - /// Details for a private note only include its [`NoteMetadata`] and [`NoteInclusionProof`]. - /// Other details needed to consume the note are expected to be stored locally, off-chain. - Private(NoteId, NoteMetadata, NoteInclusionProof), + /// Details for a private note only include its [`NoteMetadata`], public [`NoteAttachments`] + /// and [`NoteInclusionProof`]. Other details needed to consume the note are expected to be + /// stored locally, off-chain. + Private(NoteId, NoteMetadata, NoteAttachments, NoteInclusionProof), /// Contains the full [`Note`] object alongside its [`NoteInclusionProof`]. Public(Note, NoteInclusionProof), } @@ -21,7 +22,7 @@ impl MockChainNote { /// Returns the note's inclusion details. pub fn inclusion_proof(&self) -> &NoteInclusionProof { match self { - MockChainNote::Private(_, _, inclusion_proof) + MockChainNote::Private(_, _, _, inclusion_proof) | MockChainNote::Public(_, inclusion_proof) => inclusion_proof, } } @@ -29,11 +30,19 @@ impl MockChainNote { /// Returns the note's metadata. pub fn metadata(&self) -> &NoteMetadata { match self { - MockChainNote::Private(_, metadata, _) => metadata, + MockChainNote::Private(_, metadata, ..) => metadata, MockChainNote::Public(note, _) => note.metadata(), } } + /// Returns the note's attachments. + pub fn attachments(&self) -> &NoteAttachments { + match self { + MockChainNote::Private(_, _, attachments, _) => attachments, + MockChainNote::Public(note, _) => note.attachments(), + } + } + /// Returns the note's ID. pub fn id(&self) -> NoteId { match self { @@ -70,10 +79,11 @@ impl TryFrom for InputNote { impl Serializable for MockChainNote { fn write_into(&self, target: &mut W) { match self { - MockChainNote::Private(id, metadata, proof) => { + MockChainNote::Private(id, metadata, attachments, proof) => { 0u8.write_into(target); id.write_into(target); metadata.write_into(target); + attachments.write_into(target); proof.write_into(target); }, MockChainNote::Public(note, proof) => { @@ -92,8 +102,9 @@ impl Deserializable for MockChainNote { 0 => { let id = NoteId::read_from(source)?; let metadata = NoteMetadata::read_from(source)?; + let attachments = NoteAttachments::read_from(source)?; let proof = NoteInclusionProof::read_from(source)?; - Ok(MockChainNote::Private(id, metadata, proof)) + Ok(MockChainNote::Private(id, metadata, attachments, proof)) }, 1 => { let note = Note::read_from(source)?; diff --git a/crates/miden-testing/src/mock_host.rs b/crates/miden-testing/src/mock_host.rs index fa81ba4534..b64afec76b 100644 --- a/crates/miden-testing/src/mock_host.rs +++ b/crates/miden-testing/src/mock_host.rs @@ -58,9 +58,6 @@ impl<'store> MockHost<'store> { &TransactionEventId::AccountPushProcedureIndex, &TransactionEventId::LinkMapSet, &TransactionEventId::LinkMapGet, - // TODO: It should be possible to remove this after implementing - // https://github.com/0xMiden/protocol/issues/1852. - &TransactionEventId::EpilogueBeforeTxFeeRemovedFromAccount, ] .map(TransactionEventId::event_id), ); diff --git a/crates/miden-testing/src/standards/token_metadata.rs b/crates/miden-testing/src/standards/token_metadata.rs index 04427c31a6..1d861766ac 100644 --- a/crates/miden-testing/src/standards/token_metadata.rs +++ b/crates/miden-testing/src/standards/token_metadata.rs @@ -21,6 +21,7 @@ use miden_protocol::asset::{AssetAmount, TokenSymbol}; use miden_protocol::errors::MasmError; use miden_protocol::note::{NoteTag, NoteType}; use miden_protocol::{Felt, Word}; +use miden_standards::account::access::Pausable; use miden_standards::account::auth::NoAuth; use miden_standards::account::faucets::{ Description, @@ -214,6 +215,7 @@ async fn get_name_from_masm() -> anyhow::Result<()> { let account = AccountBuilder::new([1u8; 32]) .with_auth_component(NoAuth) .with_component(faucet) + .with_component(Pausable::unpaused()) .build()?; execute_tx_script( @@ -249,6 +251,7 @@ async fn get_name_zeros_returns_empty() -> anyhow::Result<()> { let account = AccountBuilder::new([1u8; 32]) .with_auth_component(NoAuth) .with_component(faucet) + .with_component(Pausable::unpaused()) .build()?; execute_tx_script( @@ -406,6 +409,7 @@ async fn get_mutability_config() -> anyhow::Result<()> { let account = AccountBuilder::new([1u8; 32]) .with_auth_component(NoAuth) .with_component(faucet) + .with_component(Pausable::unpaused()) .build()?; execute_tx_script( @@ -448,6 +452,7 @@ async fn is_field_mutable_checks( let account = AccountBuilder::new([1u8; 32]) .with_auth_component(NoAuth) .with_component(faucet) + .with_component(Pausable::unpaused()) .build()?; execute_tx_script( @@ -525,7 +530,8 @@ fn verify_faucet_with_max_name_and_description( let mut builder = AccountBuilder::new(seed) .account_type(account_type) .with_auth_component(NoAuth) - .with_component(faucet); + .with_component(faucet) + .with_component(Pausable::unpaused()); for comp in extra_components { builder = builder.with_component(comp); @@ -690,7 +696,7 @@ async fn test_field_setter_owner_succeeds( let executed = tx_context.execute().await?; let mut updated_faucet = faucet_account.clone(); - updated_faucet.apply_delta(executed.account_delta())?; + updated_faucet.apply_patch(executed.account_patch())?; for (i, expected) in new_data.iter().enumerate() { let chunk = updated_faucet.storage().get_item(slot_fn(i))?; @@ -923,7 +929,7 @@ async fn set_max_supply_mutable_owner_succeeds() -> anyhow::Result<()> { let executed = tx_context.execute().await?; let mut updated_faucet = faucet_account.clone(); - updated_faucet.apply_delta(executed.account_delta())?; + updated_faucet.apply_patch(executed.account_patch())?; let restored = FungibleFaucet::try_from(updated_faucet.storage())?; assert_eq!(restored.max_supply().as_u64(), new_max_supply, "max_supply should be updated"); diff --git a/crates/miden-testing/src/tx_context/builder.rs b/crates/miden-testing/src/tx_context/builder.rs index c4cbac72e3..bbf60a9426 100644 --- a/crates/miden-testing/src/tx_context/builder.rs +++ b/crates/miden-testing/src/tx_context/builder.rs @@ -67,6 +67,7 @@ use crate::MockChain; /// # Ok(()) /// # } /// ``` +#[derive(Clone)] pub struct TransactionContextBuilder { source_manager: Arc, account: Account, diff --git a/crates/miden-testing/src/tx_context/context.rs b/crates/miden-testing/src/tx_context/context.rs index ea930a7fe0..a1018c2dd7 100644 --- a/crates/miden-testing/src/tx_context/context.rs +++ b/crates/miden-testing/src/tx_context/context.rs @@ -15,7 +15,7 @@ use miden_protocol::account::{ }; use miden_protocol::assembly::debuginfo::{SourceLanguage, Uri}; use miden_protocol::assembly::{Assembler, SourceManager, SourceManagerSync}; -use miden_protocol::asset::{Asset, AssetCallbackFlag, AssetVaultKey, AssetWitness}; +use miden_protocol::asset::{Asset, AssetVaultKey, AssetWitness}; use miden_protocol::block::account_tree::AccountWitness; use miden_protocol::block::{BlockHeader, BlockNumber}; use miden_protocol::note::{Note, NoteScript, NoteScriptRoot}; @@ -83,15 +83,15 @@ impl TransactionContext { /// /// - If the provided `code` is not a valid program. pub async fn execute_code(&self, code: &str) -> Result { - // Fetch all witnesses for note assets and the fee asset. - let mut asset_vault_keys = self + // Fetch all witnesses for note assets. + let asset_vault_keys = self .tx_inputs .input_notes() .iter() .flat_map(|note| note.note().assets().iter().map(Asset::vault_key)) .collect::>(); - let (account, block_header, _blockchain) = self + let (account, _block_header, _blockchain) = self .get_transaction_inputs( self.tx_inputs.account().id(), BTreeSet::from_iter([self.tx_inputs.block_header().block_num()]), @@ -99,15 +99,6 @@ impl TransactionContext { .await .expect("failed to fetch transaction inputs"); - // Add the vault key for the fee asset to the list of asset vault keys which may need to be - // accessed at the end of the transaction. - let fee_asset_vault_key = AssetVaultKey::new_fungible( - block_header.fee_parameters().fee_faucet_id(), - // Assume fee asset is callback-disabled. - AssetCallbackFlag::Disabled, - ); - asset_vault_keys.insert(fee_asset_vault_key); - // Fetch the witnesses for all asset vault keys. let asset_witnesses = self .get_vault_asset_witnesses(account.id(), account.vault().root(), asset_vault_keys) @@ -155,9 +146,6 @@ impl TransactionContext { account_procedure_idx_map, None, ref_block, - // We don't need to set the initial balance in this context under the assumption that - // fees are zero. - 0u64, self.source_manager(), ); diff --git a/crates/miden-testing/src/tx_context/mod.rs b/crates/miden-testing/src/tx_context/mod.rs index 30cb008889..2087511ab2 100644 --- a/crates/miden-testing/src/tx_context/mod.rs +++ b/crates/miden-testing/src/tx_context/mod.rs @@ -1,6 +1,7 @@ mod builder; mod context; mod errors; +mod test_builder; pub use builder::TransactionContextBuilder; pub use context::TransactionContext; diff --git a/crates/miden-testing/src/tx_context/test_builder.rs b/crates/miden-testing/src/tx_context/test_builder.rs new file mode 100644 index 0000000000..acbb940414 --- /dev/null +++ b/crates/miden-testing/src/tx_context/test_builder.rs @@ -0,0 +1,329 @@ +// TEST TRANSACTION BUILDER +// ================================================================================================ + +use alloc::collections::BTreeMap; +use alloc::sync::Arc; +use alloc::vec::Vec; + +use anyhow::Context; +use miden_processor::advice::AdviceInputs; +use miden_processor::{Felt, Word}; +use miden_protocol::EMPTY_WORD; +use miden_protocol::account::auth::{PublicKeyCommitment, Signature}; +use miden_protocol::account::{Account, AccountId}; +use miden_protocol::assembly::DefaultSourceManager; +use miden_protocol::assembly::debuginfo::SourceManagerSync; +use miden_protocol::block::account_tree::AccountWitness; +use miden_protocol::note::{Note, NoteId, NoteScript, NoteScriptRoot}; +use miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE; +use miden_protocol::testing::noop_auth_component::NoopAuthComponent; +use miden_protocol::transaction::{RawOutputNote, TransactionScript}; +use miden_standards::testing::account_component::IncrNonceAuthComponent; +use miden_standards::testing::mock_account::MockAccountExt; +use miden_tx::auth::BasicAuthenticator; + +use super::TransactionContext; +use crate::MockChain; + +// TEST TRANSACTION BUILDER +// ================================================================================================ + +/// A crate-internal builder that makes a [TransactionContext] for tests. +/// +/// Use it when a test just needs some valid chain data to run against and does not care about the +/// exact state of a [`crate::MockChain`]. It makes a simple [`crate::MockChain`] inside and gets +/// the inputs from [`crate::MockChain::build_tx_context`]. +#[allow(dead_code)] +#[derive(Clone)] +pub(crate) struct TestTransactionBuilder { + source_manager: Arc, + account: Account, + advice_inputs: AdviceInputs, + authenticator: Option, + expected_output_notes: Vec, + foreign_account_inputs: BTreeMap, + input_notes: Vec, + tx_script: Option, + tx_script_args: Word, + note_args: BTreeMap, + auth_args: Word, + signatures: Vec<(PublicKeyCommitment, Word, Signature)>, + note_scripts: BTreeMap, + is_lazy_loading_enabled: bool, + is_debug_mode_enabled: bool, +} + +#[allow(dead_code)] +impl TestTransactionBuilder { + pub(crate) fn new(account: Account) -> Self { + Self { + source_manager: Arc::new(DefaultSourceManager::default()), + account, + advice_inputs: Default::default(), + authenticator: None, + expected_output_notes: Vec::new(), + foreign_account_inputs: BTreeMap::new(), + input_notes: Vec::new(), + tx_script: None, + tx_script_args: EMPTY_WORD, + note_args: BTreeMap::new(), + auth_args: EMPTY_WORD, + signatures: Vec::new(), + note_scripts: BTreeMap::new(), + is_lazy_loading_enabled: true, + is_debug_mode_enabled: cfg!(feature = "tx_context_debug"), + } + } + + /// Initializes a [TestTransactionBuilder] with a mock account. + /// + /// The wallet: + /// + /// - Includes a series of mocked assets ([miden_protocol::asset::AssetVault::mock()]). + /// - Has a nonce of `1` (so it does not imply seed validation). + /// - Has an ID of [`ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE`]. + /// - Has an account code based on an + /// [miden_standards::testing::account_component::MockAccountComponent]. + pub(crate) fn with_existing_mock_account() -> Self { + Self::new(Account::mock( + ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE, + IncrNonceAuthComponent, + )) + } + + /// Same as [`Self::with_existing_mock_account`] but with a + /// [`miden_protocol::testing::noop_auth_component::NoopAuthComponent`]. + pub(crate) fn with_noop_auth_account() -> Self { + Self::new(Account::mock( + ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE, + NoopAuthComponent, + )) + } + + /// Initializes a [TestTransactionBuilder] with a mocked fungible faucet. + pub(crate) fn with_fungible_faucet(acct_id: u128) -> Self { + Self::new(Account::mock_fungible_faucet(acct_id)) + } + + /// Initializes a [TestTransactionBuilder] with a mocked non-fungible faucet. + pub(crate) fn with_non_fungible_faucet(acct_id: u128) -> Self { + Self::new(Account::mock_non_fungible_faucet(acct_id)) + } + + /// Extend the advice inputs with the provided [AdviceInputs] instance. + pub(crate) fn extend_advice_inputs(mut self, advice_inputs: AdviceInputs) -> Self { + self.advice_inputs.extend(advice_inputs); + self + } + + /// Extend the advice inputs map with the provided iterator. + pub(crate) fn extend_advice_map( + mut self, + map_entries: impl IntoIterator)>, + ) -> Self { + self.advice_inputs.map.extend(map_entries); + self + } + + /// Set the authenticator for the transaction (if needed). + pub(crate) fn authenticator(mut self, authenticator: Option) -> Self { + self.authenticator = authenticator; + self + } + + /// Set foreign account codes that are used by the transaction. + pub(crate) fn foreign_accounts( + mut self, + inputs: impl IntoIterator, + ) -> Self { + self.foreign_account_inputs.extend( + inputs.into_iter().map(|(account, witness)| (account.id(), (account, witness))), + ); + self + } + + /// Extend the set of used input notes. + pub(crate) fn extend_input_notes(mut self, input_notes: Vec) -> Self { + self.input_notes.extend(input_notes); + self + } + + /// Set the desired transaction script. + pub(crate) fn tx_script(mut self, tx_script: TransactionScript) -> Self { + self.tx_script = Some(tx_script); + self + } + + /// Set the transaction script arguments. + pub(crate) fn tx_script_args(mut self, tx_script_args: Word) -> Self { + self.tx_script_args = tx_script_args; + self + } + + /// Set the desired auth arguments. + pub(crate) fn auth_args(mut self, auth_args: Word) -> Self { + self.auth_args = auth_args; + self + } + + /// Disables lazy loading. + /// + /// Only affects [`TransactionContext::execute_code`] and causes the host to _not_ handle lazy + /// loading events. + pub(crate) fn disable_lazy_loading(mut self) -> Self { + self.is_lazy_loading_enabled = false; + self + } + + /// Disables debug mode. + /// + /// For performance-sensitive applications, debug mode should be disabled because executing in + /// debug mode may be up to 100x slower. + pub(crate) fn disable_debug_mode(mut self) -> Self { + self.is_debug_mode_enabled = false; + self + } + + /// Extend the note arguments map with the provided one. + pub(crate) fn extend_note_args(mut self, note_args: BTreeMap) -> Self { + self.note_args.extend(note_args); + self + } + + /// Extend the expected output notes. + pub(crate) fn extend_expected_output_notes(mut self, output_notes: Vec) -> Self { + self.expected_output_notes.extend(output_notes); + self + } + + /// Sets the [`SourceManagerSync`] on the [`TransactionContext`] that will be built. + pub(crate) fn with_source_manager( + mut self, + source_manager: Arc, + ) -> Self { + self.source_manager = source_manager; + self + } + + /// Add a new signature for the message and the public key. + pub(crate) fn add_signature( + mut self, + pub_key: PublicKeyCommitment, + message: Word, + signature: Signature, + ) -> Self { + self.signatures.push((pub_key, message, signature)); + self + } + + /// Add a note script to the context for testing. + pub(crate) fn add_note_script(mut self, script: NoteScript) -> Self { + self.note_scripts.insert(script.root(), script); + self + } + + /// Builds the [TransactionContext]. + /// + /// An ad-hoc [`crate::MockChain`] is created to generate valid block data for the requested + /// input notes, and the account plus those notes are resolved into transaction inputs through + /// [`crate::MockChain::build_tx_context`]. The rest of the configuration (advice inputs, + /// transaction script, expected output notes, foreign accounts, signatures, ...) is then + /// applied on top of the resolved inputs before the [TransactionContext] is assembled. + pub(crate) fn build(self) -> anyhow::Result { + // Spin up an ad-hoc mock chain that commits the requested input notes, so that valid block + // data (block headers and the chain's Merkle Mountain Range) can be generated for them. + let mut chain_builder = MockChain::builder(); + + let input_note_ids: Vec = self.input_notes.iter().map(Note::id).collect(); + for input_note in self.input_notes { + chain_builder.add_output_note(RawOutputNote::Full(input_note)); + } + + let mut mock_chain = chain_builder.build()?; + mock_chain.prove_next_block().context("failed to prove first block")?; + mock_chain.prove_next_block().context("failed to prove second block")?; + + // Resolve the transaction inputs against the ad-hoc chain through the chain-coupled + // builder, instead of re-implementing the resolution. The account is passed by value so + // that it is used directly without requiring it to be committed to the chain. Once + // `build_tx_context` is replaced by `build_transaction`, only this call needs to change. + let mut builder = mock_chain + .build_tx_context(self.account, &input_note_ids, &[]) + .context("failed to build transaction context from mock chain")? + .extend_advice_inputs(self.advice_inputs) + .authenticator(self.authenticator) + .tx_script_args(self.tx_script_args) + .auth_args(self.auth_args) + .extend_note_args(self.note_args) + .extend_expected_output_notes(self.expected_output_notes) + .foreign_accounts(self.foreign_account_inputs.into_values()) + .with_source_manager(self.source_manager); + + if let Some(tx_script) = self.tx_script { + builder = builder.tx_script(tx_script); + } + if !self.is_lazy_loading_enabled { + builder = builder.disable_lazy_loading(); + } + if !self.is_debug_mode_enabled { + builder = builder.disable_debug_mode(); + } + for (pub_key, message, signature) in self.signatures { + builder = builder.add_signature(pub_key, message, signature); + } + for script in self.note_scripts.into_values() { + builder = builder.add_note_script(script); + } + + builder.build() + } +} + +impl Default for TestTransactionBuilder { + fn default() -> Self { + Self::with_existing_mock_account() + } +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use miden_protocol::asset::FungibleAsset; + use miden_protocol::testing::account_id::ACCOUNT_ID_SENDER; + + use super::TestTransactionBuilder; + use crate::utils::create_public_p2any_note; + + #[tokio::test] + async fn test_transaction_builder_builds_and_executes() -> anyhow::Result<()> { + let executed = + TestTransactionBuilder::with_existing_mock_account().build()?.execute().await?; + + // No input notes were provided, so the executed transaction consumes none. + assert_eq!(executed.input_notes().num_notes(), 0); + + Ok(()) + } + + #[tokio::test] + async fn test_transaction_builder_resolves_input_notes() -> anyhow::Result<()> { + let input_note = create_public_p2any_note( + ACCOUNT_ID_SENDER.try_into().unwrap(), + [FungibleAsset::mock(100)], + ); + + let executed = TestTransactionBuilder::with_existing_mock_account() + .extend_input_notes(vec![input_note.clone()]) + .build()? + .execute() + .await?; + + // The ad-hoc chain should have resolved the provided input note. + assert_eq!(executed.input_notes().num_notes(), 1); + assert_eq!(executed.input_notes().get_note(0).id(), input_note.id()); + + Ok(()) + } +} diff --git a/crates/miden-testing/src/utils.rs b/crates/miden-testing/src/utils.rs index 6b26006715..7fb9126976 100644 --- a/crates/miden-testing/src/utils.rs +++ b/crates/miden-testing/src/utils.rs @@ -18,52 +18,91 @@ use rand::rngs::SmallRng; // HELPER MACROS // ================================================================================================ +/// Asserts that a `Result<_, ExecError>` failed as expected. +/// +/// Two forms: +/// - `..., matches [if ]` — matches the inner `ExecutionError` against ``. Use +/// this for variants other than `FailedAssertion`, or to assert on a specific `err_code`. +/// - `..., $expected` — delegates to `MasmError::matches_execution_error` (e.g. a `MasmError` error +/// code). #[macro_export] macro_rules! assert_execution_error { - ($execution_result:expr, $expected_err:expr) => { + ($execution_result:expr, matches $pat:pat $(if $guard:expr)? $(,)?) => { match $execution_result { - Err($crate::ExecError(miden_processor::ExecutionError::OperationError { label: _, source_file: _, err: miden_processor::operation::OperationError::FailedAssertion { err_code, err_msg } })) => { - if let Some(ref msg) = err_msg { - assert_eq!(msg.as_ref(), $expected_err.message(), "error messages did not match"); - } + Err($crate::ExecError($pat)) $(if $guard)? => {}, + Ok(_) => ::core::panic!( + "Execution was unexpectedly successful\nexpected error: {}", + ::core::stringify!($pat), + ), + Err(err) => ::core::panic!( + "Execution error did not match:\nexpected: {}\nactual: {}", + ::core::stringify!($pat), + err, + ), + } + }; - assert_eq!( - err_code, $expected_err.code(), - "Execution failed on assertion with an unexpected error (Actual code: {}, msg: {}, Expected code: {}).", - err_code, err_msg.as_ref().map(|string| string.as_ref()).unwrap_or(""), $expected_err, - ); + ($execution_result:expr, $expected:expr $(,)?) => { + match $execution_result { + Err($crate::ExecError(actual)) => { + if !$expected.matches_execution_error(&actual) { + ::core::panic!( + "Execution error did not match:\nexpected: {}\nactual: {}", + $expected, + actual, + ); + } }, - Ok(_) => panic!("Execution was unexpectedly successful"), - Err(err) => panic!("Execution error was not as expected: {err}"), + Ok(_) => ::core::panic!( + "Execution was unexpectedly successful\nexpected error: {}", + $expected, + ), } }; } +/// Same as [`assert_execution_error!`], but for `TransactionExecutorError`. +/// The `matches` and `$expected` arms unwrap +/// `TransactionProgramExecutionFailed(_)` and match against the inner +/// `ExecutionError`. #[macro_export] macro_rules! assert_transaction_executor_error { - ($execution_result:expr, $expected_err:expr) => { + ($execution_result:expr, matches $pat:pat $(if $guard:expr)? $(,)?) => { match $execution_result { - Err(miden_tx::TransactionExecutorError::TransactionProgramExecutionFailed( - miden_processor::ExecutionError::OperationError { - label: _, - source_file: _, - err: miden_processor::operation::OperationError::FailedAssertion { - err_code, - err_msg, - }, - }, - )) => { - if let Some(ref msg) = err_msg { - assert_eq!(msg.as_ref(), $expected_err.message(), "error messages did not match"); - } + Err(miden_tx::TransactionExecutorError::TransactionProgramExecutionFailed($pat)) + $(if $guard)? => {}, + Ok(_) => ::core::panic!( + "Execution was unexpectedly successful\nexpected error: {}", + ::core::stringify!($pat), + ), + Err(err) => ::core::panic!( + "Execution error did not match:\nexpected: {}\nactual: {}", + ::core::stringify!($pat), + err, + ), + } + }; - assert_eq!( - err_code, $expected_err.code(), - "Execution failed on assertion with an unexpected error (Actual code: {}, msg: {}, Expected: {}).", - err_code, err_msg.as_ref().map(|string| string.as_ref()).unwrap_or(""), $expected_err); + ($execution_result:expr, $expected:expr $(,)?) => { + match $execution_result { + Err(miden_tx::TransactionExecutorError::TransactionProgramExecutionFailed(actual)) => { + if !$expected.matches_execution_error(&actual) { + ::core::panic!( + "Execution error did not match:\nexpected: {}\nactual: {}", + $expected, + actual, + ); + } }, - Ok(_) => panic!("Execution was unexpectedly successful"), - Err(err) => panic!("Execution error was not as expected: {err}"), + Ok(_) => ::core::panic!( + "Execution was unexpectedly successful\nexpected error: {}", + $expected, + ), + Err(err) => ::core::panic!( + "Execution error did not match:\nexpected: {}\nactual: {}", + $expected, + err, + ), } }; } diff --git a/crates/miden-testing/tests/agglayer/bridge_in.rs b/crates/miden-testing/tests/agglayer/bridge_in.rs index 6c749f0fd4..d6b4fb9467 100644 --- a/crates/miden-testing/tests/agglayer/bridge_in.rs +++ b/crates/miden-testing/tests/agglayer/bridge_in.rs @@ -7,6 +7,7 @@ use anyhow::Context; use miden_agglayer::errors::{ ERR_CLAIM_ALREADY_SPENT, ERR_CLAIM_LEAF_DESTINATION_NETWORK_MISMATCH, + ERR_GER_NOT_FOUND, ERR_TOKEN_NOT_REGISTERED, }; use miden_agglayer::{ @@ -20,6 +21,7 @@ use miden_agglayer::{ EthEmbeddedAccountId, ExitRoot, LeafValue, + RemoveGerNote, SmtNode, UpdateGerNote, agglayer_library, @@ -34,7 +36,7 @@ use miden_protocol::crypto::SequentialCommit; use miden_protocol::crypto::rand::FeltRng; use miden_protocol::note::{NoteAssets, NoteType}; use miden_protocol::transaction::RawOutputNote; -use miden_standards::account::policies::MintPolicyConfig; +use miden_standards::account::policies::MintPolicy; use miden_standards::account::wallets::BasicWallet; use miden_standards::code_builder::CodeBuilder; use miden_standards::errors::standards::ERR_FUNGIBLE_MINT_NOTE_ASSET_NOT_FROM_THIS_FAUCET; @@ -144,17 +146,27 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - // CREATE GER MANAGER ACCOUNT (sends the UPDATE_GER note) + // CREATE GER INJECTOR ACCOUNT (sends the UPDATE_GER note) // -------------------------------------------------------------------------------------------- - let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // CREATE GER REMOVER ACCOUNT (not used in this test, but distinct from admin and injector) + // -------------------------------------------------------------------------------------------- + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; // CREATE BRIDGE ACCOUNT // -------------------------------------------------------------------------------------------- let bridge_seed = builder.rng_mut().draw_word(); - let bridge_account = - create_existing_bridge_account(bridge_seed, bridge_admin.id(), ger_manager.id()); + let bridge_account = create_existing_bridge_account( + bridge_seed, + bridge_admin.id(), + ger_injector.id(), + ger_remover.id(), + ); builder.add_account(bridge_account.clone())?; // GET CLAIM DATA FROM JSON (source depends on the test case) @@ -253,7 +265,7 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a // CREATE UPDATE_GER NOTE WITH GLOBAL EXIT ROOT // -------------------------------------------------------------------------------------------- let update_ger_note = - UpdateGerNote::create(ger, ger_manager.id(), bridge_account.id(), builder.rng_mut())?; + UpdateGerNote::create(ger, ger_injector.id(), bridge_account.id(), builder.rng_mut())?; builder.add_output_note(RawOutputNote::Full(update_ger_note.clone())); // BUILD MOCK CHAIN WITH ALL ACCOUNTS @@ -297,7 +309,7 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a // -------------------------------------------------------------------------------------------- let mut updated_bridge_account = bridge_account.clone(); - updated_bridge_account.apply_delta(claim_executed.account_delta())?; + updated_bridge_account.apply_patch(claim_executed.account_patch())?; let actual_cgi_chain_hash = AggLayerBridge::cgi_chain_hash(&updated_bridge_account)?; @@ -366,11 +378,13 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a ); // Verify full note ID construction - let expected_asset: Asset = - FungibleAsset::new(agglayer_faucet.id(), miden_claim_amount.as_canonical_u64()) - .unwrap() - .with_callbacks(AssetCallbackFlag::Enabled) - .into(); + let expected_asset: Asset = FungibleAsset::new( + agglayer_faucet.id(), + miden_claim_amount.as_canonical_u64(), + AssetCallbackFlag::Enabled, + ) + .unwrap() + .into(); let expected_output_p2id_note = create_p2id_note_exact( agglayer_faucet.id(), destination_account_id, @@ -406,7 +420,7 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a // Verify the destination account received the minted asset let mut destination_account = destination_account; - destination_account.apply_delta(consume_executed_transaction.account_delta())?; + destination_account.apply_patch(consume_executed_transaction.account_patch())?; let balance = destination_account.vault().get_balance(expected_asset.vault_key())?; assert_eq!( @@ -435,13 +449,20 @@ async fn test_mint_cannot_be_consumed_by_unrelated_faucet() -> anyhow::Result<() let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; let bridge_seed = builder.rng_mut().draw_word(); - let bridge_account = - create_existing_bridge_account(bridge_seed, bridge_admin.id(), ger_manager.id()); + let bridge_account = create_existing_bridge_account( + bridge_seed, + bridge_admin.id(), + ger_injector.id(), + ger_remover.id(), + ); builder.add_account(bridge_account.clone())?; let (proof_data, leaf_data, ger, _cgi_chain_hash) = data_source.get_data(); @@ -551,7 +572,7 @@ async fn test_mint_cannot_be_consumed_by_unrelated_faucet() -> anyhow::Result<() builder.add_output_note(RawOutputNote::Full(config_note_b.clone())); let update_ger_note = - UpdateGerNote::create(ger, ger_manager.id(), bridge_account.id(), builder.rng_mut())?; + UpdateGerNote::create(ger, ger_injector.id(), bridge_account.id(), builder.rng_mut())?; builder.add_output_note(RawOutputNote::Full(update_ger_note.clone())); let mut mock_chain = builder.clone().build()?; @@ -623,17 +644,27 @@ async fn test_claim_rejects_wrong_destination_network() -> anyhow::Result<()> { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - // CREATE GER MANAGER ACCOUNT (sends the UPDATE_GER note) + // CREATE GER INJECTOR ACCOUNT (sends the UPDATE_GER note) // -------------------------------------------------------------------------------------------- - let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // CREATE GER REMOVER ACCOUNT + // -------------------------------------------------------------------------------------------- + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; // CREATE BRIDGE ACCOUNT // -------------------------------------------------------------------------------------------- let bridge_seed = builder.rng_mut().draw_word(); - let bridge_account = - create_existing_bridge_account(bridge_seed, bridge_admin.id(), ger_manager.id()); + let bridge_account = create_existing_bridge_account( + bridge_seed, + bridge_admin.id(), + ger_injector.id(), + ger_remover.id(), + ); builder.add_account(bridge_account.clone())?; // GET CLAIM DATA FROM JSON @@ -708,7 +739,7 @@ async fn test_claim_rejects_wrong_destination_network() -> anyhow::Result<()> { // CREATE UPDATE_GER NOTE WITH GLOBAL EXIT ROOT // -------------------------------------------------------------------------------------------- let update_ger_note = - UpdateGerNote::create(ger, ger_manager.id(), bridge_account.id(), builder.rng_mut())?; + UpdateGerNote::create(ger, ger_injector.id(), bridge_account.id(), builder.rng_mut())?; builder.add_output_note(RawOutputNote::Full(update_ger_note.clone())); // BUILD MOCK CHAIN WITH ALL ACCOUNTS @@ -762,15 +793,24 @@ async fn test_duplicate_claim_note_rejected() -> anyhow::Result<()> { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - // CREATE GER MANAGER ACCOUNT - let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + // CREATE GER INJECTOR ACCOUNT + let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // CREATE GER REMOVER ACCOUNT (not used in this test, but distinct from admin and injector) + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; // CREATE BRIDGE ACCOUNT let bridge_seed = builder.rng_mut().draw_word(); - let bridge_account = - create_existing_bridge_account(bridge_seed, bridge_admin.id(), ger_manager.id()); + let bridge_account = create_existing_bridge_account( + bridge_seed, + bridge_admin.id(), + ger_injector.id(), + ger_remover.id(), + ); builder.add_account(bridge_account.clone())?; // GET CLAIM DATA FROM JSON @@ -850,7 +890,7 @@ async fn test_duplicate_claim_note_rejected() -> anyhow::Result<()> { // CREATE UPDATE_GER NOTE let update_ger_note = - UpdateGerNote::create(ger, ger_manager.id(), bridge_account.id(), builder.rng_mut())?; + UpdateGerNote::create(ger, ger_injector.id(), bridge_account.id(), builder.rng_mut())?; builder.add_output_note(RawOutputNote::Full(update_ger_note.clone())); // BUILD MOCK CHAIN @@ -903,6 +943,150 @@ async fn test_duplicate_claim_note_rejected() -> anyhow::Result<()> { Ok(()) } +/// Tests that a CLAIM note referencing a removed GER is rejected. +/// +/// Uses the same known-good claim data as `test_bridge_in_claim_to_p2id`, so the failure is +/// attributable solely to the GER removal: +/// 1. Sets up the bridge (CONFIG + UPDATE_GER) so the CLAIM would succeed. +/// 2. Removes the GER via REMOVE_GER. +/// 3. Attempts to execute the CLAIM note and asserts it fails with `ERR_GER_NOT_FOUND`. +#[tokio::test] +async fn test_claim_rejects_removed_ger() -> anyhow::Result<()> { + let data_source = ClaimDataSource::L1ToMiden; + let mut builder = MockChain::builder(); + + // CREATE BRIDGE ADMIN ACCOUNT + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // CREATE GER INJECTOR ACCOUNT + let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // CREATE GER REMOVER ACCOUNT (sends the REMOVE_GER note) + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // CREATE BRIDGE ACCOUNT + let bridge_seed = builder.rng_mut().draw_word(); + let bridge_account = create_existing_bridge_account( + bridge_seed, + bridge_admin.id(), + ger_injector.id(), + ger_remover.id(), + ); + builder.add_account(bridge_account.clone())?; + + // GET CLAIM DATA FROM JSON + let (proof_data, leaf_data, ger, _cgi_chain_hash) = data_source.get_data(); + + // CREATE AGGLAYER FAUCET ACCOUNT + let token_symbol = "AGG"; + let decimals = 8u8; + let max_supply: Felt = FungibleAsset::MAX_AMOUNT.into(); + let agglayer_faucet_seed = builder.rng_mut().draw_word(); + + let origin_token_address = leaf_data.origin_token_address; + let origin_network = leaf_data.origin_network; + let scale = 10u8; + + let agglayer_faucet = create_existing_agglayer_faucet( + agglayer_faucet_seed, + token_symbol, + decimals, + max_supply, + Felt::ZERO, + bridge_account.id(), + ); + builder.add_account(agglayer_faucet.clone())?; + + // Calculate the scaled-down Miden amount + let miden_claim_amount = leaf_data + .amount + .scale_to_token_amount(scale as u32) + .expect("amount should scale successfully"); + + // CREATE CLAIM NOTE + let claim_inputs = ClaimNoteStorage { + proof_data: proof_data.clone(), + leaf_data: leaf_data.clone(), + miden_claim_amount, + }; + + let claim_note = + ClaimNote::create(claim_inputs, bridge_account.id(), bridge_admin.id(), builder.rng_mut())?; + builder.add_output_note(RawOutputNote::Full(claim_note.clone())); + + // CREATE CONFIG_AGG_BRIDGE NOTE + let config_note = ConfigAggBridgeNote::create( + ConversionMetadata { + faucet_account_id: agglayer_faucet.id(), + origin_token_address, + scale, + origin_network, + is_native: false, + metadata_hash: leaf_data.metadata_hash, + }, + bridge_admin.id(), + bridge_account.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(config_note.clone())); + + // CREATE UPDATE_GER NOTE + let update_ger_note = + UpdateGerNote::create(ger, ger_injector.id(), bridge_account.id(), builder.rng_mut())?; + builder.add_output_note(RawOutputNote::Full(update_ger_note.clone())); + + // CREATE REMOVE_GER NOTE (removes the GER the claim's proof is verified against) + let remove_ger_note = + RemoveGerNote::create(ger, ger_remover.id(), bridge_account.id(), builder.rng_mut())?; + builder.add_output_note(RawOutputNote::Full(remove_ger_note.clone())); + + // BUILD MOCK CHAIN + let mut mock_chain = builder.build()?; + + // TX0: CONFIG_AGG_BRIDGE + let config_tx_context = mock_chain + .build_tx_context(bridge_account.id(), &[config_note.id()], &[])? + .build()?; + let config_executed = config_tx_context.execute().await?; + mock_chain.add_pending_executed_transaction(&config_executed)?; + mock_chain.prove_next_block()?; + + // TX1: UPDATE_GER + let update_ger_tx_context = mock_chain + .build_tx_context(bridge_account.id(), &[update_ger_note.id()], &[])? + .build()?; + let update_ger_executed = update_ger_tx_context.execute().await?; + mock_chain.add_pending_executed_transaction(&update_ger_executed)?; + mock_chain.prove_next_block()?; + + // TX2: REMOVE_GER + let remove_ger_tx_context = mock_chain + .build_tx_context(bridge_account.id(), &[remove_ger_note.id()], &[])? + .build()?; + let remove_ger_executed = remove_ger_tx_context.execute().await?; + mock_chain.add_pending_executed_transaction(&remove_ger_executed)?; + mock_chain.prove_next_block()?; + + // TX3: CLAIM (should fail because its GER was removed) + let faucet_foreign_inputs = mock_chain.get_foreign_account_inputs(agglayer_faucet.id())?; + let result = mock_chain + .build_tx_context(bridge_account.id(), &[], &[claim_note])? + .foreign_accounts(vec![faucet_foreign_inputs]) + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_GER_NOT_FOUND); + + Ok(()) +} + /// Tests the bridge-in unlock path for Miden-native faucets. /// /// When a faucet is registered with `is_native = true`, a valid CLAIM note does NOT go through @@ -924,17 +1108,24 @@ async fn bridge_in_unlock_native_token() -> anyhow::Result<()> { let data_source = ClaimDataSource::L1ToMiden; let mut builder = MockChain::builder(); - // Bridge admin / GER manager / bridge account. + // Bridge admin / GER injector / bridge account. let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; let bridge_seed = builder.rng_mut().draw_word(); - let mut bridge_account = - create_existing_bridge_account(bridge_seed, bridge_admin.id(), ger_manager.id()); + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let mut bridge_account = create_existing_bridge_account( + bridge_seed, + bridge_admin.id(), + ger_injector.id(), + ger_remover.id(), + ); builder.add_account(bridge_account.clone())?; // Claim data: leaf data's origin_token_address + metadata_hash must match the registration @@ -961,7 +1152,7 @@ async fn bridge_in_unlock_native_token() -> anyhow::Result<()> { faucet_owner_account_id, // Seed enough native supply for the lock step's sender to bundle into the B2AGG note. Some(miden_claim_amount_u64.saturating_mul(2)), - MintPolicyConfig::OwnerOnly, + MintPolicy::owner_only(), [], )?; @@ -1005,7 +1196,9 @@ async fn bridge_in_unlock_native_token() -> anyhow::Result<()> { // B2AGG note that will seed the bridge's vault with `miden_claim_amount_u64` of native asset. let bridge_asset: Asset = - FungibleAsset::new(native_faucet.id(), miden_claim_amount_u64).unwrap().into(); + FungibleAsset::new(native_faucet.id(), miden_claim_amount_u64, AssetCallbackFlag::Disabled) + .unwrap() + .into(); let b2agg_destination_address = EthAddress::from_hex("0x1234567890abcdef1122334455667788990011aa") .expect("valid destination address"); @@ -1032,7 +1225,7 @@ async fn bridge_in_unlock_native_token() -> anyhow::Result<()> { // GER for the claim's Merkle proof. let update_ger_note = - UpdateGerNote::create(ger, ger_manager.id(), bridge_account.id(), builder.rng_mut())?; + UpdateGerNote::create(ger, ger_injector.id(), bridge_account.id(), builder.rng_mut())?; builder.add_output_note(RawOutputNote::Full(update_ger_note.clone())); let mut mock_chain = builder.clone().build()?; @@ -1043,7 +1236,7 @@ async fn bridge_in_unlock_native_token() -> anyhow::Result<()> { .build()? .execute() .await?; - bridge_account.apply_delta(config_executed.account_delta())?; + bridge_account.apply_patch(config_executed.account_patch())?; mock_chain.add_pending_executed_transaction(&config_executed)?; mock_chain.prove_next_block()?; @@ -1058,7 +1251,7 @@ async fn bridge_in_unlock_native_token() -> anyhow::Result<()> { 0, "Lock transaction should not emit any output note" ); - bridge_account.apply_delta(lock_executed.account_delta())?; + bridge_account.apply_patch(lock_executed.account_patch())?; assert_eq!( bridge_account.vault().get_balance(bridge_asset.vault_key())?, AssetAmount::new(miden_claim_amount_u64)?, @@ -1073,7 +1266,7 @@ async fn bridge_in_unlock_native_token() -> anyhow::Result<()> { .build()? .execute() .await?; - bridge_account.apply_delta(update_ger_executed.account_delta())?; + bridge_account.apply_patch(update_ger_executed.account_patch())?; mock_chain.add_pending_executed_transaction(&update_ger_executed)?; mock_chain.prove_next_block()?; @@ -1097,7 +1290,9 @@ async fn bridge_in_unlock_native_token() -> anyhow::Result<()> { }; let expected_asset: Asset = - FungibleAsset::new(native_faucet.id(), miden_claim_amount_u64).unwrap().into(); + FungibleAsset::new(native_faucet.id(), miden_claim_amount_u64, AssetCallbackFlag::Disabled) + .unwrap() + .into(); assert_eq!(output_note.metadata().sender(), bridge_account.id()); assert_eq!(output_note.metadata().note_type(), NoteType::Public); @@ -1140,7 +1335,7 @@ async fn bridge_in_unlock_native_token() -> anyhow::Result<()> { ); // Bridge vault is drained after the unlock. - bridge_account.apply_delta(claim_executed.account_delta())?; + bridge_account.apply_patch(claim_executed.account_patch())?; assert_eq!( bridge_account.vault().get_balance(expected_asset.vault_key())?, AssetAmount::ZERO, @@ -1159,7 +1354,7 @@ async fn bridge_in_unlock_native_token() -> anyhow::Result<()> { .await?; let mut destination_account = destination_account; - destination_account.apply_delta(consume_executed.account_delta())?; + destination_account.apply_patch(consume_executed.account_patch())?; assert_eq!( destination_account.vault().get_balance(expected_asset.vault_key())?, AssetAmount::new(miden_claim_amount_u64)?, @@ -1185,13 +1380,20 @@ async fn bridge_in_unlock_native_duplicate_rejected() -> anyhow::Result<()> { let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; let bridge_seed = builder.rng_mut().draw_word(); - let mut bridge_account = - create_existing_bridge_account(bridge_seed, bridge_admin.id(), ger_manager.id()); + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let mut bridge_account = create_existing_bridge_account( + bridge_seed, + bridge_admin.id(), + ger_injector.id(), + ger_remover.id(), + ); builder.add_account(bridge_account.clone())?; let (proof_data, leaf_data, ger, _cgi_chain_hash) = data_source.get_data(); @@ -1216,7 +1418,7 @@ async fn bridge_in_unlock_native_duplicate_rejected() -> anyhow::Result<()> { miden_claim_amount_u64.saturating_mul(4), faucet_owner_account_id, Some(miden_claim_amount_u64.saturating_mul(4)), - MintPolicyConfig::OwnerOnly, + MintPolicy::owner_only(), [], )?; @@ -1256,10 +1458,13 @@ async fn bridge_in_unlock_native_duplicate_rejected() -> anyhow::Result<()> { // Lock 2x the claim amount so the bridge vault could (if nullifier were broken) serve the // replayed claim. - let bridge_asset: Asset = - FungibleAsset::new(native_faucet.id(), miden_claim_amount_u64.saturating_mul(2)) - .unwrap() - .into(); + let bridge_asset: Asset = FungibleAsset::new( + native_faucet.id(), + miden_claim_amount_u64.saturating_mul(2), + AssetCallbackFlag::Disabled, + ) + .unwrap() + .into(); let b2agg_destination_address = EthAddress::from_hex("0x1234567890abcdef1122334455667788990011aa") .expect("valid destination address"); @@ -1300,7 +1505,7 @@ async fn bridge_in_unlock_native_duplicate_rejected() -> anyhow::Result<()> { builder.add_output_note(RawOutputNote::Full(claim_note_2.clone())); let update_ger_note = - UpdateGerNote::create(ger, ger_manager.id(), bridge_account.id(), builder.rng_mut())?; + UpdateGerNote::create(ger, ger_injector.id(), bridge_account.id(), builder.rng_mut())?; builder.add_output_note(RawOutputNote::Full(update_ger_note.clone())); let mut mock_chain = builder.clone().build()?; @@ -1311,7 +1516,7 @@ async fn bridge_in_unlock_native_duplicate_rejected() -> anyhow::Result<()> { .build()? .execute() .await?; - bridge_account.apply_delta(config_executed.account_delta())?; + bridge_account.apply_patch(config_executed.account_patch())?; mock_chain.add_pending_executed_transaction(&config_executed)?; mock_chain.prove_next_block()?; @@ -1321,7 +1526,7 @@ async fn bridge_in_unlock_native_duplicate_rejected() -> anyhow::Result<()> { .build()? .execute() .await?; - bridge_account.apply_delta(lock_executed.account_delta())?; + bridge_account.apply_patch(lock_executed.account_patch())?; assert_eq!( bridge_account.vault().get_balance(bridge_asset.vault_key())?, AssetAmount::new(miden_claim_amount_u64.saturating_mul(2))?, @@ -1335,7 +1540,7 @@ async fn bridge_in_unlock_native_duplicate_rejected() -> anyhow::Result<()> { .build()? .execute() .await?; - bridge_account.apply_delta(update_ger_executed.account_delta())?; + bridge_account.apply_patch(update_ger_executed.account_patch())?; mock_chain.add_pending_executed_transaction(&update_ger_executed)?; mock_chain.prove_next_block()?; @@ -1346,7 +1551,7 @@ async fn bridge_in_unlock_native_duplicate_rejected() -> anyhow::Result<()> { .execute() .await?; assert_eq!(claim_executed_1.output_notes().num_notes(), 1); - bridge_account.apply_delta(claim_executed_1.account_delta())?; + bridge_account.apply_patch(claim_executed_1.account_patch())?; assert_eq!( bridge_account.vault().get_balance(bridge_asset.vault_key())?, AssetAmount::new(miden_claim_amount_u64)?, @@ -1418,13 +1623,20 @@ async fn test_claim_fails_when_origin_network_unregistered() -> anyhow::Result<( auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; let bridge_seed = builder.rng_mut().draw_word(); - let bridge_account = - create_existing_bridge_account(bridge_seed, bridge_admin.id(), ger_manager.id()); + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let bridge_account = create_existing_bridge_account( + bridge_seed, + bridge_admin.id(), + ger_injector.id(), + ger_remover.id(), + ); builder.add_account(bridge_account.clone())?; let (proof_data, leaf_data, ger, _cgi_chain_hash) = data_source.get_data(); @@ -1501,7 +1713,7 @@ async fn test_claim_fails_when_origin_network_unregistered() -> anyhow::Result<( builder.add_output_note(RawOutputNote::Full(config_note.clone())); let update_ger_note = - UpdateGerNote::create(ger, ger_manager.id(), bridge_account.id(), builder.rng_mut())?; + UpdateGerNote::create(ger, ger_injector.id(), bridge_account.id(), builder.rng_mut())?; builder.add_output_note(RawOutputNote::Full(update_ger_note.clone())); let mut mock_chain = builder.clone().build()?; diff --git a/crates/miden-testing/tests/agglayer/bridge_out.rs b/crates/miden-testing/tests/agglayer/bridge_out.rs index b7c336e68a..730378c2e3 100644 --- a/crates/miden-testing/tests/agglayer/bridge_out.rs +++ b/crates/miden-testing/tests/agglayer/bridge_out.rs @@ -2,6 +2,7 @@ extern crate alloc; use miden_agglayer::errors::{ ERR_B2AGG_DESTINATION_NETWORK_IS_MIDEN, + ERR_B2AGG_NOTE_MUST_BE_PUBLIC, ERR_B2AGG_TARGET_ACCOUNT_MISMATCH, ERR_FAUCET_NOT_REGISTERED, }; @@ -21,13 +22,21 @@ use miden_crypto::hash::keccak::Keccak256Digest; use miden_crypto::rand::FeltRng; use miden_protocol::account::auth::AuthScheme; use miden_protocol::account::{Account, AccountId, AccountIdVersion, AccountType, StorageMapKey}; -use miden_protocol::asset::{Asset, AssetAmount, FungibleAsset}; -use miden_protocol::note::{NoteAssets, NoteType}; +use miden_protocol::asset::{Asset, AssetAmount, AssetCallbackFlag, FungibleAsset}; +use miden_protocol::errors::MasmError; +use miden_protocol::note::{ + Note, + NoteAssets, + NoteAttachment, + NoteAttachments, + NoteType, + PartialNoteMetadata, +}; use miden_protocol::transaction::RawOutputNote; use miden_protocol::{Felt, Word}; use miden_standards::account::faucets::FungibleFaucet; -use miden_standards::account::policies::MintPolicyConfig; -use miden_standards::note::{NetworkAccountTarget, StandardNote}; +use miden_standards::account::policies::MintPolicy; +use miden_standards::note::{NetworkAccountTarget, NoteExecutionHint, StandardNote}; use miden_testing::{Auth, MockChain, assert_transaction_executor_error}; use miden_tx::utils::hex_to_bytes; use rand::rngs::StdRng; @@ -75,15 +84,21 @@ async fn bridge_out_consecutive() -> anyhow::Result<()> { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - // CREATE GER MANAGER ACCOUNT (not used in this test, but distinct from admin) - let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + // CREATE GER INJECTOR ACCOUNT (not used in this test, but distinct from admin) + let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // CREATE GER REMOVER ACCOUNT (not used in this test, but distinct from admin and injector) + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; let mut bridge_account = create_existing_bridge_account( builder.rng_mut().draw_word(), bridge_admin.id(), - ger_manager.id(), + ger_injector.id(), + ger_remover.id(), ); builder.add_account(bridge_account.clone())?; @@ -139,7 +154,10 @@ async fn bridge_out_consecutive() -> anyhow::Result<()> { let eth_address = EthAddress::from_hex(&vectors.destination_addresses[i]) .expect("valid destination address"); - let bridge_asset: Asset = FungibleAsset::new(faucet.id(), amount).unwrap().into(); + let bridge_asset: Asset = + FungibleAsset::new(faucet.id(), amount, AssetCallbackFlag::Disabled) + .unwrap() + .into(); let note = B2AggNote::create( destination_network, eth_address, @@ -162,7 +180,7 @@ async fn bridge_out_consecutive() -> anyhow::Result<()> { .build()? .execute() .await?; - bridge_account.apply_delta(config_executed.account_delta())?; + bridge_account.apply_patch(config_executed.account_patch())?; mock_chain.add_pending_executed_transaction(&config_executed)?; mock_chain.prove_next_block()?; @@ -189,7 +207,11 @@ async fn bridge_out_consecutive() -> anyhow::Result<()> { }; burn_note_ids.push(burn_note.id()); - let expected_asset = Asset::from(FungibleAsset::new(faucet.id(), expected_amounts[i])?); + let expected_asset = Asset::from(FungibleAsset::new( + faucet.id(), + expected_amounts[i], + AssetCallbackFlag::Disabled, + )?); assert!( burn_note.assets().iter().any(|asset| asset == &expected_asset), "BURN note after consume #{} should contain the bridged asset", @@ -218,7 +240,7 @@ async fn bridge_out_consecutive() -> anyhow::Result<()> { "BURN note should use the BURN script" ); - bridge_account.apply_delta(executed_tx.account_delta())?; + bridge_account.apply_patch(executed_tx.account_patch())?; assert_eq!( AggLayerBridge::read_let_num_leaves(&bridge_account), (i + 1) as u64, @@ -259,7 +281,7 @@ async fn bridge_out_consecutive() -> anyhow::Result<()> { 0, "Burn transaction should not create output notes" ); - faucet.apply_delta(burn_executed_tx.account_delta())?; + faucet.apply_patch(burn_executed_tx.account_patch())?; mock_chain.add_pending_executed_transaction(&burn_executed_tx)?; mock_chain.prove_next_block()?; } @@ -354,14 +376,19 @@ async fn bridge_out_at_high_num_leaves(#[case] initial_num_leaves: u32) -> anyho let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; + let bridge_seed = builder.rng_mut().draw_word(); + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; let mut bridge_account = create_existing_bridge_account( - builder.rng_mut().draw_word(), + bridge_seed, bridge_admin.id(), - ger_manager.id(), + ger_injector.id(), + ger_remover.id(), ); populate_let_state(&mut bridge_account, initial_num_leaves, &initial_frontier); builder.add_account(bridge_account.clone())?; @@ -405,7 +432,9 @@ async fn bridge_out_at_high_num_leaves(#[case] initial_num_leaves: u32) -> anyho let destination_network = vectors.destination_networks[0]; let eth_address = EthAddress::from_hex(&vectors.destination_addresses[0]).expect("valid destination address"); - let bridge_asset: Asset = FungibleAsset::new(faucet.id(), amount).unwrap().into(); + let bridge_asset: Asset = FungibleAsset::new(faucet.id(), amount, AssetCallbackFlag::Disabled) + .unwrap() + .into(); let b2agg_note = B2AggNote::create( destination_network, eth_address, @@ -425,7 +454,7 @@ async fn bridge_out_at_high_num_leaves(#[case] initial_num_leaves: u32) -> anyho .build()? .execute() .await?; - bridge_account.apply_delta(config_executed.account_delta())?; + bridge_account.apply_patch(config_executed.account_patch())?; mock_chain.add_pending_executed_transaction(&config_executed)?; mock_chain.prove_next_block()?; @@ -438,7 +467,7 @@ async fn bridge_out_at_high_num_leaves(#[case] initial_num_leaves: u32) -> anyho .build()? .execute() .await?; - bridge_account.apply_delta(executed_tx.account_delta())?; + bridge_account.apply_patch(executed_tx.account_patch())?; let leaf = Keccak256Digest::try_from(vectors.leaves[0].as_str()) .expect("valid leaf hex from MTF vectors"); @@ -477,8 +506,13 @@ async fn test_bridge_out_fails_with_unregistered_faucet() -> anyhow::Result<()> auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - // CREATE GER MANAGER ACCOUNT (not used in this test, but distinct from admin) - let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + // CREATE GER INJECTOR ACCOUNT (not used in this test, but distinct from admin) + let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // CREATE GER REMOVER ACCOUNT (not used in this test, but distinct from admin and injector) + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; @@ -487,7 +521,8 @@ async fn test_bridge_out_fails_with_unregistered_faucet() -> anyhow::Result<()> let bridge_account = create_existing_bridge_account( builder.rng_mut().draw_word(), bridge_admin.id(), - ger_manager.id(), + ger_injector.id(), + ger_remover.id(), ); builder.add_account(bridge_account.clone())?; @@ -508,7 +543,9 @@ async fn test_bridge_out_fails_with_unregistered_faucet() -> anyhow::Result<()> // -------------------------------------------------------------------------------------------- let amount = Felt::new_unchecked(100); let bridge_asset: Asset = - FungibleAsset::new(faucet.id(), amount.as_canonical_u64()).unwrap().into(); + FungibleAsset::new(faucet.id(), amount.as_canonical_u64(), AssetCallbackFlag::Disabled) + .unwrap() + .into(); let destination_address = "0x1234567890abcdef1122334455667788990011aa"; let eth_address = EthAddress::from_hex(destination_address).expect("valid Ethereum address"); @@ -538,10 +575,29 @@ async fn test_bridge_out_fails_with_unregistered_faucet() -> anyhow::Result<()> Ok(()) } -/// B2AGG / bridge-out must reject a note whose `destination_network` equals the Miden network ID, -/// even when the faucet is registered and the rest of the bridge-out path would otherwise succeed. +/// The kind of malformation applied to an otherwise-valid B2AGG note in +/// [`test_bridge_out_rejects_invalid_b2agg_note`]. +#[derive(Debug, Clone, Copy)] +enum InvalidB2aggNote { + /// `destination_network` equals Miden's AggLayer network ID. + DestinationIsMiden, + /// Recipient-identical note marked `NoteType::Private` instead of public. + PrivateNoteType, +} + +/// B2AGG notes must be public; private notes and bridge-out requests to Miden from Miden are +/// rejected. +#[rstest::rstest] +#[case::destination_is_miden( + InvalidB2aggNote::DestinationIsMiden, + ERR_B2AGG_DESTINATION_NETWORK_IS_MIDEN +)] +#[case::private_note_type(InvalidB2aggNote::PrivateNoteType, ERR_B2AGG_NOTE_MUST_BE_PUBLIC)] #[tokio::test] -async fn test_bridge_out_fails_when_destination_is_miden_network() -> anyhow::Result<()> { +async fn test_bridge_out_rejects_invalid_b2agg_note( + #[case] invalid_note: InvalidB2aggNote, + #[case] expected_err: MasmError, +) -> anyhow::Result<()> { let mut builder = MockChain::builder(); // CREATE BRIDGE ADMIN ACCOUNT (sends CONFIG_AGG_BRIDGE notes) @@ -550,18 +606,23 @@ async fn test_bridge_out_fails_when_destination_is_miden_network() -> anyhow::Re auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - // CREATE GER MANAGER ACCOUNT (not used for GER in this test, but distinct from admin) + // CREATE GER INJECTOR ACCOUNT (not used for GER in this test, but distinct from admin) // -------------------------------------------------------------------------------------------- - let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; // CREATE BRIDGE ACCOUNT // -------------------------------------------------------------------------------------------- + let bridge_seed = builder.rng_mut().draw_word(); + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; let mut bridge_account = create_existing_bridge_account( - builder.rng_mut().draw_word(), + bridge_seed, bridge_admin.id(), - ger_manager.id(), + ger_injector.id(), + ger_remover.id(), ); builder.add_account(bridge_account.clone())?; @@ -604,24 +665,51 @@ async fn test_bridge_out_fails_when_destination_is_miden_network() -> anyhow::Re )?; builder.add_output_note(RawOutputNote::Full(config_note.clone())); - // CREATE B2AGG NOTE (targets the bridge) - // Set destination_network to exactly `AggLayerBridge::MIDEN_NETWORK_ID` so `bridge_out` - // fails immediately. + // CREATE THE INVALID B2AGG NOTE (targets the bridge) // -------------------------------------------------------------------------------------------- let amount = Felt::new_unchecked(100); let bridge_asset: Asset = - FungibleAsset::new(faucet.id(), amount.as_canonical_u64()).unwrap().into(); + FungibleAsset::new(faucet.id(), amount.as_canonical_u64(), AssetCallbackFlag::Disabled) + .unwrap() + .into(); let eth_address = EthAddress::from_hex(&vectors.destination_addresses[0]).expect("valid destination address"); - let b2agg_note = B2AggNote::create( - AggLayerBridge::MIDEN_NETWORK_ID, - eth_address, - NoteAssets::new(vec![bridge_asset])?, - bridge_account.id(), - faucet.id(), - builder.rng_mut(), - )?; + let b2agg_note = match invalid_note { + // Destination network equals Miden's own network ID, which `bridge_out` rejects. + InvalidB2aggNote::DestinationIsMiden => B2AggNote::create( + AggLayerBridge::MIDEN_NETWORK_ID, + eth_address, + NoteAssets::new(vec![bridge_asset])?, + bridge_account.id(), + faucet.id(), + builder.rng_mut(), + )?, + // Build a public B2AGG note, then reuse its recipient and assets in a private note. + InvalidB2aggNote::PrivateNoteType => { + let public_note = B2AggNote::create( + origin_network, + eth_address, + NoteAssets::new(vec![bridge_asset])?, + bridge_account.id(), + faucet.id(), + builder.rng_mut(), + )?; + + let attachment = NoteAttachment::from(NetworkAccountTarget::new( + bridge_account.id(), + NoteExecutionHint::Always, + )?); + let attachments = NoteAttachments::from(attachment); + let metadata = PartialNoteMetadata::new(faucet.id(), NoteType::Private); + Note::with_attachments( + public_note.assets().clone(), + metadata, + public_note.recipient().clone(), + attachments, + ) + }, + }; builder.add_output_note(RawOutputNote::Full(b2agg_note.clone())); @@ -637,11 +725,11 @@ async fn test_bridge_out_fails_when_destination_is_miden_network() -> anyhow::Re .build()? .execute() .await?; - bridge_account.apply_delta(config_executed.account_delta())?; + bridge_account.apply_patch(config_executed.account_patch())?; mock_chain.add_pending_executed_transaction(&config_executed)?; mock_chain.prove_next_block()?; - // TX1: EXECUTE B2AGG NOTE AGAINST BRIDGE (must fail: destination_network is Miden's ID) + // TX1: EXECUTE THE INVALID B2AGG NOTE AGAINST BRIDGE (must fail) // -------------------------------------------------------------------------------------------- let foreign_account_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; @@ -652,7 +740,7 @@ async fn test_bridge_out_fails_when_destination_is_miden_network() -> anyhow::Re .execute() .await; - assert_transaction_executor_error!(result, ERR_B2AGG_DESTINATION_NETWORK_IS_MIDEN); + assert_transaction_executor_error!(result, expected_err); Ok(()) } @@ -682,7 +770,7 @@ async fn b2agg_note_reclaim_scenario() -> anyhow::Result<()> { 1000, faucet_owner_account_id, Some(100), - MintPolicyConfig::OwnerOnly, + MintPolicy::owner_only(), [], )?; @@ -691,8 +779,13 @@ async fn b2agg_note_reclaim_scenario() -> anyhow::Result<()> { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - // Create a GER manager account (not used in this test, but distinct from admin) - let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + // Create a GER injector account (not used in this test, but distinct from admin) + let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // Create a GER remover account (not used in this test, but distinct from admin and injector) + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; @@ -700,7 +793,8 @@ async fn b2agg_note_reclaim_scenario() -> anyhow::Result<()> { let bridge_account = create_existing_bridge_account( builder.rng_mut().draw_word(), bridge_admin.id(), - ger_manager.id(), + ger_injector.id(), + ger_remover.id(), ); builder.add_account(bridge_account.clone())?; @@ -712,7 +806,10 @@ async fn b2agg_note_reclaim_scenario() -> anyhow::Result<()> { // CREATE B2AGG NOTE WITH USER ACCOUNT AS SENDER // -------------------------------------------------------------------------------------------- let amount = AssetAmount::from(50_u32); - let bridge_asset: Asset = FungibleAsset::new(faucet.id(), amount.as_u64()).unwrap().into(); + let bridge_asset: Asset = + FungibleAsset::new(faucet.id(), amount.as_u64(), AssetCallbackFlag::Disabled) + .unwrap() + .into(); let destination_network = 1u32; let destination_address = "0x1234567890abcdef1122334455667788990011aa"; @@ -752,7 +849,7 @@ async fn b2agg_note_reclaim_scenario() -> anyhow::Result<()> { ); // Apply the delta to the user account - user_account.apply_delta(executed_transaction.account_delta())?; + user_account.apply_patch(executed_transaction.account_patch())?; // VERIFY ASSETS WERE ADDED BACK TO THE ACCOUNT // -------------------------------------------------------------------------------------------- @@ -796,7 +893,7 @@ async fn b2agg_note_non_target_account_cannot_consume() -> anyhow::Result<()> { 1000, faucet_owner_account_id, Some(100), - MintPolicyConfig::OwnerOnly, + MintPolicy::owner_only(), [], )?; @@ -805,8 +902,13 @@ async fn b2agg_note_non_target_account_cannot_consume() -> anyhow::Result<()> { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - // Create a GER manager account (not used in this test, but distinct from admin) - let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + // Create a GER injector account (not used in this test, but distinct from admin) + let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // Create a GER remover account (not used in this test, but distinct from admin and injector) + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; @@ -814,7 +916,8 @@ async fn b2agg_note_non_target_account_cannot_consume() -> anyhow::Result<()> { let bridge_account = create_existing_bridge_account( builder.rng_mut().draw_word(), bridge_admin.id(), - ger_manager.id(), + ger_injector.id(), + ger_remover.id(), ); builder.add_account(bridge_account.clone())?; @@ -827,7 +930,8 @@ async fn b2agg_note_non_target_account_cannot_consume() -> anyhow::Result<()> { let malicious_account = create_existing_bridge_account( builder.rng_mut().draw_word(), bridge_admin.id(), - ger_manager.id(), + ger_injector.id(), + ger_remover.id(), ); builder.add_account(malicious_account.clone())?; @@ -835,7 +939,9 @@ async fn b2agg_note_non_target_account_cannot_consume() -> anyhow::Result<()> { // -------------------------------------------------------------------------------------------- let amount = Felt::new_unchecked(50); let bridge_asset: Asset = - FungibleAsset::new(faucet.id(), amount.as_canonical_u64()).unwrap().into(); + FungibleAsset::new(faucet.id(), amount.as_canonical_u64(), AssetCallbackFlag::Disabled) + .unwrap() + .into(); let destination_network = 1u32; let destination_address = "0x1234567890abcdef1122334455667788990011aa"; @@ -880,18 +986,23 @@ async fn b2agg_note_non_target_account_cannot_consume() -> anyhow::Result<()> { async fn bridge_out_lock_native_token() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - // Bridge admin / GER manager / bridge account. + // Bridge admin / GER injector / bridge account. let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; + let bridge_seed = builder.rng_mut().draw_word(); + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; let mut bridge_account = create_existing_bridge_account( - builder.rng_mut().draw_word(), + bridge_seed, bridge_admin.id(), - ger_manager.id(), + ger_injector.id(), + ger_remover.id(), ); builder.add_account(bridge_account.clone())?; @@ -903,7 +1014,7 @@ async fn bridge_out_lock_native_token() -> anyhow::Result<()> { 1000, faucet_owner_account_id, Some(500), - MintPolicyConfig::OwnerOnly, + MintPolicy::owner_only(), [], )?; @@ -936,7 +1047,10 @@ async fn bridge_out_lock_native_token() -> anyhow::Result<()> { // B2AGG note carrying a native asset. let amount = 42u64; - let bridge_asset: Asset = FungibleAsset::new(native_faucet.id(), amount).unwrap().into(); + let bridge_asset: Asset = + FungibleAsset::new(native_faucet.id(), amount, AssetCallbackFlag::Disabled) + .unwrap() + .into(); let destination_network = 1u32; let destination_address = EthAddress::from_hex("0x1234567890abcdef1122334455667788990011aa") .expect("valid destination address"); @@ -960,7 +1074,7 @@ async fn bridge_out_lock_native_token() -> anyhow::Result<()> { .build()? .execute() .await?; - bridge_account.apply_delta(config_executed.account_delta())?; + bridge_account.apply_patch(config_executed.account_patch())?; mock_chain.add_pending_executed_transaction(&config_executed)?; mock_chain.prove_next_block()?; @@ -978,7 +1092,7 @@ async fn bridge_out_lock_native_token() -> anyhow::Result<()> { "Lock path should not emit any output note" ); - bridge_account.apply_delta(executed_tx.account_delta())?; + bridge_account.apply_patch(executed_tx.account_patch())?; // The asset now lives in the bridge's own vault. let bridge_balance = bridge_account.vault().get_balance(bridge_asset.vault_key())?; diff --git a/crates/miden-testing/tests/agglayer/config_bridge.rs b/crates/miden-testing/tests/agglayer/config_bridge.rs index ffe5792be3..cbfc92cc04 100644 --- a/crates/miden-testing/tests/agglayer/config_bridge.rs +++ b/crates/miden-testing/tests/agglayer/config_bridge.rs @@ -11,7 +11,7 @@ use miden_agglayer::{ create_existing_bridge_account, }; use miden_protocol::account::auth::AuthScheme; -use miden_protocol::account::{AccountId, AccountIdVersion, AccountType}; +use miden_protocol::account::{AccountId, AccountIdVersion, AccountType, StorageMapKey}; use miden_protocol::block::account_tree::AccountIdKey; use miden_protocol::crypto::rand::FeltRng; use miden_protocol::transaction::RawOutputNote; @@ -22,11 +22,11 @@ use miden_testing::{Auth, MockChain}; /// /// Mirrors `bridge_config::hash_token_address` in `bridge_config.masm`: hashes the 5-felt token /// address concatenated with the origin network felt (LE-packed u32), using Poseidon2. -fn token_registry_key(origin_token_address: &EthAddress, origin_network: u32) -> Word { +fn token_registry_key(origin_token_address: &EthAddress, origin_network: u32) -> StorageMapKey { let mut elements: Vec = origin_token_address.to_elements(); let origin_network_packed = u32::from_le_bytes(origin_network.to_be_bytes()); elements.push(Felt::from(origin_network_packed)); - Hasher::hash_elements(&elements) + StorageMapKey::from_raw(Hasher::hash_elements(&elements)) } /// Tests that a CONFIG_AGG_BRIDGE note registers a faucet in the bridge's faucet registry. @@ -46,8 +46,13 @@ async fn test_config_agg_bridge_registers_faucet() -> anyhow::Result<()> { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - // CREATE GER MANAGER ACCOUNT (not used in this test, but distinct from admin) - let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + // CREATE GER INJECTOR ACCOUNT (not used in this test, but distinct from admin) + let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // CREATE GER REMOVER ACCOUNT (not used in this test, but distinct from admin and injector) + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; @@ -55,7 +60,8 @@ async fn test_config_agg_bridge_registers_faucet() -> anyhow::Result<()> { let bridge_account = create_existing_bridge_account( builder.rng_mut().draw_word(), bridge_admin.id(), - ger_manager.id(), + ger_injector.id(), + ger_remover.id(), ); builder.add_account(bridge_account.clone())?; @@ -65,7 +71,7 @@ async fn test_config_agg_bridge_registers_faucet() -> anyhow::Result<()> { // Verify the faucet is NOT in the registry before registration let registry_slot_name = AggLayerBridge::faucet_registry_map_slot_name(); - let key = AccountIdKey::new(faucet_to_register).as_word(); + let key = StorageMapKey::from_raw(AccountIdKey::new(faucet_to_register).as_word()); let value_before = bridge_account.storage().get_map_item(registry_slot_name, key)?; assert_eq!( value_before, @@ -103,7 +109,7 @@ async fn test_config_agg_bridge_registers_faucet() -> anyhow::Result<()> { // VERIFY FAUCET IS NOW REGISTERED let mut updated_bridge = bridge_account.clone(); - updated_bridge.apply_delta(executed_transaction.account_delta())?; + updated_bridge.apply_patch(executed_transaction.account_patch())?; let value_after = updated_bridge.storage().get_map_item(registry_slot_name, key)?; // TODO: use a getter helper on AggLayerBridge once available @@ -134,16 +140,21 @@ async fn test_config_agg_bridge_distinguishes_origin_network() -> anyhow::Result auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - // CREATE GER MANAGER ACCOUNT (unused here, but distinct from admin) - let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + // CREATE GER INJECTOR ACCOUNT (unused here, but distinct from admin) + let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; // CREATE BRIDGE ACCOUNT (starts with empty token registry) + let bridge_seed = builder.rng_mut().draw_word(); + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; let bridge_account = create_existing_bridge_account( - builder.rng_mut().draw_word(), + bridge_seed, bridge_admin.id(), - ger_manager.id(), + ger_injector.id(), + ger_remover.id(), ); builder.add_account(bridge_account.clone())?; @@ -207,8 +218,8 @@ async fn test_config_agg_bridge_distinguishes_origin_network() -> anyhow::Result // Apply both deltas onto a single bridge account view. let mut updated_bridge = bridge_account.clone(); - updated_bridge.apply_delta(executed_1.account_delta())?; - updated_bridge.apply_delta(executed_2.account_delta())?; + updated_bridge.apply_patch(executed_1.account_patch())?; + updated_bridge.apply_patch(executed_2.account_patch())?; // VERIFY both (address, network) pairs resolve to their own faucet, and the keys are distinct. let token_registry_slot = AggLayerBridge::token_registry_map_slot_name(); diff --git a/crates/miden-testing/tests/agglayer/faucet_helpers.rs b/crates/miden-testing/tests/agglayer/faucet_helpers.rs index ee848e2ccf..1903968220 100644 --- a/crates/miden-testing/tests/agglayer/faucet_helpers.rs +++ b/crates/miden-testing/tests/agglayer/faucet_helpers.rs @@ -18,14 +18,18 @@ fn test_faucet_helper_methods() -> anyhow::Result<()> { let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; let bridge_account = create_existing_bridge_account( builder.rng_mut().draw_word(), bridge_admin.id(), - ger_manager.id(), + ger_injector.id(), + ger_remover.id(), ); builder.add_account(bridge_account.clone())?; diff --git a/crates/miden-testing/tests/agglayer/mod.rs b/crates/miden-testing/tests/agglayer/mod.rs index d6f44ff14a..cf2362ca00 100644 --- a/crates/miden-testing/tests/agglayer/mod.rs +++ b/crates/miden-testing/tests/agglayer/mod.rs @@ -7,6 +7,7 @@ mod global_index; mod leaf_utils; mod merkle_tree_frontier; mod network_account_regression; +mod remove_ger; mod solidity_miden_address_conversion; pub mod test_utils; mod update_ger; diff --git a/crates/miden-testing/tests/agglayer/network_account_regression.rs b/crates/miden-testing/tests/agglayer/network_account_regression.rs index a3bfa23ac0..e316bd38f1 100644 --- a/crates/miden-testing/tests/agglayer/network_account_regression.rs +++ b/crates/miden-testing/tests/agglayer/network_account_regression.rs @@ -24,7 +24,7 @@ use miden_protocol::transaction::RawOutputNote; use miden_standards::code_builder::CodeBuilder; use miden_standards::errors::standards::{ ERR_NOTE_SCRIPT_ALLOWLIST_NOTE_NOT_ALLOWED, - ERR_NOTE_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED, + ERR_TX_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED, }; use miden_standards::testing::note::NoteBuilder; use miden_testing::{Auth, MockChain, assert_transaction_executor_error}; @@ -38,7 +38,7 @@ end "; /// Asserts that a transaction submitting any tx script against a bridge account fails with -/// [`ERR_NOTE_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED`], even when the transaction also consumes +/// [`ERR_TX_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED`], even when the transaction also consumes /// an allowlisted input note (UPDATE_GER). This proves the tx-script check fires regardless of /// what allowlisted input notes accompany it — the two allowlist checks are independent. #[tokio::test] @@ -48,14 +48,19 @@ async fn bridge_rejects_tx_script() -> anyhow::Result<()> { let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; + let bridge_seed = builder.rng_mut().draw_word(); + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; let bridge_account = create_existing_bridge_account( - builder.rng_mut().draw_word(), + bridge_seed, bridge_admin.id(), - ger_manager.id(), + ger_injector.id(), + ger_remover.id(), ); builder.add_account(bridge_account.clone())?; @@ -65,7 +70,7 @@ async fn bridge_rejects_tx_script() -> anyhow::Result<()> { // check. let ger = ExitRoot::from([0u8; 32]); let update_ger_note = - UpdateGerNote::create(ger, ger_manager.id(), bridge_account.id(), builder.rng_mut())?; + UpdateGerNote::create(ger, ger_injector.id(), bridge_account.id(), builder.rng_mut())?; builder.add_output_note(RawOutputNote::Full(update_ger_note.clone())); let mock_chain = builder.build()?; @@ -79,7 +84,7 @@ async fn bridge_rejects_tx_script() -> anyhow::Result<()> { .execute() .await; - assert_transaction_executor_error!(result, ERR_NOTE_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED); + assert_transaction_executor_error!(result, ERR_TX_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED); Ok(()) } @@ -94,14 +99,19 @@ async fn bridge_rejects_non_allowlisted_input_note() -> anyhow::Result<()> { let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; + let bridge_seed = builder.rng_mut().draw_word(); + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; let bridge_account = create_existing_bridge_account( - builder.rng_mut().draw_word(), + bridge_seed, bridge_admin.id(), - ger_manager.id(), + ger_injector.id(), + ger_remover.id(), ); builder.add_account(bridge_account.clone())?; @@ -126,7 +136,7 @@ async fn bridge_rejects_non_allowlisted_input_note() -> anyhow::Result<()> { } /// Asserts that a transaction submitting any tx script against an AggLayer faucet account fails -/// with [`ERR_NOTE_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED`]. Symmetric to +/// with [`ERR_TX_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED`]. Symmetric to /// [`bridge_rejects_tx_script`]: the faucet's [`AuthNetworkAccount`] allowlist (MINT, BURN) must /// reject every tx script, regardless of which input notes (if any) accompany it. /// @@ -162,7 +172,7 @@ async fn faucet_rejects_tx_script() -> anyhow::Result<()> { .execute() .await; - assert_transaction_executor_error!(result, ERR_NOTE_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED); + assert_transaction_executor_error!(result, ERR_TX_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED); Ok(()) } diff --git a/crates/miden-testing/tests/agglayer/remove_ger.rs b/crates/miden-testing/tests/agglayer/remove_ger.rs new file mode 100644 index 0000000000..d31ee6407c --- /dev/null +++ b/crates/miden-testing/tests/agglayer/remove_ger.rs @@ -0,0 +1,381 @@ +extern crate alloc; + +use miden_agglayer::errors::{ERR_GER_NOT_FOUND, ERR_SENDER_NOT_GER_REMOVER}; +use miden_agglayer::{ + AggLayerBridge, + ExitRoot, + RemoveGerNote, + UpdateGerNote, + create_existing_bridge_account, +}; +use miden_core_lib::handlers::keccak256::KeccakPreimage; +use miden_protocol::account::Account; +use miden_protocol::account::auth::AuthScheme; +use miden_protocol::crypto::rand::FeltRng; +use miden_protocol::transaction::RawOutputNote; +use miden_testing::{Auth, MockChain, MockChainBuilder, assert_transaction_executor_error}; + +const GER_BYTES: [u8; 32] = [ + 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, + 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, +]; + +/// Creates the bridge admin, GER injector, and GER remover wallets, builds the bridge account +/// wired to those roles, and registers the bridge account with the builder. +/// +/// Returns the bridge account together with the GER injector and GER remover wallets. +fn setup_bridge(builder: &mut MockChainBuilder) -> anyhow::Result<(Account, Account, Account)> { + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let bridge_seed = builder.rng_mut().draw_word(); + let bridge_account = create_existing_bridge_account( + bridge_seed, + bridge_admin.id(), + ger_injector.id(), + ger_remover.id(), + ); + builder.add_account(bridge_account.clone())?; + + Ok((bridge_account, ger_injector, ger_remover)) +} + +/// Computes one fold of the removed-GER hash chain, `keccak256(prev_chain || ger)`, in the +/// same byte representation that [`AggLayerBridge::removed_ger_hash_chain`] returns. +fn fold_removed_ger_chain(prev_chain: [u8; 32], ger_bytes: [u8; 32]) -> [u8; 32] { + let mut preimage = [0u8; 64]; + preimage[..32].copy_from_slice(&prev_chain); + preimage[32..].copy_from_slice(&ger_bytes); + let chain_felts: alloc::vec::Vec<_> = + KeccakPreimage::new(preimage.to_vec()).digest().as_ref().to_vec(); + let mut chain_bytes = [0u8; 32]; + for (i, felt) in chain_felts.iter().enumerate() { + let limb = u32::try_from(felt.as_canonical_u64()).expect("felt fits in u32"); + chain_bytes[i * 4..(i + 1) * 4].copy_from_slice(&limb.to_le_bytes()); + } + chain_bytes +} + +/// Tests the happy path: register a GER via UPDATE_GER, then remove it via REMOVE_GER. +/// Verifies that the GER is no longer registered and that the removed-GER hash chain +/// advanced to `keccak256(0...0 || ger)`. +#[tokio::test] +async fn remove_ger_note_clears_storage_and_updates_chain() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let (bridge_account, ger_injector, ger_remover) = setup_bridge(&mut builder)?; + + // STEP 1: Register the GER via UPDATE_GER + let ger = ExitRoot::from(GER_BYTES); + let update_ger_note = + UpdateGerNote::create(ger, ger_injector.id(), bridge_account.id(), builder.rng_mut())?; + builder.add_output_note(RawOutputNote::Full(update_ger_note.clone())); + + // STEP 2: Remove the GER via REMOVE_GER (sent by the GER remover) + let remove_ger_note = + RemoveGerNote::create(ger, ger_remover.id(), bridge_account.id(), builder.rng_mut())?; + builder.add_output_note(RawOutputNote::Full(remove_ger_note.clone())); + + let mut mock_chain = builder.build()?; + + let update_tx_context = mock_chain + .build_tx_context(bridge_account.id(), &[update_ger_note.id()], &[])? + .build()?; + let update_executed = update_tx_context.execute().await?; + mock_chain.add_pending_executed_transaction(&update_executed)?; + mock_chain.prove_next_block()?; + + let remove_tx_context = mock_chain + .build_tx_context(bridge_account.id(), &[remove_ger_note.id()], &[])? + .build()?; + let remove_executed = remove_tx_context.execute().await?; + + // VERIFY GER IS NO LONGER REGISTERED AND CHAIN HASH ADVANCED + let mut updated_bridge_account = bridge_account.clone(); + updated_bridge_account.apply_patch(update_executed.account_patch())?; + updated_bridge_account.apply_patch(remove_executed.account_patch())?; + + let is_registered = AggLayerBridge::is_ger_registered(ger, &updated_bridge_account)?; + assert!(!is_registered, "GER should have been removed from the bridge account"); + + // Expected chain = keccak256(0...0 || ger_bytes) + let expected_chain_bytes = fold_removed_ger_chain([0u8; 32], GER_BYTES); + let actual_chain = AggLayerBridge::removed_ger_hash_chain(&updated_bridge_account)?; + assert_eq!( + actual_chain.as_bytes(), + &expected_chain_bytes, + "removed-GER hash chain mismatch" + ); + + Ok(()) +} + +/// Tests that removing a GER from the middle of a sequence of inserted GERs leaves the +/// other GERs in place. Inserts A, B, C, removes B, and verifies that A and C remain +/// registered while B does not. +#[tokio::test] +async fn remove_ger_middle_of_multi_insert_leaves_others_intact() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let (bridge_account, ger_injector, ger_remover) = setup_bridge(&mut builder)?; + + let mut ger_a_bytes = GER_BYTES; + ger_a_bytes[31] = 0xaa; + let mut ger_b_bytes = GER_BYTES; + ger_b_bytes[31] = 0xbb; + let mut ger_c_bytes = GER_BYTES; + ger_c_bytes[31] = 0xcc; + let ger_a = ExitRoot::from(ger_a_bytes); + let ger_b = ExitRoot::from(ger_b_bytes); + let ger_c = ExitRoot::from(ger_c_bytes); + + let update_a = + UpdateGerNote::create(ger_a, ger_injector.id(), bridge_account.id(), builder.rng_mut())?; + let update_b = + UpdateGerNote::create(ger_b, ger_injector.id(), bridge_account.id(), builder.rng_mut())?; + let update_c = + UpdateGerNote::create(ger_c, ger_injector.id(), bridge_account.id(), builder.rng_mut())?; + let remove_b = + RemoveGerNote::create(ger_b, ger_remover.id(), bridge_account.id(), builder.rng_mut())?; + + builder.add_output_note(RawOutputNote::Full(update_a.clone())); + builder.add_output_note(RawOutputNote::Full(update_b.clone())); + builder.add_output_note(RawOutputNote::Full(update_c.clone())); + builder.add_output_note(RawOutputNote::Full(remove_b.clone())); + + let mut mock_chain = builder.build()?; + + let mut updated_bridge_account = bridge_account.clone(); + for note in [&update_a, &update_b, &update_c] { + let tx_context = + mock_chain.build_tx_context(bridge_account.id(), &[note.id()], &[])?.build()?; + let executed = tx_context.execute().await?; + updated_bridge_account.apply_patch(executed.account_patch())?; + mock_chain.add_pending_executed_transaction(&executed)?; + mock_chain.prove_next_block()?; + } + + let remove_tx_context = mock_chain + .build_tx_context(bridge_account.id(), &[remove_b.id()], &[])? + .build()?; + let remove_executed = remove_tx_context.execute().await?; + updated_bridge_account.apply_patch(remove_executed.account_patch())?; + + assert!( + AggLayerBridge::is_ger_registered(ger_a, &updated_bridge_account)?, + "GER A should still be registered after removing B" + ); + assert!( + !AggLayerBridge::is_ger_registered(ger_b, &updated_bridge_account)?, + "GER B should have been removed" + ); + assert!( + AggLayerBridge::is_ger_registered(ger_c, &updated_bridge_account)?, + "GER C should still be registered after removing B" + ); + + let expected_chain_bytes = fold_removed_ger_chain([0u8; 32], ger_b_bytes); + let actual_chain = AggLayerBridge::removed_ger_hash_chain(&updated_bridge_account)?; + assert_eq!( + actual_chain.as_bytes(), + &expected_chain_bytes, + "removed-GER hash chain should equal keccak256(0...0 || B)" + ); + + Ok(()) +} + +/// Tests two successful sequential removals: inserts GERs A and B, removes A, then removes B, +/// and verifies the chain folds over the non-zero intermediate value, i.e. ends at +/// `keccak256(keccak256(0...0 || A) || B)`. +#[tokio::test] +async fn remove_ger_sequential_removals_fold_chain() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let (bridge_account, ger_injector, ger_remover) = setup_bridge(&mut builder)?; + + let mut ger_a_bytes = GER_BYTES; + ger_a_bytes[31] = 0xaa; + let mut ger_b_bytes = GER_BYTES; + ger_b_bytes[31] = 0xbb; + let ger_a = ExitRoot::from(ger_a_bytes); + let ger_b = ExitRoot::from(ger_b_bytes); + + let update_a = + UpdateGerNote::create(ger_a, ger_injector.id(), bridge_account.id(), builder.rng_mut())?; + let update_b = + UpdateGerNote::create(ger_b, ger_injector.id(), bridge_account.id(), builder.rng_mut())?; + let remove_a = + RemoveGerNote::create(ger_a, ger_remover.id(), bridge_account.id(), builder.rng_mut())?; + let remove_b = + RemoveGerNote::create(ger_b, ger_remover.id(), bridge_account.id(), builder.rng_mut())?; + + builder.add_output_note(RawOutputNote::Full(update_a.clone())); + builder.add_output_note(RawOutputNote::Full(update_b.clone())); + builder.add_output_note(RawOutputNote::Full(remove_a.clone())); + builder.add_output_note(RawOutputNote::Full(remove_b.clone())); + + let mut mock_chain = builder.build()?; + + let mut updated_bridge_account = bridge_account.clone(); + for note in [&update_a, &update_b, &remove_a, &remove_b] { + let tx_context = + mock_chain.build_tx_context(bridge_account.id(), &[note.id()], &[])?.build()?; + let executed = tx_context.execute().await?; + updated_bridge_account.apply_patch(executed.account_patch())?; + mock_chain.add_pending_executed_transaction(&executed)?; + mock_chain.prove_next_block()?; + } + + assert!( + !AggLayerBridge::is_ger_registered(ger_a, &updated_bridge_account)?, + "GER A should have been removed" + ); + assert!( + !AggLayerBridge::is_ger_registered(ger_b, &updated_bridge_account)?, + "GER B should have been removed" + ); + + let expected_chain_bytes = + fold_removed_ger_chain(fold_removed_ger_chain([0u8; 32], ger_a_bytes), ger_b_bytes); + let actual_chain = AggLayerBridge::removed_ger_hash_chain(&updated_bridge_account)?; + assert_eq!( + actual_chain.as_bytes(), + &expected_chain_bytes, + "removed-GER hash chain should equal keccak256(keccak256(0...0 || A) || B)" + ); + + Ok(()) +} + +/// Tests that calling REMOVE_GER twice on the same GER reverts the second call with +/// ERR_GER_NOT_FOUND. Locks in the invariant that a removed entry stays at [0,0,0,0] +/// and cannot be re-removed. +#[tokio::test] +async fn remove_ger_double_remove_reverts() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let (bridge_account, ger_injector, ger_remover) = setup_bridge(&mut builder)?; + + let ger = ExitRoot::from(GER_BYTES); + let update_ger_note = + UpdateGerNote::create(ger, ger_injector.id(), bridge_account.id(), builder.rng_mut())?; + let remove_ger_note_first = + RemoveGerNote::create(ger, ger_remover.id(), bridge_account.id(), builder.rng_mut())?; + let remove_ger_note_second = + RemoveGerNote::create(ger, ger_remover.id(), bridge_account.id(), builder.rng_mut())?; + + builder.add_output_note(RawOutputNote::Full(update_ger_note.clone())); + builder.add_output_note(RawOutputNote::Full(remove_ger_note_first.clone())); + builder.add_output_note(RawOutputNote::Full(remove_ger_note_second.clone())); + + let mut mock_chain = builder.build()?; + + for note in [&update_ger_note, &remove_ger_note_first] { + let tx_context = + mock_chain.build_tx_context(bridge_account.id(), &[note.id()], &[])?.build()?; + let executed = tx_context.execute().await?; + mock_chain.add_pending_executed_transaction(&executed)?; + mock_chain.prove_next_block()?; + } + + let result = mock_chain + .build_tx_context(bridge_account.id(), &[remove_ger_note_second.id()], &[])? + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_GER_NOT_FOUND); + + Ok(()) +} + +/// Tests that re-inserting a previously-removed GER succeeds and that the re-insertion +/// does NOT touch the removed-GER hash chain. Documents current `update_ger` behavior: +/// it overwrites the map entry unconditionally, so a removed GER can be revived. If +/// preventing revival is ever desired, `update_ger` itself must be hardened — this test +/// would then need to be updated to expect a revert. +#[tokio::test] +async fn remove_ger_then_reinsert_succeeds() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let (bridge_account, ger_injector, ger_remover) = setup_bridge(&mut builder)?; + + let ger = ExitRoot::from(GER_BYTES); + let update_first = + UpdateGerNote::create(ger, ger_injector.id(), bridge_account.id(), builder.rng_mut())?; + let remove_note = + RemoveGerNote::create(ger, ger_remover.id(), bridge_account.id(), builder.rng_mut())?; + let update_second = + UpdateGerNote::create(ger, ger_injector.id(), bridge_account.id(), builder.rng_mut())?; + + builder.add_output_note(RawOutputNote::Full(update_first.clone())); + builder.add_output_note(RawOutputNote::Full(remove_note.clone())); + builder.add_output_note(RawOutputNote::Full(update_second.clone())); + + let mut mock_chain = builder.build()?; + + let mut updated_bridge_account = bridge_account.clone(); + for note in [&update_first, &remove_note, &update_second] { + let tx_context = + mock_chain.build_tx_context(bridge_account.id(), &[note.id()], &[])?.build()?; + let executed = tx_context.execute().await?; + updated_bridge_account.apply_patch(executed.account_patch())?; + mock_chain.add_pending_executed_transaction(&executed)?; + mock_chain.prove_next_block()?; + } + + assert!( + AggLayerBridge::is_ger_registered(ger, &updated_bridge_account)?, + "GER should be registered again after re-insertion" + ); + + let expected_chain_bytes = fold_removed_ger_chain([0u8; 32], GER_BYTES); + let actual_chain = AggLayerBridge::removed_ger_hash_chain(&updated_bridge_account)?; + assert_eq!( + actual_chain.as_bytes(), + &expected_chain_bytes, + "re-insertion must not advance the removed-GER hash chain" + ); + + Ok(()) +} + +/// Tests that REMOVE_GER reverts when the note sender is not the GER remover. +#[tokio::test] +async fn remove_ger_non_remover_sender_reverts() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let (bridge_account, ger_injector, _ger_remover) = setup_bridge(&mut builder)?; + + // Register a GER first so the failure is exclusively due to the sender check. + let ger = ExitRoot::from(GER_BYTES); + let update_ger_note = + UpdateGerNote::create(ger, ger_injector.id(), bridge_account.id(), builder.rng_mut())?; + builder.add_output_note(RawOutputNote::Full(update_ger_note.clone())); + + // The GER injector (not the remover) attempts to send the REMOVE_GER note. + let remove_ger_note = + RemoveGerNote::create(ger, ger_injector.id(), bridge_account.id(), builder.rng_mut())?; + builder.add_output_note(RawOutputNote::Full(remove_ger_note.clone())); + + let mut mock_chain = builder.build()?; + + let update_tx_context = mock_chain + .build_tx_context(bridge_account.id(), &[update_ger_note.id()], &[])? + .build()?; + let update_executed = update_tx_context.execute().await?; + mock_chain.add_pending_executed_transaction(&update_executed)?; + mock_chain.prove_next_block()?; + + let result = mock_chain + .build_tx_context(bridge_account.id(), &[remove_ger_note.id()], &[])? + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_SENDER_NOT_GER_REMOVER); + + Ok(()) +} diff --git a/crates/miden-testing/tests/agglayer/update_ger.rs b/crates/miden-testing/tests/agglayer/update_ger.rs index 6e5921c32b..f0111e9513 100644 --- a/crates/miden-testing/tests/agglayer/update_ger.rs +++ b/crates/miden-testing/tests/agglayer/update_ger.rs @@ -4,6 +4,7 @@ use alloc::string::String; use alloc::sync::Arc; use alloc::vec::Vec; +use miden_agglayer::errors::ERR_GER_ALREADY_REGISTERED; use miden_agglayer::{ AggLayerBridge, ExitRoot, @@ -20,7 +21,7 @@ use miden_protocol::account::auth::AuthScheme; use miden_protocol::crypto::rand::FeltRng; use miden_protocol::transaction::RawOutputNote; use miden_protocol::utils::sync::LazyLock; -use miden_testing::{Auth, MockChain}; +use miden_testing::{Auth, MockChain, assert_transaction_executor_error}; use miden_tx::utils::hex_to_bytes; use serde::Deserialize; @@ -52,23 +53,33 @@ static EXIT_ROOTS_VECTORS: LazyLock = LazyLock::new(|| { async fn update_ger_note_updates_storage() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - // CREATE BRIDGE ADMIN ACCOUNT (not used in this test, but distinct from GER manager) + // CREATE BRIDGE ADMIN ACCOUNT (not used in this test, but distinct from GER injector) // -------------------------------------------------------------------------------------------- let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - // CREATE GER MANAGER ACCOUNT (note sender) + // CREATE GER INJECTOR ACCOUNT (note sender) // -------------------------------------------------------------------------------------------- - let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // CREATE GER REMOVER ACCOUNT (not used in this test, but distinct from admin and injector) + // -------------------------------------------------------------------------------------------- + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; // CREATE BRIDGE ACCOUNT // -------------------------------------------------------------------------------------------- let bridge_seed = builder.rng_mut().draw_word(); - let bridge_account = - create_existing_bridge_account(bridge_seed, bridge_admin.id(), ger_manager.id()); + let bridge_account = create_existing_bridge_account( + bridge_seed, + bridge_admin.id(), + ger_injector.id(), + ger_remover.id(), + ); builder.add_account(bridge_account.clone())?; // CREATE UPDATE_GER NOTE WITH 8 STORAGE ITEMS (NEW GER AS TWO WORDS) @@ -81,7 +92,7 @@ async fn update_ger_note_updates_storage() -> anyhow::Result<()> { ]; let ger = ExitRoot::from(ger_bytes); let update_ger_note = - UpdateGerNote::create(ger, ger_manager.id(), bridge_account.id(), builder.rng_mut())?; + UpdateGerNote::create(ger, ger_injector.id(), bridge_account.id(), builder.rng_mut())?; builder.add_output_note(RawOutputNote::Full(update_ger_note.clone())); let mock_chain = builder.build()?; @@ -96,7 +107,7 @@ async fn update_ger_note_updates_storage() -> anyhow::Result<()> { // VERIFY GER HASH WAS STORED IN MAP // -------------------------------------------------------------------------------------------- let mut updated_bridge_account = bridge_account.clone(); - updated_bridge_account.apply_delta(executed_transaction.account_delta())?; + updated_bridge_account.apply_patch(executed_transaction.account_patch())?; let is_registered = AggLayerBridge::is_ger_registered(ger, &updated_bridge_account)?; assert!(is_registered, "GER was not registered in the bridge account"); @@ -265,3 +276,71 @@ async fn test_compute_ger_basic() -> anyhow::Result<()> { Ok(()) } + +/// Tests that consuming a second UPDATE_GER note with the same GER is rejected. +#[tokio::test] +async fn update_ger_rejects_duplicate() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + // CREATE BRIDGE ADMIN ACCOUNT + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // CREATE GER INJECTOR ACCOUNT + let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // CREATE GER REMOVER ACCOUNT + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // CREATE BRIDGE ACCOUNT + let bridge_seed = builder.rng_mut().draw_word(); + let bridge_account = create_existing_bridge_account( + bridge_seed, + bridge_admin.id(), + ger_injector.id(), + ger_remover.id(), + ); + builder.add_account(bridge_account.clone())?; + + let ger_bytes: [u8; 32] = [ + 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, + 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, + 0x77, 0x88, + ]; + let ger = ExitRoot::from(ger_bytes); + + // CREATE TWO UPDATE_GER NOTES WITH THE SAME GER + let update_ger_note_1 = + UpdateGerNote::create(ger, ger_injector.id(), bridge_account.id(), builder.rng_mut())?; + builder.add_output_note(RawOutputNote::Full(update_ger_note_1.clone())); + + let update_ger_note_2 = + UpdateGerNote::create(ger, ger_injector.id(), bridge_account.id(), builder.rng_mut())?; + builder.add_output_note(RawOutputNote::Full(update_ger_note_2.clone())); + + let mut mock_chain = builder.build()?; + + // TX1: Consume first UPDATE_GER note (should succeed) + let tx_context_1 = mock_chain + .build_tx_context(bridge_account.id(), &[update_ger_note_1.id()], &[])? + .build()?; + let executed_tx_1 = tx_context_1.execute().await?; + mock_chain.add_pending_executed_transaction(&executed_tx_1)?; + mock_chain.prove_next_block()?; + + // TX2: Consume second UPDATE_GER note with same GER (should fail) + let result = mock_chain + .build_tx_context(bridge_account.id(), &[], &[update_ger_note_2])? + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_GER_ALREADY_REGISTERED); + + Ok(()) +} diff --git a/crates/miden-testing/tests/auth/guarded_multisig.rs b/crates/miden-testing/tests/auth/guarded_multisig.rs index 6c995b93d5..d791738372 100644 --- a/crates/miden-testing/tests/auth/guarded_multisig.rs +++ b/crates/miden-testing/tests/auth/guarded_multisig.rs @@ -1,5 +1,11 @@ use miden_protocol::account::auth::{AuthScheme, AuthSecretKey, PublicKey}; -use miden_protocol::account::{Account, AccountBuilder, AccountProcedureRoot, AccountType}; +use miden_protocol::account::{ + Account, + AccountBuilder, + AccountProcedureRoot, + AccountType, + StorageMapKey, +}; use miden_protocol::asset::FungibleAsset; use miden_protocol::note::{ Note, @@ -199,16 +205,18 @@ async fn test_guarded_multisig_signature_required( let mut mock_chain = mock_chain_builder.build().unwrap(); let salt = Word::from([Felt::new_unchecked(777); 4]); - let tx_context_init = mock_chain + let tx_context_builder = mock_chain .build_tx_context(multisig_account.id(), &[input_note.id()], &[])? - .extend_expected_output_notes(vec![RawOutputNote::Full(output_note.clone())]) - .auth_args(salt) - .build()?; + .extend_expected_output_notes(vec![RawOutputNote::Full(output_note)]) + .auth_args(salt); - let tx_summary = match tx_context_init.execute().await.unwrap_err() { - TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, - error => anyhow::bail!("expected abort with tx effects: {error}"), - }; + let tx_summary = tx_context_builder + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); let msg = tx_summary.as_ref().to_commitment(); let tx_summary_signing = SigningInputs::TransactionSummary(tx_summary); @@ -220,12 +228,10 @@ async fn test_guarded_multisig_signature_required( .await?; // Missing guardian signature must fail. - let without_guardian_result = mock_chain - .build_tx_context(multisig_account.id(), &[input_note.id()], &[])? - .extend_expected_output_notes(vec![RawOutputNote::Full(output_note.clone())]) + let without_guardian_result = tx_context_builder + .clone() .add_signature(public_keys[0].to_commitment(), msg, sig_1.clone()) .add_signature(public_keys[1].to_commitment(), msg, sig_2.clone()) - .auth_args(salt) .build()? .execute() .await; @@ -239,18 +245,15 @@ async fn test_guarded_multisig_signature_required( .await?; // With guardian signature the transaction should succeed. - let tx_context_execute = mock_chain - .build_tx_context(multisig_account.id(), &[input_note.id()], &[])? - .extend_expected_output_notes(vec![RawOutputNote::Full(output_note)]) + let tx_context_execute = tx_context_builder .add_signature(public_keys[0].to_commitment(), msg, sig_1) .add_signature(public_keys[1].to_commitment(), msg, sig_2) .add_signature(guardian_public_key.to_commitment(), msg, guardian_signature) - .auth_args(salt) .build()? .execute() .await?; - multisig_account.apply_delta(tx_context_execute.account_delta())?; + multisig_account.apply_patch(tx_context_execute.account_patch())?; mock_chain.add_pending_executed_transaction(&tx_context_execute)?; mock_chain.prove_next_block()?; @@ -312,16 +315,18 @@ async fn test_guarded_multisig_update_guardian_public_key( ))?; let update_salt = Word::from([Felt::new_unchecked(991); 4]); - let tx_context_init = mock_chain + let tx_context_builder = mock_chain .build_tx_context(multisig_account.id(), &[], &[])? - .tx_script(update_guardian_script.clone()) - .auth_args(update_salt) - .build()?; + .tx_script(update_guardian_script) + .auth_args(update_salt); - let tx_summary = match tx_context_init.execute().await.unwrap_err() { - TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, - error => anyhow::bail!("expected abort with tx effects: {error}"), - }; + let tx_summary = tx_context_builder + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); let update_msg = tx_summary.as_ref().to_commitment(); let tx_summary_signing = SigningInputs::TransactionSummary(tx_summary); @@ -333,25 +338,22 @@ async fn test_guarded_multisig_update_guardian_public_key( .await?; // Guardian key rotation intentionally skips guardian signature for this update tx. - let update_guardian_tx = mock_chain - .build_tx_context(multisig_account.id(), &[], &[])? - .tx_script(update_guardian_script) + let update_guardian_tx = tx_context_builder .add_signature(public_keys[0].to_commitment(), update_msg, sig_1) .add_signature(public_keys[1].to_commitment(), update_msg, sig_2) - .auth_args(update_salt) .build()? .execute() .await?; let mut updated_multisig_account = multisig_account.clone(); - updated_multisig_account.apply_delta(update_guardian_tx.account_delta())?; + updated_multisig_account.apply_patch(update_guardian_tx.account_patch())?; let updated_guardian_public_key = updated_multisig_account .storage() - .get_map_item(AuthGuardedMultisig::guardian_public_key_slot(), Word::empty())?; + .get_map_item(AuthGuardedMultisig::guardian_public_key_slot(), StorageMapKey::empty())?; assert_eq!(updated_guardian_public_key, Word::from(new_guardian_public_key.to_commitment())); let updated_guardian_scheme_id = updated_multisig_account.storage().get_map_item( AuthGuardedMultisig::guardian_scheme_id_slot(), - Word::from([0u32, 0, 0, 0]), + StorageMapKey::from_index(0), )?; assert_eq!( updated_guardian_scheme_id, @@ -364,15 +366,15 @@ async fn test_guarded_multisig_update_guardian_public_key( // Build one tx summary after key update. Old GUARDIAN must fail and new GUARDIAN must pass on // this same transaction. let next_salt = Word::from([Felt::new_unchecked(992); 4]); - let tx_context_init_next = mock_chain + let tx_context_builder_next = mock_chain .build_tx_context(updated_multisig_account.id(), &[], &[])? - .auth_args(next_salt) - .build()?; + .auth_args(next_salt); - let tx_summary_next = match tx_context_init_next.execute().await.unwrap_err() { - TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, - error => anyhow::bail!("expected abort with tx effects: {error}"), - }; + let tx_summary_next = + match tx_context_builder_next.clone().build()?.execute().await.unwrap_err() { + TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, + error => anyhow::bail!("expected abort with tx effects: {error}"), + }; let next_msg = tx_summary_next.as_ref().to_commitment(); let tx_summary_next_signing = SigningInputs::TransactionSummary(tx_summary_next); @@ -390,12 +392,11 @@ async fn test_guarded_multisig_update_guardian_public_key( .await?; // Old guardian signature must fail after key update. - let with_old_guardian_result = mock_chain - .build_tx_context(updated_multisig_account.id(), &[], &[])? + let with_old_guardian_result = tx_context_builder_next + .clone() .add_signature(public_keys[0].to_commitment(), next_msg, next_sig_1.clone()) .add_signature(public_keys[1].to_commitment(), next_msg, next_sig_2.clone()) .add_signature(old_guardian_public_key.to_commitment(), next_msg, old_guardian_sig_next) - .auth_args(next_salt) .build()? .execute() .await; @@ -405,12 +406,10 @@ async fn test_guarded_multisig_update_guardian_public_key( )); // New guardian signature must pass. - mock_chain - .build_tx_context(updated_multisig_account.id(), &[], &[])? + tx_context_builder_next .add_signature(public_keys[0].to_commitment(), next_msg, next_sig_1) .add_signature(public_keys[1].to_commitment(), next_msg, next_sig_2) .add_signature(new_guardian_public_key.to_commitment(), next_msg, new_guardian_sig_next) - .auth_args(next_salt) .build()? .execute() .await?; @@ -470,16 +469,18 @@ async fn test_guarded_multisig_update_guardian_public_key_must_be_called_alone( let mock_chain = mock_chain_builder.build().unwrap(); let salt = Word::from([Felt::new_unchecked(993); 4]); - let tx_context_init = mock_chain + let tx_context_builder = mock_chain .build_tx_context(multisig_account.id(), &[receive_asset_note.id()], &[])? - .tx_script(update_guardian_script.clone()) - .auth_args(salt) - .build()?; + .tx_script(update_guardian_script) + .auth_args(salt); - let tx_summary = match tx_context_init.execute().await.unwrap_err() { - TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, - error => anyhow::bail!("expected abort with tx effects: {error}"), - }; + let tx_summary = tx_context_builder + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); let msg = tx_summary.as_ref().to_commitment(); let tx_summary_signing = SigningInputs::TransactionSummary(tx_summary); @@ -490,12 +491,10 @@ async fn test_guarded_multisig_update_guardian_public_key_must_be_called_alone( .get_signature(public_keys[1].to_commitment(), &tx_summary_signing) .await?; - let without_guardian_result = mock_chain - .build_tx_context(multisig_account.id(), &[receive_asset_note.id()], &[])? - .tx_script(update_guardian_script.clone()) + let without_guardian_result = tx_context_builder + .clone() .add_signature(public_keys[0].to_commitment(), msg, sig_1.clone()) .add_signature(public_keys[1].to_commitment(), msg, sig_2.clone()) - .auth_args(salt) .build()? .execute() .await; @@ -508,13 +507,10 @@ async fn test_guarded_multisig_update_guardian_public_key_must_be_called_alone( .get_signature(old_guardian_public_key.to_commitment(), &tx_summary_signing) .await?; - let with_guardian_result = mock_chain - .build_tx_context(multisig_account.id(), &[receive_asset_note.id()], &[])? - .tx_script(update_guardian_script) + let with_guardian_result = tx_context_builder .add_signature(public_keys[0].to_commitment(), msg, sig_1) .add_signature(public_keys[1].to_commitment(), msg, sig_2) .add_signature(old_guardian_public_key.to_commitment(), msg, old_guardian_signature) - .auth_args(salt) .build()? .execute() .await; @@ -553,18 +549,20 @@ async fn test_guarded_multisig_update_guardian_public_key_must_be_called_alone( .unwrap(); let salt = Word::from([Felt::new_unchecked(994); 4]); - let tx_context_init = mock_chain + let tx_context_builder = mock_chain .build_tx_context(multisig_account.id(), &[], &[])? - .tx_script(update_guardian_with_output_script.clone()) - .add_note_script(note_script.clone()) - .extend_expected_output_notes(vec![RawOutputNote::Full(output_note.clone())]) - .auth_args(salt) - .build()?; - - let tx_summary = match tx_context_init.execute().await.unwrap_err() { - TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, - error => anyhow::bail!("expected abort with tx effects: {error}"), - }; + .tx_script(update_guardian_with_output_script) + .add_note_script(note_script) + .extend_expected_output_notes(vec![RawOutputNote::Full(output_note)]) + .auth_args(salt); + + let tx_summary = tx_context_builder + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); let msg = tx_summary.as_ref().to_commitment(); let tx_summary_signing = SigningInputs::TransactionSummary(tx_summary); @@ -575,14 +573,9 @@ async fn test_guarded_multisig_update_guardian_public_key_must_be_called_alone( .get_signature(public_keys[1].to_commitment(), &tx_summary_signing) .await?; - let result = mock_chain - .build_tx_context(multisig_account.id(), &[], &[])? - .tx_script(update_guardian_with_output_script) - .add_note_script(note_script) - .extend_expected_output_notes(vec![RawOutputNote::Full(output_note)]) + let result = tx_context_builder .add_signature(public_keys[0].to_commitment(), msg, sig_1) .add_signature(public_keys[1].to_commitment(), msg, sig_2) - .auth_args(salt) .build()? .execute() .await; @@ -690,17 +683,21 @@ async fn test_guarded_multisig_update_guardian_enforces_no_notes( let salt = Word::from([Felt::new_unchecked(995); 4]); // Dry-run to obtain the tx summary the signers must sign. - let mut init_ctx = mock_chain + let mut tx_context_builder = mock_chain .build_tx_context(multisig_account.id(), &input_ids, &[])? - .tx_script(update_guardian_script.clone()) + .tx_script(update_guardian_script) .auth_args(salt); - if let Some(ref out) = output_note { - init_ctx = init_ctx.extend_expected_output_notes(vec![RawOutputNote::Full(out.clone())]); + if let Some(out) = output_note { + tx_context_builder = + tx_context_builder.extend_expected_output_notes(vec![RawOutputNote::Full(out)]); } - let tx_summary = match init_ctx.build()?.execute().await.unwrap_err() { - TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, - error => anyhow::bail!("expected dry-run abort with tx effects: {error}"), - }; + let tx_summary = tx_context_builder + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); let msg = tx_summary.as_ref().to_commitment(); let signing = SigningInputs::TransactionSummary(tx_summary); @@ -714,18 +711,13 @@ async fn test_guarded_multisig_update_guardian_enforces_no_notes( .get_signature(old_guardian_public_key.to_commitment(), &signing) .await?; - let mut signed_ctx = mock_chain - .build_tx_context(multisig_account.id(), &input_ids, &[])? - .tx_script(update_guardian_script) - .auth_args(salt) + let result = tx_context_builder .add_signature(public_keys[0].to_commitment(), msg, sig_1) .add_signature(public_keys[1].to_commitment(), msg, sig_2) - .add_signature(old_guardian_public_key.to_commitment(), msg, guardian_sig); - if let Some(ref out) = output_note { - signed_ctx = - signed_ctx.extend_expected_output_notes(vec![RawOutputNote::Full(out.clone())]); - } - let result = signed_ctx.build()?.execute().await; + .add_signature(old_guardian_public_key.to_commitment(), msg, guardian_sig) + .build()? + .execute() + .await; // Input check fires first, output check fires only when no input notes are present. match (include_input_note, include_output_note) { diff --git a/crates/miden-testing/tests/auth/hybrid_multisig.rs b/crates/miden-testing/tests/auth/hybrid_multisig.rs index 222cf79dbb..995f2ae1f4 100644 --- a/crates/miden-testing/tests/auth/hybrid_multisig.rs +++ b/crates/miden-testing/tests/auth/hybrid_multisig.rs @@ -1,7 +1,13 @@ use miden_processor::advice::AdviceInputs; use miden_processor::crypto::random::RandomCoin; use miden_protocol::account::auth::{AuthScheme, AuthSecretKey, PublicKey}; -use miden_protocol::account::{Account, AccountBuilder, AccountProcedureRoot, AccountType}; +use miden_protocol::account::{ + Account, + AccountBuilder, + AccountProcedureRoot, + AccountType, + StorageMapKey, +}; use miden_protocol::asset::FungibleAsset; use miden_protocol::note::NoteType; use miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE; @@ -15,7 +21,6 @@ use miden_standards::note::P2idNote; use miden_standards::testing::account_interface::get_public_keys_from_account; use miden_testing::utils::create_spawn_note; use miden_testing::{Auth, MockChainBuilder}; -use miden_tx::TransactionExecutorError; use miden_tx::auth::{BasicAuthenticator, SigningInputs, TransactionAuthenticator}; use rand::SeedableRng; use rand_chacha::ChaCha20Rng; @@ -140,17 +145,20 @@ async fn test_multisig_2_of_2_with_note_creation() -> anyhow::Result<()> { let salt = Word::from([Felt::ONE; 4]); - // Execute transaction without signatures - should fail - let tx_context_init = mock_chain + // Build transaction context with all config + let tx_context_builder = mock_chain .build_tx_context(multisig_account.id(), &[input_note.id()], &[])? - .extend_expected_output_notes(vec![RawOutputNote::Full(output_note.clone())]) - .auth_args(salt) - .build()?; + .extend_expected_output_notes(vec![RawOutputNote::Full(output_note)]) + .auth_args(salt); - let tx_summary = match tx_context_init.execute().await.unwrap_err() { - TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, - error => anyhow::bail!("expected abort with tx effects: {error}"), - }; + // Execute transaction without signatures - should fail + let tx_summary = tx_context_builder + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); // Get signatures from both approvers let msg = tx_summary.as_ref().to_commitment(); @@ -164,17 +172,14 @@ async fn test_multisig_2_of_2_with_note_creation() -> anyhow::Result<()> { .await?; // Execute transaction with signatures - should succeed - let tx_context_execute = mock_chain - .build_tx_context(multisig_account.id(), &[input_note.id()], &[])? - .extend_expected_output_notes(vec![RawOutputNote::Full(output_note)]) + let tx_context_execute = tx_context_builder .add_signature(public_keys[0].to_commitment(), msg, sig_1) .add_signature(public_keys[1].to_commitment(), msg, sig_2) - .auth_args(salt) .build()? .execute() .await?; - multisig_account.apply_delta(tx_context_execute.account_delta())?; + multisig_account.apply_patch(tx_context_execute.account_patch())?; mock_chain.add_pending_executed_transaction(&tx_context_execute)?; mock_chain.prove_next_block()?; @@ -228,16 +233,18 @@ async fn test_multisig_2_of_4_all_signer_combinations() -> anyhow::Result<()> { for (i, (signer1_idx, signer2_idx)) in signer_combinations.iter().enumerate() { let salt = Word::from([Felt::new_unchecked(10 + i as u64); 4]); - // Execute transaction without signatures first to get tx summary - let tx_context_init = mock_chain - .build_tx_context(multisig_account.id(), &[], &[])? - .auth_args(salt) - .build()?; + // Build transaction context with all config + let tx_context_builder = + mock_chain.build_tx_context(multisig_account.id(), &[], &[])?.auth_args(salt); - let tx_summary = match tx_context_init.execute().await.unwrap_err() { - TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, - error => panic!("expected abort with tx effects: {error:?}"), - }; + // Execute transaction without signatures first to get tx summary + let tx_summary = tx_context_builder + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); // Get signatures from the specific combination of signers let msg = tx_summary.as_ref().to_commitment(); @@ -251,9 +258,7 @@ async fn test_multisig_2_of_4_all_signer_combinations() -> anyhow::Result<()> { .await?; // Execute transaction with signatures - should succeed for any combination - let tx_context_execute = mock_chain - .build_tx_context(multisig_account.id(), &[], &[])? - .auth_args(salt) + let tx_context_execute = tx_context_builder .add_signature(public_keys[*signer1_idx].to_commitment(), msg, sig_1) .add_signature(public_keys[*signer2_idx].to_commitment(), msg, sig_2) .build()?; @@ -361,27 +366,27 @@ async fn test_multisig_update_signers() -> anyhow::Result<()> { .with_dynamically_linked_library(AuthMultisig::code())? .compile_tx_script(tx_script_code)?; - let advice_inputs = AdviceInputs { - map: advice_map.clone(), - ..Default::default() - }; + let advice_inputs = AdviceInputs { map: advice_map, ..Default::default() }; // Pass the MULTISIG_CONFIG_HASH as the tx_script_args let tx_script_args: Word = multisig_config_hash; - // Execute transaction without signatures first to get tx summary - let tx_context_init = mock_chain + // Build transaction context with all config + let tx_context_builder = mock_chain .build_tx_context(multisig_account.id(), &[], &[])? - .tx_script(tx_script.clone()) + .tx_script(tx_script) .tx_script_args(tx_script_args) - .extend_advice_inputs(advice_inputs.clone()) - .auth_args(salt) - .build()?; + .extend_advice_inputs(advice_inputs) + .auth_args(salt); - let tx_summary = match tx_context_init.execute().await.unwrap_err() { - TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, - error => panic!("expected abort with tx effects: {error:?}"), - }; + // Execute transaction without signatures first to get tx summary + let tx_summary = tx_context_builder + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); // Get signatures from both approvers let msg = tx_summary.as_ref().to_commitment(); @@ -395,33 +400,30 @@ async fn test_multisig_update_signers() -> anyhow::Result<()> { .await?; // Execute transaction with signatures - should succeed - let update_approvers_tx = mock_chain - .build_tx_context(multisig_account.id(), &[], &[])? - .tx_script(tx_script) - .tx_script_args(multisig_config_hash) + let update_approvers_tx = tx_context_builder .add_signature(public_keys[0].to_commitment(), msg, sig_1) .add_signature(public_keys[1].to_commitment(), msg, sig_2) - .auth_args(salt) - .extend_advice_inputs(advice_inputs) .build()? .execute() .await .unwrap(); // Verify the transaction executed successfully - assert_eq!(update_approvers_tx.account_delta().nonce_delta(), Felt::ONE); + assert_eq!( + update_approvers_tx.account_patch().final_nonce(), + Some(multisig_account.nonce() + Felt::ONE) + ); mock_chain.add_pending_executed_transaction(&update_approvers_tx)?; mock_chain.prove_next_block()?; // Apply the delta to get the updated account with new signers let mut updated_multisig_account = multisig_account.clone(); - updated_multisig_account.apply_delta(update_approvers_tx.account_delta())?; + updated_multisig_account.apply_patch(update_approvers_tx.account_patch())?; // Verify that the public keys were actually updated in storage for (i, expected_key) in new_public_keys.iter().enumerate() { - let storage_key = - [Felt::new_unchecked(i as u64), Felt::ZERO, Felt::ZERO, Felt::ZERO].into(); + let storage_key = StorageMapKey::from_index(i as u32); let storage_item = updated_multisig_account .storage() .get_map_item(AuthMultisig::approver_public_keys_slot(), storage_key) @@ -507,17 +509,20 @@ async fn test_multisig_update_signers() -> anyhow::Result<()> { new_mock_chain_builder.add_output_note(RawOutputNote::Full(input_note_new.clone())); let new_mock_chain = new_mock_chain_builder.build().unwrap(); - // Execute transaction without signatures first to get tx summary - let tx_context_init_new = new_mock_chain + // Build transaction context with base config (output notes differ between init and execute) + let tx_context_builder_new = new_mock_chain .build_tx_context(updated_multisig_account.id(), &[input_note_new.id()], &[])? - .extend_expected_output_notes(vec![RawOutputNote::Full(output_note.clone())]) - .auth_args(salt_new) - .build()?; + .auth_args(salt_new); - let tx_summary_new = match tx_context_init_new.execute().await.unwrap_err() { - TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, - error => panic!("expected abort with tx effects: {error:?}"), - }; + // Execute transaction without signatures first to get tx summary + let tx_summary_new = tx_context_builder_new + .clone() + .extend_expected_output_notes(vec![RawOutputNote::Full(output_note.clone())]) + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); // Get signatures from 3 of the 4 new approvers (threshold is 3) let msg_new = tx_summary_new.as_ref().to_commitment(); @@ -537,19 +542,20 @@ async fn test_multisig_update_signers() -> anyhow::Result<()> { // ================================================================================ // Execute transaction with new signatures - should succeed - let tx_context_execute_new = new_mock_chain - .build_tx_context(updated_multisig_account.id(), &[input_note_new.id()], &[])? + let tx_context_execute_new = tx_context_builder_new .extend_expected_output_notes(vec![RawOutputNote::Full(output_note_new)]) .add_signature(new_public_keys[0].to_commitment(), msg_new, sig_1_new) .add_signature(new_public_keys[1].to_commitment(), msg_new, sig_2_new) .add_signature(new_public_keys[2].to_commitment(), msg_new, sig_3_new) - .auth_args(salt_new) .build()? .execute() .await?; // Verify the transaction executed successfully with new signers - assert_eq!(tx_context_execute_new.account_delta().nonce_delta(), Felt::ONE); + assert_eq!( + tx_context_execute_new.account_patch().final_nonce(), + Some(updated_multisig_account.nonce() + Felt::ONE) + ); Ok(()) } @@ -625,19 +631,22 @@ async fn test_multisig_update_signers_remove_owner() -> anyhow::Result<()> { let salt = Word::from([Felt::new_unchecked(3); 4]); - // Execute without signatures to get tx summary - let tx_context_init = mock_chain + // Build transaction context with all config + let tx_context_builder = mock_chain .build_tx_context(multisig_account.id(), &[], &[])? - .tx_script(tx_script.clone()) + .tx_script(tx_script) .tx_script_args(multisig_config_hash) - .extend_advice_inputs(advice_inputs.clone()) - .auth_args(salt) - .build()?; + .extend_advice_inputs(advice_inputs) + .auth_args(salt); - let tx_summary = match tx_context_init.execute().await.unwrap_err() { - TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, - error => panic!("expected abort with tx effects: {error:?}"), - }; + // Execute without signatures to get tx summary + let tx_summary = tx_context_builder + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); // Get signatures from 4 of the 5 original approvers (threshold is 4) let msg = tx_summary.as_ref().to_commitment(); @@ -657,35 +666,32 @@ async fn test_multisig_update_signers_remove_owner() -> anyhow::Result<()> { .await?; // Execute with signatures - let update_approvers_tx = mock_chain - .build_tx_context(multisig_account.id(), &[], &[])? - .tx_script(tx_script) - .tx_script_args(multisig_config_hash) + let update_approvers_tx = tx_context_builder .add_signature(public_keys[0].to_commitment(), msg, sig_1) .add_signature(public_keys[1].to_commitment(), msg, sig_2) .add_signature(public_keys[2].to_commitment(), msg, sig_3) .add_signature(public_keys[3].to_commitment(), msg, sig_4) - .auth_args(salt) - .extend_advice_inputs(advice_inputs) .build()? .execute() .await .unwrap(); // Verify transaction success - assert_eq!(update_approvers_tx.account_delta().nonce_delta(), Felt::ONE); + assert_eq!( + update_approvers_tx.account_patch().final_nonce(), + Some(multisig_account.nonce() + Felt::ONE) + ); mock_chain.add_pending_executed_transaction(&update_approvers_tx)?; mock_chain.prove_next_block()?; // Apply delta to get updated account let mut updated_multisig_account = multisig_account.clone(); - updated_multisig_account.apply_delta(update_approvers_tx.account_delta())?; + updated_multisig_account.apply_patch(update_approvers_tx.account_patch())?; // Verify public keys were updated for (i, expected_key) in new_public_keys.iter().enumerate() { - let storage_key = - [Felt::new_unchecked(i as u64), Felt::ZERO, Felt::ZERO, Felt::ZERO].into(); + let storage_key = StorageMapKey::from_index(i as u32); let storage_item = updated_multisig_account .storage() .get_map_item(AuthMultisig::approver_public_keys_slot(), storage_key) @@ -720,8 +726,7 @@ async fn test_multisig_update_signers_remove_owner() -> anyhow::Result<()> { // Verify removed owners' slots are empty (indices 2, 3, and 4 should be cleared) for removed_idx in 2..5 { - let removed_owner_key = - [Felt::new_unchecked(removed_idx), Felt::ZERO, Felt::ZERO, Felt::ZERO].into(); + let removed_owner_key = StorageMapKey::from_index(removed_idx as u32); let removed_owner_slot = updated_multisig_account .storage() .get_map_item(AuthMultisig::approver_public_keys_slot(), removed_owner_key) @@ -737,8 +742,7 @@ async fn test_multisig_update_signers_remove_owner() -> anyhow::Result<()> { // Verify only 2 non-empty keys remain (at indices 0 and 1) let mut non_empty_count = 0; for i in 0..5 { - let storage_key = - [Felt::new_unchecked(i as u64), Felt::ZERO, Felt::ZERO, Felt::ZERO].into(); + let storage_key = StorageMapKey::from_index(i as u32); let storage_item = updated_multisig_account .storage() .get_map_item(AuthMultisig::approver_public_keys_slot(), storage_key) @@ -846,27 +850,27 @@ async fn test_multisig_new_approvers_cannot_sign_before_update() -> anyhow::Resu .with_dynamically_linked_library(AuthMultisig::code())? .compile_tx_script(tx_script_code)?; - let advice_inputs = AdviceInputs { - map: advice_map.clone(), - ..Default::default() - }; + let advice_inputs = AdviceInputs { map: advice_map, ..Default::default() }; // Pass the MULTISIG_CONFIG_HASH as the tx_script_args let tx_script_args: Word = multisig_config_hash; - // Execute transaction without signatures first to get tx summary - let tx_context_init = mock_chain + // Build transaction context with all config + let tx_context_builder = mock_chain .build_tx_context(multisig_account.id(), &[], &[])? - .tx_script(tx_script.clone()) + .tx_script(tx_script) .tx_script_args(tx_script_args) - .extend_advice_inputs(advice_inputs.clone()) - .auth_args(salt) - .build()?; + .extend_advice_inputs(advice_inputs) + .auth_args(salt); - let tx_summary = match tx_context_init.execute().await.unwrap_err() { - TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, - error => panic!("expected abort with tx effects: {error:?}"), - }; + // Execute transaction without signatures first to get tx summary + let tx_summary = tx_context_builder + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); // SECTION 3: Try to sign the transaction with the NEW approvers (should fail) // ================================================================================ @@ -883,14 +887,9 @@ async fn test_multisig_new_approvers_cannot_sign_before_update() -> anyhow::Resu .await?; // Try to execute transaction with NEW signatures - should FAIL - let tx_context_with_new_sigs = mock_chain - .build_tx_context(multisig_account.id(), &[], &[])? - .tx_script(tx_script.clone()) - .tx_script_args(multisig_config_hash) + let tx_context_with_new_sigs = tx_context_builder .add_signature(new_public_keys[0].to_commitment(), msg, new_sig_1) .add_signature(new_public_keys[1].to_commitment(), msg, new_sig_2) - .auth_args(salt) - .extend_advice_inputs(advice_inputs.clone()) .build()?; // SECTION 4: Verify that only the CURRENT approvers can sign the update transaction diff --git a/crates/miden-testing/tests/auth/multisig.rs b/crates/miden-testing/tests/auth/multisig.rs index 629bd032dc..1ba4998bda 100644 --- a/crates/miden-testing/tests/auth/multisig.rs +++ b/crates/miden-testing/tests/auth/multisig.rs @@ -7,6 +7,7 @@ use miden_protocol::account::{ AccountId, AccountProcedureRoot, AccountType, + StorageMapKey, }; use miden_protocol::asset::{AssetCallbackFlag, AssetVaultKey, FungibleAsset}; use miden_protocol::note::NoteType; @@ -14,11 +15,10 @@ use miden_protocol::testing::account_id::{ ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE, }; -use miden_protocol::transaction::RawOutputNote; +use miden_protocol::transaction::{RawOutputNote, TransactionScript}; use miden_protocol::vm::AdviceMap; use miden_protocol::{Felt, Hasher, Word}; use miden_standards::account::auth::AuthMultisig; -use miden_standards::account::interface::{AccountInterface, AccountInterfaceExt}; use miden_standards::account::wallets::BasicWallet; use miden_standards::code_builder::CodeBuilder; use miden_standards::errors::standards::{ @@ -27,6 +27,7 @@ use miden_standards::errors::standards::{ }; use miden_standards::note::P2idNote; use miden_standards::testing::account_interface::get_public_keys_from_account; +use miden_standards::tx_script::SendNotesTransactionScript; use miden_testing::utils::create_spawn_note; use miden_testing::{Auth, MockChainBuilder, assert_transaction_executor_error}; use miden_tx::TransactionExecutorError; @@ -185,17 +186,20 @@ async fn test_multisig_2_of_2_with_note_creation( let salt = Word::from([Felt::ONE; 4]); - // Execute transaction without signatures - should fail - let tx_context_init = mock_chain + // Build base transaction context + let tx_context_builder = mock_chain .build_tx_context(multisig_account.id(), &[input_note.id()], &[])? - .extend_expected_output_notes(vec![RawOutputNote::Full(output_note.clone())]) - .auth_args(salt) - .build()?; + .extend_expected_output_notes(vec![RawOutputNote::Full(output_note)]) + .auth_args(salt); - let tx_summary = match tx_context_init.execute().await.unwrap_err() { - TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, - error => anyhow::bail!("expected abort with tx effects: {error}"), - }; + // Execute transaction without signatures - should fail + let tx_summary = tx_context_builder + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); // Get signatures from both approvers let msg = tx_summary.as_ref().to_commitment(); @@ -209,17 +213,14 @@ async fn test_multisig_2_of_2_with_note_creation( .await?; // Execute transaction with signatures - should succeed - let tx_context_execute = mock_chain - .build_tx_context(multisig_account.id(), &[input_note.id()], &[])? - .extend_expected_output_notes(vec![RawOutputNote::Full(output_note)]) + let tx_context_execute = tx_context_builder .add_signature(public_keys[0].to_commitment(), msg, sig_1) .add_signature(public_keys[1].to_commitment(), msg, sig_2) - .auth_args(salt) .build()? .execute() .await?; - multisig_account.apply_delta(tx_context_execute.account_delta())?; + multisig_account.apply_patch(tx_context_execute.account_patch())?; mock_chain.add_pending_executed_transaction(&tx_context_execute)?; mock_chain.prove_next_block()?; @@ -284,16 +285,18 @@ async fn test_multisig_2_of_4_all_signer_combinations( for (i, (signer1_idx, signer2_idx)) in signer_combinations.iter().enumerate() { let salt = Word::from([Felt::new_unchecked(10 + i as u64); 4]); - // Execute transaction without signatures first to get tx summary - let tx_context_init = mock_chain - .build_tx_context(multisig_account.id(), &[], &[])? - .auth_args(salt) - .build()?; + // Build base transaction context + let tx_context_builder = + mock_chain.build_tx_context(multisig_account.id(), &[], &[])?.auth_args(salt); - let tx_summary = match tx_context_init.execute().await.unwrap_err() { - TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, - error => anyhow::bail!("expected abort with tx effects: {error}"), - }; + // Execute transaction without signatures first to get tx summary + let tx_summary = tx_context_builder + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); // Get signatures from the specific combination of signers let msg = tx_summary.as_ref().to_commitment(); @@ -307,9 +310,7 @@ async fn test_multisig_2_of_4_all_signer_combinations( .await?; // Execute transaction with signatures - should succeed for any combination - let tx_context_execute = mock_chain - .build_tx_context(multisig_account.id(), &[], &[])? - .auth_args(salt) + let tx_context_execute = tx_context_builder .add_signature(public_keys[*signer1_idx].to_commitment(), msg, sig_1) .add_signature(public_keys[*signer2_idx].to_commitment(), msg, sig_2) .build()?; @@ -361,16 +362,18 @@ async fn test_multisig_replay_protection(#[case] auth_scheme: AuthScheme) -> any let salt = Word::from([Felt::new_unchecked(3); 4]); - // Execute transaction without signatures first to get tx summary - let tx_context_init = mock_chain - .build_tx_context(multisig_account.id(), &[], &[])? - .auth_args(salt) - .build()?; + // Build base transaction context + let tx_context_builder = + mock_chain.build_tx_context(multisig_account.id(), &[], &[])?.auth_args(salt); - let tx_summary = match tx_context_init.execute().await.unwrap_err() { - TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, - error => panic!("expected abort with tx effects: {error:?}"), - }; + // Execute transaction without signatures first to get tx summary + let tx_summary = tx_context_builder + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); // Get signatures from 2 of the 3 approvers let msg = tx_summary.as_ref().to_commitment(); @@ -384,11 +387,9 @@ async fn test_multisig_replay_protection(#[case] auth_scheme: AuthScheme) -> any .await?; // Execute transaction with signatures - should succeed (first execution) - let tx_context_execute = mock_chain - .build_tx_context(multisig_account.id(), &[], &[])? + let tx_context_execute = tx_context_builder .add_signature(public_keys[0].to_commitment(), msg, sig_1.clone()) .add_signature(public_keys[1].to_commitment(), msg, sig_2.clone()) - .auth_args(salt) .build()? .execute() .await?; @@ -398,6 +399,7 @@ async fn test_multisig_replay_protection(#[case] auth_scheme: AuthScheme) -> any mock_chain.prove_next_block()?; // Attempt to execute the same transaction again - should fail due to replay protection + // Must rebuild from the updated mock chain to pick up the new account state let tx_context_replay = mock_chain .build_tx_context(multisig_account.id(), &[], &[])? .add_signature(public_keys[0].to_commitment(), msg, sig_1) @@ -488,27 +490,27 @@ async fn test_multisig_update_signers(#[case] auth_scheme: AuthScheme) -> anyhow .with_dynamically_linked_library(AuthMultisig::code())? .compile_tx_script(tx_script_code)?; - let advice_inputs = AdviceInputs { - map: advice_map.clone(), - ..Default::default() - }; + let advice_inputs = AdviceInputs { map: advice_map, ..Default::default() }; // Pass the MULTISIG_CONFIG_HASH as the tx_script_args let tx_script_args: Word = multisig_config_hash; - // Execute transaction without signatures first to get tx summary - let tx_context_init = mock_chain + // Build base transaction context + let tx_context_builder = mock_chain .build_tx_context(multisig_account.id(), &[], &[])? - .tx_script(tx_script.clone()) + .tx_script(tx_script) .tx_script_args(tx_script_args) - .extend_advice_inputs(advice_inputs.clone()) - .auth_args(salt) - .build()?; + .extend_advice_inputs(advice_inputs) + .auth_args(salt); - let tx_summary = match tx_context_init.execute().await.unwrap_err() { - TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, - error => panic!("expected abort with tx effects: {error:?}"), - }; + // Execute transaction without signatures first to get tx summary + let tx_summary = tx_context_builder + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); // Get signatures from both approvers let msg = tx_summary.as_ref().to_commitment(); @@ -522,32 +524,29 @@ async fn test_multisig_update_signers(#[case] auth_scheme: AuthScheme) -> anyhow .await?; // Execute transaction with signatures - should succeed - let update_approvers_tx = mock_chain - .build_tx_context(multisig_account.id(), &[], &[])? - .tx_script(tx_script) - .tx_script_args(multisig_config_hash) + let update_approvers_tx = tx_context_builder .add_signature(public_keys[0].to_commitment(), msg, sig_1) .add_signature(public_keys[1].to_commitment(), msg, sig_2) - .auth_args(salt) - .extend_advice_inputs(advice_inputs) .build()? .execute() .await?; // Verify the transaction executed successfully - assert_eq!(update_approvers_tx.account_delta().nonce_delta(), Felt::ONE); + assert_eq!( + update_approvers_tx.account_patch().final_nonce(), + Some(multisig_account.nonce() + Felt::ONE) + ); mock_chain.add_pending_executed_transaction(&update_approvers_tx)?; mock_chain.prove_next_block()?; // Apply the delta to get the updated account with new signers let mut updated_multisig_account = multisig_account.clone(); - updated_multisig_account.apply_delta(update_approvers_tx.account_delta())?; + updated_multisig_account.apply_patch(update_approvers_tx.account_patch())?; // Verify that the public keys were actually updated in storage for (i, expected_key) in new_public_keys.iter().enumerate() { - let storage_key = - [Felt::new_unchecked(i as u64), Felt::ZERO, Felt::ZERO, Felt::ZERO].into(); + let storage_key = StorageMapKey::from_index(i as u32); let storage_item = updated_multisig_account .storage() .get_map_item(AuthMultisig::approver_public_keys_slot(), storage_key) @@ -633,17 +632,20 @@ async fn test_multisig_update_signers(#[case] auth_scheme: AuthScheme) -> anyhow new_mock_chain_builder.add_output_note(RawOutputNote::Full(input_note_new.clone())); let new_mock_chain = new_mock_chain_builder.build().unwrap(); - // Execute transaction without signatures first to get tx summary - let tx_context_init_new = new_mock_chain + // Build base transaction context for the new signers + let tx_context_builder_new = new_mock_chain .build_tx_context(updated_multisig_account.id(), &[input_note_new.id()], &[])? - .extend_expected_output_notes(vec![RawOutputNote::Full(output_note.clone())]) - .auth_args(salt_new) - .build()?; + .auth_args(salt_new); - let tx_summary_new = match tx_context_init_new.execute().await.unwrap_err() { - TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, - error => panic!("expected abort with tx effects: {error:?}"), - }; + // Execute transaction without signatures first to get tx summary + let tx_summary_new = tx_context_builder_new + .clone() + .extend_expected_output_notes(vec![RawOutputNote::Full(output_note.clone())]) + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); // Get signatures from 3 of the 4 new approvers (threshold is 3) let msg_new = tx_summary_new.as_ref().to_commitment(); @@ -663,19 +665,20 @@ async fn test_multisig_update_signers(#[case] auth_scheme: AuthScheme) -> anyhow // ================================================================================ // Execute transaction with new signatures - should succeed - let tx_context_execute_new = new_mock_chain - .build_tx_context(updated_multisig_account.id(), &[input_note_new.id()], &[])? + let tx_context_execute_new = tx_context_builder_new .extend_expected_output_notes(vec![RawOutputNote::Full(output_note_new)]) .add_signature(new_public_keys[0].to_commitment(), msg_new, sig_1_new) .add_signature(new_public_keys[1].to_commitment(), msg_new, sig_2_new) .add_signature(new_public_keys[2].to_commitment(), msg_new, sig_3_new) - .auth_args(salt_new) .build()? .execute() .await?; // Verify the transaction executed successfully with new signers - assert_eq!(tx_context_execute_new.account_delta().nonce_delta(), Felt::ONE); + assert_eq!( + tx_context_execute_new.account_patch().final_nonce(), + Some(updated_multisig_account.nonce() + Felt::ONE) + ); Ok(()) } @@ -739,19 +742,22 @@ async fn test_multisig_update_signers_remove_owner( let salt = Word::from([Felt::new_unchecked(3); 4]); - // Execute without signatures to get tx summary - let tx_context_init = mock_chain + // Build base transaction context + let tx_context_builder = mock_chain .build_tx_context(multisig_account.id(), &[], &[])? - .tx_script(tx_script.clone()) + .tx_script(tx_script) .tx_script_args(multisig_config_hash) - .extend_advice_inputs(advice_inputs.clone()) - .auth_args(salt) - .build()?; + .extend_advice_inputs(advice_inputs) + .auth_args(salt); - let tx_summary = match tx_context_init.execute().await.unwrap_err() { - TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, - error => panic!("expected abort with tx effects: {error:?}"), - }; + // Execute without signatures to get tx summary + let tx_summary = tx_context_builder + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); // Get signatures from 4 of the 5 original approvers (threshold is 4) let msg = tx_summary.as_ref().to_commitment(); @@ -771,34 +777,31 @@ async fn test_multisig_update_signers_remove_owner( .await?; // Execute with signatures - let update_approvers_tx = mock_chain - .build_tx_context(multisig_account.id(), &[], &[])? - .tx_script(tx_script) - .tx_script_args(multisig_config_hash) + let update_approvers_tx = tx_context_builder .add_signature(public_keys[0].to_commitment(), msg, sig_1) .add_signature(public_keys[1].to_commitment(), msg, sig_2) .add_signature(public_keys[2].to_commitment(), msg, sig_3) .add_signature(public_keys[3].to_commitment(), msg, sig_4) - .auth_args(salt) - .extend_advice_inputs(advice_inputs) .build()? .execute() .await?; // Verify transaction success - assert_eq!(update_approvers_tx.account_delta().nonce_delta(), Felt::ONE); + assert_eq!( + update_approvers_tx.account_patch().final_nonce(), + Some(multisig_account.nonce() + Felt::ONE) + ); mock_chain.add_pending_executed_transaction(&update_approvers_tx)?; mock_chain.prove_next_block()?; // Apply delta to get updated account let mut updated_multisig_account = multisig_account.clone(); - updated_multisig_account.apply_delta(update_approvers_tx.account_delta())?; + updated_multisig_account.apply_patch(update_approvers_tx.account_patch())?; // Verify public keys were updated for (i, expected_key) in new_public_keys.iter().enumerate() { - let storage_key = - [Felt::new_unchecked(i as u64), Felt::ZERO, Felt::ZERO, Felt::ZERO].into(); + let storage_key = StorageMapKey::from_index(i as u32); let storage_item = updated_multisig_account .storage() .get_map_item(AuthMultisig::approver_public_keys_slot(), storage_key) @@ -833,8 +836,7 @@ async fn test_multisig_update_signers_remove_owner( // Verify removed owners' slots are empty (indices 2, 3, and 4 should be cleared) for removed_idx in 2..5 { - let removed_owner_key = - [Felt::new_unchecked(removed_idx), Felt::ZERO, Felt::ZERO, Felt::ZERO].into(); + let removed_owner_key = StorageMapKey::from_index(removed_idx as u32); let removed_owner_slot = updated_multisig_account .storage() .get_map_item(AuthMultisig::approver_public_keys_slot(), removed_owner_key) @@ -850,8 +852,7 @@ async fn test_multisig_update_signers_remove_owner( // Verify only 2 non-empty keys remain (at indices 0 and 1) let mut non_empty_count = 0; for i in 0..5 { - let storage_key = - [Felt::new_unchecked(i as u64), Felt::ZERO, Felt::ZERO, Felt::ZERO].into(); + let storage_key = StorageMapKey::from_index(i as u32); let storage_item = updated_multisig_account .storage() .get_map_item(AuthMultisig::approver_public_keys_slot(), storage_key) @@ -1009,27 +1010,27 @@ async fn test_multisig_new_approvers_cannot_sign_before_update( .with_dynamically_linked_library(AuthMultisig::code())? .compile_tx_script(tx_script_code)?; - let advice_inputs = AdviceInputs { - map: advice_map.clone(), - ..Default::default() - }; + let advice_inputs = AdviceInputs { map: advice_map, ..Default::default() }; // Pass the MULTISIG_CONFIG_HASH as the tx_script_args let tx_script_args: Word = multisig_config_hash; - // Execute transaction without signatures first to get tx summary - let tx_context_init = mock_chain + // Build base transaction context + let tx_context_builder = mock_chain .build_tx_context(multisig_account.id(), &[], &[])? - .tx_script(tx_script.clone()) + .tx_script(tx_script) .tx_script_args(tx_script_args) - .extend_advice_inputs(advice_inputs.clone()) - .auth_args(salt) - .build()?; + .extend_advice_inputs(advice_inputs) + .auth_args(salt); - let tx_summary = match tx_context_init.execute().await.unwrap_err() { - TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, - error => panic!("expected abort with tx effects: {error:?}"), - }; + // Execute transaction without signatures first to get tx summary + let tx_summary = tx_context_builder + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); // SECTION 3: Try to sign the transaction with the NEW approvers (should fail) // ================================================================================ @@ -1046,14 +1047,9 @@ async fn test_multisig_new_approvers_cannot_sign_before_update( .await?; // Try to execute transaction with NEW signatures - should FAIL - let tx_context_with_new_sigs = mock_chain - .build_tx_context(multisig_account.id(), &[], &[])? - .tx_script(tx_script.clone()) - .tx_script_args(multisig_config_hash) + let tx_context_with_new_sigs = tx_context_builder .add_signature(new_public_keys[0].to_commitment(), msg, new_sig_1) .add_signature(new_public_keys[1].to_commitment(), msg, new_sig_2) - .auth_args(salt) - .extend_advice_inputs(advice_inputs.clone()) .build()?; // SECTION 4: Verify that only the CURRENT approvers can sign the update transaction @@ -1119,15 +1115,18 @@ async fn test_multisig_proc_threshold_overrides( // 2. consume without signatures let salt = Word::from([Felt::ONE; 4]); - let tx_context = mock_chain + let tx_context_builder = mock_chain .build_tx_context(multisig_account.id(), &[note.id()], &[])? - .auth_args(salt) - .build()?; + .auth_args(salt); - let tx_summary = match tx_context.execute().await.unwrap_err() { - TransactionExecutorError::Unauthorized(tx_summary) => tx_summary, - error => panic!("expected abort with tx summary: {error:?}"), - }; + // consume without signatures + let tx_summary = tx_context_builder + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); // 3. get signature from one approver let msg = tx_summary.as_ref().to_commitment(); @@ -1137,10 +1136,8 @@ async fn test_multisig_proc_threshold_overrides( .await?; // 4. execute with signature - let tx_result = mock_chain - .build_tx_context(multisig_account.id(), &[note.id()], &[])? + let tx_result = tx_context_builder .add_signature(public_keys[0].to_commitment(), msg, sig) - .auth_args(salt) .build()? .execute() .await; @@ -1148,7 +1145,7 @@ async fn test_multisig_proc_threshold_overrides( assert!(tx_result.is_ok(), "Note consumption with 1 signature should succeed"); // Apply the transaction to the account - multisig_account.apply_delta(tx_result.as_ref().unwrap().account_delta())?; + multisig_account.apply_patch(tx_result.as_ref().unwrap().account_patch())?; mock_chain.add_pending_executed_transaction(&tx_result.unwrap())?; mock_chain.prove_next_block()?; @@ -1167,22 +1164,26 @@ async fn test_multisig_proc_threshold_overrides( Default::default(), &mut RandomCoin::new(Word::from([Felt::new_unchecked(42); 4])), )?; - let multisig_account_interface = AccountInterface::from_account(&multisig_account); - let send_note_transaction_script = - multisig_account_interface.build_send_notes_script(&[output_note.clone().into()], None)?; + let send_note_transaction_script = TransactionScript::from(SendNotesTransactionScript::new( + &multisig_account.code_interface(), + &[output_note.clone().into()], + )?); - // Execute transaction without signatures to get tx summary - let tx_context_init = mock_chain + // Build base transaction context for note sending + let tx_context_builder2 = mock_chain .build_tx_context(multisig_account.id(), &[], &[])? - .extend_expected_output_notes(vec![RawOutputNote::Full(output_note.clone())]) - .tx_script(send_note_transaction_script.clone()) - .auth_args(salt2) - .build()?; + .extend_expected_output_notes(vec![RawOutputNote::Full(output_note)]) + .tx_script(send_note_transaction_script) + .auth_args(salt2); - let tx_summary2 = match tx_context_init.execute().await.unwrap_err() { - TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, - error => panic!("expected abort with tx effects: {error:?}"), - }; + // Execute transaction without signatures to get tx summary + let tx_summary2 = tx_context_builder2 + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); // Get signature from only ONE approver let msg2 = tx_summary2.as_ref().to_commitment(); let tx_summary2_signing = SigningInputs::TransactionSummary(tx_summary2.clone()); @@ -1192,23 +1193,14 @@ async fn test_multisig_proc_threshold_overrides( .await?; // Try to execute with only 1 signature - should FAIL - let tx_context_one_sig = mock_chain - .build_tx_context(multisig_account.id(), &[], &[])? - .extend_expected_output_notes(vec![RawOutputNote::Full(output_note.clone())]) + let result = tx_context_builder2 + .clone() .add_signature(public_keys[0].to_commitment(), msg2, sig_1) - .tx_script(send_note_transaction_script.clone()) - .auth_args(salt2) - .build()?; - - let result = tx_context_one_sig.execute().await; - match result { - Err(TransactionExecutorError::Unauthorized(_)) => { - // Expected: transaction should fail with insufficient signatures - }, - _ => panic!( - "Transaction should fail with Unauthorized error when only 1 signature provided for note sending" - ), - } + .build()? + .execute() + .await; + // Expected: transaction should fail with insufficient signatures + result.unwrap_err().unwrap_unauthorized_err(); // Now get signatures from BOTH approvers let sig_1 = authenticators[0] @@ -1219,13 +1211,9 @@ async fn test_multisig_proc_threshold_overrides( .await?; // Execute with 2 signatures - should SUCCEED - let result = mock_chain - .build_tx_context(multisig_account.id(), &[], &[])? - .extend_expected_output_notes(vec![RawOutputNote::Full(output_note)]) + let result = tx_context_builder2 .add_signature(public_keys[0].to_commitment(), msg2, sig_1) .add_signature(public_keys[1].to_commitment(), msg2, sig_2) - .auth_args(salt2) - .tx_script(send_note_transaction_script) .build()? .execute() .await; @@ -1233,7 +1221,7 @@ async fn test_multisig_proc_threshold_overrides( assert!(result.is_ok(), "Transaction should succeed with 2 signatures for note sending"); // Apply the transaction to the account - multisig_account.apply_delta(result.as_ref().unwrap().account_delta())?; + multisig_account.apply_patch(result.as_ref().unwrap().account_patch())?; mock_chain.add_pending_executed_transaction(&result.unwrap())?; mock_chain.prove_next_block()?; @@ -1295,15 +1283,17 @@ async fn test_multisig_set_procedure_threshold( // 1) Set override to 1 (requires default 2 signatures). let set_salt = Word::from([Felt::new_unchecked(50); 4]); - let set_init = mock_chain + let set_builder = mock_chain .build_tx_context(multisig_account.id(), &[], &[])? - .tx_script(set_script.clone()) - .auth_args(set_salt) - .build()?; - let set_summary = match set_init.execute().await.unwrap_err() { - TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, - error => panic!("expected abort with tx effects: {error:?}"), - }; + .tx_script(set_script) + .auth_args(set_salt); + let set_summary = set_builder + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); let set_msg = set_summary.as_ref().to_commitment(); let set_summary = SigningInputs::TransactionSummary(set_summary); let set_sig_1 = authenticators[0] @@ -1313,46 +1303,43 @@ async fn test_multisig_set_procedure_threshold( .get_signature(public_keys[1].to_commitment(), &set_summary) .await?; - let set_tx = mock_chain - .build_tx_context(multisig_account.id(), &[], &[])? - .tx_script(set_script) + let set_tx = set_builder .add_signature(public_keys[0].to_commitment(), set_msg, set_sig_1) .add_signature(public_keys[1].to_commitment(), set_msg, set_sig_2) - .auth_args(set_salt) .build()? .execute() .await?; - multisig_account.apply_delta(set_tx.account_delta())?; + multisig_account.apply_patch(set_tx.account_patch())?; mock_chain.add_pending_executed_transaction(&set_tx)?; mock_chain.prove_next_block()?; // 2) Verify receive_asset can now execute with one signature. let one_sig_salt = Word::from([Felt::new_unchecked(51); 4]); - let one_sig_init = mock_chain + let one_sig_builder = mock_chain .build_tx_context(multisig_account.id(), &[one_sig_note.id()], &[])? - .auth_args(one_sig_salt) - .build()?; - let one_sig_summary = match one_sig_init.execute().await.unwrap_err() { - TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, - error => panic!("expected abort with tx effects: {error:?}"), - }; + .auth_args(one_sig_salt); + let one_sig_summary = one_sig_builder + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); let one_sig_msg = one_sig_summary.as_ref().to_commitment(); let one_sig_summary = SigningInputs::TransactionSummary(one_sig_summary); let one_sig = authenticators[0] .get_signature(public_keys[0].to_commitment(), &one_sig_summary) .await?; - let one_sig_tx = mock_chain - .build_tx_context(multisig_account.id(), &[one_sig_note.id()], &[])? + let one_sig_tx = one_sig_builder .add_signature(public_keys[0].to_commitment(), one_sig_msg, one_sig) - .auth_args(one_sig_salt) .build()? .execute() .await .expect("override=1 should allow receive_asset with one signature"); - multisig_account.apply_delta(one_sig_tx.account_delta())?; + multisig_account.apply_patch(one_sig_tx.account_patch())?; mock_chain.add_pending_executed_transaction(&one_sig_tx)?; mock_chain.prove_next_block()?; @@ -1373,15 +1360,17 @@ async fn test_multisig_set_procedure_threshold( .compile_tx_script(clear_script_code)?; let clear_salt = Word::from([Felt::new_unchecked(52); 4]); - let clear_init = mock_chain + let clear_builder = mock_chain .build_tx_context(multisig_account.id(), &[], &[])? - .tx_script(clear_script.clone()) - .auth_args(clear_salt) - .build()?; - let clear_summary = match clear_init.execute().await.unwrap_err() { - TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, - error => panic!("expected abort with tx effects: {error:?}"), - }; + .tx_script(clear_script) + .auth_args(clear_salt); + let clear_summary = clear_builder + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); let clear_msg = clear_summary.as_ref().to_commitment(); let clear_summary = SigningInputs::TransactionSummary(clear_summary); let clear_sig_1 = authenticators[0] @@ -1391,41 +1380,38 @@ async fn test_multisig_set_procedure_threshold( .get_signature(public_keys[1].to_commitment(), &clear_summary) .await?; - let clear_tx = mock_chain - .build_tx_context(multisig_account.id(), &[], &[])? - .tx_script(clear_script) + let clear_tx = clear_builder .add_signature(public_keys[0].to_commitment(), clear_msg, clear_sig_1) .add_signature(public_keys[1].to_commitment(), clear_msg, clear_sig_2) - .auth_args(clear_salt) .build()? .execute() .await?; - multisig_account.apply_delta(clear_tx.account_delta())?; + multisig_account.apply_patch(clear_tx.account_patch())?; mock_chain.add_pending_executed_transaction(&clear_tx)?; mock_chain.prove_next_block()?; // 4) After clear, one signature should no longer be sufficient for receive_asset. let clear_check_salt = Word::from([Felt::new_unchecked(53); 4]); - let clear_check_init = mock_chain + let clear_check_builder = mock_chain .build_tx_context(multisig_account.id(), &[clear_check_note.id()], &[])? - .auth_args(clear_check_salt) - .build()?; - let clear_check_summary = match clear_check_init.execute().await.unwrap_err() { - TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, - error => panic!("expected abort with tx effects: {error:?}"), - }; + .auth_args(clear_check_salt); + let clear_check_summary = clear_check_builder + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); let clear_check_msg = clear_check_summary.as_ref().to_commitment(); let clear_check_summary = SigningInputs::TransactionSummary(clear_check_summary); let clear_check_sig = authenticators[0] .get_signature(public_keys[0].to_commitment(), &clear_check_summary) .await?; - let clear_check_result = mock_chain - .build_tx_context(multisig_account.id(), &[clear_check_note.id()], &[])? + let clear_check_result = clear_check_builder .add_signature(public_keys[0].to_commitment(), clear_check_msg, clear_check_sig) - .auth_args(clear_check_salt) .build()? .execute() .await; diff --git a/crates/miden-testing/tests/auth/multisig_smart.rs b/crates/miden-testing/tests/auth/multisig_smart.rs index dbafddee0d..a963f53fda 100644 --- a/crates/miden-testing/tests/auth/multisig_smart.rs +++ b/crates/miden-testing/tests/auth/multisig_smart.rs @@ -1,7 +1,7 @@ use miden_processor::advice::AdviceInputs; use miden_protocol::account::auth::{AuthScheme, PublicKey}; -use miden_protocol::account::{Account, AccountBuilder, AccountId, AccountType}; -use miden_protocol::asset::FungibleAsset; +use miden_protocol::account::{Account, AccountBuilder, AccountId, AccountType, StorageMapKey}; +use miden_protocol::asset::{AssetCallbackFlag, FungibleAsset}; use miden_protocol::note::NoteType; use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET; use miden_protocol::transaction::TransactionScript; @@ -19,7 +19,6 @@ use miden_standards::errors::standards::{ ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_OUTPUT_NOTES, }; use miden_testing::{MockChainBuilder, assert_transaction_executor_error}; -use miden_tx::TransactionExecutorError; use miden_tx::auth::{SigningInputs, TransactionAuthenticator}; use rstest::rstest; @@ -49,6 +48,7 @@ fn create_multisig_smart_account( let asset = FungibleAsset::new( AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?, starting_balance, + AssetCallbackFlag::Disabled, )?; let multisig_account = AccountBuilder::new([0; 32]) @@ -103,17 +103,17 @@ async fn test_multisig_smart_receive_asset_policy_overrides_default_three_of_thr let mut mock_chain = mock_chain_builder.build()?; let salt = Word::from([Felt::new_unchecked(1); 4]); - let tx_summary = match mock_chain + let tx_context_builder = mock_chain .build_tx_context(multisig_account.id(), &[note.id()], &[])? - .auth_args(salt) + .auth_args(salt); + + let tx_summary = tx_context_builder + .clone() .build()? .execute() .await .unwrap_err() - { - TransactionExecutorError::Unauthorized(tx_summary) => tx_summary, - error => panic!("expected abort with tx summary: {error:?}"), - }; + .unwrap_unauthorized_err(); let msg = tx_summary.as_ref().to_commitment(); let tx_summary_signing = SigningInputs::TransactionSummary(tx_summary); @@ -121,10 +121,8 @@ async fn test_multisig_smart_receive_asset_policy_overrides_default_three_of_thr .get_signature(public_keys[0].to_commitment(), &tx_summary_signing) .await?; - let tx_result = mock_chain - .build_tx_context(multisig_account.id(), &[note.id()], &[])? + let tx_result = tx_context_builder .add_signature(public_keys[0].to_commitment(), msg, one_signature) - .auth_args(salt) .build()? .execute() .await; @@ -134,7 +132,7 @@ async fn test_multisig_smart_receive_asset_policy_overrides_default_three_of_thr "receive_asset policy threshold=1 should override the default 3-of-3 requirement" ); - multisig_account.apply_delta(tx_result.as_ref().unwrap().account_delta())?; + multisig_account.apply_patch(tx_result.as_ref().unwrap().account_patch())?; mock_chain.add_pending_executed_transaction(&tx_result.unwrap())?; mock_chain.prove_next_block()?; @@ -199,10 +197,7 @@ async fn test_multisig_smart_enforces_note_restrictions_on_tx_with_input_notes( ); }, ProcedurePolicyNoteRestriction::None | ProcedurePolicyNoteRestriction::NoOutputNotes => { - match result { - Err(TransactionExecutorError::Unauthorized(_)) => {}, - other => panic!("expected Unauthorized (no signatures provided), got: {other:?}"), - } + result.unwrap_err().unwrap_unauthorized_err(); }, } @@ -223,9 +218,9 @@ async fn test_multisig_smart_enforces_note_restrictions_on_tx_with_output_notes( ) -> anyhow::Result<()> { use miden_processor::crypto::random::RandomCoin; use miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE; - use miden_protocol::transaction::RawOutputNote; - use miden_standards::account::interface::{AccountInterface, AccountInterfaceExt}; + use miden_protocol::transaction::{RawOutputNote, TransactionScript}; use miden_standards::note::P2idNote; + use miden_standards::tx_script::SendNotesTransactionScript; let (_secret_keys, _auth_schemes, public_keys, _authenticators) = setup_keys_and_authenticators_with_scheme(2, 2, AuthScheme::EcdsaK256Keccak)?; @@ -250,8 +245,10 @@ async fn test_multisig_smart_enforces_note_restrictions_on_tx_with_output_notes( &mut RandomCoin::new(Word::from([Felt::new_unchecked(7); 4])), )?; - let send_note_script = AccountInterface::from_account(&multisig_account) - .build_send_notes_script(&[output_note.clone().into()], None)?; + let send_note_script = TransactionScript::from(SendNotesTransactionScript::new( + &multisig_account.code_interface(), + &[output_note.clone().into()], + )?); let mock_chain = MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; @@ -277,10 +274,7 @@ async fn test_multisig_smart_enforces_note_restrictions_on_tx_with_output_notes( ); }, ProcedurePolicyNoteRestriction::None | ProcedurePolicyNoteRestriction::NoInputNotes => { - match result { - Err(TransactionExecutorError::Unauthorized(_)) => {}, - other => panic!("expected Unauthorized (no signatures provided), got: {other:?}"), - } + result.unwrap_err().unwrap_unauthorized_err(); }, } @@ -333,21 +327,21 @@ async fn test_multisig_smart_update_signers_and_thresholds( let salt = Word::from([Felt::new_unchecked(3); 4]); - // Dry-run to obtain the tx summary that the current approvers must sign. - let tx_summary = match mock_chain + let tx_context_builder = mock_chain .build_tx_context(account_id, &[], &[])? - .tx_script(update_signers_script.clone()) + .tx_script(update_signers_script) .tx_script_args(multisig_config_hash) - .extend_advice_inputs(advice_inputs.clone()) - .auth_args(salt) + .extend_advice_inputs(advice_inputs) + .auth_args(salt); + + // Dry-run to obtain the tx summary that the current approvers must sign. + let tx_summary = tx_context_builder + .clone() .build()? .execute() .await .unwrap_err() - { - TransactionExecutorError::Unauthorized(tx_summary) => tx_summary, - error => panic!("expected abort with tx summary: {error:?}"), - }; + .unwrap_unauthorized_err(); let msg = tx_summary.as_ref().to_commitment(); let signing_inputs = SigningInputs::TransactionSummary(tx_summary); @@ -358,19 +352,14 @@ async fn test_multisig_smart_update_signers_and_thresholds( .get_signature(public_keys[1].to_commitment(), &signing_inputs) .await?; - let executed_tx = mock_chain - .build_tx_context(account_id, &[], &[])? - .tx_script(update_signers_script) - .tx_script_args(multisig_config_hash) - .extend_advice_inputs(advice_inputs) - .auth_args(salt) + let executed_tx = tx_context_builder .add_signature(public_keys[0].to_commitment(), msg, sig_0) .add_signature(public_keys[1].to_commitment(), msg, sig_1) .build()? .execute() .await?; - multisig_account.apply_delta(executed_tx.account_delta())?; + multisig_account.apply_patch(executed_tx.account_patch())?; // Verify the new threshold/num_approvers config is persisted. let threshold_config = multisig_account @@ -382,7 +371,7 @@ async fn test_multisig_smart_update_signers_and_thresholds( // Verify each new public key is stored at its expected map index. for (i, expected_key) in new_public_keys.iter().enumerate() { - let storage_key = Word::from([i as u32, 0, 0, 0]); + let storage_key = StorageMapKey::from_index(i as u32); let stored_pub_key = multisig_account .storage() .get_map_item(AuthMultisigSmart::approver_public_keys_slot(), storage_key) @@ -413,7 +402,7 @@ async fn test_multisig_smart_set_procedure_policy( let mock_chain = MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; - let receive_asset_root = BasicWallet::receive_asset_root().as_word(); + let receive_asset_root = StorageMapKey::from_raw(BasicWallet::receive_asset_root().as_word()); let immediate_threshold = 1u32; let delayed_threshold = 0u32; let note_restrictions = ProcedurePolicyNoteRestriction::NoInputNotes; @@ -439,19 +428,19 @@ async fn test_multisig_smart_set_procedure_policy( let salt = Word::from([Felt::new_unchecked(4); 4]); - // Dry-run to obtain the tx summary that the approvers must sign. - let tx_summary = match mock_chain + let tx_context_builder = mock_chain .build_tx_context(account_id, &[], &[])? - .tx_script(set_policy_script.clone()) - .auth_args(salt) + .tx_script(set_policy_script) + .auth_args(salt); + + // Dry-run to obtain the tx summary that the approvers must sign. + let tx_summary = tx_context_builder + .clone() .build()? .execute() .await .unwrap_err() - { - TransactionExecutorError::Unauthorized(tx_summary) => tx_summary, - error => panic!("expected abort with tx summary: {error:?}"), - }; + .unwrap_unauthorized_err(); let msg = tx_summary.as_ref().to_commitment(); let signing_inputs = SigningInputs::TransactionSummary(tx_summary); @@ -462,17 +451,14 @@ async fn test_multisig_smart_set_procedure_policy( .get_signature(public_keys[1].to_commitment(), &signing_inputs) .await?; - let executed_tx = mock_chain - .build_tx_context(account_id, &[], &[])? - .tx_script(set_policy_script) - .auth_args(salt) + let executed_tx = tx_context_builder .add_signature(public_keys[0].to_commitment(), msg, sig_0) .add_signature(public_keys[1].to_commitment(), msg, sig_1) .build()? .execute() .await?; - multisig_account.apply_delta(executed_tx.account_delta())?; + multisig_account.apply_patch(executed_tx.account_patch())?; // Policy word layout: [immediate, delayed, note_restrictions, 0] let stored_policy = multisig_account @@ -546,19 +532,19 @@ async fn test_multisig_smart_unpolicied_proc_call_requires_default_threshold() - let salt = Word::from([Felt::new_unchecked(42); 4]); - // Dry-run to capture the tx summary. - let tx_summary = match mock_chain + let tx_context_builder = mock_chain .build_tx_context(multisig_account.id(), &[note.id()], &[])? - .tx_script(set_policy_script.clone()) - .auth_args(salt) + .tx_script(set_policy_script) + .auth_args(salt); + + // Dry-run to capture the tx summary. + let tx_summary = tx_context_builder + .clone() .build()? .execute() .await .unwrap_err() - { - TransactionExecutorError::Unauthorized(tx_summary) => tx_summary, - error => panic!("expected dry-run abort with tx summary: {error:?}"), - }; + .unwrap_unauthorized_err(); let msg = tx_summary.as_ref().to_commitment(); let signing = SigningInputs::TransactionSummary(tx_summary); @@ -574,26 +560,16 @@ async fn test_multisig_smart_unpolicied_proc_call_requires_default_threshold() - // With only 1 signature (matching the low receive_asset policy), the tx must fail because // the unpolicied set_procedure_policy call contributes `default_threshold = 3`. - let one_sig_result = mock_chain - .build_tx_context(multisig_account.id(), &[note.id()], &[])? - .tx_script(set_policy_script.clone()) - .auth_args(salt) + let one_sig_result = tx_context_builder + .clone() .add_signature(public_keys[0].to_commitment(), msg, sig_0.clone()) .build()? .execute() .await; - match one_sig_result { - Err(TransactionExecutorError::Unauthorized(_)) => {}, - other => { - panic!("expected Unauthorized with 1 sig (escalation would let it pass): {other:?}") - }, - } + one_sig_result.unwrap_err().unwrap_unauthorized_err(); // With all 3 signatures the unpolicied default contribution is met and the tx succeeds. - let three_sig_result = mock_chain - .build_tx_context(multisig_account.id(), &[note.id()], &[])? - .tx_script(set_policy_script) - .auth_args(salt) + let three_sig_result = tx_context_builder .add_signature(public_keys[0].to_commitment(), msg, sig_0) .add_signature(public_keys[1].to_commitment(), msg, sig_1) .add_signature(public_keys[2].to_commitment(), msg, sig_2) diff --git a/crates/miden-testing/tests/auth/network_account.rs b/crates/miden-testing/tests/auth/network_account.rs index e8f0fa8919..1870d4f6ed 100644 --- a/crates/miden-testing/tests/auth/network_account.rs +++ b/crates/miden-testing/tests/auth/network_account.rs @@ -1,36 +1,50 @@ +use core::num::NonZeroU16; use core::slice; +use std::collections::BTreeSet; use miden_protocol::Word; use miden_protocol::account::{Account, AccountBuilder, AccountType}; -use miden_protocol::note::NoteScriptRoot; -use miden_protocol::transaction::RawOutputNote; +use miden_protocol::note::{Note, NoteScriptRoot}; +use miden_protocol::testing::account_id::ACCOUNT_ID_SENDER; +use miden_protocol::transaction::{RawOutputNote, TransactionScript, TransactionScriptRoot}; use miden_standards::account::auth::AuthNetworkAccount; use miden_standards::account::wallets::BasicWallet; use miden_standards::code_builder::CodeBuilder; use miden_standards::errors::standards::{ ERR_NOTE_SCRIPT_ALLOWLIST_NOTE_NOT_ALLOWED, - ERR_NOTE_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED, + ERR_TX_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED, }; use miden_standards::testing::note::NoteBuilder; +use miden_standards::tx_script::ExpirationTransactionScript; use miden_testing::{MockChain, assert_transaction_executor_error}; +use rstest::rstest; // HELPER FUNCTIONS // ================================================================================================ -/// A placeholder script root used when a test needs an [`AuthNetworkAccount`] account whose -/// allowlist contents are not material to the test logic (e.g. for bootstrap accounts that only -/// exist to seed a [`NoteBuilder`]). The constructor rejects empty allowlists, so tests must -/// supply at least one root. +/// A placeholder note script root used when a test needs an [`AuthNetworkAccount`] account whose +/// allowlist contents are not material to the test logic (e.g. an account that never consumes a +/// note). The constructor rejects empty allowlists, so tests must supply at least one root. fn placeholder_script_root() -> Word { NoteScriptRoot::from_array([1, 0, 0, 0]).into() } /// Builds a minimal account that uses the [`AuthNetworkAccount`] auth component with the provided -/// allowlist of input-note script roots. +/// allowlist of input-note script roots and an empty tx-script allowlist. fn build_allowlist_account(allowed_script_roots: Vec) -> anyhow::Result { - let auth_component = AuthNetworkAccount::with_allowlist( - allowed_script_roots.into_iter().map(NoteScriptRoot::from_raw).collect(), - )?; + build_account_with_allowlists(allowed_script_roots, Vec::new()) +} + +/// Builds a minimal account that uses the [`AuthNetworkAccount`] auth component with the provided +/// note-script and tx-script allowlists. +fn build_account_with_allowlists( + allowed_note_script_roots: Vec, + allowed_tx_script_roots: Vec, +) -> anyhow::Result { + let auth_component = AuthNetworkAccount::with_allowed_notes( + allowed_note_script_roots.into_iter().map(NoteScriptRoot::from_raw).collect(), + )? + .with_allowed_tx_scripts(allowed_tx_script_roots.into_iter().collect::>()); Ok(AccountBuilder::new([0; 32]) .with_auth_component(auth_component) @@ -39,14 +53,39 @@ fn build_allowlist_account(allowed_script_roots: Vec) -> anyhow::Result anyhow::Result { + Ok(NoteBuilder::new(ACCOUNT_ID_SENDER.try_into()?, &mut rand::rng()).build()?) +} + +/// Compiles a transaction script that sets the transaction expiration delta to `delta`. This is the +/// canonical kind of tx script a network account would allowlist (see protocol issue #3027). +fn expiration_tx_script(delta: u16) -> TransactionScript { + let code = format!( + " + use miden::protocol::tx + + begin + push.{delta} + exec.tx::update_expiration_block_delta + end + " + ); + + CodeBuilder::default() + .compile_tx_script(code) + .expect("expiration tx script should compile") +} + // TESTS // ================================================================================================ -/// A transaction that executes a tx script must be rejected by `AuthNetworkAccount`, even if the -/// allowlist and input notes are otherwise valid. +/// A transaction that executes a tx script whose root is not in the tx-script allowlist must be +/// rejected by `AuthNetworkAccount`. An empty tx-script allowlist rejects every tx script. #[tokio::test] async fn test_auth_network_account_rejects_tx_script() -> anyhow::Result<()> { - // Allowlist contents don't matter — the tx-script check rejects before any allowlist lookup. + // Empty tx-script allowlist => no tx script is permitted. let account = build_allowlist_account(vec![placeholder_script_root()])?; let mut builder = MockChain::builder(); @@ -62,42 +101,222 @@ async fn test_auth_network_account_rejects_tx_script() -> anyhow::Result<()> { .execute() .await; - assert_transaction_executor_error!(result, ERR_NOTE_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED); + assert_transaction_executor_error!(result, ERR_TX_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED); Ok(()) } -/// A transaction that consumes a mix of allowed and disallowed input notes must be rejected: the -/// allowlist check must fail as soon as any single consumed note is not in the allowlist, even if -/// the others are. +/// A transaction that executes a tx script whose root IS in the tx-script allowlist must succeed, +/// and the script's effect (setting the expiration delta) must be reflected in the transaction. +/// +/// The transaction also consumes an allowlisted note, both because a network transaction does so in +/// practice and because the kernel rejects a transaction that neither changes the account state nor +/// consumes a note. #[tokio::test] -async fn test_auth_network_account_rejects_when_any_note_disallowed() -> anyhow::Result<()> { - // Build a template note with the default code to learn the "allowed" script root. The - // bootstrap account never executes a transaction, so its allowlist contents don't matter. - let bootstrap_account = build_allowlist_account(vec![placeholder_script_root()])?; - let template_allowed = NoteBuilder::new(bootstrap_account.id(), &mut rand::rng()) - .build() - .expect("failed to build template allowed note"); - let allowed_root = template_allowed.script().root(); +async fn test_auth_network_account_accepts_allowlisted_tx_script() -> anyhow::Result<()> { + const DELTA: u16 = 10; + let tx_script = expiration_tx_script(DELTA); + + let mut builder = MockChain::builder(); + let note = build_input_note()?; + builder.add_output_note(RawOutputNote::Full(note.clone())); + + // Allowlist the note script root and the expiration tx script's root. + let account = + build_account_with_allowlists(vec![note.script().root().into()], vec![tx_script.root()])?; + builder.add_account(account.clone())?; + + let mock_chain = builder.build()?; + + let executed = mock_chain + .build_tx_context(account.id(), &[], slice::from_ref(¬e))? + .tx_script(tx_script) + .build()? + .execute() + .await?; + + // The expiration delta script set the expiration to reference_block + DELTA. + let reference_block = executed.block_header().block_num(); + assert_eq!( + executed.expiration_block_num(), + reference_block + u32::from(DELTA), + "the allowlisted expiration script should have set the expiration block number", + ); + + Ok(()) +} + +/// A transaction that runs no tx script must be allowed regardless of the tx-script allowlist +/// contents: the empty-root case short-circuits before any allowlist lookup. +#[tokio::test] +async fn test_auth_network_account_allows_no_tx_script_with_non_empty_allowlist() +-> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let note = build_input_note()?; + builder.add_output_note(RawOutputNote::Full(note.clone())); + + // Non-empty tx-script allowlist, but the transaction below runs no tx script at all. + let account = build_account_with_allowlists( + vec![note.script().root().into()], + vec![expiration_tx_script(10).root()], + )?; + builder.add_account(account.clone())?; + + let mock_chain = builder.build()?; + + mock_chain + .build_tx_context(account.id(), &[], slice::from_ref(¬e))? + .build()? + .execute() + .await?; + + Ok(()) +} + +/// A non-empty tx-script allowlist must still reject a tx script whose root is not in it. +#[tokio::test] +async fn test_auth_network_account_rejects_non_allowlisted_tx_script() -> anyhow::Result<()> { + // Allowlist the expiration script, then try to run a different (non-allowlisted) tx script. + let allowed_script = expiration_tx_script(10); + let account = build_account_with_allowlists( + vec![placeholder_script_root()], + vec![allowed_script.root()], + )?; + + let mut builder = MockChain::builder(); + builder.add_account(account.clone())?; + let mock_chain = builder.build()?; + + let other_script = CodeBuilder::default().compile_tx_script("begin nop end")?; + assert_ne!( + other_script.root(), + allowed_script.root(), + "the other script must differ from the allowlisted one", + ); - // Build the real account with only that one root in the allowlist. - let account = build_allowlist_account(vec![allowed_root.into()])?; + let result = mock_chain + .build_tx_context(account.id(), &[], &[])? + .tx_script(other_script) + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_TX_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED); + + Ok(()) +} + +/// Allowlisting several *distinct* tx-script roots must let a transaction run any one of them. Both +/// hardcoded expiration scripts (delta 10 and delta 30) are allowlisted, and running either one +/// end-to-end succeeds and produces the corresponding expiration block number. +#[rstest] +#[case(10)] +#[case(30)] +#[tokio::test] +async fn test_auth_network_account_accepts_any_of_multiple_allowlisted_roots( + #[case] delta: u16, +) -> anyhow::Result<()> { + let script_10 = expiration_tx_script(10); + let script_30 = expiration_tx_script(30); + let tx_script = expiration_tx_script(delta); let mut builder = MockChain::builder(); + let note = build_input_note()?; + builder.add_output_note(RawOutputNote::Full(note.clone())); + + // Allowlist the note root and both distinct expiration script roots. + let account = build_account_with_allowlists( + vec![note.script().root().into()], + vec![script_10.root(), script_30.root()], + )?; builder.add_account(account.clone())?; - // Allowed note: uses the default note code so its script root matches `allowed_root`. - let note_allowed = NoteBuilder::new(account.id(), &mut rand::rng()) - .build() - .expect("failed to build allowed input note"); + let mock_chain = builder.build()?; + + let executed = mock_chain + .build_tx_context(account.id(), &[], slice::from_ref(¬e))? + .tx_script(tx_script) + .build()? + .execute() + .await?; + assert_eq!( - note_allowed.script().root(), - allowed_root, - "default-code NoteBuilder should reproduce the allowed script root", + executed.expiration_block_num(), + executed.block_header().block_num() + u32::from(delta), + "running one of several allowlisted scripts should set the expiration to reference + delta", ); - // Disallowed note: distinct code → distinct script root → not in the allowlist. - let note_disallowed = NoteBuilder::new(account.id(), &mut rand::rng()) + Ok(()) +} + +/// An allowlisted tx script may read caller-supplied `TX_SCRIPT_ARGS`: the allowlist pins the +/// script's *code* (its root), not its arguments. Here a single expiration-delta script that takes +/// the delta from `TX_SCRIPT_ARGS` is allowlisted, and transactions supplying different arbitrary +/// deltas all run end-to-end and produce the corresponding expiration block number. +/// +/// This is the input-dependent pattern the type docs caution against in general; it is acceptable +/// for the expiration delta specifically because the delta only bounds the inclusion window of the +/// caller's own transaction (it cannot touch account nonce, state, or assets) and the kernel +/// hard-caps it at `0xFFFF` blocks. Network accounts that want this (e.g. for the ntx-builder) can +/// allowlist such a script knowingly. +#[rstest] +#[case(10)] +#[case(30)] +#[case(u16::MAX)] +#[tokio::test] +async fn test_auth_network_account_accepts_allowlisted_tx_script_with_caller_args( + #[case] delta: u16, +) -> anyhow::Result<()> { + // Use the canonical, args-driven expiration script from miden-standards - the same script the + // node allowlists - so this exercises the standardized root rather than a local copy. + let script = ExpirationTransactionScript::new( + NonZeroU16::new(delta).expect("rstest delta cases are non-zero"), + ); + + let mut builder = MockChain::builder(); + let note = build_input_note()?; + builder.add_output_note(RawOutputNote::Full(note.clone())); + + // Allowlist the note root and the single caller-parameterized expiration script. + let account = build_account_with_allowlists( + vec![note.script().root().into()], + vec![ExpirationTransactionScript::script_root()], + )?; + builder.add_account(account.clone())?; + + let mock_chain = builder.build()?; + + let executed = mock_chain + .build_tx_context(account.id(), &[], slice::from_ref(¬e))? + .tx_script(script.into()) + .tx_script_args(script.tx_script_args()) + .build()? + .execute() + .await?; + + assert_eq!( + executed.expiration_block_num(), + executed.block_header().block_num() + u32::from(delta), + "the caller-supplied expiration delta should be applied", + ); + + Ok(()) +} + +/// A transaction that consumes a mix of allowed and disallowed input notes must be rejected: the +/// allowlist check must fail as soon as any single consumed note is not in the allowlist, even if +/// the others are. +#[tokio::test] +async fn test_auth_network_account_rejects_when_any_note_disallowed() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + // Allowed note: uses the default note code, so its script root is the one we allowlist. + let note_allowed = build_input_note()?; + let account = build_allowlist_account(vec![note_allowed.script().root().into()])?; + builder.add_account(account.clone())?; + + // Disallowed note: distinct code => distinct script root => not in the allowlist. + let note_disallowed = NoteBuilder::new(ACCOUNT_ID_SENDER.try_into()?, &mut rand::rng()) .code( "\ @note_script @@ -106,11 +325,10 @@ async fn test_auth_network_account_rejects_when_any_note_disallowed() -> anyhow: end ", ) - .build() - .expect("failed to build disallowed input note"); + .build()?; assert_ne!( note_disallowed.script().root(), - allowed_root, + note_allowed.script().root(), "disallowed note must have a different script root than the allowed one", ); @@ -134,31 +352,11 @@ async fn test_auth_network_account_rejects_when_any_note_disallowed() -> anyhow: /// Consuming an input note whose script root is in the allowlist must succeed. #[tokio::test] async fn test_auth_network_account_accepts_allowed_note() -> anyhow::Result<()> { - // First build a template note so we know its script root, then use that root to configure the - // account's allowlist. The bootstrap account never executes a transaction, so its allowlist - // contents don't matter. - let bootstrap_account = build_allowlist_account(vec![placeholder_script_root()])?; - let template_note = NoteBuilder::new(bootstrap_account.id(), &mut rand::rng()) - .build() - .expect("failed to build template note"); - let allowed_root = template_note.script().root(); - - // Now build the real account with the allowlist containing that root. - let account = build_allowlist_account(vec![allowed_root.into()])?; - let mut builder = MockChain::builder(); - builder.add_account(account.clone())?; - // Build a note that uses the same code but is sent from the real account so its script root - // matches `allowed_root`. - let note = NoteBuilder::new(account.id(), &mut rand::rng()) - .build() - .expect("failed to build input note"); - assert_eq!( - note.script().root(), - allowed_root, - "NoteBuilder with default code should produce a fixed script root" - ); + let note = build_input_note()?; + let account = build_allowlist_account(vec![note.script().root().into()])?; + builder.add_account(account.clone())?; builder.add_output_note(RawOutputNote::Full(note.clone())); let mock_chain = builder.build()?; @@ -167,8 +365,7 @@ async fn test_auth_network_account_accepts_allowed_note() -> anyhow::Result<()> .build_tx_context(account.id(), &[], slice::from_ref(¬e))? .build()? .execute() - .await - .expect("consuming an allowed note should succeed"); + .await?; Ok(()) } diff --git a/crates/miden-testing/tests/auth/singlesig_acl.rs b/crates/miden-testing/tests/auth/singlesig_acl.rs index 0b4ab5106c..8d1822e149 100644 --- a/crates/miden-testing/tests/auth/singlesig_acl.rs +++ b/crates/miden-testing/tests/auth/singlesig_acl.rs @@ -2,6 +2,7 @@ use core::slice; use assert_matches::assert_matches; use miden_processor::ExecutionError; +use miden_protocol::Word; use miden_protocol::account::auth::{AuthScheme, AuthSecretKey}; use miden_protocol::account::{ Account, @@ -14,7 +15,6 @@ use miden_protocol::errors::MasmError; use miden_protocol::note::Note; use miden_protocol::testing::storage::MOCK_VALUE_SLOT0; use miden_protocol::transaction::RawOutputNote; -use miden_protocol::{Felt, Word}; use miden_standards::account::auth::AuthSingleSigAcl; use miden_standards::code_builder::CodeBuilder; use miden_standards::testing::account_component::MockAccountComponent; @@ -201,9 +201,9 @@ async fn test_acl(#[case] auth_scheme: AuthScheme) -> anyhow::Result<()> { .await .expect("no trigger, no auth should succeed"); assert_eq!( - executed.account_delta().nonce_delta(), - Felt::ZERO, - "no auth but should still trigger nonce increment" + executed.account_patch().final_nonce(), + None, + "no auth should not trigger nonce increment" ); Ok(()) @@ -246,9 +246,9 @@ async fn test_acl_with_allow_unauthorized_output_notes( .await .expect("no trigger, no auth should succeed"); assert_eq!( - executed.account_delta().nonce_delta(), - Felt::ZERO, - "no auth but should still trigger nonce increment" + executed.account_patch().final_nonce(), + None, + "no auth should not trigger nonce increment" ); Ok(()) diff --git a/crates/miden-testing/tests/lib.rs b/crates/miden-testing/tests/lib.rs index f87dfcb1fb..27e48071b1 100644 --- a/crates/miden-testing/tests/lib.rs +++ b/crates/miden-testing/tests/lib.rs @@ -9,6 +9,7 @@ use miden_protocol::Word; use miden_protocol::account::AccountId; use miden_protocol::asset::FungibleAsset; use miden_protocol::crypto::utils::Serializable; +use miden_protocol::errors::TransactionVerifierError; use miden_protocol::note::{ Note, NoteAssets, @@ -18,15 +19,10 @@ use miden_protocol::note::{ PartialNoteMetadata, }; use miden_protocol::testing::account_id::ACCOUNT_ID_SENDER; -use miden_protocol::transaction::{ExecutedTransaction, ProvenTransaction}; +use miden_protocol::transaction::{ExecutedTransaction, ProvenTransaction, TransactionVerifier}; use miden_protocol::utils::serde::Deserializable; use miden_standards::code_builder::CodeBuilder; -use miden_tx::{ - LocalTransactionProver, - ProvingOptions, - TransactionVerifier, - TransactionVerifierError, -}; +use miden_tx::{LocalTransactionProver, ProvingOptions}; // HELPER FUNCTIONS // ================================================================================================ diff --git a/crates/miden-testing/tests/scripts/allowlist.rs b/crates/miden-testing/tests/scripts/allowlist.rs index a87bfc142e..41e66a6869 100644 --- a/crates/miden-testing/tests/scripts/allowlist.rs +++ b/crates/miden-testing/tests/scripts/allowlist.rs @@ -16,14 +16,13 @@ use miden_protocol::asset::{Asset, AssetAmount, AssetCallbackFlag, FungibleAsset use miden_protocol::note::{Note, NoteTag, NoteType}; use miden_protocol::transaction::RawOutputNote; use miden_protocol::{Felt, Word}; -use miden_standards::account::access::{Authority, Ownable2Step}; +use miden_standards::account::access::{Authority, Ownable2Step, Pausable}; use miden_standards::account::faucets::{FungibleFaucet, TokenName}; use miden_standards::account::policies::{ AllowlistOwnerControlled, AllowlistStorage, - BurnPolicyConfig, - MintPolicyConfig, - PolicyRegistration, + BurnPolicy, + MintPolicy, TokenPolicyManager, TransferPolicy, }; @@ -45,7 +44,7 @@ fn dummy_owner() -> AccountId { AccountId::dummy([9; 15], AccountIdVersion::Version1, AccountType::Private) } -/// Builds a fungible faucet with [`TransferPolicy::Allowlist`] on both send and receive, +/// Builds a fungible faucet with [`TransferPolicy::with_basic_allowlist`] on both send and receive, /// plus the [`AllowlistOwnerControlled`] component (gated by `Ownable2Step::new(owner_id)`) /// so that the owner can invoke `allow_account` / `disallow_account` via owner-authored notes. /// @@ -80,18 +79,14 @@ fn add_faucet_with_owner_allowlist_transfer_initialized( .with_component(Ownable2Step::new(owner_id)) .with_component(Authority::OwnerControlled) .with_components( - TokenPolicyManager::new() - .with_mint_policy(MintPolicyConfig::AllowAll, PolicyRegistration::Active)? - .with_burn_policy(BurnPolicyConfig::AllowAll, PolicyRegistration::Active)? - .with_send_policy( - TransferPolicy::Allowlist { allow_list: allow_list.clone() }, - PolicyRegistration::Active, - )? - .with_receive_policy( - TransferPolicy::Allowlist { allow_list }, - PolicyRegistration::Active, - )?, + TokenPolicyManager::builder() + .active_mint_policy(MintPolicy::allow_all()) + .active_burn_policy(BurnPolicy::allow_all()) + .active_send_policy(TransferPolicy::with_basic_allowlist(allow_list.clone())) + .active_receive_policy(TransferPolicy::with_basic_allowlist(allow_list)) + .build(), ) + .with_component(Pausable::unpaused()) .with_component(AllowlistOwnerControlled); builder.add_account_from_builder( @@ -177,7 +172,7 @@ async fn allow_receive_asset_succeeds_when_account_pre_allowed() -> anyhow::Resu [target_account.id()], )?; - let asset = FungibleAsset::new(faucet.id(), 100)?.with_callbacks(AssetCallbackFlag::Enabled); + let asset = FungibleAsset::new(faucet.id(), 100, AssetCallbackFlag::Enabled)?; let note = builder.add_p2id_note( faucet.id(), target_account.id(), @@ -207,7 +202,7 @@ async fn allow_receive_asset_fails_when_recipient_not_allowed() -> anyhow::Resul let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; let faucet = add_faucet_with_owner_allowlist_transfer(&mut builder, owner_id)?; - let asset = FungibleAsset::new(faucet.id(), 100)?.with_callbacks(AssetCallbackFlag::Enabled); + let asset = FungibleAsset::new(faucet.id(), 100, AssetCallbackFlag::Enabled)?; let p2id_note = builder.add_p2id_note( faucet.id(), target_account.id(), @@ -237,7 +232,7 @@ async fn allow_then_receive_succeeds() -> anyhow::Result<()> { let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; let faucet = add_faucet_with_owner_allowlist_transfer(&mut builder, owner_id)?; - let asset = FungibleAsset::new(faucet.id(), 100)?.with_callbacks(AssetCallbackFlag::Enabled); + let asset = FungibleAsset::new(faucet.id(), 100, AssetCallbackFlag::Enabled)?; let p2id_note = builder.add_p2id_note( faucet.id(), target_account.id(), @@ -272,7 +267,7 @@ async fn allow_add_asset_to_note_fails_when_sender_not_allowed() -> anyhow::Resu let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; let faucet = add_faucet_with_owner_allowlist_transfer(&mut builder, owner_id)?; - let asset = FungibleAsset::new(faucet.id(), 100)?.with_callbacks(AssetCallbackFlag::Enabled); + let asset = FungibleAsset::new(faucet.id(), 100, AssetCallbackFlag::Enabled)?; let mock_chain = builder.build()?; @@ -328,8 +323,7 @@ async fn allow_then_disallow_blocks_subsequent_receive() -> anyhow::Result<()> { )?; let amount: u64 = 50; - let fungible_asset = - FungibleAsset::new(faucet.id(), amount)?.with_callbacks(AssetCallbackFlag::Enabled); + let fungible_asset = FungibleAsset::new(faucet.id(), amount, AssetCallbackFlag::Enabled)?; let p2id_note = builder.add_p2id_note( faucet.id(), target_account.id(), @@ -412,8 +406,7 @@ async fn allow_does_not_affect_other_accounts() -> anyhow::Result<()> { let faucet = add_faucet_with_owner_allowlist_transfer(&mut builder, owner_id)?; let amount: u64 = 25; - let fungible_asset = - FungibleAsset::new(faucet.id(), amount)?.with_callbacks(AssetCallbackFlag::Enabled); + let fungible_asset = FungibleAsset::new(faucet.id(), amount, AssetCallbackFlag::Enabled)?; let p2id_note = builder.add_p2id_note( faucet.id(), other_account.id(), @@ -445,8 +438,8 @@ async fn allow_does_not_affect_other_accounts() -> anyhow::Result<()> { } /// Verifies that `mint_and_send` works on a `BasicFungibleFaucet` whose `TokenPolicyManager` -/// installs the asset-callback slots (here via [`TransferPolicy::Allowlist`]) once the faucet -/// itself is allowlisted so it can satisfy the send policy when minting. +/// installs the asset-callback slots (here via [`TransferPolicy::with_basic_allowlist`]) once the +/// faucet itself is allowlisted so it can satisfy the send policy when minting. #[tokio::test] async fn mint_and_send_on_allowlist_basic_faucet() -> anyhow::Result<()> { let owner_id = dummy_owner(); diff --git a/crates/miden-testing/tests/scripts/authority.rs b/crates/miden-testing/tests/scripts/authority.rs new file mode 100644 index 0000000000..fd5dbad8e5 --- /dev/null +++ b/crates/miden-testing/tests/scripts/authority.rs @@ -0,0 +1,303 @@ +//! Tests for the `Authority` global emergency switch (`freeze` / `unfreeze`). + +use std::collections::BTreeMap; + +use miden_protocol::account::{ + Account, + AccountBuilder, + AccountId, + AccountProcedureRoot, + AccountType, + RoleSymbol, +}; +use miden_protocol::asset::AssetAmount; +use miden_protocol::note::Note; +use miden_protocol::transaction::RawOutputNote; +use miden_standards::account::access::pausable::{Pausable, PausableManager}; +use miden_standards::account::access::{AccessControl, Authority}; +use miden_standards::account::faucets::{FungibleFaucet, TokenName}; +use miden_standards::errors::standards::{ERR_AUTHORITY_FROZEN, ERR_SENDER_NOT_OWNER}; +use miden_testing::{ + AccountState, + Auth, + MockChain, + MockChainBuilder, + assert_transaction_executor_error, +}; + +use super::pausable::{ + NON_OWNER_ID, + OWNER_ID, + build_grant_role_note, + build_note, + build_pause_note, + build_set_max_supply_note, + execute_note_on_faucet, + role, + test_account_id, +}; + +// FAUCET BUILDERS +// ================================================================================================ + +/// Builds a fungible faucet with `Pausable + PausableManager + Ownable2Step(owner)`, with a +/// mutable `max_supply` so the metadata setter path can be exercised too. +fn add_owner_faucet( + builder: &mut MockChainBuilder, + owner: AccountId, + seed: u8, +) -> anyhow::Result { + let faucet = FungibleFaucet::builder() + .name(TokenName::new("SYM")?) + .symbol("SYM".try_into()?) + .decimals(8) + .max_supply(AssetAmount::new(1_000_000)?) + .is_max_supply_mutable(true) + .build()?; + + let account_builder = AccountBuilder::new([seed; 32]) + .account_type(AccountType::Public) + .with_component(faucet) + .with_components(AccessControl::Ownable2Step { owner }) + .with_component(Pausable::unpaused()) + .with_component(PausableManager); + + builder.add_account_from_builder(Auth::IncrNonce, account_builder, AccountState::Exists) +} + +/// Builds an RBAC faucet whose `pause` is gated by the `PAUSER` role. +fn add_rbac_faucet( + builder: &mut MockChainBuilder, + owner: AccountId, + roles: BTreeMap, + seed: u8, +) -> anyhow::Result { + let faucet = FungibleFaucet::builder() + .name(TokenName::new("SYM")?) + .symbol("SYM".try_into()?) + .decimals(8) + .max_supply(AssetAmount::new(1_000_000)?) + .build()?; + + let account_builder = AccountBuilder::new([seed; 32]) + .account_type(AccountType::Public) + .with_component(faucet) + .with_components(AccessControl::Rbac { owner, roles }) + .with_component(Pausable::unpaused()) + .with_component(PausableManager); + + builder.add_account_from_builder(Auth::IncrNonce, account_builder, AccountState::Exists) +} + +// NOTE BUILDERS +// ================================================================================================ + +/// Builds a note that calls `authority::freeze`. +fn build_freeze_note(sender: AccountId) -> anyhow::Result { + build_note( + sender, + r#" + use miden::standards::access::authority + + @note_script + pub proc main + repeat.16 push.0 end + call.authority::freeze + dropw dropw dropw dropw + end + "#, + ) +} + +/// Builds a note that calls `authority::unfreeze`. +fn build_unfreeze_note(sender: AccountId) -> anyhow::Result { + build_note( + sender, + r#" + use miden::standards::access::authority + + @note_script + pub proc main + repeat.16 push.0 end + call.authority::unfreeze + dropw dropw dropw dropw + end + "#, + ) +} + +// HELPERS +// ================================================================================================ + +/// Returns whether the faucet's authority surface is currently frozen. +fn is_frozen(mock_chain: &MockChain, faucet_id: AccountId) -> anyhow::Result { + let account = mock_chain.committed_account(faucet_id)?; + Ok(Authority::try_read_frozen(account.storage())?) +} + +// TESTS — OWNER-CONTROLLED EMERGENCY SWITCH +// ================================================================================================ + +#[tokio::test] +async fn owner_freezes_then_gated_procedure_is_blocked() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let faucet = add_owner_faucet(&mut builder, *OWNER_ID, 60)?; + + let freeze_note = build_freeze_note(*OWNER_ID)?; + let pause_note = build_pause_note(*OWNER_ID)?; + builder.add_output_note(RawOutputNote::Full(freeze_note.clone())); + builder.add_output_note(RawOutputNote::Full(pause_note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + // Owner freezes the authority surface. + execute_note_on_faucet(&mut mock_chain, faucet.id(), &freeze_note).await?; + assert!(is_frozen(&mock_chain, faucet.id())?); + + // The owner's own pause call is now blocked because the surface is frozen. + let result = mock_chain + .build_tx_context(faucet.id(), &[pause_note.id()], &[])? + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_AUTHORITY_FROZEN); + + Ok(()) +} + +#[tokio::test] +async fn frozen_blocks_authority_gated_metadata_setter() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let faucet = add_owner_faucet(&mut builder, *OWNER_ID, 61)?; + + let freeze_note = build_freeze_note(*OWNER_ID)?; + let set_max_supply_note = build_set_max_supply_note(*OWNER_ID, 500_000)?; + builder.add_output_note(RawOutputNote::Full(freeze_note.clone())); + builder.add_output_note(RawOutputNote::Full(set_max_supply_note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + execute_note_on_faucet(&mut mock_chain, faucet.id(), &freeze_note).await?; + + let result = mock_chain + .build_tx_context(faucet.id(), &[set_max_supply_note.id()], &[])? + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_AUTHORITY_FROZEN); + + Ok(()) +} + +#[tokio::test] +async fn non_owner_cannot_freeze() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let faucet = add_owner_faucet(&mut builder, *OWNER_ID, 62)?; + + let attacker_freeze_note = build_freeze_note(*NON_OWNER_ID)?; + builder.add_output_note(RawOutputNote::Full(attacker_freeze_note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + let result = mock_chain + .build_tx_context(faucet.id(), &[attacker_freeze_note.id()], &[])? + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_SENDER_NOT_OWNER); + + Ok(()) +} + +#[tokio::test] +async fn owner_unfreezes_and_surface_works_again() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let faucet = add_owner_faucet(&mut builder, *OWNER_ID, 63)?; + + let freeze_note = build_freeze_note(*OWNER_ID)?; + let unfreeze_note = build_unfreeze_note(*OWNER_ID)?; + let pause_note = build_pause_note(*OWNER_ID)?; + builder.add_output_note(RawOutputNote::Full(freeze_note.clone())); + builder.add_output_note(RawOutputNote::Full(unfreeze_note.clone())); + builder.add_output_note(RawOutputNote::Full(pause_note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + // Freeze, then unfreeze. `unfreeze` bypasses the frozen flag, so the owner can never be + // locked out. + execute_note_on_faucet(&mut mock_chain, faucet.id(), &freeze_note).await?; + assert!(is_frozen(&mock_chain, faucet.id())?); + + execute_note_on_faucet(&mut mock_chain, faucet.id(), &unfreeze_note).await?; + assert!(!is_frozen(&mock_chain, faucet.id())?); + + // Gated procedures work again after unfreezing. + execute_note_on_faucet(&mut mock_chain, faucet.id(), &pause_note).await?; + + Ok(()) +} + +// TESTS — RBAC EMERGENCY SWITCH +// ================================================================================================ + +#[tokio::test] +async fn frozen_blocks_role_holder_and_freeze_is_owner_only() -> anyhow::Result<()> { + let pauser = test_account_id(20); + + let roles = BTreeMap::from([(PausableManager::pause_root(), role("PAUSER"))]); + + let mut builder = MockChain::builder(); + let faucet = add_rbac_faucet(&mut builder, *OWNER_ID, roles, 64)?; + + let grant_pauser = build_grant_role_note(*OWNER_ID, &role("PAUSER"), pauser)?; + let pause_note_before = build_pause_note(pauser)?; + let pauser_freeze_note = build_freeze_note(pauser)?; + let owner_freeze_note = build_freeze_note(*OWNER_ID)?; + let pause_note_after = build_pause_note(pauser)?; + for note in [ + &grant_pauser, + &pause_note_before, + &pauser_freeze_note, + &owner_freeze_note, + &pause_note_after, + ] { + builder.add_output_note(RawOutputNote::Full(note.clone())); + } + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + execute_note_on_faucet(&mut mock_chain, faucet.id(), &grant_pauser).await?; + + // The PAUSER can pause while the surface is unfrozen. + execute_note_on_faucet(&mut mock_chain, faucet.id(), &pause_note_before).await?; + + // A role holder is not the owner, so cannot operate the emergency switch. + let pauser_freeze_result = mock_chain + .build_tx_context(faucet.id(), &[pauser_freeze_note.id()], &[])? + .build()? + .execute() + .await; + assert_transaction_executor_error!(pauser_freeze_result, ERR_SENDER_NOT_OWNER); + + // The owner freezes the surface. + execute_note_on_faucet(&mut mock_chain, faucet.id(), &owner_freeze_note).await?; + assert!(is_frozen(&mock_chain, faucet.id())?); + + // Now even the PAUSER's role-authorized pause is blocked by the frozen flag. + let pause_after_result = mock_chain + .build_tx_context(faucet.id(), &[pause_note_after.id()], &[])? + .build()? + .execute() + .await; + assert_transaction_executor_error!(pause_after_result, ERR_AUTHORITY_FROZEN); + + Ok(()) +} diff --git a/crates/miden-testing/tests/scripts/blocklist.rs b/crates/miden-testing/tests/scripts/blocklist.rs index cc1236d7ab..b8e6a1ec56 100644 --- a/crates/miden-testing/tests/scripts/blocklist.rs +++ b/crates/miden-testing/tests/scripts/blocklist.rs @@ -16,14 +16,12 @@ use miden_protocol::asset::{Asset, AssetAmount, AssetCallbackFlag, FungibleAsset use miden_protocol::note::{Note, NoteTag, NoteType}; use miden_protocol::transaction::RawOutputNote; use miden_protocol::{Felt, Word}; -use miden_standards::account::access::{Authority, Ownable2Step}; +use miden_standards::account::access::{Authority, Ownable2Step, Pausable}; use miden_standards::account::faucets::{FungibleFaucet, TokenName}; use miden_standards::account::policies::{ - BasicBlocklist, BlocklistOwnerControlled, - BurnPolicyConfig, - MintPolicyConfig, - PolicyRegistration, + BurnPolicy, + MintPolicy, TokenPolicyManager, TransferPolicy, }; @@ -45,7 +43,7 @@ fn dummy_owner() -> AccountId { AccountId::dummy([9; 15], AccountIdVersion::Version1, AccountType::Private) } -/// Builds a fungible faucet with [`TransferPolicy::Blocklist`] on both send and receive, +/// Builds a fungible faucet with the basic blocklist transfer policy on both send and receive, /// plus the [`BlocklistOwnerControlled`] component (gated by `Ownable2Step::new(owner_id)`) /// so that the owner can invoke `block_account` / `unblock_account` via owner-authored notes. fn add_faucet_with_owner_blocklist_transfer( @@ -57,9 +55,10 @@ fn add_faucet_with_owner_blocklist_transfer( /// Same as [`add_faucet_with_owner_blocklist_transfer`] but seeds the `blocked_accounts` /// storage map with the given accounts at deploy time via -/// [`BasicBlocklist::with_blocked_accounts`]. The transfer policy is wired up through -/// [`TransferPolicy::Custom`] so the manager does not also install an empty `BasicBlocklist` -/// (which would conflict with the seeded one). +/// [`TransferPolicy::with_basic_blocklist`]. The receive policy reuses the same root via +/// [`TransferPolicy::empty_basic_blocklist`]; the manager dedups companion components by +/// procedure root, so the seeded `BasicBlocklist` from the send policy is installed exactly +/// once. fn add_faucet_with_owner_blocklist_transfer_initialized( builder: &mut MockChainBuilder, owner_id: AccountId, @@ -72,27 +71,20 @@ fn add_faucet_with_owner_blocklist_transfer_initialized( .max_supply(AssetAmount::new(1_000_000)?) .build()?; - let basic_blocklist = BasicBlocklist::with_blocked_accounts(initial_blocked); - let account_builder = AccountBuilder::new([43u8; 32]) .account_type(AccountType::Public) .with_component(faucet) .with_component(Ownable2Step::new(owner_id)) .with_component(Authority::OwnerControlled) .with_components( - TokenPolicyManager::new() - .with_mint_policy(MintPolicyConfig::AllowAll, PolicyRegistration::Active)? - .with_burn_policy(BurnPolicyConfig::AllowAll, PolicyRegistration::Active)? - .with_send_policy( - TransferPolicy::Custom(BasicBlocklist::root()), - PolicyRegistration::Active, - )? - .with_receive_policy( - TransferPolicy::Custom(BasicBlocklist::root()), - PolicyRegistration::Active, - )?, + TokenPolicyManager::builder() + .active_mint_policy(MintPolicy::allow_all()) + .active_burn_policy(BurnPolicy::allow_all()) + .active_send_policy(TransferPolicy::with_basic_blocklist(initial_blocked)) + .active_receive_policy(TransferPolicy::empty_basic_blocklist()) + .build(), ) - .with_component(basic_blocklist) + .with_component(Pausable::unpaused()) .with_component(BlocklistOwnerControlled); builder.add_account_from_builder( @@ -171,7 +163,7 @@ async fn block_receive_asset_succeeds_when_not_blocked() -> anyhow::Result<()> { let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; let faucet = add_faucet_with_owner_blocklist_transfer(&mut builder, owner_id)?; - let asset = FungibleAsset::new(faucet.id(), 100)?.with_callbacks(AssetCallbackFlag::Enabled); + let asset = FungibleAsset::new(faucet.id(), 100, AssetCallbackFlag::Enabled)?; let note = builder.add_p2id_note( faucet.id(), target_account.id(), @@ -208,7 +200,7 @@ async fn block_receive_asset_fails_when_account_pre_blocked() -> anyhow::Result< [target_account.id()], )?; - let asset = FungibleAsset::new(faucet.id(), 100)?.with_callbacks(AssetCallbackFlag::Enabled); + let asset = FungibleAsset::new(faucet.id(), 100, AssetCallbackFlag::Enabled)?; let p2id_note = builder.add_p2id_note( faucet.id(), target_account.id(), @@ -238,7 +230,7 @@ async fn block_receive_asset_fails_when_recipient_blocked() -> anyhow::Result<() let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; let faucet = add_faucet_with_owner_blocklist_transfer(&mut builder, owner_id)?; - let asset = FungibleAsset::new(faucet.id(), 100)?.with_callbacks(AssetCallbackFlag::Enabled); + let asset = FungibleAsset::new(faucet.id(), 100, AssetCallbackFlag::Enabled)?; let p2id_note = builder.add_p2id_note( faucet.id(), target_account.id(), @@ -275,7 +267,7 @@ async fn block_add_asset_to_note_fails_when_sender_blocked() -> anyhow::Result<( let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; let faucet = add_faucet_with_owner_blocklist_transfer(&mut builder, owner_id)?; - let asset = FungibleAsset::new(faucet.id(), 100)?.with_callbacks(AssetCallbackFlag::Enabled); + let asset = FungibleAsset::new(faucet.id(), 100, AssetCallbackFlag::Enabled)?; let block_note = build_owner_admin_note(owner_id, target_account.id(), "block_account", 2)?; builder.add_output_note(RawOutputNote::Full(block_note.clone())); @@ -333,8 +325,7 @@ async fn block_then_unblock_then_receive_succeeds() -> anyhow::Result<()> { let faucet = add_faucet_with_owner_blocklist_transfer(&mut builder, owner_id)?; let amount: u64 = 50; - let fungible_asset = - FungibleAsset::new(faucet.id(), amount)?.with_callbacks(AssetCallbackFlag::Enabled); + let fungible_asset = FungibleAsset::new(faucet.id(), amount, AssetCallbackFlag::Enabled)?; let p2id_note = builder.add_p2id_note( faucet.id(), target_account.id(), @@ -416,8 +407,7 @@ async fn block_does_not_affect_other_accounts() -> anyhow::Result<()> { let faucet = add_faucet_with_owner_blocklist_transfer(&mut builder, owner_id)?; let amount: u64 = 25; - let fungible_asset = - FungibleAsset::new(faucet.id(), amount)?.with_callbacks(AssetCallbackFlag::Enabled); + let fungible_asset = FungibleAsset::new(faucet.id(), amount, AssetCallbackFlag::Enabled)?; let p2id_note = builder.add_p2id_note( faucet.id(), other_account.id(), @@ -447,7 +437,7 @@ async fn block_does_not_affect_other_accounts() -> anyhow::Result<()> { } /// Verifies that `mint_and_send` works on a `BasicFungibleFaucet` whose `TokenPolicyManager` -/// installs the asset-callback slots (here via [`TransferPolicy::Blocklist`]). +/// installs the asset-callback slots (here via the basic blocklist transfer policy). #[tokio::test] async fn mint_and_send_on_blocklist_basic_faucet() -> anyhow::Result<()> { let owner_id = dummy_owner(); @@ -461,7 +451,7 @@ async fn mint_and_send_on_blocklist_basic_faucet() -> anyhow::Result<()> { let note_type = NoteType::Private; // `mint_and_send` takes the full asset (ASSET_KEY + ASSET_VALUE) the MINT note carries. - let asset = FungibleAsset::new(faucet.id(), amount)?.with_callbacks(AssetCallbackFlag::Enabled); + let asset = FungibleAsset::new(faucet.id(), amount, AssetCallbackFlag::Enabled)?; let asset_key = asset.to_key_word(); let asset_value = asset.to_value_word(); diff --git a/crates/miden-testing/tests/scripts/expiration.rs b/crates/miden-testing/tests/scripts/expiration.rs new file mode 100644 index 0000000000..0a47162ab6 --- /dev/null +++ b/crates/miden-testing/tests/scripts/expiration.rs @@ -0,0 +1,40 @@ +use core::num::NonZeroU16; + +use miden_protocol::account::auth::AuthScheme; +use miden_standards::tx_script::ExpirationTransactionScript; +use miden_testing::{Auth, MockChain}; + +/// Example: attach the standardized expiration transaction script to a transaction and choose the +/// expiration delta at execution time via `TX_SCRIPT_ARGS`, rather than baking it into the script. +/// A single allowlistable script root therefore works for any delta. +#[tokio::test] +async fn expiration_tx_script_sets_expiration_from_tx_args() -> anyhow::Result<()> { + const DELTA: NonZeroU16 = NonZeroU16::new(42).unwrap(); + + let mut builder = MockChain::builder(); + let account = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let mock_chain = builder.build()?; + + // Mirror how a real caller (client / node) attaches the script: convert the typed script into a + // `TransactionScript` and read its matching `TX_SCRIPT_ARGS` off the same typed value, rather + // than assembling the argument word by hand. + let script = ExpirationTransactionScript::new(DELTA); + + let executed = mock_chain + .build_tx_context(account.id(), &[], &[])? + .tx_script(script.into()) + .tx_script_args(script.tx_script_args()) + .build()? + .execute() + .await?; + + assert_eq!( + executed.expiration_block_num(), + executed.block_header().block_num() + u32::from(DELTA.get()), + "the tx-args-supplied expiration delta should be applied", + ); + + Ok(()) +} diff --git a/crates/miden-testing/tests/scripts/faucet.rs b/crates/miden-testing/tests/scripts/faucet.rs index c15b364d1d..07311c9d14 100644 --- a/crates/miden-testing/tests/scripts/faucet.rs +++ b/crates/miden-testing/tests/scripts/faucet.rs @@ -6,7 +6,14 @@ use std::collections::BTreeSet; use miden_processor::crypto::random::RandomCoin; use miden_protocol::account::auth::AuthScheme; -use miden_protocol::account::{Account, AccountBuilder, AccountId, AccountIdVersion, AccountType}; +use miden_protocol::account::{ + Account, + AccountBuilder, + AccountId, + AccountIdVersion, + AccountProcedureRoot, + AccountType, +}; use miden_protocol::assembly::DefaultSourceManager; use miden_protocol::asset::{Asset, AssetAmount, AssetCallbackFlag, FungibleAsset, TokenSymbol}; use miden_protocol::note::{ @@ -26,22 +33,24 @@ use miden_protocol::note::{ use miden_protocol::testing::account_id::ACCOUNT_ID_PRIVATE_SENDER; use miden_protocol::transaction::{ExecutedTransaction, RawOutputNote}; use miden_protocol::{Felt, Word}; -use miden_standards::account::access::{Authority, Ownable2Step}; +use miden_standards::account::access::{Authority, Ownable2Step, Pausable}; use miden_standards::account::faucets::{FungibleFaucet, TokenName}; use miden_standards::account::policies::{ BurnAllowAll, BurnOwnerOnly, - BurnPolicyConfig, - MintPolicyConfig, - PolicyRegistration, + BurnPolicy, + MinBurnAmount, + MintPolicy, TokenPolicyManager, TransferPolicy, }; use miden_standards::code_builder::CodeBuilder; use miden_standards::errors::standards::{ + ERR_BURN_AMOUNT_BELOW_MIN_BURN_AMOUNT, ERR_BURN_POLICY_ROOT_NOT_ALLOWED, ERR_FAUCET_BURN_AMOUNT_EXCEEDS_TOKEN_SUPPLY, ERR_FUNGIBLE_ASSET_DISTRIBUTE_AMOUNT_EXCEEDS_MAX_SUPPLY, + ERR_FUNGIBLE_ASSET_MAX_SUPPLY_EXCEEDS_FUNGIBLE_ASSET_MAX_AMOUNT, ERR_MINT_POLICY_ROOT_NOT_ALLOWED, ERR_SENDER_NOT_OWNER, }; @@ -136,9 +145,12 @@ pub fn verify_minted_output_note( ) -> anyhow::Result<()> { let output_note = executed_transaction.output_notes().get_note(0).clone(); - let fungible_asset: Asset = FungibleAsset::new(faucet.id(), params.amount.as_canonical_u64())? - .with_callbacks(AssetCallbackFlag::Enabled) - .into(); + let fungible_asset: Asset = FungibleAsset::new( + faucet.id(), + params.amount.as_canonical_u64(), + AssetCallbackFlag::Enabled, + )? + .into(); let assets = NoteAssets::new(vec![fungible_asset])?; let partial_metadata = @@ -198,6 +210,56 @@ fn create_set_burn_policy_note_script(policy_root: Word) -> String { ) } +/// Builds a note script that invokes every `get_*_policy` getter via `call` and asserts each one +/// uses the 16-felt call ABI. +fn create_policy_getters_note_script( + mint_root: AccountProcedureRoot, + burn_root: AccountProcedureRoot, + send_root: AccountProcedureRoot, + receive_root: AccountProcedureRoot, +) -> String { + let mint_root = mint_root.as_word(); + let burn_root = burn_root.as_word(); + let send_root = send_root.as_word(); + let receive_root = receive_root.as_word(); + format!( + r#" + use miden::standards::faucets::policies::policy_manager + + @note_script + pub proc main + padw padw padw padw + call.policy_manager::get_mint_policy + # => [MINT_POLICY_ROOT, pad(12)] + push.{mint_root} + assert_eqw.err="get_mint_policy returned an unexpected root or violated the call ABI" + dropw dropw dropw + + padw padw padw padw + call.policy_manager::get_burn_policy + # => [BURN_POLICY_ROOT, pad(12)] + push.{burn_root} + assert_eqw.err="get_burn_policy returned an unexpected root or violated the call ABI" + dropw dropw dropw + + padw padw padw padw + call.policy_manager::get_send_policy + # => [SEND_POLICY_ROOT, pad(12)] + push.{send_root} + assert_eqw.err="get_send_policy returned an unexpected root or violated the call ABI" + dropw dropw dropw + + padw padw padw padw + call.policy_manager::get_receive_policy + # => [RECEIVE_POLICY_ROOT, pad(12)] + push.{receive_root} + assert_eqw.err="get_receive_policy returned an unexpected root or violated the call ABI" + dropw dropw dropw + end + "# + ) +} + /// Builds a network fungible faucet that opts in to runtime burn policy switching. /// /// The burn policy manager is constructed with `BurnAllowAll` as the active policy and @@ -210,7 +272,7 @@ fn build_network_faucet_with_burn_switching( max_supply: u64, owner: AccountId, token_supply: u64, - mint_policy: MintPolicyConfig, + mint_policy: MintPolicy, ) -> anyhow::Result { let name = TokenName::new(token_symbol)?; let symbol = TokenSymbol::new(token_symbol)?; @@ -224,12 +286,50 @@ fn build_network_faucet_with_burn_switching( .token_supply(token_supply) .build()?; - let token_policy_manager = TokenPolicyManager::new() - .with_mint_policy(mint_policy, PolicyRegistration::Active)? - .with_burn_policy(BurnPolicyConfig::AllowAll, PolicyRegistration::Active)? - .with_burn_policy(BurnPolicyConfig::OwnerOnly, PolicyRegistration::Reserved)? - .with_send_policy(TransferPolicy::AllowAll, PolicyRegistration::Active)? - .with_receive_policy(TransferPolicy::AllowAll, PolicyRegistration::Active)?; + let token_policy_manager = TokenPolicyManager::builder() + .active_mint_policy(mint_policy) + .active_burn_policy(BurnPolicy::allow_all()) + .allowed_burn_policy(BurnPolicy::owner_only()) + .active_send_policy(TransferPolicy::allow_all()) + .active_receive_policy(TransferPolicy::allow_all()) + .build(); + + let account_builder = AccountBuilder::new(builder.rng_mut().random()) + .account_type(AccountType::Public) + .with_component(faucet) + .with_component(Ownable2Step::new(owner)) + .with_component(Authority::OwnerControlled) + .with_components(token_policy_manager) + .with_component(Pausable::unpaused()); + + builder.add_account_from_builder(Auth::IncrNonce, account_builder, AccountState::Exists) +} + +/// Builds an existing public fungible faucet whose send and receive policies are registered only +/// as reserved alternatives, with no active transfer policy. Used to exercise minting on a faucet +/// that has reserved-but-inactive transfer policies. +fn build_existing_faucet_with_reserved_only_transfer_policy( + builder: &mut MockChainBuilder, + token_symbol: &str, + max_supply: u64, + owner: AccountId, +) -> anyhow::Result { + let name = TokenName::new(token_symbol)?; + let symbol = TokenSymbol::new(token_symbol)?; + let max_supply = AssetAmount::new(max_supply)?; + let faucet = FungibleFaucet::builder() + .name(name) + .symbol(symbol) + .decimals(10) + .max_supply(max_supply) + .build()?; + + let token_policy_manager = TokenPolicyManager::builder() + .active_mint_policy(MintPolicy::allow_all()) + .active_burn_policy(BurnPolicy::allow_all()) + .allowed_send_policy(TransferPolicy::allow_all()) + .allowed_receive_policy(TransferPolicy::allow_all()) + .build(); let account_builder = AccountBuilder::new(builder.rng_mut().random()) .account_type(AccountType::Public) @@ -241,6 +341,68 @@ fn build_network_faucet_with_burn_switching( builder.add_account_from_builder(Auth::IncrNonce, account_builder, AccountState::Exists) } +/// Builds a network fungible faucet whose active burn policy is `min_burn_amount`, configured +/// with the given threshold. The faucet installs an owner-controlled [`Authority`] so the +/// owner-gated `set_min_burn_amount` setter can be exercised, plus the standard transfer +/// policies. +fn build_network_faucet_with_min_burn_amount( + builder: &mut MockChainBuilder, + token_symbol: &str, + max_supply: u64, + owner: AccountId, + token_supply: u64, + min_burn_amount: u64, +) -> anyhow::Result { + let name = TokenName::new(token_symbol)?; + let symbol = TokenSymbol::new(token_symbol)?; + let max_supply = AssetAmount::new(max_supply)?; + let token_supply = AssetAmount::new(token_supply)?; + let min_burn_amount = AssetAmount::new(min_burn_amount)?; + let faucet = FungibleFaucet::builder() + .name(name) + .symbol(symbol) + .decimals(10) + .max_supply(max_supply) + .token_supply(token_supply) + .build()?; + + let token_policy_manager = TokenPolicyManager::builder() + .active_mint_policy(MintPolicy::owner_only()) + .active_burn_policy(BurnPolicy::min_burn_amount(min_burn_amount)) + .active_send_policy(TransferPolicy::allow_all()) + .active_receive_policy(TransferPolicy::allow_all()) + .build(); + + let account_builder = AccountBuilder::new(builder.rng_mut().random()) + .account_type(AccountType::Public) + .with_component(faucet) + .with_component(Ownable2Step::new(owner)) + .with_component(Authority::OwnerControlled) + .with_components(token_policy_manager) + .with_component(Pausable::unpaused()); + + builder.add_account_from_builder(Auth::IncrNonce, account_builder, AccountState::Exists) +} + +/// Builds a note script that calls the owner-gated `set_min_burn_amount` procedure with the +/// given new threshold. The procedure expects `[new_min_burn_amount, pad(15)]`, so the script +/// pushes 15 padding elements before the amount. +fn create_set_min_burn_amount_note_script(new_min_burn_amount: u64) -> String { + format!( + r#" + use miden::standards::faucets::policies::burn::min_burn_amount + + @note_script + pub proc main + padw padw padw push.0.0.0 + push.{new_min_burn_amount} + call.min_burn_amount::set_min_burn_amount + dropw dropw dropw dropw + end + "# + ) +} + // TESTS MINT FUNGIBLE ASSET // ================================================================================================ @@ -272,6 +434,39 @@ async fn minting_fungible_asset_on_existing_faucet_succeeds() -> anyhow::Result< Ok(()) } +/// Checks that minting on a faucet whose transfer policies are registered only as reserved +/// alternatives still produces assets carrying `AssetCallbackFlag::Enabled`. The mint succeeds and +/// the output asset is enabled only if `has_callbacks` is true from creation. +#[tokio::test] +async fn minting_on_reserved_only_transfer_policy_faucet_enables_callbacks() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let owner_account_id = + AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Private); + + let faucet = build_existing_faucet_with_reserved_only_transfer_policy( + &mut builder, + "RSV", + 1000, + owner_account_id, + )?; + let mut mock_chain = builder.build()?; + + let params = FaucetTestParams { + recipient: Word::from([0, 1, 2, 3u32]), + tag: NoteTag::default(), + note_type: NoteType::Private, + amount: Felt::new_unchecked(100), + }; + + let executed_transaction = + execute_mint_transaction(&mut mock_chain, faucet.clone(), ¶ms).await?; + // `verify_minted_output_note` asserts the minted asset carries `AssetCallbackFlag::Enabled`. + verify_minted_output_note(&executed_transaction, &faucet, ¶ms)?; + + Ok(()) +} + /// Tests that mint fails when the minted amount would exceed the max supply. #[tokio::test] async fn faucet_contract_mint_fungible_asset_fails_exceeds_max_supply() -> anyhow::Result<()> { @@ -382,7 +577,7 @@ async fn prove_burning_fungible_asset_on_existing_faucet_succeeds() -> anyhow::R Some(token_supply.into()), )?; - let fungible_asset = FungibleAsset::new(faucet.id(), 100).unwrap(); + let fungible_asset = FungibleAsset::new(faucet.id(), 100, AssetCallbackFlag::Disabled).unwrap(); // need to create a note with the fungible asset to be burned let burn_note_script_code = " @@ -421,11 +616,15 @@ async fn prove_burning_fungible_asset_on_existing_faucet_succeeds() -> anyhow::R .execute() .await?; + assert_eq!( + executed_transaction.account_patch().final_nonce(), + Some(faucet.nonce() + Felt::ONE) + ); + assert_eq!(executed_transaction.input_notes().get_note(0).id(), note.id()); + // Prove, serialize/deserialize and verify the transaction prove_and_verify_transaction(executed_transaction.clone()).await?; - assert_eq!(executed_transaction.account_delta().nonce_delta(), Felt::ONE); - assert_eq!(executed_transaction.input_notes().get_note(0).id(), note.id()); Ok(()) } @@ -447,7 +646,8 @@ async fn faucet_burn_fungible_asset_fails_amount_exceeds_token_supply() -> anyho // Try to burn 100 tokens when only 50 have been issued let burn_amount = 100u64; - let fungible_asset = FungibleAsset::new(faucet.id(), burn_amount).unwrap(); + let fungible_asset = + FungibleAsset::new(faucet.id(), burn_amount, AssetCallbackFlag::Disabled).unwrap(); let burn_note_script_code = " # burn the asset @@ -530,8 +730,7 @@ async fn test_public_note_creation_with_script_from_datastore() -> anyhow::Resul let output_script_root = note_recipient.script().root(); let callbacks_flag = AssetCallbackFlag::Enabled; - let asset = - FungibleAsset::new(faucet.id(), amount.as_canonical_u64())?.with_callbacks(callbacks_flag); + let asset = FungibleAsset::new(faucet.id(), amount.as_canonical_u64(), callbacks_flag)?; let metadata = PartialNoteMetadata::new(faucet.id(), note_type).with_tag(tag); let expected_note = Note::new(NoteAssets::new(vec![asset.into()])?, metadata, note_recipient); @@ -627,8 +826,7 @@ async fn test_public_note_creation_with_script_from_datastore() -> anyhow::Resul executed_transaction, note_type: NoteType::Public, sender: faucet.id(), - assets: [FungibleAsset::new(faucet.id(), amount.as_canonical_u64())? - .with_callbacks(AssetCallbackFlag::Enabled)], + assets: [FungibleAsset::new(faucet.id(), amount.as_canonical_u64(), AssetCallbackFlag::Enabled)?], ); let output_note = executed_transaction.output_notes().get_note(0); @@ -652,7 +850,10 @@ async fn test_public_note_creation_with_script_from_datastore() -> anyhow::Resul assert_eq!(full_note.id(), expected_note.id()); // Verify nonce was incremented - assert_eq!(executed_transaction.account_delta().nonce_delta(), Felt::ONE); + assert_eq!( + executed_transaction.account_patch().final_nonce(), + Some(faucet.nonce() + Felt::ONE) + ); Ok(()) } @@ -676,7 +877,7 @@ async fn network_faucet_mint() -> anyhow::Result<()> { max_supply, faucet_owner_account_id, Some(token_supply), - MintPolicyConfig::OwnerOnly, + MintPolicy::owner_only(), [], )?; @@ -707,11 +908,11 @@ async fn network_faucet_mint() -> anyhow::Result<()> { // -------------------------------------------------------------------------------------------- let amount = Felt::new_unchecked(75); - // The faucet has callbacks configured via `TransferPolicy::AllowAll`, so the asset to mint + // The faucet has callbacks configured via [`TransferPolicy::allow_all`], so the asset to mint // must match on the callback flag. - let mint_asset = FungibleAsset::new(faucet.id(), amount.as_canonical_u64()) - .unwrap() - .with_callbacks(AssetCallbackFlag::Enabled); + let mint_asset = + FungibleAsset::new(faucet.id(), amount.as_canonical_u64(), AssetCallbackFlag::Enabled) + .unwrap(); let serial_num = Word::default(); let output_note_tag = NoteTag::with_account_target(target_account.id()); @@ -751,8 +952,8 @@ async fn network_faucet_mint() -> anyhow::Result<()> { let output_note = executed_transaction.output_notes().get_note(0); // Verify the output note contains the minted fungible asset - let expected_asset = FungibleAsset::new(faucet.id(), amount.as_canonical_u64())? - .with_callbacks(AssetCallbackFlag::Enabled); + let expected_asset = + FungibleAsset::new(faucet.id(), amount.as_canonical_u64(), AssetCallbackFlag::Enabled)?; let assets = NoteAssets::new(vec![expected_asset.into()])?; let details_commitment = NoteDetailsCommitment::from_raw_commitments(recipient, assets.commitment()); @@ -776,7 +977,7 @@ async fn network_faucet_mint() -> anyhow::Result<()> { let consume_executed_transaction = consume_tx_context.execute().await?; // Apply the delta to the target account and verify the asset was added to the account's vault - target_account.apply_delta(consume_executed_transaction.account_delta())?; + target_account.apply_patch(consume_executed_transaction.account_patch())?; // Verify the account's vault now contains the expected fungible asset let actual_asset = target_account.vault().get(expected_asset.vault_key()).unwrap(); @@ -801,15 +1002,15 @@ async fn test_network_faucet_owner_can_mint() -> anyhow::Result<()> { 1000, owner_account_id, Some(50), - MintPolicyConfig::OwnerOnly, + MintPolicy::owner_only(), [], )?; let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; let mock_chain = builder.build()?; let amount = Felt::new_unchecked(75); - let mint_asset = FungibleAsset::new(faucet.id(), amount.as_canonical_u64())? - .with_callbacks(AssetCallbackFlag::Enabled); + let mint_asset = + FungibleAsset::new(faucet.id(), amount.as_canonical_u64(), AssetCallbackFlag::Enabled)?; let output_note_tag = NoteTag::with_account_target(target_account.id()); let p2id_note = create_p2id_note_exact( @@ -869,7 +1070,7 @@ async fn test_network_faucet_set_policy_rejects_non_allowed_root() -> anyhow::Re 1000, owner_account_id, Some(0), - MintPolicyConfig::OwnerOnly, + MintPolicy::owner_only(), [set_policy_note_script.root()], )?; let mock_chain = builder.build()?; @@ -906,7 +1107,7 @@ async fn test_network_faucet_set_burn_policy_rejects_non_allowed_root() -> anyho 1000, owner_account_id, Some(0), - MintPolicyConfig::OwnerOnly, + MintPolicy::owner_only(), [set_policy_note_script.root()], )?; let mock_chain = builder.build()?; @@ -925,6 +1126,48 @@ async fn test_network_faucet_set_burn_policy_rejects_non_allowed_root() -> anyho Ok(()) } +/// Conformance test for the policy getters' 16-felt call ABI. +#[tokio::test] +async fn test_network_faucet_policy_getters_works() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let owner_account_id = + AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Private); + + // `add_existing_network_faucet` activates AllowAll for burn, send, and receive, plus the mint + // policy passed below, so the active roots are known up front. + let getters_note_script = compile_note_script(&create_policy_getters_note_script( + MintPolicy::allow_all().root(), + BurnPolicy::allow_all().root(), + TransferPolicy::allow_all().root(), + TransferPolicy::allow_all().root(), + ))?; + + let faucet = builder.add_existing_network_faucet( + "NET", + 1000, + owner_account_id, + Some(0), + MintPolicy::allow_all(), + [getters_note_script.root()], + )?; + let mock_chain = builder.build()?; + + let result = execute_faucet_note_script( + &mock_chain, + faucet.id(), + owner_account_id, + getters_note_script, + 402, + ) + .await?; + + // A clean execution proves every getter returned exactly 16 felts with the expected root. + result.map_err(|err| anyhow::anyhow!("policy getter conformance tx failed: {err:?}"))?; + + Ok(()) +} + /// Tests that a non-owner cannot mint assets on network faucet. #[tokio::test] async fn test_network_faucet_non_owner_cannot_mint() -> anyhow::Result<()> { @@ -941,15 +1184,15 @@ async fn test_network_faucet_non_owner_cannot_mint() -> anyhow::Result<()> { 1000, owner_account_id, Some(50), - MintPolicyConfig::OwnerOnly, + MintPolicy::owner_only(), [], )?; let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; let mock_chain = builder.build()?; let amount = Felt::new_unchecked(75); - let mint_asset = FungibleAsset::new(faucet.id(), amount.as_canonical_u64())? - .with_callbacks(AssetCallbackFlag::Enabled); + let mint_asset = + FungibleAsset::new(faucet.id(), amount.as_canonical_u64(), AssetCallbackFlag::Enabled)?; let output_note_tag = NoteTag::with_account_target(target_account.id()); let p2id_note = create_p2id_note_exact( @@ -996,7 +1239,7 @@ async fn test_network_faucet_owner_storage() -> anyhow::Result<()> { 1000, owner_account_id, Some(50), - MintPolicyConfig::OwnerOnly, + MintPolicy::owner_only(), [], )?; let _mock_chain = builder.build()?; @@ -1068,14 +1311,14 @@ async fn test_network_faucet_transfer_ownership() -> anyhow::Result<()> { 1000, initial_owner_account_id, Some(50), - MintPolicyConfig::OwnerOnly, + MintPolicy::owner_only(), [transfer_script.root(), accept_script.root()], )?; let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; let amount = Felt::new_unchecked(75); - let mint_asset = FungibleAsset::new(faucet.id(), amount.as_canonical_u64())? - .with_callbacks(AssetCallbackFlag::Enabled); + let mint_asset = + FungibleAsset::new(faucet.id(), amount.as_canonical_u64(), AssetCallbackFlag::Enabled)?; let output_note_tag = NoteTag::with_account_target(target_account.id()); let p2id_note = create_p2id_note_exact( @@ -1134,7 +1377,7 @@ async fn test_network_faucet_transfer_ownership() -> anyhow::Result<()> { mock_chain.prove_next_block()?; let mut updated_faucet = faucet.clone(); - updated_faucet.apply_delta(executed_transaction.account_delta())?; + updated_faucet.apply_patch(executed_transaction.account_patch())?; let mut rng = RandomCoin::new([Felt::from(400u32); 4].into()); let accept_note = NoteBuilder::new(new_owner_account_id, &mut rng) @@ -1151,7 +1394,7 @@ async fn test_network_faucet_transfer_ownership() -> anyhow::Result<()> { let executed_transaction = tx_context.execute().await?; let mut final_faucet = updated_faucet.clone(); - final_faucet.apply_delta(executed_transaction.account_delta())?; + final_faucet.apply_patch(executed_transaction.account_patch())?; // Verify that owner changed to new_owner and nominated was cleared // Word: [owner_suffix, owner_prefix, nominated_suffix, nominated_prefix] @@ -1206,7 +1449,7 @@ async fn test_network_faucet_only_owner_can_transfer() -> anyhow::Result<()> { 1000, owner_account_id, Some(50), - MintPolicyConfig::OwnerOnly, + MintPolicy::owner_only(), [transfer_script.root()], )?; let mock_chain = builder.build()?; @@ -1282,7 +1525,7 @@ async fn test_network_faucet_renounce_ownership() -> anyhow::Result<()> { 1000, owner_account_id, Some(50), - MintPolicyConfig::OwnerOnly, + MintPolicy::owner_only(), [renounce_script.root(), transfer_script.root()], )?; @@ -1325,7 +1568,7 @@ async fn test_network_faucet_renounce_ownership() -> anyhow::Result<()> { mock_chain.prove_next_block()?; let mut updated_faucet = faucet.clone(); - updated_faucet.apply_delta(executed_transaction.account_delta())?; + updated_faucet.apply_patch(executed_transaction.account_patch())?; // Check stored value after renouncing - should be zero let stored_owner_after = updated_faucet.storage().get_item(Ownable2Step::slot_name())?; @@ -1364,7 +1607,7 @@ fn test_network_faucet_contains_default_burn_policy_root() -> anyhow::Result<()> 200, owner_account_id, Some(100), - MintPolicyConfig::OwnerOnly, + MintPolicy::owner_only(), [], )?; @@ -1389,12 +1632,13 @@ async fn network_faucet_burn() -> anyhow::Result<()> { 200, faucet_owner_account_id, Some(100), - MintPolicyConfig::OwnerOnly, + MintPolicy::owner_only(), [], )?; let burn_amount = 100u64; - let fungible_asset = FungibleAsset::new(faucet.id(), burn_amount).unwrap(); + let fungible_asset = + FungibleAsset::new(faucet.id(), burn_amount, AssetCallbackFlag::Disabled).unwrap(); // CREATE BURN NOTE // -------------------------------------------------------------------------------------------- @@ -1424,11 +1668,14 @@ async fn network_faucet_burn() -> anyhow::Result<()> { assert_eq!(executed_transaction.output_notes().num_notes(), 0); // Verify the transaction was executed successfully - assert_eq!(executed_transaction.account_delta().nonce_delta(), Felt::ONE); + assert_eq!( + executed_transaction.account_patch().final_nonce(), + Some(faucet.nonce() + Felt::ONE) + ); assert_eq!(executed_transaction.input_notes().get_note(0).id(), note.id()); // Apply the delta to the faucet account and verify the token issuance decreased - faucet.apply_delta(executed_transaction.account_delta())?; + faucet.apply_patch(executed_transaction.account_patch())?; let final_token_supply = FungibleFaucet::try_from(faucet.storage())?.token_supply(); assert_eq!( final_token_supply, @@ -1456,7 +1703,7 @@ async fn test_network_faucet_non_owner_cannot_burn_when_owner_only_policy_active 200, owner_account_id, 100, - MintPolicyConfig::OwnerOnly, + MintPolicy::owner_only(), )?; let set_policy_note_script = create_set_burn_policy_note_script(BurnOwnerOnly::root().as_word()); @@ -1466,7 +1713,8 @@ async fn test_network_faucet_non_owner_cannot_burn_when_owner_only_policy_active .code(set_policy_note_script.as_str()) .build()?; let burn_amount = 10u64; - let fungible_asset = FungibleAsset::new(faucet.id(), burn_amount).unwrap(); + let fungible_asset = + FungibleAsset::new(faucet.id(), burn_amount, AssetCallbackFlag::Disabled).unwrap(); let mut rng = RandomCoin::new([Felt::from(501u32); 4].into()); let burn_note = BurnNote::create( non_owner_account_id, @@ -1511,7 +1759,7 @@ async fn test_network_faucet_owner_can_burn_when_owner_only_policy_active() -> a 200, owner_account_id, 100, - MintPolicyConfig::OwnerOnly, + MintPolicy::owner_only(), )?; let set_policy_note_script = create_set_burn_policy_note_script(BurnOwnerOnly::root().as_word()); @@ -1521,7 +1769,8 @@ async fn test_network_faucet_owner_can_burn_when_owner_only_policy_active() -> a .code(set_policy_note_script.as_str()) .build()?; let burn_amount = 10u64; - let fungible_asset = FungibleAsset::new(faucet.id(), burn_amount).unwrap(); + let fungible_asset = + FungibleAsset::new(faucet.id(), burn_amount, AssetCallbackFlag::Disabled).unwrap(); let mut rng = RandomCoin::new([Felt::from(511u32); 4].into()); let burn_note = BurnNote::create( owner_account_id, @@ -1548,7 +1797,326 @@ async fn test_network_faucet_owner_can_burn_when_owner_only_policy_active() -> a let executed_transaction = tx_context.execute().await?; assert_eq!(executed_transaction.output_notes().num_notes(), 0); - assert_eq!(executed_transaction.account_delta().nonce_delta(), Felt::ONE); + assert_eq!( + executed_transaction.account_patch().final_nonce(), + Some(faucet.nonce() + Felt::from(2u8),), + "nonce should be incremented by 1 in each of the 2 txs" + ); + + Ok(()) +} + +// TESTS FOR MIN BURN AMOUNT BURN POLICY +// ================================================================================================ + +/// Tests that the `min_burn_amount` policy is installed as the active burn policy and its +/// procedure root is exported by the account code. +#[test] +fn test_network_faucet_min_burn_amount_policy_is_active() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let owner_account_id = + AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Private); + + let faucet = build_network_faucet_with_min_burn_amount( + &mut builder, + "NET", + 200, + owner_account_id, + 100, + 50, + )?; + + let stored_root = faucet.storage().get_item(TokenPolicyManager::active_burn_policy_slot())?; + + assert_eq!(stored_root, MinBurnAmount::root().as_word()); + assert!(faucet.code().has_procedure(stored_root)); + + Ok(()) +} + +/// Tests that a burn below the configured minimum burn amount is rejected. +#[tokio::test] +async fn test_network_faucet_burn_below_min_burn_amount_fails() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let owner_account_id = + AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Private); + + let faucet = build_network_faucet_with_min_burn_amount( + &mut builder, + "NET", + 200, + owner_account_id, + 100, + 50, + )?; + + // Burn amount of 10 is below the configured minimum of 50. + let burn_amount = 10u64; + let fungible_asset = + FungibleAsset::new(faucet.id(), burn_amount, AssetCallbackFlag::Disabled).unwrap(); + let mut rng = RandomCoin::new([Felt::from(600u32); 4].into()); + let burn_note = BurnNote::create( + owner_account_id, + faucet.id(), + fungible_asset.into(), + NoteAttachments::default(), + &mut rng, + )?; + builder.add_output_note(RawOutputNote::Full(burn_note.clone())); + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + let tx_context = mock_chain.build_tx_context(faucet.id(), &[burn_note.id()], &[])?.build()?; + let result = tx_context.execute().await; + + assert_transaction_executor_error!(result, ERR_BURN_AMOUNT_BELOW_MIN_BURN_AMOUNT); + + Ok(()) +} + +/// Tests that a burn of exactly the configured minimum burn amount succeeds (boundary case). +#[tokio::test] +async fn test_network_faucet_burn_at_min_burn_amount_succeeds() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let owner_account_id = + AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Private); + + let mut faucet = build_network_faucet_with_min_burn_amount( + &mut builder, + "NET", + 200, + owner_account_id, + 100, + 50, + )?; + + // Burn amount equal to the configured minimum of 50 meets the threshold. + let burn_amount = 50u64; + let fungible_asset = + FungibleAsset::new(faucet.id(), burn_amount, AssetCallbackFlag::Disabled).unwrap(); + let mut rng = RandomCoin::new([Felt::from(601u32); 4].into()); + let burn_note = BurnNote::create( + owner_account_id, + faucet.id(), + fungible_asset.into(), + NoteAttachments::default(), + &mut rng, + )?; + builder.add_output_note(RawOutputNote::Full(burn_note.clone())); + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + let initial_token_supply = FungibleFaucet::try_from(faucet.storage())?.token_supply(); + + let tx_context = mock_chain.build_tx_context(faucet.id(), &[burn_note.id()], &[])?.build()?; + let executed_transaction = tx_context.execute().await?; + + faucet.apply_patch(executed_transaction.account_patch())?; + let final_token_supply = FungibleFaucet::try_from(faucet.storage())?.token_supply(); + assert_eq!( + final_token_supply, + AssetAmount::new(initial_token_supply.as_u64() - burn_amount).unwrap() + ); + + Ok(()) +} + +/// Tests that the owner can lower the minimum burn amount via `set_min_burn_amount`, after which +/// a burn that previously violated the threshold succeeds. +#[tokio::test] +async fn test_network_faucet_owner_can_set_min_burn_amount() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let owner_account_id = + AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Private); + + let mut faucet = build_network_faucet_with_min_burn_amount( + &mut builder, + "NET", + 200, + owner_account_id, + 100, + 50, + )?; + + // Owner lowers the minimum burn amount from 50 to 5. + let set_note_script = create_set_min_burn_amount_note_script(5); + let mut rng = RandomCoin::new([Felt::from(610u32); 4].into()); + let set_note = NoteBuilder::new(owner_account_id, &mut rng) + .note_type(NoteType::Private) + .code(set_note_script.as_str()) + .build()?; + + // A burn of 10 is below the original threshold (50) but at/above the new one (5). + let burn_amount = 10u64; + let fungible_asset = + FungibleAsset::new(faucet.id(), burn_amount, AssetCallbackFlag::Disabled).unwrap(); + let mut rng = RandomCoin::new([Felt::from(611u32); 4].into()); + let burn_note = BurnNote::create( + owner_account_id, + faucet.id(), + fungible_asset.into(), + NoteAttachments::default(), + &mut rng, + )?; + builder.add_output_note(RawOutputNote::Full(set_note.clone())); + builder.add_output_note(RawOutputNote::Full(burn_note.clone())); + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + let initial_token_supply = FungibleFaucet::try_from(faucet.storage())?.token_supply(); + + // Execute the set-min-burn-amount note first. + let source_manager = Arc::new(DefaultSourceManager::default()); + let tx_context = mock_chain + .build_tx_context(faucet.id(), &[set_note.id()], &[])? + .with_source_manager(source_manager.clone()) + .build()?; + let set_transaction = tx_context.execute().await?; + mock_chain.add_pending_executed_transaction(&set_transaction)?; + mock_chain.prove_next_block()?; + faucet.apply_patch(set_transaction.account_patch())?; + + // The burn that was below the original threshold now succeeds. + let tx_context = mock_chain.build_tx_context(faucet.id(), &[burn_note.id()], &[])?.build()?; + let burn_transaction = tx_context.execute().await?; + + // Lowering the threshold left the supply untouched; only the burn reduces it. + faucet.apply_patch(burn_transaction.account_patch())?; + let final_token_supply = FungibleFaucet::try_from(faucet.storage())?.token_supply(); + assert_eq!( + final_token_supply, + AssetAmount::new(initial_token_supply.as_u64() - burn_amount).unwrap() + ); + + Ok(()) +} + +/// Tests that a non-owner cannot update the minimum burn amount via `set_min_burn_amount`. +#[tokio::test] +async fn test_network_faucet_non_owner_cannot_set_min_burn_amount() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let owner_account_id = + AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Private); + let non_owner_account_id = + AccountId::dummy([2; 15], AccountIdVersion::Version1, AccountType::Private); + + let faucet = build_network_faucet_with_min_burn_amount( + &mut builder, + "NET", + 200, + owner_account_id, + 100, + 50, + )?; + + let set_note_script = create_set_min_burn_amount_note_script(5); + let mut rng = RandomCoin::new([Felt::from(620u32); 4].into()); + let set_note = NoteBuilder::new(non_owner_account_id, &mut rng) + .note_type(NoteType::Private) + .code(set_note_script.as_str()) + .build()?; + builder.add_output_note(RawOutputNote::Full(set_note.clone())); + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + let tx_context = mock_chain.build_tx_context(faucet.id(), &[set_note.id()], &[])?.build()?; + let result = tx_context.execute().await; + + assert_transaction_executor_error!(result, ERR_SENDER_NOT_OWNER); + + Ok(()) +} + +/// Builds a network faucet whose `max_supply` is mutable so the owner-gated `set_max_supply` +/// setter can be exercised. +fn build_network_faucet_mutable_max_supply( + builder: &mut MockChainBuilder, + token_symbol: &str, + max_supply: u64, + owner: AccountId, +) -> anyhow::Result { + let name = TokenName::new(token_symbol)?; + let symbol = TokenSymbol::new(token_symbol)?; + let max_supply = AssetAmount::new(max_supply)?; + let faucet = FungibleFaucet::builder() + .name(name) + .symbol(symbol) + .decimals(10) + .max_supply(max_supply) + .is_max_supply_mutable(true) + .build()?; + + let token_policy_manager = TokenPolicyManager::builder() + .active_mint_policy(MintPolicy::owner_only()) + .active_burn_policy(BurnPolicy::allow_all()) + .active_send_policy(TransferPolicy::allow_all()) + .active_receive_policy(TransferPolicy::allow_all()) + .build(); + + let account_builder = AccountBuilder::new(builder.rng_mut().random()) + .account_type(AccountType::Public) + .with_component(faucet) + .with_component(Ownable2Step::new(owner)) + .with_component(Authority::OwnerControlled) + .with_components(token_policy_manager) + .with_component(Pausable::unpaused()); + + builder.add_account_from_builder(Auth::IncrNonce, account_builder, AccountState::Exists) +} + +/// Builds a note script that calls the owner-gated `set_max_supply` procedure with the given +/// new cap. +fn create_set_max_supply_note_script(new_max_supply: u64) -> NoteScript { + let code = format!( + r#" + @note_script + pub proc main + padw padw padw push.0.0.0 + push.{new_max_supply} + call.::miden::standards::faucets::fungible::set_max_supply + dropw dropw dropw dropw + end + "# + ); + CodeBuilder::default().compile_note_script(&code).unwrap() +} + +/// Tests that `set_max_supply` rejects a cap above `FUNGIBLE_ASSET_MAX_AMOUNT`, keeping the +/// stored cap consistent with the bound enforced at mint time. +#[tokio::test] +async fn test_set_max_supply_rejects_cap_above_fungible_asset_max_amount() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let owner_account_id = + AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Private); + + let faucet = + build_network_faucet_mutable_max_supply(&mut builder, "NET", 200, owner_account_id)?; + + // One above the maximum representable fungible asset amount. + let new_max_supply = FungibleAsset::MAX_AMOUNT.as_u64() + 1; + let set_note_script = create_set_max_supply_note_script(new_max_supply); + let mut rng = RandomCoin::new([Felt::from(630u32); 4].into()); + let set_note = NoteBuilder::new(owner_account_id, &mut rng) + .note_type(NoteType::Private) + .script(set_note_script) + .build()?; + builder.add_output_note(RawOutputNote::Full(set_note.clone())); + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + let tx_context = mock_chain.build_tx_context(faucet.id(), &[set_note.id()], &[])?.build()?; + let result = tx_context.execute().await; + + assert_transaction_executor_error!( + result, + ERR_FUNGIBLE_ASSET_MAX_SUPPLY_EXCEEDS_FUNGIBLE_ASSET_MAX_AMOUNT + ); Ok(()) } @@ -1573,17 +2141,17 @@ async fn test_mint_note_output_note_types(#[case] note_type: NoteType) -> anyhow 1000, faucet_owner_account_id, Some(50), - MintPolicyConfig::OwnerOnly, + MintPolicy::owner_only(), [], )?; let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; let amount = Felt::new_unchecked(75); - // The faucet has callbacks configured via `TransferPolicy::AllowAll`, so the asset to mint + // The faucet has callbacks configured via [`TransferPolicy::allow_all`], so the asset to mint // must match on the callback flag. - let mint_asset = FungibleAsset::new(faucet.id(), amount.as_canonical_u64()) - .unwrap() - .with_callbacks(AssetCallbackFlag::Enabled); + let mint_asset = + FungibleAsset::new(faucet.id(), amount.as_canonical_u64(), AssetCallbackFlag::Enabled) + .unwrap(); let serial_num = Word::from([1, 2, 3, 4u32]); // Create the expected P2ID output note @@ -1662,10 +2230,10 @@ async fn test_mint_note_output_note_types(#[case] note_type: NoteType) -> anyhow .build()?; let consume_executed_transaction = consume_tx_context.execute().await?; - target_account_mut.apply_delta(consume_executed_transaction.account_delta())?; + target_account_mut.apply_patch(consume_executed_transaction.account_patch())?; - let expected_asset = FungibleAsset::new(faucet.id(), amount.as_canonical_u64())? - .with_callbacks(AssetCallbackFlag::Enabled); + let expected_asset = + FungibleAsset::new(faucet.id(), amount.as_canonical_u64(), AssetCallbackFlag::Enabled)?; let balance = target_account_mut.vault().get_balance(expected_asset.vault_key())?; assert_eq!(balance, expected_asset.amount()); @@ -1757,9 +2325,8 @@ async fn multiple_mints_in_single_tx_produce_correct_amounts() -> anyhow::Result assert_eq!(executed_transaction.output_notes().num_notes(), 2); // Verify first note has exactly amount_1 tokens. - let expected_asset_1: Asset = FungibleAsset::new(faucet.id(), amount_1)? - .with_callbacks(AssetCallbackFlag::Enabled) - .into(); + let expected_asset_1: Asset = + FungibleAsset::new(faucet.id(), amount_1, AssetCallbackFlag::Enabled)?.into(); let output_note_1 = executed_transaction.output_notes().get_note(0); let assets_1 = NoteAssets::new(vec![expected_asset_1])?; let details_commitment_1 = @@ -1768,9 +2335,8 @@ async fn multiple_mints_in_single_tx_produce_correct_amounts() -> anyhow::Result assert_eq!(output_note_1.id(), expected_id_1); // Verify second note has exactly amount_2 tokens. - let expected_asset_2: Asset = FungibleAsset::new(faucet.id(), amount_2)? - .with_callbacks(AssetCallbackFlag::Enabled) - .into(); + let expected_asset_2: Asset = + FungibleAsset::new(faucet.id(), amount_2, AssetCallbackFlag::Enabled)?.into(); let output_note_2 = executed_transaction.output_notes().get_note(1); let assets_2 = NoteAssets::new(vec![expected_asset_2])?; let details_commitment_2 = @@ -1781,10 +2347,10 @@ async fn multiple_mints_in_single_tx_produce_correct_amounts() -> anyhow::Result Ok(()) } -// NetworkFungibleFaucet + TransferPolicy::Blocklist (post-#2879 happy path) +// NetworkFungibleFaucet + TransferPolicy::basic_blocklist (post-#2879 happy path) // ================================================================================================ -/// Builds a network faucet with [`TransferPolicy::Blocklist`] on both send and receive, +/// Builds a network faucet with [`TransferPolicy::basic_blocklist`] on both send and receive, /// so the manager populates the asset-callback slots and callbacks dispatch to the /// basic blocklist predicate. fn build_network_faucet_with_blocklist_transfer( @@ -1806,22 +2372,25 @@ fn build_network_faucet_with_blocklist_transfer( .token_supply(token_supply) .build()?; - let token_policy_manager = TokenPolicyManager::new() - .with_mint_policy(MintPolicyConfig::OwnerOnly, PolicyRegistration::Active)? - .with_burn_policy(BurnPolicyConfig::AllowAll, PolicyRegistration::Active)? - .with_send_policy(TransferPolicy::Blocklist, PolicyRegistration::Active)? - .with_receive_policy(TransferPolicy::Blocklist, PolicyRegistration::Active)?; + let token_policy_manager = TokenPolicyManager::builder() + .active_mint_policy(MintPolicy::owner_only()) + .active_burn_policy(BurnPolicy::allow_all()) + .active_send_policy(TransferPolicy::empty_basic_blocklist()) + .active_receive_policy(TransferPolicy::empty_basic_blocklist()) + .build(); let account_builder = AccountBuilder::new(builder.rng_mut().random()) .account_type(AccountType::Public) .with_component(faucet) .with_component(Ownable2Step::new(owner)) .with_component(Authority::OwnerControlled) - .with_components(token_policy_manager); + .with_components(token_policy_manager) + .with_component(Pausable::unpaused()); builder.add_account_from_builder( Auth::NetworkAccount { allowed_script_roots: BTreeSet::from([MintNote::script_root()]), + allowed_tx_script_roots: BTreeSet::new(), }, account_builder, AccountState::Exists, @@ -1829,7 +2398,7 @@ fn build_network_faucet_with_blocklist_transfer( } /// Verifies that the network-faucet mint pattern works when `TokenPolicyManager` installs -/// asset-callback slots (here via [`TransferPolicy::Blocklist`]). +/// asset-callback slots (here via [`TransferPolicy::basic_blocklist`]). /// /// Before the protocol fix in 0xMiden/protocol#2879 the kernel rejected this with /// `ERR_FOREIGN_ACCOUNT_CONTEXT_AGAINST_NATIVE_ACCOUNT` because the issuing faucet was also @@ -1859,9 +2428,9 @@ async fn network_faucet_mint_with_blocklist() -> anyhow::Result<()> { // The blocklist faucet has asset callbacks enabled, so the asset embedded in the MINT // note must carry the matching callback flag: `mint_and_send` binds the mint to the // full ASSET_KEY derived for the faucet, which encodes that flag. - let mint_asset = FungibleAsset::new(faucet.id(), amount.as_canonical_u64()) - .unwrap() - .with_callbacks(AssetCallbackFlag::Enabled); + let mint_asset = + FungibleAsset::new(faucet.id(), amount.as_canonical_u64(), AssetCallbackFlag::Enabled) + .unwrap(); let serial_num = Word::default(); let output_note_tag = NoteTag::with_account_target(target_account.id()); diff --git a/crates/miden-testing/tests/scripts/fee.rs b/crates/miden-testing/tests/scripts/fee.rs deleted file mode 100644 index 3ba0eddded..0000000000 --- a/crates/miden-testing/tests/scripts/fee.rs +++ /dev/null @@ -1,51 +0,0 @@ -use anyhow::Context; -use miden_protocol::asset::FungibleAsset; -use miden_protocol::{self, Felt, Word}; -use miden_testing::{Auth, MockChain}; - -use crate::prove_and_verify_transaction; - -// FEE TESTS -// ================================================================================================ - -/// Tests that a simple account can be created with non-zero fees and the transaction can be proven -/// and verified. -/// -/// This is an interesting test case because the prover needs to apply the fee asset to the account -/// delta in order to prove the correct delta commitment. Once we have other tests with fees, this -/// test may become obsolete. -#[tokio::test] -async fn prove_account_creation_with_fees() -> anyhow::Result<()> { - let amount = 10_000; - let mut builder = MockChain::builder().verification_base_fee(50); - let account = builder.create_new_wallet(Auth::IncrNonce)?; - let fee_note = builder.add_p2id_note_with_fee(account.id(), amount)?; - let chain = builder.build()?; - - let tx = chain - .build_tx_context(account, &[fee_note.id()], &[])? - .build()? - .execute() - .await - .context("failed to execute account-creating transaction")?; - - let expected_fee = tx.compute_fee(); - assert_eq!(expected_fee, tx.fee().amount()); - - // We expect that the new account contains the amount minus the paid fee. - let added_asset = FungibleAsset::new(chain.fee_faucet_id(), amount)?.sub(tx.fee())?; - - assert_eq!(tx.account_delta().nonce_delta(), Felt::ONE); - // except for the nonce, the storage delta should be empty - assert!(tx.account_delta().storage().is_empty()); - assert_eq!(tx.account_delta().vault().added_assets().count(), 1); - assert_eq!(tx.account_delta().vault().removed_assets().count(), 0); - assert_eq!(tx.account_delta().vault().added_assets().next().unwrap(), added_asset.into()); - assert_eq!(tx.final_account().nonce(), Felt::ONE); - // account commitment should not be the empty word - assert_ne!(tx.account_delta().to_commitment(), Word::empty()); - - prove_and_verify_transaction(tx).await?; - - Ok(()) -} diff --git a/crates/miden-testing/tests/scripts/mod.rs b/crates/miden-testing/tests/scripts/mod.rs index 2b66737105..3569771278 100644 --- a/crates/miden-testing/tests/scripts/mod.rs +++ b/crates/miden-testing/tests/scripts/mod.rs @@ -1,7 +1,8 @@ mod allowlist; +mod authority; mod blocklist; +mod expiration; mod faucet; -mod fee; mod ownable2step; mod p2id; mod p2ide; diff --git a/crates/miden-testing/tests/scripts/ownable2step.rs b/crates/miden-testing/tests/scripts/ownable2step.rs index a08ebbd0e2..4b82c009d7 100644 --- a/crates/miden-testing/tests/scripts/ownable2step.rs +++ b/crates/miden-testing/tests/scripts/ownable2step.rs @@ -230,7 +230,7 @@ async fn test_complete_ownership_transfer() -> anyhow::Result<()> { let executed = tx.execute().await?; let mut updated = account.clone(); - updated.apply_delta(executed.account_delta())?; + updated.apply_patch(executed.account_patch())?; // Verify intermediate state: owner unchanged, nominated set assert_eq!(get_owner_from_storage(&updated)?, Some(owner)); @@ -251,7 +251,7 @@ async fn test_complete_ownership_transfer() -> anyhow::Result<()> { let executed2 = tx2.execute().await?; let mut final_account = updated.clone(); - final_account.apply_delta(executed2.account_delta())?; + final_account.apply_patch(executed2.account_patch())?; assert_eq!(get_owner_from_storage(&final_account)?, Some(new_owner)); assert_eq!(get_nominated_owner_from_storage(&final_account)?, None); @@ -286,7 +286,7 @@ async fn test_accept_ownership_only_nominated_owner() -> anyhow::Result<()> { let executed = tx.execute().await?; let mut updated = account.clone(); - updated.apply_delta(executed.account_delta())?; + updated.apply_patch(executed.account_patch())?; // Commit step 1 to the chain mock_chain.add_pending_executed_transaction(&executed)?; @@ -359,7 +359,7 @@ async fn test_cancel_transfer() -> anyhow::Result<()> { let executed = tx.execute().await?; let mut updated = account.clone(); - updated.apply_delta(executed.account_delta())?; + updated.apply_patch(executed.account_patch())?; // Commit step 1 to the chain mock_chain.add_pending_executed_transaction(&executed)?; @@ -376,7 +376,7 @@ async fn test_cancel_transfer() -> anyhow::Result<()> { let executed2 = tx2.execute().await?; let mut final_account = updated.clone(); - final_account.apply_delta(executed2.account_delta())?; + final_account.apply_patch(executed2.account_patch())?; assert_eq!(get_nominated_owner_from_storage(&final_account)?, None); assert_eq!(get_owner_from_storage(&final_account)?, Some(owner)); @@ -408,7 +408,7 @@ async fn test_transfer_to_self_creates_self_nomination() -> anyhow::Result<()> { let executed = tx.execute().await?; let mut updated = account.clone(); - updated.apply_delta(executed.account_delta())?; + updated.apply_patch(executed.account_patch())?; assert_eq!(get_owner_from_storage(&updated)?, Some(owner)); assert_eq!(get_nominated_owner_from_storage(&updated)?, Some(owner)); @@ -439,7 +439,7 @@ async fn test_renounce_ownership() -> anyhow::Result<()> { let executed = tx.execute().await?; let mut final_account = account.clone(); - final_account.apply_delta(executed.account_delta())?; + final_account.apply_patch(executed.account_patch())?; assert_eq!(get_owner_from_storage(&final_account)?, None); assert_eq!(get_nominated_owner_from_storage(&final_account)?, None); @@ -476,7 +476,7 @@ async fn test_renounce_ownership_fails_with_pending_transfer() -> anyhow::Result let executed = tx.execute().await?; let mut updated = account.clone(); - updated.apply_delta(executed.account_delta())?; + updated.apply_patch(executed.account_patch())?; // Commit step 1 to the chain. mock_chain.add_pending_executed_transaction(&executed)?; diff --git a/crates/miden-testing/tests/scripts/p2id.rs b/crates/miden-testing/tests/scripts/p2id.rs index 8c4b376101..15ac94dbfb 100644 --- a/crates/miden-testing/tests/scripts/p2id.rs +++ b/crates/miden-testing/tests/scripts/p2id.rs @@ -1,6 +1,6 @@ use miden_protocol::account::Account; use miden_protocol::account::auth::AuthScheme; -use miden_protocol::asset::{Asset, AssetVault, FungibleAsset}; +use miden_protocol::asset::{Asset, AssetCallbackFlag, AssetVault, FungibleAsset}; use miden_protocol::crypto::rand::RandomCoin; use miden_protocol::note::{NoteAttachments, NoteTag, NoteType}; use miden_protocol::testing::account_id::{ @@ -25,8 +25,12 @@ use crate::prove_and_verify_transaction; async fn p2id_script_multiple_assets() -> anyhow::Result<()> { // Create assets let fungible_asset_1: Asset = FungibleAsset::mock(123); - let fungible_asset_2: Asset = - FungibleAsset::new(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2.try_into()?, 456)?.into(); + let fungible_asset_2: Asset = FungibleAsset::new( + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2.try_into()?, + 456, + AssetCallbackFlag::Disabled, + )? + .into(); let mut builder = MockChain::builder(); @@ -175,7 +179,7 @@ async fn prove_consume_multiple_notes() -> anyhow::Result<()> { let executed_transaction = tx_context.execute().await?; - account.apply_delta(executed_transaction.account_delta())?; + account.apply_patch(executed_transaction.account_patch())?; let resulting_asset = account.vault().assets().next().unwrap(); if let Asset::Fungible(asset) = resulting_asset { assert_eq!(asset.amount().as_u64(), 123); @@ -199,9 +203,11 @@ async fn test_create_consume_multiple_notes() -> anyhow::Result<()> { )?; let input_note_faucet_id = ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into()?; - let input_note_asset_1: Asset = FungibleAsset::new(input_note_faucet_id, 11)?.into(); + let input_note_asset_1: Asset = + FungibleAsset::new(input_note_faucet_id, 11, AssetCallbackFlag::Disabled)?.into(); - let input_note_asset_2: Asset = FungibleAsset::new(input_note_faucet_id, 100)?.into(); + let input_note_asset_2: Asset = + FungibleAsset::new(input_note_faucet_id, 100, AssetCallbackFlag::Disabled)?.into(); let input_note_1 = builder.add_p2id_note( ACCOUNT_ID_SENDER.try_into()?, @@ -292,7 +298,7 @@ async fn test_create_consume_multiple_notes() -> anyhow::Result<()> { assert_eq!(executed_transaction.output_notes().num_notes(), 2); - account.apply_delta(executed_transaction.account_delta())?; + account.apply_patch(executed_transaction.account_patch())?; assert_eq!(account.vault().get_balance(input_note_asset_1.vault_key())?.as_u64(), 111); assert_eq!(account.vault().get_balance(asset_1.vault_key())?.as_u64(), 5); diff --git a/crates/miden-testing/tests/scripts/pausable.rs b/crates/miden-testing/tests/scripts/pausable.rs index e12339036f..eb7051d94c 100644 --- a/crates/miden-testing/tests/scripts/pausable.rs +++ b/crates/miden-testing/tests/scripts/pausable.rs @@ -6,26 +6,38 @@ extern crate alloc; +use alloc::collections::BTreeMap; use alloc::string::String; use miden_processor::crypto::random::RandomCoin; -use miden_protocol::Word; -use miden_protocol::account::{Account, AccountBuilder, AccountId, AccountIdVersion, AccountType}; +use miden_protocol::account::{ + Account, + AccountBuilder, + AccountId, + AccountIdVersion, + AccountProcedureRoot, + AccountType, + RoleSymbol, +}; use miden_protocol::asset::{Asset, AssetAmount, AssetCallbackFlag, FungibleAsset}; -use miden_protocol::errors::MasmError; use miden_protocol::note::{Note, NoteTag, NoteType}; use miden_protocol::transaction::RawOutputNote; use miden_protocol::utils::sync::LazyLock; -use miden_standards::account::access::AccessControl; -use miden_standards::account::access::pausable::{PausableManager, PausableStorage}; +use miden_protocol::{Felt, Word}; +use miden_standards::account::access::pausable::{Pausable, PausableManager, PausableStorage}; +use miden_standards::account::access::{AccessControl, Authority}; use miden_standards::account::faucets::{FungibleFaucet, TokenName}; use miden_standards::account::policies::{ - BurnPolicyConfig, - MintPolicyConfig, - PolicyRegistration, + BurnPolicy, + MintPolicy, TokenPolicyManager, TransferPolicy, }; +use miden_standards::errors::standards::{ + ERR_PAUSABLE_IS_PAUSED, + ERR_SENDER_LACKS_ROLE, + ERR_SENDER_NOT_OWNER, +}; use miden_standards::testing::note::NoteBuilder; use miden_testing::{ AccountState, @@ -35,14 +47,10 @@ use miden_testing::{ assert_transaction_executor_error, }; -const ERR_PAUSABLE_IS_PAUSED: MasmError = MasmError::from_static_str("the contract is paused"); - -const ERR_SENDER_NOT_OWNER: MasmError = MasmError::from_static_str("note sender is not the owner"); - -static OWNER_ID: LazyLock = LazyLock::new(|| test_account_id(11)); -static NON_OWNER_ID: LazyLock = LazyLock::new(|| test_account_id(99)); +pub(crate) static OWNER_ID: LazyLock = LazyLock::new(|| test_account_id(11)); +pub(crate) static NON_OWNER_ID: LazyLock = LazyLock::new(|| test_account_id(99)); -fn test_account_id(seed: u8) -> AccountId { +pub(crate) fn test_account_id(seed: u8) -> AccountId { AccountId::dummy([seed; 15], AccountIdVersion::Version1, AccountType::Private) } @@ -67,6 +75,7 @@ fn add_faucet_with_pause( .account_type(AccountType::Public) .with_component(faucet) .with_components(AccessControl::Ownable2Step { owner }) + .with_component(Pausable::unpaused()) .with_component(PausableManager); builder.add_account_from_builder(Auth::IncrNonce, account_builder, AccountState::Exists) @@ -75,7 +84,7 @@ fn add_faucet_with_pause( // NOTE BUILDERS // ================================================================================================ -fn build_note(sender: AccountId, code: impl Into) -> anyhow::Result { +pub(crate) fn build_note(sender: AccountId, code: impl Into) -> anyhow::Result { let seed: [u32; 4] = rand::random(); let mut rng = RandomCoin::new(Word::from(seed)); Ok(NoteBuilder::new(sender, &mut rng) @@ -85,7 +94,7 @@ fn build_note(sender: AccountId, code: impl Into) -> anyhow::Result anyhow::Result { +pub(crate) fn build_pause_note(sender: AccountId) -> anyhow::Result { build_note( sender, r#" @@ -118,7 +127,7 @@ fn build_unpause_note(sender: AccountId) -> anyhow::Result { ) } -async fn execute_note_on_faucet( +pub(crate) async fn execute_note_on_faucet( mock_chain: &mut MockChain, faucet_id: AccountId, note: &Note, @@ -260,6 +269,201 @@ async fn pausable_manager_pause_while_paused_is_noop() -> anyhow::Result<()> { Ok(()) } +// TESTS — PAUSABLE MANAGER WITH PER-PROCEDURE RBAC ROLES +// ================================================================================================ + +pub(crate) fn role(name: &str) -> RoleSymbol { + RoleSymbol::new(name).expect("role symbol should be valid") +} + +/// Maps `pause` → `PAUSER` and `unpause` → `UNPAUSER` so the two capabilities are gated by +/// distinct roles. +fn pause_unpause_roles() -> BTreeMap { + BTreeMap::from([ + (PausableManager::pause_root(), role("PAUSER")), + (PausableManager::unpause_root(), role("UNPAUSER")), + ]) +} + +/// Builds an RBAC faucet whose pause / unpause are gated per-procedure. Any authority-gated +/// procedure not present in `roles` falls back to the owner check. +fn add_rbac_faucet_with_pause( + builder: &mut MockChainBuilder, + owner: AccountId, + roles: BTreeMap, + seed: u8, + mutable_max_supply: bool, +) -> anyhow::Result { + let faucet = FungibleFaucet::builder() + .name(TokenName::new("SYM")?) + .symbol("SYM".try_into()?) + .decimals(8) + .max_supply(AssetAmount::new(1_000_000)?) + .is_max_supply_mutable(mutable_max_supply) + .build()?; + + let account_builder = AccountBuilder::new([seed; 32]) + .account_type(AccountType::Public) + .with_component(faucet) + .with_components(AccessControl::Rbac { owner, roles }) + .with_component(Pausable::unpaused()) + .with_component(PausableManager); + + builder.add_account_from_builder(Auth::IncrNonce, account_builder, AccountState::Exists) +} + +/// Builds an owner-or-admin-authored note that grants `role` to `account_id` via +/// `rbac::grant_role`. +pub(crate) fn build_grant_role_note( + sender: AccountId, + role: &RoleSymbol, + account_id: AccountId, +) -> anyhow::Result { + build_note( + sender, + format!( + r#" + use miden::standards::access::rbac + + @note_script + pub proc main + repeat.13 push.0 end + push.{account_prefix} + push.{account_suffix} + push.{role} + call.rbac::grant_role + dropw dropw dropw dropw + end + "#, + account_prefix = account_id.prefix().as_felt(), + account_suffix = account_id.suffix(), + role = Felt::from(role), + ), + ) +} + +/// Builds a note that calls `set_max_supply`, an authority-gated procedure intentionally left out +/// of the role map in the tests below so it exercises the owner fallback. +pub(crate) fn build_set_max_supply_note( + sender: AccountId, + new_max_supply: u64, +) -> anyhow::Result { + build_note( + sender, + format!( + r#" + @note_script + pub proc main + push.{new_max_supply} + swap drop + call.::miden::standards::faucets::fungible::set_max_supply + end + "#, + ), + ) +} + +/// Returns whether the faucet's `is_paused` slot is set in the latest committed state. +fn is_faucet_paused(mock_chain: &MockChain, faucet_id: AccountId) -> anyhow::Result { + let account = mock_chain.committed_account(faucet_id)?; + let word = account.storage().get_item(PausableStorage::is_paused_slot())?; + Ok(word != Word::default()) +} + +#[tokio::test] +async fn rbac_pause_and_unpause_use_distinct_roles() -> anyhow::Result<()> { + let pauser = test_account_id(20); + let unpauser = test_account_id(21); + + let mut builder = MockChain::builder(); + let faucet = + add_rbac_faucet_with_pause(&mut builder, *OWNER_ID, pause_unpause_roles(), 47, false)?; + + // Owner grants PAUSER to `pauser` and UNPAUSER to `unpauser`; then each acts in their lane. + let grant_pauser = build_grant_role_note(*OWNER_ID, &role("PAUSER"), pauser)?; + let grant_unpauser = build_grant_role_note(*OWNER_ID, &role("UNPAUSER"), unpauser)?; + let pause_note = build_pause_note(pauser)?; + let unpause_note = build_unpause_note(unpauser)?; + for note in [&grant_pauser, &grant_unpauser, &pause_note, &unpause_note] { + builder.add_output_note(RawOutputNote::Full(note.clone())); + } + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + execute_note_on_faucet(&mut mock_chain, faucet.id(), &grant_pauser).await?; + execute_note_on_faucet(&mut mock_chain, faucet.id(), &grant_unpauser).await?; + + // PAUSER pauses → the faucet records the paused state. + execute_note_on_faucet(&mut mock_chain, faucet.id(), &pause_note).await?; + assert!(is_faucet_paused(&mock_chain, faucet.id())?); + + // UNPAUSER unpauses → the faucet records the unpaused state. + execute_note_on_faucet(&mut mock_chain, faucet.id(), &unpause_note).await?; + assert!(!is_faucet_paused(&mock_chain, faucet.id())?); + + Ok(()) +} + +#[tokio::test] +async fn rbac_pause_fails_when_sender_lacks_pauser_role() -> anyhow::Result<()> { + let unpauser = test_account_id(22); + + let mut builder = MockChain::builder(); + let faucet = + add_rbac_faucet_with_pause(&mut builder, *OWNER_ID, pause_unpause_roles(), 48, false)?; + + // `unpauser` only holds UNPAUSER, so calling `pause` (gated by PAUSER) must be rejected. + let grant_unpauser = build_grant_role_note(*OWNER_ID, &role("UNPAUSER"), unpauser)?; + let pause_note = build_pause_note(unpauser)?; + builder.add_output_note(RawOutputNote::Full(grant_unpauser.clone())); + builder.add_output_note(RawOutputNote::Full(pause_note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + execute_note_on_faucet(&mut mock_chain, faucet.id(), &grant_unpauser).await?; + + let result = mock_chain + .build_tx_context(faucet.id(), &[pause_note.id()], &[])? + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_SENDER_LACKS_ROLE); + + Ok(()) +} + +#[tokio::test] +async fn rbac_unmapped_procedure_falls_back_to_owner() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let faucet = + add_rbac_faucet_with_pause(&mut builder, *OWNER_ID, pause_unpause_roles(), 49, true)?; + + // `set_max_supply` is not in the role map → falls back to the owner check. The owner can call + // it; a non-owner cannot. + let owner_note = build_set_max_supply_note(*OWNER_ID, 500_000)?; + let attacker_note = build_set_max_supply_note(*NON_OWNER_ID, 500_000)?; + builder.add_output_note(RawOutputNote::Full(owner_note.clone())); + builder.add_output_note(RawOutputNote::Full(attacker_note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + execute_note_on_faucet(&mut mock_chain, faucet.id(), &owner_note).await?; + + let result = mock_chain + .build_tx_context(faucet.id(), &[attacker_note.id()], &[])? + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_SENDER_NOT_OWNER); + + Ok(()) +} + // SANITY TESTS — PausableStorage helper // ================================================================================================ @@ -294,13 +498,15 @@ fn add_faucet_with_pause_and_policies( .account_type(AccountType::Public) .with_component(faucet) .with_components(AccessControl::Ownable2Step { owner }) + .with_component(Pausable::unpaused()) .with_component(PausableManager) .with_components( - TokenPolicyManager::new() - .with_mint_policy(MintPolicyConfig::AllowAll, PolicyRegistration::Active)? - .with_burn_policy(BurnPolicyConfig::AllowAll, PolicyRegistration::Active)? - .with_send_policy(TransferPolicy::Blocklist, PolicyRegistration::Active)? - .with_receive_policy(TransferPolicy::Blocklist, PolicyRegistration::Active)?, + TokenPolicyManager::builder() + .active_mint_policy(MintPolicy::allow_all()) + .active_burn_policy(BurnPolicy::allow_all()) + .active_send_policy(TransferPolicy::empty_basic_blocklist()) + .active_receive_policy(TransferPolicy::empty_basic_blocklist()) + .build(), ); builder.add_account_from_builder(Auth::IncrNonce, account_builder, AccountState::Exists) @@ -312,7 +518,7 @@ async fn pausable_transfer_succeeds_when_unpaused() -> anyhow::Result<()> { let target = builder.add_existing_wallet(Auth::IncrNonce)?; let faucet = add_faucet_with_pause_and_policies(&mut builder, *OWNER_ID)?; - let asset = FungibleAsset::new(faucet.id(), 100)?.with_callbacks(AssetCallbackFlag::Enabled); + let asset = FungibleAsset::new(faucet.id(), 100, AssetCallbackFlag::Enabled)?; let note = builder.add_p2id_note( faucet.id(), target.id(), @@ -341,7 +547,7 @@ async fn pausable_transfer_fails_when_paused() -> anyhow::Result<()> { let target = builder.add_existing_wallet(Auth::IncrNonce)?; let faucet = add_faucet_with_pause_and_policies(&mut builder, *OWNER_ID)?; - let asset = FungibleAsset::new(faucet.id(), 100)?.with_callbacks(AssetCallbackFlag::Enabled); + let asset = FungibleAsset::new(faucet.id(), 100, AssetCallbackFlag::Enabled)?; let note = builder.add_p2id_note( faucet.id(), target.id(), @@ -377,7 +583,7 @@ async fn pausable_transfer_resumes_after_unpause() -> anyhow::Result<()> { let target = builder.add_existing_wallet(Auth::IncrNonce)?; let faucet = add_faucet_with_pause_and_policies(&mut builder, *OWNER_ID)?; - let asset = FungibleAsset::new(faucet.id(), 100)?.with_callbacks(AssetCallbackFlag::Enabled); + let asset = FungibleAsset::new(faucet.id(), 100, AssetCallbackFlag::Enabled)?; let note = builder.add_p2id_note( faucet.id(), target.id(), @@ -467,7 +673,7 @@ async fn pausable_burn_fails_when_paused() -> anyhow::Result<()> { let faucet = add_faucet_with_pause_and_policies(&mut builder, *OWNER_ID)?; // Pre-stage a burn note carrying an asset issued by this faucet. - let burn_asset = FungibleAsset::new(faucet.id(), 50)?; + let burn_asset = FungibleAsset::new(faucet.id(), 50, AssetCallbackFlag::Disabled)?; let burn_note_script_code = r#" @note_script pub proc main @@ -523,6 +729,7 @@ fn add_faucet_mutable_max_supply_with_pause( .account_type(AccountType::Public) .with_component(faucet) .with_components(AccessControl::Ownable2Step { owner }) + .with_component(Pausable::unpaused()) .with_component(PausableManager); builder.add_account_from_builder(Auth::IncrNonce, account_builder, AccountState::Exists) @@ -579,7 +786,7 @@ async fn pausable_set_max_supply_fails_when_paused() -> anyhow::Result<()> { // TESTS — PAUSABLE MANAGER WITH AUTH-CONTROLLED ACCESS CONTROL // ================================================================================================ -/// Same as `add_faucet_with_pause` but uses `AccessControl::AuthControlled`. +/// Same as `add_faucet_with_pause` but uses `Authority::AuthControlled` directly. fn add_faucet_with_pause_auth_controlled( builder: &mut MockChainBuilder, ) -> anyhow::Result { @@ -593,7 +800,8 @@ fn add_faucet_with_pause_auth_controlled( let account_builder = AccountBuilder::new([46u8; 32]) .account_type(AccountType::Public) .with_component(faucet) - .with_components(AccessControl::AuthControlled) + .with_component(Authority::AuthControlled) + .with_component(Pausable::unpaused()) .with_component(PausableManager); builder.add_account_from_builder(Auth::IncrNonce, account_builder, AccountState::Exists) diff --git a/crates/miden-testing/tests/scripts/pswap.rs b/crates/miden-testing/tests/scripts/pswap.rs index d8beab912b..de7e93b514 100644 --- a/crates/miden-testing/tests/scripts/pswap.rs +++ b/crates/miden-testing/tests/scripts/pswap.rs @@ -2,8 +2,8 @@ use std::collections::BTreeMap; use std::slice; use miden_protocol::account::auth::AuthScheme; -use miden_protocol::account::{Account, AccountId, AccountType, AccountVaultDelta}; -use miden_protocol::asset::{Asset, AssetAmount, FungibleAsset}; +use miden_protocol::account::{Account, AccountId, AccountType, AccountVaultPatch}; +use miden_protocol::asset::{Asset, AssetAmount, AssetCallbackFlag, AssetVaultKey, FungibleAsset}; use miden_protocol::crypto::rand::{FeltRng, RandomCoin}; use miden_protocol::errors::MasmError; use miden_protocol::note::{Note, NoteAttachments, NoteType}; @@ -68,41 +68,26 @@ fn build_pswap_note( } #[track_caller] -fn assert_fungible_asset_eq(asset: &Asset, expected: FungibleAsset) { - match asset { - Asset::Fungible(f) => { - assert_eq!(f.faucet_id(), expected.faucet_id(), "faucet id mismatch"); - assert_eq!( - f.amount(), - expected.amount(), - "amount mismatch (expected {}, got {})", - expected.amount(), - f.amount() - ); - }, - _ => panic!("expected fungible asset, got non-fungible"), - } -} - -#[track_caller] -fn assert_vault_added_removed( - vault_delta: &AccountVaultDelta, - expected_added: FungibleAsset, - expected_removed: FungibleAsset, +fn assert_vault_patch( + vault_patch: &AccountVaultPatch, + expected_assets: impl IntoIterator, ) { - let added: Vec = vault_delta.added_assets().collect(); - let removed: Vec = vault_delta.removed_assets().collect(); - assert_eq!(added.len(), 1, "expected exactly 1 added asset"); - assert_eq!(removed.len(), 1, "expected exactly 1 removed asset"); - assert_fungible_asset_eq(&added[0], expected_added); - assert_fungible_asset_eq(&removed[0], expected_removed); -} - -#[track_caller] -fn assert_vault_single_added(vault_delta: &AccountVaultDelta, expected: FungibleAsset) { - let added: Vec = vault_delta.added_assets().collect(); - assert_eq!(added.len(), 1, "expected exactly 1 added asset"); - assert_fungible_asset_eq(&added[0], expected); + let updated: Vec = vault_patch.updated_assets().collect(); + let removed: Vec = vault_patch.removed_asset_keys().copied().collect(); + let expected_assets = expected_assets.into_iter().collect::>(); + assert_eq!(vault_patch.num_assets(), expected_assets.len()); + + for expected in expected_assets { + if expected.amount().as_u64() == 0 { + assert!(removed.contains(&expected.vault_key())); + } else { + let actual = updated + .iter() + .find(|asset| asset.vault_key() == expected.vault_key()) + .expect("updated asset should be present"); + assert_eq!(actual, &Asset::Fungible(expected)); + } + } } // TESTS @@ -141,15 +126,15 @@ async fn pswap_note_alice_reconstructs_and_consumes_p2id( let alice = builder.add_existing_wallet_with_assets( BASIC_AUTH, - [FungibleAsset::new(usdc_faucet.id(), 50)?.into()], + [FungibleAsset::new(usdc_faucet.id(), 50, AssetCallbackFlag::Disabled)?.into()], )?; let bob = builder.add_existing_wallet_with_assets( BASIC_AUTH, - [FungibleAsset::new(eth_faucet.id(), fill_amount)?.into()], + [FungibleAsset::new(eth_faucet.id(), fill_amount, AssetCallbackFlag::Disabled)?.into()], )?; - let offered_asset = FungibleAsset::new(usdc_faucet.id(), 50)?; - let requested_asset = FungibleAsset::new(eth_faucet.id(), 25)?; + let offered_asset = FungibleAsset::new(usdc_faucet.id(), 50, AssetCallbackFlag::Disabled)?; + let requested_asset = FungibleAsset::new(eth_faucet.id(), 25, AssetCallbackFlag::Disabled)?; let is_partial = fill_amount < u64::from(requested_asset.amount()); let mut rng = RandomCoin::new(Word::default()); @@ -176,8 +161,11 @@ async fn pswap_note_alice_reconstructs_and_consumes_p2id( let mut note_args_map = BTreeMap::new(); note_args_map.insert(pswap_note.id(), PswapNote::create_args(fill_amount, 0)?); - let (p2id_note, remainder_pswap) = - pswap.execute(bob.id(), Some(FungibleAsset::new(eth_faucet.id(), fill_amount)?), None)?; + let (p2id_note, remainder_pswap) = pswap.execute( + bob.id(), + Some(FungibleAsset::new(eth_faucet.id(), fill_amount, AssetCallbackFlag::Disabled)?), + None, + )?; let mut expected_output_notes = vec![RawOutputNote::Full(p2id_note.clone())]; let predicted_remainder = if is_partial { @@ -289,8 +277,11 @@ async fn pswap_note_alice_reconstructs_and_consumes_p2id( let executed_transaction = tx_context.execute().await?; // Verify Alice received the filled amount. - let vault_delta = executed_transaction.account_delta().vault(); - assert_vault_single_added(vault_delta, FungibleAsset::new(eth_faucet.id(), fill_amount)?); + let vault_patch = executed_transaction.account_patch().vault(); + assert_vault_patch( + vault_patch, + [FungibleAsset::new(eth_faucet.id(), fill_amount, AssetCallbackFlag::Disabled)?], + ); Ok(()) } @@ -318,9 +309,9 @@ async fn pswap_attachment_layout_matches_masm_test() -> anyhow::Result<()> { let usdc_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "USDC", 1000, Some(150))?; let eth_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", 1000, Some(50))?; - let usdc_50 = FungibleAsset::new(usdc_faucet.id(), 50)?; - let eth_20 = FungibleAsset::new(eth_faucet.id(), 20)?; - let eth_25 = FungibleAsset::new(eth_faucet.id(), 25)?; + let usdc_50 = FungibleAsset::new(usdc_faucet.id(), 50, AssetCallbackFlag::Disabled)?; + let eth_20 = FungibleAsset::new(eth_faucet.id(), 20, AssetCallbackFlag::Disabled)?; + let eth_25 = FungibleAsset::new(eth_faucet.id(), 25, AssetCallbackFlag::Disabled)?; let alice = builder.add_existing_wallet_with_assets(BASIC_AUTH, [usdc_50.into()])?; let bob = builder.add_existing_wallet_with_assets(BASIC_AUTH, [eth_20.into()])?; @@ -461,7 +452,10 @@ async fn pswap_fill_test( let alice = builder.add_existing_wallet_with_assets( BASIC_AUTH, - [FungibleAsset::new(usdc_faucet.id(), offered_total)?.into()], + [ + FungibleAsset::new(usdc_faucet.id(), offered_total, AssetCallbackFlag::Disabled)? + .into(), + ], )?; let consumer_id = if use_network_account { @@ -471,27 +465,36 @@ async fn pswap_fill_test( Account::builder(seed) .account_type(AccountType::Public) .with_component(BasicWallet) - .with_assets([FungibleAsset::new(eth_faucet.id(), fill_amount)?.into()]), + .with_assets([FungibleAsset::new( + eth_faucet.id(), + fill_amount, + AssetCallbackFlag::Disabled, + )? + .into()]), miden_testing::AccountState::Exists, )?; network_consumer.id() } else { - let bob = builder.add_existing_wallet_with_assets( - BASIC_AUTH, - [FungibleAsset::new(eth_faucet.id(), fill_amount)?.into()], - )?; + let bob = + builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(eth_faucet.id(), fill_amount, AssetCallbackFlag::Disabled)? + .into()], + )?; bob.id() }; - let offered_asset = FungibleAsset::new(usdc_faucet.id(), offered_total)?; - let requested_asset = FungibleAsset::new(eth_faucet.id(), requested_total)?; + let offered_asset = + FungibleAsset::new(usdc_faucet.id(), offered_total, AssetCallbackFlag::Disabled)?; + let requested_asset = + FungibleAsset::new(eth_faucet.id(), requested_total, AssetCallbackFlag::Disabled)?; let (pswap, pswap_note) = build_pswap_note(&mut builder, alice.id(), offered_asset, requested_asset, note_type)?; let mut mock_chain = builder.build()?; - let fill_asset = FungibleAsset::new(eth_faucet.id(), fill_amount)?; + let fill_asset = FungibleAsset::new(eth_faucet.id(), fill_amount, AssetCallbackFlag::Disabled)?; let (p2id_note, remainder_pswap) = if use_network_account { let p2id = pswap.execute_full_fill(consumer_id)?; @@ -538,26 +541,33 @@ async fn pswap_fill_test( // P2ID note carries fill_amount ETH let p2id_assets = output_notes.get_note(0).assets(); assert_eq!(p2id_assets.num_assets(), 1); - assert_fungible_asset_eq( - p2id_assets.iter().next().unwrap(), - FungibleAsset::new(eth_faucet.id(), fill_amount)?, + assert_eq!( + p2id_assets.iter().next().unwrap().unwrap_fungible(), + FungibleAsset::new(eth_faucet.id(), fill_amount, AssetCallbackFlag::Disabled)?, ); // On partial fill, assert remainder note has offered - payout USDC if is_partial { let remainder_assets = output_notes.get_note(1).assets(); - assert_fungible_asset_eq( - remainder_assets.iter().next().unwrap(), - FungibleAsset::new(usdc_faucet.id(), offered_total - payout_amount)?, + assert_eq!( + remainder_assets.iter().next().unwrap().unwrap_fungible(), + FungibleAsset::new( + usdc_faucet.id(), + offered_total - payout_amount, + AssetCallbackFlag::Disabled + )?, ); } - // Consumer's vault delta: +payout USDC, -fill ETH - let vault_delta = executed_transaction.account_delta().vault(); - assert_vault_added_removed( - vault_delta, - FungibleAsset::new(usdc_faucet.id(), payout_amount)?, - FungibleAsset::new(eth_faucet.id(), fill_amount)?, + // Consumer's vault: +payout USDC, -fill ETH (the consumer spent its entire ETH balance, results + // in 0). + let vault_patch = executed_transaction.account_patch().vault(); + assert_vault_patch( + vault_patch, + [ + FungibleAsset::new(usdc_faucet.id(), payout_amount, AssetCallbackFlag::Disabled)?, + FungibleAsset::new(eth_faucet.id(), 0, AssetCallbackFlag::Disabled)?, + ], ); mock_chain.add_pending_executed_transaction(&executed_transaction)?; @@ -576,8 +586,8 @@ async fn pswap_note_note_fill_cross_swap_test() -> anyhow::Result<()> { // Alice offers 50 USDC for 25 ETH. Bob offers 25 ETH for 50 USDC. They // cross-swap through Charlie, so each side's offered asset is the other // side's requested asset. - let usdc_50 = FungibleAsset::new(usdc_faucet.id(), 50)?; - let eth_25 = FungibleAsset::new(eth_faucet.id(), 25)?; + let usdc_50 = FungibleAsset::new(usdc_faucet.id(), 50, AssetCallbackFlag::Disabled)?; + let eth_25 = FungibleAsset::new(eth_faucet.id(), 25, AssetCallbackFlag::Disabled)?; let alice = builder.add_existing_wallet_with_assets(BASIC_AUTH, [usdc_50.into()])?; let bob = builder.add_existing_wallet_with_assets(BASIC_AUTH, [eth_25.into()])?; @@ -632,9 +642,10 @@ async fn pswap_note_note_fill_cross_swap_test() -> anyhow::Result<()> { ); // Charlie's vault should be unchanged - let vault_delta = executed_transaction.account_delta().vault(); - assert_eq!(vault_delta.added_assets().count(), 0); - assert_eq!(vault_delta.removed_assets().count(), 0); + assert!( + executed_transaction.account_patch().vault().is_empty(), + "Charlie's vault should be unchanged" + ); Ok(()) } @@ -668,15 +679,16 @@ async fn pswap_note_combined_account_fill_and_note_fill_test() -> anyhow::Result // Bob's pswap: 30 ETH offered for 60 USDC requested. // Charlie consumes both; his vault supplies 20 ETH (account_fill) and // the other 30 ETH is sourced from Bob's offered leg via note_fill. - let alice_offered = FungibleAsset::new(usdc_faucet.id(), 100)?; - let alice_requested = FungibleAsset::new(eth_faucet.id(), 50)?; - let bob_offered = FungibleAsset::new(eth_faucet.id(), 30)?; - let bob_requested = FungibleAsset::new(usdc_faucet.id(), 60)?; + let alice_offered = FungibleAsset::new(usdc_faucet.id(), 100, AssetCallbackFlag::Disabled)?; + let alice_requested = FungibleAsset::new(eth_faucet.id(), 50, AssetCallbackFlag::Disabled)?; + let bob_offered = FungibleAsset::new(eth_faucet.id(), 30, AssetCallbackFlag::Disabled)?; + let bob_requested = FungibleAsset::new(usdc_faucet.id(), 60, AssetCallbackFlag::Disabled)?; - let charlie_vault_eth = FungibleAsset::new(eth_faucet.id(), 20)?; + let charlie_vault_eth = FungibleAsset::new(eth_faucet.id(), 20, AssetCallbackFlag::Disabled)?; let account_fill_eth = charlie_vault_eth; let note_fill_eth = bob_offered; - let charlie_payout_usdc = FungibleAsset::new(usdc_faucet.id(), 40)?; + let charlie_payout_usdc = + FungibleAsset::new(usdc_faucet.id(), 40, AssetCallbackFlag::Disabled)?; let alice = builder.add_existing_wallet_with_assets(BASIC_AUTH, [alice_offered.into()])?; let bob = builder.add_existing_wallet_with_assets(BASIC_AUTH, [bob_offered.into()])?; @@ -739,10 +751,16 @@ async fn pswap_note_combined_account_fill_and_note_fill_test() -> anyhow::Result "Bob's P2ID ({bob_requested:?}) not found", ); - // Charlie's vault: -20 ETH (account_fill) + 40 USDC (account_fill_payout). + // Charlie's vault: -20 ETH, results in 0 (account_fill) + 40 USDC (account_fill_payout). // The note_fill legs flow entirely through inflight and never touch his vault. - let vault_delta = executed_transaction.account_delta().vault(); - assert_vault_added_removed(vault_delta, charlie_payout_usdc, charlie_vault_eth); + let vault_patch = executed_transaction.account_patch().vault(); + assert_vault_patch( + vault_patch, + [ + charlie_payout_usdc, + FungibleAsset::new(eth_faucet.id(), 0, AssetCallbackFlag::Disabled)?, + ], + ); Ok(()) } @@ -754,16 +772,15 @@ async fn pswap_note_creator_reclaim_test() -> anyhow::Result<()> { let usdc_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "USDC", 1000, Some(50))?; let eth_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", 1000, Some(25))?; - let alice = builder.add_existing_wallet_with_assets( - BASIC_AUTH, - [FungibleAsset::new(usdc_faucet.id(), 50)?.into()], - )?; + let initial_asset = FungibleAsset::new(usdc_faucet.id(), 40, AssetCallbackFlag::Disabled)?; + let offered_asset = FungibleAsset::new(usdc_faucet.id(), 50, AssetCallbackFlag::Disabled)?; + let alice = builder.add_existing_wallet_with_assets(BASIC_AUTH, [initial_asset.into()])?; let (_, pswap_note) = build_pswap_note( &mut builder, alice.id(), - FungibleAsset::new(usdc_faucet.id(), 50)?, - FungibleAsset::new(eth_faucet.id(), 25)?, + offered_asset, + FungibleAsset::new(eth_faucet.id(), 25, AssetCallbackFlag::Disabled)?, NoteType::Public, )?; @@ -777,8 +794,10 @@ async fn pswap_note_creator_reclaim_test() -> anyhow::Result<()> { let output_notes = executed_transaction.output_notes(); assert_eq!(output_notes.num_notes(), 0, "Expected 0 output notes for reclaim"); - let vault_delta = executed_transaction.account_delta().vault(); - assert_vault_single_added(vault_delta, FungibleAsset::new(usdc_faucet.id(), 50)?); + // The patch holds the absolute post-tx balance: Alice's initial balance plus the offered asset + // from the reclaimed note. + let vault_patch = executed_transaction.account_patch().vault(); + assert_vault_patch(vault_patch, [initial_asset.add(offered_asset)?]); Ok(()) } @@ -811,18 +830,18 @@ async fn pswap_note_invalid_input_test( let alice = builder.add_existing_wallet_with_assets( BASIC_AUTH, - [FungibleAsset::new(usdc_faucet.id(), 50)?.into()], + [FungibleAsset::new(usdc_faucet.id(), 50, AssetCallbackFlag::Disabled)?.into()], )?; let bob = builder.add_existing_wallet_with_assets( BASIC_AUTH, - [FungibleAsset::new(eth_faucet.id(), 30)?.into()], + [FungibleAsset::new(eth_faucet.id(), 30, AssetCallbackFlag::Disabled)?.into()], )?; let (_, pswap_note) = build_pswap_note( &mut builder, alice.id(), - FungibleAsset::new(usdc_faucet.id(), 50)?, - FungibleAsset::new(eth_faucet.id(), 25)?, + FungibleAsset::new(usdc_faucet.id(), 50, AssetCallbackFlag::Disabled)?, + FungibleAsset::new(eth_faucet.id(), 25, AssetCallbackFlag::Disabled)?, NoteType::Public, )?; let mock_chain = builder.build()?; @@ -864,18 +883,18 @@ async fn pswap_note_idx_nonzero_regression_test() -> anyhow::Result<()> { let alice = builder.add_existing_wallet_with_assets( BASIC_AUTH, - [FungibleAsset::new(usdc_faucet.id(), 50)?.into()], + [FungibleAsset::new(usdc_faucet.id(), 50, AssetCallbackFlag::Disabled)?.into()], )?; let bob = builder.add_existing_wallet_with_assets( BASIC_AUTH, - [FungibleAsset::new(eth_faucet.id(), 25)?.into()], + [FungibleAsset::new(eth_faucet.id(), 25, AssetCallbackFlag::Disabled)?.into()], )?; let (pswap, pswap_note) = build_pswap_note( &mut builder, alice.id(), - FungibleAsset::new(usdc_faucet.id(), 50)?, - FungibleAsset::new(eth_faucet.id(), 25)?, + FungibleAsset::new(usdc_faucet.id(), 50, AssetCallbackFlag::Disabled)?, + FungibleAsset::new(eth_faucet.id(), 25, AssetCallbackFlag::Disabled)?, NoteType::Public, )?; @@ -892,8 +911,11 @@ async fn pswap_note_idx_nonzero_regression_test() -> anyhow::Result<()> { let mut note_args_map = BTreeMap::new(); note_args_map.insert(pswap_note.id(), PswapNote::create_args(25, 0)?); - let (expected_p2id, _) = - pswap.execute(bob.id(), Some(FungibleAsset::new(eth_faucet.id(), 25)?), None)?; + let (expected_p2id, _) = pswap.execute( + bob.id(), + Some(FungibleAsset::new(eth_faucet.id(), 25, AssetCallbackFlag::Disabled)?), + None, + )?; // Consume spawn first so the PSWAP-created P2ID gets note_idx == 1. let tx_context = mock_chain @@ -924,17 +946,19 @@ async fn pswap_note_idx_nonzero_regression_test() -> anyhow::Result<()> { // P2ID at idx 1 must carry the full 25 ETH. let p2id_out = output_notes.get_note(1); assert_eq!(p2id_out.assets().num_assets(), 1, "P2ID must have 1 asset"); - assert_fungible_asset_eq( - p2id_out.assets().iter().next().unwrap(), - FungibleAsset::new(eth_faucet.id(), 25)?, + assert_eq!( + p2id_out.assets().iter().next().unwrap().unwrap_fungible(), + FungibleAsset::new(eth_faucet.id(), 25, AssetCallbackFlag::Disabled)?, ); - // Bob's vault: +50 USDC payout, -25 ETH fill. - let vault_delta = executed.account_delta().vault(); - assert_vault_added_removed( - vault_delta, - FungibleAsset::new(usdc_faucet.id(), 50)?, - FungibleAsset::new(eth_faucet.id(), 25)?, + // Bob's vault: +50 USDC payout, -25 ETH fill (Bob spent his entire ETH balance, results in 0). + let vault_patch = executed.account_patch().vault(); + assert_vault_patch( + vault_patch, + [ + FungibleAsset::new(usdc_faucet.id(), 50, AssetCallbackFlag::Disabled)?, + FungibleAsset::new(eth_faucet.id(), 0, AssetCallbackFlag::Disabled)?, + ], ); Ok(()) @@ -958,19 +982,19 @@ async fn pswap_multiple_partial_fills_test(#[case] fill_amount: u64) -> anyhow:: let alice = builder.add_existing_wallet_with_assets( BASIC_AUTH, - [FungibleAsset::new(usdc_faucet.id(), 50)?.into()], + [FungibleAsset::new(usdc_faucet.id(), 50, AssetCallbackFlag::Disabled)?.into()], )?; let bob = builder.add_existing_wallet_with_assets( BASIC_AUTH, - [FungibleAsset::new(eth_faucet.id(), fill_amount)?.into()], + [FungibleAsset::new(eth_faucet.id(), fill_amount, AssetCallbackFlag::Disabled)?.into()], )?; let (pswap, pswap_note) = build_pswap_note( &mut builder, alice.id(), - FungibleAsset::new(usdc_faucet.id(), 50)?, - FungibleAsset::new(eth_faucet.id(), 25)?, + FungibleAsset::new(usdc_faucet.id(), 50, AssetCallbackFlag::Disabled)?, + FungibleAsset::new(eth_faucet.id(), 25, AssetCallbackFlag::Disabled)?, NoteType::Public, )?; @@ -980,8 +1004,11 @@ async fn pswap_multiple_partial_fills_test(#[case] fill_amount: u64) -> anyhow:: note_args_map.insert(pswap_note.id(), PswapNote::create_args(fill_amount, 0)?); let payout_amount = pswap.calculate_offered_for_requested(fill_amount)?; - let (p2id_note, remainder_pswap) = - pswap.execute(bob.id(), Some(FungibleAsset::new(eth_faucet.id(), fill_amount)?), None)?; + let (p2id_note, remainder_pswap) = pswap.execute( + bob.id(), + Some(FungibleAsset::new(eth_faucet.id(), fill_amount, AssetCallbackFlag::Disabled)?), + None, + )?; let mut expected_notes = vec![RawOutputNote::Full(p2id_note)]; if let Some(remainder) = remainder_pswap { @@ -1000,9 +1027,16 @@ async fn pswap_multiple_partial_fills_test(#[case] fill_amount: u64) -> anyhow:: let expected_count = if fill_amount < 25 { 2 } else { 1 }; assert_eq!(output_notes.num_notes(), expected_count); - // Verify Bob's vault - let vault_delta = executed_transaction.account_delta().vault(); - assert_vault_single_added(vault_delta, FungibleAsset::new(usdc_faucet.id(), payout_amount)?); + // Verify Bob's vault: +payout USDC, and -fill ETH (Bob spent his entire ETH balance, + // results in 0). + let vault_patch = executed_transaction.account_patch().vault(); + assert_vault_patch( + vault_patch, + [ + FungibleAsset::new(usdc_faucet.id(), payout_amount, AssetCallbackFlag::Disabled)?, + FungibleAsset::new(eth_faucet.id(), 0, AssetCallbackFlag::Disabled)?, + ], + ); Ok(()) } @@ -1028,18 +1062,18 @@ async fn run_partial_fill_ratio_case( let alice = builder.add_existing_wallet_with_assets( BASIC_AUTH, - [FungibleAsset::new(usdc_faucet.id(), offered_usdc)?.into()], + [FungibleAsset::new(usdc_faucet.id(), offered_usdc, AssetCallbackFlag::Disabled)?.into()], )?; let bob = builder.add_existing_wallet_with_assets( BASIC_AUTH, - [FungibleAsset::new(eth_faucet.id(), fill_eth)?.into()], + [FungibleAsset::new(eth_faucet.id(), fill_eth, AssetCallbackFlag::Disabled)?.into()], )?; let (pswap, pswap_note) = build_pswap_note( &mut builder, alice.id(), - FungibleAsset::new(usdc_faucet.id(), offered_usdc)?, - FungibleAsset::new(eth_faucet.id(), requested_eth)?, + FungibleAsset::new(usdc_faucet.id(), offered_usdc, AssetCallbackFlag::Disabled)?, + FungibleAsset::new(eth_faucet.id(), requested_eth, AssetCallbackFlag::Disabled)?, NoteType::Public, )?; @@ -1054,8 +1088,11 @@ async fn run_partial_fill_ratio_case( assert!(payout_amount > 0, "payout_amount must be > 0"); assert!(payout_amount <= offered_usdc, "payout_amount > offered"); - let (p2id_note, remainder_pswap) = - pswap.execute(bob.id(), Some(FungibleAsset::new(eth_faucet.id(), fill_eth)?), None)?; + let (p2id_note, remainder_pswap) = pswap.execute( + bob.id(), + Some(FungibleAsset::new(eth_faucet.id(), fill_eth, AssetCallbackFlag::Disabled)?), + None, + )?; let mut expected_notes = vec![RawOutputNote::Full(p2id_note)]; if remaining_requested > 0 { @@ -1075,11 +1112,14 @@ async fn run_partial_fill_ratio_case( let expected_count = if remaining_requested > 0 { 2 } else { 1 }; assert_eq!(output_notes.num_notes(), expected_count); - let vault_delta = executed_tx.account_delta().vault(); - assert_vault_added_removed( - vault_delta, - FungibleAsset::new(usdc_faucet.id(), payout_amount)?, - FungibleAsset::new(eth_faucet.id(), fill_eth)?, + let vault_patch = executed_tx.account_patch().vault(); + // New ETH balance should be zero. + assert_vault_patch( + vault_patch, + [ + FungibleAsset::new(usdc_faucet.id(), payout_amount, AssetCallbackFlag::Disabled)?, + FungibleAsset::new(eth_faucet.id(), 0, AssetCallbackFlag::Disabled)?, + ], ); assert_eq!(payout_amount + remaining_offered, offered_usdc, "conservation"); @@ -1199,18 +1239,24 @@ async fn pswap_chained_partial_fills_test( let alice = builder.add_existing_wallet_with_assets( BASIC_AUTH, - [FungibleAsset::new(usdc_faucet.id(), current_offered)?.into()], + [ + FungibleAsset::new(usdc_faucet.id(), current_offered, AssetCallbackFlag::Disabled)? + .into(), + ], )?; let bob = builder.add_existing_wallet_with_assets( BASIC_AUTH, - [FungibleAsset::new(eth_faucet.id(), *fill_amount)?.into()], + [FungibleAsset::new(eth_faucet.id(), *fill_amount, AssetCallbackFlag::Disabled)? + .into()], )?; // Use the PswapNote builder directly so we can inject `current_serial` // for this chain position (each remainder in the chain bumps // `serial[3] + 1`, and the test walks through that sequence manually). - let offered_fungible = FungibleAsset::new(usdc_faucet.id(), current_offered)?; - let requested_fungible = FungibleAsset::new(eth_faucet.id(), current_requested)?; + let offered_fungible = + FungibleAsset::new(usdc_faucet.id(), current_offered, AssetCallbackFlag::Disabled)?; + let requested_fungible = + FungibleAsset::new(eth_faucet.id(), current_requested, AssetCallbackFlag::Disabled)?; let storage = PswapNoteStorage::builder() .requested_asset(requested_fungible) @@ -1235,7 +1281,7 @@ async fn pswap_chained_partial_fills_test( let remaining_offered = current_offered - payout_amount; let (p2id_note, remainder_pswap) = pswap.execute( bob.id(), - Some(FungibleAsset::new(eth_faucet.id(), *fill_amount)?), + Some(FungibleAsset::new(eth_faucet.id(), *fill_amount, AssetCallbackFlag::Disabled)?), None, )?; @@ -1267,10 +1313,15 @@ async fn pswap_chained_partial_fills_test( let expected_count = if remaining_requested > 0 { 2 } else { 1 }; assert_eq!(output_notes.num_notes(), expected_count, "fill {}", fill_index + 1); - let vault_delta = executed_tx.account_delta().vault(); - assert_vault_single_added( - vault_delta, - FungibleAsset::new(usdc_faucet.id(), payout_amount)?, + // Bob's vault: +payout USDC, and -fill ETH (Bob spent his entire ETH balance, + // results in 0). + let vault_patch = executed_tx.account_patch().vault(); + assert_vault_patch( + vault_patch, + [ + FungibleAsset::new(usdc_faucet.id(), payout_amount, AssetCallbackFlag::Disabled)?, + FungibleAsset::new(eth_faucet.id(), 0, AssetCallbackFlag::Disabled)?, + ], ); // Update state for next fill @@ -1305,19 +1356,24 @@ fn compare_pswap_create_output_notes_vs_test_helper() { let alice = builder .add_existing_wallet_with_assets( BASIC_AUTH, - [FungibleAsset::new(usdc_faucet.id(), 50).unwrap().into()], + [FungibleAsset::new(usdc_faucet.id(), 50, AssetCallbackFlag::Disabled) + .unwrap() + .into()], ) .unwrap(); let bob = builder .add_existing_wallet_with_assets( BASIC_AUTH, - [FungibleAsset::new(eth_faucet.id(), 25).unwrap().into()], + [FungibleAsset::new(eth_faucet.id(), 25, AssetCallbackFlag::Disabled) + .unwrap() + .into()], ) .unwrap(); // Create swap note using PswapNote builder let mut rng = RandomCoin::new(Word::default()); - let requested_asset = FungibleAsset::new(eth_faucet.id(), 25).unwrap(); + let requested_asset = + FungibleAsset::new(eth_faucet.id(), 25, AssetCallbackFlag::Disabled).unwrap(); let storage = PswapNoteStorage::builder() .requested_asset(requested_asset) .creator_account_id(alice.id()) @@ -1328,7 +1384,9 @@ fn compare_pswap_create_output_notes_vs_test_helper() { .storage(storage) .serial_number(rng.draw_word()) .note_type(NoteType::Public) - .offered_asset(FungibleAsset::new(usdc_faucet.id(), 50).unwrap()) + .offered_asset( + FungibleAsset::new(usdc_faucet.id(), 50, AssetCallbackFlag::Disabled).unwrap(), + ) .build() .unwrap() .into(); @@ -1344,7 +1402,11 @@ fn compare_pswap_create_output_notes_vs_test_helper() { // Full fill: should produce P2ID note, no remainder let (p2id_note, remainder) = pswap - .execute(bob.id(), Some(FungibleAsset::new(eth_faucet.id(), 25).unwrap()), None) + .execute( + bob.id(), + Some(FungibleAsset::new(eth_faucet.id(), 25, AssetCallbackFlag::Disabled).unwrap()), + None, + ) .unwrap(); assert!(remainder.is_none(), "Full fill should not produce remainder"); @@ -1352,21 +1414,25 @@ fn compare_pswap_create_output_notes_vs_test_helper() { assert_eq!(p2id_note.metadata().sender(), bob.id(), "P2ID sender should be consumer"); assert_eq!(p2id_note.metadata().note_type(), NoteType::Public, "P2ID note type mismatch"); assert_eq!(p2id_note.assets().num_assets(), 1, "P2ID should have 1 asset"); - assert_fungible_asset_eq( - p2id_note.assets().iter().next().unwrap(), - FungibleAsset::new(eth_faucet.id(), 25).unwrap(), + assert_eq!( + p2id_note.assets().iter().next().unwrap().unwrap_fungible(), + FungibleAsset::new(eth_faucet.id(), 25, AssetCallbackFlag::Disabled).unwrap(), ); // Partial fill: should produce P2ID note + remainder let (p2id_partial, remainder_partial) = pswap - .execute(bob.id(), Some(FungibleAsset::new(eth_faucet.id(), 10).unwrap()), None) + .execute( + bob.id(), + Some(FungibleAsset::new(eth_faucet.id(), 10, AssetCallbackFlag::Disabled).unwrap()), + None, + ) .unwrap(); let remainder_pswap = remainder_partial.expect("Partial fill should produce remainder"); assert_eq!(p2id_partial.assets().num_assets(), 1); - assert_fungible_asset_eq( - p2id_partial.assets().iter().next().unwrap(), - FungibleAsset::new(eth_faucet.id(), 10).unwrap(), + assert_eq!( + p2id_partial.assets().iter().next().unwrap().unwrap_fungible(), + FungibleAsset::new(eth_faucet.id(), 10, AssetCallbackFlag::Disabled).unwrap(), ); // Verify remainder properties @@ -1392,14 +1458,14 @@ fn pswap_original_has_no_pswap_scheme() -> anyhow::Result<()> { let eth_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", 1000, Some(50))?; let alice = builder.add_existing_wallet_with_assets( BASIC_AUTH, - [FungibleAsset::new(usdc_faucet.id(), 50)?.into()], + [FungibleAsset::new(usdc_faucet.id(), 50, AssetCallbackFlag::Disabled)?.into()], )?; let (pswap, _) = build_pswap_note( &mut builder, alice.id(), - FungibleAsset::new(usdc_faucet.id(), 50)?, - FungibleAsset::new(eth_faucet.id(), 25)?, + FungibleAsset::new(usdc_faucet.id(), 50, AssetCallbackFlag::Disabled)?, + FungibleAsset::new(eth_faucet.id(), 25, AssetCallbackFlag::Disabled)?, NoteType::Public, )?; @@ -1427,22 +1493,22 @@ fn pswap_remainder_carries_pswap_scheme() -> anyhow::Result<()> { let eth_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", 1000, Some(50))?; let alice = builder.add_existing_wallet_with_assets( BASIC_AUTH, - [FungibleAsset::new(usdc_faucet.id(), 50)?.into()], + [FungibleAsset::new(usdc_faucet.id(), 50, AssetCallbackFlag::Disabled)?.into()], )?; let bob = builder.add_existing_wallet_with_assets( BASIC_AUTH, - [FungibleAsset::new(eth_faucet.id(), 10)?.into()], + [FungibleAsset::new(eth_faucet.id(), 10, AssetCallbackFlag::Disabled)?.into()], )?; let (pswap, _) = build_pswap_note( &mut builder, alice.id(), - FungibleAsset::new(usdc_faucet.id(), 50)?, - FungibleAsset::new(eth_faucet.id(), 25)?, + FungibleAsset::new(usdc_faucet.id(), 50, AssetCallbackFlag::Disabled)?, + FungibleAsset::new(eth_faucet.id(), 25, AssetCallbackFlag::Disabled)?, NoteType::Public, )?; - let account_fill = FungibleAsset::new(eth_faucet.id(), 10)?; + let account_fill = FungibleAsset::new(eth_faucet.id(), 10, AssetCallbackFlag::Disabled)?; let (_, remainder_pswap) = pswap.execute(bob.id(), Some(account_fill), None)?; let remainder_pswap = remainder_pswap.expect("partial fill should produce a remainder"); @@ -1488,20 +1554,28 @@ async fn pswap_creator_reconstructs_lineage_from_attachments() -> anyhow::Result let alice = builder.add_existing_wallet_with_assets(BASIC_AUTH, [])?; let bob = builder.add_existing_wallet_with_assets( BASIC_AUTH, - [FungibleAsset::new(eth_faucet.id(), total_fill)?.into()], + [FungibleAsset::new(eth_faucet.id(), total_fill, AssetCallbackFlag::Disabled)?.into()], )?; let original_pswap = PswapNote::builder() .sender(alice.id()) .storage( PswapNoteStorage::builder() - .requested_asset(FungibleAsset::new(eth_faucet.id(), initial_requested)?) + .requested_asset(FungibleAsset::new( + eth_faucet.id(), + initial_requested, + AssetCallbackFlag::Disabled, + )?) .creator_account_id(alice.id()) .build(), ) .serial_number(RandomCoin::new(Word::default()).draw_word()) .note_type(NoteType::Public) - .offered_asset(FungibleAsset::new(usdc_faucet.id(), initial_offered)?) + .offered_asset(FungibleAsset::new( + usdc_faucet.id(), + initial_offered, + AssetCallbackFlag::Disabled, + )?) .build()?; let original_pswap_note: Note = original_pswap.clone().into(); builder.add_output_note(RawOutputNote::Full(original_pswap_note.clone())); @@ -1515,6 +1589,9 @@ async fn pswap_creator_reconstructs_lineage_from_attachments() -> anyhow::Result let mut current_pswap_note = original_pswap_note; let mut current_offered = initial_offered; let mut current_requested = initial_requested; + // Alice starts with an empty wallet and accumulates `fill_amount` ETH each round, so the + // patch's absolute balance is the running total of all fills consumed so far. + let mut alice_eth_balance = 0; for (idx, fill_amount) in fills.iter().copied().enumerate() { let depth = (idx + 1) as u32; @@ -1526,7 +1603,7 @@ async fn pswap_creator_reconstructs_lineage_from_attachments() -> anyhow::Result let (predicted_payback_note, predicted_remainder_pswap) = current_pswap.execute( bob.id(), - Some(FungibleAsset::new(eth_faucet.id(), fill_amount)?), + Some(FungibleAsset::new(eth_faucet.id(), fill_amount, AssetCallbackFlag::Disabled)?), None, )?; @@ -1606,9 +1683,14 @@ async fn pswap_creator_reconstructs_lineage_from_attachments() -> anyhow::Result .build()? .execute() .await?; - assert_vault_single_added( - alice_tx.account_delta().vault(), - FungibleAsset::new(eth_faucet.id(), fill_amount)?, + alice_eth_balance += fill_amount; + assert_vault_patch( + alice_tx.account_patch().vault(), + [FungibleAsset::new( + eth_faucet.id(), + alice_eth_balance, + AssetCallbackFlag::Disabled, + )?], ); mock_chain.add_pending_executed_transaction(&alice_tx)?; mock_chain.prove_next_block()?; @@ -1640,11 +1722,11 @@ async fn pswap_disambiguates_multiple_creator_pswaps_in_same_tx() -> anyhow::Res let alice = builder.add_existing_wallet_with_assets( BASIC_AUTH, - [FungibleAsset::new(usdc_faucet.id(), 100)?.into()], + [FungibleAsset::new(usdc_faucet.id(), 100, AssetCallbackFlag::Disabled)?.into()], )?; let bob = builder.add_existing_wallet_with_assets( BASIC_AUTH, - [FungibleAsset::new(eth_faucet.id(), 30)?.into()], + [FungibleAsset::new(eth_faucet.id(), 30, AssetCallbackFlag::Disabled)?.into()], )?; // Two PSWAPs from Alice, both USDC → ETH, but distinct serials → distinct order_ids. @@ -1652,7 +1734,7 @@ async fn pswap_disambiguates_multiple_creator_pswaps_in_same_tx() -> anyhow::Res let mut rng = RandomCoin::new(Word::default()); let serial = rng.draw_word(); let storage = PswapNoteStorage::builder() - .requested_asset(FungibleAsset::new(eth_faucet.id(), 20)?) + .requested_asset(FungibleAsset::new(eth_faucet.id(), 20, AssetCallbackFlag::Disabled)?) .creator_account_id(alice.id()) .build(); @@ -1661,7 +1743,7 @@ async fn pswap_disambiguates_multiple_creator_pswaps_in_same_tx() -> anyhow::Res .storage(storage) .serial_number(serial) .note_type(NoteType::Public) - .offered_asset(FungibleAsset::new(usdc_faucet.id(), 40)?) + .offered_asset(FungibleAsset::new(usdc_faucet.id(), 40, AssetCallbackFlag::Disabled)?) .build()? }; let pswap_b = { @@ -1669,7 +1751,7 @@ async fn pswap_disambiguates_multiple_creator_pswaps_in_same_tx() -> anyhow::Res let mut rng = RandomCoin::new(Word::from([Felt::from(7u32); 4])); let serial = rng.draw_word(); let storage = PswapNoteStorage::builder() - .requested_asset(FungibleAsset::new(eth_faucet.id(), 30)?) + .requested_asset(FungibleAsset::new(eth_faucet.id(), 30, AssetCallbackFlag::Disabled)?) .creator_account_id(alice.id()) .build(); @@ -1678,7 +1760,7 @@ async fn pswap_disambiguates_multiple_creator_pswaps_in_same_tx() -> anyhow::Res .storage(storage) .serial_number(serial) .note_type(NoteType::Public) - .offered_asset(FungibleAsset::new(usdc_faucet.id(), 60)?) + .offered_asset(FungibleAsset::new(usdc_faucet.id(), 60, AssetCallbackFlag::Disabled)?) .build()? }; @@ -1696,10 +1778,16 @@ async fn pswap_disambiguates_multiple_creator_pswaps_in_same_tx() -> anyhow::Res note_args.insert(note_a.id(), PswapNote::create_args(fill_each, 0)?); note_args.insert(note_b.id(), PswapNote::create_args(fill_each, 0)?); - let (payback_a, remainder_a) = - pswap_a.execute(bob.id(), Some(FungibleAsset::new(eth_faucet.id(), fill_each)?), None)?; - let (payback_b, remainder_b) = - pswap_b.execute(bob.id(), Some(FungibleAsset::new(eth_faucet.id(), fill_each)?), None)?; + let (payback_a, remainder_a) = pswap_a.execute( + bob.id(), + Some(FungibleAsset::new(eth_faucet.id(), fill_each, AssetCallbackFlag::Disabled)?), + None, + )?; + let (payback_b, remainder_b) = pswap_b.execute( + bob.id(), + Some(FungibleAsset::new(eth_faucet.id(), fill_each, AssetCallbackFlag::Disabled)?), + None, + )?; let remainder_a_note = Note::from(remainder_a.expect("partial fill A produces remainder")); let remainder_b_note = Note::from(remainder_b.expect("partial fill B produces remainder")); @@ -1767,15 +1855,17 @@ fn pswap_parse_inputs_roundtrip() { let alice = builder .add_existing_wallet_with_assets( BASIC_AUTH, - [FungibleAsset::new(usdc_faucet.id(), 50).unwrap().into()], + [FungibleAsset::new(usdc_faucet.id(), 50, AssetCallbackFlag::Disabled) + .unwrap() + .into()], ) .unwrap(); let (_, pswap_note) = build_pswap_note( &mut builder, alice.id(), - FungibleAsset::new(usdc_faucet.id(), 50).unwrap(), - FungibleAsset::new(eth_faucet.id(), 25).unwrap(), + FungibleAsset::new(usdc_faucet.id(), 50, AssetCallbackFlag::Disabled).unwrap(), + FungibleAsset::new(eth_faucet.id(), 25, AssetCallbackFlag::Disabled).unwrap(), NoteType::Public, ) .unwrap(); diff --git a/crates/miden-testing/tests/scripts/rbac.rs b/crates/miden-testing/tests/scripts/rbac.rs index 5ec76d81a2..68a47cdfea 100644 --- a/crates/miden-testing/tests/scripts/rbac.rs +++ b/crates/miden-testing/tests/scripts/rbac.rs @@ -1,5 +1,6 @@ extern crate alloc; +use alloc::collections::BTreeMap; use alloc::string::String; use core::slice; @@ -11,6 +12,7 @@ use miden_protocol::account::{ AccountIdVersion, AccountType, RoleSymbol, + StorageMapKey, }; use miden_protocol::errors::AccountIdError; use miden_protocol::note::{Note, NoteType}; @@ -32,7 +34,7 @@ fn create_rbac_account_with_owner(owner: AccountId) -> anyhow::Result { let account = AccountBuilder::new([9; 32]) .account_type(AccountType::Public) .with_auth_component(Auth::IncrNonce) - .with_components(AccessControl::Rbac { owner, authority_role: None }) + .with_components(AccessControl::Rbac { owner, roles: BTreeMap::new() }) .build_existing()?; Ok(account) @@ -54,12 +56,17 @@ fn role(name: &str) -> RoleSymbol { RoleSymbol::new(name).expect("role symbol should be valid") } -fn role_config_key(role: &RoleSymbol) -> Word { - Word::from([Felt::ZERO, Felt::ZERO, Felt::ZERO, Felt::from(role)]) +fn role_config_key(role: &RoleSymbol) -> StorageMapKey { + StorageMapKey::from_raw(Word::from([Felt::ZERO, Felt::ZERO, Felt::ZERO, Felt::from(role)])) } -fn role_membership_key(role: &RoleSymbol, account_id: AccountId) -> Word { - Word::from([Felt::ZERO, Felt::from(role), account_id.suffix(), account_id.prefix().as_felt()]) +fn role_membership_key(role: &RoleSymbol, account_id: AccountId) -> StorageMapKey { + StorageMapKey::from_raw(Word::from([ + Felt::ZERO, + Felt::from(role), + account_id.suffix(), + account_id.prefix().as_felt(), + ])) } fn account_id_from_felt_pair( @@ -118,7 +125,7 @@ async fn execute_note_and_apply( let executed = tx.execute().await?; let mut updated = account.clone(); - updated.apply_delta(executed.account_delta())?; + updated.apply_patch(executed.account_patch())?; Ok(updated) } @@ -325,23 +332,6 @@ fn set_role_admin_raw_script(role: Felt, admin_role: Felt) -> String { ) } -fn assert_sender_has_role_script(role: &RoleSymbol) -> String { - format!( - r#" - use miden::standards::access::rbac - - @note_script - pub proc main - repeat.15 push.0 end - push.{role} - call.rbac::assert_sender_has_role - dropw dropw dropw dropw - end - "#, - role = Felt::from(role), - ) -} - // TESTS // ================================================================================================ @@ -671,34 +661,6 @@ async fn test_rbac_member_count_and_has_role_queries() -> anyhow::Result<()> { Ok(()) } -#[tokio::test] -async fn test_rbac_assert_sender_has_role() -> anyhow::Result<()> { - let owner = test_account_id(120); - let minter = test_account_id(121); - let outsider = test_account_id(122); - - let minter_role = role("MINTER"); - - let (account, mock_chain) = create_rbac_chain(owner)?; - - let grant_note = build_note(owner, grant_role_script(&minter_role, minter))?; - let updated = execute_note_and_apply(&mock_chain, &account, &grant_note).await?; - - // Member can pass the assertion. - let member_check = build_note(minter, assert_sender_has_role_script(&minter_role))?; - let _ = execute_note_and_apply(&mock_chain, &updated, &member_check).await?; - - // Outsider cannot. - let outsider_check = build_note(outsider, assert_sender_has_role_script(&minter_role))?; - let tx = mock_chain - .build_tx_context(updated, &[], slice::from_ref(&outsider_check))? - .build()?; - let result = tx.execute().await; - assert!(result.is_err()); - - Ok(()) -} - #[tokio::test] async fn test_rbac_non_owner_cannot_set_role_admin() -> anyhow::Result<()> { let owner = test_account_id(89); diff --git a/crates/miden-testing/tests/scripts/send_note.rs b/crates/miden-testing/tests/scripts/send_note.rs index e1565146ea..515fe0f964 100644 --- a/crates/miden-testing/tests/scripts/send_note.rs +++ b/crates/miden-testing/tests/scripts/send_note.rs @@ -1,5 +1,5 @@ +use core::num::NonZeroU16; use core::slice; -use std::collections::BTreeMap; use miden_protocol::Word; use miden_protocol::account::auth::AuthScheme; @@ -19,10 +19,10 @@ use miden_protocol::note::{ PartialNoteMetadata, }; use miden_protocol::testing::note::DEFAULT_NOTE_SCRIPT; -use miden_protocol::transaction::RawOutputNote; -use miden_standards::account::interface::{AccountInterface, AccountInterfaceExt}; +use miden_protocol::transaction::{RawOutputNote, TransactionScript}; use miden_standards::code_builder::CodeBuilder; use miden_standards::note::P2idNote; +use miden_standards::tx_script::SendNotesTransactionScript; use miden_testing::utils::create_p2any_note; use miden_testing::{Auth, MockChain}; @@ -62,8 +62,6 @@ async fn test_send_note_script_basic_wallet() -> anyhow::Result<()> { let spawn_note = builder.add_spawn_note([&p2any_note])?; let mock_chain = builder.build()?; - let sender_account_interface = AccountInterface::from_account(&sender_basic_wallet_account); - let attachment_0 = NoteAttachment::with_words( NoteAttachmentScheme::new(42)?, vec![Word::from([9, 8, 7, 6u32]), Word::from([5, 4, 3, 2u32])], @@ -94,9 +92,13 @@ async fn test_send_note_script_basic_wallet() -> anyhow::Result<()> { )?; let partial_note = PartialNote::from(p2id_note.clone()); - let expiration_delta = 10u16; - let send_note_transaction_script = sender_account_interface - .build_send_notes_script(slice::from_ref(&partial_note), Some(expiration_delta))?; + let expiration_delta = NonZeroU16::new(10).expect("10 is non-zero"); + let send_note_transaction_script = + TransactionScript::from(SendNotesTransactionScript::with_expiration_delta( + &sender_basic_wallet_account.code_interface(), + slice::from_ref(&partial_note), + expiration_delta, + )?); let executed_transaction = mock_chain .build_tx_context(sender_basic_wallet_account.id(), &[spawn_note.id()], &[]) @@ -107,24 +109,34 @@ async fn test_send_note_script_basic_wallet() -> anyhow::Result<()> { .execute() .await?; - // assert that the removed asset is in the delta - let mut removed_assets: BTreeMap<_, _> = executed_transaction - .account_delta() - .vault() - .removed_assets() - .map(|asset| (asset.vault_key(), asset)) - .collect(); - assert_eq!(removed_assets.len(), 2, "two assets should have been removed"); + // Assert that the non-fungible asset was removed + let vault_patch = executed_transaction.account_patch().vault(); + assert_eq!( + vault_patch.removed_asset_keys().count(), + 1, + "the non-fungible asset should have been completely removed" + ); + assert_eq!( + vault_patch.removed_asset_keys().next().unwrap(), + &sent_asset0.vault_key(), + "the non-fungible asset should have been completely removed" + ); + + // Assert that the fungible asset's value was decremented assert_eq!( - removed_assets.remove(&sent_asset0.vault_key()).unwrap(), - sent_asset0, - "sent asset0 should be in removed assets" + vault_patch.updated_assets().count(), + 1, + "the fungible asset should have been updated" ); + // Expected value is total - (sent_asset1 + sent_asset2). + let expected_removed = sent_asset1.unwrap_fungible().add(sent_asset2.unwrap_fungible())?; + let expected_asset_value = total_asset.unwrap_fungible().sub(expected_removed)?.into(); assert_eq!( - removed_assets.remove(&sent_asset1.vault_key()).unwrap(), - sent_asset1.unwrap_fungible().add(sent_asset2.unwrap_fungible())?.into(), - "sent asset1 + sent_asset2 should be in removed assets" + vault_patch.updated_assets().next().unwrap(), + expected_asset_value, + "fungible asset should have been decremented" ); + assert_eq!( executed_transaction.output_notes().get_note(0), &RawOutputNote::Partial(p2any_note.into()) @@ -151,16 +163,13 @@ async fn test_send_note_script_fungible_faucet() -> anyhow::Result<()> { )?; let mock_chain = builder.build()?; - let sender_account_interface = AccountInterface::from_account(&sender_fungible_faucet_account); - let tag = NoteTag::with_account_target(sender_fungible_faucet_account.id()); let attachment = NoteAttachment::with_word(NoteAttachmentScheme::new(100)?, Word::empty()); let metadata = PartialNoteMetadata::new(sender_fungible_faucet_account.id(), NoteType::Public) .with_tag(tag); let assets = NoteAssets::new(vec![Asset::Fungible( - FungibleAsset::new(sender_fungible_faucet_account.id(), 10) - .unwrap() - .with_callbacks(AssetCallbackFlag::Enabled), + FungibleAsset::new(sender_fungible_faucet_account.id(), 10, AssetCallbackFlag::Enabled) + .unwrap(), )])?; let note_script = CodeBuilder::default().compile_note_script(DEFAULT_NOTE_SCRIPT).unwrap(); let serial_num = RandomCoin::new(Word::from([1, 2, 3, 4u32])).draw_word(); @@ -170,9 +179,13 @@ async fn test_send_note_script_fungible_faucet() -> anyhow::Result<()> { let note = Note::with_attachments(assets.clone(), metadata, recipient, attachments); let partial_note: PartialNote = note.clone().into(); - let expiration_delta = 10u16; - let send_note_transaction_script = sender_account_interface - .build_send_notes_script(slice::from_ref(&partial_note), Some(expiration_delta))?; + let expiration_delta = NonZeroU16::new(10).expect("10 is non-zero"); + let send_note_transaction_script = + TransactionScript::from(SendNotesTransactionScript::with_expiration_delta( + &sender_fungible_faucet_account.code_interface(), + slice::from_ref(&partial_note), + expiration_delta, + )?); let executed_transaction = mock_chain .build_tx_context(sender_fungible_faucet_account.id(), &[], &[]) diff --git a/crates/miden-testing/tests/scripts/swap.rs b/crates/miden-testing/tests/scripts/swap.rs index 20c3d3fcfb..2b0cc3672c 100644 --- a/crates/miden-testing/tests/scripts/swap.rs +++ b/crates/miden-testing/tests/scripts/swap.rs @@ -2,7 +2,7 @@ use anyhow::Context; use miden_protocol::Felt; use miden_protocol::account::auth::AuthScheme; use miden_protocol::account::{Account, AccountId, AccountType}; -use miden_protocol::asset::{Asset, FungibleAsset, NonFungibleAsset}; +use miden_protocol::asset::{Asset, AssetCallbackFlag, FungibleAsset, NonFungibleAsset}; use miden_protocol::note::{Note, NoteDetails, NoteId, NoteType}; use miden_protocol::testing::account_id::{ ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, @@ -65,8 +65,8 @@ pub async fn prove_send_swap_note() -> anyhow::Result<()> { .await?; sender_account - .apply_delta(create_swap_note_tx.account_delta()) - .context("failed to apply delta")?; + .apply_patch(create_swap_note_tx.account_patch()) + .context("failed to apply patch")?; assert!( create_swap_note_tx @@ -115,8 +115,8 @@ async fn consume_swap_note_private_payback_note() -> anyhow::Result<()> { .await?; target_account - .apply_delta(consume_swap_note_tx.account_delta()) - .context("failed to apply delta to target account")?; + .apply_patch(consume_swap_note_tx.account_patch()) + .context("failed to apply patch to target account")?; let output_payback_note = consume_swap_note_tx.output_notes().iter().next().unwrap().clone(); assert_eq!( @@ -145,8 +145,8 @@ async fn consume_swap_note_private_payback_note() -> anyhow::Result<()> { .await?; sender_account - .apply_delta(consume_payback_tx.account_delta()) - .context("failed to apply delta to sender account")?; + .apply_patch(consume_payback_tx.account_patch()) + .context("failed to apply patch to sender account")?; assert!(sender_account.vault().assets().any(|asset| asset == requested_asset)); @@ -199,7 +199,7 @@ async fn consume_swap_note_public_payback_note() -> anyhow::Result<()> { .execute() .await?; - target_account.apply_delta(consume_swap_note_tx.account_delta())?; + target_account.apply_patch(consume_swap_note_tx.account_patch())?; let output_payback_note = consume_swap_note_tx.output_notes().iter().next().unwrap().clone(); assert_eq!( @@ -227,7 +227,7 @@ async fn consume_swap_note_public_payback_note() -> anyhow::Result<()> { .execute() .await?; - sender_account.apply_delta(consume_payback_tx.account_delta())?; + sender_account.apply_patch(consume_payback_tx.account_patch())?; assert!(sender_account.vault().assets().any(|asset| asset == requested_asset)); Ok(()) @@ -240,8 +240,8 @@ async fn settle_coincidence_of_wants() -> anyhow::Result<()> { // Create two different assets for the swap let faucet0 = AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?; let faucet1 = AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1)?; - let asset_a = FungibleAsset::new(faucet0, 10_777)?.into(); - let asset_b = FungibleAsset::new(faucet1, 10)?.into(); + let asset_a = FungibleAsset::new(faucet0, 10_777, AssetCallbackFlag::Disabled)?.into(); + let asset_b = FungibleAsset::new(faucet1, 10, AssetCallbackFlag::Disabled)?.into(); let mut builder = MockChain::builder(); @@ -331,7 +331,7 @@ fn setup_swap_test(payback_note_type: NoteType) -> anyhow::Result .account_type(AccountType::Private) .build_with_seed([5; 32]); - let offered_asset = FungibleAsset::new(faucet_id, 2000)?.into(); + let offered_asset = FungibleAsset::new(faucet_id, 2000, AssetCallbackFlag::Disabled)?.into(); let requested_asset = NonFungibleAsset::mock(&[1, 2, 3, 4]); let mut builder = MockChain::builder(); diff --git a/crates/miden-testing/tests/wallet/mod.rs b/crates/miden-testing/tests/wallet/mod.rs index f2a71098f6..452405f7bf 100644 --- a/crates/miden-testing/tests/wallet/mod.rs +++ b/crates/miden-testing/tests/wallet/mod.rs @@ -1,6 +1,5 @@ use miden_protocol::Word; use miden_protocol::account::auth::AuthSecretKey; -use miden_standards::AuthMethod; use miden_standards::account::wallets::create_basic_wallet; use rand_chacha::ChaCha20Rng; use rand_chacha::rand_core::SeedableRng; @@ -19,7 +18,7 @@ fn wallet_creation() { let sec_key = AuthSecretKey::new_falcon512_poseidon2_with_rng(&mut rng); let auth_scheme = auth::AuthScheme::Falcon512Poseidon2; let pub_key = sec_key.public_key().to_commitment(); - let auth_method: AuthMethod = AuthMethod::SingleSig { approver: (pub_key, auth_scheme) }; + let auth_component = AuthSingleSig::new(pub_key, auth_scheme); // we need to use an initial seed to create the wallet account let init_seed: [u8; 32] = [ @@ -29,7 +28,7 @@ fn wallet_creation() { let account_type = AccountType::Private; - let wallet = create_basic_wallet(init_seed, auth_method, account_type).unwrap(); + let wallet = create_basic_wallet(init_seed, auth_component, account_type).unwrap(); let expected_code = AccountCode::from_components(&[ AuthSingleSig::new(pub_key, auth_scheme).into(), @@ -58,7 +57,7 @@ fn wallet_creation_2() { let sec_key = AuthSecretKey::new_ecdsa_k256_keccak_with_rng(&mut rng); let auth_scheme = auth::AuthScheme::EcdsaK256Keccak; let pub_key = sec_key.public_key().to_commitment(); - let auth_method: AuthMethod = AuthMethod::SingleSig { approver: (pub_key, auth_scheme) }; + let auth_component = AuthSingleSig::new(pub_key, auth_scheme); // we need to use an initial seed to create the wallet account let init_seed: [u8; 32] = [ @@ -68,7 +67,7 @@ fn wallet_creation_2() { let account_type = AccountType::Private; - let wallet = create_basic_wallet(init_seed, auth_method, account_type).unwrap(); + let wallet = create_basic_wallet(init_seed, auth_component, account_type).unwrap(); let expected_code = AccountCode::from_components(&[ AuthSingleSig::new(pub_key, auth_scheme).into(), diff --git a/crates/miden-tx-batch-prover/Cargo.toml b/crates/miden-tx-batch-prover/Cargo.toml deleted file mode 100644 index df138ae0e2..0000000000 --- a/crates/miden-tx-batch-prover/Cargo.toml +++ /dev/null @@ -1,26 +0,0 @@ -[package] -authors.workspace = true -categories = ["no-std"] -description = "Miden blockchain transaction batch executor and prover" -edition.workspace = true -homepage.workspace = true -keywords = ["batch", "miden", "prover"] -license.workspace = true -name = "miden-tx-batch-prover" -readme = "README.md" -repository.workspace = true -rust-version.workspace = true -version.workspace = true - -[lib] -bench = false -doctest = false - -[features] -default = ["std"] -std = ["miden-protocol/std", "miden-tx/std"] -testing = [] - -[dependencies] -miden-protocol = { workspace = true } -miden-tx = { workspace = true } diff --git a/crates/miden-tx-batch-prover/README.md b/crates/miden-tx-batch-prover/README.md deleted file mode 100644 index 37378de9be..0000000000 --- a/crates/miden-tx-batch-prover/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# Miden Transaction Batch Prover - -This crate contains tools for executing and proving Miden transaction batches. - -## License - -This project is [MIT licensed](../../LICENSE). diff --git a/crates/miden-tx-batch-prover/src/lib.rs b/crates/miden-tx-batch-prover/src/lib.rs deleted file mode 100644 index 368056917b..0000000000 --- a/crates/miden-tx-batch-prover/src/lib.rs +++ /dev/null @@ -1,9 +0,0 @@ -#![no_std] - -extern crate alloc; - -#[cfg(feature = "std")] -extern crate std; - -mod local_batch_prover; -pub use local_batch_prover::LocalBatchProver; diff --git a/crates/miden-tx-batch-prover/src/local_batch_prover.rs b/crates/miden-tx-batch-prover/src/local_batch_prover.rs deleted file mode 100644 index 09a5dc3fce..0000000000 --- a/crates/miden-tx-batch-prover/src/local_batch_prover.rs +++ /dev/null @@ -1,88 +0,0 @@ -use alloc::boxed::Box; - -use miden_protocol::batch::{ProposedBatch, ProvenBatch}; -use miden_protocol::errors::ProvenBatchError; -use miden_tx::TransactionVerifier; - -// LOCAL BATCH PROVER -// ================================================================================================ - -/// A local prover for transaction batches, proving the transactions in a [`ProposedBatch`] and -/// returning a [`ProvenBatch`]. -#[derive(Clone)] -pub struct LocalBatchProver { - proof_security_level: u32, -} - -impl LocalBatchProver { - /// Creates a new [`LocalBatchProver`] instance. - pub fn new(proof_security_level: u32) -> Self { - Self { proof_security_level } - } - - /// Attempts to prove the [`ProposedBatch`] into a [`ProvenBatch`]. - /// - /// Currently we don't perform any recursive proving. For now, this function runs a native - /// verifier for each transaction separately, and outputs a `ProvenBatch` object if all of the - /// individual proofs verify. - /// - /// # Errors - /// - /// Returns an error if: - /// - a proof of any transaction in the batch fails to verify. - pub fn prove(&self, proposed_batch: ProposedBatch) -> Result { - let verifier = TransactionVerifier::new(self.proof_security_level); - - for tx in proposed_batch.transactions() { - verifier.verify(tx).map_err(|source| { - ProvenBatchError::TransactionVerificationFailed { - transaction_id: tx.id(), - source: Box::new(source), - } - })?; - } - - self.prove_inner(proposed_batch) - } - - /// Proves the provided [`ProposedBatch`] into a [`ProvenBatch`], **without verifying batches - /// and proving the block**. - /// - /// This is exposed for testing purposes. - #[cfg(any(feature = "testing", test))] - pub fn prove_dummy( - &self, - proposed_batch: ProposedBatch, - ) -> Result { - self.prove_inner(proposed_batch) - } - - /// Converts a proposed batch into a proven batch. - /// - /// For now, this doesn't do anything interesting. - fn prove_inner(&self, proposed_batch: ProposedBatch) -> Result { - let tx_headers = proposed_batch.transaction_headers(); - let ( - _transactions, - block_header, - _block_chain, - _authenticatable_unauthenticated_notes, - id, - updated_accounts, - input_notes, - output_notes, - batch_expiration_block_num, - ) = proposed_batch.into_parts(); - - ProvenBatch::new_unchecked( - id, - block_header.commitment(), - block_header.block_num(), - updated_accounts, - input_notes, - output_notes, - batch_expiration_block_num, - tx_headers, - ) - } -} diff --git a/crates/miden-tx-batch/Cargo.toml b/crates/miden-tx-batch/Cargo.toml new file mode 100644 index 0000000000..d855a0ac7e --- /dev/null +++ b/crates/miden-tx-batch/Cargo.toml @@ -0,0 +1,32 @@ +[package] +authors.workspace = true +categories = ["no-std"] +description = "Miden blockchain transaction batch executor, prover, and verifier" +edition.workspace = true +homepage.workspace = true +keywords = ["batch", "miden", "prover"] +license.workspace = true +name = "miden-tx-batch" +readme = "README.md" +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[lib] +bench = false +doctest = false + +[features] +default = ["std"] +std = ["miden-processor/std", "miden-protocol/std", "miden-prover/std", "miden-verifier/std"] +testing = ["miden-processor/testing", "miden-protocol/testing"] + +[dependencies] +miden-processor = { workspace = true } +miden-protocol = { workspace = true } +miden-prover = { workspace = true } +miden-verifier = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +miden-protocol = { features = ["testing"], workspace = true } diff --git a/crates/miden-tx-batch/README.md b/crates/miden-tx-batch/README.md new file mode 100644 index 0000000000..a696069007 --- /dev/null +++ b/crates/miden-tx-batch/README.md @@ -0,0 +1,7 @@ +# Miden Transaction Batch + +This crate contains tools for executing, proving, and verifying Miden transaction batches. + +## License + +This project is [MIT licensed](../../LICENSE). diff --git a/crates/miden-tx-batch/src/batch_executor.rs b/crates/miden-tx-batch/src/batch_executor.rs new file mode 100644 index 0000000000..218bfcbcb8 --- /dev/null +++ b/crates/miden-tx-batch/src/batch_executor.rs @@ -0,0 +1,54 @@ +use miden_processor::{DefaultHost, ExecutionError, ExecutionOptions, FastProcessor}; +use miden_protocol::batch::{BatchKernel, BatchOutputs, ProposedBatch}; +use miden_protocol::errors::ProvenBatchError; + +use crate::ExecutedBatch; + +// BATCH EXECUTOR +// ================================================================================================ + +/// Executes the batch kernel over a [`ProposedBatch`], producing an [`ExecutedBatch`]. +#[derive(Clone, Default)] +pub struct BatchExecutor; + +impl BatchExecutor { + /// Creates a new [`BatchExecutor`] instance. + pub fn new() -> Self { + Self + } + + /// Runs the batch kernel over the [`ProposedBatch`], returning an [`ExecutedBatch`] that can be + /// passed to [`LocalBatchProver::prove`](crate::LocalBatchProver::prove). + /// + /// # Errors + /// + /// Returns an error if: + /// - the batch kernel program fails to execute; + /// - the kernel output stack fails to parse. + pub fn execute( + &self, + proposed_batch: ProposedBatch, + ) -> Result { + let (stack_inputs, advice_inputs) = BatchKernel::prepare_inputs(&proposed_batch); + + let processor = FastProcessor::new_with_options( + stack_inputs, + advice_inputs, + ExecutionOptions::default(), + ) + .map_err(ExecutionError::advice_error_no_context) + .map_err(ProvenBatchError::BatchKernelExecutionFailed)?; + + let trace_inputs = processor + .execute_trace_inputs_sync(&BatchKernel::main(), &mut DefaultHost::default()) + .map_err(ProvenBatchError::BatchKernelExecutionFailed)?; + + // Parse and validate the output stack shape (padding cells are zero and the expiration + // fits in u32); the actual output values themselves are not checked until the kernel + // verifies them. + let batch_outputs = BatchOutputs::parse(trace_inputs.stack_outputs()) + .map_err(ProvenBatchError::BatchKernelOutputInvalid)?; + + Ok(ExecutedBatch::new(proposed_batch, trace_inputs, batch_outputs)) + } +} diff --git a/crates/miden-tx-batch/src/errors.rs b/crates/miden-tx-batch/src/errors.rs new file mode 100644 index 0000000000..5a009bb257 --- /dev/null +++ b/crates/miden-tx-batch/src/errors.rs @@ -0,0 +1,14 @@ +use miden_verifier::VerificationError; +use thiserror::Error; + +// BATCH VERIFIER ERROR +// ================================================================================================ + +/// Errors returned when verifying a [`ProvenBatch`](miden_protocol::batch::ProvenBatch)'s proof. +#[derive(Debug, Error)] +pub enum BatchVerifierError { + #[error("failed to verify batch")] + BatchVerificationFailed(#[source] VerificationError), + #[error("batch proof security level is {actual} but must be at least {expected_minimum}")] + InsufficientProofSecurityLevel { actual: u32, expected_minimum: u32 }, +} diff --git a/crates/miden-tx-batch/src/executed_batch.rs b/crates/miden-tx-batch/src/executed_batch.rs new file mode 100644 index 0000000000..7bbf365060 --- /dev/null +++ b/crates/miden-tx-batch/src/executed_batch.rs @@ -0,0 +1,48 @@ +use miden_processor::TraceBuildInputs; +use miden_protocol::batch::{BatchOutputs, ProposedBatch}; + +// EXECUTED BATCH +// ================================================================================================ + +/// A [`ProposedBatch`] whose batch kernel has been executed, but not yet proven. +/// +/// Produced by [`BatchExecutor::execute`](crate::BatchExecutor::execute) and consumed by +/// [`LocalBatchProver::prove`](crate::LocalBatchProver::prove). It carries the executed batch's +/// trace inputs so that proving only needs to build the trace and generate the proof. +pub struct ExecutedBatch { + proposed_batch: ProposedBatch, + trace_inputs: TraceBuildInputs, + batch_outputs: BatchOutputs, +} + +impl ExecutedBatch { + /// Creates a new [`ExecutedBatch`] from the proposed batch, the trace inputs and the public + /// outputs produced by executing the batch kernel over it. + pub(crate) fn new( + proposed_batch: ProposedBatch, + trace_inputs: TraceBuildInputs, + batch_outputs: BatchOutputs, + ) -> Self { + Self { + proposed_batch, + trace_inputs, + batch_outputs, + } + } + + /// Returns the [`ProposedBatch`] this batch was executed from. + pub fn proposed_batch(&self) -> &ProposedBatch { + &self.proposed_batch + } + + /// Returns the public outputs produced by the batch kernel. + pub fn batch_outputs(&self) -> &BatchOutputs { + &self.batch_outputs + } + + /// Consumes the executed batch, returning the proposed batch and the trace inputs needed to + /// prove it. + pub(crate) fn into_parts(self) -> (ProposedBatch, TraceBuildInputs) { + (self.proposed_batch, self.trace_inputs) + } +} diff --git a/crates/miden-tx-batch/src/lib.rs b/crates/miden-tx-batch/src/lib.rs new file mode 100644 index 0000000000..66280230f5 --- /dev/null +++ b/crates/miden-tx-batch/src/lib.rs @@ -0,0 +1,21 @@ +#![no_std] + +extern crate alloc; + +#[cfg(feature = "std")] +extern crate std; + +mod batch_executor; +pub use batch_executor::BatchExecutor; + +mod errors; +pub use errors::BatchVerifierError; + +mod executed_batch; +pub use executed_batch::ExecutedBatch; + +mod local_batch_prover; +pub use local_batch_prover::LocalBatchProver; + +mod verifier; +pub use verifier::BatchVerifier; diff --git a/crates/miden-tx-batch/src/local_batch_prover.rs b/crates/miden-tx-batch/src/local_batch_prover.rs new file mode 100644 index 0000000000..8eec60af3b --- /dev/null +++ b/crates/miden-tx-batch/src/local_batch_prover.rs @@ -0,0 +1,87 @@ +use miden_protocol::batch::{ProposedBatch, ProvenBatch}; +use miden_protocol::errors::ProvenBatchError; +use miden_prover::{ExecutionProof, ProvingOptions, TraceProvingInputs, prove_from_trace_sync}; + +use crate::ExecutedBatch; + +// LOCAL BATCH PROVER +// ================================================================================================ + +/// A local prover for transaction batches. +/// +/// Proves an [`ExecutedBatch`] (produced by [`BatchExecutor`](crate::BatchExecutor)) into a +/// [`ProvenBatch`] carrying an [`ExecutionProof`] over the batch's public commitments. +#[derive(Clone, Default)] +pub struct LocalBatchProver { + proving_options: ProvingOptions, +} + +impl LocalBatchProver { + /// Creates a new [`LocalBatchProver`] instance. + pub fn new() -> Self { + Self::default() + } + + /// Proves the [`ExecutedBatch`] into a [`ProvenBatch`]. + /// + /// Builds the execution trace from the executed batch and generates the proof, attaching it to + /// the returned [`ProvenBatch`]. The kernel's public outputs are not yet cross-checked against + /// the proposed batch's expected values. + /// + /// # Errors + /// + /// Returns an error if proof generation fails. + pub fn prove(&self, executed_batch: ExecutedBatch) -> Result { + let (proposed_batch, trace_inputs) = executed_batch.into_parts(); + + let (_stack_outputs, proof) = prove_from_trace_sync(TraceProvingInputs::new( + trace_inputs, + self.proving_options.clone(), + )) + .map_err(ProvenBatchError::BatchKernelExecutionFailed)?; + + Self::build_proven_batch(proposed_batch, proof) + } + + /// Returns a [`ProvenBatch`] built from the proposed batch with a dummy [`ExecutionProof`] + /// attached, without running the batch kernel. + #[cfg(any(feature = "testing", test))] + pub fn prove_dummy( + &self, + proposed_batch: ProposedBatch, + ) -> Result { + Self::build_proven_batch(proposed_batch, ExecutionProof::new_dummy()) + } + + /// Combines the parts of a [`ProposedBatch`] with the produced [`ExecutionProof`] into a + /// [`ProvenBatch`]. + fn build_proven_batch( + proposed_batch: ProposedBatch, + proof: ExecutionProof, + ) -> Result { + let tx_headers = proposed_batch.transaction_headers(); + let ( + _transactions, + block_header, + _block_chain, + _authenticatable_unauthenticated_notes, + id, + updated_accounts, + input_notes, + output_notes, + batch_expiration_block_num, + ) = proposed_batch.into_parts(); + + ProvenBatch::new_unchecked( + id, + block_header.commitment(), + block_header.block_num(), + updated_accounts, + input_notes, + output_notes, + batch_expiration_block_num, + tx_headers, + proof, + ) + } +} diff --git a/crates/miden-tx-batch/src/verifier.rs b/crates/miden-tx-batch/src/verifier.rs new file mode 100644 index 0000000000..a4b1047c86 --- /dev/null +++ b/crates/miden-tx-batch/src/verifier.rs @@ -0,0 +1,74 @@ +use miden_protocol::Word; +use miden_protocol::batch::{BatchKernel, BatchOutputs, ProvenBatch}; +use miden_protocol::block::BlockNumber; +use miden_protocol::vm::ProgramInfo; +use miden_verifier::verify; + +use crate::BatchVerifierError; + +// BATCH VERIFIER +// ================================================================================================ + +/// The [`BatchVerifier`] verifies the execution proof attached to a [`ProvenBatch`] against the +/// batch kernel program. +/// +/// The `proof_security_level` specifies the minimum security level (in bits) the batch proof must +/// have to be considered valid. +/// +/// # Warning +/// +/// The current batch kernel is a skeleton that drops its inputs and emits an all-zero output +/// region, so a successful [`verify`](BatchVerifier::verify) attests only that the kernel program +/// ran over the batch's `[BLOCK_COMMITMENT, BATCH_ID]` public inputs. It does **not** yet bind the +/// batch's notes, account updates, or expiration: those values are not part of what the proof +/// commits to, so a `ProvenBatch` whose contents were mutated would still verify. This verifier +/// must therefore not be relied on at a trust boundary until the kernel verification logic that +/// emits and binds the real commitments lands. +pub struct BatchVerifier { + batch_program_info: ProgramInfo, + proof_security_level: u32, +} + +impl BatchVerifier { + /// Returns a new [`BatchVerifier`] instantiated with the specified minimum security level. + pub fn new(proof_security_level: u32) -> Self { + let batch_program_info = BatchKernel::program_info(); + Self { batch_program_info, proof_security_level } + } + + /// Verifies the provided [`ProvenBatch`]'s execution proof against the batch kernel. + /// + /// # Errors + /// Returns an error if: + /// - Batch proof verification fails. + /// - The security level of the verified proof is insufficient. + pub fn verify(&self, batch: &ProvenBatch) -> Result<(), BatchVerifierError> { + let stack_inputs = + BatchKernel::build_input_stack(batch.reference_block_commitment(), batch.id()); + + // The skeleton kernel drops its inputs and emits the all-zero output region, so the proof + // attests to empty outputs. Once the kernel computes the real commitments, these empty + // values become `batch.input_notes().commitment()`, the batch note tree root and + // `batch.batch_expiration_block_num()`. + let stack_outputs = + BatchOutputs::new(Word::empty(), Word::empty(), BlockNumber::from(0u32)) + .into_stack_outputs(); + + let proof_security_level = verify( + self.batch_program_info.clone(), + stack_inputs, + stack_outputs, + batch.proof().clone(), + ) + .map_err(BatchVerifierError::BatchVerificationFailed)?; + + if proof_security_level < self.proof_security_level { + return Err(BatchVerifierError::InsufficientProofSecurityLevel { + actual: proof_security_level, + expected_minimum: self.proof_security_level, + }); + } + + Ok(()) + } +} diff --git a/crates/miden-tx/Cargo.toml b/crates/miden-tx/Cargo.toml index 629d794521..dff2035d72 100644 --- a/crates/miden-tx/Cargo.toml +++ b/crates/miden-tx/Cargo.toml @@ -17,16 +17,9 @@ doctest = false [features] concurrent = ["miden-prover/concurrent", "std"] -default = ["std"] -std = [ - "concurrent", - "miden-processor/std", - "miden-protocol/std", - "miden-prover/std", - "miden-standards/std", - "miden-verifier/std", -] -testing = ["miden-processor/testing", "miden-protocol/testing", "miden-standards/testing"] +default = ["std"] +std = ["concurrent", "miden-processor/std", "miden-protocol/std", "miden-prover/std", "miden-standards/std"] +testing = ["miden-processor/testing", "miden-protocol/testing", "miden-standards/testing"] [dependencies] # Workspace dependencies @@ -36,7 +29,6 @@ miden-standards = { workspace = true } # Miden dependencies miden-processor = { workspace = true } miden-prover = { workspace = true } -miden-verifier = { workspace = true } # External dependencies thiserror = { workspace = true } diff --git a/crates/miden-tx/src/errors/mod.rs b/crates/miden-tx/src/errors/mod.rs index f56aea7131..69b99429eb 100644 --- a/crates/miden-tx/src/errors/mod.rs +++ b/crates/miden-tx/src/errors/mod.rs @@ -13,19 +13,16 @@ use miden_protocol::block::BlockNumber; use miden_protocol::crypto::merkle::smt::SmtProofError; use miden_protocol::errors::{ AccountDeltaError, - AccountError, AssetError, NoteError, OutputNoteError, ProvenTransactionError, TransactionInputError, - TransactionInputsExtractionError, TransactionOutputError, }; use miden_protocol::note::{NoteId, PartialNoteMetadata}; use miden_protocol::transaction::TransactionSummary; use miden_protocol::{Felt, Word}; -use miden_verifier::VerificationError; use thiserror::Error; // NOTE EXECUTION ERROR @@ -86,14 +83,10 @@ impl From for TransactionExecutorError { #[derive(Debug, Error)] pub enum TransactionExecutorError { - #[error("failed to read fee asset from transaction inputs")] - FeeAssetRetrievalFailed(#[source] TransactionInputsExtractionError), #[error("failed to fetch transaction inputs from the data store")] FetchTransactionInputsFailed(#[source] DataStoreError), #[error("failed to fetch asset witnesses from the data store")] FetchAssetWitnessFailed(#[source] DataStoreError), - #[error("fee asset must be fungible but was non-fungible")] - FeeAssetMustBeFungible, #[error("foreign account inputs for ID {0} are not anchored on reference block")] ForeignAccountNotAnchoredInReference(AccountId), #[error( @@ -109,25 +102,17 @@ pub enum TransactionExecutorError { #[error("failed to process account update commitment: {0}")] AccountUpdateCommitment(&'static str), #[error( - "account delta commitment computed in transaction kernel ({in_kernel_commitment}) does not match account delta computed via the host ({host_commitment})" + "account patch commitment computed in transaction kernel ({in_kernel_commitment}) does not match account patch computed via the host ({host_commitment})" )] - InconsistentAccountDeltaCommitment { + InconsistentAccountPatchCommitment { in_kernel_commitment: Word, host_commitment: Word, }, - #[error("failed to remove the fee asset from the pre-fee account delta")] - RemoveFeeAssetFromDelta(#[source] AccountDeltaError), #[error("input account ID {input_id} does not match output account ID {output_id}")] InconsistentAccountId { input_id: AccountId, output_id: AccountId, }, - #[error("expected account nonce delta to be {expected}, found {actual}")] - InconsistentAccountNonceDelta { expected: Felt, actual: Felt }, - #[error( - "fee asset amount {account_balance} in the account vault is not sufficient to cover the transaction fee of {tx_fee}" - )] - InsufficientFee { account_balance: u64, tx_fee: u64 }, #[error("account witness provided for account ID {0} is invalid")] InvalidAccountWitness(AccountId, #[source] SmtProofError), #[error( @@ -150,15 +135,21 @@ pub enum TransactionExecutorError { MissingAuthenticator, } +#[cfg(any(test, feature = "testing"))] +impl TransactionExecutorError { + pub fn unwrap_unauthorized_err(self) -> Box { + match self { + TransactionExecutorError::Unauthorized(transaction_summary) => transaction_summary, + other => panic!("expected TransactionExecutorError::Unauthorized, got {other}"), + } + } +} + // TRANSACTION PROVER ERROR // ================================================================================================ #[derive(Debug, Error)] pub enum TransactionProverError { - #[error("failed to apply account delta")] - AccountDeltaApplyFailed(#[source] AccountError), - #[error("failed to remove the fee asset from the pre-fee account delta")] - RemoveFeeAssetFromDelta(#[source] AccountDeltaError), #[error("failed to construct transaction outputs")] TransactionOutputConstructionFailed(#[source] TransactionOutputError), #[error("failed to shrink output note")] @@ -200,17 +191,6 @@ impl TransactionProverError { } } -// TRANSACTION VERIFIER ERROR -// ================================================================================================ - -#[derive(Debug, Error)] -pub enum TransactionVerifierError { - #[error("failed to verify transaction")] - TransactionVerificationFailed(#[source] VerificationError), - #[error("transaction proof security level is {actual} but must be at least {expected_minimum}")] - InsufficientProofSecurityLevel { actual: u32, expected_minimum: u32 }, -} - // TRANSACTION KERNEL ERROR // ================================================================================================ @@ -274,8 +254,6 @@ pub enum TransactionKernelError { AccountStorageSlotsNumMissing(u32), #[error("account nonce can only be incremented once")] NonceCanOnlyIncrementOnce, - #[error("failed to convert fee asset into fungible asset")] - FailedToConvertFeeAsset(#[source] AssetError), #[error( "failed to get inputs for foreign account {foreign_account_id} from data store at reference block {ref_block}" )] @@ -303,10 +281,6 @@ pub enum TransactionKernelError { // thiserror will return this when calling Error::source on TransactionKernelError. source: DataStoreError, }, - #[error( - "fee asset amount {account_balance} in the account vault is not sufficient to cover the transaction fee of {tx_fee}" - )] - InsufficientFee { account_balance: u64, tx_fee: u64 }, /// This variant signals that a signature over the contained commitments is required, but /// missing. #[error("transaction requires a signature")] diff --git a/crates/miden-tx/src/executor/exec_host.rs b/crates/miden-tx/src/executor/exec_host.rs index 68fd5e1f1f..cb601de491 100644 --- a/crates/miden-tx/src/executor/exec_host.rs +++ b/crates/miden-tx/src/executor/exec_host.rs @@ -10,8 +10,8 @@ use miden_processor::{BaseHost, FutureMaybeSend, Host, ProcessorState}; use miden_protocol::account::auth::PublicKeyCommitment; use miden_protocol::account::{ AccountCode, - AccountDelta, AccountId, + AccountPatch, PartialAccount, StorageMapKey, StorageSlotId, @@ -19,7 +19,7 @@ use miden_protocol::account::{ }; use miden_protocol::assembly::debuginfo::Location; use miden_protocol::assembly::{SourceFile, SourceManagerSync, SourceSpan}; -use miden_protocol::asset::{AssetVaultKey, AssetWitness, FungibleAsset}; +use miden_protocol::asset::{AssetVaultKey, AssetWitness}; use miden_protocol::block::BlockNumber; use miden_protocol::crypto::merkle::smt::SmtProof; use miden_protocol::note::{ @@ -97,9 +97,6 @@ where /// authenticator that produced it. generated_signatures: BTreeMap>, - /// The initial balance of the fee asset in the native account's vault. - initial_fee_asset_balance: u64, - /// The source manager to track source code file span information, improving any MASM related /// error messages. source_manager: Arc, @@ -123,7 +120,6 @@ where acct_procedure_index_map: AccountProcedureIndexMap, authenticator: Option<&'auth AUTH>, ref_block: BlockNumber, - initial_fee_asset_balance: u64, source_manager: Arc, ) -> Self { let base_host = TransactionBaseHost::new( @@ -142,7 +138,6 @@ where accessed_foreign_account_code: Vec::new(), foreign_account_slot_names: BTreeMap::new(), generated_signatures: BTreeMap::new(), - initial_fee_asset_balance, source_manager, } } @@ -200,7 +195,7 @@ where /// The signature is requested from the host's authenticator. pub async fn on_auth_requested( &mut self, - pub_key_hash: Word, + pub_key_commitment: PublicKeyCommitment, tx_summary: TransactionSummary, ) -> Result, TransactionKernelError> { let signing_inputs = SigningInputs::TransactionSummary(Box::new(tx_summary)); @@ -212,71 +207,17 @@ where let message = signing_inputs.to_commitment(); let signature: Vec = authenticator - .get_signature(PublicKeyCommitment::from(pub_key_hash), &signing_inputs) + .get_signature(pub_key_commitment, &signing_inputs) .await .map_err(TransactionKernelError::SignatureGenerationFailed)? .to_prepared_signature(message); - let signature_key = Hasher::merge(&[pub_key_hash, message]); + let signature_key = Hasher::merge(&[pub_key_commitment.into(), message]); self.generated_signatures.insert(signature_key, signature.clone()); Ok(vec![AdviceMutation::extend_stack(signature)]) } - /// Handles the [`TransactionEvent::EpilogueBeforeTxFeeRemovedFromAccount`] and returns an error - /// if the account cannot pay the fee. - async fn on_before_tx_fee_removed_from_account( - &self, - fee_asset: FungibleAsset, - ) -> Result, TransactionKernelError> { - // Construct initial fee asset. - let initial_fee_asset = - FungibleAsset::new(fee_asset.faucet_id(), self.initial_fee_asset_balance) - .expect("fungible asset created from fee asset should be valid"); - - // Compute the current balance of the fee asset in the account based on the initial value - // and the delta. - let current_fee_asset = { - let fee_asset_amount_delta = self - .base_host - .account_delta_tracker() - .vault_delta() - .fungible() - .amount(&initial_fee_asset.vault_key()) - .unwrap_or(0); - - // SAFETY: Initial fee faucet ID should be a fungible faucet and amount should - // be less than MAX_AMOUNT as checked by the account delta. - let fee_asset_delta = FungibleAsset::new( - initial_fee_asset.faucet_id(), - fee_asset_amount_delta.unsigned_abs(), - ) - .expect("faucet ID and amount should be valid"); - - // SAFETY: These computations are essentially the same as the ones executed by the - // transaction kernel, which should have aborted if they weren't valid. - if fee_asset_amount_delta > 0 { - initial_fee_asset - .add(fee_asset_delta) - .expect("transaction kernel should ensure amounts do not exceed MAX_AMOUNT") - } else { - initial_fee_asset - .sub(fee_asset_delta) - .expect("transaction kernel should ensure amount is not negative") - } - }; - - // Return an error if the balance in the account does not cover the fee. - if current_fee_asset.amount() < fee_asset.amount() { - return Err(TransactionKernelError::InsufficientFee { - account_balance: current_fee_asset.amount().as_u64(), - tx_fee: fee_asset.amount().as_u64(), - }); - } - - Ok(Vec::new()) - } - /// Handles a request for a storage map witness by querying the data store for a merkle path. /// /// Note that we request witnesses against the _initial_ map root of the accounts. See also @@ -447,7 +388,7 @@ where pub fn into_parts( self, ) -> ( - AccountDelta, + AccountPatch, InputNotes, Vec, Vec, @@ -455,10 +396,10 @@ where TransactionProgress, BTreeMap, ) { - let (account_delta, input_notes, output_notes) = self.base_host.into_parts(); + let (account_patch, input_notes, output_notes) = self.base_host.into_parts(); ( - account_delta, + account_patch, input_notes, output_notes, self.accessed_foreign_account_code, @@ -535,11 +476,16 @@ where self.on_foreign_account_requested(account_id).await }, - TransactionEvent::AccountVaultAfterRemoveAsset { asset } => { - self.base_host.on_account_vault_after_remove_asset(asset) + TransactionEvent::AccountVaultAfterAssetUpdate { patch } => { + self.base_host.on_account_vault_after_remove_asset(patch) }, - TransactionEvent::AccountVaultAfterAddAsset { asset } => { - self.base_host.on_account_vault_after_add_asset(asset) + + TransactionEvent::AccountBeforeAssetDeltaComputation => { + self.base_host.on_account_before_asset_delta_computation() + }, + + TransactionEvent::AccountOnAssetDeltaComputation { delta: update } => { + self.base_host.on_account_on_asset_delta_computation(update) }, TransactionEvent::AccountStorageAfterSetItem { slot_name, new_value } => { @@ -632,11 +578,15 @@ where .on_note_before_add_attachment(note_idx, attachment) .map(|_| Vec::new()), - TransactionEvent::AuthRequest { pub_key_hash, tx_summary, signature } => { + TransactionEvent::AuthRequest { + pub_key_commitment, + tx_summary, + signature, + } => { if let Some(signature) = signature { Ok(self.base_host.on_auth_requested(signature)) } else { - self.on_auth_requested(pub_key_hash, tx_summary).await + self.on_auth_requested(pub_key_commitment, tx_summary).await } }, @@ -645,10 +595,6 @@ where Err(TransactionKernelError::Unauthorized(Box::new(tx_summary))) }, - TransactionEvent::EpilogueBeforeTxFeeRemovedFromAccount { fee_asset } => { - self.on_before_tx_fee_removed_from_account(fee_asset).await - }, - TransactionEvent::LinkMapSet { advice_mutation } => Ok(advice_mutation), TransactionEvent::LinkMapGet { advice_mutation } => Ok(advice_mutation), TransactionEvent::Progress(tx_progress) => match tx_progress { diff --git a/crates/miden-tx/src/executor/mod.rs b/crates/miden-tx/src/executor/mod.rs index 5bed519c61..ab8d76f3c5 100644 --- a/crates/miden-tx/src/executor/mod.rs +++ b/crates/miden-tx/src/executor/mod.rs @@ -8,7 +8,7 @@ pub use miden_processor::{ExecutionOptions, MastForestStore}; use miden_protocol::account::AccountId; use miden_protocol::assembly::DefaultSourceManager; use miden_protocol::assembly::debuginfo::SourceManagerSync; -use miden_protocol::asset::{Asset, AssetCallbackFlag, AssetVaultKey}; +use miden_protocol::asset::{Asset, AssetVaultKey}; use miden_protocol::block::BlockNumber; use miden_protocol::transaction::{ ExecutedTransaction, @@ -45,13 +45,6 @@ pub use notes_checker::{ mod program_executor; pub use program_executor::ProgramExecutor; -/// TODO: Decide whether to allow fee assets to have callbacks. -/// -/// Since fee removal is a way of transferring assets, but we do not have a fee-removal callback, -/// using a callback-enabled asset allows bypassing the callbacks. For now, assume fee assets are -/// callback-disabled. -const FEE_ASSET_CALLBACK_FLAG: AssetCallbackFlag = AssetCallbackFlag::Disabled; - // TRANSACTION EXECUTOR // ================================================================================================ @@ -314,19 +307,11 @@ where .map_err(TransactionExecutorError::FetchTransactionInputsFailed)?; let native_account_vault_root = account.vault().root(); - let fee_asset_vault_key = AssetVaultKey::new_fungible( - block_header.fee_parameters().fee_faucet_id(), - FEE_ASSET_CALLBACK_FLAG, - ); let mut tx_inputs = TransactionInputs::new(account, block_header, blockchain, input_notes) .map_err(TransactionExecutorError::InvalidTransactionInputs)? .with_tx_args(tx_args); - // Add the vault key for the fee asset to the list of asset vault keys which will need to be - // accessed at the end of the transaction. - asset_vault_keys.insert(fee_asset_vault_key); - // filter out any asset vault keys for which we already have witnesses in the advice inputs asset_vault_keys.retain(|asset_key| { !tx_inputs.has_vault_asset_witness(native_account_vault_root, asset_key) @@ -370,26 +355,6 @@ where let account_procedure_index_map = AccountProcedureIndexMap::new([tx_inputs.account().code()]); - let initial_fee_asset_balance = { - let vault_root = tx_inputs.account().vault().root(); - let fee_parameters = tx_inputs.block_header().fee_parameters(); - let fee_asset_vault_key = AssetVaultKey::new_fungible( - fee_parameters.fee_faucet_id(), - FEE_ASSET_CALLBACK_FLAG, - ); - - let fee_asset = tx_inputs - .read_vault_asset(vault_root, fee_asset_vault_key) - .map_err(TransactionExecutorError::FeeAssetRetrievalFailed)?; - match fee_asset { - Some(Asset::Fungible(fee_asset)) => fee_asset.amount().as_u64(), - Some(Asset::NonFungible(_)) => { - return Err(TransactionExecutorError::FeeAssetMustBeFungible); - }, - // If the asset was not found, its balance is zero. - None => 0, - } - }; let host = TransactionExecutorHost::new( tx_inputs.account(), input_notes.clone(), @@ -398,7 +363,6 @@ where account_procedure_index_map, self.authenticator, tx_inputs.block_header().block_num(), - initial_fee_asset_balance, self.source_manager.clone(), ); @@ -418,11 +382,8 @@ fn build_executed_transaction, ) -> Result { - // Note that the account delta does not contain the removed transaction fee, so it is the - // "pre-fee" delta of the transaction. - let ( - pre_fee_account_delta, + account_patch, _input_notes, output_notes, accessed_foreign_account_code, @@ -435,21 +396,14 @@ fn build_executed_transaction TransactionExecutorError { Some(TransactionKernelError::Unauthorized(summary)) => { TransactionExecutorError::Unauthorized(summary.clone()) }, - Some(TransactionKernelError::InsufficientFee { account_balance, tx_fee }) => { - TransactionExecutorError::InsufficientFee { - account_balance: *account_balance, - tx_fee: *tx_fee, - } - }, Some(TransactionKernelError::MissingAuthenticator) => { TransactionExecutorError::MissingAuthenticator }, diff --git a/crates/miden-tx/src/host/account_delta_tracker.rs b/crates/miden-tx/src/host/account_delta_tracker.rs deleted file mode 100644 index 889470b735..0000000000 --- a/crates/miden-tx/src/host/account_delta_tracker.rs +++ /dev/null @@ -1,91 +0,0 @@ -use miden_protocol::Felt; -use miden_protocol::account::{ - AccountCode, - AccountDelta, - AccountId, - AccountVaultDelta, - PartialAccount, -}; - -use crate::host::storage_delta_tracker::StorageDeltaTracker; - -// ACCOUNT DELTA TRACKER -// ================================================================================================ - -/// Keeps track of changes made to the account during transaction execution. -/// -/// Currently, this tracks: -/// - Changes to the account storage, slots and maps. -/// - Changes to the account vault. -/// - Changes to the account nonce. -/// -/// TODO: implement tracking of: -/// - account code changes. -#[derive(Debug, Clone)] -pub struct AccountDeltaTracker { - account_id: AccountId, - storage: StorageDeltaTracker, - vault: AccountVaultDelta, - code: Option, - nonce_delta: Felt, -} - -impl AccountDeltaTracker { - /// Returns a new [AccountDeltaTracker] instantiated for the specified account. - pub fn new(account: &PartialAccount) -> Self { - let code = if account.is_new() { - Some(account.code().clone()) - } else { - None - }; - - Self { - account_id: account.id(), - storage: StorageDeltaTracker::new(account), - vault: AccountVaultDelta::default(), - code, - nonce_delta: Felt::ZERO, - } - } - - /// Returns true if the nonce delta is non-zero. - pub fn was_nonce_incremented(&self) -> bool { - self.nonce_delta != Felt::ZERO - } - - /// Increments the nonce delta by one. - pub fn increment_nonce(&mut self) { - self.nonce_delta += Felt::ONE; - } - - /// Returns a reference to the vault delta. - pub fn vault_delta(&self) -> &AccountVaultDelta { - &self.vault - } - - /// Returns a mutable reference to the vault delta. - pub fn vault_delta_mut(&mut self) -> &mut AccountVaultDelta { - &mut self.vault - } - - /// Returns a mutable reference to the current storage delta tracker. - pub fn storage(&mut self) -> &mut StorageDeltaTracker { - &mut self.storage - } - - /// Consumes `self` and returns the resulting [AccountDelta]. - /// - /// Normalizes the delta by removing entries for storage slots where the initial and new - /// value are equal. - pub fn into_delta(self) -> AccountDelta { - let account_id = self.account_id; - let nonce_delta = self.nonce_delta; - - let storage_delta = self.storage.into_delta(); - let vault_delta = self.vault; - - AccountDelta::new(account_id, storage_delta, vault_delta, nonce_delta) - .expect("account delta created in delta tracker should be valid") - .with_code(self.code) - } -} diff --git a/crates/miden-tx/src/host/account_update_tracker.rs b/crates/miden-tx/src/host/account_update_tracker.rs new file mode 100644 index 0000000000..32d8e26d1b --- /dev/null +++ b/crates/miden-tx/src/host/account_update_tracker.rs @@ -0,0 +1,118 @@ +use miden_protocol::Felt; +use miden_protocol::account::{AccountCode, AccountDelta, AccountId, AccountPatch, PartialAccount}; + +use crate::TransactionKernelError; +use crate::host::storage_patch_tracker::StoragePatchTracker; +use crate::host::tx_event::{AssetDelta, AssetPatch}; +use crate::host::vault_update_tracker::VaultUpdateTracker; + +// ACCOUNT DELTA TRACKER +// ================================================================================================ + +/// Keeps track of changes made to the account during transaction execution. +/// +/// Currently, this tracks: +/// - Changes to the account storage, slots and maps. +/// - Changes to the account vault. +/// - Changes to the account nonce. +/// +/// TODO: implement tracking of: +/// - account code changes. +#[derive(Debug, Clone)] +pub struct AccountUpdateTracker { + account_id: AccountId, + storage: StoragePatchTracker, + vault: VaultUpdateTracker, + code: Option, + initial_nonce: Felt, + nonce_delta: Felt, +} + +impl AccountUpdateTracker { + /// Returns a new [`AccountUpdateTracker`] instantiated for the specified account. + pub fn new(account: &PartialAccount) -> Self { + let code = if account.is_new() { + Some(account.code().clone()) + } else { + None + }; + + Self { + account_id: account.id(), + storage: StoragePatchTracker::new(account), + vault: VaultUpdateTracker::default(), + code, + nonce_delta: Felt::ZERO, + initial_nonce: account.nonce(), + } + } + + /// Returns true if the nonce delta is non-zero. + pub fn was_nonce_incremented(&self) -> bool { + self.nonce_delta != Felt::ZERO + } + + /// Increments the nonce delta by one. + pub fn increment_nonce(&mut self) { + self.nonce_delta += Felt::ONE; + } + + /// Updates the vault patch. + pub fn update_asset_patch(&mut self, patch: AssetPatch) -> Result<(), TransactionKernelError> { + self.vault.update_patch(patch) + } + + /// Updates the vault delta. + pub fn update_asset_delta(&mut self, delta: AssetDelta) { + self.vault.update_delta(delta) + } + + /// Clears the accumulating vault delta so the next pass of the kernel's delta computation + /// rebuilds it from scratch. + pub fn reset_vault_delta(&mut self) { + self.vault.reset_delta(); + } + + /// Returns a mutable reference to the current storage patch tracker. + pub fn storage(&mut self) -> &mut StoragePatchTracker { + &mut self.storage + } + + /// Consumes `self` and returns the resulting [AccountDelta]. + /// + /// Normalizes the delta by removing entries for storage slots where the initial and new + /// value are equal. + pub fn into_delta(self) -> AccountDelta { + let account_id = self.account_id; + let nonce_delta = self.nonce_delta; + + let storage_patch = self.storage.into_patch(); + let vault_delta = self.vault.into_delta(); + + AccountDelta::new(account_id, storage_patch, vault_delta, nonce_delta) + .expect("account delta created in delta tracker should be valid") + .with_code(self.code) + } + + /// Consumes `self` and returns the resulting [`AccountPatch`]. + /// + /// Normalizes the patch by removing entries for storage slots where the initial and new + /// value are equal. + pub fn into_patch(self) -> AccountPatch { + let storage_patch = self.storage.into_patch(); + let vault_patch = self.vault.into_patch(); + + let new_nonce = if self.nonce_delta == Felt::ZERO { + None + } else { + debug_assert!( + self.initial_nonce.as_canonical_u64() < (Felt::ORDER - 1), + "tx kernel should abort if nonce would overflow" + ); + Some(self.initial_nonce + self.nonce_delta) + }; + + AccountPatch::new(self.account_id, storage_patch, vault_patch, self.code, new_nonce) + .expect("account patch created in delta tracker should be valid") + } +} diff --git a/crates/miden-tx/src/host/mod.rs b/crates/miden-tx/src/host/mod.rs index dd007bb615..5213e8dc4e 100644 --- a/crates/miden-tx/src/host/mod.rs +++ b/crates/miden-tx/src/host/mod.rs @@ -1,7 +1,9 @@ -mod account_delta_tracker; +mod account_update_tracker; +use account_update_tracker::AccountUpdateTracker; -use account_delta_tracker::AccountDeltaTracker; -mod storage_delta_tracker; +mod storage_patch_tracker; + +mod vault_update_tracker; mod link_map; pub use link_map::{LinkMap, MemoryViewer}; @@ -40,6 +42,7 @@ use miden_protocol::account::{ AccountDelta, AccountHeader, AccountId, + AccountPatch, AccountStorageHeader, PartialAccount, StorageMapKey, @@ -61,6 +64,7 @@ pub(crate) use tx_event::{RecipientData, TransactionEvent, TransactionProgressEv pub use tx_progress::TransactionProgress; use crate::errors::TransactionKernelError; +use crate::host::tx_event::{AssetDelta, AssetPatch}; // TRANSACTION BASE HOST // ================================================================================================ @@ -83,8 +87,8 @@ pub struct TransactionBaseHost<'store, STORE> { /// Account state changes accumulated during transaction execution. /// - /// The delta is updated by event handlers. - account_delta: AccountDeltaTracker, + /// The tracker is updated by event handlers. + update_tracker: AccountUpdateTracker, /// A map of the procedure MAST roots to the corresponding procedure indices for all the /// account codes involved in the transaction (for native and foreign accounts alike). @@ -129,7 +133,7 @@ impl<'store, STORE> TransactionBaseHost<'store, STORE> { scripts_mast_store, initial_account_header: account.into(), initial_account_storage_header: account.storage().header().clone(), - account_delta: AccountDeltaTracker::new(account), + update_tracker: AccountUpdateTracker::new(account), acct_procedure_index_map, output_notes: BTreeMap::default(), input_notes, @@ -173,13 +177,13 @@ impl<'store, STORE> TransactionBaseHost<'store, STORE> { } /// Returns a reference to the account delta tracker of this transaction host. - pub fn account_delta_tracker(&self) -> &AccountDeltaTracker { - &self.account_delta + pub fn account_update_tracker(&self) -> &AccountUpdateTracker { + &self.update_tracker } - /// Clones the inner [`AccountDeltaTracker`] and converts it into an [`AccountDelta`]. + /// Clones the inner [`AccountUpdateTracker`] and converts it into an [`AccountDelta`]. pub fn build_account_delta(&self) -> AccountDelta { - self.account_delta_tracker().clone().into_delta() + self.account_update_tracker().clone().into_delta() } /// Returns the input notes consumed in this transaction. @@ -194,10 +198,10 @@ impl<'store, STORE> TransactionBaseHost<'store, STORE> { } /// Consumes `self` and returns the account delta, input and output notes. - pub fn into_parts(self) -> (AccountDelta, InputNotes, Vec) { + pub fn into_parts(self) -> (AccountPatch, InputNotes, Vec) { let output_notes = self.output_notes.into_values().map(|builder| builder.build()).collect(); - (self.account_delta.into_delta(), self.input_notes, output_notes) + (self.update_tracker.into_patch(), self.input_notes, output_notes) } // MUTATORS @@ -342,11 +346,11 @@ impl<'store, STORE> TransactionBaseHost<'store, STORE> { pub fn on_account_after_increment_nonce( &mut self, ) -> Result, TransactionKernelError> { - if self.account_delta.was_nonce_incremented() { + if self.update_tracker.was_nonce_incremented() { return Err(TransactionKernelError::NonceCanOnlyIncrementOnce); } - self.account_delta.increment_nonce(); + self.update_tracker.increment_nonce(); Ok(Vec::new()) } @@ -360,7 +364,7 @@ impl<'store, STORE> TransactionBaseHost<'store, STORE> { slot_name: StorageSlotName, new_value: Word, ) -> Result, TransactionKernelError> { - self.account_delta.storage().set_item(slot_name, new_value); + self.update_tracker.storage().set_item(slot_name, new_value); Ok(Vec::new()) } @@ -373,7 +377,7 @@ impl<'store, STORE> TransactionBaseHost<'store, STORE> { old_map_value: Word, new_map_value: Word, ) -> Result, TransactionKernelError> { - self.account_delta + self.update_tracker .storage() .set_map_item(slot_name, key, old_map_value, new_map_value); @@ -383,28 +387,31 @@ impl<'store, STORE> TransactionBaseHost<'store, STORE> { // ACCOUNT VAULT UPDATE HANDLERS // -------------------------------------------------------------------------------------------- - /// Tracks the addition of an asset to the account vault in the account delta. - pub fn on_account_vault_after_add_asset( + /// Resets the accumulating vault delta before the kernel iterates the asset delta. + pub fn on_account_before_asset_delta_computation( &mut self, - asset: Asset, ) -> Result, TransactionKernelError> { - self.account_delta - .vault_delta_mut() - .add_asset(asset) - .map_err(TransactionKernelError::AccountDeltaAddAssetFailed)?; + self.update_tracker.reset_vault_delta(); Ok(Vec::new()) } - /// Tracks the removal of an asset from the account vault in the account delta. + /// Tracks the computation of an asset delta for the account delta. + pub fn on_account_on_asset_delta_computation( + &mut self, + delta: AssetDelta, + ) -> Result, TransactionKernelError> { + self.update_tracker.update_asset_delta(delta); + + Ok(Vec::new()) + } + + /// Tracks the update of an asset from the account vault in the account patch. pub fn on_account_vault_after_remove_asset( &mut self, - asset: Asset, + patch: AssetPatch, ) -> Result, TransactionKernelError> { - self.account_delta - .vault_delta_mut() - .remove_asset(asset) - .map_err(TransactionKernelError::AccountDeltaRemoveAssetFailed)?; + self.update_tracker.update_asset_patch(patch)?; Ok(Vec::new()) } diff --git a/crates/miden-tx/src/host/storage_delta_tracker.rs b/crates/miden-tx/src/host/storage_patch_tracker.rs similarity index 81% rename from crates/miden-tx/src/host/storage_delta_tracker.rs rename to crates/miden-tx/src/host/storage_patch_tracker.rs index a86ff85abe..e55ef695ad 100644 --- a/crates/miden-tx/src/host/storage_delta_tracker.rs +++ b/crates/miden-tx/src/host/storage_patch_tracker.rs @@ -3,13 +3,13 @@ use alloc::vec::Vec; use miden_protocol::Word; use miden_protocol::account::{ - AccountStorageDelta, AccountStorageHeader, + AccountStoragePatch, PartialAccount, StorageMapKey, - StorageSlotDelta, StorageSlotHeader, StorageSlotName, + StorageSlotPatch, StorageSlotType, }; @@ -24,8 +24,8 @@ use miden_protocol::account::{ /// first time the key is written to, then the previous value is the initial value of that key in /// that slot. #[derive(Debug, Clone)] -pub struct StorageDeltaTracker { - /// Flag indicating whether this delta is for a new account. +pub struct StoragePatchTracker { + /// Flag indicating whether this patch is for a new account. is_account_new: bool, /// The _initial_ storage header of the native account against which the transaction is /// executed. This is only used to look up the initial values of storage _value_ slots, while @@ -34,18 +34,18 @@ pub struct StorageDeltaTracker { /// A map from slot name to a map of key-value pairs where the key is a storage map key and /// the value represents the value of that key at the beginning of transaction execution. init_maps: BTreeMap>, - /// The account storage delta. - delta: AccountStorageDelta, + /// The account storage patch. + patch: AccountStoragePatch, } -impl StorageDeltaTracker { +impl StoragePatchTracker { // CONSTRUCTORS // -------------------------------------------------------------------------------------------- - /// Constructs a new initial storage delta from the provided account. + /// Constructs a new initial storage patch from the provided account. /// - /// If the account is new, inserts the storage entries into the delta analogously to the - /// transaction kernel delta. + /// If the account is new, inserts the storage entries into the patch analogously to the + /// transaction kernel patch. pub fn new(account: &PartialAccount) -> Self { let initial_storage_header = if account.is_new() { empty_storage_header_from_account(account) @@ -53,21 +53,21 @@ impl StorageDeltaTracker { account.storage().header().clone() }; - let mut storage_delta_tracker = Self { + let mut storage_patch_tracker = Self { is_account_new: account.is_new(), storage_header: initial_storage_header, init_maps: BTreeMap::new(), - delta: AccountStorageDelta::new(), + patch: AccountStoragePatch::new(), }; - // Insert account storage into delta if it is new to match the kernel behavior. + // Insert account storage into patch if it is new to match the kernel behavior. if account.is_new() { account.storage().header().slots().for_each(|slot_header| { match slot_header.slot_type() { StorageSlotType::Value => { - // For new accounts, all values should be added to the delta, even empty - // words, so that the final delta includes the storage slot. - storage_delta_tracker + // For new accounts, all values should be added to the patch, even empty + // words, so that the final patch includes the storage slot. + storage_patch_tracker .set_item(slot_header.name().clone(), slot_header.value()); }, StorageSlotType::Map => { @@ -77,13 +77,13 @@ impl StorageDeltaTracker { .find(|map| map.root() == slot_header.value()) .expect("storage map should be present in partial storage"); - // Make sure each map is represented by at least an empty storage map delta. - storage_delta_tracker - .delta - .insert_empty_map_delta(slot_header.name().clone()); + // Make sure each map is represented by at least an empty storage map patch. + storage_patch_tracker + .patch + .insert_empty_map_patch(slot_header.name().clone()); storage_map.entries().for_each(|(key, value)| { - storage_delta_tracker.set_map_item( + storage_patch_tracker.set_map_item( slot_header.name().clone(), *key, Word::empty(), @@ -95,7 +95,7 @@ impl StorageDeltaTracker { }); } - storage_delta_tracker + storage_patch_tracker } // PUBLIC MUTATORS @@ -103,7 +103,7 @@ impl StorageDeltaTracker { /// Updates a value slot. pub fn set_item(&mut self, slot_name: StorageSlotName, new_value: Word) { - self.delta + self.patch .set_item(slot_name, new_value) .expect("transaction kernel should not change slot types"); } @@ -116,17 +116,17 @@ impl StorageDeltaTracker { prev_value: Word, new_value: Word, ) { - // Don't update the delta if the new value matches the old one. + // Don't update the patch if the new value matches the old one. if prev_value != new_value { self.set_init_map_item(slot_name.clone(), key, prev_value); - self.delta + self.patch .set_map_item(slot_name, key, new_value) .expect("transaction kernel should not change slot types"); } } - /// Consumes `self` and returns the resulting, normalized [`AccountStorageDelta`]. - pub fn into_delta(self) -> AccountStorageDelta { + /// Consumes `self` and returns the resulting, normalized [`AccountStoragePatch`]. + pub fn into_patch(self) -> AccountStoragePatch { self.normalize() } @@ -145,24 +145,24 @@ impl StorageDeltaTracker { slot_map.entry(key).or_insert(prev_value); } - /// Normalizes the storage delta by: + /// Normalizes the storage patch by: /// /// - removing entries for value slot updates whose new value is equal to the initial value at /// the beginning of transaction execution. /// - removing entries for map slot updates where for a given key, the new value is equal to the /// initial value at the beginning of transaction execution. - fn normalize(self) -> AccountStorageDelta { + fn normalize(self) -> AccountStoragePatch { let Self { is_account_new, storage_header, init_maps, - delta, + patch, } = self; - let mut deltas = delta.into_map(); + let mut patches = patch.into_map(); - deltas.retain(|slot_name, slot_delta| { - match slot_delta { - StorageSlotDelta::Value(new_value) => { + patches.retain(|slot_name, slot_patch| { + match slot_patch { + StorageSlotPatch::Value(new_value) => { // SAFETY: The header in the initial storage is the one from the account // against which the transaction is executed, so accessing that slot name // should be fine. @@ -180,11 +180,11 @@ impl StorageDeltaTracker { // different from the initial value. // On the map level: Keep only the maps that are non-empty after its key-value // pairs have been normalized, or if the account is new. - StorageSlotDelta::Map(map_delta) => { + StorageSlotPatch::Map(map_patch) => { let init_map = init_maps.get(slot_name); if let Some(init_map) = init_map { - map_delta.as_map_mut().retain(|key, new_value| { + map_patch.as_map_mut().retain(|key, new_value| { let initial_value = init_map.get(key).expect( "the initial value should be present for every value that was updated", ); @@ -192,14 +192,14 @@ impl StorageDeltaTracker { }); } - // Only retain the map delta if the account is new or if it still contains + // Only retain the map patch if the account is new or if it still contains // values after normalization. - is_account_new || !map_delta.is_empty() + is_account_new || !map_patch.is_empty() }, } }); - AccountStorageDelta::from_raw(deltas) + AccountStoragePatch::from_raw(patches) } } diff --git a/crates/miden-tx/src/host/tx_event.rs b/crates/miden-tx/src/host/tx_event.rs index 7e6ef7ec6d..366dc8719d 100644 --- a/crates/miden-tx/src/host/tx_event.rs +++ b/crates/miden-tx/src/host/tx_event.rs @@ -3,6 +3,8 @@ use alloc::vec::Vec; use miden_processor::ProcessorState; use miden_processor::advice::{AdviceMutation, AdviceProvider}; use miden_processor::trace::RowIndex; +use miden_protocol::account::auth::PublicKeyCommitment; +use miden_protocol::account::delta::AssetDeltaOperation; use miden_protocol::account::{ AccountId, StorageMap, @@ -10,7 +12,7 @@ use miden_protocol::account::{ StorageSlotName, StorageSlotType, }; -use miden_protocol::asset::{Asset, AssetVault, AssetVaultKey, FungibleAsset}; +use miden_protocol::asset::{Asset, AssetVault, AssetVaultKey}; use miden_protocol::note::{ NoteAttachment, NoteAttachmentContent, @@ -68,12 +70,14 @@ pub(crate) enum TransactionEvent { foreign_account_id: AccountId, }, - AccountVaultAfterRemoveAsset { - asset: Asset, + AccountVaultAfterAssetUpdate { + patch: AssetPatch, }, - AccountVaultAfterAddAsset { - asset: Asset, + AccountBeforeAssetDeltaComputation, + + AccountOnAssetDeltaComputation { + delta: AssetDelta, }, AccountStorageAfterSetItem { @@ -142,7 +146,7 @@ pub(crate) enum TransactionEvent { /// The data necessary to handle an auth request. AuthRequest { - pub_key_hash: Word, + pub_key_commitment: PublicKeyCommitment, tx_summary: TransactionSummary, signature: Option>, }, @@ -151,10 +155,6 @@ pub(crate) enum TransactionEvent { tx_summary: TransactionSummary, }, - EpilogueBeforeTxFeeRemovedFromAccount { - fee_asset: FungibleAsset, - }, - LinkMapSet { advice_mutation: Vec, }, @@ -165,6 +165,21 @@ pub(crate) enum TransactionEvent { Progress(TransactionProgressEvent), } +#[derive(Debug)] +pub(crate) struct AssetPatch { + pub asset_key: AssetVaultKey, + /// The absolute value of `asset_key` in the vault before the operation. + pub initial_vault_value: Word, + /// The absolute value of `asset_key` in the vault after the operation. + pub final_vault_value: Word, +} + +#[derive(Debug, Clone)] +pub(crate) struct AssetDelta { + pub delta_op: AssetDeltaOperation, + pub asset: Asset, +} + impl TransactionEvent { /// Extracts the [`TransactionEventId`] from the stack as well as the data necessary to handle /// it. @@ -220,36 +235,67 @@ impl TransactionEvent { current_vault_root, )? }, - TransactionEventId::AccountVaultAfterRemoveAsset => { - // Expected stack state: [event, ASSET_KEY, ASSET_VALUE] + TransactionEventId::AccountVaultAfterRemoveAsset + | TransactionEventId::AccountVaultAfterAddAsset => { + // Expected stack state: + // [event, ASSET_KEY, INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE] let asset_key = process.get_stack_word(1); - let asset_value = process.get_stack_word(5); + let initial_vault_value = process.get_stack_word(5); + let final_vault_value = process.get_stack_word(9); - let asset = - Asset::from_key_value_words(asset_key, asset_value).map_err(|source| { - TransactionKernelError::MalformedAssetInEventHandler { - handler: "AccountVaultAfterRemoveAsset", - source, - } - })?; + let asset_key = AssetVaultKey::try_from(asset_key).map_err(|source| { + TransactionKernelError::MalformedAssetInEventHandler { + handler: "AccountVaultAfterRemoveAsset", + source, + } + })?; - Some(TransactionEvent::AccountVaultAfterRemoveAsset { asset }) + let patch = AssetPatch { + asset_key, + initial_vault_value, + final_vault_value, + }; + Some(TransactionEvent::AccountVaultAfterAssetUpdate { patch }) }, - TransactionEventId::AccountVaultAfterAddAsset => { - // Expected stack state: [event, ASSET_KEY, ASSET_VALUE] - let asset_key = process.get_stack_word(1); - let asset_value = process.get_stack_word(5); + TransactionEventId::AccountBeforeAssetDeltaComputation => { + Some(TransactionEvent::AccountBeforeAssetDeltaComputation) + }, + TransactionEventId::AccountOnAssetDeltaComputation => Some({ + // Expected stack state: + // [event, delta_op, ASSET_KEY, DELTA_ASSET_VALUE] + let delta_op = process.get_stack_item(1); + let asset_key = process.get_stack_word(2); + let delta_asset_value = process.get_stack_word(6); + let asset_key = AssetVaultKey::try_from(asset_key).map_err(|source| { + TransactionKernelError::MalformedAssetInEventHandler { + handler: "AccountOnAssetDeltaComputation", + source, + } + })?; let asset = - Asset::from_key_value_words(asset_key, asset_value).map_err(|source| { + Asset::from_key_value(asset_key, delta_asset_value).map_err(|source| { TransactionKernelError::MalformedAssetInEventHandler { - handler: "AccountVaultAfterAddAsset", + handler: "AccountOnAssetDeltaComputation", source, } })?; + let delta_op = AssetDeltaOperation::try_from( + u8::try_from(delta_op.as_canonical_u64()).map_err(|_| { + TransactionKernelError::other("failed to convert asset delta op to u8") + })?, + ) + .map_err(|source| { + TransactionKernelError::other_with_source( + "failed to decode asset delta op", + source, + ) + })?; - Some(TransactionEvent::AccountVaultAfterAddAsset { asset }) - }, + TransactionEvent::AccountOnAssetDeltaComputation { + delta: AssetDelta { delta_op, asset }, + } + }), TransactionEventId::AccountVaultBeforeGetAsset => { // Expected stack state: // [event, ASSET_KEY, vault_root_ptr] @@ -445,8 +491,8 @@ impl TransactionEvent { TransactionEventId::AuthRequest => { // Expected stack state: [event, MESSAGE, PUB_KEY] let message = process.get_stack_word(1); - let pub_key_hash = process.get_stack_word(5); - let signature_key = Hasher::merge(&[pub_key_hash, message]); + let pub_key_commitment = PublicKeyCommitment::from(process.get_stack_word(5)); + let signature_key = Hasher::merge(&[pub_key_commitment.into(), message]); let signature = process .advice_provider() @@ -455,7 +501,11 @@ impl TransactionEvent { let tx_summary = extract_tx_summary(base_host, process, message)?; - Some(TransactionEvent::AuthRequest { pub_key_hash, tx_summary, signature }) + Some(TransactionEvent::AuthRequest { + pub_key_commitment, + tx_summary, + signature, + }) }, TransactionEventId::Unauthorized => { @@ -466,17 +516,6 @@ impl TransactionEvent { Some(TransactionEvent::Unauthorized { tx_summary }) }, - TransactionEventId::EpilogueBeforeTxFeeRemovedFromAccount => { - // Expected stack state: [event, FEE_ASSET_KEY, FEE_ASSET_VALUE] - let fee_asset_key = process.get_stack_word(1); - let fee_asset_value = process.get_stack_word(5); - - let fee_asset = FungibleAsset::from_key_value_words(fee_asset_key, fee_asset_value) - .map_err(TransactionKernelError::FailedToConvertFeeAsset)?; - - Some(TransactionEvent::EpilogueBeforeTxFeeRemovedFromAccount { fee_asset }) - }, - TransactionEventId::LinkMapSet => Some(TransactionEvent::LinkMapSet { advice_mutation: LinkMap::handle_set_event(process), }), @@ -571,7 +610,7 @@ fn on_account_vault_asset_accessed<'store, STORE>( vault_key: AssetVaultKey, vault_root: Word, ) -> Result, TransactionKernelError> { - let leaf_index = Felt::try_from(vault_key.to_leaf_index().position()) + let leaf_index = Felt::try_from(vault_key.hash().to_leaf_index().position()) .expect("expected key index to be a felt"); let active_account_id = process.get_active_account_id()?; diff --git a/crates/miden-tx/src/host/vault_update_tracker.rs b/crates/miden-tx/src/host/vault_update_tracker.rs new file mode 100644 index 0000000000..90b14b0649 --- /dev/null +++ b/crates/miden-tx/src/host/vault_update_tracker.rs @@ -0,0 +1,123 @@ +use alloc::collections::BTreeMap; + +use miden_protocol::Word; +use miden_protocol::account::delta::AssetDeltaOperation; +use miden_protocol::account::{ + AccountVaultDelta, + AccountVaultPatch, + FungibleAssetDelta, + NonFungibleAssetDelta, + NonFungibleDeltaAction, +}; +use miden_protocol::asset::{Asset, AssetVaultKey}; + +use crate::TransactionKernelError; +use crate::host::tx_event::{AssetDelta, AssetPatch}; + +/// Keeps track of the updates to an account's vault during transaction execution. +/// +/// On each add/remove event the tracker records: +/// - the initial value of the touched vault key, only the very first time it is observed, +/// - the final absolute value of the touched vault key in [`AccountVaultPatch`]. +/// +/// When the delta commitment is computed in the VM, the tracker records the relative change as the +/// per-asset [`AssetDelta`] reported by the kernel. Note that the delta could be computed multiple +/// times, so the tracker is reset before every computation to ensure the host delta matches the tx +/// kernel delta. +/// +/// At the end of the transaction, [`Self::into_patch`] normalizes the patch by dropping entries +/// whose final value equals the initial value, i.e. vault keys that were touched but ultimately +/// unchanged. +#[derive(Debug, Clone, Default)] +pub(crate) struct VaultUpdateTracker { + /// The latest absolute [`AssetDelta`] reported by the kernel for each touched vault key. + delta: BTreeMap, + /// For each touched vault key, the `(initial, final)` absolute values. The initial value is + /// recorded only on the very first observation and never overwritten; the final value is + /// updated on every observation. + entries: BTreeMap, +} + +impl VaultUpdateTracker { + /// Inserts an asset patch. + pub fn update_patch(&mut self, patch: AssetPatch) -> Result<(), TransactionKernelError> { + self.entries + .entry(patch.asset_key) + .and_modify(|(_, r#final)| *r#final = patch.final_vault_value) + .or_insert((patch.initial_vault_value, patch.final_vault_value)); + + Ok(()) + } + + /// Inserts an asset delta. + pub fn update_delta(&mut self, delta: AssetDelta) { + self.delta.insert(delta.asset.vault_key(), delta); + } + + /// Clears the accumulating vault delta. + pub fn reset_delta(&mut self) { + self.delta.clear(); + } + + /// Consumes self and returns the vault delta. + pub fn into_delta(self) -> AccountVaultDelta { + self.build_delta() + } + + /// Consumes self and returns the normalized vault patch. + /// + /// Drops entries whose final value equals the initial value at the start of the transaction. + pub fn into_patch(self) -> AccountVaultPatch { + let normalized = self + .entries + .into_iter() + .filter_map(|(key, (initial_value, final_value))| { + if final_value == initial_value { + None + } else { + Some((key, final_value)) + } + }) + .collect(); + + AccountVaultPatch::new(normalized).expect("tx kernel should only emit valid assets") + } + + // HELPER FUNCTIONS + // --------------------------------------------------------------------------------------------- + + /// Builds an [`AccountVaultDelta`] from the flat per-asset delta map. + /// + /// TODO(unified_delta): Will be simplified once `AccountVaultDelta` tracks only generic assets. + fn build_delta(&self) -> AccountVaultDelta { + let mut fungible: BTreeMap = BTreeMap::new(); + let mut non_fungible: BTreeMap = + BTreeMap::new(); + + for (&vault_key, asset_delta) in &self.delta { + match asset_delta.asset { + Asset::Fungible(fungible_asset) => { + let amount = fungible_asset.amount().as_i64(); + let signed_amount = match asset_delta.delta_op { + AssetDeltaOperation::Add => amount, + AssetDeltaOperation::Remove => -amount, + }; + fungible.insert(vault_key, signed_amount); + }, + Asset::NonFungible(non_fungible_asset) => { + let action = match asset_delta.delta_op { + AssetDeltaOperation::Add => NonFungibleDeltaAction::Add, + AssetDeltaOperation::Remove => NonFungibleDeltaAction::Remove, + }; + non_fungible.insert(vault_key, (non_fungible_asset, action)); + }, + } + } + + let fungible = FungibleAssetDelta::new(fungible) + .expect("tx kernel should only emit valid fungible asset deltas"); + let non_fungible = NonFungibleAssetDelta::new(non_fungible); + + AccountVaultDelta::new(fungible, non_fungible) + } +} diff --git a/crates/miden-tx/src/lib.rs b/crates/miden-tx/src/lib.rs index e5c670d6f4..040a3348ba 100644 --- a/crates/miden-tx/src/lib.rs +++ b/crates/miden-tx/src/lib.rs @@ -32,9 +32,6 @@ pub use prover::{ TransactionProverHost, }; -mod verifier; -pub use verifier::TransactionVerifier; - mod errors; pub use errors::{ AuthenticationError, @@ -43,7 +40,6 @@ pub use errors::{ TransactionExecutorError, TransactionKernelError, TransactionProverError, - TransactionVerifierError, }; pub mod auth; diff --git a/crates/miden-tx/src/prover/mod.rs b/crates/miden-tx/src/prover/mod.rs index c8cb298dc5..afd020e9f9 100644 --- a/crates/miden-tx/src/prover/mod.rs +++ b/crates/miden-tx/src/prover/mod.rs @@ -1,9 +1,7 @@ use alloc::vec::Vec; use miden_processor::ExecutionOptions; -use miden_protocol::account::delta::AccountUpdateDetails; -use miden_protocol::account::{AccountDelta, PartialAccount}; -use miden_protocol::asset::Asset; +use miden_protocol::account::{AccountPatch, AccountUpdateDetails, PartialAccount}; use miden_protocol::block::BlockNumber; use miden_protocol::transaction::{ InputNote, @@ -49,13 +47,12 @@ impl LocalTransactionProver { &self, input_notes: &InputNotes, tx_outputs: TransactionOutputs, - pre_fee_account_delta: AccountDelta, + account_patch: AccountPatch, account: PartialAccount, ref_block_num: BlockNumber, ref_block_commitment: Word, proof: ExecutionProof, ) -> Result { - let fee = tx_outputs.fee(); let expiration_block_num = tx_outputs.expiration_block_num(); let (account_header, output_notes) = tx_outputs.into_parts(); @@ -66,19 +63,12 @@ impl LocalTransactionProver { .collect::, _>>() .map_err(TransactionProverError::OutputNoteShrinkFailed)?; - // Compute the commitment of the pre-fee delta, which goes into the proven transaction, - // since it is the output of the transaction and so is needed for proof verification. - let pre_fee_delta_commitment: Word = pre_fee_account_delta.to_commitment(); - - // The full transaction delta is the pre fee delta with the fee asset removed. - let mut post_fee_account_delta = pre_fee_account_delta; - post_fee_account_delta - .vault_mut() - .remove_asset(Asset::from(fee)) - .map_err(TransactionProverError::RemoveFeeAssetFromDelta)?; + // Compute the commitment of the patch, which goes into the proven transaction since it is + // the output of the transaction and so is needed for proof verification. + let patch_commitment: Word = account_patch.to_commitment(); let account_update_details = if account.id().is_public() { - AccountUpdateDetails::Delta(post_fee_account_delta) + AccountUpdateDetails::Public(account_patch) } else { AccountUpdateDetails::Private }; @@ -87,7 +77,7 @@ impl LocalTransactionProver { account.id(), account.initial_commitment(), account_header.to_commitment(), - pre_fee_delta_commitment, + patch_commitment, account_update_details, ) .map_err(TransactionProverError::ProvenTransactionBuildFailed)?; @@ -98,7 +88,6 @@ impl LocalTransactionProver { output_notes, ref_block_num, ref_block_commitment, - fee, expiration_block_num, proof, ) @@ -156,9 +145,7 @@ impl LocalTransactionProver { .map_err(TransactionProverError::TransactionProgramExecutionFailed)?; // Extract transaction outputs and process transaction data. - // Note that the account delta does not contain the removed transaction fee, so it is the - // "pre-fee" delta of the transaction. - let (pre_fee_account_delta, input_notes, output_notes) = host.into_parts(); + let (account_patch, input_notes, output_notes) = host.into_parts(); let tx_outputs = TransactionKernel::from_transaction_parts(&stack_outputs, &advice_inputs, output_notes) .map_err(TransactionProverError::TransactionOutputConstructionFailed)?; @@ -166,7 +153,7 @@ impl LocalTransactionProver { self.build_proven_transaction( &input_notes, tx_outputs, - pre_fee_account_delta, + account_patch, partial_account, ref_block.block_num(), ref_block.commitment(), @@ -181,14 +168,14 @@ impl LocalTransactionProver { &self, executed_transaction: miden_protocol::transaction::ExecutedTransaction, ) -> Result { - let (tx_inputs, tx_outputs, account_delta, _) = executed_transaction.into_parts(); + let (tx_inputs, tx_outputs, account_patch, _) = executed_transaction.into_parts(); let (partial_account, ref_block, _, input_notes, _) = tx_inputs.into_parts(); self.build_proven_transaction( &input_notes, tx_outputs, - account_delta, + account_patch, partial_account, ref_block.block_num(), ref_block.commitment(), diff --git a/crates/miden-tx/src/prover/prover_host.rs b/crates/miden-tx/src/prover/prover_host.rs index 99743eefbd..4d96d7e013 100644 --- a/crates/miden-tx/src/prover/prover_host.rs +++ b/crates/miden-tx/src/prover/prover_host.rs @@ -6,7 +6,7 @@ use miden_processor::event::EventError; use miden_processor::mast::MastForest; use miden_processor::{BaseHost, FutureMaybeSend, Host, MastForestStore, ProcessorState}; use miden_protocol::Word; -use miden_protocol::account::{AccountDelta, PartialAccount}; +use miden_protocol::account::{AccountPatch, PartialAccount}; use miden_protocol::assembly::debuginfo::Location; use miden_protocol::assembly::{SourceFile, SourceSpan}; use miden_protocol::transaction::{InputNote, InputNotes, RawOutputNote}; @@ -55,7 +55,7 @@ where // -------------------------------------------------------------------------------------------- /// Consumes `self` and returns the account delta, input and output notes. - pub fn into_parts(self) -> (AccountDelta, InputNotes, Vec) { + pub fn into_parts(self) -> (AccountPatch, InputNotes, Vec) { self.base_host.into_parts() } } @@ -125,11 +125,16 @@ where // proving time, so there is nothing to do. TransactionEvent::AccountBeforeForeignLoad { .. } => Ok(Vec::new()), - TransactionEvent::AccountVaultAfterRemoveAsset { asset } => { - self.base_host.on_account_vault_after_remove_asset(asset) + TransactionEvent::AccountVaultAfterAssetUpdate { patch } => { + self.base_host.on_account_vault_after_remove_asset(patch) }, - TransactionEvent::AccountVaultAfterAddAsset { asset } => { - self.base_host.on_account_vault_after_add_asset(asset) + + TransactionEvent::AccountBeforeAssetDeltaComputation => { + self.base_host.on_account_before_asset_delta_computation() + }, + + TransactionEvent::AccountOnAssetDeltaComputation { delta } => { + self.base_host.on_account_on_asset_delta_computation(delta) }, TransactionEvent::AccountStorageAfterSetItem { slot_name, new_value } => { @@ -197,11 +202,6 @@ where ))) }, - // We don't track enough information to handle this event. Since this just improves - // error messages for users and the error should not be relevant during proving, we - // ignore it. - TransactionEvent::EpilogueBeforeTxFeeRemovedFromAccount { .. } => Ok(Vec::new()), - TransactionEvent::LinkMapSet { advice_mutation } => Ok(advice_mutation), TransactionEvent::LinkMapGet { advice_mutation } => Ok(advice_mutation), diff --git a/deny.toml b/deny.toml index 8d1652a202..690b65da85 100644 --- a/deny.toml +++ b/deny.toml @@ -11,6 +11,7 @@ db-urls = ["https://github.com/rustsec/advisory-db"] ignore = [ "RUSTSEC-2024-0436", # paste is unmaintained but no alternative available "RUSTSEC-2025-0141", # bincode is unmaintained, replace with wincode (https://github.com/0xMiden/miden-vm/issues/2550) + "RUSTSEC-2026-0173", # proc-macro-error2 is unmaintained, ignore until `alloy-sol-macro` has migrated away ] yanked = "warn" diff --git a/docs/src/asset.md b/docs/src/asset.md index 345d377826..7f837ecbfc 100644 --- a/docs/src/asset.md +++ b/docs/src/asset.md @@ -145,6 +145,8 @@ Examples of such assets include NFTs like a DevCon ticket. [Accounts](./account) and [notes](note) have vaults used to store assets. Accounts use a sparse Merkle tree as a vault while notes use a simple list. This enables an account to store a practically unlimited number of assets while a note can only store up to 64 assets. +Asset vault keys are hashed before being used as keys in the underlying sparse Merkle tree. Hashing the raw key ensures a uniform leaf distribution: in particular, it prevents non-fungible assets issued by the same faucet from sharing an SMT leaf (their raw vault keys share the fourth element - the faucet ID prefix - which the SMT uses to determine leaf membership). +

Asset storage

diff --git a/docs/src/index.md b/docs/src/index.md index bf7c554b86..4e519f6084 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -41,9 +41,9 @@ An [Asset](asset) can be fungible and non-fungible. They are stored in the owner ### Transactions -A [Transaction](transaction) describes the production and consumption of notes by a single account. +A [Transaction](transaction) is always executed against a single account. It involves at least one of two things: mutating that account's state — its storage or vault, for example by executing a public smart contract function — or creating or consuming notes. The account is not always mutated: a transaction may only consume and create notes, leaving the account itself untouched. -Executing a transaction always results in a STARK proof. +After a transaction is executed, a STARK proof must be created to attest to its correctness before submitting it to the network. This proving step can be done separately from execution, for example by a "remote prover" running a powerful machine. The [transaction chapter](transaction) describes the transaction design and implementation, including an in-depth discussion of how transaction execution happens in the transaction kernel program. @@ -65,6 +65,8 @@ Miden's state model captures the individual states of all accounts and notes, an The [Blockchain](blockchain) defines how state progresses as aggregated-state-updates in batches, blocks, and epochs. The [blockchain chapter](blockchain) describes the execution model and how blocks are built. -##### Operators capture and progress state +##### Operational roles capture and progress state + +Miden's node infrastructure is split across operational roles. At a high level, RPC nodes expose network state and accept transactions; network-transaction builders execute and prove [network transactions](./transaction.md#network-transaction) against public, shared-state accounts; and batch and block builders verify proven transactions, record newly created notes and consumed-note nullifiers in the state databases, and extend the chain. In the current centralized setting these responsibilities may be run by a single operator, but the protocol model separates the roles performed by the underlying infrastructure. ![Architecture state process](img/miden-architecture-state-progress.gif) diff --git a/docs/src/note.md b/docs/src/note.md index c0a4b7cda1..ea8ba9511c 100644 --- a/docs/src/note.md +++ b/docs/src/note.md @@ -80,6 +80,8 @@ A note can have up to 4 attachments. Each attachment is a variable-size, _public The note commits to all of its attachments via a sequential hash over the individual attachment commitments (the attachments commitment). The attachment schemes are encoded in the note's metadata. When the note is consumed, the actual attachment content is provided via the advice provider. +Schemes are not required to be unique within a note, but adding multiple attachments with the same scheme is discouraged. It provides no additional benefit and only increases the note's public on-chain data and the fees its creator pays. + Example use cases for attachments are: - Communicate the note details of a private note in encrypted form. This means the encrypted note is attached publicly to the otherwise private note. - For [network transactions](./transaction.md#network-transaction), encode the ID of the network account that should @@ -99,7 +101,7 @@ The `Note` lifecycle proceeds through four primary phases: **creation**, **valid Accounts can create notes in a transaction. The `Note` exists if it is included in the global notes DB. - **Users:** Executing local or network transactions. -- **Miden operators:** Facilitating on-chain actions, e.g. such as executing user notes against a DEX or other contracts. +- **[Miden node infrastructure](./index.md#operational-roles-capture-and-progress-state):** Facilitating on-chain actions, e.g. such as executing user notes against a DEX or other contracts. #### Note Type diff --git a/scripts/check-features.sh b/scripts/check-features.sh index 7eae778e4b..a06cc5eb21 100755 --- a/scripts/check-features.sh +++ b/scripts/check-features.sh @@ -15,7 +15,7 @@ export BUILD_GENERATED_FILES_IN_SRC=1 # Run cargo-hack with comprehensive feature checking # Focus on library packages that have significant feature matrices -for package in miden-protocol miden-standards miden-agglayer miden-tx miden-testing miden-block-prover miden-tx-batch-prover; do +for package in miden-protocol miden-standards miden-agglayer miden-tx miden-testing miden-block-prover miden-tx-batch; do echo "Checking package: $package" cargo hack check -p "$package" --each-feature --all-targets done