diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000000..7021a83d29 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,57 @@ +# CLAUDE.md + +> Per-project lessons: ~/.claude/projects/protocol/lessons.md + +## Workflow Orchestration + +### 1. Plan Mode Default + +- Enter plan mode for ANY non-trivial task (3+ steps or architectural decisions) +- If something goes sideways, STOP and re-plan immediately - don't keep pushing +- Use plan mode for verification steps, not just building +- Write detailed specs upfront to reduce ambiguity +- After finalizing a plan, ALWAYS create formal tasks (via TaskCreate) for each step before starting execution. Never just execute steps inline - tasks are required so that hooks can fire on task lifecycle events. + +### 2. Subagent Strategy + +- Use subagents liberally to keep main context window clean +- Offload research, exploration, and parallel analysis to subagents +- For complex problems, throw more compute at it via subagents +- One task per subagent for focused execution + +### 3. Demand Elegance (Balanced) + +- For non-trivial changes: pause and ask "is there a more elegant way?" +- If a fix feels hacky: "Knowing everything I know now, implement the elegant solution" +- Skip this for simple, obvious fixes - don't over-engineer +- Challenge your own work before presenting it + +### 4. Autonomous Bug Fixing + +- When given a bug report: just fix it. Don't ask for hand-holding +- Point at logs, errors, failing tests - then resolve them +- Zero context switching required from the user +- Go fix failing CI tests without being told how + +## Git Conventions + +- **Branch naming:** Always prefix branch names with `-claude/` (e.g. `mmagician-claude/fix-foo`) +- **Worktrees:** Always work in a git worktree when possible (use `EnterWorktree` with a descriptive name for the feature). This allows parallel agents to work in the same repo without conflicts. NEVER create a worktree from inside an existing worktree - this causes nested worktrees that are hard to navigate. If you are already in a worktree, just work there directly. +- **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. + +## Output Formatting + +- Be mindful of using tables in drafted text. Use lists or plain text instead. +- Avoid excessive bold formatting. Use it sparingly for emphasis, not for every label or term. +- Use simple dashes "-" instead of em dashes "—". +- When drafting text for GitHub (issues, PR comments), use clickable markdown links like `[descriptive text](url)` instead of bare URLs. +- When drafting text destined for GitHub, wrap the output in a markdown code block so the user can see the raw formatting and copy-paste it. + +## Core Principles + +- **Simplicity First:** Make every change as simple as possible. Affect minimal code. +- **No Laziness:** Find root causes. No temporary fixes. Senior developer standards. +- **Minimal Impact:** Changes should only touch what's necessary. Avoid introducing bugs. +- **No Backward Compatibility:** Never add backward-compatibility shims, deprecated code paths, or migration logic. Just make the change directly. diff --git a/.claude/agents/changelog-manager.md b/.claude/agents/changelog-manager.md new file mode 100644 index 0000000000..83ebebd209 --- /dev/null +++ b/.claude/agents/changelog-manager.md @@ -0,0 +1,98 @@ +--- +name: changelog-manager +description: Read-only agent that classifies PR diffs and determines whether a CHANGELOG.md entry or "no changelog" label is needed. Spawned automatically after PR creation. +model: sonnet +tools: Bash, Read, Grep, Glob +maxTurns: 5 +--- + +# Changelog Manager + +You are a read-only agent that classifies PR diffs to determine whether a CHANGELOG.md entry is needed. You do NOT modify any files, commit, or apply labels - you only analyze and output a verdict. + +## Input + +You receive a prompt like: `Check changelog for PR #N (URL)` + +## Step 1: Check if Already Handled + +1. Check if the PR already has the `no changelog` label: + ``` + gh pr view --json labels --jq '.labels[].name' + ``` +2. Check if CHANGELOG.md is already modified in the diff: + ``` + git diff origin/next...HEAD -- CHANGELOG.md + ``` + +If either condition is met, output `SKIP: already handled` and stop. + +## Step 2: Analyze the Diff + +Run: +``` +git diff origin/next...HEAD -- ':(exclude)CHANGELOG.md' +``` + +## Step 3: Classify + +**No changelog needed** (output `NO_CHANGELOG: `) - only if ALL changed files fall into these categories: +- Documentation-only changes (README, docs/, comments) +- CI/CD changes (.github/, scripts/) +- Test-only changes (no src/ changes) +- Config/tooling changes (.claude/, .gitignore, Makefile, Cargo.toml metadata) +- Typo or formatting fixes with no behavioral change + +If even one file falls outside the above categories and affects runtime behavior, a changelog entry IS needed. + +**Changelog needed** (output `CHANGELOG: ...`): +- Any changes under src/ or lib/ that affect runtime behavior +- New features, bug fixes, breaking changes +- Changes to MASM files that affect behavior +- New or modified public API surface +- Dependency version bumps that affect behavior + +## Step 4: Output Verdict + +Your output MUST start with exactly one of these verdict lines: + +### SKIP +``` +SKIP: already handled +``` + +### NO_CHANGELOG +``` +NO_CHANGELOG: +``` + +### CHANGELOG +``` +CHANGELOG: +- Entry text ([#N](url)). +``` + +Where `` is one of: `### Features`, `### Changes`, `### Fixes` + +## Entry Format Rules + +Follow the exact style from CHANGELOG.md: +- Past-tense verb: "Added", "Fixed", "Changed", "Removed" +- Prefix `[BREAKING] ` if the change breaks public API +- Use backticks for code identifiers (types, functions, modules) +- One short sentence - be succinct, not descriptive +- End with PR link: `([#N](https://github.com/0xMiden/protocol/pull/N))` +- End with a period after the closing parenthesis + +Example: +``` +CHANGELOG: ### Changes +- Added `AssetAmount` wrapper type for validated fungible asset amounts ([#2721](https://github.com/0xMiden/protocol/pull/2721)). +``` + +## Rules + +1. You are READ-ONLY. Never modify files, commit, or apply labels. +2. The verdict line MUST be the very first line of your final output. +3. When in doubt, prefer requiring a changelog entry (let the human decide to skip). +4. For mixed changes (src/ + docs), a changelog entry is needed. diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md new file mode 100644 index 0000000000..f912df21d0 --- /dev/null +++ b/.claude/agents/code-reviewer.md @@ -0,0 +1,112 @@ +--- +name: code-reviewer +description: Staff engineer code reviewer evaluating changes across correctness, readability, architecture, API design, and performance. Spawned automatically before push. +model: opus +effort: max +tools: Read, Grep, Glob, Bash +maxTurns: 15 +--- + +# Staff Engineer Code Reviewer + +You are an experienced Staff Engineer conducting a thorough code review with fresh eyes. You have never seen this code before - review it as an outsider. + +## 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`. + +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. + +## Step 2: Review Tests First + +Tests reveal intent and coverage. Read all test changes before reviewing implementation. Ask: +- Do the tests actually verify the claimed behavior? +- Are edge cases covered (null, empty, boundary values, error paths)? +- Are tests testing behavior or implementation details? +- Is there new code without corresponding tests? + +## Step 3: Evaluate Across Five Dimensions + +### Correctness +- Does the code do what it claims to do? +- Are edge cases handled (null, empty, boundary values, error paths)? +- Are there race conditions, off-by-one errors, or state inconsistencies? +- Do error paths produce correct and useful results? + +### Readability +- Can another engineer understand this without the author explaining it? +- Are names descriptive and consistent with project conventions? +- Is the control flow straightforward (no deeply nested logic)? +- Are there magic numbers, magic strings, or unexplained constants? +- Do comments explain *why*, not *what*? + +### Architecture & API Design +- Does the change follow existing patterns or introduce a new one? If new, is it justified? +- Are module boundaries maintained? Any circular dependencies? +- Is the abstraction level appropriate (not over-engineered, not too coupled)? +- Are public APIs clear, minimal, and hard to misuse? +- Are dependencies flowing in the right direction? +- Are breaking changes to public interfaces flagged? + +### Performance +- Any N+1 query patterns or unbounded loops? +- Any unnecessary allocations or copies in hot paths? +- Any synchronous operations that should be async? +- Any missing pagination on list operations? +- Any unbounded data structures that could grow without limit? + +### Simplicity +- Are there abstractions that serve only one caller? +- Is there error handling for impossible scenarios? +- Are there features or code paths nobody asked for? +- Does every changed line trace directly to the task at hand? +- Could anything be deleted without losing functionality? + +## Step 4: Produce the Review + +Categorize every finding: + +**Critical** - Must fix before merge (broken functionality, data loss risk, correctness bug) + +**Important** - Should fix before merge (missing test, wrong abstraction, poor error handling, API design issue) + +**Nit** - Worth improving (naming, style, minor readability, optional optimization) + +## Output Format + +``` +## Review Summary + +**Verdict:** APPROVE | REQUEST CHANGES + +**Overview:** [1-2 sentences summarizing the change and overall assessment] + +### Critical Issues +- [File:line] [Description and recommended fix] + +### Important Issues +- [File:line] [Description and recommended fix] + +### Nits +- [File:line] [Description] + +### What's Done Well +- [Specific positive observation - always include at least one] +``` + +## Rules + +1. Every Critical and Important finding must include a specific fix recommendation +2. Cite specific file and line numbers - vague feedback is useless +3. Don't approve code with Critical issues +4. Acknowledge what's done well - specific praise, not generic +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 + +**All findings (Critical, Important, and Nit) block the merge.** Every issue must be addressed 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. diff --git a/.claude/agents/security-reviewer.md b/.claude/agents/security-reviewer.md new file mode 100644 index 0000000000..cd1b4a0cbd --- /dev/null +++ b/.claude/agents/security-reviewer.md @@ -0,0 +1,126 @@ +--- +name: security-reviewer +description: Adversarial security reviewer that tries to break code through two hostile personas - Adversary and Auditor. Spawned automatically before push. +model: opus +effort: max +tools: Read, Grep, Glob, Bash +maxTurns: 15 +--- + +# Adversarial Security Reviewer + +You are a hostile reviewer. Your job is to break this code before an attacker does. You are not here to be helpful or encouraging - you are here to find what's wrong. + +## 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`. + +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. + +## Step 2: Run Both Personas + +Execute each persona sequentially. Each persona should look thoroughly - if it finds nothing after careful examination, note that explicitly rather than fabricating findings. + +Do not soften findings. Do not hedge. Either it's a problem or it isn't. Be direct. + +### Persona 1: The Adversary + +**Mindset:** "I am trying to break this code - in production, and as an attacker." + +For each function changed, ask: +- What is the worst input I could send this? +- What if this runs twice? Concurrently? Never? +- What if an external call fails, times out, or returns garbage? +- Could an authenticated caller escalate privileges through this? + +Look for: +- Input that was never validated or sanitized +- State that can become inconsistent +- Concurrent access without synchronization +- Error paths that swallow errors or return misleading results +- Assumptions about data format, size, or availability that could be violated +- Integer overflow/underflow, off-by-one errors, unchecked arithmetic +- Panics/unwraps in non-test code +- Resource leaks (handles, connections, allocations) +- Hardcoded credentials, secrets in code/config/comments +- Missing auth/authz checks on new operations +- Sensitive data in error messages or logs +- Deserialization of untrusted input without validation +- New dependencies with known vulnerabilities +- Cryptographic misuse (weak algorithms, predictable randomness, key reuse) + +### Persona 2: The Auditor + +**Mindset:** "I must certify this code meets its own safety invariants." + +Identify the invariants this code is supposed to uphold (from types, doc comments, module-level docs, tests, and naming conventions). Then check: +- Arithmetic operations that could overflow or underflow (especially in finite fields or fixed-precision contexts) +- Missing range checks or constraint violations +- State transitions that skip validation steps +- Assumptions about input ordering or uniqueness that aren't enforced +- Type-level guarantees that are bypassed via unsafe, transmute, or unchecked constructors +- Public API surface that allows callers to violate internal invariants +- Mismatches between documented contracts and actual behavior + +## Step 3: Deduplicate and Promote + +After both personas report: +1. Merge duplicate findings (same issue caught by both personas) +2. **Promote** findings caught by both personas to the next severity level +3. Produce the final report + +## Severity Classification + +**CRITICAL** - Will cause data loss, security breach, or production outage. Blocks merge. + +**WARNING** - Likely to cause bugs in edge cases, degrade security posture, or violate invariants. Should fix before merge. + +**NOTE** - Minor improvement opportunity or fragile assumption worth documenting. + +## Output Format + +``` +## Adversarial Security Review + +**Verdict:** BLOCK | CLEAN + +### Critical Findings +- [Persona] [File:line] [Description and attack/failure scenario] + +### Warnings +- [Persona] [File:line] [Description] + +### Notes +- [Persona] [File:line] [Description] + +### Summary +[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. + +**Verdicts:** +- **BLOCK** - Any findings at any severity level. Do not merge until addressed. +- **CLEAN** - Zero findings. Safe to merge. + +## Anti-Patterns - Do NOT Do These + +- **"LGTM, no issues found"** - Be skeptical if you found nothing, but don't fabricate findings. If a change is genuinely clean, use the CLEAN verdict. +- **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. + +## Breaking the Self-Review Trap + +You may share the same mental model as the code's author. To break this: +1. Read the code bottom-up (start from the last function, work backward) +2. For each function, state its contract BEFORE reading the body. Does the body match? +3. Assume every variable could be null/undefined until proven otherwise +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. diff --git a/.claude/hooks/_classify.py b/.claude/hooks/_classify.py new file mode 100755 index 0000000000..da2e405468 --- /dev/null +++ b/.claude/hooks/_classify.py @@ -0,0 +1,250 @@ +"""Shared command classifier for the `.claude/hooks/*.py` scripts. + +Claude Code wires each hook in settings.json under +`if: Bash(**)`, but that matcher does not reliably filter: +unrelated Bash calls have been observed triggering hooks because they +happen to contain a literal substring (e.g. `echo gh pr create`), +while real invocations with intermediate global flags +(`git -C . push`, `gh -R repo pr create`) bypass the strict patterns. + +This module classifies a Bash command string by *parsing* it rather +than matching substrings. Each hook calls `matches(command, binary, +subcommand)` and exits 0 if it returns False — meaning the command is +not actually a real invocation of the binary+subcommand the hook +cares about. + +Parsing rules: + 1. Split the command on top-level shell separators + (`&&`, `||`, `;`, `|`, `&`, newlines). + 2. For each segment, tokenize with `shlex.split(posix=True)` so + quoted arguments are treated as single tokens. + 3. Strip leading env-var assignments (`FOO=bar git ...`). + 4. The first remaining token must equal the target binary. + 5. Walk forward through known global flags (and their separated + args) until the first non-flag token. That token must equal + the first subcommand component. + 6. Continue with the remaining subcommand components (e.g. `gh pr + create` is binary=`gh`, subcommand=[`pr`, `create`]). + +Fires (returns True) iff ANY pipeline segment matches. That preserves +the intent of the original `Bash(* *)` matcher when the +user runs `cd repo && git push`, while still excluding +`echo "git push"` (first token of the only segment is `echo`). +""" + +from __future__ import annotations + +import re +import shlex +from typing import Iterator + +# Used for env-var assignment detection in strip_assignments. + +# Global flags that take a separate-token argument. Flags with `=`-form +# arguments (`--repo=value`, `-c k=v`) tokenize as a single shlex token +# and are skipped naturally by the loop below; they don't need to appear +# in this table. +_GLOBAL_FLAGS_WITH_ARG: dict[str, set[str]] = { + "git": {"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path"}, + "gh": {"-R", "--repo", "--hostname"}, +} + +# Pattern for `NAME=value` env-var assignments that may precede a command. +# Must start with a letter or underscore, then any [A-Za-z0-9_] chars, +# then `=`, then anything. Matches POSIX identifier rules. +_ASSIGNMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") + +# Two-character separators (longest first so `;;` matches before `;`, +# `&&` before `&`, `||` before `|`). +_TWO_CHAR_SEPARATORS = ("&&", "||", ";;") +# Single-character separators. `\n` belongs here so unquoted newlines +# split commands the way bash does. +_SINGLE_CHAR_SEPARATORS = frozenset(";|&\n") + + +def _split_unquoted_separators(command: str) -> list[str]: + """Split `command` into raw segments on unquoted shell separators. + Walks the string char-by-char, tracking quote state so that + separators inside `"..."` or `'...'` are preserved as part of the + segment, and so backslash-newline outside quotes collapses to a + space (line continuation). + """ + segments: list[str] = [] + current: list[str] = [] + quote_char: str | None = None + i = 0 + n = len(command) + while i < n: + c = command[i] + # Inside a quoted region: copy verbatim until the matching quote. + if quote_char is not None: + current.append(c) + if c == "\\" and i + 1 < n: + # Inside double quotes, backslash escapes the next char; + # inside single quotes, it's literal. Either way, copy + # the next char verbatim so we don't mistake an escaped + # quote for a closer. + current.append(command[i + 1]) + i += 2 + continue + if c == quote_char: + quote_char = None + i += 1 + continue + # Outside quotes: detect opening quote, line continuation, or + # separator. Otherwise copy verbatim. + if c in ('"', "'"): + quote_char = c + current.append(c) + i += 1 + continue + if c == "\\" and i + 1 < n and command[i + 1] == "\n": + current.append(" ") + i += 2 + continue + # Longest separator first. + if i + 1 < n and command[i : i + 2] in _TWO_CHAR_SEPARATORS: + segments.append("".join(current)) + current = [] + i += 2 + continue + if c in _SINGLE_CHAR_SEPARATORS: + segments.append("".join(current)) + current = [] + i += 1 + continue + current.append(c) + i += 1 + segments.append("".join(current)) + return segments + + +def pipeline_segments(command: str) -> list[list[str]]: + """Split `command` on top-level shell separators and tokenize each + segment. Separators inside quotes are preserved as part of the + segment text. Backslash-newline outside quotes collapses to a + space (line continuation). Segments that fail to tokenize + (unbalanced quotes, etc.) are silently dropped — they could not + have been a real invocation of any target anyway. + """ + if not command or not command.strip(): + return [] + segments: list[list[str]] = [] + for raw in _split_unquoted_separators(command): + raw = raw.strip() + if not raw: + continue + try: + tokens = shlex.split(raw, posix=True) + except ValueError: + # Unbalanced quotes or similar — skip this segment. + continue + if tokens: + segments.append(tokens) + return segments + + +def strip_assignments(tokens: list[str]) -> list[str]: + """Drop leading `FOO=bar` env-var assignments from `tokens`.""" + i = 0 + while i < len(tokens) and _ASSIGNMENT_RE.match(tokens[i]): + i += 1 + return tokens[i:] + + +def _walk_past_global_flags(tokens: list[str], binary: str, start: int) -> int: + """Given `tokens` and an index `start` pointing at the position just + after `binary`, return the index of the first non-flag token. + Multi-token flags listed in _GLOBAL_FLAGS_WITH_ARG[binary] consume + the next token as their argument. `--flag=value` style flags occupy + a single token (the `=` is inside) and consume nothing extra. + Unknown `-...` tokens are conservatively treated as flag-only + (single-token, no argument) — if we guess wrong we'll miss the + subcommand, which is the safer failure mode. + """ + flags_with_arg = _GLOBAL_FLAGS_WITH_ARG.get(binary, set()) + i = start + while i < len(tokens) and tokens[i].startswith("-"): + token = tokens[i] + # `-x=value` and `--flag=value` occupy a single token. + if "=" in token: + i += 1 + continue + if token in flags_with_arg: + # Known flag-with-arg: skip the flag AND its next-token arg. + i += 2 + else: + # Unknown flag, or known no-arg flag: skip only the flag. + i += 1 + return i + + +def matches(command: str, binary: str, subcommand: list[str]) -> bool: + """Return True iff any pipeline segment in `command` invokes + `binary` with the given `subcommand` path, accounting for env-var + assignments and known global flags between `binary` and the first + subcommand component. + + Thin wrapper around `iter_match_args` — see also `match_args` + (first segment only) for callers that need to inspect args. + + >>> matches("git push", "git", ["push"]) + True + >>> matches("git -C . push", "git", ["push"]) + True + >>> matches("echo git push", "git", ["push"]) + False + >>> matches("gh -R foo/bar pr create", "gh", ["pr", "create"]) + True + >>> matches("gh pr list", "gh", ["pr", "create"]) + False + """ + return next(iter_match_args(command, binary, subcommand), None) is not None + + +def iter_match_args( + command: str, binary: str, subcommand: list[str] +) -> Iterator[list[str]]: + """Yield the args of EVERY pipeline segment in `command` that + invokes `binary` with `subcommand`. Each yielded list is the + tokens that follow the subcommand chain in one matching segment. + + Use this when the hook's invariant is per-invocation (e.g. + "no `gh pr create` should be non-draft") rather than per-command. + `gh pr create --draft && gh pr create` has two matching segments; + iterating lets the hook inspect both and deny if any lacks the + required flag. + + >>> list(iter_match_args("gh pr create --draft && gh pr create", "gh", ["pr", "create"])) + [['--draft'], []] + >>> list(iter_match_args("git status", "git", ["push"])) + [] + """ + if not subcommand: + return + for tokens in pipeline_segments(command): + tokens = strip_assignments(tokens) + if not tokens or tokens[0] != binary: + continue + i = _walk_past_global_flags(tokens, binary, 1) + # Subcommand chain must appear contiguously starting at `i`. + if tokens[i : i + len(subcommand)] == list(subcommand): + yield tokens[i + len(subcommand) :] + + +def match_args(command: str, binary: str, subcommand: list[str]) -> list[str] | None: + """Return the args of the FIRST matching pipeline segment, or None + if no segment matches. Convenience for callers that don't care + about chained invocations; prefer `iter_match_args` if the hook's + invariant is per-invocation. + + >>> match_args("gh pr create --draft", "gh", ["pr", "create"]) + ['--draft'] + >>> match_args("gh -R foo/bar pr create --title x", "gh", ["pr", "create"]) + ['--title', 'x'] + >>> match_args("gh pr create && echo --draft", "gh", ["pr", "create"]) + [] + >>> match_args("echo gh pr create --draft", "gh", ["pr", "create"]) is None + True + """ + return next(iter_match_args(command, binary, subcommand), None) diff --git a/.claude/hooks/_hookutils.py b/.claude/hooks/_hookutils.py new file mode 100644 index 0000000000..0ee1fb50f8 --- /dev/null +++ b/.claude/hooks/_hookutils.py @@ -0,0 +1,85 @@ +"""Shared filesystem / git helpers used by multiple hook scripts. + +Kept separate from `_classify.py` because the concerns are different: +`_classify` parses Bash commands; this module wraps the bits of the +repository that hooks need to inspect (git toplevel, Makefile target +presence). Both modules live alongside the hook scripts so they're +importable via the `sys.path` shim in each hook. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path +from typing import Any + + +def repo_root() -> Path | None: + """Return the absolute path of the current git worktree's top + level, or None if we're not inside a git worktree. + """ + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + return None + out = result.stdout.strip() + if not out: + return None + return Path(out) + + +def makefile_has_target(makefile: Path, target: str) -> bool: + """Return True if `makefile` declares a recipe for `target` (i.e. + any line starts with `target:`). False if the file is missing or + no matching recipe is found. + """ + try: + text = makefile.read_text() + except OSError: + return False + needle = f"{target}:" + return any(line.startswith(needle) for line in text.splitlines()) + + +def read_payload(stdin: Any = None) -> dict | None: + """Parse the Claude Code hook payload from stdin and return the + top-level dict, or None on any malformed input. `stdin` defaults + to `sys.stdin`; pass a file-like object to make this unit-testable. + """ + source = stdin if stdin is not None else sys.stdin + try: + payload = json.loads(source.read()) + except (json.JSONDecodeError, ValueError, OSError): + return None + if not isinstance(payload, dict): + return None + return payload + + +def command_from_payload(payload: Any) -> str | None: + """Return `payload["tool_input"]["command"]` as a string, or None + on any unexpected shape. Defensive against missing keys, non-dict + `tool_input`, non-string `command`. + """ + if not isinstance(payload, dict): + return None + tool_input = payload.get("tool_input") + if not isinstance(tool_input, dict): + return None + command = tool_input.get("command", "") + if not isinstance(command, str): + return None + return command + + +def read_command(stdin: Any = None) -> str | None: + """Read the hook payload from stdin and return its + `tool_input.command` field as a string, or None on any malformed + input (so the hook can fail open with `sys.exit(0)`). + """ + return command_from_payload(read_payload(stdin)) diff --git a/.claude/hooks/post_pr_create_changelog.py b/.claude/hooks/post_pr_create_changelog.py new file mode 100755 index 0000000000..9408763a24 --- /dev/null +++ b/.claude/hooks/post_pr_create_changelog.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Post-PR-create hook: spawns a changelog-manager agent to classify +the PR diff and decide whether a CHANGELOG.md entry or "no changelog" +label is needed. Outputs actionable instructions to the main agent via +hookSpecificOutput. + +The agent is responsible for locating the correct unreleased section +in CHANGELOG.md. This hook does not pre-resolve a version. +""" + +from __future__ import annotations + +import json +import re +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 # noqa: E402 + +TARGET = ("gh", ["pr", "create"]) + +_PR_URL_RE = re.compile(r"https://github\.com/[^\s\"]+/pull/(\d+)") + + +def main() -> None: + payload = read_payload() + command = command_from_payload(payload) + if command is None or not matches(command, *TARGET): + sys.exit(0) + # mypy hint: command_from_payload returns None unless payload is a dict + assert payload is not None # nosec - guarded by command check above + + tool_response = payload.get("tool_response", "") or "" + if not isinstance(tool_response, str): + # PostToolUse may pass the response as a structured object; coerce + # to text by JSON-dumping so the URL regex still works. + tool_response = json.dumps(tool_response) + cwd = payload.get("cwd", "") or "" + if not isinstance(cwd, str) or not cwd: + sys.exit(0) + + match = _PR_URL_RE.search(tool_response) + if not match: + sys.exit(0) + pr_url = match.group(0) + pr_number = match.group(1) + + prompt = ( + f"Check changelog for PR #{pr_number} ({pr_url}). Important: if the " + "diff contains ANY changes that affect runtime behavior, a changelog " + "entry is needed, even if the PR also contains config/tooling/docs " + "changes." + ) + allowed_tools = "Bash(git:*) Bash(gh:*) Read Grep Glob" + + result = subprocess.run( + [ + "claude", + "--agent", + "changelog-manager", + "--allowedTools", + allowed_tools, + "-p", + prompt, + ], + capture_output=True, + text=True, + cwd=cwd, + ) + verdict_line = _find_verdict_line(result.stdout) + + if verdict_line is None: + _emit_warning(pr_number, result.stdout, result.stderr) + sys.exit(2) + + if verdict_line.startswith("SKIP:"): + sys.exit(0) + + if verdict_line.startswith("NO_CHANGELOG:"): + _emit_context( + "No changelog entry needed for this PR. Apply the 'no changelog' " + f"label now:\n\ngh pr edit {pr_number} --add-label 'no changelog'" + ) + sys.exit(2) + + if verdict_line.startswith("CHANGELOG:"): + # Everything from `CHANGELOG:` onward (across newlines) is the + # suggested entry body. + entry = _extract_changelog_entry(result.stdout) + _emit_context( + f"Changelog entry needed for PR #{pr_number}. Add the following " + "to CHANGELOG.md under the appropriate unreleased section (read " + f"the file to locate it), then commit and push:\n\n{entry}" + ) + sys.exit(2) + + # Fallthrough — shouldn't happen given _find_verdict_line, but be defensive. + _emit_warning(pr_number, result.stdout, result.stderr) + sys.exit(2) + + +def _find_verdict_line(stdout: str) -> str | None: + for line in stdout.splitlines(): + if line.startswith(("SKIP:", "NO_CHANGELOG:", "CHANGELOG:")): + return line + return None + + +def _extract_changelog_entry(stdout: str) -> str: + """Return the body of the `CHANGELOG:` block — i.e. everything from + the `CHANGELOG: ` prefix onward, with that prefix stripped on the + first line, preserving subsequent lines verbatim. + """ + lines = stdout.splitlines() + out: list[str] = [] + found = False + for line in lines: + if not found: + if line.startswith("CHANGELOG:"): + out.append(line[len("CHANGELOG:") :].lstrip(" ")) + found = True + continue + out.append(line) + return "\n".join(out) + + +def _emit_context(message: str) -> None: + output = { + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": message, + } + } + sys.stdout.write(json.dumps(output) + "\n") + + +def _emit_warning(pr_number: str, stdout: str, stderr: str) -> None: + warning = ( + f"WARNING: changelog-manager produced no verdict for PR #{pr_number}. " + "Decide manually: add a CHANGELOG.md entry under the appropriate " + "unreleased section, or apply the 'no changelog' label via: gh pr " + f"edit {pr_number} --add-label 'no changelog'" + ) + if stderr.strip(): + warning += f"\n\n--- classifier stderr ---\n{stderr}" + if stdout.strip(): + warning += f"\n\n--- classifier stdout (no verdict line recognized) ---\n{stdout}" + _emit_context(warning) + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/pre_commit_lint.py b/.claude/hooks/pre_commit_lint.py new file mode 100755 index 0000000000..c64f3ed189 --- /dev/null +++ b/.claude/hooks/pre_commit_lint.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Pre-commit hook: runs `make lint` in Rust repositories before +allowing `git commit`. Exit 0 = allow, exit 2 = block (with reason on +stderr). +""" + +from __future__ import annotations + +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 makefile_has_target, read_command, repo_root # noqa: E402 + +# Imported by tests to verify the hook routes correctly. +TARGET = ("git", ["commit"]) + + +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.exit(0) + if not (root / "Cargo.toml").is_file(): + sys.exit(0) + if not makefile_has_target(root / "Makefile", "lint"): + sys.exit(0) + + result = subprocess.run( + ["make", "-C", str(root), "lint"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + sys.stderr.write("make lint failed - fix issues before committing:\n") + sys.stderr.write(result.stdout) + sys.stderr.write(result.stderr) + sys.exit(2) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/pre_pr_draft.py b/.claude/hooks/pre_pr_draft.py new file mode 100755 index 0000000000..01119297c6 --- /dev/null +++ b/.claude/hooks/pre_pr_draft.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""PreToolUse hook for the Bash tool: blocks `gh pr create` invocations +that do not pass `--draft`. PRs must be created as drafts; a human +promotes them to ready-for-review when appropriate. + +Routes via `_classify.iter_match_args` (segment-scoped, per-invocation) +so detection only looks at the args of the actual `gh pr create` +segments — not anywhere in the raw command string. This avoids the +false positives the earlier substring-based check had (`echo gh pr +create`, `gh pr create --title "--draft"`) and enforces the rule on +EVERY matching segment, not just the first (so chained creates can't +slip past). + +`--draft=true`/`--draft=1`/etc. count; `--draft=false`/`--draft=0` +do NOT (Cobra/pflag accept the explicit-false form to disable a +boolean flag). + +Output protocol: writes JSON to stdout per the Claude Code PreToolUse +hook contract. Exit code is always 0; the deny signal is carried in +the JSON payload's `permissionDecision` field. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from _classify import iter_match_args # noqa: E402 +from _hookutils import read_command # noqa: E402 + +TARGET = ("gh", ["pr", "create"]) + +# `gh pr create` flags that take a separate-token argument. Mirrored +# from `gh pr create --help` as of gh 2.x (Nov 2026). If gh adds new +# arg-taking flags, an invocation like `gh pr create --new-flag --draft` +# would otherwise misread `--draft` as a flag rather than the value of +# `--new-flag`. The unknown-flag branch below catches that case +# conservatively: any unknown `-...` token consumes the next token as +# its value. +_ARG_TAKING_FLAGS = frozenset( + { + "--base", "-B", + "--body", "-b", + "--body-file", "-F", + "--head", "-H", + "--label", "-l", + "--assignee", "-a", + "--reviewer", "-r", + "--milestone", "-m", + "--project", "-p", + "--template", "-T", + "--title", "-t", + } +) + +# `gh pr create` flags that are boolean (no arg). Used so the +# unknown-flag conservative branch doesn't accidentally swallow a real +# subsequent `--draft` when a known boolean appears before it. +_BOOLEAN_FLAGS = frozenset( + { + "--draft", "-d", + "--web", "-w", + "--fill", "-f", + "--fill-first", + "--fill-verbose", + "--dry-run", + "--editor", "-e", + "--no-maintainer-edit", + } +) + +# pflag-style truthy values for `--draft=`. Anything not in this +# set is treated as falsy (draft NOT requested). +_TRUTHY_VALUES = frozenset({"1", "t", "T", "true", "TRUE", "True"}) + + +def has_draft_flag(args: list[str]) -> bool: + """Walk `gh pr create` args left-to-right and return True iff + `--draft` (or `-d`, or `--draft=`) appears in a flag + position — not as the value of another flag. + + Walking rules: + - `--draft` / `-d` alone → True. + - `--draft=` / `-d=` → True if `` is pflag-truthy. + - `--=` (single-token) consumes one token; never the + next. + - Known arg-taking flag → consumes the next token. + - Known boolean flag → does NOT consume the next token. + - Unknown `-...` token → consumes the next token (conservative; + a hypothetical new arg-taking gh flag would otherwise let + `--draft` be misread). + - Anything else (positional arg) → advance one. + """ + i = 0 + while i < len(args): + tok = args[i] + # The flag itself. + if tok == "--draft" or tok == "-d": + return True + if tok.startswith("--draft=") or tok.startswith("-d="): + value = tok.split("=", 1)[1] + return value in _TRUTHY_VALUES + # `--flag=value` style: single token, consumes nothing extra. + if tok.startswith("--") and "=" in tok: + i += 1 + continue + # Known boolean flag: stand-alone, doesn't consume next. + if tok in _BOOLEAN_FLAGS: + i += 1 + continue + # Known arg-taking flag: consume the next token as the value. + if tok in _ARG_TAKING_FLAGS: + i += 2 + continue + # Unknown `-...` token: conservatively assume arg-taking. + if tok.startswith("-"): + i += 2 + continue + # Positional arg. + i += 1 + return False + + +def main() -> None: + command = read_command() + if command is None: + sys.exit(0) + + # Inspect EVERY matching `gh pr create` segment. The hook's + # invariant is per-invocation: none of them may be non-draft. + for args in iter_match_args(command, *TARGET): + if not has_draft_flag(args): + _emit_deny(command) + break + # Hook exits 0 either way; the deny is carried in the JSON payload. + sys.exit(0) + + +def _emit_deny(command: str) -> None: + reason = ( + "PRs must be created as drafts. Re-run with --draft:\n\n" + f" {command} --draft" + ) + 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 new file mode 100755 index 0000000000..02f4b25467 --- /dev/null +++ b/.claude/hooks/pre_push_review.py @@ -0,0 +1,213 @@ +#!/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/pre_push_test.py b/.claude/hooks/pre_push_test.py new file mode 100755 index 0000000000..bf30ebdf9a --- /dev/null +++ b/.claude/hooks/pre_push_test.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Pre-push hook: runs `make test` in Rust repositories before allowing +`git push`. Exit 0 = allow, exit 2 = block (with reason on stderr). +""" + +from __future__ import annotations + +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 makefile_has_target, read_command, repo_root # noqa: E402 + +TARGET = ("git", ["push"]) + + +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.exit(0) + if not (root / "Cargo.toml").is_file(): + sys.exit(0) + if not makefile_has_target(root / "Makefile", "test"): + sys.exit(0) + + sys.stderr.write("Running make test...\n") + result = subprocess.run( + ["make", "-C", str(root), "test"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + sys.stderr.write("make test failed - fix failing tests before pushing:\n") + sys.stderr.write(result.stdout) + sys.stderr.write(result.stderr) + sys.exit(2) + sys.stderr.write("All tests passed.\n") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/tests/__init__.py b/.claude/hooks/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.claude/hooks/tests/conftest.py b/.claude/hooks/tests/conftest.py new file mode 100644 index 0000000000..92d3bfb3b2 --- /dev/null +++ b/.claude/hooks/tests/conftest.py @@ -0,0 +1,9 @@ +"""Make the hook scripts importable from tests.""" + +import sys +from pathlib import Path + +# Tests live in .claude/hooks/tests/; the modules under test live in +# .claude/hooks/. Add the parent dir to sys.path so `import _classify` +# and `import pre_push_review` work. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) diff --git a/.claude/hooks/tests/test_classify.py b/.claude/hooks/tests/test_classify.py new file mode 100644 index 0000000000..208fb6747d --- /dev/null +++ b/.claude/hooks/tests/test_classify.py @@ -0,0 +1,317 @@ +"""Tests for the `_classify` module — Layer 1 (primitives) and Layer 3 +(edge cases) of the test plan. Layer 2 (per-hook routing) lives in +test_hooks.py. +""" + +import pytest + +from _classify import ( + iter_match_args, + match_args, + matches, + pipeline_segments, + strip_assignments, +) + + +# --------------------------------------------------------------------------- +# pipeline_segments — top-level splitting + shlex tokenization. +# --------------------------------------------------------------------------- + + +def test_segments_single_command(): + assert pipeline_segments("git push") == [["git", "push"]] + + +def test_segments_split_on_and_and(): + assert pipeline_segments("cd repo && git push") == [ + ["cd", "repo"], + ["git", "push"], + ] + + +def test_segments_split_on_pipe(): + assert pipeline_segments("cat foo | grep bar") == [ + ["cat", "foo"], + ["grep", "bar"], + ] + + +def test_segments_split_on_or_or_semicolon_amp(): + cmd = "a || b ; c & d" + assert pipeline_segments(cmd) == [["a"], ["b"], ["c"], ["d"]] + + +def test_segments_split_on_newline(): + assert pipeline_segments("git push\ngit status") == [ + ["git", "push"], + ["git", "status"], + ] + + +def test_segments_preserves_quoted_separators(): + # Separators inside quotes must not split — shlex sees them as one + # token with the literal characters. + assert pipeline_segments('echo "a && b"') == [["echo", "a && b"]] + + +def test_segments_drops_empty_segments(): + assert pipeline_segments(" ") == [] + assert pipeline_segments("") == [] + assert pipeline_segments(";;;") == [] + + +def test_segments_drops_unbalanced_quotes(): + # An unbalanced quote means everything after the opening `"` is + # inside a quoted region from the splitter's perspective, so we + # never see the `&&` as a separator. The single produced segment + # fails shlex tokenization and is dropped. This matches bash's own + # behavior — bash would prompt for the closing quote rather than + # treat `git push` as a separate command. + assert pipeline_segments('echo "broken && git push') == [] + + +# --------------------------------------------------------------------------- +# strip_assignments — leading FOO=bar removal. +# --------------------------------------------------------------------------- + + +def test_strip_no_assignments(): + assert strip_assignments(["git", "push"]) == ["git", "push"] + + +def test_strip_one_assignment(): + assert strip_assignments(["FOO=bar", "git", "push"]) == ["git", "push"] + + +def test_strip_multiple_assignments(): + assert strip_assignments(["FOO=bar", "BAZ=qux", "git", "push"]) == ["git", "push"] + + +def test_strip_does_not_eat_args_after_command(): + # `-c k=v` is an arg to git, not a leading assignment. It comes + # after the command name, so strip_assignments shouldn't touch it. + # (And we'd never pass it here in practice — strip_assignments only + # runs on the leading tokens.) + assert strip_assignments(["git", "-c", "user.name=foo", "commit"]) == [ + "git", + "-c", + "user.name=foo", + "commit", + ] + + +def test_strip_rejects_non_identifier_prefix(): + # `=value` alone is not a valid assignment; leave it alone. + assert strip_assignments(["=value", "git", "push"]) == ["=value", "git", "push"] + # Digit-leading is not a valid identifier. + assert strip_assignments(["1FOO=bar", "git"]) == ["1FOO=bar", "git"] + + +# --------------------------------------------------------------------------- +# matches — the primary classifier entrypoint. The richer per-hook +# parametrized table lives in test_hooks.py; the cases here exercise +# the matcher's own semantics. +# --------------------------------------------------------------------------- + + +def test_matches_simple(): + assert matches("git push", "git", ["push"]) is True + + +def test_matches_with_args_after_subcommand(): + assert matches("git push origin main", "git", ["push"]) is True + + +def test_matches_multi_word_subcommand(): + assert matches("gh pr create", "gh", ["pr", "create"]) is True + + +def test_matches_rejects_partial_subcommand(): + assert matches("gh pr list", "gh", ["pr", "create"]) is False + + +def test_matches_rejects_when_binary_is_quoted_argument(): + assert matches('echo "git push"', "git", ["push"]) is False + + +def test_matches_rejects_bare_substring(): + assert matches("git push-graph", "git", ["push"]) is False + + +def test_matches_fires_when_any_segment_matches(): + # Multi-segment command, only the second is the real invocation. + assert matches("cd repo && git push", "git", ["push"]) is True + + +def test_matches_handles_env_var_prefix(): + assert matches("FOO=bar git push", "git", ["push"]) is True + + +def test_matches_walks_past_known_git_flags(): + assert matches("git -C . push", "git", ["push"]) is True + assert matches("git -c user.name=foo push", "git", ["push"]) is True + assert matches("git --git-dir /tmp/x push", "git", ["push"]) is True + + +def test_matches_walks_past_known_gh_flags(): + assert matches("gh -R foo/bar pr create", "gh", ["pr", "create"]) is True + assert matches("gh --repo foo/bar pr create", "gh", ["pr", "create"]) is True + assert matches("gh --hostname=example.com pr create", "gh", ["pr", "create"]) is True + + +def test_matches_walks_past_unknown_flag_conservatively(): + # Unknown flag without arg: still finds subcommand. + assert matches("git --no-pager push", "git", ["push"]) is True + + +def test_matches_handles_multiple_global_flags(): + assert ( + matches( + "git -c user.name=foo -C . push origin main", + "git", + ["push"], + ) + is True + ) + + +def test_matches_empty_command_is_false(): + assert matches("", "git", ["push"]) is False + + +def test_matches_empty_subcommand_is_false(): + # Asking "does this command run `git `" is meaningless; we + # return False rather than vacuously True. + assert matches("git", "git", []) is False + + +def test_matches_bare_binary_is_false(): + # `git` alone is not `git push`. + assert matches("git", "git", ["push"]) is False + + +def test_matches_handles_redirection(): + # `git push > out` — shlex preserves `>` as a token; the subcommand + # check ignores everything after `push` anyway. + assert matches("git push > out.txt", "git", ["push"]) is True + + +def test_matches_quoted_substring_does_not_fire(): + # Reviewer-reported regression: `printf` with a quoted "git push" + # is not a push. + assert matches('printf "git push" > out.txt', "git", ["push"]) is False + + +# --------------------------------------------------------------------------- +# Edge cases from the test plan. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "command", + ["", " ", "\n\n", ";;;"], +) +def test_empty_or_whitespace_only_command_is_false(command): + assert matches(command, "git", ["push"]) is False + + +def test_backslash_continuation_in_shlex(): + # shlex.split handles backslash-newline as line continuation in + # POSIX mode. Verify the matcher copes. + assert matches("git \\\n push", "git", ["push"]) is True + + +def test_unbalanced_quotes_segment_dropped(): + # The segment with the bad quote is dropped; remaining segments + # are still classified. Here there's no good segment, so False. + assert matches('echo "broken', "git", ["push"]) is False + + +def test_pipe_with_unrelated_first_command(): + # First segment is `cat`, second is `grep`. Neither is `git push`. + assert matches("cat file | grep foo", "git", ["push"]) is False + + +# --------------------------------------------------------------------------- +# match_args — used by pre_pr_draft to scope flag inspection to the +# matched `gh pr create` segment rather than the raw command string. +# --------------------------------------------------------------------------- + + +def test_match_args_no_args(): + assert match_args("gh pr create", "gh", ["pr", "create"]) == [] + + +def test_match_args_with_simple_args(): + assert match_args("gh pr create --draft", "gh", ["pr", "create"]) == ["--draft"] + + +def test_match_args_walks_past_global_flags(): + assert match_args( + "gh -R foo/bar pr create --title x", "gh", ["pr", "create"] + ) == ["--title", "x"] + + +def test_match_args_returns_only_matched_segment(): + # The `&& echo --draft` segment must NOT pollute the args. + assert ( + match_args("gh pr create && echo --draft", "gh", ["pr", "create"]) == [] + ) + + +def test_match_args_no_match_returns_none(): + assert match_args("echo gh pr create --draft", "gh", ["pr", "create"]) is None + assert match_args("git push", "gh", ["pr", "create"]) is None + + +def test_match_args_picks_first_matching_segment(): + # If two segments would match (unusual), return the first. + assert match_args( + "gh pr create --draft && gh pr create --title y", + "gh", + ["pr", "create"], + ) == ["--draft"] + + +# --------------------------------------------------------------------------- +# iter_match_args — yields args for every matching segment, so callers +# whose invariant is per-invocation can fold over all of them. +# --------------------------------------------------------------------------- + + +def test_iter_match_args_no_match(): + assert list(iter_match_args("git status", "gh", ["pr", "create"])) == [] + + +def test_iter_match_args_single_segment(): + assert list(iter_match_args("gh pr create --draft", "gh", ["pr", "create"])) == [ + ["--draft"] + ] + + +def test_iter_match_args_two_matching_segments(): + # Both segments match; both args lists are yielded in order. + assert list( + iter_match_args( + "gh pr create --draft && gh pr create", + "gh", + ["pr", "create"], + ) + ) == [["--draft"], []] + + +def test_iter_match_args_mixed_segments(): + # First segment matches, second is `gh pr list` (different subcommand). + assert list( + iter_match_args( + "gh pr create --draft && gh pr list", + "gh", + ["pr", "create"], + ) + ) == [["--draft"]] + + +def test_iter_match_args_empty_subcommand(): + # An empty subcommand is meaningless; yield nothing. + assert list(iter_match_args("gh", "gh", [])) == [] diff --git a/.claude/hooks/tests/test_hooks.py b/.claude/hooks/tests/test_hooks.py new file mode 100644 index 0000000000..fc944d7f0a --- /dev/null +++ b/.claude/hooks/tests/test_hooks.py @@ -0,0 +1,141 @@ +"""Per-hook routing tests (Layer 2 of the test plan). + +For each hook, parametrize over a table of `(command, should_fire)` +pairs and verify that `_classify.matches(command, *hook.TARGET)` +returns the expected boolean. pytest test IDs include the hook name +so a regression points directly at the responsible script. +""" + +from __future__ import annotations + +import importlib + +import pytest + +from _classify import matches + + +HOOK_NAMES = [ + "pre_commit_lint", + "pre_pr_draft", + "pre_push_review", + "pre_push_test", + "post_pr_create_changelog", +] + + +# Per-hook routing cases. The format is `(hook_module, command, should_fire)`. +# We rely on the cases referencing actual hook module names so a typo here +# blows up at collection time instead of silently skipping coverage. +# +# Some hooks intentionally share a TARGET with another hook +# (see PAIRED_TARGETS below). For each pair, only ONE hook appears in +# the table; routing for the partner is guaranteed identical because +# both delegate to `_classify.matches` with the same arguments. +# `test_paired_hooks_share_target` enforces that contract — if anyone +# 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_commit_lint — `git commit`. + ("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", "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. + ("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), + ("pre_pr_draft", "gh --hostname=github.com pr create", True), + ("pre_pr_draft", "echo gh pr create", False), + ("pre_pr_draft", 'echo "gh pr create"', False), + ("pre_pr_draft", "gh pr list", False), + ("pre_pr_draft", "gh issue create", False), +] + + +# Pairs of hooks that intentionally share the same TARGET. The first +# 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_pr_draft", "post_pr_create_changelog"), +] + + +@pytest.fixture(scope="module") +def hooks() -> dict[str, object]: + """Import every hook module exactly once and return name -> module.""" + return {name: importlib.import_module(name) for name in HOOK_NAMES} + + +def _case_id(case: tuple[str, str, bool]) -> str: + hook, command, expected = case + # Pytest IDs cannot contain spaces; replace for readability. + safe_cmd = command.replace(" ", "_").replace("\n", "\\n") + return f"{hook}-{safe_cmd}-{expected}" + + +@pytest.mark.parametrize("case", HOOK_CASES, ids=_case_id) +def test_hook_routes(hooks: dict[str, object], case: tuple[str, str, bool]) -> None: + hook_name, command, should_fire = case + hook = hooks[hook_name] + binary, subcommand = hook.TARGET # type: ignore[attr-defined] + actual = matches(command, binary, subcommand) + assert actual is should_fire, ( + f"{hook_name}.TARGET={hook.TARGET!r} expected matches({command!r}) " # type: ignore[attr-defined] + f"== {should_fire}, got {actual}" + ) + + +def test_all_hooks_export_target(hooks: dict[str, object]) -> None: + """Smoke test: every hook module exposes a TARGET tuple of + (binary: str, subcommand: list[str]).""" + for name, hook in hooks.items(): + target = getattr(hook, "TARGET", None) + assert target is not None, f"{name} is missing TARGET" + assert isinstance(target, tuple) and len(target) == 2, f"{name}.TARGET shape wrong: {target!r}" + binary, subcommand = target + assert isinstance(binary, str) and binary, f"{name}.TARGET[0] must be a non-empty str" + assert isinstance(subcommand, list) and subcommand, f"{name}.TARGET[1] must be a non-empty list" + assert all(isinstance(x, str) and x for x in subcommand), ( + f"{name}.TARGET[1] must be a list of non-empty strings" + ) + + +@pytest.mark.parametrize("pair", PAIRED_TARGETS, ids=lambda p: f"{p[0]}_vs_{p[1]}") +def test_paired_hooks_share_target( + hooks: dict[str, object], pair: tuple[str, str] +) -> None: + """For documented pairs, verify both hooks export the same TARGET. + + Lets us skip replaying the parametrized routing cases for the + second hook — its behavior is identical to the first by virtue of + routing through `_classify.matches` with the same arguments. If + this test ever fails, either re-align the targets or split the + pair and add explicit per-hook cases for both. + """ + a, b = pair + target_a = hooks[a].TARGET # type: ignore[attr-defined] + target_b = hooks[b].TARGET # type: ignore[attr-defined] + assert target_a == target_b, ( + f"{a}.TARGET ({target_a!r}) differs from {b}.TARGET ({target_b!r}). " + f"Either re-align or remove the entry from PAIRED_TARGETS and add " + f"explicit per-hook routing cases for both." + ) diff --git a/.claude/hooks/tests/test_hookutils.py b/.claude/hooks/tests/test_hookutils.py new file mode 100644 index 0000000000..2ca967b8fe --- /dev/null +++ b/.claude/hooks/tests/test_hookutils.py @@ -0,0 +1,104 @@ +"""Tests for `_hookutils.read_payload` and `command_from_payload` — +the defensive JSON-parsing layer every hook routes through. + +`repo_root` and `makefile_has_target` are not unit-tested here because +they're thin wrappers around `git rev-parse --show-toplevel` and +`pathlib.Path.read_text`, exercised end-to-end by the existing hooks +running under `make hooks-test`. +""" + +from __future__ import annotations + +import io +import json + +import pytest + +from _hookutils import command_from_payload, read_command, read_payload + + +# --------------------------------------------------------------------------- +# read_payload — top-level safety. +# --------------------------------------------------------------------------- + + +def test_read_payload_returns_dict() -> None: + text = json.dumps({"tool_input": {"command": "git push"}}) + assert read_payload(io.StringIO(text)) == { + "tool_input": {"command": "git push"} + } + + +def test_read_payload_empty_input() -> None: + assert read_payload(io.StringIO("")) is None + + +def test_read_payload_malformed_json() -> None: + assert read_payload(io.StringIO("not json")) is None + + +def test_read_payload_truncated_json() -> None: + assert read_payload(io.StringIO('{"tool_input":')) is None + + +@pytest.mark.parametrize("non_dict", ['"a string"', "42", "[1,2,3]", "true", "null"]) +def test_read_payload_non_dict_top_level(non_dict: str) -> None: + assert read_payload(io.StringIO(non_dict)) is None + + +# --------------------------------------------------------------------------- +# command_from_payload — extract tool_input.command defensively. +# --------------------------------------------------------------------------- + + +def test_command_from_payload_happy_path() -> None: + assert ( + command_from_payload({"tool_input": {"command": "git push"}}) + == "git push" + ) + + +def test_command_from_payload_empty_command() -> None: + assert command_from_payload({"tool_input": {"command": ""}}) == "" + + +def test_command_from_payload_missing_command_key() -> None: + # `tool_input.command` defaults to "" via .get, so this returns "". + assert command_from_payload({"tool_input": {}}) == "" + + +def test_command_from_payload_missing_tool_input() -> None: + assert command_from_payload({}) is None + + +@pytest.mark.parametrize("not_dict", [None, "string", 42, [], False]) +def test_command_from_payload_non_dict_payload(not_dict: object) -> None: + assert command_from_payload(not_dict) is None + + +@pytest.mark.parametrize("not_dict", [None, "string", 42, [], False]) +def test_command_from_payload_non_dict_tool_input(not_dict: object) -> None: + assert command_from_payload({"tool_input": not_dict}) is None + + +@pytest.mark.parametrize("not_str", [None, 42, [], {}, False]) +def test_command_from_payload_non_string_command(not_str: object) -> None: + assert command_from_payload({"tool_input": {"command": not_str}}) is None + + +# --------------------------------------------------------------------------- +# read_command — composition of the two. +# --------------------------------------------------------------------------- + + +def test_read_command_happy_path() -> None: + text = json.dumps({"tool_input": {"command": "gh pr create --draft"}}) + assert read_command(io.StringIO(text)) == "gh pr create --draft" + + +def test_read_command_malformed_returns_none() -> None: + assert read_command(io.StringIO("not json")) is None + + +def test_read_command_missing_tool_input_returns_none() -> None: + assert read_command(io.StringIO("{}")) is None diff --git a/.claude/hooks/tests/test_pre_pr_draft.py b/.claude/hooks/tests/test_pre_pr_draft.py new file mode 100644 index 0000000000..75f337f5bd --- /dev/null +++ b/.claude/hooks/tests/test_pre_pr_draft.py @@ -0,0 +1,280 @@ +"""Tests for `pre_pr_draft` — segment-scoped, flag-value-aware +draft-detection on the matched `gh pr create` segment(s), plus +end-to-end `main()` JSON contract. + +Layers: + - `has_draft_flag(args)` unit tests covering the flag/value walker + (truthy/falsy `=value`, known boolean shields, unknown-flag + conservatism). + - Wired-through cases combining `iter_match_args` + `has_draft_flag` + against real command strings (segment scoping, multi-segment fold). + - `main()` end-to-end with mocked stdin/stdout (deny payload shape, + allow path, malformed payload, multi-segment fold). +""" + +from __future__ import annotations + +import io +import json + +import pytest + +from _classify import iter_match_args, match_args +from pre_pr_draft import TARGET, has_draft_flag + + +# --------------------------------------------------------------------------- +# has_draft_flag — unit-level cases against the walker directly. +# --------------------------------------------------------------------------- + + +def test_has_draft_flag_empty_args() -> None: + assert has_draft_flag([]) is False + + +def test_has_draft_flag_bare_long() -> None: + assert has_draft_flag(["--draft"]) is True + + +def test_has_draft_flag_bare_short() -> None: + assert has_draft_flag(["-d"]) is True + + +@pytest.mark.parametrize( + "value, expected", + [ + ("true", True), + ("TRUE", True), + ("True", True), + ("1", True), + ("t", True), + ("T", True), + # pflag boolean falsy values — must NOT count as draft requested. + ("false", False), + ("FALSE", False), + ("False", False), + ("0", False), + ("f", False), + ("F", False), + # Anything else also falsy (safe default). + ("yes", False), + ("no", False), + ("anything", False), + ("", False), + ], +) +def test_has_draft_flag_long_value_parsing(value: str, expected: bool) -> None: + assert has_draft_flag([f"--draft={value}"]) is expected + + +def test_has_draft_flag_short_equals_form() -> None: + assert has_draft_flag(["-d=true"]) is True + assert has_draft_flag(["-d=false"]) is False + + +def test_has_draft_flag_consumed_as_arg_to_title() -> None: + # --title takes an arg; --draft is its value, not a flag. + assert has_draft_flag(["--title", "--draft"]) is False + + +def test_has_draft_flag_chained_consumed_values() -> None: + # --title eats hello, --body eats world, --draft is finally a flag. + assert ( + has_draft_flag(["--title", "hello", "--body", "world", "--draft"]) + is True + ) + + +def test_has_draft_flag_attached_value_does_not_consume_next() -> None: + # --title=hello is a single token; --draft that follows is a real flag. + assert has_draft_flag(["--title=hello", "--draft"]) is True + + +def test_has_draft_flag_known_boolean_does_not_shield() -> None: + # --web is a known boolean; it doesn't consume --draft as its value. + assert has_draft_flag(["--web", "--draft"]) is True + + +def test_has_draft_flag_unknown_flag_consumes_next() -> None: + # Conservative: if gh ever adds an arg-taking flag we don't know, + # we'd rather miss a real --draft than allow a non-draft PR. + assert has_draft_flag(["--brand-new-flag", "--draft"]) is False + + +def test_has_draft_flag_positional_args_dont_shield() -> None: + # `gh pr create some-positional --draft` — positionals advance one, + # so --draft is reachable. + assert has_draft_flag(["some-positional", "--draft"]) is True + + +# --------------------------------------------------------------------------- +# Wired through iter_match_args — real command strings. +# --------------------------------------------------------------------------- + + +# Each case: (command, expected_should_allow). True = ALL matching +# segments are draft (or there are none); False = at least one +# matching segment is non-draft. +DRAFT_CASES: list[tuple[str, bool]] = [ + # Trivial allow / deny. + ("gh pr create --draft", True), + ("gh pr create", False), + # `--draft=value` forms. + ("gh pr create --draft=true", True), + ("gh pr create --draft=false", False), # huitseeker / reviewers + ("gh pr create --draft=1", True), + ("gh pr create --draft=0", False), + # Global flag walk-past still works. + ("gh -R foo/bar pr create --draft", True), + ("gh -R foo/bar pr create", False), + # huitseeker case 1: `--draft` in a different segment doesn't count. + ("gh pr create && echo --draft", False), + # huitseeker case 2: `--draft` as the value of `--title` doesn't count. + ('gh pr create --title "--draft"', False), + # ...but a legitimate `--draft` after a complete `--title value` does. + ("gh pr create --title hello --draft", True), + # Multi-segment fold (security reviewer note): if ANY matching + # segment lacks --draft, deny. + ("gh pr create --draft && gh pr create", False), + ("gh pr create && gh pr create --draft", False), + ("gh pr create --draft && gh pr create --draft", True), + # Short-form value-consuming flag shields its value. + ("gh pr create -t --draft", False), + ("gh pr create -t hello --draft", True), +] + + +@pytest.mark.parametrize( + "case", + DRAFT_CASES, + ids=lambda c: c[0].replace(" ", "_").replace('"', "'"), +) +def test_draft_flag_wired_through(case: tuple[str, str | bool]) -> None: + command, should_allow = case + # Mirror the main() loop: all matching segments must have --draft. + matched_segments = list(iter_match_args(command, *TARGET)) + if not matched_segments: + # No `gh pr create` at all — wouldn't enter this test path. + pytest.skip(f"no matching segment in {command!r}") + actual = all(has_draft_flag(args) for args in matched_segments) + assert actual is should_allow, ( + f"command {command!r}: matched_segments={matched_segments!r}; " + f"all-have-draft={actual}, expected {should_allow}" + ) + + +def test_match_args_skipped_when_no_gh_pr_create() -> None: + # Smoke: commands with no `gh pr create` at all return None from + # match_args. main() exits 0 silently in that case. + assert match_args("echo gh pr create", "gh", ["pr", "create"]) is None + assert match_args("git status", "gh", ["pr", "create"]) is None + + +# --------------------------------------------------------------------------- +# main() end-to-end with mocked stdin/stdout. +# --------------------------------------------------------------------------- + + +def _run_main( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + payload: dict | str, +) -> tuple[int, str, str]: + """Invoke pre_pr_draft.main() with `payload` (a dict; serialized to + JSON) or a raw string (passed through unchanged) on stdin. Returns + (exit_code, captured_stdout, captured_stderr). + """ + import pre_pr_draft # imported fresh per-call so monkeypatched stdin sticks + + text = payload if isinstance(payload, str) else json.dumps(payload) + monkeypatch.setattr("sys.stdin", io.StringIO(text)) + with pytest.raises(SystemExit) as exc: + pre_pr_draft.main() + captured = capsys.readouterr() + code = exc.value.code if isinstance(exc.value.code, int) else 0 + return code, captured.out, captured.err + + +def test_main_emits_deny_payload( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + code, out, _ = _run_main( + monkeypatch, capsys, {"tool_input": {"command": "gh pr create"}} + ) + assert code == 0 # hook exits 0; deny is in the JSON payload + parsed = json.loads(out) + spec = parsed["hookSpecificOutput"] + assert spec["hookEventName"] == "PreToolUse" + assert spec["permissionDecision"] == "deny" + assert "--draft" in spec["permissionDecisionReason"] + + +def test_main_allows_draft( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + code, out, _ = _run_main( + monkeypatch, + capsys, + {"tool_input": {"command": "gh pr create --draft"}}, + ) + assert code == 0 + assert out == "" + + +def test_main_allows_unrelated_command( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + code, out, _ = _run_main( + monkeypatch, + capsys, + {"tool_input": {"command": "echo gh pr create --draft"}}, + ) + assert code == 0 + assert out == "" + + +def test_main_denies_multi_segment_one_non_draft( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + code, out, _ = _run_main( + monkeypatch, + capsys, + {"tool_input": {"command": "gh pr create --draft && gh pr create"}}, + ) + assert code == 0 + parsed = json.loads(out) + assert parsed["hookSpecificOutput"]["permissionDecision"] == "deny" + + +def test_main_handles_malformed_json( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + code, out, _ = _run_main(monkeypatch, capsys, "not json at all") + assert code == 0 + assert out == "" + + +def test_main_handles_non_dict_tool_input( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + code, out, _ = _run_main(monkeypatch, capsys, {"tool_input": "string"}) + assert code == 0 + assert out == "" + + +def test_main_handles_missing_command_key( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + code, out, _ = _run_main(monkeypatch, capsys, {"tool_input": {}}) + assert code == 0 + assert out == "" + + +def test_main_handles_non_string_command( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + code, out, _ = _run_main( + monkeypatch, capsys, {"tool_input": {"command": 123}} + ) + assert code == 0 + assert out == "" diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000000..a5c83a74b5 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,43 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "if": "Bash(*git *)", + "command": ".claude/hooks/pre_commit_lint.py" + }, + { + "type": "command", + "if": "Bash(*git *)", + "command": ".claude/hooks/pre_push_test.py" + }, + { + "type": "command", + "if": "Bash(*git *)", + "command": ".claude/hooks/pre_push_review.py" + }, + { + "type": "command", + "if": "Bash(*gh *)", + "command": ".claude/hooks/pre_pr_draft.py" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "if": "Bash(*gh *)", + "command": ".claude/hooks/post_pr_create_changelog.py" + } + ] + } + ] + } +} diff --git a/.claude/skills/masm-constants/SKILL.md b/.claude/skills/masm-constants/SKILL.md new file mode 100644 index 0000000000..7a130d1219 --- /dev/null +++ b/.claude/skills/masm-constants/SKILL.md @@ -0,0 +1,131 @@ +--- +name: masm-constants +description: Enforce constant definition and organization conventions for Miden Assembly (.masm) files. Use when editing, reviewing, or creating .masm files that define or use constants. +--- + +# MASM Constant Conventions + +## Placement + +Constants must be defined at the **top of the file**, before any procedure definitions, but after imports or type aliases. + +## Organization + +### Constants Section (first) + +Group non-error constants by topic. Use blank lines between sections. Common topics: + +- **Slot names** – use `word("path::to::slot")` for slot identifiers +- **Memory pointer offsets** – offsets into memory regions +- **Magic numbers / values** – domain-specific literals +- **Event identifiers** – for `emit` / `trace` + +Order sections by dependency or by usage frequency. Put the most widely used first. + +### Errors Section (after constants) + +Errors (panic/assert error codes, e.g. `ERR_*`) go in a **dedicated "errors" section**, placed after the constants section. Define them with string values describing the error. + +## Naming + +### Memory Pointers + +Memory pointer constants describe offsets into memory regions that are shared or global (e.g. layout of a memory region, input/output structure offsets). They are **not** scoped to a single procedure and do not use the procedure prefix. + +```masm +# Good: descriptive, shared usage +const ASSET_OFF = 0 +const AMOUNT_OFF = 1 +const NOTE_DATA_LEN = 16 + +# Or grouped by memory region if applicable +const INPUT_PTR_OFF = 0 +const INPUT_LEN_OFF = 1 +``` + +### Memory Locals Offsets + +Memory locals offsets are **procedure-scoped**: they describe offsets within a procedure's local memory. They must be **prefixed with the procedure name** they belong to: + +```masm +# Good: procedure-prefixed +const validate_note_NOTE_IDX_LOC = 0 +const validate_note_ASSET_LOC = 1 +const process_input_INPUT_PTR_OFF = 0 + +# Bad: generic, ambiguous +const NOTE_IDX_LOC = 0 +const ASSET_LOC = 1 +const INPUT_PTR_OFF = 0 +``` + +This keeps offsets scoped and avoids collisions when multiple procedures use local memory. + +## Formatting + +Put **spaces around the equals sign**. + +**Errors** – string value describing the error: +```masm +const ERR_BRIDGE_NOT_MAINNET = "bridge not mainnet" +const ERR_UNAUTHORIZED = "unauthorized" +``` + +**Slots** – use `word()` with the slot path: +```masm +const BRIDGE_ID_SLOT = word("miden::agglayer::faucet") +const SLOT_ACCOUNT_ID = word("account::id") +``` + +**Offsets / numeric values** – plain numbers: +```masm +const validate_note_NOTE_IDX_LOC = 0 +const validate_note_ASSET_LOC = 1 +``` + +## Example Layout + +```masm +# CONSTANTS +# ================================================================================================= + +# Slots +const BRIDGE_ID_SLOT = word("miden::agglayer::faucet") +const SLOT_ACCOUNT_ID = word("account::id") + +# Memory pointers +const ASSET_OFF = 0 +const AMOUNT_OFF = 1 + +# validate_note locals +const validate_note_NOTE_IDX_LOC = 0 +const validate_note_ASSET_LOC = 1 + +# process_input locals +const process_input_INPUT_PTR_OFF = 0 +const process_input_LEN_OFF = 1 + +# ERRORS +# ================================================================================================= + +const ERR_BRIDGE_NOT_MAINNET = "bridge not mainnet" +const ERR_UNAUTHORIZED = "unauthorized" +const ERR_NOTE_NOT_FOUND = "note not found" + +# PUBLIC INTERFACE +# ================================================================================================= + +pub proc validate_note + ... +end +``` + +## Validation Checklist + +- [ ] Errors in dedicated section, placed after constants +- [ ] All constants defined at top of file +- [ ] Non-error constants grouped by topic with blank lines between sections +- [ ] Memory pointers (shared/global) use descriptive names without procedure prefix +- [ ] Memory locals offsets prefixed with procedure name +- [ ] Spaces around `=` in all constant definitions +- [ ] Section comments used to label topic groups diff --git a/.claude/skills/masm-doc-comments/SKILL.md b/.claude/skills/masm-doc-comments/SKILL.md new file mode 100644 index 0000000000..734bee8851 --- /dev/null +++ b/.claude/skills/masm-doc-comments/SKILL.md @@ -0,0 +1,257 @@ +--- +name: masm-doc-comments +description: Enforce doc comment conventions for Miden Assembly (.masm) procedures. Use when editing, reviewing, or creating .masm procedures, especially when documenting inputs, outputs, panic conditions, or invocation types. +--- + +# MASM Procedure Doc Comments + +## Overview + +Every public MASM procedure should have a doc comment block using `#!` prefix with these sections in order: + +1. **Description** - What the procedure does +2. **Inputs/Outputs** - Stack state before/after +3. **Where** - Explanation of each stack item (omit when the Description already names every item) +4. **Panics if** - Error conditions (when applicable) +5. **Invocation** - How the procedure is called (`exec`, `call`, or `dyncall`; omitted for syscall-invoked kernel procedures) + +## Required Format + +```masm +#! Brief description of what the procedure does. +#! +#! Additional context if needed. +#! +#! Inputs: [item1, item2, WORD_ITEM] +#! Outputs: [result1, RESULT_WORD] +#! +#! Where: +#! - item1 is the description of item1. +#! - item2 is the description of item2. +#! - WORD_ITEM is a 4-element word representing X. +#! - result1 is the description of result1. +#! - RESULT_WORD is the resulting word. +#! +#! Panics if: +#! - condition that causes the procedure to fail. +#! - another error condition. +#! +#! Invocation: exec +pub proc my_procedure +``` + +## Stack Notation + +### Naming Conventions + +| Type | Style | Example | +|------|-------|---------| +| Single felt | lowercase with underscores | `note_index`, `amount`, `balance` | +| Word (4 felts) | UPPERCASE with underscores | `ASSET`, `RECIPIENT`, `SCRIPT_ROOT` | +| Multi-felt (2-3) | lowercase with `{parts}` suffix | `account_id_{suffix,prefix}` | +| All-zero Word | `EMPTY_WORD` | `[..., EMPTY_WORD, ...]` | + +`EMPTY_WORD` is a naming convention used in stack trackers, `Where:` bullets, and prose comments to denote the all-zero Word `[0, 0, 0, 0]`. + +In composite braces, list parts in **stack-top-first order, no spaces inside the braces**: `account_id_{suffix,prefix}` because the suffix sits on top of the stack and the prefix below it. The same rule applies to other split-128-bit IDs (`sender_{suffix,prefix}`, `faucet_id_{suffix,prefix}`, etc.). + +### Stack Order + +Items are listed left-to-right, with the **top of stack first**: + +```masm +#! Inputs: [top_item, second_item, THIRD_WORD] +``` + +### Empty Stack + +Use empty brackets for no inputs or outputs: + +```masm +#! Inputs: [] +#! Outputs: [result] +``` + +### Span notation `(N)` family + +`(N)` after an item name denotes a span of N felts (not a Word). Spans stay lowercase; Words stay UPPERCASE and never take `(N)`. `pad(N)` (see masm-padding skill) is one member of this family; other spans appear in protocol code: + +```masm +#! Inputs: [first_element, foreign_procedure_inputs(15)] +#! Outputs: [foreign_procedure_outputs(16)] +``` + +### Padding (for `call` and `dyncall` procedures) + +Procedures entered at the stack-depth-16 floor (`call` and `dyncall`) must show explicit padding so Inputs and Outputs each sum to 16 elements (see masm-padding skill): + +```masm +#! Inputs: [ASSET, pad(12)] +#! Outputs: [pad(16)] +#! +#! Invocation: call +``` + +## Where Section + +Define every item from Inputs and Outputs: + +```masm +#! Where: +#! - note_index is the index of the input note. +#! - sender_{suffix,prefix} are the suffix and prefix felts of the sender ID. +#! - ASSET_KEY is the vault key of the asset [0, 0, faucet_id_suffix, faucet_id_prefix]. +#! - balance is the fungible asset balance in the vault. +``` + +**Rules:** +- Use "is" for single items, "are" for multi-part items +- Start descriptions lowercase (continues the sentence) +- End each line with a period +- Group related items (e.g., all inputs, then all outputs) +- Avoid including low-level details, e.g. how a value is computed. + - Good: NOTE_DETAILS_COMMITMENT is the commitment to the note's details. + - Avoid: NOTE_DETAILS_COMMITMENT is the commitment to the note's details computed as `hash(RECIPIENT_DIGEST || ASSETS_COMMITMENT)`. + +### When `Where:` may be omitted + +Omit the `Where:` section entirely when the Description already names every Inputs/Outputs item and adding bullets would just repeat that information. Common case: trivial accessor procedures. + +```masm +#! Returns the maximum supply. +#! +#! Inputs: [pad(16)] +#! Outputs: [max_supply, pad(15)] +#! +#! Invocation: call +pub proc get_max_supply +``` + +No `Where:` is needed: the single named output `max_supply` is already identified by the Description. If you would otherwise write `#! - max_supply is the maximum supply.`, skip it. + +Add `Where:` whenever any item needs description beyond what the Description line conveys — different name, additional constraint, composition (`ASSET_KEY = [0, 0, faucet_id_suffix, faucet_id_prefix]`), or anything non-obvious. + +## Panics Section + +Bullets describe the **condition**, not the error identifier. Write `the nonce has already been incremented.`, not `ERR_ACCOUNT_NONCE_CAN_ONLY_BE_INCREMENTED_ONCE.`. The identifier is an implementation detail of the assert; the doc comment should read as English. + +### Direct Panics + +List conditions from `assert*` statements in the procedure: + +```masm +#! Panics if: +#! - flag is false. +proc sample_procedure + # => [flag] + assert.err=ERR_FLAG_IS_FALSE +``` + +### Propagated Panics + +When calling other procedures that may panic: + +**Simple case (< 4 conditions):** List the specific conditions: + +```masm +#! Description, inputs, etc. +#! Panics if: +#! - flag is false. +proc sample_procedure + # => [flag] + exec.another_procedure # this procedure may panic +end + +proc another_procedure + # => [flag] + assert.err=ERR_FLAG_IS_FALSE +end +``` + +**Complex case (4+ conditions):** Reference the subprocedure: + +```masm +#! Description, inputs, etc. +#! Panics if: +#! - another_procedure fails to verify. +proc sample_procedure + # => [flag_1, flag_2, flag_3, flag_4] + exec.another_procedure # this procedure may panic +end + +#! Description, inputs, etc. +#! Panics if: +#! - flag_1 is false. +#! - flag_2 is false. +#! - flag_3 is false. +#! - flag_4 is false. +proc another_procedure + # => [flag_1, flag_2, flag_3, flag_4] + assert.err=ERR_FLAG_1_IS_FALSE + assert.err=ERR_FLAG_2_IS_FALSE + assert.err=ERR_FLAG_3_IS_FALSE + assert.err=ERR_FLAG_4_IS_FALSE +end +``` + +### No Panics + +Omit the "Panics if:" section entirely if the procedure cannot panic. + +## Invocation Types + +Specify how the procedure should be invoked. The value matches the MASM instruction that user-code callers use to enter the procedure: + +| Value | Used by callers as | When to use | +|---|---|---| +| `exec` | `exec.` | Standard inline call. Shares the caller's stack; no padding requirement. | +| `call` | `call.` | Cross-context call (e.g. into another account). Enters at stack depth 16 — Inputs/Outputs must show `pad(N)` to total 16 (see masm-padding). | +| `dyncall` | `dyncall` from a script | Entry point of a note script or transaction script. Stack-depth-16 floor applies on entry. | + +```masm +#! Invocation: exec +``` + +```masm +#! Invocation: call +``` + +```masm +#! Invocation: dyncall +``` + +For existing procedures, pick the value that matches how callers invoke them: `call` when invoked via `call.`, `exec` for `exec.`, `dyncall` for note-script and transaction-script entry points. + +### Kernel procedures (syscall-invoked) are exempt + +Kernel procedures under `crates/miden-protocol/asm/kernels/` are invoked by the VM via `syscall.` from user code. They do not carry an `Invocation:` line — the `syscall` invocation model is implied by the procedure's location in a kernel module. Omit the `Invocation:` line entirely for these procs. + +## Prose Conventions + +Doc comments are read by people unfamiliar with the change that introduced them. Keep the prose general, accurate, and consistent with the rest of the codebase. + +### Reuse existing terminology + +Use the vocabulary already established in surrounding modules and doc comments. Do not coin new terms or borrow colloquialisms for a concept that already has a name. For example, a value written to a local is "stored" or "saved" (matching `loc_storew`), not "stashed"; describe what code does plainly rather than labelling it ("load-bearing", "the real check", and similar). + +### Document the procedure, not the change + +Describe the procedure as it currently behaves, for a reader who has never seen the PR that added or modified it. Avoid PR narrative, rationale for a recent fix, and framing such as "this is the X that prevents Y". State what the procedure does and what it guarantees. + +### Stay at this layer's abstraction + +Describe behavior in terms of this procedure and its inputs and outputs. Do not explain how a lower layer (for example the kernel, or a specific syscall) implements or enforces something — that is an implementation detail that may change. Panic bullets in particular state the condition in domain terms ("the asset does not belong to this faucet"), not the mechanism or which layer raises it. + +## Validation Checklist + +- [ ] Description starts with a capitalized present-tense verb and the first sentence ends with a period. Canonical verbs observed in protocol source: `Returns`, `Gets`, `Computes`, `Burns`, `Creates`, `Increments`, `Copies`, `Asserts`, `Verifies`, `Hashes`, `Adds`, `Removes`. +- [ ] Inputs and Outputs use correct stack notation +- [ ] Where section defines every stack item that needs description beyond the Description line, and is omitted entirely when the Description alone covers every item +- [ ] Words are UPPERCASE, felts are lowercase +- [ ] Panics section lists direct asserts and propagated errors +- [ ] Complex panic propagation uses "if fails to verify" shorthand +- [ ] Invocation type specified: `exec`, `call`, or `dyncall`. Kernel (syscall-invoked) procedures are exempt — no `Invocation:` line. +- [ ] For `call` and `dyncall`: padding shown in Inputs/Outputs (see masm-padding skill) +- [ ] Prose reuses existing terminology; no coined terms or colloquialisms +- [ ] Describes the procedure's current behavior, not the change that introduced it +- [ ] No lower-layer (kernel/syscall) implementation details; panic bullets describe conditions in domain terms diff --git a/.claude/skills/masm-file-structure/SKILL.md b/.claude/skills/masm-file-structure/SKILL.md new file mode 100644 index 0000000000..404cd88626 --- /dev/null +++ b/.claude/skills/masm-file-structure/SKILL.md @@ -0,0 +1,108 @@ +--- +name: masm-file-structure +description: Enforce file structure and section ordering for Miden Assembly (.masm) files. Use when editing, reviewing, or creating .masm files. +--- + +# MASM File Structure + +MASM files must follow a fixed section order. Use section headers with the long separator line: + +```masm +# SECTION NAME +# ================================================================================================= +``` + +## Section Order + +1. **Imports** – `use` statements only; no section header +2. **Type aliases** – `type` definitions +3. **Constants** – see the masm-constants skill for organization (non-error constants first, then errors) +4. **Public interface** – `pub proc` procedures that form the module API +5. **Helper procedures** – `proc` (non-pub) procedures used internally + +## Example Structure + +```masm +use miden::agglayer::bridge::bridge_config +use miden::agglayer::bridge::leaf_utils +use miden::core::mem +use miden::core::word + +# TYPE ALIASES +# ================================================================================================= + +type BeWord = struct @bigendian { a: felt, b: felt, c: felt, d: felt } +type DoubleWord = struct { word_lo: BeWord, word_hi: BeWord } +type MemoryAddress = u32 + +# CONSTANTS +# ================================================================================================= + +const PROOF_DATA_PTR = 0 +const PROOF_DATA_WORD_LEN = 134 + +# ERRORS +# ================================================================================================= + +const ERR_BRIDGE_NOT_MAINNET = "bridge not mainnet" +const ERR_LEADING_BITS_NON_ZERO = "leading bits of global index must be zero" + +# PUBLIC INTERFACE +# ================================================================================================= + +#! Main entry point. Computes the leaf value and verifies it. +#! +#! Inputs: [LEAF_DATA_KEY, PROOF_DATA_KEY, pad(8)] +#! Outputs: [pad(16)] +#! +#! Invocation: call +pub proc verify_leaf_bridge + exec.get_leaf_value + exec.verify_leaf +end + +# HELPER PROCEDURES +# ================================================================================================= + +#! Loads leaf data and computes the leaf value. +#! +#! Inputs: [LEAF_DATA_KEY] +#! Outputs: [LEAF_VALUE[8]] +#! +#! Invocation: exec +proc get_leaf_value(leaf_data_key: BeWord) -> DoubleWord + ... +end + +#! Verifies leaf against Merkle proof. +#! +#! Inputs: [LEAF_VALUE[8], PROOF_DATA_KEY] +#! Outputs: [] +#! +#! Invocation: exec +proc verify_leaf + ... +end +``` + +## Guidelines + +- **Imports**: One `use` per line; group by module. No blank lines between imports. +- **Type aliases**: Define shared types (e.g. `DoubleWord`, `MemoryAddress`) before constants or procedures. +- **Constants**: Follow the masm-constants skill. +- **Public interface**: Only `pub proc`; these are the module’s API. Order by importance or call flow. +- **Helper procedures**: Non-pub procedures that support the public interface. May include `pub proc` helpers (e.g. `get_leaf_value`) if they are used internally or re-exported, or used for unit tests. + +## When Sections Are Omitted + +- No imports → start with type aliases or constants +- No type aliases → constants follow imports +- No helpers → public interface is the last section + +## Validation Checklist + +- [ ] Imports at top (if any) +- [ ] Type aliases before constants and procedures +- [ ] Constants before procedures; errors subsection after non-error constants +- [ ] Public interface (`pub proc`) before helper procedures +- [ ] Section headers use `# SECTION NAME` and `# ===...` separator diff --git a/.claude/skills/masm-inline-comments/SKILL.md b/.claude/skills/masm-inline-comments/SKILL.md new file mode 100644 index 0000000000..903662f67f --- /dev/null +++ b/.claude/skills/masm-inline-comments/SKILL.md @@ -0,0 +1,117 @@ +--- +name: masm-inline-comments +description: Enforce inline commenting conventions for Miden Assembly (.masm) files. Use when editing, reviewing, or creating .masm files. +--- + +# MASM Inline Commenting Conventions + +## Rules + +### 1. Inline comments start lowercase + +Inline comments (single `#`) should begin with a lowercase letter. + +```masm +# good: lowercase start +exec.native_account::remove_asset +# => [ASSET, note_idx, pad(11)] + +# Bad: uppercase start (avoid) +# Remove the asset from the account +``` + +### 2. Don't over-comment obvious operations + +Only add comments that provide value. Skip comments for self-explanatory operations. +Only apply this rule to new code you write. Do not remove comments that are present in the code. + +**Skip comments for:** +- Simple arithmetic: `add`, `sub`, `mul`, `div` +- Basic stack ops when context is clear: `drop`, `swap`, `dup` +- Standard control flow: `if.true`, `while.true`, `end` + +**Do comment:** +- Stack state after complex operations: `# => [ptr, ASSET, end_ptr]` +- Purpose of a code block: `# compute the pointer at which we should stop iterating` +- Non-obvious logic or business rules +- TODO items and references to external specs + +### 3. Blank line after `# => [...]` trackers + +Insert a blank line after a `# => [...]` stack-state tracker, except when the next non-blank line is one of: + +- `end` (proc / `while.true` / `if.true` / `repeat.N` closing). +- A control-flow keyword such as `else`, `else.true`, or `else.false`. +- Another `# =>` line that continues the same multi-line stack state. +- A `#` continuation comment that explains the tracker. + +This pairs each stack state visually with the operation that produced it and lets the eye skim from one labeled state to the next. + +**Good:** + +```masm +exec.native_account::remove_asset +# => [ASSET, note_idx, pad(11)] + +dupw dup.8 movdn.4 +# => [ASSET, note_idx, ASSET, note_idx, pad(11)] +``` + +**Also OK (no blank line before `end` or control flow):** + +```masm +# => [pad(16)] +end +``` + +### 4. Match the doc block + +An inline `# => [...]` tracker uses the same item names, capitalization, and `(N)` span notation as the `#!` doc block for the enclosing procedure (see masm-doc-comments skill): + +- Single-felt names stay lowercase: `note_idx`, `final_nonce`. +- Word names stay UPPERCASE: `ASSET`, `RECIPIENT`. +- `(N)` spans stay lowercase: `pad(12)`, `foreign_procedure_inputs(15)`. + +Composite names like `account_id_{suffix,prefix}` are a doc-block shorthand for a group of felts. In inline trackers they decompose into their individual felts since each felt occupies one stack slot: + +```masm +#! Inputs: [account_id_{suffix,prefix}, amount] +pub proc transfer + # => [account_id_suffix, account_id_prefix, amount] + ... +end +``` + +### 5. Reuse existing terminology + +Use the vocabulary already established in the surrounding code and doc comments. Do not coin new terms or colloquialisms for a concept that already has a name — a value written to a local is "stored", not "stashed". This applies to inline comments and to constant-header comments. + +### 6. Comment the code, not the change + +Inline comments explain what the code does for a future reader, not why a particular PR made a change. Avoid PR narrative and framing such as "this is the X that prevents Y"; describe the operation and its purpose as the code stands. + +## Examples + +**Good:** + +```masm +# remove the asset from the account +exec.native_account::remove_asset +# => [ASSET, note_idx, pad(11)] + +dupw dup.8 movdn.4 +# => [ASSET, note_idx, ASSET, note_idx, pad(11)] + +exec.output_note::add_asset +# => [ASSET, note_idx, pad(11)] +``` + +**Avoid:** + +```masm +# Swap the top two elements +swap # swap + +# Drop the word +dropw # drops 4 elements +``` diff --git a/.claude/skills/masm-padding/SKILL.md b/.claude/skills/masm-padding/SKILL.md new file mode 100644 index 0000000000..149be39e9b --- /dev/null +++ b/.claude/skills/masm-padding/SKILL.md @@ -0,0 +1,160 @@ +--- +name: masm-padding +description: Enforce stack padding conventions for Miden Assembly (.masm) procedures based on invocation type (call vs exec). Use when editing, reviewing, or creating .masm procedures, especially those with Invocation annotations. +--- + +# MASM Padding Conventions + +## Overview + +Padding requirements differ based on procedure invocation type: + +| Invocation | Padding Required | Input/Output Elements | +|------------|------------------|----------------------| +| `call` | Explicit padding in comments | Exactly 16 | +| `exec` | No explicit padding | No requirement | + +## Stack Depth Floor: 16 + +Miden VM enforces a minimum operand-stack depth of 16 elements (`MIN_STACK_DEPTH = 16` in the VM core). When an operation would naively shrink the stack below 16, the VM auto-fills the missing positions with zeros via the overflow-table mechanism. The actual depth stays exactly 16; only the visible content shrinks. + +This invariant applies at the entry boundary of: + +- `call` procedures, +- note scripts and transaction scripts (entered via `dyncall` at depth 16). + +It does NOT apply to mid-chain `exec` procedures, which share the caller's stack and can drop the visible count below 16 by consuming caller elements (see Danger Zone). + +### Tracking the floor in inline comments + +When the naive math would put the stack below 16, the `# =>` tracker must reflect the actual auto-padded depth, not the naive count. + +```masm +# entry at depth 16: [VALUE, pad(12)] +dropw +# => [pad(16)] # correct: VALUE replaced with zeros, depth still 16 +``` + +Not: + +```masm +# => [pad(12)] # wrong: depth is still 16, only the visible content shrank +``` + +This shows up most often at the start of note scripts that don't use their input arguments: + +```masm +begin + dropw + # => [pad(16)] + ... +end +``` + +## Call Procedures + +Procedures invoked with `call` must have explicit padding in: +1. **Doc comments** (`#!`) for Inputs/Outputs +2. **Inline comments** (`#`) showing stack state + +### Doc Comment Format + +Use `pad(N)` notation where N + other elements = 16: + +```masm +#! Inputs: [ASSET, pad(12)] +#! Outputs: [pad(16)] +#! +#! Invocation: call +pub proc receive_asset +``` + +### Inline Comment Format + +Track padding through the procedure: + +```masm +exec.native_account::set_item +# => [OLD_VALUE, pad(12)] + +dropw +# => [pad(16)] auto-padded to 16 elements +``` + +## Exec Procedures + +Procedures invoked with `exec` should NOT have explicit padding: + +```masm +#! Inputs: [PUB_KEY] +#! Outputs: [] +#! +#! Invocation: exec +pub proc authenticate_transaction +``` + +### Why No Padding for Exec + +`exec` procedures share the caller's stack directly. Explicit padding would be misleading because: +- The actual stack may have additional elements from the caller +- The procedure may consume caller's stack elements + +### Danger Zone + +If an `exec` procedure's stack falls below the specified stack elements, it will consume stack items from its caller, potentially leading to unexpected behavior. This is a bug and should be fixed by ensuring the procedure maintains sufficient stack depth and avoiding dropping more stack elements than available. + +### Example of Dangerous Behavior + +```masm +# => [num_approvers, threshold] +dropw # dropw drops 4 elements, which will result in "negative" stack consumption (consuming 2 elements from the caller's stack) +``` + + +## Intermediate States + +Inside a procedure, the stack may temporarily exceed 16 elements: + +```masm +# => [num_approvers, threshold, MULTISIG_CONFIG, pad(12)] +# ^--- 18 elements total, must be reduced before return +``` + +These extra elements must be explicitly dropped before the procedure returns (directly or via called procedures). + +## Debugging Stack Depth + +When unsure whether the stack matches the depth you expect, use the assembly's debug instructions to inspect it at runtime. These cost zero VM cycles, do not affect the program hash, and are stripped at compile time when the assembler is not in debug mode. + +- `debug.stack` – print the full operand stack. +- `debug.stack.N` – print only the top N elements (1 ≤ N < 256). +- `sdepth` – push the current stack depth onto the stack as a felt; useful when you need depth as a runtime value, e.g. to assert it: + + ```masm + sdepth push.16 eq assert.err="depth must be 16 here" + ``` + +Run with the `--debug` flag to see output: + +```bash +miden-vm run program.masm --debug +``` + +Without `--debug`, debug instructions are silently removed. Remove or comment out `debug.*` lines before committing production MASM. + +## Validation Checklist + +For all invocation types: +- [ ] Inline `# =>` trackers reflect the post-auto-pad depth (never below 16) at boundaries that enforce the floor (`call`, note scripts, tx scripts) +- [ ] No `debug.*` instruction is left in production MASM + +For `call` procedures: +- [ ] Inputs doc comment shows exactly 16 elements with `pad(N)` +- [ ] Outputs doc comment shows exactly 16 elements with `pad(N)` +- [ ] Inline comments use `# =>` format with `pad(N)` notation +- [ ] All intermediate states track the full stack including padding + +For `exec` procedures: +- [ ] No `pad(N)` in Inputs/Outputs doc comments +- [ ] No explicit padding in inline stack state comments +- [ ] Verify stack never drops below safe depth diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000000..ec173e0ea4 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,3 @@ +self-hosted-runner: + labels: + - warp-ubuntu-latest-x64-8x diff --git a/.github/actions/workspace-release/action.yml b/.github/actions/workspace-release/action.yml index 44797e2825..959c7ce8ab 100644 --- a/.github/actions/workspace-release/action.yml +++ b/.github/actions/workspace-release/action.yml @@ -22,24 +22,41 @@ runs: - name: Verify tag matches release branch HEAD if: ${{ inputs.verify-branch-head == 'true' }} shell: bash + env: + RELEASE_BRANCH: ${{ inputs.release-branch }} run: | - git fetch origin ${{ inputs.release-branch }} --depth=1 - branch_sha="$(git rev-parse origin/${{ inputs.release-branch }})" + case "$RELEASE_BRANCH" in + "" | -*) + echo "::error::Invalid release branch name: $RELEASE_BRANCH" + exit 1 + ;; + esac + + if ! git check-ref-format --branch "$RELEASE_BRANCH" >/dev/null 2>&1; then + echo "::error::Invalid release branch name: $RELEASE_BRANCH" + exit 1 + fi + + git fetch origin "refs/heads/$RELEASE_BRANCH:refs/remotes/origin/$RELEASE_BRANCH" --depth=1 + branch_sha="$(git rev-parse "refs/remotes/origin/$RELEASE_BRANCH")" tag_sha="$(git rev-parse HEAD)" echo "branch_sha=$branch_sha" echo "tag_sha=$tag_sha" if [ "$branch_sha" != "$tag_sha" ]; then - echo "::error::The release/tag commit does not match origin/${{ inputs.release-branch }} HEAD. Aborting." + echo "::error::The release/tag commit does not match origin/$RELEASE_BRANCH HEAD. Aborting." exit 1 fi - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + shell: bash + run: | + rustup +stable update --no-self-update + rustup default stable - name: Cache cargo registry and git index - uses: actions/cache@v4 + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: | ~/.cargo/registry @@ -53,8 +70,10 @@ runs: # Install cargo-msrv for MSRV checks # Using binstall with --force to avoid stale cached binaries (see PR #2234) + # Keep this on a released install-action commit with an explicit tool; shortcut tags can + # trip zizmor's impostor-commit audit when they are later retagged. - name: Install cargo-binstall - uses: taiki-e/install-action@v2 + uses: taiki-e/install-action@055f5df8c3f65ea01cd41e9dc855becd88953486 # v2.75.18 with: tool: cargo-binstall diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index 8400212bc1..8c82d89609 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -28,10 +28,12 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "20" cache: "npm" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 884a7be16a..215d859d2b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,10 +22,12 @@ jobs: name: build for no-std runs-on: ubuntu-latest steps: - - uses: actions/checkout@main + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false - name: Cleanup large tools for build space uses: ./.github/actions/cleanup-runner - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: # Only update the cache on push onto the next branch. This strikes a nice balance between # cache hits and cache evictions (github has a 10GB cache limit). @@ -39,18 +41,24 @@ jobs: check-features: name: check all feature combinations - runs-on: ubuntu-latest + runs-on: warp-ubuntu-latest-x64-8x steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false - name: Cleanup large tools for build space uses: ./.github/actions/cleanup-runner - - uses: Swatinem/rust-cache@v2 + - uses: WarpBuilds/rust-cache@9d0cc3090d9c87de74ea67617b246e978735b1a1 # v2.9.1 with: save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/next' }} - name: Install rust run: rustup update --no-self-update + # Keep this on a released install-action commit with an explicit tool; shortcut tags can + # trip zizmor's impostor-commit audit when they are later retagged. - name: Install cargo-hack - uses: taiki-e/install-action@cargo-hack + uses: taiki-e/install-action@055f5df8c3f65ea01cd41e9dc855becd88953486 # v2.75.18 + with: + tool: cargo-hack - name: Check all feature combinations run: make check-features @@ -58,10 +66,12 @@ jobs: name: Check benchmarks compilation runs-on: ubuntu-latest steps: - - uses: actions/checkout@main + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false - name: Cleanup large tools for build space uses: ./.github/actions/cleanup-runner - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: # Only update the cache on push onto the next branch. This strikes a nice balance between # cache hits and cache evictions (github has a 10GB cache limit). diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index aad0fe708b..e46382c0c2 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -15,9 +15,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@main + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 + persist-credentials: false - name: Check for changes in changelog env: BASE_REF: ${{ github.event.pull_request.base.ref }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index dee254de61..640df98fcd 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -22,31 +22,85 @@ jobs: name: cargo-deny on ubuntu-latest runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: taiki-e/install-action@v2 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - uses: taiki-e/install-action@055f5df8c3f65ea01cd41e9dc855becd88953486 # v2.75.18 with: tool: cargo-deny - name: Check dependencies with cargo-deny run: cargo deny check + cargo-shear: + name: check for unused dependencies + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - name: Install cargo-shear + uses: taiki-e/install-action@055f5df8c3f65ea01cd41e9dc855becd88953486 # v2.75.18 + with: + tool: cargo-shear@1.11.2 + - name: Check for unused dependencies + run: make shear + + workspace-inheritance: + name: workspace inheritance on ubuntu-latest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + # Do not pin taiki-e's moving tool shortcut tags by hash. Pin a released + # install-action commit and set tool explicitly, or zizmor's + # impostor-commit audit can flag stale shortcut-tag hashes. + - name: Install cargo-binstall + uses: taiki-e/install-action@055f5df8c3f65ea01cd41e9dc855becd88953486 # v2.75.18 + with: + tool: cargo-binstall + - name: Install workspace inheritance checker + run: cargo binstall --no-confirm cargo-workspace-inheritance-check@1.2.0 + - name: Check workspace inheritance + run: cargo workspace-inheritance-check --promotion-threshold 2 --promotion-failure + typos: name: spellcheck runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v4 - - uses: taiki-e/install-action@v2 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - uses: taiki-e/install-action@055f5df8c3f65ea01cd41e9dc855becd88953486 # v2.75.18 with: tool: typos - run: make typos-check + hooks-test: + name: claude hooks pytest + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.10' + - name: Install pytest + run: python -m pip install --upgrade pip pytest + - run: make hooks-test + toml: name: toml formatting runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v4 - - uses: taiki-e/install-action@v2 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - uses: taiki-e/install-action@055f5df8c3f65ea01cd41e9dc855becd88953486 # v2.75.18 with: tool: taplo-cli - run: make toml-check @@ -55,10 +109,12 @@ jobs: name: clippy runs-on: ubuntu-latest steps: - - uses: actions/checkout@main + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false - name: Cleanup large tools for build space uses: ./.github/actions/cleanup-runner - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: # Only update the cache on push onto the next branch. This strikes a nice balance between # cache hits and cache evictions (github has a 10GB cache limit). @@ -73,10 +129,12 @@ jobs: name: clippy no_std runs-on: ubuntu-latest steps: - - uses: actions/checkout@main + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false - name: Cleanup large tools for build space uses: ./.github/actions/cleanup-runner - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: # Only update the cache on push onto the next branch. This strikes a nice balance between # cache hits and cache evictions (github has a 10GB cache limit). @@ -92,10 +150,12 @@ jobs: name: rustfmt runs-on: ubuntu-latest steps: - - uses: actions/checkout@main + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false - name: Cleanup large tools for build space uses: ./.github/actions/cleanup-runner - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: # Only update the cache on push onto the next branch. This strikes a nice balance between # cache hits and cache evictions (github has a 10GB cache limit). @@ -110,10 +170,12 @@ jobs: name: doc runs-on: ubuntu-latest steps: - - uses: actions/checkout@main + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false - name: Cleanup large tools for build space uses: ./.github/actions/cleanup-runner - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: # Only update the cache on push onto the next branch. This strikes a nice balance between # cache hits and cache evictions (github has a 10GB cache limit). @@ -127,24 +189,17 @@ jobs: name: generated files check runs-on: ubuntu-latest steps: - - uses: actions/checkout@main + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false - name: Cleanup large tools for build space uses: ./.github/actions/cleanup-runner - name: Rustup run: rustup update --no-self-update - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/next' }} - name: Rebuild generated files in src run: BUILD_GENERATED_FILES_IN_SRC=1 make check - name: Diff check run: git diff --exit-code - - unused_deps: - name: check for unused dependencies - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Machete - uses: bnjbvr/cargo-machete@main diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index da076ca883..1add757772 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,17 +20,24 @@ permissions: jobs: test: name: test - runs-on: ubuntu-latest + runs-on: warp-ubuntu-latest-x64-8x steps: - - uses: actions/checkout@main + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false - name: Cleanup large tools for build space uses: ./.github/actions/cleanup-runner - - uses: taiki-e/install-action@nextest - - uses: Swatinem/rust-cache@v2 + # Keep this on a released install-action commit with an explicit tool; shortcut tags can + # trip zizmor's impostor-commit audit when they are later retagged. + - name: Install nextest + uses: taiki-e/install-action@055f5df8c3f65ea01cd41e9dc855becd88953486 # v2.75.18 + with: + tool: cargo-nextest + - uses: WarpBuilds/rust-cache@9d0cc3090d9c87de74ea67617b246e978735b1a1 # v2.9.1 with: save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/next' }} - name: Install rust - run: rustup update --no-self-update + run: rustup update --no-self-update - name: Build tests run: make test-release-build - name: test @@ -38,12 +45,14 @@ jobs: doc-tests: name: doc-tests - runs-on: ubuntu-latest + runs-on: warp-ubuntu-latest-x64-8x steps: - - uses: actions/checkout@main + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false - name: Cleanup large tools for build space uses: ./.github/actions/cleanup-runner - - uses: Swatinem/rust-cache@v2 + - uses: WarpBuilds/rust-cache@9d0cc3090d9c87de74ea67617b246e978735b1a1 # v2.9.1 with: save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/next' }} - name: Install rust diff --git a/.github/workflows/workspace-dry-run.yml b/.github/workflows/workspace-dry-run.yml index 8d23123df3..161cbf7d8b 100644 --- a/.github/workflows/workspace-dry-run.yml +++ b/.github/workflows/workspace-dry-run.yml @@ -22,9 +22,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 + persist-credentials: false - name: Dry-run workspace release uses: ./.github/actions/workspace-release diff --git a/.github/workflows/workspace-publish.yml b/.github/workflows/workspace-publish.yml index f9f6d9e8ee..7903e80927 100644 --- a/.github/workflows/workspace-publish.yml +++ b/.github/workflows/workspace-publish.yml @@ -16,13 +16,14 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 + persist-credentials: false ref: ${{ github.event.release.target_commitish }} - name: Authenticate with crates.io - uses: rust-lang/crates-io-auth-action@v1 + uses: rust-lang/crates-io-auth-action@63a7064947ceca9989005e118db3a5fecdc9259f # v1.0.0 id: auth - name: Publish workspace crates diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 0000000000..9d6ecbc162 --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,39 @@ +name: GitHub Actions Security Analysis with zizmor + +on: + push: + branches: [main, next] + paths: + - ".github/workflows/**" + - ".github/actions/**" + - "zizmor.yml" + pull_request: + types: [opened, reopened, synchronize] + paths: + - ".github/workflows/**" + - ".github/actions/**" + - "zizmor.yml" + merge_group: + workflow_dispatch: + +permissions: {} + +jobs: + zizmor: + name: Run zizmor + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - name: Run zizmor + uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2 + with: + advanced-security: false + config: zizmor.yml + version: 1.23.1 diff --git a/.gitignore b/.gitignore index f35eaefcd9..193527e2d6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,15 @@ # Ignore compiled library files *.masl -# Ignore Claude Code config -.claude/ +# Ignore Claude Code local config, worktrees, and runtime state +.claude/settings.local.json +.claude/worktrees/ +.claude/scheduled_tasks.lock + +# Python bytecode caches (e.g. from .claude/hooks/ pytest runs) +__pycache__/ +*.pyc +*.pyo # Docs platform ignores .vscode diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bad1e88c2..3aca0e4aca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,93 @@ # Changelog +## v0.15.0 (2026-05-22) + +### Features + +- Added a `FungibleTokenMetadata` component supporting name, description, logo URI, and external links, along with MASM procedures for retrieving token metadata (get_token_metadata, get_max_supply, get_decimals, get_token_symbol). Also aligned fungible faucet token metadata with the standard by using the canonical storage slot, enabling compatibility with MASM metadata getters ([#2439](https://github.com/0xMiden/miden-base/pull/2439)). +- Added `PSWAP` (partial swap) note for decentralized partial-fill asset exchange with remainder note re-creation ([#2636](https://github.com/0xMiden/protocol/pull/2636)). +- [BREAKING] Renamed `NoteId` to `NoteDetailsCommitment`, new `NoteId` struct now includes the NoteMetadata ([#1731](https://github.com/0xMiden/protocol/issues/1731)). +- Added lock/unlock path for Miden-native tokens in the AggLayer bridge: `is_native` flag in `faucet_registry_map`, bridge-local `faucet_metadata_map` (replacing FPI to faucets for conversion data), and `lock_asset` / `unlock_and_send` procedures so the bridge holds native assets in its own vault instead of burn/mint via a faucet ([#2771](https://github.com/0xMiden/protocol/pull/2771)). +- [BREAKING] Added support for multiple attachments per note ([#2795](https://github.com/0xMiden/protocol/pull/2795), [#2849](https://github.com/0xMiden/protocol/pull/2849)): +- [BREAKING] Removed `AccountStorageMode::Network`; network accounts are now identified via `NetworkAccountNoteAllowlist` ([#2900](https://github.com/0xMiden/protocol/pull/2900)). +- Added `PswapAttachment` scheme and `PswapNote::payback_note` / `remainder_note` discovery helpers so creators can reconstruct private paybacks from on-chain commitments ([#2909](https://github.com/0xMiden/protocol/pull/2909)). +- Added benchmark for ECDSA signed transaction ([#2967](https://github.com/0xMiden/protocol/pull/2967)). + +### Changes + +- Documented the `miden::protocol::account_id` module in the protocol library docs ([#2607](https://github.com/0xMiden/protocol/issues/2607)). +- [BREAKING] Renamed `procedure_digest!` to `procedure_root!` and return `AccountProcedureRoot` instead of `Word` ([#2621](https://github.com/0xMiden/protocol/issues/2621)). +- [BREAKING] Introduced `AssetComposition` and encoded composition in the asset vault key's metadata byte ([#2631](https://github.com/0xMiden/protocol/issues/2631)). +- Added `BlockNumber::saturating_sub()` ([#2660](https://github.com/0xMiden/protocol/issues/2660)). +- [BREAKING] Renamed `ProvenBatch::new` to `new_unchecked` ([#2687](https://github.com/0xMiden/miden-base/issues/2687)). +- Added `ShortCapitalString` type and related `TokenSymbol` and `RoleSymbol` types ([#2690](https://github.com/0xMiden/protocol/pull/2690)). +- [BREAKING] Renamed the guarded multisig component-facing APIs from `multisig_guardian` / `AuthMultisigGuardian` to `guarded_multisig` / `AuthGuardedMultisig`, while retaining the `guardian` auth namespace and guardian-specific procedures. +- Added shared `ProcedurePolicy` for AuthMultisig ([#2670](https://github.com/0xMiden/protocol/pull/2670)). +- [BREAKING] Changed `NoteType` encoding from 2 bits to 1 and makes `NoteType::Private` the default ([#2691](https://github.com/0xMiden/miden-base/issues/2691)). +- [BREAKING] Renamed `native_asset_id` to `fee_faucet_id` ([#2718](https://github.com/0xMiden/protocol/pull/2718)). +- Added `AssetAmount` wrapper type for validated fungible asset amounts ([#2721](https://github.com/0xMiden/protocol/pull/2721)). +- Added validation of leaf type on CLAIM note processing to prevent message leaves from being processed as asset claims ([#2730](https://github.com/0xMiden/protocol/pull/2730)). +- [BREAKING] Removed redundant outputs from kernel procedures: `note::write_assets_to_memory`, `active_note::get_assets`, `input_note::get_assets`, `output_note::get_assets`, `active_note::get_storage`, and `faucet::mint` no longer return values identical to their inputs ([#2733](https://github.com/0xMiden/protocol/pull/2733)). +- Added `metadata_into_note_type` procedure to `note.masm` for extracting note type from metadata header ([#2738](https://github.com/0xMiden/protocol/pull/2738)). +- [BREAKING] Reduced `MAX_ASSETS_PER_NOTE` from 255 to 64 and `NOTE_MEM_SIZE` from 3072 to 1024 ([#2741](https://github.com/0xMiden/protocol/issues/2741)). +- [BREAKING] Stored `origin_network` in LE-packed format in AggLayer faucet storage ([#2745](https://github.com/0xMiden/protocol/pull/2745)). +- Optimized `B2AGG` processing with selective load/save of Local Exit Tree frontier entries, halving frontier storage map syscalls ([#2752](https://github.com/0xMiden/protocol/pull/2752)). +- [BREAKING] Renamed `extract_sender_from_metadata` to `metadata_into_sender` and `extract_attachment_info_from_metadata` to `metadata_into_attachment_info` in `note.masm` ([#2758](https://github.com/0xMiden/protocol/pull/2758)). +- Updated `SwapNote::build_tag` to use 1-bit `NoteType` encoding, increasing script root bits from 14 to 15 ([#2758](https://github.com/0xMiden/protocol/pull/2758)). +- Use number of storage slots from native account in account delta commitment computation ([#2770](https://github.com/0xMiden/protocol/pull/2770)). +- [BREAKING] Added cycle counts to notes returned by `NoteConsumptionInfo` and removed public fields from related types ([#2772](https://github.com/0xMiden/miden-base/issues/2772)). +- Added `TransactionScript::from_package()` method to create `TransactionScript` from `miden-mast-package::Package` ([#2779](https://github.com/0xMiden/protocol/pull/2779)). +- [BREAKING] Removed unused `payback_attachment` from `SwapNoteStorage` and `attachment` from `MintNoteStorage` ([#2789](https://github.com/0xMiden/protocol/pull/2789)). +- Automatically enable `concurrent` feature in `miden-tx` for `std` context ([#2791](https://github.com/0xMiden/protocol/pull/2791)). +- Added `Pausable` standard component with `pause`, `unpause`, `is_paused` procedures and `on_before_asset_added_to_account`, `on_before_asset_added_to_note` callbacks ([#2793](https://github.com/0xMiden/protocol/pull/2793)). +- Added trace row counts to `bench-tx.json` ([#2794](https://github.com/0xMiden/protocol/pull/2794)). +- [BREAKING] Renamed `set_attachment` to `add_attachment`, `set_word_attachment` to `add_word_attachment`, and `set_array_attachment` to `add_array_attachment` in `miden::protocol::output_note` ([#2795](https://github.com/0xMiden/protocol/pull/2795), [#2849](https://github.com/0xMiden/protocol/pull/2849)). +- Added foundations for `AuthMultisigSmart` ([#2806](https://github.com/0xMiden/protocol/pull/2806)). +- Added `tx::get_tx_script_root` kernel procedure returning the root of the executed transaction script (empty word if no script was executed) ([#2816](https://github.com/0xMiden/protocol/pull/2816)). +- Added `AuthNetworkAccount` auth component that rejects transactions which execute a tx script or consume input notes outside of a fixed allowlist of note script roots ([#2817](https://github.com/0xMiden/protocol/pull/2817)). +- Added basic blocklist transfer policy with owner-managed admin (`block_account`/`unblock_account`) and runtime policy switching via the protocol-reserved asset callback slots ([#2820])(https://github.com/0xMiden/protocol/pull/2820). +- [BREAKING] Renamed `OwnerControlledBlocklist` to `BlocklistOwnerControlled`. +- Added basic allowlist transfer policy (default-deny dual of the blocklist) with owner-managed admin (`allow_account`/`disallow_account`) and runtime policy switching via the protocol-reserved asset callback slots. +- Derive `Hash` implementation for `StorageMapKey` and `StorageMapKeyHash` to allow using those values as keys in containers ([#2843](https://github.com/0xMiden/protocol/issues/2843)). +- [BREAKING] Replaced `metadata_into_attachment_info` with `metadata_into_attachment_schemes` in `miden::protocol::note` ([#2795](https://github.com/0xMiden/protocol/pull/2795), [#2849](https://github.com/0xMiden/protocol/pull/2849)). +- [BREAKING] All `get_metadata` procedures (`active_note`, `input_note`, `output_note`) no longer return attachments ([#2795](https://github.com/0xMiden/protocol/pull/2795), [#2849](https://github.com/0xMiden/protocol/pull/2849)). +- [BREAKING] Added `NoteScriptRoot` newtype wrapping note script roots ([#2851](https://github.com/0xMiden/protocol/pull/2851)). +- Re-exported `MIN_STACK_DEPTH` from `miden-processor` ([#2856](https://github.com/0xMiden/protocol/pull/2856)). +- [BREAKING] Renamed `NoteId` to `NoteDetailsCommitment`, new `NoteId` struct now includes the NoteMetadata ([#2861](https://github.com/0xMiden/protocol/pull/2861)). +- Added `metadata_into_tag` helper for extracting the tag from metadata. This should be used instead of extracting the tag manually from the header ([#2871](https://github.com/0xMiden/protocol/pull/2871)). +- [BREAKING] Renamed `note::build_recipient_hash` to `note::compute_recipient` and `note::build_recipient` to `note::compute_and_store_recipient` ([#2875](https://github.com/0xMiden/protocol/issues/2875)). +- 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] 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)). +- [BREAKING] Replaced the `FungibleFaucetBuilder` with a `bon` builder on `FungibleFaucet` ([#2916](https://github.com/0xMiden/protocol/pull/2916)). +- [BREAKING] Removed `StandardNote::is_compatible_with` and `AccountInterfaceExt::is_compatible_with` ([#2920](https://github.com/0xMiden/protocol/issues/2920)). +- [BREAKING] Introduced `AccountComponentName` string wrapper ([#2621](https://github.com/0xMiden/protocol/pull/2621)). +- Added `Authority` account component ([#2925](https://github.com/0xMiden/protocol/pull/2925)). +- [BREAKING] `FungibleAsset::amount()` and `AssetVault::get_balance()` now return `AssetAmount` ([#2928](https://github.com/0xMiden/protocol/pull/2928)). +- [BREAKING] Upgraded `miden-vm` to v0.23 and `miden-crypto` to v0.25. Notable downstream changes: dropped the immediate form of `adv_push` in kernel and standards MASM, marked cross-module-referenced MASM constants and procedures `pub`, migrated to the split `Host`/`BaseHost` trait surface, renamed `Felt::new` call sites to the preserved-behavior `Felt::new_unchecked`, switched `ecdsa_k256_keccak`/`eddsa_25519_sha512` `SecretKey` references to the new `SigningKey`/`KeyExchangeKey` types, and recomputed the kernel's `EMPTY_SMT_ROOT` constant for the Plonky3-aligned Poseidon2 and domain-separated `SmtLeaf::hash` ([#2931](https://github.com/0xMiden/protocol/pull/2931)). +- [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)). + +### Fixes + +- Fixed auth components to use initial storage state for authentication ([#2677](https://github.com/0xMiden/protocol/issues/2677)). +- Made deserialization of `AccountCode` more robust ([#2788](https://github.com/0xMiden/protocol/pull/2788)). +- [BREAKING] Replaced `NoAuth` with the new `AuthNetworkAccount` auth component on the AggLayer bridge and AggLayer faucet, closing the forged-MINT attack surface where any transaction against the bridge could emit a bridge-authored MINT note ([#2797](https://github.com/0xMiden/protocol/issues/2797), [#2818](https://github.com/0xMiden/protocol/pull/2818)). +- Renamed the AggLayer faucet registry flag constant for clarity ([#2812](https://github.com/0xMiden/protocol/issues/2812)). +- Fixed `output_note::add_asset` and `output_note::set_attachment` to no longer accept invalid note indices ([#2824](https://github.com/0xMiden/protocol/pull/2824)). +- [BREAKING] Keyed the AggLayer faucet token registry by `(origin_token_address, origin_network)` instead of `origin_token_address` alone, preventing same-address cross-network mint collisions on CLAIM ([#2860](https://github.com/0xMiden/protocol/pull/2860)). +- Validated `PartialBlockchain` invariants on deserialization ([#2888](https://github.com/0xMiden/protocol/pull/2888)). +- Fixed `set_procedure_threshold` in the multisig auth component validating per-procedure overrides against initial `num_approvers`. +- Bound MINT notes to their faucet ([#2911](https://github.com/0xMiden/protocol/pull/2911)). +- Fixed `LocalTransactionProver` accumulating `MastForest` entries across `prove()` calls, causing `capacity_overflow` panics in WASM environments where linear memory fragmentation prevents subsequent allocations ([#2918](https://github.com/0xMiden/protocol/pull/2918)). +- Fixed `TokenPolicyManager::manager_storage_slots` to register the protocol-reserved asset-callback storage slots whenever any transfer policy is configured (including `TransferAllowAll`), so every minted asset carries `AssetCallbackFlag::Enabled` and future `set_send_policy` / `set_receive_policy` switches apply uniformly to the entire circulating supply ([#2946](https://github.com/0xMiden/protocol/pull/2946)). +- Fixed `create_fungible_faucet` leaving authority-gated setters unauthenticated under `AccessControl::AuthControlled`: the `AuthSingleSigAcl` trigger list now contains every authority-gated setter root (`set_max_supply`, `set_description`, `set_logo_uri`, `set_external_link`, `set_mint_policy`, `set_burn_policy`, `set_send_policy`, `set_receive_policy`) in addition to `mint_and_send`. ([#2958](https://github.com/0xMiden/protocol/pull/2958)). +- [BREAKING] Added missing transaction `ref_block_commitment` validation in `ProposedBatch::new` ([#2971](https://github.com/0xMiden/protocol/pull/2971)). + ## 0.14.6 (2026-05-09) - Fixed asset callback against native account panicking ([#2868](https://github.com/0xMiden/protocol/pull/2868)). @@ -56,6 +144,8 @@ - Added `ProgramExecutor` hooks to support DAP and other custom transaction program executors ([#2574](https://github.com/0xMiden/protocol/pull/2574)). - Added `create_fungible_key` for construction of fungible asset keys ([#2575](https://github.com/0xMiden/protocol/pull/2575)). - Added metadata hash storage to AggLayer faucet and FPI retrieval during bridge-out leaf construction ([#2583](https://github.com/0xMiden/protocol/pull/2583)). +- Added `BurnPolicyConfig` for flexible burning policy execution ([#2664](https://github.com/0xMiden/protocol/pull/2664)) + - Added `SwapNoteStorage` for typed serialization/deserialization of SWAP note storage ([#2585](https://github.com/0xMiden/protocol/pull/2585)). - Added `InputNoteCommitment::from_parts()` for construction of input note commitments from a nullifier and optional note header ([#2588](https://github.com/0xMiden/protocol/pull/2588)). - Added `bool` schema type to the type registry and updated ACL auth component to use it for boolean config fields ([#2591](https://github.com/0xMiden/protocol/pull/2591)). @@ -64,6 +154,8 @@ - [BREAKING] Changed `native_account::remove_asset` to return the asset value remaining in the vault instead of the removed value ([#2626](https://github.com/0xMiden/protocol/pull/2626)). - Implemented `TransactionEventId::event_name` and `Host::resolve_event` for better VM diagnostics during even handler failures ([#2628](https://github.com/0xMiden/protocol/pull/2628)). - Added `FixedWidthString` for fixed-width UTF-8 string storage in `miden-standards` (`miden::standards::utils::string`). ([#2633](https://github.com/0xMiden/protocol/pull/2633)) +- [BREAKING] Extracted mint and burn policy management into a unified `TokenPolicyManager` account component ([#2821](https://github.com/0xMiden/protocol/pull/2821)) +- Added `AccountBuilder::with_components` for installing iterators of components in order as part of ([#2821](https://github.com/0xMiden/protocol/pull/2821)). ### Changes diff --git a/Cargo.lock b/Cargo.lock index 34fc3be52d..dba1077aa0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,18 +4,18 @@ version = 4 [[package]] name = "addr2line" -version = "0.21.0" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" dependencies = [ "gimli", ] [[package]] -name = "adler" -version = "1.0.2" +name = "adler2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aead" @@ -23,7 +23,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -60,9 +60,9 @@ dependencies = [ [[package]] name = "alloy-primitives" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de3b431b4e72cd8bd0ec7a50b4be18e73dab74de0dba180eef171055e5d5926e" +checksum = "4885c1409b6936c4898e646ef58baf6ec54edaf6d8179f79df805a7b85b7cf3e" dependencies = [ "bytes", "cfg-if", @@ -72,14 +72,14 @@ dependencies = [ "paste", "ruint", "rustc-hash", - "sha3", + "sha3 0.11.0", ] [[package]] name = "alloy-sol-macro" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab81bab693da9bb79f7a95b64b394718259fdd7e41dceeced4cad57cb71c4f6a" +checksum = "840128ed2b2971d6d4668a553fe403a82683d3acc646c73e75887e7157408033" dependencies = [ "alloy-sol-macro-expander", "alloy-sol-macro-input", @@ -91,9 +91,9 @@ dependencies = [ [[package]] name = "alloy-sol-macro-expander" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "489f1620bb7e2483fb5819ed01ab6edc1d2f93939dce35a5695085a1afd1d699" +checksum = "63ec265e5d65d725175f6ca7711c970824c90ef9c0d1f1973711d4150ee612dd" dependencies = [ "alloy-sol-macro-input", "const-hex", @@ -102,16 +102,16 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "sha3", + "sha3 0.11.0", "syn 2.0.117", "syn-solidity", ] [[package]] name = "alloy-sol-macro-input" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56cef806ad22d4392c5fc83cf8f2089f988eb99c7067b4e0c6f1971fc1cca318" +checksum = "89bf01077f18650876cfa682eb1f949967b5cde03f1a51c955c469d2c9b4aa67" dependencies = [ "const-hex", "dunce", @@ -125,9 +125,9 @@ dependencies = [ [[package]] name = "alloy-sol-types" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64612d29379782a5dde6f4b6570d9c756d734d760c0c94c254d361e678a6591f" +checksum = "384cf252de0db2dec52821eac037a7f57e2aa33fe5b900ce6fe39973402341f1" dependencies = [ "alloy-primitives", "alloy-sol-macro", @@ -230,17 +230,17 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "backtrace" -version = "0.3.71" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ "addr2line", - "cc", "cfg-if", "libc", "miniz_oxide", "object", "rustc-demangle", + "windows-link", ] [[package]] @@ -266,14 +266,13 @@ name = "bench-note-checker" version = "0.1.0" dependencies = [ "anyhow", - "criterion 0.6.0", + "criterion", "miden-protocol", "miden-standards", "miden-testing", "miden-tx", "serde", "tokio", - "tokio-test", ] [[package]] @@ -281,13 +280,14 @@ name = "bench-transaction" version = "0.1.0" dependencies = [ "anyhow", - "criterion 0.6.0", + "criterion", "miden-agglayer", + "miden-processor", "miden-protocol", "miden-standards", "miden-testing", "miden-tx", - "rand 0.9.3", + "rand 0.9.4", "serde", "serde_json", "tokio", @@ -325,15 +325,15 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "blake3" -version = "1.8.4" +version = "1.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" dependencies = [ "arrayref", "arrayvec", @@ -352,6 +352,40 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bon" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f47dbe92550676ee653353c310dfb9cf6ba17ee70396e1f7cf0a2020ad49b2fe" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" +dependencies = [ + "darling", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -384,9 +418,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.59" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "jobserver", @@ -457,16 +491,16 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", "zeroize", ] [[package]] name = "clap" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", ] @@ -487,33 +521,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" -[[package]] -name = "color-eyre" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f1885697ee8a177096d42f158922251a41973117f6d8a234cee94b9509157b7" -dependencies = [ - "backtrace", - "color-spantrace", - "eyre", - "indenter", - "once_cell", - "owo-colors 1.3.0", - "tracing-error", -] - -[[package]] -name = "color-spantrace" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6eee477a4a8a72f4addd4de416eb56d54bc307b284d6601bafdee1f4ea462d1" -dependencies = [ - "once_cell", - "owo-colors 1.3.0", - "tracing-core", - "tracing-error", -] - [[package]] name = "colorchoice" version = "1.0.5" @@ -522,9 +529,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "const-hex" -version = "1.18.1" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "531185e432bb31db1ecda541e9e7ab21468d4d844ad7505e0546a49b4945d49b" +checksum = "20d9a563d167a9cce0f94153382b33cb6eded6dfabff03c69ad65a28ea1514e0" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -582,6 +589,7 @@ dependencies = [ "ciborium", "clap", "criterion-plot", + "futures", "is-terminal", "itertools 0.10.5", "num-traits", @@ -594,29 +602,6 @@ dependencies = [ "serde_derive", "serde_json", "tinytemplate", - "walkdir", -] - -[[package]] -name = "criterion" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bf7af66b0989381bd0be551bd7cc91912a655a58c6918420c9527b1fd8b4679" -dependencies = [ - "anes", - "cast", - "ciborium", - "clap", - "criterion-plot", - "itertools 0.13.0", - "num-traits", - "oorandom", - "plotters", - "rayon", - "regex", - "serde", - "serde_json", - "tinytemplate", "tokio", "walkdir", ] @@ -631,6 +616,12 @@ dependencies = [ "itertools 0.10.5", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -685,6 +676,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -694,7 +694,7 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest", + "digest 0.10.7", "fiat-crypto", "rustc_version 0.4.1", "subtle", @@ -712,6 +712,40 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + [[package]] name = "debugid" version = "0.8.0" @@ -760,12 +794,22 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", "const-oid", - "crypto-common", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.2", +] + [[package]] name = "dissimilar" version = "1.0.11" @@ -785,7 +829,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest", + "digest 0.10.7", "elliptic-curve", "rfc6979", "signature", @@ -818,9 +862,9 @@ dependencies = [ [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "elliptic-curve" @@ -830,7 +874,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest", + "digest 0.10.7", "ff", "generic-array", "group", @@ -921,16 +965,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "eyre" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" -dependencies = [ - "indenter", - "once_cell", -] - [[package]] name = "fastrand" version = "2.4.1" @@ -1074,9 +1108,9 @@ checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-timer" -version = "3.0.3" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" [[package]] name = "futures-util" @@ -1160,9 +1194,9 @@ dependencies = [ [[package]] name = "gimli" -version = "0.28.1" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "glob" @@ -1203,9 +1237,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -1240,7 +1274,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", ] [[package]] @@ -1249,6 +1292,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "indenter" version = "0.3.4" @@ -1257,12 +1306,12 @@ checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" [[package]] name = "indexmap" -version = "2.13.1" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -1320,15 +1369,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.14.0" @@ -1346,9 +1386,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.23" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" dependencies = [ "jiff-static", "log", @@ -1359,9 +1399,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.23" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" dependencies = [ "proc-macro2", "quote", @@ -1380,10 +1420,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.94" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -1411,6 +1453,16 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "keccak" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + [[package]] name = "lalrpop" version = "0.22.2" @@ -1425,7 +1477,7 @@ dependencies = [ "petgraph", "regex", "regex-syntax", - "sha3", + "sha3 0.10.9", "string_cache", "term", "unicode-xid", @@ -1455,9 +1507,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.184" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libm" @@ -1496,14 +1548,14 @@ dependencies = [ "generator", "scoped-tls", "tracing", - "tracing-subscriber 0.3.23", + "tracing-subscriber", ] [[package]] name = "macro-string" -version = "0.1.4" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" +checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" dependencies = [ "proc-macro2", "quote", @@ -1534,16 +1586,25 @@ dependencies = [ "libc", ] +[[package]] +name = "miden-ace-codegen" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5cec5dea133d84f194fdb0e1fc1915f37ec3314cc4c847ef3d6ef1f91a29a1" +dependencies = [ + "miden-core", + "miden-crypto", + "thiserror", +] + [[package]] name = "miden-agglayer" -version = "0.14.6" +version = "0.15.0" dependencies = [ "alloy-sol-types", "fs-err", - "miden-agglayer", "miden-assembly", "miden-core", - "miden-core-lib", "miden-crypto", "miden-protocol", "miden-standards", @@ -1558,22 +1619,25 @@ dependencies = [ [[package]] name = "miden-air" -version = "0.22.1" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d15646ebc95906b2a7cb66711d1e184f53fd6edc2605730bbcf0c2a129f792cf" +checksum = "a04e2a9fe12abe40a7a3b10f0184ead7105a081d9940d927d32120544a84c2b3" dependencies = [ + "miden-ace-codegen", "miden-core", "miden-crypto", + "miden-lifted-stark", "miden-utils-indexing", + "proptest", "thiserror", "tracing", ] [[package]] name = "miden-assembly" -version = "0.22.1" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae6013b3a390e0dcb29242f4480a7727965887bbf0903466c88f362b4cb20c0e" +checksum = "d09158daf738e51eeb035ef4b71b9e9f9173d4b012d532034aca8d7b673e82fe" dependencies = [ "env_logger", "log", @@ -1582,15 +1646,16 @@ dependencies = [ "miden-mast-package", "miden-package-registry", "miden-project", + "proptest", "smallvec", "thiserror", ] [[package]] name = "miden-assembly-syntax" -version = "0.22.1" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "996156b8f7c5fe6be17dea71089c6d7985c2dec1e3a4fec068b1dfc690e25df5" +checksum = "fce9e789cfcf73408c792f116fda46f6e5bac4eebbcf6c8299c58cfc4629f677" dependencies = [ "aho-corasick", "env_logger", @@ -1613,7 +1678,7 @@ dependencies = [ [[package]] name = "miden-block-prover" -version = "0.14.6" +version = "0.15.0" dependencies = [ "miden-protocol", "thiserror", @@ -1621,12 +1686,12 @@ dependencies = [ [[package]] name = "miden-core" -version = "0.22.1" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdec54a321cdf3d23e9ef615e91cb858038c6b4d4202507bdec048fc6d7763e4" +checksum = "c2315cc7abb7abee25889de16739685f584215dc0e4c77f873e7054d0e234712" dependencies = [ "derive_more", - "itertools 0.14.0", + "log", "miden-crypto", "miden-debug-types", "miden-formatting", @@ -1643,9 +1708,9 @@ dependencies = [ [[package]] name = "miden-core-lib" -version = "0.22.1" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "621e8fa911a790bcf3cd3aedce80bc10922a19d6181f08ff3ca078f955cff70b" +checksum = "35bd6eafb6d904c09add69070c8c1ecc3dd5a954587a4a6f8fe0a32125dcbd6b" dependencies = [ "env_logger", "fs-err", @@ -1660,24 +1725,26 @@ dependencies = [ [[package]] name = "miden-crypto" -version = "0.23.0" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ed0a034a460e27723dcfdf25effffab84331c3b46b13e7a1bd674197cc71bfe" +checksum = "35198bebd353cddc25ad4aafb5f4ef9e71b283d71c787b8938c575c16974135d" dependencies = [ "blake3", "cc", "chacha20poly1305", "curve25519-dalek", + "der", "ed25519-dalek", "flume", - "glob", "hkdf", "k256", "miden-crypto-derive", "miden-field", + "miden-lifted-stark", "miden-serde-utils", "num", "num-complex", + "once_cell", "p3-blake3", "p3-challenger", "p3-dft", @@ -1685,17 +1752,16 @@ dependencies = [ "p3-keccak", "p3-matrix", "p3-maybe-rayon", - "p3-miden-lifted-stark", "p3-symmetric", "p3-util", - "rand 0.9.3", + "rand 0.9.4", "rand_chacha", "rand_core 0.9.5", "rand_hc", "rayon", "serde", "sha2", - "sha3", + "sha3 0.10.9", "subtle", "thiserror", "x25519-dalek", @@ -1703,9 +1769,9 @@ dependencies = [ [[package]] name = "miden-crypto-derive" -version = "0.23.0" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8bf6ebde028e79bcc61a3632d2f375a5cc64caa17d014459f75015238cb1e08" +checksum = "9068c6554db0e051f62913575de9949841a46b96ae92d4b7d28e1fed5d8f052b" dependencies = [ "quote", "syn 2.0.117", @@ -1713,9 +1779,9 @@ dependencies = [ [[package]] name = "miden-debug-types" -version = "0.22.1" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6e50274d11c80b901cf6c90362de8c98c8c8ad6030c80624d683b63d899a0fb" +checksum = "85e70c3163517092a462abb2ac502bb1d23c6717dcbc9f1358e712bcdcbc68f5" dependencies = [ "memchr", "miden-crypto", @@ -1724,6 +1790,7 @@ dependencies = [ "miden-utils-indexing", "miden-utils-sync", "paste", + "proptest", "serde", "serde_spanned", "thiserror", @@ -1731,15 +1798,16 @@ dependencies = [ [[package]] name = "miden-field" -version = "0.23.0" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38011348f4fb4c9e5ce1f471203d024721c00e3b60a91aa91aaefe6738d8b5ea" +checksum = "379a39db52cd932a95d4017a18b712ee53ed0f86cfedf8c63ed72d687a18a191" dependencies = [ "miden-serde-utils", "num-bigint", "p3-challenger", "p3-field", "p3-goldilocks", + "p3-util", "paste", "rand 0.10.1", "serde", @@ -1756,13 +1824,48 @@ dependencies = [ "unicode-width 0.1.14", ] +[[package]] +name = "miden-lifted-air" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "789e0e469d1731012d8a018057317f31580611535c20d2a47c022213228cb733" +dependencies = [ + "p3-air", + "p3-field", + "p3-matrix", + "p3-util", + "thiserror", +] + +[[package]] +name = "miden-lifted-stark" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f62cca91182917b22a47e150028b7c785df620a15b2974a39c64e2b1b7a889d3" +dependencies = [ + "miden-lifted-air", + "miden-stark-transcript", + "miden-stateful-hasher", + "p3-challenger", + "p3-dft", + "p3-field", + "p3-goldilocks", + "p3-matrix", + "p3-maybe-rayon", + "p3-symmetric", + "p3-util", + "rand 0.10.1", + "serde", + "thiserror", + "tracing", +] + [[package]] name = "miden-mast-package" -version = "0.22.1" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc8b2e3447fcde1f0e6b76e5219f129517639772cb02ca543177f0584e315288" +checksum = "c4b79ebfcb493b6a3be4e065eca5f9109bfdf83e3b4d894eb246a37af7711242" dependencies = [ - "derive_more", "miden-assembly-syntax", "miden-core", "miden-debug-types", @@ -1781,7 +1884,7 @@ dependencies = [ "indenter", "lazy_static", "miden-miette-derive", - "owo-colors 4.3.0", + "owo-colors", "regex", "rustc_version 0.2.3", "rustversion", @@ -1808,13 +1911,14 @@ dependencies = [ [[package]] name = "miden-package-registry" -version = "0.22.1" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "969ba3942052e52b3968e34dbd1c52c707e75777ee42ebdae2c8f57af56cf6cf" +checksum = "c8836d3302f2de24ca3a8299fbdbc9b4a90963825e7962ce198a8f84ffdbb0a6" dependencies = [ "miden-assembly-syntax", "miden-core", "miden-mast-package", + "proptest", "pubgrub", "serde", "smallvec", @@ -1823,9 +1927,9 @@ dependencies = [ [[package]] name = "miden-processor" -version = "0.22.1" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ec6cecbf22bd92b73a931ee80b424e46b8b7cdf4f2f3c364c25c5c15d2840da" +checksum = "a29df38b4dce4b644862f4b314db80927f11fbd2d373221cf4d2cbb775e90360" dependencies = [ "itertools 0.14.0", "miden-air", @@ -1836,20 +1940,20 @@ dependencies = [ "paste", "rayon", "thiserror", - "tokio", "tracing", ] [[package]] name = "miden-project" -version = "0.22.1" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3840520c01881534fbbceb6b3687ec1c407fbaf310a35ce415fd3510abc52fdb" +checksum = "af05f1abc7e0a0ca5d28492e48534aab2265d5948330699d59a29a6e2bfcf072" dependencies = [ "miden-assembly-syntax", "miden-core", "miden-mast-package", "miden-package-registry", + "proptest", "serde", "serde-untagged", "thiserror", @@ -1858,13 +1962,12 @@ dependencies = [ [[package]] name = "miden-protocol" -version = "0.14.6" +version = "0.15.0" dependencies = [ "anyhow", "assert_matches", "bech32", - "color-eyre", - "criterion 0.5.1", + "criterion", "fs-err", "getrandom 0.3.4", "miden-assembly", @@ -1872,14 +1975,14 @@ dependencies = [ "miden-core", "miden-core-lib", "miden-crypto", + "miden-crypto-derive", "miden-mast-package", "miden-processor", "miden-protocol", - "miden-protocol-macros", "miden-utils-sync", "miden-verifier", "pprof", - "rand 0.9.3", + "rand 0.9.4", "rand_chacha", "rand_xoshiro", "regex", @@ -1892,39 +1995,26 @@ dependencies = [ "walkdir", ] -[[package]] -name = "miden-protocol-macros" -version = "0.14.6" -dependencies = [ - "miden-protocol", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "miden-prover" -version = "0.22.1" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bb2c94e36f57684d7fa0cd382adeedc1728d502dbbe69ad1c12f4a931f45511" +checksum = "f2800c1923060c0da2f6e8d45259cccf34c87d4636f7d76446bda51c0dc07d48" dependencies = [ "bincode", "miden-air", "miden-core", "miden-crypto", - "miden-debug-types", "miden-processor", "serde", - "thiserror", - "tokio", "tracing", ] [[package]] name = "miden-serde-utils" -version = "0.23.0" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff78082e9b4ca89863e68da01b35f8a4029ee6fd912e39fa41fde4273a7debab" +checksum = "d78cd1d4fcad937312e544f7d53423485e453598aa4fb989d2b6374027a8c136" dependencies = [ "p3-field", "p3-goldilocks", @@ -1932,30 +2022,50 @@ dependencies = [ [[package]] name = "miden-standards" -version = "0.14.6" +version = "0.15.0" dependencies = [ "anyhow", "assert_matches", + "bon", "fs-err", "miden-assembly", - "miden-core", "miden-core-lib", - "miden-processor", "miden-protocol", "miden-standards", - "rand 0.9.3", + "rand 0.9.4", "regex", "thiserror", "walkdir", ] +[[package]] +name = "miden-stark-transcript" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05901db2e30d3954243960fe21cea7fbec39f97c27774b56fd5031c28c4881ba" +dependencies = [ + "p3-challenger", + "p3-field", + "serde", + "thiserror", +] + +[[package]] +name = "miden-stateful-hasher" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faeb47a90c55c5d45051d23cf691588804dd531995b4582c79108b64e445a905" +dependencies = [ + "p3-field", + "p3-symmetric", +] + [[package]] name = "miden-testing" -version = "0.14.6" +version = "0.15.0" dependencies = [ "anyhow", "assert_matches", - "hex", "itertools 0.14.0", "miden-agglayer", "miden-assembly", @@ -1968,7 +2078,7 @@ dependencies = [ "miden-tx", "miden-tx-batch-prover", "primitive-types", - "rand 0.9.3", + "rand 0.9.4", "rand_chacha", "rstest", "serde", @@ -1979,24 +2089,20 @@ dependencies = [ [[package]] name = "miden-tx" -version = "0.14.6" +version = "0.15.0" dependencies = [ - "anyhow", - "assert_matches", - "miden-assembly", "miden-processor", "miden-protocol", "miden-prover", "miden-standards", - "miden-tx", "miden-verifier", - "rstest", + "rand_chacha", "thiserror", ] [[package]] name = "miden-tx-batch-prover" -version = "0.14.6" +version = "0.15.0" dependencies = [ "miden-protocol", "miden-tx", @@ -2004,9 +2110,9 @@ dependencies = [ [[package]] name = "miden-utils-core-derive" -version = "0.22.1" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3846c8674ccec0c37005f99c1a599a24790ba2a5e5f4e1c7aec5f456821df835" +checksum = "9dc6b702574184af27e29d4441c213327521447b9683bac5018e429c91619aef" dependencies = [ "proc-macro2", "quote", @@ -2015,33 +2121,33 @@ dependencies = [ [[package]] name = "miden-utils-diagnostics" -version = "0.22.1" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "397f5d1e8679cf17cf7713ffd9654840791a6ed5818b025bbc2fbfdce846579a" +checksum = "d7d66bc24d1770ae5392f54a33d3df9fcc02ee93a07d358cc763493ba7c88280" dependencies = [ "miden-crypto", "miden-debug-types", "miden-miette", - "paste", "tracing", ] [[package]] name = "miden-utils-indexing" -version = "0.22.1" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8834e76299686bcce3de1685158aa4cff49b7fa5e0e00a6cc811e8f2cf5775f" +checksum = "7f87e39461905a7b3145c8903840821fc55107e94e73932baef03bbb46d25de9" dependencies = [ "miden-crypto", + "proptest", "serde", "thiserror", ] [[package]] name = "miden-utils-sync" -version = "0.22.1" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a9e9747e9664c1a0997bb040ae291306ea0a1c74a572141ec66cec855c1b0e8" +checksum = "7cb89b92fa49b29c57c0f4edfc6acfbdfc617ab0ead60ad0a9409bcda2e5bec9" dependencies = [ "lock_api", "loom", @@ -2051,9 +2157,9 @@ dependencies = [ [[package]] name = "miden-verifier" -version = "0.22.1" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4580df640d889c9f3c349cd2268968e44a99a8cf0df6c36ae5b1fb273712b00" +checksum = "18bed960749c3c078f56a25f7c396770af3e08a7815ff86144fd63d3b619797a" dependencies = [ "bincode", "miden-air", @@ -2066,9 +2172,9 @@ dependencies = [ [[package]] name = "midenc-hir-type" -version = "0.5.3" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eb29d7c049fb69373c7e775e3d4411e63e4ee608bc43826282ba62c6ec9f891" +checksum = "1ff0511aa2201f7098995e38a3c97a319d379c3b2d26fb83677b21b71f61a7b4" dependencies = [ "miden-formatting", "miden-serde-utils", @@ -2080,11 +2186,11 @@ dependencies = [ [[package]] name = "miniz_oxide" -version = "0.7.4" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ - "adler", + "adler2", ] [[package]] @@ -2219,9 +2325,9 @@ dependencies = [ [[package]] name = "object" -version = "0.32.2" +version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ "memchr", ] @@ -2231,6 +2337,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -2250,12 +2360,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" -[[package]] -name = "owo-colors" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2386b4ebe91c2f7f51082d4cefa145d030e33a1842a96b12e4885cc3c01f7a55" - [[package]] name = "owo-colors" version = "4.3.0" @@ -2264,9 +2368,9 @@ checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" [[package]] name = "p3-air" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f2ec9cbfc642fc5173817287c3f8b789d07743b5f7e812d058b7a03e344f9ab" +checksum = "c824e8d7c7ddf208b742eac8d48e0b2d52d22fa013578a7762bf6931dbab1f46" dependencies = [ "p3-field", "p3-matrix", @@ -2275,9 +2379,9 @@ dependencies = [ [[package]] name = "p3-blake3" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b667f43b19499dd939c9e2553aa95688936a88360d50117dae3c8848d07dbc70" +checksum = "2733229a713bd83ccf5eb749e8f8e7380c1052674394a25c0422a772204a20af" dependencies = [ "blake3", "p3-symmetric", @@ -2286,9 +2390,9 @@ dependencies = [ [[package]] name = "p3-challenger" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a0b490c745a7d2adeeafff06411814c8078c432740162332b3cd71be0158a76" +checksum = "8972ccd1d5dc90e46cdb1f2ab4ee2bae49b3917e5e98aa533f0c2b779c010445" dependencies = [ "p3-field", "p3-maybe-rayon", @@ -2298,24 +2402,11 @@ dependencies = [ "tracing", ] -[[package]] -name = "p3-commit" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916ae7989d5c3b49f887f5c55b2f9826bdbb81aaebf834503c4145d8b267c829" -dependencies = [ - "itertools 0.14.0", - "p3-field", - "p3-matrix", - "p3-util", - "serde", -] - [[package]] name = "p3-dft" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55301e91544440254977108b85c32c09d7ea05f2f0dd61092a2825339906a4a7" +checksum = "17771aca44632f9cc11f2718d7ea7ec06794946c4190ef3a985bfc893f14c18a" dependencies = [ "itertools 0.14.0", "p3-field", @@ -2328,9 +2419,9 @@ dependencies = [ [[package]] name = "p3-field" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85affca7fc983889f260655c4cf74163eebb94605f702e4b6809ead707cba54f" +checksum = "6f3eb24d0591fd4d282d89cbe4e4efba5571c699375006f80b2cbf53ce83461c" dependencies = [ "itertools 0.14.0", "num-bigint", @@ -2344,9 +2435,9 @@ dependencies = [ [[package]] name = "p3-goldilocks" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ca1081f5c47b940f2d75a11c04f62ea1cc58a5d480dd465fef3861c045c63cd" +checksum = "5751c6591a0d2397d726620c2c29a7436ec6c5e19d2ed74ca5d078d4fbb18eb5" dependencies = [ "num-bigint", "p3-challenger", @@ -2364,9 +2455,9 @@ dependencies = [ [[package]] name = "p3-keccak" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebcf27615ece1995e4fcf4c69740f1cf515d1481367a20b4b3ce7f4f1b8d70f7" +checksum = "77a7df174ff0c19a8742eb4698eaa1667c5f858d018e2faf09c55f1f24a6f9c3" dependencies = [ "p3-symmetric", "p3-util", @@ -2375,9 +2466,9 @@ dependencies = [ [[package]] name = "p3-matrix" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53428126b009071563d1d07305a9de8be0d21de00b57d2475289ee32ffca6577" +checksum = "ea9c94c0714944e7b8a9a62e6340b1e3e1d3f8ecfd3e35c08798360200e73eff" dependencies = [ "itertools 0.14.0", "p3-field", @@ -2390,127 +2481,31 @@ dependencies = [ [[package]] name = "p3-maybe-rayon" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "082bf467011c06c768c579ec6eb9accb5e1e62108891634cc770396e917f978a" +checksum = "eebc233a34b1ab0273f35b4052fa2eeb3114b22ba4575bd7da00716e878ffb77" dependencies = [ "rayon", ] [[package]] name = "p3-mds" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35209e6214102ea6ec6b8cb1b9c15a9b8e597a39f9173597c957f123bced81b3" -dependencies = [ - "p3-dft", - "p3-field", - "p3-symmetric", - "p3-util", - "rand 0.10.1", -] - -[[package]] -name = "p3-miden-lifted-air" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5c31c65fdc88952d7b301546add9670676e5b878aa0066dd929f107c203b006" -dependencies = [ - "p3-air", - "p3-field", - "p3-matrix", - "p3-util", - "thiserror", -] - -[[package]] -name = "p3-miden-lifted-fri" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab9932f1b0a16609a45cd4ee10a4d35412728bc4b38837c7979d7c85d8dcc9fc" -dependencies = [ - "p3-challenger", - "p3-commit", - "p3-dft", - "p3-field", - "p3-matrix", - "p3-maybe-rayon", - "p3-miden-lmcs", - "p3-miden-transcript", - "p3-util", - "rand 0.10.1", - "thiserror", - "tracing", -] - -[[package]] -name = "p3-miden-lifted-stark" -version = "0.5.0" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c3956ab7270c3cdd53ca9796d39ae1821984eb977415b0672110f9666bff5d8" +checksum = "6b5441fa8116246ec9e6c835f15273cb27777ca572960ec87476b67fef13e01e" dependencies = [ - "p3-challenger", "p3-dft", "p3-field", - "p3-matrix", - "p3-maybe-rayon", - "p3-miden-lifted-air", - "p3-miden-lifted-fri", - "p3-miden-lmcs", - "p3-miden-stateful-hasher", - "p3-miden-transcript", - "p3-util", - "thiserror", - "tracing", -] - -[[package]] -name = "p3-miden-lmcs" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c46791c983e772136db3d48f102431457451447abb9087deb6c8ce3c1efc86" -dependencies = [ - "p3-commit", - "p3-field", - "p3-matrix", - "p3-maybe-rayon", - "p3-miden-stateful-hasher", - "p3-miden-transcript", "p3-symmetric", "p3-util", "rand 0.10.1", - "serde", - "thiserror", - "tracing", -] - -[[package]] -name = "p3-miden-stateful-hasher" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec47a9d9615eb3d9d2a59b00d19751d9ad85384b55886827913d680d912eac6a" -dependencies = [ - "p3-field", - "p3-symmetric", -] - -[[package]] -name = "p3-miden-transcript" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c565647487e4a949f67e6f115b0391d6cb82ac8e561165789939bab23d0ae7" -dependencies = [ - "p3-challenger", - "p3-field", - "serde", - "thiserror", ] [[package]] name = "p3-monty-31" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffa8c99ec50c035020bbf5457c6a729ba6a975719c1a8dd3f16421081e4f650c" +checksum = "8724f330ea6d19dd4f2436aa0f88b5fcbf88f0f55ca7fccd3fea8b736dbcddad" dependencies = [ "itertools 0.14.0", "num-bigint", @@ -2532,9 +2527,9 @@ dependencies = [ [[package]] name = "p3-poseidon1" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a018b618e3fa0aec8be933b1d8e404edd23f46991f6bf3f5c2f3f95e9413fe9" +checksum = "04e2a562fea210baae390a32f9ecf0dd8724ae3f4352d1c8e413077b6f00a162" dependencies = [ "p3-field", "p3-symmetric", @@ -2543,9 +2538,9 @@ dependencies = [ [[package]] name = "p3-poseidon2" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "256a668a9ba916f8767552f13d0ba50d18968bc74a623bfdafa41e2970c944d0" +checksum = "06394851c161d17e4aa4ad2aad5557d32f14cadd1dc838f965d8e1821a63b8c5" dependencies = [ "p3-field", "p3-mds", @@ -2556,9 +2551,9 @@ dependencies = [ [[package]] name = "p3-symmetric" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c60a71a1507c13611b0f2b0b6e83669fd5b76f8e3115bcbced5ccfdf3ca7807" +checksum = "9ac1a276d421f8ef3361bb7d8c39a02c93c6b3f10eeaa559cc4c50222f9a5b82" dependencies = [ "itertools 0.14.0", "p3-field", @@ -2568,9 +2563,9 @@ dependencies = [ [[package]] name = "p3-util" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8b766b9e9254bf3fa98d76e42cf8a5b30628c182dfd5272d270076ee12f0fc0" +checksum = "d08a58162a4c264269ef454f0b28dcda89939490eecacb2b2cf5b00f719b80f6" dependencies = [ "rayon", "serde", @@ -2688,9 +2683,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" dependencies = [ "portable-atomic", ] @@ -2704,7 +2699,7 @@ dependencies = [ "aligned-vec", "backtrace", "cfg-if", - "criterion 0.5.1", + "criterion", "findshlibs", "inferno", "libc", @@ -2810,9 +2805,9 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "num-traits", - "rand 0.9.3", + "rand 0.9.4", "rand_chacha", "rand_xorshift", "regex-syntax", @@ -2876,18 +2871,18 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "rand_core 0.6.4", ] [[package]] name = "rand" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", "rand_core 0.9.5", @@ -2899,7 +2894,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ - "rand_core 0.10.0", + "rand_core 0.10.1", ] [[package]] @@ -2932,9 +2927,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rand_hc" @@ -2965,9 +2960,9 @@ dependencies = [ [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -2989,7 +2984,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", ] [[package]] @@ -3077,13 +3072,13 @@ dependencies = [ [[package]] name = "ruint" -version = "1.17.2" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c141e807189ad38a07276942c6623032d3753c8859c146104ac2e4d68865945a" +checksum = "0298da754d1395046b0afdc2f20ee76d29a8ae310cd30ffa84ed42acba9cb12a" dependencies = [ "proptest", - "rand 0.8.5", - "rand 0.9.3", + "rand 0.8.6", + "rand 0.9.4", "ruint-macro", "serde_core", "valuable", @@ -3132,7 +3127,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", @@ -3249,9 +3244,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "indexmap", "itoa", @@ -3289,17 +3284,27 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak 0.1.6", ] [[package]] name = "sha3" -version = "0.10.8" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" dependencies = [ - "digest", - "keccak", + "digest 0.11.3", + "keccak 0.2.0", ] [[package]] @@ -3323,15 +3328,15 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -3396,9 +3401,9 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "str_stack" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9091b6114800a5f2141aee1d1b9d6ca3592ac062dc5decb3764ec5895a47b4eb" +checksum = "7f446288b699d66d0fd2e30d1cfe7869194312524b3b9252594868ed26ef056a" [[package]] name = "strength_reduce" @@ -3427,6 +3432,12 @@ dependencies = [ "vte", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -3435,9 +3446,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "symbolic-common" -version = "12.17.3" +version = "12.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ca086c1eb5c7ee74b151ba83c6487d5d33f8c08ad991b86f3f58f6629e68d5" +checksum = "332615d90111d8eeaf86a84dc9bbe9f65d0d8c5cf11b4caccedc37754eb0dcfd" dependencies = [ "debugid", "memmap2", @@ -3447,9 +3458,9 @@ dependencies = [ [[package]] name = "symbolic-demangle" -version = "12.17.3" +version = "12.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baa911a28a62823aaf2cc2e074212492a3ee69d0d926cc8f5b12b4a108ff5c0c" +checksum = "912017718eb4d21930546245af9a3475c9dccf15675a5c215664e76621afc471" dependencies = [ "rustc-demangle", "symbolic-common", @@ -3479,9 +3490,9 @@ dependencies = [ [[package]] name = "syn-solidity" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53f425ae0b12e2f5ae65542e00898d500d4d318b4baf09f40fd0d410454e9947" +checksum = "ec005042c7d952febc1a3ef5b0f6674e9054aa836877a31c90b20e25b3d31744" dependencies = [ "paste", "proc-macro2", @@ -3587,9 +3598,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.51.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "pin-project-lite", "tokio-macros", @@ -3606,28 +3617,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tokio-stream" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-test" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" -dependencies = [ - "futures-core", - "tokio", - "tokio-stream", -] - [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -3654,9 +3643,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.10+spec-1.1.0" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap", "toml_datetime", @@ -3711,16 +3700,6 @@ dependencies = [ "valuable", ] -[[package]] -name = "tracing-error" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4d7c0b83d4a500748fa5879461652b361edf5c9d51ede2a2ac03875ca185e24" -dependencies = [ - "tracing", - "tracing-subscriber 0.2.25", -] - [[package]] name = "tracing-log" version = "0.2.0" @@ -3732,17 +3711,6 @@ dependencies = [ "tracing-core", ] -[[package]] -name = "tracing-subscriber" -version = "0.2.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0d2eaa99c3c2e41547cfa109e910a68ea03823cccad4a0525dcbc9b01e8c71" -dependencies = [ - "sharded-slab", - "thread_local", - "tracing-core", -] - [[package]] name = "tracing-subscriber" version = "0.3.23" @@ -3795,9 +3763,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "uint" @@ -3859,7 +3827,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -3871,9 +3839,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "js-sys", "wasm-bindgen", @@ -3887,9 +3855,9 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "version-ranges" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3595ffe225639f1e0fd8d7269dcc05d2fbfea93cfac2fea367daf1adb60aae91" +checksum = "31e9bd4e9c9ff6a2a9b5969462ba26216af3e010df0377dad8320ab515262ef8" dependencies = [ "smallvec", ] @@ -3927,11 +3895,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -3940,14 +3908,14 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" -version = "0.2.117" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -3958,9 +3926,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.117" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3968,9 +3936,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.117" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -3981,9 +3949,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.117" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -4016,7 +3984,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap", "semver 1.0.28", @@ -4024,9 +3992,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.94" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", @@ -4089,9 +4057,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -4105,6 +4073,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" @@ -4154,7 +4128,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.0", + "bitflags 2.11.1", "indexmap", "log", "serde", diff --git a/Cargo.toml b/Cargo.toml index e83af0b6ca..94f216c54e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,6 @@ members = [ "crates/miden-agglayer", "crates/miden-block-prover", "crates/miden-protocol", - "crates/miden-protocol-macros", "crates/miden-standards", "crates/miden-testing", "crates/miden-tx", @@ -21,7 +20,7 @@ homepage = "https://miden.xyz" license = "MIT" repository = "https://github.com/0xMiden/protocol" rust-version = "1.90" -version = "0.14.6" +version = "0.15.0" [profile.release] codegen-units = 1 @@ -37,36 +36,41 @@ lto = true [workspace.dependencies] # Workspace crates -miden-agglayer = { default-features = false, path = "crates/miden-agglayer", version = "0.14" } -miden-block-prover = { default-features = false, path = "crates/miden-block-prover", version = "0.14" } -miden-protocol = { default-features = false, path = "crates/miden-protocol", version = "0.14" } -miden-protocol-macros = { default-features = false, path = "crates/miden-protocol-macros", version = "0.14" } -miden-standards = { default-features = false, path = "crates/miden-standards", version = "0.14" } -miden-testing = { default-features = false, path = "crates/miden-testing", version = "0.14" } -miden-tx = { default-features = false, path = "crates/miden-tx", version = "0.14" } -miden-tx-batch-prover = { default-features = false, path = "crates/miden-tx-batch-prover", version = "0.14" } +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 dependencies -miden-assembly = { default-features = false, version = "0.22" } -miden-assembly-syntax = { default-features = false, version = "0.22" } -miden-core = { default-features = false, version = "0.22" } -miden-core-lib = { default-features = false, version = "0.22" } -miden-crypto = { default-features = false, version = "0.23" } -miden-mast-package = { default-features = false, version = "0.22" } -miden-processor = { default-features = false, version = "0.22" } -miden-prover = { default-features = false, version = "0.22" } -miden-utils-sync = { default-features = false, version = "0.22" } -miden-verifier = { default-features = false, version = "0.22" } +miden-assembly = { default-features = false, version = "0.23" } +miden-assembly-syntax = { default-features = false, version = "0.23" } +miden-core = { default-features = false, version = "0.23" } +miden-core-lib = { default-features = false, version = "0.23" } +miden-crypto = { default-features = false, version = "0.25" } +miden-crypto-derive = { default-features = false, version = "0.25" } +miden-mast-package = { default-features = false, version = "0.23" } +miden-processor = { default-features = false, version = "0.23" } +miden-prover = { default-features = false, version = "0.23" } +miden-utils-sync = { default-features = false, version = "0.23" } +miden-verifier = { default-features = false, version = "0.23" } # External dependencies alloy-sol-types = { default-features = false, version = "1.5" } anyhow = { default-features = false, features = ["backtrace", "std"], version = "1.0" } assert_matches = { default-features = false, version = "1.5" } +bon = { default-features = false, version = "3" } +criterion = { default-features = false, version = "0.5" } fs-err = { default-features = false, version = "3" } primitive-types = { default-features = false, version = "0.14" } rand = { default-features = false, version = "0.9" } rand_chacha = { default-features = false, version = "0.9" } +regex = { version = "1.11" } rstest = { version = "0.26" } serde = { default-features = false, version = "1.0" } +serde_json = { default-features = false, version = "1.0" } thiserror = { default-features = false, version = "2.0" } tokio = { default-features = false, features = ["sync"], version = "1" } +walkdir = { version = "2.5" } diff --git a/Makefile b/Makefile index 72f6eac962..5bda7aee3c 100644 --- a/Makefile +++ b/Makefile @@ -40,6 +40,10 @@ format: ## Runs Format using nightly toolchain format-check: ## Runs Format using nightly toolchain but only in check mode cargo +nightly fmt --all --check +.PHONY: shear +shear: ## Runs cargo-shear to find unused or misplaced dependencies + cargo shear + .PHONY: typos-check typos-check: ## Runs spellchecker typos @@ -60,7 +64,11 @@ lint: ## Runs all linting tasks at once (Clippy, fixing, formatting, typos) @$(BUILD_GENERATED_FILES_IN_SRC) $(MAKE) clippy-no-std @$(BUILD_GENERATED_FILES_IN_SRC) $(MAKE) typos-check @$(BUILD_GENERATED_FILES_IN_SRC) $(MAKE) toml - cargo machete + @$(BUILD_GENERATED_FILES_IN_SRC) $(MAKE) shear + +.PHONY: hooks-test +hooks-test: ## Runs the pytest suite for the python hooks under .claude/hooks/ + python3 -m pytest .claude/hooks/tests/ # --- docs ---------------------------------------------------------------------------------------- @@ -146,11 +154,12 @@ build-no-std-testing: ## Build without the standard library. Includes the `testi .PHONY: generate-solidity-test-vectors generate-solidity-test-vectors: ## Regenerate Solidity test vectors using Foundry - cd crates/miden-agglayer/solidity-compat && forge test -vv --match-test test_generateVectors cd crates/miden-agglayer/solidity-compat && forge test -vv --match-test test_generateCanonicalZeros - cd crates/miden-agglayer/solidity-compat && forge test -vv --match-test test_generateVerificationProofData - cd crates/miden-agglayer/solidity-compat && forge test -vv --match-test test_generateLeafValueVectors cd crates/miden-agglayer/solidity-compat && forge test -vv --match-test test_generateClaimAssetVectors + cd crates/miden-agglayer/solidity-compat && forge test -vv --match-test test_generateLeafValueVectors + cd crates/miden-agglayer/solidity-compat && forge test -vv --match-test test_generateVerificationProofData + cd crates/miden-agglayer/solidity-compat && forge test -vv --match-test test_generate_MTF_vectors + cd crates/miden-agglayer/solidity-compat && forge test -vv --match-test test_generateExitRootVectors # --- benchmarking -------------------------------------------------------------------------------- @@ -172,11 +181,11 @@ check-tools: ## Checks if development tools are installed @command -v typos >/dev/null 2>&1 && echo "[OK] typos is installed" || echo "[MISSING] typos is not installed (run: make install-tools)" @command -v cargo nextest >/dev/null 2>&1 && echo "[OK] cargo-nextest is installed" || echo "[MISSING] cargo-nextest is not installed (run: make install-tools)" @command -v taplo >/dev/null 2>&1 && echo "[OK] taplo is installed" || echo "[MISSING] taplo is not installed (run: make install-tools)" - @command -v cargo-machete >/dev/null 2>&1 && echo "[OK] cargo-machete is installed" || echo "[MISSING] cargo-machete is not installed (run: make install-tools)" + @command -v cargo-shear >/dev/null 2>&1 && echo "[OK] cargo-shear is installed" || echo "[MISSING] cargo-shear is not installed (run: make install-tools)" .PHONY: install-tools -install-tools: ## Installs development tools required by the Makefile (mdbook, typos, nextest, taplo) - @echo "Installing development tools..."" +install-tools: ## Installs development tools required by the Makefile (mdbook, typos, nextest, taplo, shear) + @echo "Installing development tools..." @if ! command -v node >/dev/null 2>&1; then \ echo "Node.js not found. Please install Node.js from https://nodejs.org/ or using your package manager"; \ echo "On macOS: brew install node"; \ @@ -187,7 +196,7 @@ install-tools: ## Installs development tools required by the Makefile (mdbook, t cargo install typos-cli --locked cargo install cargo-nextest --locked cargo install taplo-cli --locked - cargo install cargo-machete --locked + cargo install cargo-shear --locked @echo "Development tools installation complete!" # -- documentation --------------------------------------------------------------------------------- diff --git a/bin/bench-note-checker/Cargo.toml b/bin/bench-note-checker/Cargo.toml index 09f0485573..15096ad5cb 100644 --- a/bin/bench-note-checker/Cargo.toml +++ b/bin/bench-note-checker/Cargo.toml @@ -10,6 +10,10 @@ repository.workspace = true rust-version.workspace = true version = "0.1.0" +[lib] +doctest = false +test = false + [dependencies] # Workspace dependencies miden-protocol = { features = ["testing"], workspace = true } @@ -20,11 +24,10 @@ miden-tx = { workspace = true } # External dependencies anyhow = { workspace = true } serde = { features = ["derive"], workspace = true } -tokio = { features = ["macros", "rt"], workspace = true } [dev-dependencies] -criterion = { features = ["async_tokio", "html_reports"], version = "0.6" } -tokio-test = "0.4" +criterion = { features = ["async_tokio", "html_reports"], workspace = true } +tokio = { features = ["macros", "rt"], workspace = true } [[bench]] harness = false diff --git a/bin/bench-note-checker/src/lib.rs b/bin/bench-note-checker/src/lib.rs index 0dc338dbfe..5d91c28a3f 100644 --- a/bin/bench-note-checker/src/lib.rs +++ b/bin/bench-note-checker/src/lib.rs @@ -81,8 +81,6 @@ pub fn setup_mixed_notes_benchmark(config: MixedNotesConfig) -> anyhow::Result anyhow::Result<() // Validate that we got the expected number of successful notes. assert_eq!( setup.expected_successful_count, - result.successful.len(), + result.successful().len(), "Expected {} successful notes, got {}", setup.expected_successful_count, - result.successful.len() + result.successful().len() ); // Validate that we have some failed notes (all the failing ones). - assert!(!result.failed.is_empty(), "Expected some failed notes"); + assert!(!result.failed().is_empty(), "Expected some failed notes"); Ok(()) } diff --git a/bin/bench-transaction/Cargo.toml b/bin/bench-transaction/Cargo.toml index 8ee10d1097..3b7f864030 100644 --- a/bin/bench-transaction/Cargo.toml +++ b/bin/bench-transaction/Cargo.toml @@ -10,6 +10,9 @@ repository.workspace = true rust-version.workspace = true version = "0.1.0" +[lib] +doctest = false + [[bench]] harness = false name = "time_counting_benchmarks" @@ -18,17 +21,18 @@ path = "src/time_counting_benchmarks/prove.rs" [dependencies] # Workspace dependencies miden-agglayer = { features = ["testing"], workspace = true } +miden-processor = { features = ["concurrent"], workspace = true } miden-protocol = { features = ["testing"], workspace = true } miden-standards = { workspace = true } miden-testing = { workspace = true } -miden-tx = { workspace = true } +miden-tx = { features = ["concurrent"], workspace = true } # External dependencies anyhow = { workspace = true } rand = { workspace = true } serde = { features = ["derive", "std"], workspace = true } -serde_json = { features = ["preserve_order"], package = "serde_json", version = "1.0" } +serde_json = { features = ["preserve_order", "std"], workspace = true } tokio = { features = ["macros", "rt"], workspace = true } [dev-dependencies] -criterion = { features = ["async_tokio", "html_reports"], version = "0.6" } +criterion = { features = ["async_tokio", "html_reports"], workspace = true } diff --git a/bin/bench-transaction/README.md b/bin/bench-transaction/README.md index 093a182869..ecca833441 100644 --- a/bin/bench-transaction/README.md +++ b/bin/bench-transaction/README.md @@ -6,7 +6,8 @@ Below we describe how to benchmark Miden transactions. The following transactions are benchmarked: -- **P2ID notes**: Consume single/two P2ID notes, create single P2ID note +- **P2ID notes**: Consume single P2ID notes with Falcon or ECDSA signing, consume two P2ID + notes, create single P2ID note - **CLAIM notes (agglayer bridge-in)**: Consume CLAIM note for L1-to-Miden bridging and L2-to-Miden bridging - **B2AGG note (agglayer bridge-out)**: Consume B2AGG note for Miden-to-AggLayer bridging @@ -33,14 +34,19 @@ Each of the above transactions is measured in two groups: - Total number of cycles - Authentication procedure - After tx cycles were obtained (The number of cycles the epilogue took to execute after the number of transaction cycles were obtained) - + + In the same pass we also rebuild the `ExecutionTrace` for each scenario and emit + per-component trace row counts (`core_rows`, `chiplets_rows`, `range_rows`) plus the + per-chiplet shape breakdown (`hasher_rows`, `bitwise_rows`, `memory_rows`, + `kernel_rom_rows`, `ace_rows`). + Results of this benchmark will be stored in the [`bin/bench-tx/bench-tx.json`](bench-tx.json) file. - Benchmarking the transaction execution and proving. - For each transaction in this group we measure how much time it takes to execute the transaction and to execute and prove the transaction. + For each transaction in this group we measure how much time it takes to execute the transaction and to execute and prove the transaction. This group uses the [Criterion.rs](https://github.com/bheisler/criterion.rs) to collect the elapsed time. Results of this benchmark group are printed to the terminal and look like so: ```zsh - Execute transaction/Execute transaction which consumes single P2ID note + Execute transaction/Execute transaction which consumes single P2ID note with Falcon signing time: [7.2611 ms 7.2772 ms 7.2929 ms] change: [−0.9131% −0.5837% −0.3058%] (p = 0.00 < 0.05) Change within noise threshold. @@ -49,7 +55,7 @@ Each of the above transactions is measured in two groups: change: [−1.2256% −0.7611% −0.3355%] (p = 0.00 < 0.05) Change within noise threshold. - Execute and prove transaction/Execute and prove transaction which consumes single P2ID note + Execute and prove transaction/Execute and prove transaction which consumes single P2ID note with Falcon signing time: [698.96 ms 703.92 ms 708.70 ms] change: [−2.3061% −0.4274% +0.9653%] (p = 0.70 > 0.05) No change in performance detected. @@ -81,6 +87,39 @@ cargo run --bin bench-transaction --features concurrent cargo bench --bin bench-transaction --bench time_counting_benchmarks --features concurrent ``` +## Trace shape and miden-vm's synthetic benchmark + +The `trace` section in `bench-tx.json` is the input contract for miden-vm's +`miden-vm-synthetic-bench`. Its hard targets are the AIR-side row totals +(`trace.core_rows`, `trace.chiplets_rows`, `trace.range_rows`); the +`trace.chiplets_shape.*` per-chiplet breakdown is advisory profiling metadata +and is required to satisfy the chiplet-bus invariant +`chiplets_rows == hasher + bitwise + memory + kernel_rom + ace + 1`. + +The consumer's hard match is on padded power-of-two brackets, not raw row +equality: + +- `padded_core_side = max(64, next_pow2(max(core_rows, range_rows)))` +- `padded_chiplets = max(64, next_pow2(chiplets_rows))` + +These two can land in different brackets on the same workload (e.g. +`consume two P2ID notes` has `padded_core_side = 131072` but +`padded_chiplets = 262144`). + +To feed the snapshot into `miden-vm`, regenerate `bench-tx.json` here and copy +it across: + +```bash +cargo run --release --bin bench-transaction --features concurrent +cp bin/bench-transaction/bench-tx.json \ + ../miden-vm/benches/synthetic-bench/snapshots/bench-tx.json +cargo bench -p miden-vm-synthetic-bench +``` + +The schema is maintained manually; bench-tx.json's `trace` section is what the +consumer's loader keys off. When changing the shape of the trace section, bump +both repos together. + ## License -This project is [MIT licensed](../../LICENSE). \ No newline at end of file +This project is [MIT licensed](../../LICENSE). diff --git a/bin/bench-transaction/bench-tx.json b/bin/bench-transaction/bench-tx.json index eca1d773f6..081a3ea21f 100644 --- a/bin/bench-transaction/bench-tx.json +++ b/bin/bench-transaction/bench-tx.json @@ -1,79 +1,176 @@ { - "consume single P2ID note": { - "prologue": 3501, - "notes_processing": 1761, + "consume single P2ID note with Falcon signing": { + "prologue": 3512, + "notes_processing": 1757, "note_execution": { - "0x62e6fe0d5f649b395b5c3f63736fd7b469fd1e0e867c441696d1a70324d3f9a2": 1721 + "0x2cc6f4f31352e856292d9daf412f97468ffce9993e8e9b403a5d4a4fb7be8163": 1717 }, "tx_script_processing": 42, "epilogue": { - "total": 72351, - "auth_procedure": 70846, - "after_tx_cycles_obtained": 612 + "total": 72815, + "auth_procedure": 71322, + "after_tx_cycles_obtained": 603 + }, + "trace": { + "core_rows": 78170, + "chiplets_rows": 61069, + "range_rows": 20367, + "chiplets_shape": { + "hasher_rows": 58320, + "bitwise_rows": 392, + "memory_rows": 2301, + "kernel_rom_rows": 55, + "ace_rows": 0 + } + } + }, + "consume single P2ID note with ECDSA signing": { + "prologue": 3512, + "notes_processing": 1757, + "note_execution": { + "0x48854330460cc896ea9d2761baeff069b6deea60c70a590f06c0cca7c75ef30b": 1717 + }, + "tx_script_processing": 42, + "epilogue": { + "total": 4823, + "auth_procedure": 3330, + "after_tx_cycles_obtained": 603 + }, + "trace": { + "core_rows": 10178, + "chiplets_rows": 18845, + "range_rows": 1201, + "chiplets_shape": { + "hasher_rows": 17728, + "bitwise_rows": 392, + "memory_rows": 669, + "kernel_rom_rows": 55, + "ace_rows": 0 + } } }, "consume two P2ID notes": { - "prologue": 4537, - "notes_processing": 3600, + "prologue": 4592, + "notes_processing": 3636, "note_execution": { - "0x51e09a95d1aa86daa64a59ce960c7a6baba576decf870aa4500e9847c3740bf9": 1721, - "0xf11434d602ac1f69b171c08d5b3352c59c57ecec02e191c61b2a6599269a12c3": 1830 + "0x6a0b18f0dd3a23ac61b77ac70c4b1be62878087d3af8c235eac5ef6e453f964c": 1870, + "0xa469507fa4c9f2259e30a2052b5d700e0cfe6be3b6c3eb8f747ed8d86e8fb59a": 1717 }, "tx_script_processing": 42, "epilogue": { - "total": 72299, - "auth_procedure": 70820, - "after_tx_cycles_obtained": 612 + "total": 72763, + "auth_procedure": 71296, + "after_tx_cycles_obtained": 603 + }, + "trace": { + "core_rows": 81077, + "chiplets_rows": 63669, + "range_rows": 20335, + "chiplets_shape": { + "hasher_rows": 60656, + "bitwise_rows": 560, + "memory_rows": 2397, + "kernel_rom_rows": 55, + "ace_rows": 0 + } } }, "create single P2ID note": { - "prologue": 1766, + "prologue": 1791, "notes_processing": 32, "note_execution": {}, - "tx_script_processing": 1667, + "tx_script_processing": 1661, "epilogue": { - "total": 73243, - "auth_procedure": 71058, - "after_tx_cycles_obtained": 612 + "total": 73917, + "auth_procedure": 71646, + "after_tx_cycles_obtained": 603 + }, + "trace": { + "core_rows": 77445, + "chiplets_rows": 59632, + "range_rows": 20271, + "chiplets_shape": { + "hasher_rows": 57008, + "bitwise_rows": 328, + "memory_rows": 2240, + "kernel_rom_rows": 55, + "ace_rows": 0 + } } }, "consume CLAIM note (L1 to Miden)": { - "prologue": 2897, - "notes_processing": 28536, + "prologue": 2979, + "notes_processing": 29656, "note_execution": { - "0xdbc80122500b117ed9c951e4f1362424ef081cbda9f7d4af3a6a30d1f029d376": 28496 + "0xa1946d015e643cf1956ab511c73583f75c30017c417cd40961d38b8e17ad3ad3": 29616 }, "tx_script_processing": 42, "epilogue": { - "total": 4093, - "auth_procedure": 880, - "after_tx_cycles_obtained": 612 + "total": 5249, + "auth_procedure": 1781, + "after_tx_cycles_obtained": 603 + }, + "trace": { + "core_rows": 37970, + "chiplets_rows": 45953, + "range_rows": 2685, + "chiplets_shape": { + "hasher_rows": 39728, + "bitwise_rows": 2728, + "memory_rows": 3441, + "kernel_rom_rows": 55, + "ace_rows": 0 + } } }, "consume CLAIM note (L2 to Miden)": { - "prologue": 2897, - "notes_processing": 40786, + "prologue": 2979, + "notes_processing": 41869, "note_execution": { - "0x1f04a3e738aad3c25b4148a9a1be0c4dfe8cc9ec69171909a730630045dced97": 40746 + "0x7c4ab3b63bd083dfd2a48a147bf31d45d827a4caf46e3d6515798dbbccf1094e": 41829 }, "tx_script_processing": 42, "epilogue": { - "total": 4093, - "auth_procedure": 880, - "after_tx_cycles_obtained": 612 + "total": 5249, + "auth_procedure": 1781, + "after_tx_cycles_obtained": 603 + }, + "trace": { + "core_rows": 50183, + "chiplets_rows": 52159, + "range_rows": 2905, + "chiplets_shape": { + "hasher_rows": 44448, + "bitwise_rows": 2984, + "memory_rows": 4671, + "kernel_rom_rows": 55, + "ace_rows": 0 + } } }, "consume B2AGG note (bridge-out)": { - "prologue": 3718, - "notes_processing": 145590, + "prologue": 3793, + "notes_processing": 145479, "note_execution": { - "0xa0e31e2fec803d6cb21681b36ea6312d366d9bae97eea9255e43f3fb95e46524": 145550 + "0xa76b8bcf2daa5556bb5c2bd421dac2695996e86095648e16f8c608349025ff51": 145439 }, "tx_script_processing": 42, "epilogue": { - "total": 13756, - "auth_procedure": 880, - "after_tx_cycles_obtained": 612 + "total": 14898, + "auth_procedure": 1781, + "after_tx_cycles_obtained": 603 + }, + "trace": { + "core_rows": 164256, + "chiplets_rows": 178248, + "range_rows": 4361, + "chiplets_shape": { + "hasher_rows": 163312, + "bitwise_rows": 2680, + "memory_rows": 12200, + "kernel_rom_rows": 55, + "ace_rows": 0 + } } } -} \ No newline at end of file +} diff --git a/bin/bench-transaction/src/context_setups.rs b/bin/bench-transaction/src/context_setups.rs index b399402fe1..75e47ef1f3 100644 --- a/bin/bench-transaction/src/context_setups.rs +++ b/bin/bench-transaction/src/context_setups.rs @@ -1,23 +1,26 @@ use anyhow::Result; pub use miden_agglayer::testing::ClaimDataSource; use miden_agglayer::{ + AggLayerBridge, B2AggNote, + ClaimNote, ClaimNoteStorage, ConfigAggBridgeNote, + ConversionMetadata, EthAddress, MetadataHash, UpdateGerNote, - create_claim_note, create_existing_agglayer_faucet, create_existing_bridge_account, }; -use miden_protocol::Felt; use miden_protocol::account::auth::AuthScheme; +use miden_protocol::account::{Account, StorageMapKey}; use miden_protocol::asset::{Asset, FungibleAsset}; use miden_protocol::crypto::rand::FeltRng; use miden_protocol::note::{NoteAssets, NoteType}; use miden_protocol::testing::account_id::ACCOUNT_ID_SENDER; use miden_protocol::transaction::RawOutputNote; +use miden_protocol::{Felt, Word}; use miden_standards::code_builder::CodeBuilder; use miden_standards::note::StandardNote; use miden_testing::{Auth, MockChain, TransactionContext}; @@ -89,18 +92,24 @@ pub fn tx_create_single_p2id_note() -> Result { .build() } +pub fn tx_consume_single_p2id_note_falcon() -> Result { + tx_consume_single_p2id_note_with_auth(AuthScheme::Falcon512Poseidon2) +} + +pub fn tx_consume_single_p2id_note_ecdsa() -> Result { + tx_consume_single_p2id_note_with_auth(AuthScheme::EcdsaK256Keccak) +} + /// Returns the transaction context which could be used to run the transaction which consumes a /// single P2ID note into a new basic wallet. -pub fn tx_consume_single_p2id_note() -> Result { +fn tx_consume_single_p2id_note_with_auth(auth_scheme: AuthScheme) -> Result { // Create assets let fungible_asset: Asset = FungibleAsset::mock(123); let mut builder = MockChain::builder(); // Create target account - let target_account = builder.create_new_wallet(Auth::BasicAuth { - auth_scheme: AuthScheme::Falcon512Poseidon2, - })?; + let target_account = builder.create_new_wallet(Auth::BasicAuth { auth_scheme })?; // Create the note let note = builder @@ -190,7 +199,7 @@ pub async fn tx_consume_claim_note(data_source: ClaimDataSource) -> Result Result Result Result Result Result Result { +pub async fn tx_consume_b2agg_note(pre_populate_leaves: Option) -> Result { let vectors = &*miden_agglayer::testing::SOLIDITY_MTF_VECTORS; let mut builder = MockChain::builder(); @@ -317,11 +398,17 @@ pub async fn tx_consume_b2agg_note() -> Result { })?; // CREATE BRIDGE ACCOUNT - let bridge_account = create_existing_bridge_account( + let mut bridge_account = create_existing_bridge_account( builder.rng_mut().draw_word(), bridge_admin.id(), ger_manager.id(), ); + + // Pre-populate frontier before adding the account to the mock chain + if let Some(num_leaves) = pre_populate_leaves { + populate_let_frontier(&mut bridge_account, num_leaves); + } + builder.add_account(bridge_account.clone())?; // CREATE AGGLAYER FAUCET ACCOUNT (with conversion metadata for FPI) @@ -335,20 +422,23 @@ pub async fn tx_consume_b2agg_note() -> Result { builder.rng_mut().draw_word(), "AGG", 8, - Felt::new(FungibleAsset::MAX_AMOUNT), - Felt::new(bridge_amount), + FungibleAsset::MAX_AMOUNT.into(), + Felt::new_unchecked(bridge_amount), bridge_account.id(), - &origin_token_address, - origin_network, - scale, - MetadataHash::from_token_info("AGG", "AGG", 8), ); builder.add_account(faucet.clone())?; // CREATE CONFIG_AGG_BRIDGE NOTE (registers faucet + token address in bridge) + let metadata_hash = MetadataHash::from_token_info("AGG", "AGG", 8); let config_note = ConfigAggBridgeNote::create( - faucet.id(), - &origin_token_address, + ConversionMetadata { + faucet_account_id: faucet.id(), + origin_token_address, + scale, + origin_network, + is_native: false, + metadata_hash, + }, bridge_admin.id(), bridge_account.id(), builder.rng_mut(), diff --git a/bin/bench-transaction/src/cycle_counting_benchmarks/mod.rs b/bin/bench-transaction/src/cycle_counting_benchmarks/mod.rs index 39e24c64d5..31fccbca26 100644 --- a/bin/bench-transaction/src/cycle_counting_benchmarks/mod.rs +++ b/bin/bench-transaction/src/cycle_counting_benchmarks/mod.rs @@ -1,21 +1,31 @@ use core::fmt; +pub mod trace_capture; pub mod utils; /// Indicates the type of the transaction execution benchmark +#[derive(Clone, Copy)] pub enum ExecutionBenchmark { - ConsumeSingleP2ID, + ConsumeSingleP2IDFalcon, + ConsumeSingleP2IDEcdsa, ConsumeTwoP2ID, CreateSingleP2ID, ConsumeClaimNoteL1ToMiden, ConsumeClaimNoteL2ToMiden, ConsumeB2AggNote, + ConsumeB2AggNotePopulated2p31, + ConsumeB2AggNotePopulated2p31m1, } impl fmt::Display for ExecutionBenchmark { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - ExecutionBenchmark::ConsumeSingleP2ID => write!(f, "consume single P2ID note"), + ExecutionBenchmark::ConsumeSingleP2IDFalcon => { + write!(f, "consume single P2ID note with Falcon signing") + }, + ExecutionBenchmark::ConsumeSingleP2IDEcdsa => { + write!(f, "consume single P2ID note with ECDSA signing") + }, ExecutionBenchmark::ConsumeTwoP2ID => write!(f, "consume two P2ID notes"), ExecutionBenchmark::CreateSingleP2ID => write!(f, "create single P2ID note"), ExecutionBenchmark::ConsumeClaimNoteL1ToMiden => { @@ -27,6 +37,12 @@ impl fmt::Display for ExecutionBenchmark { ExecutionBenchmark::ConsumeB2AggNote => { write!(f, "consume B2AGG note (bridge-out)") }, + ExecutionBenchmark::ConsumeB2AggNotePopulated2p31 => { + write!(f, "consume B2AGG note (bridge-out, 2^31 leaves)") + }, + ExecutionBenchmark::ConsumeB2AggNotePopulated2p31m1 => { + write!(f, "consume B2AGG note (bridge-out, 2^31-1 leaves)") + }, } } } diff --git a/bin/bench-transaction/src/cycle_counting_benchmarks/trace_capture.rs b/bin/bench-transaction/src/cycle_counting_benchmarks/trace_capture.rs new file mode 100644 index 0000000000..ced1be7f4f --- /dev/null +++ b/bin/bench-transaction/src/cycle_counting_benchmarks/trace_capture.rs @@ -0,0 +1,80 @@ +//! Capture per-component trace lengths alongside cycle measurements. + +use std::sync::Arc; + +use anyhow::{Context as _, Result}; +use miden_processor::FastProcessor; +use miden_processor::trace::{TraceLenSummary, build_trace}; +use miden_protocol::transaction::{TransactionInputs, TransactionKernel, TransactionMeasurements}; +use miden_testing::TransactionContext; +use miden_tx::{ + AccountProcedureIndexMap, + ExecutionOptions, + ScriptMastForestStore, + TransactionMastStore, + TransactionProverHost, +}; + +/// Executes the transaction, then replays its inputs through the trace-build path to capture a +/// `TraceLenSummary`. +/// +/// Two passes: `TransactionExecutor` first so the authenticator resolves any required signatures +/// into the `ExecutedTransaction`'s inputs, then `LocalTransactionProver::prove`'s trace-build +/// setup against those inputs (minus the prove step). The duplicate run is per-bench, not +/// per-iteration. +pub async fn capture_measurements_and_trace_summary( + context: TransactionContext, +) -> Result<(TransactionMeasurements, TraceLenSummary)> { + let executed = context + .execute() + .await + .context("pre-execution (to resolve signatures) failed")?; + let (tx_inputs, _tx_outputs, _account_delta, measurements) = executed.into_parts(); + + let trace_summary = build_trace_summary(tx_inputs).await?; + + Ok((measurements, trace_summary)) +} + +// TODO(#2841): integrate `TraceLenSummary` into `TransactionMeasurements` so we can drop this +// duplicate-execute path. See https://github.com/0xMiden/protocol/issues/2841. +async fn build_trace_summary(tx_inputs: TransactionInputs) -> Result { + let (stack_inputs, tx_advice_inputs) = TransactionKernel::prepare_inputs(&tx_inputs); + + let mast_store = Arc::new(TransactionMastStore::new()); + mast_store.load_account_code(tx_inputs.account().code()); + for account_code in tx_inputs.foreign_account_code() { + mast_store.load_account_code(account_code); + } + + let script_mast_store = ScriptMastForestStore::new( + tx_inputs.tx_script(), + tx_inputs.input_notes().iter().map(|n| n.note().script()), + ); + let account_procedure_index_map = AccountProcedureIndexMap::new( + tx_inputs.foreign_account_code().iter().chain([tx_inputs.account().code()]), + ); + + let (partial_account, _ref_block, _blockchain, input_notes, _tx_args) = tx_inputs.into_parts(); + let mut host = TransactionProverHost::new( + &partial_account, + input_notes, + mast_store.as_ref(), + script_mast_store, + account_procedure_index_map, + ); + + let advice_inputs = tx_advice_inputs.into_advice_inputs(); + let program = TransactionKernel::main(); + + let processor = + FastProcessor::new_with_options(stack_inputs, advice_inputs, ExecutionOptions::default()) + .context("failed to construct FastProcessor for trace capture")?; + let trace_inputs = processor + .execute_trace_inputs(&program, &mut host) + .await + .context("failed to execute transaction kernel for trace")?; + let trace = build_trace(trace_inputs).context("failed to build trace from execution output")?; + + Ok(*trace.trace_len_summary()) +} diff --git a/bin/bench-transaction/src/cycle_counting_benchmarks/utils.rs b/bin/bench-transaction/src/cycle_counting_benchmarks/utils.rs index 49a916c390..7159925b0e 100644 --- a/bin/bench-transaction/src/cycle_counting_benchmarks/utils.rs +++ b/bin/bench-transaction/src/cycle_counting_benchmarks/utils.rs @@ -5,6 +5,7 @@ use std::fs::{read_to_string, write}; use std::path::Path; use anyhow::Context; +use miden_processor::trace::TraceLenSummary; use miden_protocol::transaction::TransactionMeasurements; use serde::Serialize; use serde_json::{Value, from_str, to_string_pretty}; @@ -14,8 +15,8 @@ use super::ExecutionBenchmark; // MEASUREMENTS PRINTER // ================================================================================================ -/// Helper structure holding the cycle count of each transaction stage which could be easily -/// converted to the JSON file. +/// Helper structure holding the cycle and trace counts of each transaction stage which could be +/// easily converted to the JSON file. #[derive(Debug, Clone, Serialize)] pub struct MeasurementsPrinter { prologue: usize, @@ -23,26 +24,28 @@ pub struct MeasurementsPrinter { note_execution: BTreeMap, tx_script_processing: usize, epilogue: EpilogueMeasurements, + trace: TraceMeasurements, } -impl From for MeasurementsPrinter { - fn from(tx_measurements: TransactionMeasurements) -> Self { - let note_execution_map = tx_measurements +impl MeasurementsPrinter { + pub fn from_parts(measurements: TransactionMeasurements, trace: TraceLenSummary) -> Self { + let note_execution_map = measurements .note_execution .iter() .map(|(id, len)| (id.to_hex(), *len)) .collect(); MeasurementsPrinter { - prologue: tx_measurements.prologue, - notes_processing: tx_measurements.notes_processing, + prologue: measurements.prologue, + notes_processing: measurements.notes_processing, note_execution: note_execution_map, - tx_script_processing: tx_measurements.tx_script_processing, + tx_script_processing: measurements.tx_script_processing, epilogue: EpilogueMeasurements::from_parts( - tx_measurements.epilogue, - tx_measurements.auth_procedure, - tx_measurements.after_tx_cycles_obtained, + measurements.epilogue, + measurements.auth_procedure, + measurements.after_tx_cycles_obtained, ), + trace: TraceMeasurements::from(trace), } } } @@ -75,6 +78,61 @@ impl EpilogueMeasurements { } } +/// Per-component trace row counts from a real `ExecutionTrace`. `core_rows`, `chiplets_rows`, +/// and `range_rows` are the AIR-side totals; `chiplets_shape` is an advisory per-chiplet breakdown +/// that satisfies `chiplets_rows == hasher + bitwise + memory + kernel_rom + ace + 1`. +#[derive(Debug, Clone, Serialize)] +struct TraceMeasurements { + core_rows: usize, + chiplets_rows: usize, + range_rows: usize, + chiplets_shape: ChipletsTraceShape, +} + +#[derive(Debug, Clone, Serialize)] +struct ChipletsTraceShape { + hasher_rows: usize, + bitwise_rows: usize, + memory_rows: usize, + kernel_rom_rows: usize, + ace_rows: usize, +} + +impl From for TraceMeasurements { + fn from(summary: TraceLenSummary) -> Self { + let chiplets = summary.chiplets_trace_len(); + // The pinned `miden-processor` doesn't expose an ACE accessor yet, so derive it from the + // total. The chiplet-bus invariant + // (`chiplets_rows == hasher + bitwise + memory + kernel_rom + ace + 1`) keeps holding + // when the upstream accessor lands. + let known = chiplets.hash_chiplet_len() + + chiplets.bitwise_chiplet_len() + + chiplets.memory_chiplet_len() + + chiplets.kernel_rom_len(); + // Guard against the per-chiplet accessors and `trace_len()` going out of sync upstream; + // without this, `saturating_sub` below would silently produce `ace_rows = 0`. + debug_assert!( + known < chiplets.trace_len(), + "chiplet accessors disagree with trace_len(): known = {} >= trace_len = {}", + known, + chiplets.trace_len(), + ); + let ace_rows = chiplets.trace_len().saturating_sub(known + 1); + Self { + core_rows: summary.main_trace_len(), + chiplets_rows: chiplets.trace_len(), + range_rows: summary.range_trace_len(), + chiplets_shape: ChipletsTraceShape { + hasher_rows: chiplets.hash_chiplet_len(), + bitwise_rows: chiplets.bitwise_chiplet_len(), + memory_rows: chiplets.memory_chiplet_len(), + kernel_rom_rows: chiplets.kernel_rom_len(), + ace_rows, + }, + } + } +} + /// Writes the provided benchmark results to the JSON file at the provided path. pub fn write_bench_results_to_json( path: &Path, @@ -94,11 +152,144 @@ pub fn write_bench_results_to_json( } // write the benchmarks JSON to the results file - write( - path, - to_string_pretty(&benchmark_json).expect("failed to convert json to String"), - ) - .context("failed to write benchmark results to file")?; + let mut serialized = + to_string_pretty(&benchmark_json).expect("failed to convert json to String"); + serialized.push('\n'); + write(path, serialized).context("failed to write benchmark results to file")?; Ok(()) } + +#[cfg(test)] +mod tests { + use serde::Deserialize; + + /// Minimal mirror of the bench-tx.json `trace` section used to validate the committed file + /// against the producer's contract. + #[derive(Deserialize)] + struct ScenarioForTest { + trace: TraceForTest, + } + + #[derive(Deserialize)] + struct TraceForTest { + core_rows: u64, + chiplets_rows: u64, + range_rows: u64, + chiplets_shape: ChipletsShapeForTest, + } + + #[derive(Deserialize)] + struct ChipletsShapeForTest { + hasher_rows: u64, + bitwise_rows: u64, + memory_rows: u64, + kernel_rom_rows: u64, + ace_rows: u64, + } + + const MIN_TRACE_LEN: u64 = 64; + const COMMITTED_BENCH_TX_JSON: &str = include_str!("../../bench-tx.json"); + + /// Expected padded brackets per committed scenario. Mirrors `COMMITTED_SCENARIO_EXPECTATIONS` + /// in the miden-vm consumer; refresh both together when a kernel change moves a bracket. + struct ScenarioExpectation { + name: &'static str, + padded_core_side: u64, + padded_chiplets: u64, + } + + const COMMITTED_SCENARIO_EXPECTATIONS: &[ScenarioExpectation] = &[ + ScenarioExpectation { + name: "consume single P2ID note with Falcon signing", + padded_core_side: 131_072, + padded_chiplets: 65_536, + }, + ScenarioExpectation { + name: "consume single P2ID note with ECDSA signing", + padded_core_side: 16_384, + padded_chiplets: 32_768, + }, + ScenarioExpectation { + name: "consume two P2ID notes", + padded_core_side: 131_072, + padded_chiplets: 65_536, + }, + ScenarioExpectation { + name: "create single P2ID note", + padded_core_side: 131_072, + padded_chiplets: 65_536, + }, + ScenarioExpectation { + name: "consume CLAIM note (L1 to Miden)", + padded_core_side: 65_536, + padded_chiplets: 65_536, + }, + ScenarioExpectation { + name: "consume CLAIM note (L2 to Miden)", + padded_core_side: 65_536, + padded_chiplets: 65_536, + }, + ScenarioExpectation { + name: "consume B2AGG note (bridge-out)", + padded_core_side: 262_144, + padded_chiplets: 262_144, + }, + ]; + + fn padded_core_side(t: &TraceForTest) -> u64 { + t.core_rows.max(t.range_rows).next_power_of_two().max(MIN_TRACE_LEN) + } + + fn padded_chiplets(t: &TraceForTest) -> u64 { + t.chiplets_rows.next_power_of_two().max(MIN_TRACE_LEN) + } + + fn assert_scenario(scenarios: &serde_json::Value, expected: &ScenarioExpectation) { + let name = expected.name; + let raw = scenarios + .get(name) + .unwrap_or_else(|| panic!("scenario `{name}` is missing from bench-tx.json")); + let scenario: ScenarioForTest = serde_json::from_value(raw.clone()) + .unwrap_or_else(|err| panic!("scenario `{name}` does not match the schema: {err}")); + let trace = &scenario.trace; + let chiplets_shape = &trace.chiplets_shape; + + assert!(trace.core_rows > 0, "{name}: core_rows should be > 0"); + assert!(trace.chiplets_rows > 0, "{name}: chiplets_rows should be > 0"); + assert!(trace.range_rows > 0, "{name}: range_rows should be > 0"); + + let chiplets_sum = chiplets_shape.hasher_rows + + chiplets_shape.bitwise_rows + + chiplets_shape.memory_rows + + chiplets_shape.kernel_rom_rows + + chiplets_shape.ace_rows + + 1; + assert_eq!( + trace.chiplets_rows, chiplets_sum, + "{name}: chiplets_rows must equal sum(chiplets_shape) + 1", + ); + + let core_side = padded_core_side(trace); + let chiplets = padded_chiplets(trace); + assert!(core_side.is_power_of_two(), "{name}: padded_core_side not a power of two"); + assert!(chiplets.is_power_of_two(), "{name}: padded_chiplets not a power of two"); + assert_eq!( + core_side, expected.padded_core_side, + "{name}: padded_core_side regressed to a different bracket", + ); + assert_eq!( + chiplets, expected.padded_chiplets, + "{name}: padded_chiplets regressed to a different bracket", + ); + } + + #[test] + fn committed_bench_tx_matches_trace_contract() { + let parsed: serde_json::Value = serde_json::from_str(COMMITTED_BENCH_TX_JSON) + .expect("bench-tx.json should be valid JSON"); + for expected in COMMITTED_SCENARIO_EXPECTATIONS { + assert_scenario(&parsed, expected); + } + } +} diff --git a/bin/bench-transaction/src/main.rs b/bin/bench-transaction/src/main.rs index 2b9dae9848..18b3526a01 100644 --- a/bin/bench-transaction/src/main.rs +++ b/bin/bench-transaction/src/main.rs @@ -3,21 +3,33 @@ use std::io::Write; use std::path::Path; use anyhow::{Context, Result}; -use miden_protocol::transaction::TransactionMeasurements; mod context_setups; use context_setups::{ ClaimDataSource, tx_consume_b2agg_note, tx_consume_claim_note, - tx_consume_single_p2id_note, + tx_consume_single_p2id_note_ecdsa, + tx_consume_single_p2id_note_falcon, tx_consume_two_p2id_notes, tx_create_single_p2id_note, }; mod cycle_counting_benchmarks; use cycle_counting_benchmarks::ExecutionBenchmark; -use cycle_counting_benchmarks::utils::write_bench_results_to_json; +use cycle_counting_benchmarks::trace_capture::capture_measurements_and_trace_summary; +use cycle_counting_benchmarks::utils::{MeasurementsPrinter, write_bench_results_to_json}; +use miden_testing::TransactionContext; + +async fn run_scenario( + bench: ExecutionBenchmark, + context: TransactionContext, +) -> Result<(ExecutionBenchmark, MeasurementsPrinter)> { + let (measurements, trace) = capture_measurements_and_trace_summary(context) + .await + .with_context(|| format!("failed to capture measurements for `{bench}`"))?; + Ok((bench, MeasurementsPrinter::from_parts(measurements, trace))) +} #[tokio::main(flavor = "current_thread")] async fn main() -> Result<()> { @@ -26,59 +38,41 @@ async fn main() -> Result<()> { let mut file = File::create(path).context("failed to create file")?; file.write_all(b"{}").context("failed to write to file")?; - // run all available benchmarks let benchmark_results = vec![ - ( - ExecutionBenchmark::ConsumeSingleP2ID, - tx_consume_single_p2id_note()? - .execute() - .await - .map(TransactionMeasurements::from)? - .into(), - ), - ( - ExecutionBenchmark::ConsumeTwoP2ID, - tx_consume_two_p2id_notes()? - .execute() - .await - .map(TransactionMeasurements::from)? - .into(), - ), - ( - ExecutionBenchmark::CreateSingleP2ID, - tx_create_single_p2id_note()? - .execute() - .await - .map(TransactionMeasurements::from)? - .into(), - ), - ( + run_scenario( + ExecutionBenchmark::ConsumeSingleP2IDFalcon, + tx_consume_single_p2id_note_falcon()?, + ) + .await?, + run_scenario( + ExecutionBenchmark::ConsumeSingleP2IDEcdsa, + tx_consume_single_p2id_note_ecdsa()?, + ) + .await?, + run_scenario(ExecutionBenchmark::ConsumeTwoP2ID, tx_consume_two_p2id_notes()?).await?, + run_scenario(ExecutionBenchmark::CreateSingleP2ID, tx_create_single_p2id_note()?).await?, + run_scenario( ExecutionBenchmark::ConsumeClaimNoteL1ToMiden, - tx_consume_claim_note(ClaimDataSource::SimulatedL1ToMiden) - .await? - .execute() - .await - .map(TransactionMeasurements::from)? - .into(), - ), - ( + tx_consume_claim_note(ClaimDataSource::L1ToMiden).await?, + ) + .await?, + run_scenario( ExecutionBenchmark::ConsumeClaimNoteL2ToMiden, - tx_consume_claim_note(ClaimDataSource::SimulatedL2ToMiden) - .await? - .execute() - .await - .map(TransactionMeasurements::from)? - .into(), - ), - ( - ExecutionBenchmark::ConsumeB2AggNote, - tx_consume_b2agg_note() - .await? - .execute() - .await - .map(TransactionMeasurements::from)? - .into(), - ), + tx_consume_claim_note(ClaimDataSource::L2ToMiden).await?, + ) + .await?, + run_scenario(ExecutionBenchmark::ConsumeB2AggNote, tx_consume_b2agg_note(None).await?) + .await?, + run_scenario( + ExecutionBenchmark::ConsumeB2AggNotePopulated2p31, + tx_consume_b2agg_note(Some(1 << 31)).await?, + ) + .await?, + run_scenario( + ExecutionBenchmark::ConsumeB2AggNotePopulated2p31m1, + tx_consume_b2agg_note(Some((1u32 << 31) - 1)).await?, + ) + .await?, ]; // store benchmark results in the JSON file diff --git a/bin/bench-transaction/src/time_counting_benchmarks/prove.rs b/bin/bench-transaction/src/time_counting_benchmarks/prove.rs index 62749a9df6..7749bab4bf 100644 --- a/bin/bench-transaction/src/time_counting_benchmarks/prove.rs +++ b/bin/bench-transaction/src/time_counting_benchmarks/prove.rs @@ -1,24 +1,29 @@ +use std::future::Future; use std::hint::black_box; -use std::time::Duration; +use std::time::{Duration, Instant}; use anyhow::Result; use bench_transaction::context_setups::{ ClaimDataSource, tx_consume_b2agg_note, tx_consume_claim_note, - tx_consume_single_p2id_note, + tx_consume_single_p2id_note_ecdsa, + tx_consume_single_p2id_note_falcon, tx_consume_two_p2id_notes, }; -use criterion::{BatchSize, Criterion, SamplingMode, criterion_group, criterion_main}; +use criterion::{BatchSize, Bencher, Criterion, SamplingMode, criterion_group, criterion_main}; use miden_protocol::transaction::{ExecutedTransaction, ProvenTransaction}; +use miden_testing::TransactionContext; use miden_tx::LocalTransactionProver; // BENCHMARK NAMES // ================================================================================================ const BENCH_GROUP_EXECUTE: &str = "Execute transaction"; -const BENCH_EXECUTE_TX_CONSUME_SINGLE_P2ID: &str = - "Execute transaction which consumes single P2ID note"; +const BENCH_EXECUTE_TX_CONSUME_SINGLE_P2ID_FALCON: &str = + "Execute transaction which consumes single P2ID note with Falcon signing"; +const BENCH_EXECUTE_TX_CONSUME_SINGLE_P2ID_ECDSA: &str = + "Execute transaction which consumes single P2ID note with ECDSA signing"; const BENCH_EXECUTE_TX_CONSUME_TWO_P2ID: &str = "Execute transaction which consumes two P2ID notes"; const BENCH_EXECUTE_TX_CONSUME_CLAIM_L1: &str = "Execute transaction which consumes CLAIM note (L1 to Miden)"; @@ -28,8 +33,10 @@ const BENCH_EXECUTE_TX_CONSUME_B2AGG: &str = "Execute transaction which consumes B2AGG note (bridge-out)"; const BENCH_GROUP_EXECUTE_AND_PROVE: &str = "Execute and prove transaction"; -const BENCH_EXECUTE_AND_PROVE_TX_CONSUME_SINGLE_P2ID: &str = - "Execute and prove transaction which consumes single P2ID note"; +const BENCH_EXECUTE_AND_PROVE_TX_CONSUME_SINGLE_P2ID_FALCON: &str = + "Execute and prove transaction which consumes single P2ID note with Falcon signing"; +const BENCH_EXECUTE_AND_PROVE_TX_CONSUME_SINGLE_P2ID_ECDSA: &str = + "Execute and prove transaction which consumes single P2ID note with ECDSA signing"; const BENCH_EXECUTE_AND_PROVE_TX_CONSUME_TWO_P2ID: &str = "Execute and prove transaction which consumes two P2ID notes"; const BENCH_EXECUTE_AND_PROVE_TX_CONSUME_CLAIM_L1: &str = @@ -50,51 +57,41 @@ fn core_benchmarks(c: &mut Criterion) { execute_group .sampling_mode(SamplingMode::Flat) - .sample_size(10) - .warm_up_time(Duration::from_millis(1000)); + .sample_size(30) + .warm_up_time(Duration::from_millis(1000)) + .measurement_time(Duration::from_secs(30)); - execute_group.bench_function(BENCH_EXECUTE_TX_CONSUME_SINGLE_P2ID, |b| { + execute_group.bench_function(BENCH_EXECUTE_TX_CONSUME_SINGLE_P2ID_FALCON, |b| { b.to_async(tokio::runtime::Builder::new_current_thread().build().unwrap()) .iter_batched( || { - // prepare the transaction context - tx_consume_single_p2id_note() + tx_consume_single_p2id_note_falcon() .expect("failed to create a context which consumes single P2ID note") }, - |tx_context| async move { - // benchmark the transaction execution - black_box(tx_context.execute().await) - }, + |tx_context| async move { black_box(tx_context.execute().await) }, BatchSize::SmallInput, ); }); - execute_group.bench_function(BENCH_EXECUTE_TX_CONSUME_TWO_P2ID, |b| { + execute_group.bench_function(BENCH_EXECUTE_TX_CONSUME_SINGLE_P2ID_ECDSA, |b| { b.to_async(tokio::runtime::Builder::new_current_thread().build().unwrap()) .iter_batched( || { - // prepare the transaction context - tx_consume_two_p2id_notes() - .expect("failed to create a context which consumes two P2ID notes") - }, - |tx_context| async move { - // benchmark the transaction execution - black_box(tx_context.execute().await) + tx_consume_single_p2id_note_ecdsa() + .expect("failed to create a context which consumes single P2ID note") }, + |tx_context| async move { black_box(tx_context.execute().await) }, BatchSize::SmallInput, ); }); - execute_group.bench_function(BENCH_EXECUTE_TX_CONSUME_CLAIM_L1, |b| { + execute_group.bench_function(BENCH_EXECUTE_TX_CONSUME_TWO_P2ID, |b| { b.to_async(tokio::runtime::Builder::new_current_thread().build().unwrap()) .iter_batched( || { - // prepare the transaction context (async setup) - let rt = tokio::runtime::Builder::new_current_thread() - .build() - .expect("failed to build tokio runtime for setup"); - rt.block_on(tx_consume_claim_note(ClaimDataSource::SimulatedL1ToMiden)) - .expect("failed to create a context which consumes CLAIM note (L1)") + // prepare the transaction context + tx_consume_two_p2id_notes() + .expect("failed to create a context which consumes two P2ID notes") }, |tx_context| async move { // benchmark the transaction execution @@ -104,42 +101,16 @@ fn core_benchmarks(c: &mut Criterion) { ); }); + execute_group.bench_function(BENCH_EXECUTE_TX_CONSUME_CLAIM_L1, |b| { + bench_async_execute(b, || tx_consume_claim_note(ClaimDataSource::L1ToMiden)); + }); + execute_group.bench_function(BENCH_EXECUTE_TX_CONSUME_CLAIM_L2, |b| { - b.to_async(tokio::runtime::Builder::new_current_thread().build().unwrap()) - .iter_batched( - || { - // prepare the transaction context (async setup) - let rt = tokio::runtime::Builder::new_current_thread() - .build() - .expect("failed to build tokio runtime for setup"); - rt.block_on(tx_consume_claim_note(ClaimDataSource::SimulatedL2ToMiden)) - .expect("failed to create a context which consumes CLAIM note (L2)") - }, - |tx_context| async move { - // benchmark the transaction execution - black_box(tx_context.execute().await) - }, - BatchSize::SmallInput, - ); + bench_async_execute(b, || tx_consume_claim_note(ClaimDataSource::L2ToMiden)); }); execute_group.bench_function(BENCH_EXECUTE_TX_CONSUME_B2AGG, |b| { - b.to_async(tokio::runtime::Builder::new_current_thread().build().unwrap()) - .iter_batched( - || { - // prepare the transaction context (async setup) - let rt = tokio::runtime::Builder::new_current_thread() - .build() - .expect("failed to build tokio runtime for setup"); - rt.block_on(tx_consume_b2agg_note()) - .expect("failed to create a context which consumes B2AGG note") - }, - |tx_context| async move { - // benchmark the transaction execution - black_box(tx_context.execute().await) - }, - BatchSize::SmallInput, - ); + bench_async_execute(b, || tx_consume_b2agg_note(None)); }); execute_group.finish(); @@ -151,38 +122,62 @@ fn core_benchmarks(c: &mut Criterion) { execute_and_prove_group .sampling_mode(SamplingMode::Flat) - .sample_size(10) - .warm_up_time(Duration::from_millis(1000)); + .sample_size(30) + .warm_up_time(Duration::from_millis(1000)) + .measurement_time(Duration::from_secs(30)); - execute_and_prove_group.bench_function(BENCH_EXECUTE_AND_PROVE_TX_CONSUME_SINGLE_P2ID, |b| { - b.to_async(tokio::runtime::Builder::new_current_thread().build().unwrap()) - .iter_batched( - || { - // prepare the transaction context - tx_consume_single_p2id_note() - .expect("failed to create a context which consumes single P2ID note") - }, - |tx_context| async move { - // benchmark the transaction execution and proving - black_box( - prove_transaction( - tx_context - .execute() - .await - .expect("execution of the single P2ID note consumption tx failed"), + execute_and_prove_group.bench_function( + BENCH_EXECUTE_AND_PROVE_TX_CONSUME_SINGLE_P2ID_FALCON, + |b| { + b.to_async(tokio::runtime::Builder::new_current_thread().build().unwrap()) + .iter_batched( + || { + tx_consume_single_p2id_note_falcon() + .expect("failed to create a context which consumes single P2ID note") + }, + |tx_context| async move { + black_box( + prove_transaction( + tx_context.execute().await.expect( + "execution of the single P2ID note consumption tx failed", + ), + ) + .await, ) - .await, - ) - }, - BatchSize::SmallInput, - ); - }); + }, + BatchSize::SmallInput, + ); + }, + ); + + execute_and_prove_group.bench_function( + BENCH_EXECUTE_AND_PROVE_TX_CONSUME_SINGLE_P2ID_ECDSA, + |b| { + b.to_async(tokio::runtime::Builder::new_current_thread().build().unwrap()) + .iter_batched( + || { + tx_consume_single_p2id_note_ecdsa() + .expect("failed to create a context which consumes single P2ID note") + }, + |tx_context| async move { + black_box( + prove_transaction( + tx_context.execute().await.expect( + "execution of the single P2ID note consumption tx failed", + ), + ) + .await, + ) + }, + BatchSize::SmallInput, + ); + }, + ); execute_and_prove_group.bench_function(BENCH_EXECUTE_AND_PROVE_TX_CONSUME_TWO_P2ID, |b| { b.to_async(tokio::runtime::Builder::new_current_thread().build().unwrap()) .iter_batched( || { - // prepare the transaction context tx_consume_two_p2id_notes() .expect("failed to create a context which consumes two P2ID notes") }, @@ -203,84 +198,15 @@ fn core_benchmarks(c: &mut Criterion) { }); execute_and_prove_group.bench_function(BENCH_EXECUTE_AND_PROVE_TX_CONSUME_CLAIM_L1, |b| { - b.to_async(tokio::runtime::Builder::new_current_thread().build().unwrap()) - .iter_batched( - || { - // prepare the transaction context (async setup) - let rt = tokio::runtime::Builder::new_current_thread() - .build() - .expect("failed to build tokio runtime for setup"); - rt.block_on(tx_consume_claim_note(ClaimDataSource::SimulatedL1ToMiden)) - .expect("failed to create a context which consumes CLAIM note (L1)") - }, - |tx_context| async move { - // benchmark the transaction execution and proving - black_box( - prove_transaction( - tx_context - .execute() - .await - .expect("execution of the CLAIM note (L1) consumption tx failed"), - ) - .await, - ) - }, - BatchSize::SmallInput, - ); + bench_async_execute_and_prove(b, || tx_consume_claim_note(ClaimDataSource::L1ToMiden)); }); execute_and_prove_group.bench_function(BENCH_EXECUTE_AND_PROVE_TX_CONSUME_CLAIM_L2, |b| { - b.to_async(tokio::runtime::Builder::new_current_thread().build().unwrap()) - .iter_batched( - || { - // prepare the transaction context (async setup) - let rt = tokio::runtime::Builder::new_current_thread() - .build() - .expect("failed to build tokio runtime for setup"); - rt.block_on(tx_consume_claim_note(ClaimDataSource::SimulatedL2ToMiden)) - .expect("failed to create a context which consumes CLAIM note (L2)") - }, - |tx_context| async move { - // benchmark the transaction execution and proving - black_box( - prove_transaction( - tx_context - .execute() - .await - .expect("execution of the CLAIM note (L2) consumption tx failed"), - ) - .await, - ) - }, - BatchSize::SmallInput, - ); + bench_async_execute_and_prove(b, || tx_consume_claim_note(ClaimDataSource::L2ToMiden)); }); execute_and_prove_group.bench_function(BENCH_EXECUTE_AND_PROVE_TX_CONSUME_B2AGG, |b| { - b.to_async(tokio::runtime::Builder::new_current_thread().build().unwrap()) - .iter_batched( - || { - // prepare the transaction context (async setup) - let rt = tokio::runtime::Builder::new_current_thread() - .build() - .expect("failed to build tokio runtime for setup"); - rt.block_on(tx_consume_b2agg_note()) - .expect("failed to create a context which consumes B2AGG note") - }, - |tx_context| async move { - // benchmark the transaction execution and proving - black_box( - prove_transaction( - tx_context - .execute() - .await - .expect("execution of the B2AGG note consumption tx failed"), - ) - .await, - ) - }, - BatchSize::SmallInput, - ); + bench_async_execute_and_prove(b, || tx_consume_b2agg_note(None)); }); execute_and_prove_group.finish(); @@ -295,5 +221,50 @@ async fn prove_transaction(executed_transaction: ExecutedTransaction) -> Result< Ok(()) } +/// Times `execute()` for an async-built tx context. Uses `iter_custom` because async builders +/// can't run inside `iter_batched`'s setup under a current_thread runtime (nested `block_on` +/// panics). +fn bench_async_execute(b: &mut Bencher<'_>, build_context: F) +where + F: Fn() -> Fut, + Fut: Future>, +{ + b.iter_custom(|iters| { + let rt = tokio::runtime::Builder::new_current_thread().build().unwrap(); + rt.block_on(async { + let mut total = Duration::ZERO; + for _ in 0..iters { + let tx_context = build_context().await.expect("failed to build tx context"); + let start = Instant::now(); + let _ = black_box(tx_context.execute().await); + total += start.elapsed(); + } + total + }) + }); +} + +/// Same shape as [`bench_async_execute`] but also drives the prover after `execute()`. +fn bench_async_execute_and_prove(b: &mut Bencher<'_>, build_context: F) +where + F: Fn() -> Fut, + Fut: Future>, +{ + b.iter_custom(|iters| { + let rt = tokio::runtime::Builder::new_current_thread().build().unwrap(); + rt.block_on(async { + let mut total = Duration::ZERO; + for _ in 0..iters { + let tx_context = build_context().await.expect("failed to build tx context"); + let start = Instant::now(); + let executed = tx_context.execute().await.expect("execute failed"); + let _ = black_box(prove_transaction(executed).await); + total += start.elapsed(); + } + total + }) + }); +} + criterion_group!(benches, core_benchmarks); criterion_main!(benches); diff --git a/crates/miden-agglayer/Cargo.toml b/crates/miden-agglayer/Cargo.toml index 16e4ae2afa..c314c4e322 100644 --- a/crates/miden-agglayer/Cargo.toml +++ b/crates/miden-agglayer/Cargo.toml @@ -12,7 +12,8 @@ rust-version.workspace = true version.workspace = true [lib] -bench = false +bench = false +doctest = false [features] default = ["std"] @@ -23,7 +24,6 @@ testing = ["dep:serde", "dep:serde_json", "miden-protocol/testing"] # Miden dependencies miden-assembly = { workspace = true } miden-core = { workspace = true } -miden-core-lib = { workspace = true } miden-protocol = { workspace = true } miden-standards = { workspace = true } miden-utils-sync = { workspace = true } @@ -38,23 +38,19 @@ miden-crypto = { workspace = true } # Optional testing dependencies serde = { features = ["alloc", "derive"], optional = true, workspace = true } -serde_json = { default-features = false, features = ["alloc"], optional = true, version = "1.0" } +serde_json = { default-features = false, features = ["alloc"], optional = true, workspace = true } [dev-dependencies] -miden-agglayer = { features = ["testing"], path = "." } +miden-protocol = { features = ["testing"], workspace = true } serde = { features = ["derive"], workspace = true } -serde_json = { version = "1.0" } +serde_json = { features = ["std"], workspace = true } [build-dependencies] fs-err = { workspace = true } miden-assembly = { workspace = true } miden-core = { workspace = true } -miden-core-lib = { workspace = true } miden-crypto = { workspace = true } miden-protocol = { features = ["testing"], workspace = true } miden-standards = { workspace = true } -regex = { version = "1.11" } -walkdir = { version = "2.5" } - -[package.metadata.cargo-machete] -ignored = ["miden-core-lib", "miden-standards"] +regex = { workspace = true } +walkdir = { workspace = true } diff --git a/crates/miden-agglayer/SPEC.md b/crates/miden-agglayer/SPEC.md index 3a3038aa46..189f9323ef 100644 --- a/crates/miden-agglayer/SPEC.md +++ b/crates/miden-agglayer/SPEC.md @@ -38,15 +38,23 @@ The crate `miden-agglayer` implements the AggLayer bridging protocol on the Mide A user initiates a bridge-out by creating a [`B2AGG`](#41-b2agg) note containing a single fungible asset and the destination network/address. The bridge account consumes this note: -1. Validates that the asset's faucet is registered in the faucet registry. -2. FPIs to the faucet (`agglayer_faucet::asset_to_origin_asset`) to obtain the scaled - U256 amount, origin token address, and origin network. -3. FPIs to the faucet (`agglayer_faucet::get_metadata_hash`) to obtain the metadata hash. +1. Validates that the asset's faucet is registered in the faucet registry, and that the + destination network is not Miden's AggLayer network ID. +2. Reads conversion metadata (origin token address, origin network, scale) for the asset's + faucet from the bridge's local `faucet_metadata_map`. No FPI into the faucet is required; + metadata was written to the map at registration time. +3. Reads the precomputed metadata hash for the same faucet from `faucet_metadata_map`. 4. Constructs a leaf-data structure (leaf type, origin network, origin token address, destination network, destination address, amount, metadata hash). 5. Computes the Keccak-256 leaf value and appends it to the Local Exit Tree (LET). -6. Creates a public [`BURN`](#45-burn-generated) note targeting the faucet, which burns the asset and - decreases the faucet's token supply. +6. Dispatches on the faucet's `is_native` flag (also read from the registry): + - **Wrapped faucet (`is_native = false`):** the bridge does not hold the asset onchain; it + emits a public [`BURN`](#45-burn-generated) note targeting the faucet, which the faucet + consumes to burn the asset and decrement the faucet's token supply. + - **Miden-native faucet (`is_native = true`):** the bridge does not hold mint/burn authority + for the faucet, so it cannot emit a `BURN`. Instead it locks the asset by adding it to + the bridge's own vault (`native_account::add_asset`); a later bridge-in claim for the + same token can pay out from this locked balance. The leaf appended to the LET can later be included in a Merkle proof on any AggLayer-connected chain to claim the bridged asset. @@ -72,15 +80,21 @@ The `CLAIM` note is consumed by the bridge account: 4. Updates the claimed global index (CGI) chain hash: `NEW_CGI = Keccak256(OLD_CGI, Keccak256(GLOBAL_INDEX, LEAF_VALUE))`. 5. Checks and sets the claim nullifier to prevent double-claiming. -6. Looks up the faucet from the origin token address via the token registry. +6. Looks up the faucet from the `(origin_token_address, origin_network)` pair via the token + registry. 7. Verifies the claim amount against the leaf's U256 amount and the faucet's scale factor. -8. Creates a [`MINT`](#47-mint-generated) note targeting the faucet. - -The faucet consumes the `MINT` note, mints the specified amount, and creates a [`P2ID`](#46-p2id-generated) note -that delivers the minted assets to the recipient's Miden account. - -TODO: Destination network from the leaf data is not validated against Miden's own network -ID ([#2698](https://github.com/0xMiden/protocol/issues/2698)). +8. Dispatches on the faucet's `is_native` flag: + - **Wrapped faucet (`is_native = false`):** the bridge emits a [`MINT`](#47-mint-generated) + note targeting the faucet. The faucet consumes the `MINT` note, mints the specified amount, + and creates a [`P2ID`](#46-p2id-generated) note delivering the minted assets to the + recipient's Miden account. + - **Miden-native faucet (`is_native = true`):** the bridge cannot mint via the faucet, so + it removes the asset from its own vault (`native_account::remove_asset`) and emits a + `P2ID` note targeted at the recipient directly. The asset must have been previously + locked into the bridge by a prior bridge-out for the same token. + +Inside `bridge_in::claim`, immediately after proof and leaf data are piped into memory, the bridge asserts the leaf's `destination_network` equals the global MASM constant `MIDEN_NETWORK_ID` in `asm/agglayer/common/constants.masm` (after `swap_u32_bytes` on the LE-packed memory limb). The same value is exposed to Rust as `AggLayerBridge::MIDEN_NETWORK_ID`, matching Solidity test vectors. +This mirrors Solidity `claimAsset` destination-network checks. TODO: The leaf type field is not validated to be `LEAF_TYPE_ASSET` (0) ([#2699](https://github.com/0xMiden/protocol/issues/2699)). @@ -116,11 +130,22 @@ TODO: Duplicate GER insertions are silently accepted ![Faucet registration flow](diagrams/faucet-registration.png) -Each bridged token requires a dedicated AggLayer faucet on Miden. The Bridge Operator -creates [`CONFIG_AGG_BRIDGE`](#43-config_agg_bridge) notes to register faucets. The bridge consumes these notes, -asserting the sender is the bridge admin, then registers the faucet in both the faucet -registry and the token registry. For a detailed description of the faucet and token -registries, see [Section 7](#7-faucet-registry). +Each bridged token (wrapped or Miden-native) requires registration in the bridge's +registries. The Bridge Operator creates [`CONFIG_AGG_BRIDGE`](#43-config_agg_bridge) notes +carrying the faucet's account ID, the origin token address, the origin network, the scale +factor, the metadata hash, and an `is_native` flag. The bridge consumes the note (asserting +the sender is the bridge admin) and runs two calls back-to-back: + +- `bridge_config::register_faucet` writes the registration flag plus `is_native` into + `faucet_registry_map`, the conversion metadata into `faucet_metadata_map` (sub-keys 0 and + 1), and the `(origin_token_address, origin_network) → faucet_id` mapping into + `token_registry_map`. +- `bridge_config::store_faucet_metadata_hash` writes the precomputed metadata hash into + `faucet_metadata_map` (sub-keys 2 and 3). + +The split is necessary because the 16-element MASM stack cannot fit all 18 registration +felts at once. For a detailed description of the registries, see +[Section 7](#7-faucet-registry). TODO: Faucet registrations are permanent; no remapping or deregistration is supported ([#2704](https://github.com/0xMiden/protocol/issues/2704), @@ -186,7 +211,7 @@ Bridges an asset out of Miden into the AggLayer: | | | |-|-| | **Invocation** | `call` | -| **Inputs** | `[origin_token_addr(5), faucet_id_suffix, faucet_id_prefix, pad(9)]` | +| **Inputs** | `[origin_token_addr(5), origin_network, faucet_id_suffix, faucet_id_prefix, pad(8)]` | | **Outputs** | `[pad(16)]` | | **Context** | Consuming a `CONFIG_AGG_BRIDGE` note on the bridge account | | **Panics** | Note sender is not the bridge admin | @@ -196,9 +221,13 @@ Asserts the note sender matches the bridge admin stored in 1. Writes `[0, 0, faucet_id_suffix, faucet_id_prefix] -> [1, 0, 0, 0]` into the `faucet_registry_map` map slot. -2. Hashes `origin_token_addr` (5 felts) using `Poseidon2::hash_elements` and writes - `hash(origin_token_addr) -> [0, 0, faucet_id_suffix, faucet_id_prefix]` into the - `token_registry_map` map slot. +2. Hashes `origin_token_addr` (5 felts) together with `origin_network` (1 felt) using + `Poseidon2::hash_elements` and writes + `hash(origin_token_addr, origin_network) -> [0, 0, faucet_id_suffix, faucet_id_prefix]` + into the `token_registry_map` map slot. The `(origin_network, origin_token_address)` + pair is the canonical asset identity (matching the Solidity `tokenInfoHash`); keying on + the address alone would let a CLAIM bound to one origin network resolve to the faucet of + the same address on another network. #### `bridge_config::update_ger` @@ -223,12 +252,14 @@ Asserts the note sender matches the GER manager stored in | **Inputs** | `[PROOF_DATA_KEY, LEAF_DATA_KEY, faucet_mint_amount, pad(7)]` on the operand stack; proof data and leaf data in the advice map keyed by `PROOF_DATA_KEY` and `LEAF_DATA_KEY` respectively | | **Outputs** | `[pad(16)]` | | **Context** | Consuming a `CLAIM` note on the bridge account | -| **Panics** | GER not known; global index invalid; Merkle proof verification failed; origin token address not in token registry; claim already spent; amount conversion mismatch | +| **Panics** | Leaf `destination_network` does not match `agglayer::common::constants::MIDEN_NETWORK_ID`; invalid leaf type; GER not known; global index invalid; Merkle proof verification failed; (origin token address, origin network) pair not in token registry; claim already spent; amount conversion mismatch | Validates a bridge-in claim and creates a MINT note targeting the faucet: 1. Pipes proof data and leaf data from the advice map into memory, verifying preimage - integrity. + integrity, then asserts the leaf's `destination_network` matches the global + `MIDEN_NETWORK_ID` constant (`asm/agglayer/common/constants.masm`) after `swap_u32_bytes` on + the LE-packed limb (same convention as other AggLayer bridge-in u32 felts in memory). 2. Extracts the destination account ID from the leaf data's destination address (via `eth_address::to_account_id`). 3. Validates the Merkle proof via `verify_leaf_bridge`: computes the leaf @@ -242,8 +273,10 @@ Validates a bridge-in claim and creates a MINT note targeting the faucet: `Poseidon2::hash_elements(leaf_index, source_bridge_network)` to prevent double-claiming. For mainnet deposits, `source_bridge_network = 0`. For rollup deposits, `source_bridge_network = rollup_index + 1`. -6. Looks up the faucet account ID from the origin token address via - `bridge_config::lookup_faucet_by_token_address`. +6. Looks up the faucet account ID from the `(origin_token_address, origin_network)` pair via + `bridge_config::lookup_faucet_by_token_address`. Resolving by the full pair (rather than the + address alone) prevents same-address cross-network collisions where a CLAIM proven on one + origin network could resolve to a faucet registered on another. 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`. @@ -259,7 +292,7 @@ Validates a bridge-in claim and creates a MINT note targeting the faucet: | `agglayer::bridge::let_root_hi` | Value | -- | Upper word of the LET root | LET root high word (Keccak-256 upper 16 bytes) | | `agglayer::bridge::let_num_leaves` | Value | -- | `[count, 0, 0, 0]` | Number of leaves appended to the LET | | `agglayer::bridge::faucet_registry_map` | Map | `[0, 0, faucet_id_suffix, faucet_id_prefix]` | `[1, 0, 0, 0]` if registered | Registered faucet lookup | -| `agglayer::bridge::token_registry_map` | Map | `Poseidon2::hash_elements(origin_token_addr[5])` | `[0, 0, faucet_id_suffix, faucet_id_prefix]` | Origin token address to faucet ID lookup | +| `agglayer::bridge::token_registry_map` | Map | `Poseidon2::hash_elements(origin_token_addr[5] \|\| origin_network)` | `[0, 0, faucet_id_suffix, faucet_id_prefix]` | (Origin token address, origin network) to faucet ID lookup | | `agglayer::bridge::claim_nullifiers` | Map | `Poseidon2::hash_elements(leaf_index, source_bridge_network)` | `[1, 0, 0, 0]` if claimed | Prevents double-claiming of bridge-in deposits | | `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) | @@ -267,7 +300,7 @@ Validates a bridge-in claim and creates a MINT note targeting the faucet: | `agglayer::bridge::ger_manager_account_id` | Value | -- | `[0, 0, mgr_suffix, mgr_prefix]` | GER manager account ID for UPDATE_GER note authorization | Initial state: all map slots empty, all value slots `[0, 0, 0, 0]` except -`admin_account_id` and `ger_manager_account_id` which are set at account creation time. +`admin_account_id` and `ger_manager_account_id` (set at account creation time). ### 3.2 Faucet Account Component @@ -288,14 +321,19 @@ modules in `asm/agglayer/common/`. | | | |-|-| | **Invocation** | `call` | -| **Inputs** | `[amount, tag, note_type, RECIPIENT, pad(9)]` | +| **Inputs** | `[ASSET_KEY, ASSET_VALUE, tag, note_type, RECIPIENT, pad(2)]` | | **Outputs** | `[note_idx, pad(15)]` | | **Context** | Consuming a `MINT` note on the faucet account | -| **Panics** | Faucet owner verification fails; minting exceeds supply | +| **Panics** | Faucet owner verification fails; minting exceeds supply; the asset stored in the MINT note does not belong to the consuming faucet | -Re-export of `miden::standards::faucets::network_fungible::mint_and_send`. Mints the -specified amount and creates an output note with the given recipient. Requires the -faucet's owner (the bridge account) to be the creator of this note (the bridge is stored in `Ownable2Step` storage slot as the owner; the faucet's `mint_and_send` executes the current access policy via `exec.policy_manager::execute_mint_policy`). +Re-export of `miden::standards::faucets::fungible::mint_and_send`. Mints the asset +identified by `ASSET_KEY` / `ASSET_VALUE` and creates an output note with the given +recipient. Requires the faucet's owner (the bridge account) to be the creator of this note +(the bridge is stored in `Ownable2Step` storage slot as the owner; the faucet's +`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). #### `agglayer_faucet::get_metadata_hash` @@ -338,7 +376,7 @@ Converts a Miden-native asset amount to the origin chain's U256 representation: #### `agglayer_faucet::burn` -This is a re-export of `miden::standards::faucets::basic_fungible::burn`. It burns the fungible asset from the active note, decreasing the faucet's token supply. +This is a re-export of `miden::standards::faucets::fungible::burn`. It burns the fungible asset from the active note, decreasing the faucet's token supply. | | | |-|-| @@ -359,7 +397,7 @@ This is a re-export of `miden::standards::faucets::basic_fungible::burn`. It bur | `agglayer::faucet::metadata_hash_hi` | Value | Upper word of the metadata hash | Metadata hash high word (4 u32 felts) | **Companion component storage slots:** The faucet account also includes storage from -companion components required by `network_fungible::mint_and_send`: +companion components required by `fungible::mint_and_send`: - `Ownable2Step` owner config slot: stores the bridge account ID as owner. - `OwnerControlled` slots (3): `active_policy_proc_root`, `allowed_policy_proc_roots`, @@ -402,7 +440,7 @@ Keccak preimage format directly — the felt value does **not** equal the numeri | Field | Value | |-------|-------| | `serial_num` | Random (`rng.draw_word()`) | -| `script` | `B2AGG.masl` | +| `script` | `b2agg.masm` | | `storage` | 6 felts -- see layout below | **Storage layout (6 felts):** @@ -461,7 +499,7 @@ token registry, and creates a MINT note targeting the faucet. | Field | Value | |-------|-------| | `serial_num` | Random (`rng.draw_word()`) | -| `script` | `CLAIM.masl` | +| `script` | `claim.masm` | | `storage` | 569 felts -- see layout below | **Storage layout (569 felts):** @@ -495,9 +533,9 @@ The storage is divided into three logical regions: proof data (felts 0-535), lea advice map as two keyed entries (`PROOF_DATA_KEY`, `LEAF_DATA_KEY`). 4. The `miden_claim_amount` is read from memory. 5. `bridge_in::claim` is called with `[PROOF_DATA_KEY, LEAF_DATA_KEY, miden_claim_amount]` - on the stack. The bridge validates the proof, checks the claim nullifier, looks up the - faucet via the token registry, verifies the amount conversion, then builds a MINT - output note targeting the faucet. + on the stack. The bridge asserts the leaf's `destination_network` matches the global + `MIDEN_NETWORK_ID` MASM constant, validates the proof, checks the claim nullifier, looks up the faucet via the token + registry, verifies the amount conversion, then builds a MINT output note targeting the faucet. #### Permissions @@ -530,16 +568,17 @@ The storage is divided into three logical regions: proof data (felts 0-535), lea | Field | Value | |-------|-------| | `serial_num` | Random (`rng.draw_word()`) | -| `script` | `CONFIG_AGG_BRIDGE.masl` | -| `storage` | 7 felts -- see layout below | +| `script` | `config_agg_bridge.masm` | +| `storage` | 8 felts -- see layout below | -**Storage layout (7 felts):** +**Storage layout (8 felts):** | Index | Field | Encoding | |-------|-------|----------| | 0-4 | `origin_token_addr` | 5 x u32 felts (20-byte Ethereum address) | -| 5 | `faucet_id_suffix` | Felt (AccountId suffix) | -| 6 | `faucet_id_prefix` | Felt (AccountId prefix) | +| 5 | `origin_network` | Felt (LE-packed u32 origin network identifier) | +| 6 | `faucet_id_suffix` | Felt (AccountId suffix) | +| 7 | `faucet_id_prefix` | Felt (AccountId prefix) | **Consumption:** Script validates attachment target, loads storage, and calls `bridge_config::register_faucet` (which asserts sender is bridge admin and performs @@ -577,7 +616,7 @@ CLAIM notes can be verified against it. | Field | Value | |-------|-------| | `serial_num` | Random (`rng.draw_word()`) | -| `script` | `UPDATE_GER.masl` | +| `script` | `update_ger.masm` | | `storage` | 8 felts -- see layout below | **Storage layout (8 felts):** @@ -716,44 +755,45 @@ to mint and distribute assets to the recipient. |-------|-------| | `serial_num` | Derived from `PROOF_DATA_KEY` (Poseidon2 hash of the CLAIM proof data) | | `script` | Standard MINT script (`miden::standards::notes::mint::main`) | -| `storage` | 18 felts -- see layout below | +| `storage` | 22 felts -- see layout below | -**Storage layout (18 felts):** +**Storage layout (22 felts):** | Index | Field | Encoding | |-------|-------|----------| -| 0 | `tag` | Note tag for the P2ID output note (targeting the destination account) | -| 1 | `amount` | Scaled-down Miden token amount to mint | -| 2 | `attachment_kind` | 0 (none - the inner P2ID note has no attachment) | -| 3 | `attachment_scheme` | 0 (none) | -| 4-7 | `attachment` | `[0, 0, 0, 0]` (empty) | -| 8-11 | `p2id_script_root` | Script root of the P2ID note | -| 12-15 | `serial_num` | Serial number for the P2ID note (same as PROOF_DATA_KEY) | -| 16 | `account_id_suffix` | Destination account suffix | -| 17 | `account_id_prefix` | Destination account prefix | +| 0-3 | `P2ID_SCRIPT_ROOT` | Script root of the P2ID output note | +| 4-7 | `SERIAL_NUM` | Serial number for the P2ID note (same as PROOF_DATA_KEY) | +| 8-11 | `ASSET_KEY` | Vault key of the fungible asset to mint (faucet ID baked in) | +| 12-15 | `ASSET_VALUE` | Value word of the asset: `[native_amount, 0, 0, 0]` | +| 16 | `dest_tag` | Note tag for the P2ID output note (targeting the destination account) | +| 17-19 | padding | Zeros so the P2ID storage below stays word-aligned | +| 20 | `account_id_suffix` | Destination account suffix | +| 21 | `account_id_prefix` | Destination account prefix | **Consumption:** -The standard MINT script for public note creation loads the 18 storage items from the MINT note note storage and calls the faucet's -`mint_and_send` procedure (re-exported from `network_fungible::mint_and_send`). - -Before minting, `mint_and_send` executes the active mint policy via -`policy_manager::execute_mint_policy`. For AggLayer faucets, the active policy is -`owner_controlled::owner_only`, which calls `ownable2step::assert_sender_is_owner`. This -asserts that the MINT note's sender matches the faucet's owner (the bridge account, set -via the `Ownable2Step` companion component at account creation time). This ensures only -the bridge can trigger minting on the faucet. - -After the policy check passes, `mint_and_send` mints the specified amount and creates a -P2ID output note for the recipient using the storage items (script root, serial number, -destination account ID, tag). +The standard MINT script for public note creation loads the 22 storage items from the MINT +note storage, reconstructs the P2ID `RECIPIENT` from `P2ID_SCRIPT_ROOT`, `SERIAL_NUM`, and +the P2ID storage at `[20..21]`, and calls the faucet's `mint_and_send` procedure +(re-exported from `fungible::mint_and_send`) with the stored `ASSET_KEY`, `ASSET_VALUE`, +`dest_tag`, and `RECIPIENT`. + +`mint_and_send` saves the supplied `ASSET_KEY` in a local, runs the active mint policy +(`owner_controlled::owner_only` for AggLayer faucets, which asserts the MINT note's sender +is the faucet's owner -- the bridge account, set via `Ownable2Step` at account creation), +and creates the skeleton P2ID output note via `output_note::create`. It 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 issuing faucet: a MINT note whose `ASSET_KEY` +was resolved for faucet A cannot be consumed by any other faucet B even if both share the +bridge as owner. Once the bind passes, the minted asset is attached to the P2ID output +note via `output_note::add_asset`. #### Permissions | Role | Enforcement | |------|------------| | **Issuer** | Bridge account only -- **enforced** by faucet's `owner_only` mint policy via `Ownable2Step` (asserts note sender is the faucet's owner, i.e. the bridge) | -| **Consumer** | Target faucet only -- **enforced** via `NetworkAccountTarget` attachment | +| **Consumer** | Target faucet only -- **enforced** by `mint_and_send`, which panics if the stored `ASSET_KEY` does not belong to the consuming faucet. The `NetworkAccountTarget` attachment is retained as the network-routing primitive and is not a consume-side bind | --- @@ -772,7 +812,7 @@ between them, as implemented in Rust (`eth_types/address.rs`) and MASM ### 6.1 Background -Miden's `AccountId` (version 0) consists of two Goldilocks field elements: +Miden's `AccountId` (version 1) consists of two Goldilocks field elements: ```text prefix: [hash (56 bits) | storage_mode (2 bits) | type (2 bits) | version (4 bits)] @@ -988,60 +1028,110 @@ Terminology: deployed wrapped ERC20 contract. A faucet must be registered in the [Bridge Contract](#31-bridge-account-component) before it can participate in bridging. The -bridge maintains two registry maps: +bridge maintains three registry maps, all keyed by faucet account ID and populated atomically +by `bridge_config::register_faucet` during the [`CONFIG_AGG_BRIDGE`](#43-config_agg_bridge) note +consumption: - **Faucet registry** (`agglayer::bridge::faucet_registry_map`): maps faucet account IDs - to a registration flag. Used during bridge-out to verify an asset's faucet is authorized - (see `bridge_config::assert_faucet_registered`). + to a registration value `[1, is_native, 0, 0]`. Used during bridge-out to verify an + asset's faucet is authorized (`bridge_config::assert_faucet_registered`) and, via the + `is_native` flag, to branch between burn/lock on bridge-out and mint/unlock on bridge-in. - **Token registry** (`agglayer::bridge::token_registry_map`): maps Poseidon2 hashes of - native token addresses to faucet account IDs. Used during bridge-in to look up the - correct faucet for a given origin token (see - `bridge_config::lookup_faucet_by_token_address`). - -Both registries are populated atomically by `bridge_config::register_faucet` during the [`CONFIG_AGG_BRIDGE`](#43-config_agg_bridge) note consumption. - -### 7.1 Bridging-in: Registering non-native faucets on Miden - -When a new ERC20 token is bridged to Miden for the first time, a corresponding AggLayer -faucet account must be created and registered. The faucet serves as the mint/burn -authority for the wrapped token on Miden. - -The `AggLayerFaucet` struct (Rust, `src/faucet.rs`) captures the faucet-specific -configuration: - -- Token metadata: symbol, decimals, max_supply, token_supply (TODO Missing information about the token name ([#2585](https://github.com/0xMiden/protocol/issues/2585))) -- Origin token address: the ERC20 contract address on the origin chain -- Origin network: the chain ID of the origin chain -- Scale factor: the exponent used to convert between EVM U256 amounts and Field elements on Miden -- Metadata hash: `keccak256(abi.encode(name, symbol, decimals))`. This is precomputed by the bridge admin at faucet creation time and is currently not verified onchain (TODO Verify metadata hash onchain ([#2586](https://github.com/0xMiden/protocol/issues/2586))) + the `(origin_token_address, origin_network)` pair to faucet account IDs. Used during + bridge-in to look up the correct faucet for a given origin asset + (`bridge_config::lookup_faucet_by_token_address`). Keying on the pair (rather than the + address alone) matches the canonical asset identity used by Solidity's + `tokenInfoHash = keccak256(abi.encodePacked(originNetwork, originTokenAddress))` and + prevents same-address cross-network collisions. +- **Faucet metadata map** (`agglayer::bridge::faucet_metadata_map`): stores all conversion + metadata — origin address, origin network, scale, and the precomputed + `keccak256(abi.encode(name, symbol, decimals))` metadata hash — for every registered + faucet. A single map with four sub-keys per faucet ID is enough to hold the full set: + + | Sub-key | Value | + | ------------------------------------------ | ---------------------------------------------- | + | `[0, 0, faucet_id_suffix, faucet_id_prefix]` | `[addr0, addr1, addr2, addr3]` | + | `[1, 0, faucet_id_suffix, faucet_id_prefix]` | `[addr4, origin_network, scale, 0]` | + | `[2, 0, faucet_id_suffix, faucet_id_prefix]` | `[mh_lo0, mh_lo1, mh_lo2, mh_lo3]` | + | `[3, 0, faucet_id_suffix, faucet_id_prefix]` | `[mh_hi0, mh_hi1, mh_hi2, mh_hi3]` | + + The metadata map lets `bridge_out` and `bridge_in` read conversion data from bridge-local + storage rather than issuing foreign-procedure-invocation (FPI) calls into the faucet; this + is required for native-token support, where the faucet is not under the bridge's control + and does not necessarily expose any AggLayer-specific procedures. + +### 7.1 Registering faucets on Miden + +Every faucet that participates in bridging — whether it represents a wrapped foreign token +or a Miden-native token — must be registered in the bridge's three registries before it can +be referenced by a `B2AGG` (bridge-out) or `CLAIM` (bridge-in) note. Registration is the +same flow for both kinds; the `is_native` flag in the `CONFIG_AGG_BRIDGE` note storage tells +the bridge which dispatch path to take for each future bridge operation against that faucet. + +The `AggLayerFaucet` Rust struct (`src/faucet.rs`) holds only +token metadata — symbol, decimals, max supply, and token supply +(TODO Missing token name ([#2585](https://github.com/0xMiden/protocol/issues/2585))). +Conversion metadata (origin address, origin network, scale, and metadata hash) is +*not* stored on the faucet; it is carried by the `CONFIG_AGG_BRIDGE` note at registration +time and written directly into the bridge's `faucet_metadata_map`. The metadata hash is +precomputed by the bridge admin and is currently not verified onchain +(TODO Verify metadata hash onchain ([#2586](https://github.com/0xMiden/protocol/issues/2586))). Registration is performed via [`CONFIG_AGG_BRIDGE`](#43-config_agg_bridge) notes. The bridge -operator creates a `CONFIG_AGG_BRIDGE` note containing the faucet's account ID and the -origin token address, then sends it to the bridge account. On consumption, the note -script calls `bridge_config::register_faucet`, which performs a two-step registration: - -1. Writes a registration flag under the faucet ID key in the `faucet_registry_map`: - `[0, 0, faucet_id_suffix, faucet_id_prefix]` -> `[1, 0, 0, 0]`. -2. Hashes the origin token address using Poseidon2 and writes - the mapping into the `token_registry_map`: - `hash(origin_token_addr)` -> `[0, 0, faucet_id_suffix, faucet_id_prefix]`. - -The token registry enables the bridge to resolve which Miden-side faucet corresponds to a given -origin token address during CLAIM note processing. When the bridge -processes a [`CLAIM`](#42-claim) note, it reads the origin token address from the leaf data and calls -`bridge_config::lookup_faucet_by_token_address` to find the registered faucet. This -lookup hashes the address with Poseidon2 and retrieves the faucet ID from the token -registry map. If the token address is not registered, the `CLAIM` note consumption will fail. - -This means that the bridge admin must register the faucet on the Miden side before the corresponding tokens can be bridged in. - -The bridge admin is a trusted role, and is the sole entity that can register faucets on the Miden side (due to the caller restriction on [`bridge_config::register_faucet`](#bridge_configregister_faucet)). - -### 7.2 Bridging-out: How Miden-native tokens are registered on other chains - -When an asset is bridged out from Miden, [`bridge_out::bridge_out`](#bridge_outbridge_out) constructs a leaf for -the Local Exit Tree. The leaf includes the metadata hash, which the bridge fetches from -the faucet via FPI (`agglayer_faucet::get_metadata_hash`), as well as the other leaf data fields, including origin network and origin token address. +operator creates a `CONFIG_AGG_BRIDGE` note carrying the faucet's account ID, origin token +address, origin network, scale, metadata hash, and the `is_native` flag, then sends it to +the bridge. On consumption, the note script calls `bridge_config::register_faucet` (plus +`store_faucet_metadata_hash` for the metadata hash — split into two calls because the +16-element MASM stack cannot fit all 18 registration felts at once). These procedures +perform the following writes: + +1. `faucet_registry_map`: `[0, 0, faucet_id_suffix, faucet_id_prefix]` → `[1, is_native, 0, 0]`. +2. `faucet_metadata_map`: origin-address + origin-network + scale under sub-keys + `[0, 0, fid_s, fid_p]` and `[1, 0, fid_s, fid_p]`; metadata hash (lo/hi) under + `[2, 0, fid_s, fid_p]` and `[3, 0, fid_s, fid_p]`. +3. `token_registry_map`: `Poseidon2(origin_token_addr, origin_network)` → `[0, 0, fid_s, fid_p]`. + Keying on the pair (not the address alone) matches Solidity's `tokenInfoHash` and + prevents same-address cross-network collisions. + +The token registry enables the bridge to resolve which Miden-side faucet corresponds to a +given origin asset during CLAIM note processing. When the bridge processes a +[`CLAIM`](#42-claim) note, it reads the origin token address and origin network from the leaf +data and calls `bridge_config::lookup_faucet_by_token_address` to find the registered +faucet. If the `(origin_token_address, origin_network)` pair is not registered, the `CLAIM` +note consumption will fail. + +The bridge admin is a trusted role, and is the sole entity that can register faucets on +the Miden side (enforced by the caller restriction on +[`bridge_config::register_faucet`](#bridge_configregister_faucet)). + +#### Wrapped (`is_native = false`) vs Miden-native (`is_native = true`) faucets + +The difference between the two kinds is what they represent and how the bridge dispatches +operations against them — *not* how they are registered. + +- **Wrapped faucets** represent a foreign ERC20 token bridged into Miden. The bridge holds + mint/burn authority for the faucet, so a bridge-in CLAIM emits a `MINT` note that the + faucet consumes to mint the wrapped asset, and a bridge-out B2AGG emits a `BURN` note that + the faucet consumes to destroy it. For these faucets, `origin_token_address` is the + foreign EVM token address. +- **Miden-native faucets** represent a Miden-native fungible asset that is being made + bridgeable. The bridge does *not* own the faucet and cannot mint or burn through it, so it + uses lock/unlock semantics instead: bridge-out adds the asset to the bridge's own vault + (`lock_asset`), and bridge-in claims the same token by removing the asset from the vault + and emitting a `P2ID` note directly (`unlock_and_send`). For these faucets, + `origin_token_address` is the faucet's own `AccountId` in the [Embedded + Format](#62-embedded-format), and `origin_network` is Miden's own network ID. + +In both cases the bridge admin drives registration via the same `CONFIG_AGG_BRIDGE` note; +the bridge admin is responsible for setting `is_native` correctly for the faucet at hand. + +### 7.2 Bridging-out: How tokens are registered on other chains + +When an asset is bridged out from Miden, [`bridge_out::bridge_out`](#bridge_outbridge_out) +constructs a leaf for the Local Exit Tree. The metadata hash, origin token address, origin +network, and scale factor are all read from the bridge's local `faucet_metadata_map` +(`bridge_config::get_faucet_conversion_info` and `bridge_config::get_faucet_metadata_hash`). +No FPI into the faucet is required — the bridge is fully self-contained for conversion data. On the EVM destination chain, when a user claims the bridged asset via `PolygonZkEVMBridgeV2.claimAsset()`, the wrapped token is deployed lazily on first claim. @@ -1051,22 +1141,35 @@ as a parameter to `claimAsset()`. The EVM bridge verifies that no wrapped token exists yet, the bridge deploys a new `TokenWrapped` ERC20 using the decoded name, symbol, and decimals from the metadata bytes. -#### Miden-native faucets - -A Miden-native faucet uses the same storage -layout and registration flow as a wrapped faucet. The key difference is what values are -stored in the conversion metadata: +For Miden-native faucets, the registered metadata uses: - `origin_token_address`: the faucet's own `AccountId` as per the [Embedded Format](#62-embedded-format). - `origin_network`: Miden's network ID as assigned by AggLayer (currently unassigned). -- `metadata_hash`: `keccak256(abi.encode(name, symbol, decimals))` - same as for wrapped +- `metadata_hash`: `keccak256(abi.encode(name, symbol, decimals))` — same as for wrapped faucets. -On the EVM side, `claimAsset()` sees `originNetwork != networkID` (foreign asset), so it -follows the wrapped token path: computes +On the EVM side, `claimAsset()` sees `originNetwork != networkID` (foreign asset) for a +Miden-native token, so it follows the wrapped token path: computes `tokenInfoHash = keccak256(abi.encodePacked(originNetwork, originTokenAddress))`, and deploys a new `TokenWrapped` ERC20 via `CREATE2` on first claim, minting on subsequent -claims. The `CREATE2` salt is `tokenInfoHash`, so the wrapper address is deterministic -from the `(originNetwork, originTokenAddress)` pair. The metadata bytes provided by the -claimer (which must hash to the leaf's `metadataHash`) are used to initialize the wrapped -token's name, symbol, and decimals. +claims. + +### 7.3 Native vs non-native paths on the Miden side + +The `is_native` flag recorded in `faucet_registry_map` splits the bridge's own Miden-side +behavior on both directions: + +| Direction | `is_native = false` (wrapped / foreign) | `is_native = true` (Miden-native) | +| ------------ | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| Bridge-out | `bridge_out::create_burn_note` — emits a BURN note consumed by the faucet. | `bridge_out::lock_asset` — `native_account::add_asset` locks the asset in the bridge vault. No BURN note is emitted. | +| Bridge-in | `bridge_in_output::build_mint_output_note` — emits a MINT note consumed by the faucet. | `bridge_in_output::unlock_and_send` — `native_account::remove_asset` unlocks from the vault, then emits a P2ID note directly to the recipient. No MINT note is emitted. | + +The LET leaf is constructed identically in both bridge-out branches. The native branch +does not require the bridge to be the faucet's owner, and `ownable2step::assert_sender_is_owner` +is not invoked on the native path. The P2ID note emitted by `unlock_and_send` uses the +`PROOF_DATA_KEY` as its serial number, which makes the note commitment deterministic for +a given claim and prevents double-spend within the same claim. + +This mirrors `PolygonZkEVMBridgeV2.claimAsset()`'s handling of +`originNetwork == networkID`: the EVM bridge transfers native tokens from / to its own +balance instead of minting / burning them via the token contract. diff --git a/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm b/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm index 4df7db2aac..15123668f2 100644 --- a/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm +++ b/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm @@ -3,13 +3,14 @@ use miden::protocol::account_id use miden::protocol::active_account use miden::protocol::active_note use miden::protocol::native_account +use agglayer::common::utils # ERRORS # ================================================================================================= const ERR_GER_NOT_FOUND = "GER not found in storage" const ERR_FAUCET_NOT_REGISTERED = "faucet is not registered in the bridge's faucet registry" -const ERR_TOKEN_NOT_REGISTERED = "token address is not registered in the bridge's token 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" @@ -22,21 +23,37 @@ const GER_MANAGER_SLOT = word("agglayer::bridge::ger_manager_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") # Flags -const GER_KNOWN_FLAG = 1 -const IS_FAUCET_REGISTERED_FLAG = 1 +const GER_KNOWN_FLAG = [1, 0, 0, 0] +const FAUCET_REGISTERED_FLAG = 1 # Offset in the local memory of the `hash_token_address` procedure const TOKEN_ADDR_HASH_PTR = 0 +# Local memory slot offsets used inside the `register_faucet` procedure +const REG_TOKEN_HASH_LOC = 0 +const REG_FAUCET_ID_SUFFIX_LOC = 4 +const REG_FAUCET_ID_PREFIX_LOC = 5 +const REG_SCALE_LOC = 6 +const REG_ORIGIN_NETWORK_LOC = 7 +const REG_IS_NATIVE_LOC = 8 + +# faucet_metadata_map sub-keys (used as the first element of the 4-felt KEY). +# Each sub-key indexes a different word of metadata for a given faucet ID. +const FAUCET_METADATA_SUBKEY_ADDR_LO = 0 # [addr0, addr1, addr2, addr3] +const FAUCET_METADATA_SUBKEY_ADDR_HI = 1 # [addr4, origin_network, scale, 0] +const FAUCET_METADATA_SUBKEY_HASH_LO = 2 # METADATA_HASH_LO[4] +const FAUCET_METADATA_SUBKEY_HASH_HI = 3 # METADATA_HASH_HI[4] + # PUBLIC INTERFACE # ================================================================================================= #! Updates the Global Exit Root (GER) in the bridge account storage. #! #! Computes hash(GER) = poseidon2::merge(GER_LOWER, GER_UPPER) and stores it in a map with value -#! [GER_KNOWN_FLAG, 0, 0, 0] to indicate the GER is known. +#! GER_KNOWN_FLAG to indicate the GER is known. #! #! Inputs: [GER_LOWER[4], GER_UPPER[4], pad(8)] #! Outputs: [pad(16)] @@ -54,9 +71,9 @@ pub proc update_ger exec.poseidon2::merge # => [GER_HASH, pad(12)] - # prepare VALUE = [GER_KNOWN_FLAG, 0, 0, 0] - push.0.0.0.GER_KNOWN_FLAG - # => [GER_KNOWN_FLAG, 0, 0, 0, GER_HASH, pad(12)] + # prepare VALUE = GER_KNOWN_FLAG + push.GER_KNOWN_FLAG + # => [GER_KNOWN_FLAG, GER_HASH, pad(12)] swapw # => [GER_HASH, VALUE, pad(12)] @@ -83,7 +100,7 @@ end #! - the GER is not found in storage. #! #! Invocation: exec -proc assert_valid_ger +pub proc assert_valid_ger # compute hash(GER) exec.poseidon2::merge # => [GER_HASH] @@ -94,78 +111,169 @@ proc assert_valid_ger exec.active_account::get_map_item # => [VALUE] - # assert the GER is known in storage (VALUE = [GER_KNOWN_FLAG, 0, 0, 0]) - push.0.0.0.GER_KNOWN_FLAG - # => [GER_KNOWN_FLAG, 0, 0, 0, VALUE] + # assert the GER is known in storage (VALUE = GER_KNOWN_FLAG) + push.GER_KNOWN_FLAG + # => [GER_KNOWN_FLAG, VALUE] assert_eqw.err=ERR_GER_NOT_FOUND # => [] end -#! Registers a faucet in the bridge's faucet registry and token registry. +#! Registers a faucet in the bridge's faucet registry, token registry, and metadata map. +#! +#! Stores conversion metadata for the faucet using a sub-key scheme in faucet_metadata_map: +#! 1. KEY [0, 0, faucet_id_suffix, faucet_id_prefix] -> [addr0, addr1, addr2, addr3] (origin address part 1) +#! 2. KEY [1, 0, faucet_id_suffix, faucet_id_prefix] -> [addr4, origin_network, scale, 0] (origin address part 2) #! -#! 1. Writes `KEY -> [1, 0, 0, 0]` into the `faucet_registry` map, where -#! `KEY = [0, 0, faucet_id_suffix, faucet_id_prefix]`. -#! 2. Writes `hash(tokenAddress[5]) -> [faucet_id_suffix, faucet_id_prefix, 0, 0]` into the -#! `token_registry` map. +#! Also registers: +#! 3. faucet_registry_map: [0, 0, faucet_id_suffix, faucet_id_prefix] -> [1, is_native, 0, 0] +#! 4. token_registry_map: hash(tokenAddress[5] || origin_network) -> [0, 0, faucet_id_suffix, faucet_id_prefix]. +#! The (origin_network, origin_token_address) pair is the canonical asset identity; +#! keying on the address alone would collide across networks. #! -#! Inputs: [origin_token_addr(5), faucet_id_suffix, faucet_id_prefix, pad(9)] +#! Inputs: [origin_token_addr(5), faucet_id_suffix, faucet_id_prefix, scale, origin_network, is_native, pad(6)] #! Outputs: [pad(16)] #! #! Panics if: #! - the note sender is not the bridge admin. #! #! Invocation: call +@locals(14) pub proc register_faucet - # assert the note sender is the bridge admin. exec.assert_sender_is_bridge_admin - # => [origin_token_addr(5), faucet_id_suffix, faucet_id_prefix, pad(9)] + # => [addr0, addr1, addr2, addr3, addr4, faucet_id_suffix, faucet_id_prefix, scale, origin_network, is_native, pad(6)] + + # Save non-address data to locals. + movup.5 loc_store.REG_FAUCET_ID_SUFFIX_LOC + movup.5 loc_store.REG_FAUCET_ID_PREFIX_LOC + movup.5 loc_store.REG_SCALE_LOC + movup.5 loc_store.REG_ORIGIN_NETWORK_LOC + movup.5 loc_store.REG_IS_NATIVE_LOC + # => [addr0, addr1, addr2, addr3, addr4, pad(11)] + + # Duplicate the 5-felt address for hashing before it gets consumed. + repeat.5 + dup.4 + end + # => [addr0, addr1, addr2, addr3, addr4, addr0, addr1, addr2, addr3, addr4, pad(11)] + + # Push origin_network to position 5 so hash_token_address sees [addr(5), origin_network]. + # Per the agglayer #2860 fix, the token registry is keyed on the (address, network) pair so + # that the same EVM address on two different chains resolves to two distinct faucets. + # + # Byte-swap origin_network before hashing so the felt matches the leaf-side representation + # used by `lookup_faucet_by_token_address` (`claim_note.rs` LE-packs the leaf's + # origin_network felt). `faucet_metadata_map` keeps origin_network in raw form, so this swap + # is a hash-input-only conversion. + loc_load.REG_ORIGIN_NETWORK_LOC exec.utils::swap_u32_bytes movdn.5 + # => [addr0, addr1, addr2, addr3, addr4, origin_network_swapped, addr0, addr1, addr2, addr3, addr4, pad(11)] - # Save faucet ID for later use in token_registry - dup.6 dup.6 - # => [faucet_id_suffix, faucet_id_prefix, origin_token_addr(5), - # faucet_id_suffix, faucet_id_prefix, pad(9)] + exec.hash_token_address + # => [TOKEN_ADDR_HASH, addr0, addr1, addr2, addr3, addr4, pad(11)] - # --- 1. Register faucet in faucet_registry --- + loc_storew_le.REG_TOKEN_HASH_LOC dropw + # => [addr0, addr1, addr2, addr3, addr4, pad(11)] - # set_map_item expects [slot_id(2), KEY, VALUE] and returns [OLD_VALUE]. - # Build KEY = [0, 0, suffix, prefix] and VALUE = [IS_FAUCET_REGISTERED_FLAG, 0, 0, 0] - push.0.0.0.IS_FAUCET_REGISTERED_FLAG - # => [IS_FAUCET_REGISTERED_FLAG, 0, 0, 0, - # faucet_id_suffix, faucet_id_prefix, origin_token_addr(5), - # faucet_id_suffix, faucet_id_prefix, pad(9)] + # --- Step 1: Store origin address part 1 in faucet_metadata_map --- + # KEY = [0, 0, faucet_id_suffix, faucet_id_prefix], VALUE = [addr0, addr1, addr2, addr3] - movup.5 movup.5 push.0.0 - # => [ - # [0, 0, faucet_id_suffix, faucet_id_prefix], - # [IS_FAUCET_REGISTERED_FLAG, 0, 0, 0], - # origin_token_addr(5), faucet_id_suffix, faucet_id_prefix, pad(9) - # ] + loc_load.REG_FAUCET_ID_PREFIX_LOC loc_load.REG_FAUCET_ID_SUFFIX_LOC + push.0.FAUCET_METADATA_SUBKEY_ADDR_LO + # => [SUBKEY_ADDR_LO, 0, faucet_id_suffix, faucet_id_prefix, addr0, addr1, addr2, addr3, addr4, pad(11)] - push.FAUCET_REGISTRY_MAP_SLOT[0..2] + push.FAUCET_METADATA_MAP_SLOT[0..2] exec.native_account::set_map_item - # => [OLD_VALUE, origin_token_addr(5), faucet_id_suffix, faucet_id_prefix, pad(9)] + dropw + # => [addr4, pad(15)] + + # --- Step 2: Store origin address part 2 + origin_network + scale --- + # KEY = [SUBKEY_ADDR_HI, 0, faucet_id_suffix, faucet_id_prefix], VALUE = [addr4, origin_network, scale, 0] + push.0 + loc_load.REG_SCALE_LOC + loc_load.REG_ORIGIN_NETWORK_LOC + movup.3 + # => [addr4, origin_network, scale, 0, pad(15)] + + loc_load.REG_FAUCET_ID_PREFIX_LOC loc_load.REG_FAUCET_ID_SUFFIX_LOC + # => [faucet_id_suffix, faucet_id_prefix, addr4, origin_network, scale, 0, pad(15)] + + push.0.FAUCET_METADATA_SUBKEY_ADDR_HI + # => [SUBKEY_ADDR_HI, 0, faucet_id_suffix, faucet_id_prefix, addr4, origin_network, scale, 0, pad(15)] + + push.FAUCET_METADATA_MAP_SLOT[0..2] + exec.native_account::set_map_item dropw - # => [origin_token_addr(5), faucet_id_suffix, faucet_id_prefix, pad(9)] + # => [pad(16)] - # --- 2. Register token address → faucet ID in token_registry --- + # --- Step 3: Store [1, is_native, 0, 0] in faucet_registry_map --- + # KEY = [0, 0, faucet_id_suffix, faucet_id_prefix], VALUE = [1, is_native, 0, 0] + # The trailing [0, 0] of VALUE is supplied by the stack's bottom pads. - # Hash the token address - exec.hash_token_address - # => [TOKEN_ADDR_HASH, faucet_id_suffix, faucet_id_prefix, pad(10)] + loc_load.REG_IS_NATIVE_LOC push.FAUCET_REGISTERED_FLAG + # => [1, is_native, pad(16)] - # Build VALUE = [0, 0, faucet_id_suffix, faucet_id_prefix] - movup.5 movup.5 push.0.0 - # => [0, 0, faucet_id_suffix, faucet_id_prefix, TOKEN_ADDR_HASH, pad(10)] + loc_load.REG_FAUCET_ID_PREFIX_LOC loc_load.REG_FAUCET_ID_SUFFIX_LOC push.0.0 + # => [0, 0, faucet_id_suffix, faucet_id_prefix, 1, is_native, pad(16)] - swapw - # => [TOKEN_ADDR_HASH, 0, 0, faucet_id_suffix, faucet_id_prefix, pad(10)] + push.FAUCET_REGISTRY_MAP_SLOT[0..2] + exec.native_account::set_map_item + dropw + # => [pad(16)] + + # --- Step 4: Store TOKEN_ADDR_HASH -> [0, 0, faucet_id_suffix, faucet_id_prefix] in token_registry --- + + loc_load.REG_FAUCET_ID_PREFIX_LOC loc_load.REG_FAUCET_ID_SUFFIX_LOC + # => [faucet_id_suffix, faucet_id_prefix, pad(16)] + + push.0.0 + # => [0, 0, faucet_id_suffix, faucet_id_prefix, pad(16)] + + padw loc_loadw_le.REG_TOKEN_HASH_LOC + # => [TOKEN_ADDR_HASH, 0, 0, faucet_id_suffix, faucet_id_prefix, pad(16)] push.TOKEN_REGISTRY_MAP_SLOT[0..2] exec.native_account::set_map_item - # => [OLD_VALUE, pad(12)] + dropw + # => [pad(16)] +end + +#! Stores the metadata hash for a registered faucet in the bridge's faucet metadata map. +#! +#! This is the second call in the faucet registration flow (called after register_faucet). +#! Stores the metadata hash using sub-keys 2 and 3 in faucet_metadata_map: +#! - KEY [2, 0, faucet_id_suffix, faucet_id_prefix] -> METADATA_HASH_LO +#! - KEY [3, 0, faucet_id_suffix, faucet_id_prefix] -> METADATA_HASH_HI +#! +#! Inputs: [faucet_id_suffix, faucet_id_prefix, METADATA_HASH_LO, METADATA_HASH_HI, pad(6)] +#! Outputs: [pad(16)] +#! +#! Panics if: +#! - the note sender is not the bridge admin. +#! +#! Invocation: call +pub proc store_faucet_metadata_hash + exec.assert_sender_is_bridge_admin + # => [faucet_id_suffix, faucet_id_prefix, MH_LO, MH_HI, pad(6)] + # --- Store METADATA_HASH_LO at key [SUBKEY_HASH_LO, 0, faucet_id_suffix, faucet_id_prefix] --- + dup.1 dup.1 swapw movup.5 movup.5 + # => [faucet_id_suffix, faucet_id_prefix, MH_LO, faucet_id_suffix, faucet_id_prefix, MH_HI, pad(6)] + + push.0.FAUCET_METADATA_SUBKEY_HASH_LO + # => [SUBKEY_HASH_LO, 0, faucet_id_suffix, faucet_id_prefix, MH_LO, faucet_id_suffix, faucet_id_prefix, MH_HI, pad(6)] + + push.FAUCET_METADATA_MAP_SLOT[0..2] + exec.native_account::set_map_item + dropw + # => [faucet_id_suffix, faucet_id_prefix, MH_HI, pad(6)] + + # --- Store METADATA_HASH_HI at key [SUBKEY_HASH_HI, 0, faucet_id_suffix, faucet_id_prefix] --- + push.0.FAUCET_METADATA_SUBKEY_HASH_HI + # => [SUBKEY_HASH_HI, 0, faucet_id_suffix, faucet_id_prefix, MH_HI, pad(6)] + + push.FAUCET_METADATA_MAP_SLOT[0..2] + exec.native_account::set_map_item dropw # => [pad(16)] end @@ -173,6 +281,7 @@ end #! Asserts that a faucet is registered in the bridge's faucet registry. #! #! Looks up the faucet ID in the faucet registry map and asserts the registration flag is set. +#! The stored value is [is_registered, is_native, 0, 0]. #! #! Inputs: [faucet_id_suffix, faucet_id_prefix] #! Outputs: [] @@ -181,7 +290,7 @@ end #! - the faucet is not registered in the faucet registry. #! #! Invocation: exec -proc assert_faucet_registered +pub proc assert_faucet_registered # Build KEY = [0, 0, faucet_id_suffix, faucet_id_prefix] push.0.0 # => [0, 0, faucet_id_suffix, faucet_id_prefix] @@ -190,24 +299,144 @@ proc assert_faucet_registered exec.active_account::get_map_item # => [VALUE] - # the stored word must be [1, 0, 0, 0] for registered faucets + # the stored word is [1, is_native, 0, 0] for registered faucets + # assert element 0 (registration flag) equals "1" assert.err=ERR_FAUCET_NOT_REGISTERED drop drop drop # => [] end -#! Looks up the faucet account ID for a given origin token address. +#! Returns the scale factor for a registered faucet from the bridge's faucet metadata map. +#! +#! Reads the metadata from the faucet_metadata_map at key [1, 0, faucet_id_suffix, faucet_id_prefix]. +#! The stored value is [addr4, origin_network, scale, 0]. +#! +#! Inputs: [faucet_id_suffix, faucet_id_prefix] +#! Outputs: [scale] +#! +#! Invocation: exec +pub proc get_faucet_scale + # Build KEY = [SUBKEY_ADDR_HI, 0, faucet_id_suffix, faucet_id_prefix] + push.0.FAUCET_METADATA_SUBKEY_ADDR_HI + # => [SUBKEY_ADDR_HI, 0, faucet_id_suffix, faucet_id_prefix] + + push.FAUCET_METADATA_MAP_SLOT[0..2] + exec.active_account::get_map_item + # => [addr4, origin_network, scale, 0] + + drop drop swap drop + # => [scale] +end + +#! Returns the origin token address (5 felts), origin network, and scale factor for a registered +#! faucet from the bridge's faucet metadata map. +#! +#! Reads sub-keys 0 and 1 from faucet_metadata_map: +#! - Key [0, 0, faucet_id_suffix, faucet_id_prefix] -> [addr0, addr1, addr2, addr3] +#! - Key [1, 0, faucet_id_suffix, faucet_id_prefix] -> [addr4, origin_network, scale, 0] +#! +#! Inputs: [faucet_id_suffix, faucet_id_prefix] +#! Outputs: [origin_addr(5), origin_network, scale] +#! +#! Invocation: exec +pub proc get_faucet_conversion_info + # Prepare the SUBKEY_ADDR_LO KEY underneath. + push.0.FAUCET_METADATA_SUBKEY_ADDR_LO + # => [SUBKEY_ADDR_LO, 0, faucet_id_suffix, faucet_id_prefix] + + # Prepare the SUBKEY_ADDR_HI KEY on top. + dup.3 dup.3 push.0.FAUCET_METADATA_SUBKEY_ADDR_HI + # => [SUBKEY_ADDR_HI, 0, faucet_id_suffix, faucet_id_prefix, SUBKEY_ADDR_LO, 0, faucet_id_suffix, faucet_id_prefix] + + # Read sub-key 1: [addr4, origin_network, scale, 0] + push.FAUCET_METADATA_MAP_SLOT[0..2] + exec.active_account::get_map_item + # => [addr4, origin_network, scale, 0, 0, 0, faucet_id_suffix, faucet_id_prefix] + + # Surface the pre-built sub-key 0 KEY for the second read. + swapw + # => [0, 0, faucet_id_suffix, faucet_id_prefix, addr4, origin_network, scale, 0] + + # Read sub-key 0: [addr0, addr1, addr2, addr3] + push.FAUCET_METADATA_MAP_SLOT[0..2] + exec.active_account::get_map_item + # => [addr0, addr1, addr2, addr3, addr4, origin_network, scale, 0] + + # Drop the trailing 0 left over from sub-key 1. + movup.7 drop + # => [addr0, addr1, addr2, addr3, addr4, origin_network, scale] +end + +#! Returns the metadata hash (8 u32 felts) for a registered faucet from the bridge's faucet +#! metadata map. +#! +#! Reads sub-keys 2 and 3 from faucet_metadata_map: +#! - Key [2, 0, faucet_id_suffix, faucet_id_prefix] -> METADATA_HASH_LO +#! - Key [3, 0, faucet_id_suffix, faucet_id_prefix] -> METADATA_HASH_HI +#! +#! Inputs: [faucet_id_suffix, faucet_id_prefix] +#! Outputs: [METADATA_HASH_LO, METADATA_HASH_HI] +#! +#! Invocation: exec +pub proc get_faucet_metadata_hash + # Prepare the SUBKEY_HASH_LO KEY underneath. + push.0.FAUCET_METADATA_SUBKEY_HASH_LO + # => [SUBKEY_HASH_LO, 0, faucet_id_suffix, faucet_id_prefix] + + # Prepare the SUBKEY_HASH_HI KEY on top. + dup.3 dup.3 push.0.FAUCET_METADATA_SUBKEY_HASH_HI + # => [SUBKEY_HASH_HI, 0, faucet_id_suffix, faucet_id_prefix, SUBKEY_HASH_LO, 0, faucet_id_suffix, faucet_id_prefix] + + # Read sub-key HASH_HI: METADATA_HASH_HI + push.FAUCET_METADATA_MAP_SLOT[0..2] + exec.active_account::get_map_item + # => [MH_HI, SUBKEY_HASH_LO, 0, faucet_id_suffix, faucet_id_prefix] + + # Surface the pre-built sub-key HASH_LO KEY for the second read. + swapw + # => [SUBKEY_HASH_LO, 0, faucet_id_suffix, faucet_id_prefix, MH_HI] + + # Read sub-key HASH_LO: METADATA_HASH_LO + push.FAUCET_METADATA_MAP_SLOT[0..2] + exec.active_account::get_map_item + # => [MH_LO, MH_HI] +end + +#! Returns whether a faucet is native (not owned by the bridge). +#! +#! Reads the faucet_registry_map value [1, is_native, 0, 0] and returns the is_native flag. +#! +#! Inputs: [faucet_id_suffix, faucet_id_prefix] +#! Outputs: [is_native] +#! +#! Invocation: exec +pub proc is_faucet_native + # Build KEY = [0, 0, faucet_id_suffix, faucet_id_prefix] + push.0.0 + # => [0, 0, faucet_id_suffix, faucet_id_prefix] + + push.FAUCET_REGISTRY_MAP_SLOT[0..2] + exec.active_account::get_map_item + # => [1, is_native, 0, 0] + + # Drop element 0 (registration flag), move is_native past the two trailing zeros, drop them. + drop movdn.2 drop drop + # => [is_native] +end + +#! Looks up the faucet account ID for a given (origin_token_address, origin_network) pair. #! -#! Hashes the origin token address (5 felts) and looks up the result in the token_registry map. +#! Hashes the (origin_token_address, origin_network) pair and looks up the result in the +#! token_registry map. #! -#! Inputs: [origin_token_addr(5)] +#! Inputs: [origin_token_addr(5), origin_network] #! Outputs: [faucet_id_suffix, faucet_id_prefix] #! #! Panics if: -#! - the token address is not registered in the token registry. +#! - the (origin_token_address, origin_network) pair is not registered in the token registry. #! #! Invocation: exec -proc lookup_faucet_by_token_address - # Hash the token address +pub proc lookup_faucet_by_token_address + # Hash the (token address, origin network) pair exec.hash_token_address # => [TOKEN_ADDR_HASH] @@ -222,7 +451,7 @@ proc lookup_faucet_by_token_address exec.account_id::is_equal # => [is_id_zero, 0, 0, faucet_id_suffix, faucet_id_prefix] - # If AccountId returned from map is zero, it means the token is not registered. + # If AccountId returned from map is zero, it means the (address, network) pair is not registered. assertz.err=ERR_TOKEN_NOT_REGISTERED drop drop # => [faucet_id_suffix, faucet_id_prefix] @@ -231,23 +460,24 @@ end # HELPER PROCEDURES # ================================================================================================= -#! Hashes a 5-felt origin token address using Poseidon2. +#! Hashes the (origin_token_address, origin_network) pair using Poseidon2. #! -#! Writes the 5 felts to memory and computes the Poseidon2 hash. +#! Writes the 6 felts to memory and computes the Poseidon2 hash. #! -#! Inputs: [origin_token_addr(5)] +#! Inputs: [origin_token_addr(5), origin_network] #! Outputs: [TOKEN_ADDR_HASH] #! #! Invocation: exec @locals(8) proc hash_token_address - # Write origin_token_addr[5] to local memory for hashing + # Write the (origin_token_addr, origin_network) felts to local memory for hashing loc_storew_le.TOKEN_ADDR_HASH_PTR dropw locaddr.TOKEN_ADDR_HASH_PTR add.4 mem_store + locaddr.TOKEN_ADDR_HASH_PTR add.5 mem_store # => [] - # Hash the token address: poseidon2::hash_elements(num_elements=5, start_ptr) - push.5 locaddr.TOKEN_ADDR_HASH_PTR + # Hash the pair: poseidon2::hash_elements(num_elements=6, start_ptr) + push.6 locaddr.TOKEN_ADDR_HASH_PTR exec.poseidon2::hash_elements # => [TOKEN_ADDR_HASH] end diff --git a/crates/miden-agglayer/asm/agglayer/bridge/bridge_in.masm b/crates/miden-agglayer/asm/agglayer/bridge/bridge_in.masm index 0371a60e1e..cdd622435a 100644 --- a/crates/miden-agglayer/asm/agglayer/bridge/bridge_in.masm +++ b/crates/miden-agglayer/asm/agglayer/bridge/bridge_in.masm @@ -1,23 +1,16 @@ use agglayer::bridge::bridge_config +use agglayer::bridge::bridge_in_output use agglayer::bridge::leaf_utils +use agglayer::common::constants::MIDEN_NETWORK_ID use agglayer::common::utils use agglayer::common::asset_conversion use agglayer::common::eth_address -use agglayer::faucet -> agglayer_faucet use miden::core::crypto::hashes::keccak256 use miden::core::crypto::hashes::poseidon2 use miden::core::mem use miden::core::word -use miden::protocol::note -use miden::protocol::output_note -use miden::protocol::output_note::ATTACHMENT_KIND_NONE use miden::protocol::active_account use miden::protocol::native_account -use miden::protocol::tx -use miden::standards::note_tag -use miden::standards::note_tag::DEFAULT_TAG -use miden::standards::attachments::network_account_target -use miden::standards::note::execution_hint::ALWAYS use miden::protocol::types::DoubleWord use miden::protocol::types::MemoryAddress @@ -32,6 +25,8 @@ const ERR_ROLLUP_INDEX_NON_ZERO = "rollup index must be zero for a mainnet depos const ERR_SMT_ROOT_VERIFICATION_FAILED = "merkle proof verification failed: provided SMT root does not match the computed root" const ERR_CLAIM_ALREADY_SPENT = "claim note has already been spent" const ERR_SOURCE_BRIDGE_NETWORK_OVERFLOW = "source bridge network overflowed u32" +const ERR_INVALID_LEAF_TYPE = "invalid leaf type: only asset claims (leafType=0) are supported" +const ERR_CLAIM_LEAF_DESTINATION_NETWORK_MISMATCH = "claim leaf destination network does not match Miden AggLayer network ID" # CONSTANTS # ================================================================================================= @@ -58,24 +53,6 @@ const CGI_CHAIN_HASH_HI_SLOT_NAME = word("agglayer::bridge::cgi_chain_hash_hi") const CLAIM_PROOF_DATA_WORD_LEN = 134 const CLAIM_LEAF_DATA_WORD_LEN = 8 -# MINT note storage layout (public mode, 18 felts total): -# - tag [0] : 1 felt -# - amount [1] : 1 felt -# - attachment_kind [2] : 1 felt -# - attachment_scheme [3] : 1 felt -# - ATTACHMENT [4..7] : 4 felts -# - P2ID_SCRIPT_ROOT [8..11] : 4 felts -# - SERIAL_NUM [12..15] : 4 felts -# - account_id_suffix [16] : 1 felt -# - account_id_prefix [17] : 1 felt -const MINT_NOTE_NUM_STORAGE_ITEMS = 18 - -# P2ID output note constants -const OUTPUT_NOTE_TYPE_PUBLIC = 1 - -# P2ID attachment constants (the P2ID note created by the faucet has no attachment) -const P2ID_ATTACHMENT_SCHEME_NONE = 0 - # Global memory pointers # ------------------------------------------------------------------------------------------------- @@ -102,10 +79,10 @@ const LEAF_DATA_START_PTR = 0 # Memory pointers for piped advice map data (used by claim procedure) const CLAIM_PROOF_DATA_START_PTR = 0 const CLAIM_LEAF_DATA_START_PTR = 536 -const CLAIM_OUTPUT_NOTE_FAUCET_AMOUNT = 568 +pub const CLAIM_OUTPUT_NOTE_FAUCET_AMOUNT = 568 # Memory addresses for stored keys (used by claim procedure) -const CLAIM_PROOF_DATA_KEY_MEM_ADDR = 700 +pub const CLAIM_PROOF_DATA_KEY_MEM_ADDR = 700 const CLAIM_LEAF_DATA_KEY_MEM_ADDR = 704 # Memory addresses used to temporarily store leaf_index and source_bridge_network @@ -113,37 +90,32 @@ const CLAIM_LEAF_DATA_KEY_MEM_ADDR = 704 const CLAIM_LEAF_INDEX_MEM_ADDR = 900 const CLAIM_SOURCE_BRIDGE_NETWORK_MEM_ADDR = 901 -# Memory addresses for leaf data fields (derived from leaf data layout at CLAIM_LEAF_DATA_START_PTR=536) -const ORIGIN_TOKEN_ADDRESS_0 = 538 -const ORIGIN_TOKEN_ADDRESS_1 = 539 -const ORIGIN_TOKEN_ADDRESS_2 = 540 -const ORIGIN_TOKEN_ADDRESS_3 = 541 -const ORIGIN_TOKEN_ADDRESS_4 = 542 -const DESTINATION_ADDRESS_0 = 544 -const DESTINATION_ADDRESS_1 = 545 -const DESTINATION_ADDRESS_2 = 546 -const DESTINATION_ADDRESS_3 = 547 -const DESTINATION_ADDRESS_4 = 548 -const OUTPUT_NOTE_ASSET_AMOUNT_MEM_ADDR_0 = 549 -const OUTPUT_NOTE_ASSET_AMOUNT_MEM_ADDR_1 = 550 -const OUTPUT_NOTE_ASSET_AMOUNT_MEM_ADDR_2 = 551 -const OUTPUT_NOTE_ASSET_AMOUNT_MEM_ADDR_3 = 552 -const OUTPUT_NOTE_ASSET_AMOUNT_MEM_ADDR_4 = 553 -const OUTPUT_NOTE_ASSET_AMOUNT_MEM_ADDR_5 = 554 -const OUTPUT_NOTE_ASSET_AMOUNT_MEM_ADDR_6 = 555 -const OUTPUT_NOTE_ASSET_AMOUNT_MEM_ADDR_7 = 556 - -# Memory addresses for MINT note output construction -const MINT_NOTE_STORAGE_MEM_ADDR_0 = 800 -const MINT_NOTE_STORAGE_DEST_TAG = 800 -const MINT_NOTE_STORAGE_NATIVE_AMOUNT = 801 -const MINT_NOTE_STORAGE_ATTACHMENT_KIND = 802 -const MINT_NOTE_STORAGE_ATTACHMENT_SCHEME = 803 -const MINT_NOTE_STORAGE_ATTACHMENT = 804 -const MINT_NOTE_STORAGE_OUTPUT_SCRIPT_ROOT = 808 -const MINT_NOTE_STORAGE_OUTPUT_SERIAL_NUM = 812 -const MINT_NOTE_STORAGE_OUTPUT_NOTE_SUFFIX = 816 -const MINT_NOTE_STORAGE_OUTPUT_NOTE_PREFIX = 817 +# Memory addresses for leaf data fields (felts relative to CLAIM_LEAF_DATA_START_PTR): +const LEAF_TYPE_ADDRESS = CLAIM_LEAF_DATA_START_PTR +const ORIGIN_NETWORK_ADDRESS = CLAIM_LEAF_DATA_START_PTR + 1 + +const ORIGIN_TOKEN_ADDRESS_0 = CLAIM_LEAF_DATA_START_PTR + 2 +const ORIGIN_TOKEN_ADDRESS_1 = CLAIM_LEAF_DATA_START_PTR + 3 +const ORIGIN_TOKEN_ADDRESS_2 = CLAIM_LEAF_DATA_START_PTR + 4 +const ORIGIN_TOKEN_ADDRESS_3 = CLAIM_LEAF_DATA_START_PTR + 5 +const ORIGIN_TOKEN_ADDRESS_4 = CLAIM_LEAF_DATA_START_PTR + 6 + +const DESTINATION_NETWORK_ID_MEM_ADDR = CLAIM_LEAF_DATA_START_PTR + 7 + +const DESTINATION_ADDRESS_0 = CLAIM_LEAF_DATA_START_PTR + 8 +const DESTINATION_ADDRESS_1 = CLAIM_LEAF_DATA_START_PTR + 9 +const DESTINATION_ADDRESS_2 = CLAIM_LEAF_DATA_START_PTR + 10 +const DESTINATION_ADDRESS_3 = CLAIM_LEAF_DATA_START_PTR + 11 +const DESTINATION_ADDRESS_4 = CLAIM_LEAF_DATA_START_PTR + 12 + +const OUTPUT_NOTE_ASSET_AMOUNT_MEM_ADDR_0 = CLAIM_LEAF_DATA_START_PTR + 13 +const OUTPUT_NOTE_ASSET_AMOUNT_MEM_ADDR_1 = CLAIM_LEAF_DATA_START_PTR + 14 +const OUTPUT_NOTE_ASSET_AMOUNT_MEM_ADDR_2 = CLAIM_LEAF_DATA_START_PTR + 15 +const OUTPUT_NOTE_ASSET_AMOUNT_MEM_ADDR_3 = CLAIM_LEAF_DATA_START_PTR + 16 +const OUTPUT_NOTE_ASSET_AMOUNT_MEM_ADDR_4 = CLAIM_LEAF_DATA_START_PTR + 17 +const OUTPUT_NOTE_ASSET_AMOUNT_MEM_ADDR_5 = CLAIM_LEAF_DATA_START_PTR + 18 +const OUTPUT_NOTE_ASSET_AMOUNT_MEM_ADDR_6 = CLAIM_LEAF_DATA_START_PTR + 19 +const OUTPUT_NOTE_ASSET_AMOUNT_MEM_ADDR_7 = CLAIM_LEAF_DATA_START_PTR + 20 # Local memory offsets # ------------------------------------------------------------------------------------------------- @@ -162,10 +134,11 @@ const CLAIM_DEST_ID_SUFFIX_LOCAL = 1 #! Validates a claim against the AggLayer bridge and creates a MINT note for the AggLayer faucet. #! #! This procedure is called by the CLAIM note script. It validates the Merkle proof and then -#! looks up the faucet account ID from the token registry using the origin token address from -#! the leaf data, and creates a MINT note targeting the AggLayer Faucet. +#! looks up the faucet account ID from the token registry using the (origin_token_address, +#! origin_network) pair from the leaf data, and creates a MINT note targeting the AggLayer +#! Faucet. #! -#! The MINT note uses the standard MINT note pattern (public mode) with 18 storage items. +#! The MINT note uses the standard MINT note pattern (public mode) with 22 storage items. #! See `write_mint_note_storage` for the full storage layout. #! #! Inputs: [PROOF_DATA_KEY, LEAF_DATA_KEY, faucet_mint_amount, pad(7)] @@ -192,8 +165,10 @@ const CLAIM_DEST_ID_SUFFIX_LOCAL = 1 #! } #! #! Panics if: +#! - the leaf type is not 0 (not an asset claim). +#! - the leaf destination network does not match the global `MIDEN_NETWORK_ID` constant. #! - the Merkle proof validation fails. -#! - the origin token address is not registered in the bridge's token registry. +#! - the (origin_token_address, origin_network) pair is not registered in the bridge's token registry. #! #! Invocation: call @locals(2) # 0: dest_prefix, 1: dest_suffix @@ -206,6 +181,10 @@ pub proc claim exec.claim_batch_pipe_double_words # => [pad(16)] + # check that the destination network stored in the leaf data matches the Miden network ID + exec.assert_claim_leaf_destination_network + # => [pad(16)] + exec.load_destination_address exec.eth_address::to_account_id loc_store.CLAIM_DEST_ID_SUFFIX_LOCAL loc_store.CLAIM_DEST_ID_PREFIX_LOCAL @@ -216,14 +195,19 @@ pub proc claim # => [PROOF_DATA_KEY, pad(12)] swapw mem_loadw_be.CLAIM_LEAF_DATA_KEY_MEM_ADDR + + # Validate that this is an asset claim (leafType == 0) + exec.validate_leaf_type # => [LEAF_DATA_KEY, PROOF_DATA_KEY, pad(8)] exec.verify_leaf_bridge # => [pad(16)] - # Look up the faucet account ID from the origin token address - exec.load_origin_token_address - # => [origin_token_addr(5), pad(16)] + # Look up the faucet account ID from the (origin_token_address, origin_network) pair so a + # CLAIM bound to one origin network cannot resolve to a faucet registered for the same + # token address on a different network. + exec.load_origin_token_address_and_network + # => [origin_token_addr(5), origin_network, pad(16)] exec.bridge_config::lookup_faucet_by_token_address # => [faucet_id_suffix, faucet_id_prefix, pad(16)] @@ -234,18 +218,44 @@ pub proc claim # Verify faucet_mint_amount matches the leaf data amount exec.verify_claim_amount # => [faucet_id_suffix, faucet_id_prefix, pad(16)] - - # Build MINT output note targeting the AggLayer faucet - loc_load.CLAIM_DEST_ID_PREFIX_LOCAL loc_load.CLAIM_DEST_ID_SUFFIX_LOCAL - # => [destination_id_suffix, destination_id_prefix, faucet_id_suffix, faucet_id_prefix, pad(16)] - exec.build_mint_output_note - # => [pad(16)] + # Branch on is_native: native faucets unlock from the bridge vault and emit a P2ID note + # directly to the recipient; non-native faucets go through the standard MINT path. + dup.1 dup.1 exec.bridge_config::is_faucet_native + # => [is_native, faucet_id_suffix, faucet_id_prefix, pad(16)] + + if.true + loc_load.CLAIM_DEST_ID_PREFIX_LOCAL loc_load.CLAIM_DEST_ID_SUFFIX_LOCAL + # => [destination_id_suffix, destination_id_prefix, faucet_id_suffix, faucet_id_prefix, pad(16)] + + exec.bridge_in_output::unlock_and_send + # => [pad(16)] + else + loc_load.CLAIM_DEST_ID_PREFIX_LOCAL loc_load.CLAIM_DEST_ID_SUFFIX_LOCAL + # => [destination_id_suffix, destination_id_prefix, faucet_id_suffix, faucet_id_prefix, pad(16)] + + exec.bridge_in_output::build_mint_output_note + # => [pad(16)] + end end # HELPER PROCEDURES # ================================================================================================= +#! Validates that the leaf type is an asset claim (leafType == 0), not a message. +#! +#! Inputs: [] +#! Outputs: [] +#! +#! Panics if: +#! - the leaf type is not 0 (not an asset claim). +#! +#! Invocation: exec +proc validate_leaf_type + mem_load.LEAF_TYPE_ADDRESS + assertz.err=ERR_INVALID_LEAF_TYPE +end + #! Computes the leaf value and verifies it against the AggLayer bridge state. #! #! Verification is delegated to `verify_leaf` to mimic the AggLayer Solidity contracts. @@ -432,7 +442,7 @@ end #! by the faucet's scale factor. #! #! This procedure: -#! 1. Performs an FPI call to the faucet's `get_scale` procedure to retrieve the scale factor. +#! 1. Reads the scale factor from the bridge's faucet_metadata_map. #! 2. Loads the raw U256 amount from the leaf data in memory. #! 3. Calls `verify_u256_to_native_amount_conversion` to assert that #! `faucet_mint_amount == floor(raw_amount / 10^scale)`. @@ -441,44 +451,26 @@ end #! Outputs: [] #! #! Panics if: -#! - the FPI call to the faucet's get_scale fails. #! - the faucet_mint_amount does not match the expected scaled-down value. #! #! Invocation: exec proc verify_claim_amount - # Step 1: Pad the stack explicitly for FPI call (get_scale takes no inputs) - padw padw - movup.9 movup.9 - padw padw - movup.9 movup.9 - # => [faucet_id_suffix, faucet_id_prefix, pad(16)] - - # Step 2: FPI call to faucet's get_scale procedure - procref.agglayer_faucet::get_scale - # => [PROC_MAST_ROOT(4), faucet_id_suffix, faucet_id_prefix, pad(16)] - - movup.5 movup.5 - # => [faucet_id_suffix, faucet_id_prefix, PROC_MAST_ROOT(4), pad(16)] - - exec.tx::execute_foreign_procedure - # => [scale, pad(15)] - - # Clean up FPI output padding, keeping only scale - movdn.15 dropw dropw dropw drop drop drop + # Step 1: Read scale from bridge's faucet_metadata_map + exec.bridge_config::get_faucet_scale # => [scale] - # Step 3: Load the raw U256 amount from leaf data memory + # Step 2: Load the raw U256 amount from leaf data memory exec.load_raw_claim_amount # => [x7, x6, x5, x4, x3, x2, x1, x0, scale] - # Step 4: Load faucet_mint_amount (y) and position it for verification + # Step 3: Load faucet_mint_amount (y) and position it for verification mem_load.CLAIM_OUTPUT_NOTE_FAUCET_AMOUNT # => [y, x7, x6, x5, x4, x3, x2, x1, x0, scale] movdn.9 # => [x7, x6, x5, x4, x3, x2, x1, x0, scale, y] - # Step 5: Verify that y = floor(x / 10^scale) + # Step 4: Verify that y = floor(x / 10^scale) exec.asset_conversion::verify_u256_to_native_amount_conversion # => [] end @@ -511,7 +503,7 @@ pub proc get_leaf_value(leaf_data_key: word) -> DoubleWord exec.mem::pipe_preimage_to_memory drop # => [] - # compute the leaf value for elements in memory starting at LEAF_DATA_START_PTR + # compute the leaf value from elements in memory starting at LEAF_DATA_START_PTR push.LEAF_DATA_START_PTR exec.leaf_utils::compute_leaf_value # => [LEAF_VALUE[8]] @@ -778,195 +770,21 @@ proc load_raw_claim_amount # => [U256_LO, U256_HI] end -#! Reads the origin token address (5 felts) from the leaf data in memory. +#! Reads the origin token address (5 felts) and origin network identifier from the leaf data +#! in memory, in the order expected by `bridge_config::lookup_faucet_by_token_address`. #! #! Inputs: [] -#! Outputs: [origin_token_addr(5)] +#! Outputs: [origin_token_addr(5), origin_network] #! #! Invocation: exec -proc load_origin_token_address +proc load_origin_token_address_and_network + mem_load.ORIGIN_NETWORK_ADDRESS mem_load.ORIGIN_TOKEN_ADDRESS_4 mem_load.ORIGIN_TOKEN_ADDRESS_3 mem_load.ORIGIN_TOKEN_ADDRESS_2 mem_load.ORIGIN_TOKEN_ADDRESS_1 mem_load.ORIGIN_TOKEN_ADDRESS_0 - # => [origin_token_addr(5)] -end - -#! Builds a PUBLIC MINT output note targeting the AggLayer Faucet. -#! -#! The MINT note uses public mode (18 storage items) so the AggLayer Faucet creates a PUBLIC P2ID -#! note on consumption. This procedure orchestrates three steps: -#! 1. Write all 18 MINT note storage items to global memory. -#! 2. Build the MINT note recipient digest from the storage. -#! 3. Create the output note, and set the attachment. -#! -#! Inputs: [destination_id_suffix, destination_id_prefix, faucet_id_suffix, faucet_id_prefix] -#! Outputs: [] -#! -#! Invocation: exec -proc build_mint_output_note - # Step 1: Write all 18 MINT note storage items to global memory - exec.write_mint_note_storage - # => [faucet_id_suffix, faucet_id_prefix] - - # Step 2: Build the MINT note recipient digest - exec.build_mint_recipient - # => [MINT_RECIPIENT, faucet_id_suffix, faucet_id_prefix] - - # Step 3: Create the output note and set the faucet attachment - exec.create_mint_note_with_attachment - # => [] -end - -#! Writes all 18 MINT note storage items to global memory. -#! -#! Storage layout: -#! - [0]: tag (note tag for the P2ID output note, targeting the destination account) -#! - [1]: amount (the scaled-down Miden amount to mint) -#! - [2]: attachment_kind (0 = no attachment) -#! - [3]: attachment_scheme (0 = no attachment) -#! - [4-7]: ATTACHMENT ([0, 0, 0, 0]) -#! - [8-11]: P2ID_SCRIPT_ROOT (script root of the P2ID note) -#! - [12-15]: SERIAL_NUM (serial number for the P2ID note, derived from PROOF_DATA_KEY) -#! - [16]: account_id_suffix (destination account suffix) -#! - [17]: account_id_prefix (destination account prefix) -#! -#! Inputs: [destination_id_suffix, destination_id_prefix] -#! Outputs: [] -#! -#! Invocation: exec -proc write_mint_note_storage - # Write P2ID storage items first (before prefix is consumed): [16..17] - # Write destination_id_suffix [16] - dup mem_store.MINT_NOTE_STORAGE_OUTPUT_NOTE_SUFFIX - # => [destination_id_suffix, destination_id_prefix] - - # Write destination_id_prefix [17] - dup.1 mem_store.MINT_NOTE_STORAGE_OUTPUT_NOTE_PREFIX - # => [destination_id_suffix, destination_id_prefix] - - drop - # => [destination_id_prefix] - - # Get the native amount from the pre-computed miden_claim_amount - mem_load.CLAIM_OUTPUT_NOTE_FAUCET_AMOUNT - # => [native_amount, destination_id_prefix] - - # Compute the note tag for the destination account (consumes prefix) - swap - # => [destination_id_prefix, native_amount] - - exec.note_tag::create_account_target - # => [dest_tag, native_amount] - - # Write tag to MINT note storage [0] - mem_store.MINT_NOTE_STORAGE_DEST_TAG - # => [native_amount] - - # Write amount to MINT note storage [1] - mem_store.MINT_NOTE_STORAGE_NATIVE_AMOUNT - # => [] - - # Write P2ID attachment fields (the P2ID note has no attachment) - # attachment_kind = NONE [2] - push.ATTACHMENT_KIND_NONE mem_store.MINT_NOTE_STORAGE_ATTACHMENT_KIND - # => [] - - # attachment_scheme = NONE [3] - push.P2ID_ATTACHMENT_SCHEME_NONE mem_store.MINT_NOTE_STORAGE_ATTACHMENT_SCHEME - # => [] - - # ATTACHMENT = empty word [4..7] - padw mem_storew_le.MINT_NOTE_STORAGE_ATTACHMENT dropw - # => [] - - # Write P2ID_SCRIPT_ROOT to MINT note storage [8..11] - procref.::miden::standards::notes::p2id::main - # => [P2ID_SCRIPT_ROOT] - - mem_storew_le.MINT_NOTE_STORAGE_OUTPUT_SCRIPT_ROOT dropw - # => [] - - # Write SERIAL_NUM (PROOF_DATA_KEY) to MINT note storage [12..15] - padw mem_loadw_be.CLAIM_PROOF_DATA_KEY_MEM_ADDR - # => [SERIAL_NUM] - - mem_storew_le.MINT_NOTE_STORAGE_OUTPUT_SERIAL_NUM dropw - # => [] -end - -#! Builds the MINT note recipient digest from the storage items already written to global memory. -#! -#! Uses the MINT note script root and PROOF_DATA_KEY as serial number, then calls -#! `note::build_recipient` with the storage pointer and item count. -#! -#! Inputs: [] -#! Outputs: [MINT_RECIPIENT] -#! -#! Invocation: exec -proc build_mint_recipient - # Get the MINT note script root - procref.::miden::standards::notes::mint::main - # => [MINT_SCRIPT_ROOT] - - # Generate a serial number for the MINT note (use PROOF_DATA_KEY) - padw mem_loadw_be.CLAIM_PROOF_DATA_KEY_MEM_ADDR - # => [MINT_SERIAL_NUM, MINT_SCRIPT_ROOT] - - # Build the MINT note recipient - push.MINT_NOTE_NUM_STORAGE_ITEMS - # => [num_storage_items, MINT_SERIAL_NUM, MINT_SCRIPT_ROOT] - - push.MINT_NOTE_STORAGE_MEM_ADDR_0 - # => [storage_ptr, num_storage_items, MINT_SERIAL_NUM, MINT_SCRIPT_ROOT] - - exec.note::build_recipient - # => [MINT_RECIPIENT] -end - -#! Creates the MINT output note and sets the NetworkAccountTarget attachment on it. -#! -#! Creates a public output note with no assets, and sets the attachment so only the target faucet -#! can consume the note. -#! -#! Inputs: [MINT_RECIPIENT, faucet_id_suffix, faucet_id_prefix] -#! Outputs: [] -#! -#! Invocation: exec -proc create_mint_note_with_attachment - # Create the MINT output note targeting the faucet - push.OUTPUT_NOTE_TYPE_PUBLIC - # => [note_type, MINT_RECIPIENT, faucet_id_suffix, faucet_id_prefix] - - # Set tag to DEFAULT - push.DEFAULT_TAG - # => [tag, note_type, MINT_RECIPIENT, faucet_id_suffix, faucet_id_prefix] - - # Create the output note (no assets - MINT notes carry no assets) - exec.output_note::create - # => [note_idx, faucet_id_suffix, faucet_id_prefix] - - movdn.2 - # => [faucet_id_suffix, faucet_id_prefix, note_idx] - - # Set the attachment on the MINT note to target the faucet account - # NetworkAccountTarget attachment: targets the faucet so only it can consume the note - # network_account_target::new expects [suffix, prefix, exec_hint] - # and returns [attachment_scheme, attachment_kind, ATTACHMENT] - push.ALWAYS # exec_hint = ALWAYS - movdn.2 - # => [faucet_id_suffix, faucet_id_prefix, exec_hint, note_idx] - - exec.network_account_target::new - # => [attachment_scheme, attachment_kind, ATTACHMENT, note_idx] - - # Rearrange for set_attachment: [note_idx, attachment_scheme, attachment_kind, ATTACHMENT] - movup.6 - # => [note_idx, attachment_scheme, attachment_kind, ATTACHMENT(4)] - - exec.output_note::set_attachment - # => [] + # => [origin_token_addr(5), origin_network] end #! Computes the root of the SMT based on the provided Merkle path, leaf value and leaf index. @@ -1113,3 +931,31 @@ proc store_cgi_chain_hash exec.native_account::set_item dropw # => [] end + +#! Asserts the claim leaf's `destination_network` matches the global `MIDEN_NETWORK_ID`. +#! +#! `claim_batch_pipe_double_words` stores leaf felts as LE-packed u32 limbs. `swap_u32_bytes` +#! converts the loaded limb to the canonical u32 value so it can be compared to `MIDEN_NETWORK_ID` +#! from `agglayer::common::constants`. +#! +#! Inputs: [] +#! Outputs: [] +#! +#! Panics if: +#! - the leaf destination network does not match the Miden AggLayer network ID constant. +#! +#! Invocation: exec +proc assert_claim_leaf_destination_network + # load the destination network ID onto the stack + mem_load.DESTINATION_NETWORK_ID_MEM_ADDR + # => [destination_network_id_le] + + # change the endianness to BE to compare it with the Miden network ID + exec.utils::swap_u32_bytes + # => [destination_network_id_be] + + # assert that the destination network ID matches the Miden network ID + push.MIDEN_NETWORK_ID + assert_eq.err=ERR_CLAIM_LEAF_DESTINATION_NETWORK_MISMATCH + # => [] +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 new file mode 100644 index 0000000000..629a699f6f --- /dev/null +++ b/crates/miden-agglayer/asm/agglayer/bridge/bridge_in_output.masm @@ -0,0 +1,320 @@ +use agglayer::bridge::bridge_in::CLAIM_OUTPUT_NOTE_FAUCET_AMOUNT +use agglayer::bridge::bridge_in::CLAIM_PROOF_DATA_KEY_MEM_ADDR +use miden::protocol::asset +use miden::protocol::native_account +use miden::protocol::note +use miden::protocol::note::NOTE_TYPE_PUBLIC +use miden::protocol::output_note +use miden::standards::note_tag +use miden::standards::note_tag::DEFAULT_TAG +use miden::standards::notes::p2id +use miden::standards::attachments::network_account_target +use miden::standards::note::execution_hint::ALWAYS + +# CONSTANTS +# ================================================================================================= + +# MINT note storage layout (public mode, 22 felts total). The full window feeds the +# storage commitment via `note::compute_and_store_recipient`, so it must match +# `MintNoteStorage::Public` exactly. The padding at [17..19] keeps the P2ID storage +# at [20..21] word-aligned and is written as explicit zeros rather than relying on +# memory zero-init. +# - P2ID_SCRIPT_ROOT [0..3] : 4 felts +# - SERIAL_NUM [4..7] : 4 felts +# - ASSET_KEY [8..11] : 4 felts (vault key of asset to mint) +# - ASSET_VALUE [12..15] : 4 felts (asset value word; [amount, 0, 0, 0]) +# - tag [16] : 1 felt +# - padding [17..19] : 3 felts (word-aligns P2ID; explicitly zeroed) +# - account_id_suffix [20] : 1 felt +# - account_id_prefix [21] : 1 felt +const MINT_NOTE_NUM_STORAGE_ITEMS = 22 + +# Memory addresses for MINT note output construction +const MINT_NOTE_STORAGE_MEM_ADDR_0 = 800 +const MINT_NOTE_STORAGE_OUTPUT_SCRIPT_ROOT = 800 +const MINT_NOTE_STORAGE_OUTPUT_SERIAL_NUM = 804 +const MINT_NOTE_STORAGE_ASSET_KEY = 808 +const MINT_NOTE_STORAGE_ASSET_VALUE = 812 +const MINT_NOTE_STORAGE_DEST_TAG = 816 +# padding at 817, 818, 819 so the P2ID storage below stays word-aligned +const MINT_NOTE_STORAGE_OUTPUT_NOTE_SUFFIX = 820 +const MINT_NOTE_STORAGE_OUTPUT_NOTE_PREFIX = 821 + +# Offsets in the local memory of the `unlock_and_send` procedure +const UNLOCK_ASSET_KEY_LOC = 0 +const UNLOCK_ASSET_VALUE_LOC = 4 +const UNLOCK_DEST_SUFFIX_LOC = 8 +const UNLOCK_DEST_PREFIX_LOC = 9 + +# PUBLIC INTERFACE +# ================================================================================================= + +#! Builds a PUBLIC MINT output note targeting the AggLayer Faucet. +#! +#! Used on the bridge-in claim path for bridge-owned faucets. The MINT note uses public mode (22 +#! storage items) so the AggLayer Faucet creates a PUBLIC P2ID note on consumption. The 22 items +#! include the fungible asset (`ASSET_KEY` + `ASSET_VALUE`) for the resolved faucet at the +#! resolved native amount, so the faucet's `mint_and_send` can verify the asset's faucet ID +#! against the active account at consumption time. This procedure orchestrates three steps: +#! 1. Write all 22 MINT note storage items to global memory. +#! 2. Build the MINT note recipient digest from the storage. +#! 3. Create the output note, and set the attachment. +#! +#! Inputs: [destination_id_suffix, destination_id_prefix, faucet_id_suffix, faucet_id_prefix] +#! Outputs: [] +#! +#! Invocation: exec +pub proc build_mint_output_note + # Step 1: Write all 22 MINT note storage items to global memory + exec.write_mint_note_storage + # => [faucet_id_suffix, faucet_id_prefix] + + # Step 2: Build the MINT note recipient digest + exec.build_mint_recipient + # => [MINT_RECIPIENT, faucet_id_suffix, faucet_id_prefix] + + # Step 3: Create the output note and set the faucet attachment + exec.create_mint_note_with_attachment + # => [] +end + +#! Removes the fungible asset for the claim from the bridge's vault and creates a PUBLIC P2ID +#! output note targeted at the destination account. +#! +#! Used on the bridge-in claim path for Miden-native faucets (ones whose mint authority the bridge +#! does not hold). Instead of creating a MINT note for the faucet, the asset is removed from the +#! bridge's own vault (where it was placed by a prior `lock_asset` on the bridge-out side) and +#! attached to a new P2ID note. The P2ID serial number is derived from `CLAIM_PROOF_DATA_KEY` +#! (matching the MINT path's serial-number choice) so the resulting note commitment is +#! deterministic across runs. +#! +#! Replay safety does not rely on serial-number uniqueness. A replayed claim is rejected earlier +#! in `bridge_in::claim` by the nullifier check (`assert_claim_not_spent`), so `unlock_and_send` +#! only runs once per (leaf_index, source_bridge_network) pair even though its serial number is +#! deterministic. +#! +#! Inputs: [destination_id_suffix, destination_id_prefix, faucet_id_suffix, faucet_id_prefix] +#! Outputs: [] +#! +#! Invocation: exec +@locals(10) +pub proc unlock_and_send + # Stash destination to locals so we can reload it later, after `native_account::remove_asset` + # has consumed the stack copy. + loc_store.UNLOCK_DEST_SUFFIX_LOC loc_store.UNLOCK_DEST_PREFIX_LOC + # => [faucet_id_suffix, faucet_id_prefix] + + # Build the fungible asset (ASSET_KEY, ASSET_VALUE) from the faucet id and the pre-computed + # Miden claim amount. `asset::create_fungible_asset` is pure MASM (no FPI). + mem_load.CLAIM_OUTPUT_NOTE_FAUCET_AMOUNT movdn.2 + # => [faucet_id_suffix, faucet_id_prefix, amount] + + # TODO(#2963): callbacks bit is hardcoded to 0. If a bridge-owned faucet enables + # callbacks, the ASSET_KEY derived here will not match the asset the bridge actually + # holds, and `native_account::remove_asset` will panic on a legitimate withdrawal. + push.0 # enable_callbacks = 0 + # => [0, faucet_id_suffix, faucet_id_prefix, amount] + + exec.asset::create_fungible_asset + # => [ASSET_KEY, ASSET_VALUE] + + # Stash the asset to locals so we can re-use it for `output_note::add_asset` after + # `native_account::remove_asset` consumes its stack copy. + dupw.1 loc_storew_le.UNLOCK_ASSET_VALUE_LOC dropw + dupw loc_storew_le.UNLOCK_ASSET_KEY_LOC dropw + # => [ASSET_KEY, ASSET_VALUE] + + # 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] + + dropw + # => [] + + # Build p2id::new's input [dest_suffix, dest_prefix, tag, note_type, SERIAL_NUM] from the + # bottom up. + padw mem_loadw_be.CLAIM_PROOF_DATA_KEY_MEM_ADDR + # => [SERIAL_NUM] + + push.NOTE_TYPE_PUBLIC + # => [note_type, SERIAL_NUM] + + loc_load.UNLOCK_DEST_PREFIX_LOC + # => [dest_prefix, note_type, SERIAL_NUM] + + exec.note_tag::create_account_target + # => [dest_tag, note_type, SERIAL_NUM] + + loc_load.UNLOCK_DEST_PREFIX_LOC loc_load.UNLOCK_DEST_SUFFIX_LOC + # => [dest_suffix, dest_prefix, dest_tag, note_type, SERIAL_NUM] + + exec.p2id::new + # => [note_idx] + + # Reload the asset from locals and attach it to the newly created P2ID note. + padw loc_loadw_le.UNLOCK_ASSET_VALUE_LOC + # => [ASSET_VALUE, note_idx] + + padw loc_loadw_le.UNLOCK_ASSET_KEY_LOC + # => [ASSET_KEY, ASSET_VALUE, note_idx] + + exec.output_note::add_asset + # => [] +end + +# MINT-PATH HELPERS (used only by build_mint_output_note) +# ================================================================================================= + +#! Writes all 22 MINT note storage items to global memory. +#! +#! Storage layout: +#! - [0..3]: P2ID_SCRIPT_ROOT (script root of the P2ID note) +#! - [4..7]: SERIAL_NUM (serial number for the P2ID note, derived from PROOF_DATA_KEY) +#! - [8..11]: ASSET_KEY (vault key of the fungible asset that the AggLayer faucet must mint) +#! - [12..15]: ASSET_VALUE (value word of the asset: [native_amount, 0, 0, 0]) +#! - [16]: dest_tag (note tag for the P2ID output note, targeting the destination account) +#! - [17..19]: padding (so the P2ID storage below stays word-aligned at [20..21]) +#! - [20]: account_id_suffix (destination account suffix) +#! - [21]: account_id_prefix (destination account prefix) +#! +#! Inputs: [destination_id_suffix, destination_id_prefix, faucet_id_suffix, faucet_id_prefix] +#! Outputs: [faucet_id_suffix, faucet_id_prefix] +#! +#! Invocation: exec +proc write_mint_note_storage + # write P2ID destination first so we can consume destination_id_prefix for the tag. + # destination_id_suffix [20] + dup mem_store.MINT_NOTE_STORAGE_OUTPUT_NOTE_SUFFIX + # destination_id_prefix [21] + dup.1 mem_store.MINT_NOTE_STORAGE_OUTPUT_NOTE_PREFIX + # => [destination_id_suffix, destination_id_prefix, faucet_id_suffix, faucet_id_prefix] + + drop + # => [destination_id_prefix, faucet_id_suffix, faucet_id_prefix] + + # compute the destination tag [16] (consumes destination_id_prefix) + exec.note_tag::create_account_target + # => [dest_tag, faucet_id_suffix, faucet_id_prefix] + + # write the tag word [16..19] as [dest_tag, 0, 0, 0]: the tag at [16] plus explicit zeros + # at the padding elements [17..19] that keep the P2ID storage word-aligned + push.0 push.0 push.0 movup.3 + # => [dest_tag, 0, 0, 0, faucet_id_suffix, faucet_id_prefix] + + mem_storew_le.MINT_NOTE_STORAGE_DEST_TAG dropw + # => [faucet_id_suffix, faucet_id_prefix] + + # build ASSET_KEY + ASSET_VALUE for the faucet's fungible asset at the resolved + # native amount. Duplicate faucet_id so the original stays on the stack as + # `write_mint_note_storage`'s output contract. + dup.1 dup.1 + # => [faucet_id_suffix, faucet_id_prefix, faucet_id_suffix, faucet_id_prefix] + + mem_load.CLAIM_OUTPUT_NOTE_FAUCET_AMOUNT + # => [native_amount, faucet_id_suffix, faucet_id_prefix, faucet_id_suffix, faucet_id_prefix] + + # prepare stack for asset::create_fungible_asset: [enable_callbacks, suffix, prefix, amount] + # TODO(#2963): callbacks bit is hardcoded to 1 to match AggLayer faucets configured with + # `AllowAll` transfer policies). We need to support the registration of both types of agglayer + # faucets, with & without callbacks. + movdn.2 push.1 + # => [1, faucet_id_suffix, faucet_id_prefix, native_amount, faucet_id_suffix, faucet_id_prefix] + + exec.asset::create_fungible_asset + # => [ASSET_KEY, ASSET_VALUE, faucet_id_suffix, faucet_id_prefix] + + # write ASSET_KEY [8..11] + mem_storew_le.MINT_NOTE_STORAGE_ASSET_KEY dropw + # => [ASSET_VALUE, faucet_id_suffix, faucet_id_prefix] + + # write ASSET_VALUE [12..15] + mem_storew_le.MINT_NOTE_STORAGE_ASSET_VALUE dropw + # => [faucet_id_suffix, faucet_id_prefix] + + # write P2ID_SCRIPT_ROOT to MINT note storage [0..3] + procref.::miden::standards::notes::p2id::main + # => [P2ID_SCRIPT_ROOT, faucet_id_suffix, faucet_id_prefix] + + mem_storew_le.MINT_NOTE_STORAGE_OUTPUT_SCRIPT_ROOT dropw + # => [faucet_id_suffix, faucet_id_prefix] + + # write SERIAL_NUM (PROOF_DATA_KEY) to MINT note storage [4..7] + padw mem_loadw_be.CLAIM_PROOF_DATA_KEY_MEM_ADDR + # => [SERIAL_NUM, faucet_id_suffix, faucet_id_prefix] + + mem_storew_le.MINT_NOTE_STORAGE_OUTPUT_SERIAL_NUM dropw + # => [faucet_id_suffix, faucet_id_prefix] +end + +#! Builds the MINT note recipient digest from the storage items already written to global memory. +#! +#! Uses the MINT note script root and PROOF_DATA_KEY as serial number, then calls +#! `note::compute_and_store_recipient` with the storage pointer and item count. +#! +#! Inputs: [] +#! Outputs: [MINT_RECIPIENT] +#! +#! Invocation: exec +proc build_mint_recipient + # Get the MINT note script root + procref.::miden::standards::notes::mint::main + # => [MINT_SCRIPT_ROOT] + + # Generate a serial number for the MINT note (use PROOF_DATA_KEY) + padw mem_loadw_be.CLAIM_PROOF_DATA_KEY_MEM_ADDR + # => [MINT_SERIAL_NUM, MINT_SCRIPT_ROOT] + + # Compute the MINT note recipient + push.MINT_NOTE_NUM_STORAGE_ITEMS + # => [num_storage_items, MINT_SERIAL_NUM, MINT_SCRIPT_ROOT] + + push.MINT_NOTE_STORAGE_MEM_ADDR_0 + # => [storage_ptr, num_storage_items, MINT_SERIAL_NUM, MINT_SCRIPT_ROOT] + + exec.note::compute_and_store_recipient + # => [MINT_RECIPIENT] +end + +#! Creates the MINT output note and sets the NetworkAccountTarget attachment on it. +#! +#! Creates a public output note with no assets, and sets the `NetworkAccountTarget` attachment so +#! the network routes the note to the target faucet. The attachment is for routing only; the +#! consume-side security bind is `fungible::mint_and_send`, which rejects the MINT note if its +#! stored `ASSET_KEY` does not belong to the consuming faucet. +#! +#! Inputs: [MINT_RECIPIENT, faucet_id_suffix, faucet_id_prefix] +#! Outputs: [] +#! +#! Invocation: exec +proc create_mint_note_with_attachment + # Create the MINT output note targeting the faucet + push.NOTE_TYPE_PUBLIC + # => [note_type, MINT_RECIPIENT, faucet_id_suffix, faucet_id_prefix] + + # Set tag to DEFAULT + push.DEFAULT_TAG + # => [tag, note_type, MINT_RECIPIENT, faucet_id_suffix, faucet_id_prefix] + + # Create the output note (no assets - MINT notes carry no assets) + exec.output_note::create + # => [note_idx, faucet_id_suffix, faucet_id_prefix] + + movdn.2 + # => [faucet_id_suffix, faucet_id_prefix, note_idx] + + # set the attachment on the MINT note so the network routes it to the target faucet. + # the consume-side security bind is `fungible::mint_and_send` (it rejects an asset that + # does not belong to the consuming faucet), not this attachment. + # network_account_target::new expects [suffix, prefix, exec_hint] + # and returns [attachment_scheme, ATTACHMENT] + push.ALWAYS # exec_hint = ALWAYS + movdn.2 + # => [faucet_id_suffix, faucet_id_prefix, exec_hint, note_idx] + + exec.network_account_target::new + # => [attachment_scheme, ATTACHMENT, note_idx] + + exec.output_note::add_word_attachment + # => [] +end diff --git a/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm b/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm index 824ad3da19..7d108e4f81 100644 --- a/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm +++ b/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm @@ -3,7 +3,6 @@ use miden::protocol::active_account use miden::protocol::asset use miden::protocol::native_account use miden::protocol::note -use miden::protocol::tx use miden::standards::data_structures::double_word_array use miden::standards::attachments::network_account_target use miden::standards::note_tag::DEFAULT_TAG @@ -11,13 +10,19 @@ use miden::standards::note::execution_hint::ALWAYS use miden::protocol::types::MemoryAddress use miden::protocol::output_note use miden::core::crypto::hashes::poseidon2 +use agglayer::common::constants::MIDEN_NETWORK_ID use agglayer::common::utils -use agglayer::faucet -> agglayer_faucet +use agglayer::common::asset_conversion use agglayer::bridge::bridge_config use agglayer::bridge::leaf_utils use agglayer::bridge::merkle_tree_frontier use agglayer::common::eth_address::EthereumAddressFormat +# ERRORS +# ================================================================================================= + +const ERR_B2AGG_DESTINATION_NETWORK_IS_MIDEN = "B2AGG note destination network ID must not be Miden's AggLayer network ID" + # CONSTANTS # ================================================================================================= @@ -65,18 +70,18 @@ const DESTINATION_ADDRESS_2_LOC=10 const DESTINATION_ADDRESS_3_LOC=11 const DESTINATION_ADDRESS_4_LOC=12 const DESTINATION_NETWORK_LOC=13 +const BRIDGE_OUT_IS_NATIVE_LOC=14 # create_burn_note memory locals const CREATE_BURN_NOTE_BURN_ASSET_LOC=0 const ATTACHMENT_LOC=8 const ATTACHMENT_SCHEME_LOC=12 -const ATTACHMENT_KIND_LOC=13 # Other constants # ------------------------------------------------------------------------------------------------- const LEAF_TYPE_ASSET=0 -const PUBLIC_NOTE=1 +use miden::protocol::note::NOTE_TYPE_PUBLIC const BURN_NOTE_NUM_STORAGE_ITEMS=0 # PUBLIC INTERFACE @@ -100,16 +105,20 @@ const BURN_NOTE_NUM_STORAGE_ITEMS=0 #! - dest_network_id is the u32 destination network/chain ID. #! - dest_address(5) are 5 u32 values representing a 20-byte Ethereum address. #! +#! Panics if: +#! - destination network ID is Miden's AggLayer network ID. +#! #! Invocation: call -@locals(14) +@locals(15) pub proc bridge_out - # => [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 # => [dest_network_id, dest_address(5), pad(10)] + dup exec.assert_destination_id_not_miden_id + # => [dest_network_id, dest_address(5), pad(10)] + loc_store.DESTINATION_NETWORK_LOC loc_store.DESTINATION_ADDRESS_0_LOC loc_store.DESTINATION_ADDRESS_1_LOC @@ -165,27 +174,29 @@ pub proc bridge_out exec.write_address_to_memory # => [pad(16)] - # --- 3. Fetch metadata hash from the faucet via FPI and write to memory --- - procref.agglayer_faucet::get_metadata_hash - # => [PROC_MAST_ROOT, pad(16)] - - # Reload asset to extract faucet ID for the FPI call + # --- 3. Fetch metadata hash from bridge storage and write to memory --- + # Reload asset to extract faucet id for the metadata lookup and the is_native flag. locaddr.BRIDGE_OUT_BURN_ASSET_LOC exec.asset::load swapw dropw - # => [ASSET_KEY, PROC_MAST_ROOT, pad(16)] - # ASSET_KEY layout: [0, 0, faucet_id_suffix, faucet_id_prefix] + # => [ASSET_KEY, pad(16)] - # Extract faucet ID, drop padding and amount - drop drop - # => [faucet_id_suffix, faucet_id_prefix, PROC_MAST_ROOT, pad(16)] + # extract the faucet ID from the asset key + exec.asset::key_into_faucet_id + # => [faucet_id_suffix, faucet_id_prefix, pad(16)] + + # Stash the is_native flag for the lock/burn branch later in this procedure. + dup.1 dup.1 + exec.bridge_config::is_faucet_native + loc_store.BRIDGE_OUT_IS_NATIVE_LOC + # => [faucet_id_suffix, faucet_id_prefix, pad(16)] - exec.tx::execute_foreign_procedure - # => [METADATA_HASH_LO, METADATA_HASH_HI, pad(8)] + exec.bridge_config::get_faucet_metadata_hash + # => [METADATA_HASH_LO, METADATA_HASH_HI, pad(16)] push.LEAF_DATA_START_PTR push.METADATA_HASH_OFFSET add movdn.8 - # => [METADATA_HASH_LO, METADATA_HASH_HI, metadata_hash_ptr, pad(8)] + # => [METADATA_HASH_LO, METADATA_HASH_HI, metadata_hash_ptr, pad(16)] exec.utils::mem_store_double_word_unaligned # => [pad(16)] @@ -210,21 +221,49 @@ pub proc bridge_out exec.add_leaf_bridge # => [pad(16)] - # --- 4. Create BURN output note for ASSET --- + # --- 5. Dispatch on is_native: lock into the bridge vault or burn via an output note --- locaddr.BRIDGE_OUT_BURN_ASSET_LOC exec.asset::load # => [ASSET_KEY, ASSET_VALUE, pad(16)] - - exec.create_burn_note - # => [pad(16)] + + loc_load.BRIDGE_OUT_IS_NATIVE_LOC + # => [is_native, ASSET_KEY, ASSET_VALUE, pad(16)] + + if.true + exec.lock_asset + # => [pad(16)] + else + exec.create_burn_note + # => [pad(16)] + end end # HELPER PROCEDURES # ================================================================================================= -#! Validates that a faucet is registered in the bridge's faucet registry, then performs an FPI call -#! to the faucet's `asset_to_origin_asset` procedure to obtain the scaled amount, origin token -#! address, and origin network. +#! Asserts that the bridge-out destination network ID is not equal to the Miden's AggLayer network +#! ID. +#! +#! Inputs: [dest_network_id] +#! Outputs: [] +#! +#! Panics if: +#! - the destination network ID equals `MIDEN_NETWORK_ID`. +#! +#! Invocation: exec +proc assert_destination_id_not_miden_id + # change the endianness to BE to compare it with the Miden network ID + exec.utils::swap_u32_bytes + # => [destination_network_id_be] + + # assert that the destination network ID is not equal to the Miden network ID + push.MIDEN_NETWORK_ID neq assert.err=ERR_B2AGG_DESTINATION_NETWORK_IS_MIDEN + # => [] +end + +#! Validates that a faucet is registered in the bridge's faucet registry and converts the asset's +#! native Miden amount to the origin (AggLayer-side) U256 amount using bridge-local conversion +#! metadata. #! #! Inputs: [ASSET_KEY, ASSET_VALUE] #! Outputs: [AMOUNT_U256_LO, AMOUNT_U256_HI, origin_addr(5), origin_network] @@ -238,43 +277,38 @@ end #! #! Panics if: #! - The faucet is not registered in the faucet registry. -#! - The FPI call to asset_to_origin_asset fails. #! #! Invocation: exec proc convert_asset - # --- Step 1: Assert faucet is registered --- - # pad in preparation for FPI call - repeat.2 - padw padw swapdw - end - # => [ASSET_KEY, ASSET_VALUE, pad(16)] - swapw exec.asset::fungible_value_into_amount movdn.4 - # => [ASSET_KEY, amount, pad(16)] + # => [ASSET_KEY, amount] exec.asset::key_into_faucet_id - # => [faucet_id_suffix, faucet_id_prefix, amount, pad(16)] + # => [faucet_id_suffix, faucet_id_prefix, amount] dup.1 dup.1 exec.bridge_config::assert_faucet_registered - # => [faucet_id_suffix, faucet_id_prefix, amount, pad(16)] + # => [faucet_id_suffix, faucet_id_prefix, amount] - # --- Step 2: FPI to faucet's asset_to_origin_asset --- + # Fetch origin token address, origin network, and scale from bridge storage. + exec.bridge_config::get_faucet_conversion_info + # => [addr0, addr1, addr2, addr3, addr4, origin_network, scale, amount] - procref.agglayer_faucet::asset_to_origin_asset - # => [PROC_MAST_ROOT, faucet_id_suffix, faucet_id_prefix, amount, pad(16)] + # Bring [amount, scale] to the top for scale_native_amount_to_u256. + movup.6 + # => [scale, addr0, addr1, addr2, addr3, addr4, origin_network, amount] - # Move faucet_id above PROC_MAST_ROOT - movup.5 movup.5 - # => [faucet_id_suffix, faucet_id_prefix, PROC_MAST_ROOT, amount, pad(15), pad(1)] + movup.7 + # => [amount, scale, addr0, addr1, addr2, addr3, addr4, origin_network] - exec.tx::execute_foreign_procedure - # => [AMOUNT_U256_LO, AMOUNT_U256_HI, origin_addr(5), origin_network, pad(2), pad(1)] + exec.asset_conversion::scale_native_amount_to_u256 + exec.asset_conversion::reverse_limbs_and_change_byte_endianness + # => [U256_LO, U256_HI, addr0, addr1, addr2, addr3, addr4, origin_network] - # drop the 3 trailing padding elements - repeat.3 - movup.14 drop - end + # Byte-swap origin_network to match the EVM-side big-endian encoding used in the leaf layout. + movup.13 + exec.utils::swap_u32_bytes + movdn.13 # => [AMOUNT_U256_LO, AMOUNT_U256_HI, origin_addr(5), origin_network] end @@ -300,8 +334,8 @@ proc add_leaf_bridge(leaf_data_start_ptr: MemoryAddress) exec.leaf_utils::compute_leaf_value # => [LEAF_VALUE_LO, LEAF_VALUE_HI] - # Load the LET frontier from storage into memory at LET_FRONTIER_MEM_PTR - exec.load_let_frontier_to_memory + # Load num_leaves and only the frontier entries that will be read (bit h=1) + exec.load_let_frontier_selective # => [LEAF_VALUE_LO, LEAF_VALUE_HI] # Push frontier pointer below the leaf value @@ -316,58 +350,91 @@ proc add_leaf_bridge(leaf_data_start_ptr: MemoryAddress) exec.save_let_root_and_num_leaves # => [] - # Write the updated frontier from memory back to the map - exec.save_let_frontier_to_storage + # Write only the frontier entries that were modified from memory back to storage (bit h=0 in original num_leaves) + exec.save_let_frontier_selective # => [] end -#! Loads the LET (Local Exit Tree) frontier from account storage into memory. +#! Selectively loads the LET (Local Exit Tree) frontier from account storage into memory. #! -#! The num_leaves is read from its dedicated value slot, and the 32 frontier entries are read from -#! the LET map slot (double-word array, indices 0..31). The data is placed into memory at -#! LET_FRONTIER_MEM_PTR, matching the layout expected by append_and_update_frontier: -#! [num_leaves, 0, 0, 0, [[FRONTIER_NODE_LO, FRONTIER_NODE_HI]; 32]] +#! First loads num_leaves from its dedicated value slot. Then, based on the bit pattern of +#! num_leaves, loads only the frontier entries that will be read by append_and_update_frontier, +#! i.e. those at heights where the corresponding bit in num_leaves is 1. +#! +#! For example: +#! for a tree of depth=4, the frontier entry at height=2 will be read with the pattern 0b_X1XX: +#! - num_leaves = 0b_0100 (4), +#! - num_leaves = 0b_0101 (5), +#! - num_leaves = 0b_0110 (6), +#! - num_leaves = 0b_0111 (7), +#! (without any writes to frontier[2] in the meantime). +#! +#! To understand the read pattern, consider the following: +#! The root of the depth-3 subtree is computed as `merge(root-d2-left, root-d2-right)`: +#! +#! root-d3 +#! / \ +#! root-d2-left root-d2-right #! -#! Empty (uninitialized) map entries return zeros, which is the correct initial state for the -#! frontier when there are no leaves. +#! As we add leaves 5-8, the `root-d2-left` stays fixed as frontier[2] (all four leaves are inserted) +#! while `root-d2-right` changes with each leaf 5-8. +#! So as we're adding leaves 5-8, we ALWAYS read frontier[2] from storage and merge it with the +#! freshly computed root-d2-right. +#! +#! Entries at heights where the corresponding bit in num_leaves is 0 are not loaded, because +#! append_and_update_frontier will instead read the canonical zeros at those heights. +#! +#! The data is placed into memory at LET_FRONTIER_MEM_PTR, matching the layout expected by +#! append_and_update_frontier: +#! [num_leaves, 0, 0, 0, [[FRONTIER_NODE_LO, FRONTIER_NODE_HI]; 32]] #! #! Inputs: [] #! Outputs: [] #! #! Invocation: exec -proc load_let_frontier_to_memory +proc load_let_frontier_selective # 1. Load num_leaves from its value slot push.LET_NUM_LEAVES_SLOT[0..2] exec.active_account::get_item - # => [num_leaves_word] + # => [num_leaves, 0, 0, 0] + + # 2. Keep a copy of num_leaves on the stack for bit checking, then write the + # [num_leaves, 0, 0, 0] word to memory at LET_FRONTIER_MEM_PTR. + dup.0 movdn.4 + # => [num_leaves, 0, 0, 0, num_leaves] push.LET_FRONTIER_MEM_PTR mem_storew_le dropw - # => [] + # => [num_leaves] - # 2. Load 32 frontier double-word entries from the map via double_word_array::get push.0 - # => [h=0] + # => [h=0, num_leaves] repeat.32 - # => [h] - - # Read frontier[h] as a double word from the map - dup push.LET_FRONTIER_SLOT[0..2] - exec.double_word_array::get - # => [VALUE_0, VALUE_1, h] - - # Compute memory address and store the double word - dup.8 mul.8 add.LET_FRONTIER_MEM_PTR add.4 movdn.8 - # => [VALUE_0, VALUE_1, mem_addr, h] - exec.utils::mem_store_double_word - dropw dropw drop - # => [h] - - add.1 - # => [h+1] + # => [h, num_leaves_shifted] + + # Check if bit h is set (this entry will be read by append_and_update_frontier) + dup.1 u32and.1 + if.true + # Load frontier[h] from storage into memory + dup push.LET_FRONTIER_SLOT[0..2] + exec.double_word_array::get + # => [VALUE_0, VALUE_1, h, num_leaves_shifted] + + # Compute memory address: LET_FRONTIER_MEM_PTR + 4 + h * 8 + dup.8 mul.8 add.LET_FRONTIER_MEM_PTR add.4 movdn.8 + # => [VALUE_0, VALUE_1, mem_addr, h, num_leaves_shifted] + + exec.utils::mem_store_double_word + dropw dropw drop + # => [h, num_leaves_shifted] + end + + # Shift num_leaves right by 1, increment h + swap u32shr.1 swap add.1 + # => [h+1, num_leaves_shifted>>1] end - drop + drop drop # => [] end @@ -400,38 +467,71 @@ proc save_let_root_and_num_leaves # => [] end -#! Writes the 32 frontier entries from memory back to the LET map slot. +#! Selectively writes modified frontier entries from memory back to the LET map slot. +#! +#! Only entries that were written by append_and_update_frontier are saved back to storage. +#! These are the entries at heights where the corresponding bit in the pre-append num_leaves +#! was 0. +#! +#! Note, that not all entries that are written to storage will be meaningfully read again. +#! Some frontier heights may be written multiple times before they are read again. For example: +#! for a tree of depth=4, the frontier entry at height=2 will be written with the pattern 0b_X0XX: +#! - num_leaves = 0b_0000 (0), +#! - num_leaves = 0b_0001 (1), +#! - num_leaves = 0b_0010 (2), +#! - num_leaves = 0b_0011 (3), +#! before it's ever read when inserting the 5th leaf (when pre-insert num_leaves = 0b_0100 (4)). +#! Notice that only the last write here is meaningful. #! -#! Each frontier entry is a double word (Keccak256 digest) stored at -#! LET_FRONTIER_MEM_PTR + 4 + h * 8, and is written to the map at double_word_array index h. +#! This pattern is later repeated for height=2 but when the upper bit is 1: +#! - num_leaves = 0b_1000 (8), +#! - num_leaves = 0b_1001 (9), +#! - num_leaves = 0b_1010 (10), +#! - num_leaves = 0b_1011 (11), +#! before it's read again when inserting the 13th leaf (when pre-insert num_leaves = 0b_1100 (12)). +#! +#! This is a little wasteful, but already better than the previous implementation, which +#! unconditionally saved all frontier entries to storage. +#! TODO: potential room for optimization here? Need to see if this affects root computation #! #! Inputs: [] #! Outputs: [] #! #! Invocation: exec -proc save_let_frontier_to_storage +proc save_let_frontier_selective + # Get old_num_leaves = new_num_leaves - 1 + push.LET_FRONTIER_MEM_PTR mem_load sub.1 + # => [old_num_leaves] + push.0 - # => [h=0] + # => [h=0, old_num_leaves] repeat.32 - # => [h] - - # Load frontier[h] double word from memory - dup mul.8 add.LET_FRONTIER_MEM_PTR add.4 - exec.utils::mem_load_double_word - # => [VALUE_0, VALUE_1, h] - - # Write it back to the map at index h - dup.8 push.LET_FRONTIER_SLOT[0..2] - exec.double_word_array::set - dropw dropw - # => [h] - - add.1 - # => [h+1] + # => [h, old_num_leaves_shifted] + + # Check if bit h is 0 (this entry was written and needs saving) + dup.1 u32and.1 + if.false + # Load frontier[h] double word from memory + dup mul.8 add.LET_FRONTIER_MEM_PTR add.4 + exec.utils::mem_load_double_word + # => [VALUE_0, VALUE_1, h, old_num_leaves_shifted] + + # Write it back to the map at index h + dup.8 push.LET_FRONTIER_SLOT[0..2] + exec.double_word_array::set + # => [OLD_VALUE_0, OLD_VALUE_1, h, old_num_leaves_shifted] + + dropw dropw + # => [h, old_num_leaves_shifted] + end + + # Shift old_num_leaves right by 1, increment h + swap u32shr.1 swap add.1 + # => [h+1, old_num_leaves_shifted>>1] end - drop + drop drop # => [] end @@ -508,11 +608,10 @@ proc create_burn_note # => [faucet_id_suffix, faucet_id_prefix, exec_hint, ASSET_KEY] exec.network_account_target::new - # => [attachment_scheme, attachment_kind, NOTE_ATTACHMENT, ASSET_KEY] + # => [attachment_scheme, NOTE_ATTACHMENT, ASSET_KEY] # Save attachment data to locals loc_store.ATTACHMENT_SCHEME_LOC - loc_store.ATTACHMENT_KIND_LOC loc_storew_le.ATTACHMENT_LOC dropw # => [ASSET_KEY] @@ -525,10 +624,10 @@ proc create_burn_note push.BURN_NOTE_NUM_STORAGE_ITEMS push.0 # => [storage_ptr, num_storage_items, SERIAL_NUM, SCRIPT_ROOT] - exec.note::build_recipient + exec.note::compute_and_store_recipient # => [RECIPIENT] - push.PUBLIC_NOTE + push.NOTE_TYPE_PUBLIC push.DEFAULT_TAG # => [tag, note_type, RECIPIENT] @@ -539,27 +638,45 @@ proc create_burn_note call.output_note::create # => [note_idx, pad(15)] - # duplicate note_idx: one for set_attachment, one for add_asset - dup swapw loc_loadw_le.ATTACHMENT_LOC - # => [NOTE_ATTACHMENT, note_idx, note_idx, pad(11)] + # duplicate note_idx: one for add_word_attachment, one for add_asset + dup + # => [note_idx, note_idx, pad(15)] - loc_load.ATTACHMENT_KIND_LOC - loc_load.ATTACHMENT_SCHEME_LOC - # => [scheme, kind, NOTE_ATTACHMENT, note_idx, note_idx, pad(11)] + padw loc_loadw_le.ATTACHMENT_LOC + # => [NOTE_ATTACHMENT, note_idx, note_idx, pad(15)] - movup.6 - # => [note_idx, scheme, kind, NOTE_ATTACHMENT, note_idx, pad(11)] + loc_load.ATTACHMENT_SCHEME_LOC + # => [scheme, NOTE_ATTACHMENT, note_idx, note_idx, pad(15)] - exec.output_note::set_attachment - # => [note_idx, pad(11)] + # network_account_target is a word-sized attachment + exec.output_note::add_word_attachment + # => [note_idx, pad(15)] locaddr.CREATE_BURN_NOTE_BURN_ASSET_LOC exec.asset::load - # => [ASSET_KEY, ASSET_VALUE, note_idx, pad(11)] + # => [ASSET_KEY, ASSET_VALUE, note_idx, pad(15)] exec.output_note::add_asset - # => [pad(11)] + # => [pad(15)] + + dropw dropw dropw drop drop drop + # => [] +end - dropw dropw drop drop drop +#! Locks a fungible asset in the bridge's own vault. +#! +#! Used on the bridge-out path for Miden-native faucets (ones whose mint/burn authority the bridge +#! does not hold). Instead of creating a BURN note, the asset is simply added to the bridge's own +#! vault; the bridge-in claim side will later remove it via `unlock_and_send`. +#! +#! Inputs: [ASSET_KEY, ASSET_VALUE] +#! Outputs: [] +#! +#! Invocation: exec +proc lock_asset + exec.native_account::add_asset + # => [ASSET_VALUE'] + + dropw # => [] end diff --git a/crates/miden-agglayer/asm/agglayer/bridge/canonical_zeros.masm b/crates/miden-agglayer/asm/agglayer/bridge/canonical_zeros.masm index 8ebc0ba1d8..11ceaae5da 100644 --- a/crates/miden-agglayer/asm/agglayer/bridge/canonical_zeros.masm +++ b/crates/miden-agglayer/asm/agglayer/bridge/canonical_zeros.masm @@ -1,8 +1,8 @@ -# This file is generated by build.rs, do not modify +# This file contains deterministic values. Do not modify manually. -# This file contains the canonical zeros for the Keccak hash function. +# This file contains the canonical zeros for the Keccak hash function. # Zero of height `n` (ZERO_N) is the root of the binary tree of height `n` with leaves equal zero. -# +# # Since the Keccak hash is represented by eight u32 values, each constant consists of two Words. const ZERO_0_L = [0, 0, 0, 0] @@ -102,7 +102,8 @@ const ZERO_31_L = [2340505732, 1648733876, 2660540036, 3759582231] const ZERO_31_R = [2389186238, 4049365781, 1653344606, 2840985724] use ::agglayer::common::utils::mem_store_double_word - + + #! Inputs: [zeros_ptr] #! Outputs: [] pub proc load_zeros_to_memory diff --git a/crates/miden-agglayer/asm/agglayer/common/constants.masm b/crates/miden-agglayer/asm/agglayer/common/constants.masm new file mode 100644 index 0000000000..d0d1b24c9b --- /dev/null +++ b/crates/miden-agglayer/asm/agglayer/common/constants.masm @@ -0,0 +1,10 @@ +# AggLayer-wide numeric constants shared by bridge-in and other modules. +# +# Each `const NAME = ` line is parsed at crate build time into +# `pub const NAME: u32 = value` in `agglayer_constants.rs` (see `build.rs`). + +# NETWORK IDS +# ================================================================================================= + +# AggLayer-assigned network ID for this Miden chain. +pub const MIDEN_NETWORK_ID = 77 diff --git a/crates/miden-agglayer/asm/agglayer/common/utils.masm b/crates/miden-agglayer/asm/agglayer/common/utils.masm index 45bed28405..79a1fe8c5f 100644 --- a/crates/miden-agglayer/asm/agglayer/common/utils.masm +++ b/crates/miden-agglayer/asm/agglayer/common/utils.masm @@ -10,7 +10,7 @@ use miden::protocol::types::MemoryAddress #! #! Inputs: [value] #! Outputs: [swapped] -proc swap_u32_bytes +pub proc swap_u32_bytes # part0 = (value & 0xFF) << 24 dup u32and.0xFF u32shl.24 # => [part0, value] @@ -40,7 +40,7 @@ end #! Outputs: [WORD_1, WORD_2, ptr] #! #! Total cycles: 28 -proc mem_store_double_word( +pub proc mem_store_double_word( double_word_to_store: DoubleWord, mem_ptr: MemoryAddress ) -> (DoubleWord, MemoryAddress) @@ -55,7 +55,7 @@ end #! #! Inputs: [WORD_1, WORD_2, ptr] #! Outputs: [] -proc mem_store_double_word_unaligned( +pub proc mem_store_double_word_unaligned( double_word_to_store: DoubleWord, mem_ptr: MemoryAddress ) @@ -83,10 +83,37 @@ end #! #! Inputs: [ptr] #! Outputs: [WORD_1, WORD_2] -proc mem_load_double_word(mem_ptr: MemoryAddress) -> DoubleWord +pub proc mem_load_double_word(mem_ptr: MemoryAddress) -> DoubleWord padw dup.4 add.4 mem_loadw_le # => [WORD_2, ptr] padw movup.8 mem_loadw_le # => [WORD_1, WORD_2] end + +#! Loads two words from the provided unaligned (not a multiple of 4) memory address. +#! +#! Mirrors `mem_store_double_word_unaligned`: reads 8 consecutive felts via individual +#! `mem_load`s and stacks them as `[WORD_1, WORD_2]` (with WORD_1's first felt on top of +#! the stack, matching the order produced by the aligned `mem_load_double_word`). +#! +#! Inputs: [ptr] +#! Outputs: [WORD_1, WORD_2] +pub proc mem_load_double_word_unaligned(mem_ptr: MemoryAddress) -> DoubleWord + # Load WORD_2 first so it ends up underneath WORD_1 on the final stack. + dup add.7 mem_load + dup.1 add.6 mem_load + dup.2 add.5 mem_load + dup.3 add.4 mem_load + # => [w2_0, w2_1, w2_2, w2_3, ptr] + + # Load WORD_1 on top. + dup.4 add.3 mem_load + dup.5 add.2 mem_load + dup.6 add.1 mem_load + dup.7 mem_load + # => [w1_0, w1_1, w1_2, w1_3, w2_0, w2_1, w2_2, w2_3, ptr] + + movup.8 drop + # => [WORD_1, WORD_2] +end diff --git a/crates/miden-agglayer/asm/agglayer/faucet/mod.masm b/crates/miden-agglayer/asm/agglayer/faucet/mod.masm index 408c0e93e1..4b47a1ef60 100644 --- a/crates/miden-agglayer/asm/agglayer/faucet/mod.masm +++ b/crates/miden-agglayer/asm/agglayer/faucet/mod.masm @@ -1,171 +1,6 @@ -use miden::core::sys -use agglayer::common::utils -use agglayer::common::asset_conversion -use miden::protocol::active_account - -# CONSTANTS -# ================================================================================================= - -# Storage slots for conversion metadata. -# Slot 1: [addr_felt0, addr_felt1, addr_felt2, addr_felt3] — first 4 felts of origin token address -const CONVERSION_INFO_1_SLOT = word("agglayer::faucet::conversion_info_1") -# Slot 2: [addr_felt4, origin_network, scale, 0] — remaining address felt + origin network + scale -const CONVERSION_INFO_2_SLOT = word("agglayer::faucet::conversion_info_2") - -# Storage slots for the pre-computed metadata hash (keccak256 of ABI-encoded token metadata). -# The 32-byte hash is split across two value slots, each holding 4 u32 felts. -const METADATA_HASH_LO_SLOT = word("agglayer::faucet::metadata_hash_lo") -const METADATA_HASH_HI_SLOT = word("agglayer::faucet::metadata_hash_hi") - # PUBLIC INTERFACE # ================================================================================================= -#! Returns the origin token address (5 felts) from faucet conversion storage. -#! -#! Reads conversion_info_1 (first 4 felts of address) and conversion_info_2 (5th felt) from storage. -#! -#! Inputs: [] -#! Outputs: [addr0, addr1, addr2, addr3, addr4] -#! -#! Invocation: exec -pub proc get_origin_token_address - push.CONVERSION_INFO_1_SLOT[0..2] - exec.active_account::get_item - # => [addr0, addr1, addr2, addr3] - - # Read slot 2: [addr4, origin_network, scale, 0] - push.CONVERSION_INFO_2_SLOT[0..2] - exec.active_account::get_item - # => [addr4, origin_network, scale, 0, addr0, addr1, addr2, addr3] - - # Keep only addr4, drop origin_network, scale, 0 - movdn.7 drop drop drop - # => [addr0, addr1, addr2, addr3, addr4] -end - -#! Returns the origin network identifier from faucet conversion storage. -#! -#! Inputs: [] -#! Outputs: [origin_network] -#! -#! Invocation: exec -pub proc get_origin_network - push.CONVERSION_INFO_2_SLOT[0..2] - exec.active_account::get_item - # => [addr4, origin_network, scale, 0] - - drop movdn.2 drop drop - # => [origin_network] -end - -#! Returns the scale factor from faucet conversion storage. -#! -#! Inputs: [] -#! Outputs: [scale] -#! -#! Invocation: exec -proc get_scale_inner - push.CONVERSION_INFO_2_SLOT[0..2] - exec.active_account::get_item - # => [addr4, origin_network, scale, 0] - - drop drop swap drop - # => [scale] -end - -#! Returns the pre-computed metadata hash (8 u32 felts) from faucet storage. -#! -#! The metadata hash is `keccak256(abi.encode(name, symbol, decimals))` and is stored across two -#! value slots (lo and hi, 4 felts each). -#! -#! Inputs: [pad(16)] -#! Outputs: [METADATA_HASH_LO(4), METADATA_HASH_HI(4), pad(8)] -#! -#! Invocation: call -pub proc get_metadata_hash - push.METADATA_HASH_LO_SLOT[0..2] - exec.active_account::get_item - # => [lo0, lo1, lo2, lo3, pad(16)] - - push.METADATA_HASH_HI_SLOT[0..2] - exec.active_account::get_item - # => [hi0, hi1, hi2, hi3, lo0, lo1, lo2, lo3, pad(16)] - - # Rearrange: move hi below lo - swapw - # => [lo0, lo1, lo2, lo3, hi0, hi1, hi2, hi3, pad(16)] - - # Drop 8 excess padding elements (24 -> 16) - swapdw dropw dropw - # => [METADATA_HASH_LO(4), METADATA_HASH_HI(4), pad(8)] -end - -#! Returns the scale factor from faucet conversion storage. -#! -#! Called via FPI from the bridge account. -#! -#! Inputs: [pad(16)] -#! Outputs: [scale, pad(15)] -#! -#! Invocation: call -pub proc get_scale - exec.get_scale_inner - # => [scale, pad(16)] - - swap drop - # => [scale, pad(15)] -end - -#! Converts a native Miden asset amount to origin asset data using the stored conversion metadata -#! (origin_token_address, origin_network, and scale). -#! -#! This procedure is intended to be called via FPI from the bridge account. -#! It reads the faucet's conversion metadata from storage, scales the native amount to U256 format, -#! and returns the result along with origin token address and network. -#! -#! Inputs: [amount, pad(15)] -#! Outputs: [AMOUNT_U256_LO, AMOUNT_U256_HI, addr0, addr1, addr2, addr3, addr4, origin_network, pad(2)] -#! -#! Where: -#! - amount: The native Miden asset amount -#! - AMOUNT_U256: The scaled amount as 8 u32 limbs (little-endian U256) -#! - addr0..addr4: Origin token address (5 felts, u32 limbs) -#! - origin_network: Origin network identifier -#! -#! Invocation: call -pub proc asset_to_origin_asset - # => [amount, pad(15)] - - # Step 1: Get scale from storage - exec.get_scale_inner swap - # => [amount, scale, pad(15)] - - # Step 2: Scale amount to U256 - exec.asset_conversion::scale_native_amount_to_u256 - exec.asset_conversion::reverse_limbs_and_change_byte_endianness - # => [U256_LO, U256_HI, pad(15)] - - # Step 3: Get origin token address - exec.get_origin_token_address - # => [addr0, addr1, addr2, addr3, addr4, U256_LO, U256_HI, pad(15)] - - # Move address below the U256 amount - repeat.5 movdn.12 end - # => [U256_LO, U256_HI, addr0, addr1, addr2, addr3, addr4, pad(15)] - - # Step 4: Get origin network - exec.get_origin_network - exec.utils::swap_u32_bytes - # => [origin_network, U256_LO, U256_HI, addr0..addr4, pad(15)] - - # Move origin_network after the address fields - movdn.13 - # => [U256_LO, U256_HI, addr0, addr1, addr2, addr3, addr4, origin_network, pad(15)] - - exec.sys::truncate_stack - # => [U256_LO, U256_HI, addr0, addr1, addr2, addr3, addr4, origin_network, pad(2)] -end - #! Burns the fungible asset from the active note. #! #! This procedure retrieves the asset from the active note and burns it. The note must contain @@ -182,14 +17,14 @@ end #! - the amount about to be burned is greater than the outstanding supply of the asset. #! #! Invocation: call -pub use ::miden::standards::faucets::basic_fungible::burn +pub use ::miden::standards::faucets::fungible::receive_and_burn -#! Re-export the network fungible faucet's `mint_and_send` procedure. +#! Re-export the fungible faucet's `mint_and_send` procedure. #! -#! See `miden::standards::faucets::network_fungible::mint_and_send` for more details. +#! See `miden::standards::faucets::fungible::mint_and_send` for more details. #! -#! Inputs: [amount, tag, note_type, RECIPIENT, pad(9)] +#! Inputs: [ASSET_KEY, ASSET_VALUE, tag, note_type, RECIPIENT, pad(2)] #! Outputs: [note_idx, pad(15)] #! #! Invocation: call -pub use ::miden::standards::faucets::network_fungible::mint_and_send +pub use ::miden::standards::faucets::fungible::mint_and_send diff --git a/crates/miden-agglayer/asm/components/bridge.masm b/crates/miden-agglayer/asm/components/bridge.masm index 4c38d5a019..14c5169f93 100644 --- a/crates/miden-agglayer/asm/components/bridge.masm +++ b/crates/miden-agglayer/asm/components/bridge.masm @@ -4,11 +4,13 @@ # # The bridge exposes: # - `register_faucet` from the bridge_config module +# - `store_faucet_metadata_hash` from the bridge_config module # - `update_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_in::claim pub use ::agglayer::bridge::bridge_out::bridge_out diff --git a/crates/miden-agglayer/asm/components/faucet.masm b/crates/miden-agglayer/asm/components/faucet.masm index 71927c63d9..83202199da 100644 --- a/crates/miden-agglayer/asm/components/faucet.masm +++ b/crates/miden-agglayer/asm/components/faucet.masm @@ -3,15 +3,9 @@ # This is a thin wrapper that re-exports faucet-related procedures from the agglayer library. # # The faucet exposes: -# - `mint_and_send` from the network fungible faucet (for MINT note consumption, with owner -# verification) -# - `asset_to_origin_asset` for bridge-out FPI -# - `get_metadata_hash` for bridge-out FPI (metadata hash retrieval) -# - `get_scale` for bridge-in FPI (amount verification) -# - `burn` for bridge-out +# - `mint_and_send` from the fungible faucet (for MINT note consumption, with owner verification +# gated by the active mint policy) +# - `receive_and_burn` for bridge-out pub use ::agglayer::faucet::mint_and_send -pub use ::agglayer::faucet::asset_to_origin_asset -pub use ::agglayer::faucet::get_metadata_hash -pub use ::agglayer::faucet::get_scale -pub use ::agglayer::faucet::burn +pub use ::agglayer::faucet::receive_and_burn diff --git a/crates/miden-agglayer/asm/note_scripts/CONFIG_AGG_BRIDGE.masm b/crates/miden-agglayer/asm/note_scripts/CONFIG_AGG_BRIDGE.masm deleted file mode 100644 index 532aa49ade..0000000000 --- a/crates/miden-agglayer/asm/note_scripts/CONFIG_AGG_BRIDGE.masm +++ /dev/null @@ -1,90 +0,0 @@ -use agglayer::bridge::bridge_config -use miden::protocol::active_note -use miden::standards::attachments::network_account_target - -# CONSTANTS -# ================================================================================================= - -const CONFIG_AGG_BRIDGE_NUM_STORAGE_ITEMS = 7 - -const STORAGE_START_PTR = 0 -const ORIGIN_TOKEN_ADDR_0 = STORAGE_START_PTR -const ORIGIN_TOKEN_ADDR_4 = 4 - -# ERRORS -# ================================================================================================= - -const ERR_CONFIG_AGG_BRIDGE_UNEXPECTED_STORAGE_ITEMS = "CONFIG_AGG_BRIDGE expects exactly 7 note storage items" -const ERR_CONFIG_AGG_BRIDGE_TARGET_ACCOUNT_MISMATCH = "CONFIG_AGG_BRIDGE note attachment target account does not match consuming account" - -#! Agglayer Bridge CONFIG_AGG_BRIDGE script: registers a faucet in the bridge's faucet registry and -#! token registry. -#! -#! This note can only be consumed by the Agglayer Bridge account that is targeted by the note -#! attachment, and only if the note was sent by the bridge admin. -#! Upon consumption, it registers the faucet ID and origin token address mapping in the bridge. -#! -#! Requires that the account exposes: -#! - agglayer::bridge_config::register_faucet procedure. -#! -#! Inputs: [ARGS, pad(12)] -#! Outputs: [pad(16)] -#! -#! NoteStorage layout (7 felts total): -#! - origin_token_addr_0 [0] : 1 felt -#! - origin_token_addr_1 [1] : 1 felt -#! - origin_token_addr_2 [2] : 1 felt -#! - origin_token_addr_3 [3] : 1 felt -#! - origin_token_addr_4 [4] : 1 felt -#! - faucet_id_suffix [5] : 1 felt -#! - faucet_id_prefix [6] : 1 felt -#! -#! Where: -#! - faucet_id_suffix: Suffix felt of the faucet account ID to register. -#! - faucet_id_prefix: Prefix felt of the faucet account ID to register. -#! -#! Panics if: -#! - The note attachment target account does not match the consuming bridge account. -#! - The note does not contain exactly 7 storage items. -#! - The account does not expose the register_faucet procedure. -@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_CONFIG_AGG_BRIDGE_TARGET_ACCOUNT_MISMATCH - # => [pad(16)] - - # Load note storage to memory - push.STORAGE_START_PTR exec.active_note::get_storage - # => [num_storage_items, dest_ptr, pad(16)] - - # Validate the number of storage items - push.CONFIG_AGG_BRIDGE_NUM_STORAGE_ITEMS assert_eq.err=ERR_CONFIG_AGG_BRIDGE_UNEXPECTED_STORAGE_ITEMS drop - # => [pad(16)] - - # Load origin_token_addr(5) and faucet_id from memory - # register_faucet expects: [origin_token_addr(5), faucet_id_suffix, faucet_id_prefix, pad(9)] - - # Load origin_token_addr_4, faucet_id_suffix, and faucet_id_prefix onto the sack. Notice that we - # can use `mem_loadw_le` here: that allows us to reuse the existing zeros on the stack, and - # since note memory was not initialized, fourth element on the stack will be equal ZERO, which - # is what we want. - mem_loadw_le.ORIGIN_TOKEN_ADDR_4 - # => [addr4, faucet_id_suffix, faucet_id_prefix, pad(13)] - - # Load remaining origin_token_addr_[0..3] onto the stack - padw mem_loadw_le.ORIGIN_TOKEN_ADDR_0 - # => [addr0, addr1, addr2, addr3, addr4, faucet_id_suffix, faucet_id_prefix, pad(13)] - - # Register the faucet in the bridge - # => [addr0, addr1, addr2, addr3, addr4, faucet_id_suffix, faucet_id_prefix, pad(9), pad(4)] - - call.bridge_config::register_faucet - # => [pad(16), pad(4)] - - dropw - # => [pad(16)] -end diff --git a/crates/miden-agglayer/asm/note_scripts/B2AGG.masm b/crates/miden-agglayer/asm/note_scripts/b2agg.masm similarity index 90% rename from crates/miden-agglayer/asm/note_scripts/B2AGG.masm rename to crates/miden-agglayer/asm/note_scripts/b2agg.masm index 4bd5b321b9..ae5906e1a3 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 destination network ID equals Miden's AggLayer network ID. @note_script pub proc main dropw @@ -77,26 +78,26 @@ pub proc main # Store note storage -> mem[8..14] push.B2AGG_NOTE_STORAGE_PTR exec.active_note::get_storage - # => [num_storage_items, storage_ptr, pad(16)] + # => [num_storage_items, pad(16)] # Validate the number of storage items push.B2AGG_NOTE_NUM_STORAGE_ITEMS assert_eq.err=ERR_B2AGG_UNEXPECTED_NUMBER_OF_STORAGE_ITEMS - # => [storage_ptr, pad(16)] + # => [pad(16)] # load the 6 B2AGG felts from B2AGG_NOTE_STORAGE_PTR as two words - add.4 mem_loadw_le swapw mem_loadw_le.B2AGG_NOTE_STORAGE_PTR + push.B2AGG_NOTE_STORAGE_PTR add.4 mem_loadw_le swapw mem_loadw_le.B2AGG_NOTE_STORAGE_PTR # => [dest_network, dest_address(5), pad(10)] # Store note assets -> mem[0..8] push.B2AGG_NOTE_ASSETS_PTR exec.active_note::get_assets - # => [num_assets, assets_ptr, dest_network, dest_address(5), pad(10)] + # => [num_assets, dest_network, dest_address(5), pad(10)] # Must be exactly 1 asset push.B2AGG_NOTE_NUM_ASSETS assert_eq.err=ERR_B2AGG_WRONG_NUMBER_OF_ASSETS - # => [assets_ptr, dest_network, dest_address(5), pad(10)] + # => [dest_network, dest_address(5), pad(10)] # Load asset onto the stack from B2AGG_NOTE_ASSETS_PTR - exec.asset::load + push.B2AGG_NOTE_ASSETS_PTR exec.asset::load # => [ASSET_KEY, ASSET_VALUE, dest_network, dest_address(5), pad(10)] call.bridge_out::bridge_out diff --git a/crates/miden-agglayer/asm/note_scripts/CLAIM.masm b/crates/miden-agglayer/asm/note_scripts/claim.masm similarity index 98% rename from crates/miden-agglayer/asm/note_scripts/CLAIM.masm rename to crates/miden-agglayer/asm/note_scripts/claim.masm index 292eec2128..d8bdfdd65f 100644 --- a/crates/miden-agglayer/asm/note_scripts/CLAIM.masm +++ b/crates/miden-agglayer/asm/note_scripts/claim.masm @@ -86,7 +86,7 @@ pub proc main # => [pad(16)] # Load CLAIM note storage into memory, starting at address 0 - push.CLAIM_NOTE_STORAGE_PTR exec.active_note::get_storage drop drop + push.CLAIM_NOTE_STORAGE_PTR exec.active_note::get_storage drop # => [pad(16)] exec.write_claim_data_into_advice_map_by_key @@ -161,7 +161,7 @@ proc write_claim_data_into_advice_map_by_key exec.poseidon2::hash_elements # => [PROOF_DATA_KEY, LEAF_DATA_KEY] - push.PROOF_DATA_SIZE push.PROOF_DATA_START_PTR + push.PROOF_DATA_SIZE add.PROOF_DATA_START_PTR push.PROOF_DATA_START_PTR movdn.5 movdn.5 # => [PROOF_DATA_KEY, start_ptr, end_ptr, LEAF_DATA_KEY] diff --git a/crates/miden-agglayer/asm/note_scripts/config_agg_bridge.masm b/crates/miden-agglayer/asm/note_scripts/config_agg_bridge.masm new file mode 100644 index 0000000000..018f114844 --- /dev/null +++ b/crates/miden-agglayer/asm/note_scripts/config_agg_bridge.masm @@ -0,0 +1,140 @@ +use agglayer::bridge::bridge_config +use agglayer::common::utils +use miden::protocol::active_note +use miden::standards::attachments::network_account_target + +# CONSTANTS +# ================================================================================================= + +const CONFIG_AGG_BRIDGE_NUM_STORAGE_ITEMS = 18 + +const STORAGE_START_PTR = 0 + +const ORIGIN_TOKEN_ADDR_0 = 0 +const ORIGIN_TOKEN_ADDR_1 = 1 +const ORIGIN_TOKEN_ADDR_2 = 2 +const ORIGIN_TOKEN_ADDR_3 = 3 +const ORIGIN_TOKEN_ADDR_4 = 4 +const FAUCET_ID_SUFFIX = 5 +const FAUCET_ID_PREFIX = 6 +const SCALE = 7 +const ORIGIN_NETWORK = 8 +const IS_NATIVE = 9 +const METADATA_HASH_LO_0 = 10 +const METADATA_HASH_LO_1 = 11 +const METADATA_HASH_LO_2 = 12 +const METADATA_HASH_LO_3 = 13 +const METADATA_HASH_HI_0 = 14 +const METADATA_HASH_HI_1 = 15 +const METADATA_HASH_HI_2 = 16 +const METADATA_HASH_HI_3 = 17 + +# ERRORS +# ================================================================================================= + +const ERR_CONFIG_AGG_BRIDGE_UNEXPECTED_STORAGE_ITEMS = "CONFIG_AGG_BRIDGE expects exactly 18 note storage items" +const ERR_CONFIG_AGG_BRIDGE_TARGET_ACCOUNT_MISMATCH = "CONFIG_AGG_BRIDGE note attachment target account does not match consuming account" + +#! Agglayer Bridge CONFIG_AGG_BRIDGE script: registers a faucet in the bridge's faucet registry, +#! token registry, and faucet metadata map. +#! +#! This note can only be consumed by the Agglayer Bridge account that is targeted by the note +#! attachment, and only if the note was sent by the bridge admin. +#! Upon consumption, it registers the faucet ID, origin token address mapping, scale factor, +#! origin network, is_native flag, and metadata hash in the bridge. +#! +#! The registration is split into two calls due to the 16-element stack limit: +#! 1. register_faucet: stores address, scale, origin_network, is_native, and registry entries +#! 2. store_faucet_metadata_hash: stores the metadata hash +#! +#! Requires that the account exposes: +#! - agglayer::bridge_config::register_faucet procedure. +#! - agglayer::bridge_config::store_faucet_metadata_hash procedure. +#! +#! Inputs: [ARGS, pad(12)] +#! Outputs: [pad(16)] +#! +#! NoteStorage layout (18 felts total): +#! - origin_token_addr_0 [0] : 1 felt +#! - origin_token_addr_1 [1] : 1 felt +#! - origin_token_addr_2 [2] : 1 felt +#! - origin_token_addr_3 [3] : 1 felt +#! - origin_token_addr_4 [4] : 1 felt +#! - faucet_id_suffix [5] : 1 felt +#! - faucet_id_prefix [6] : 1 felt +#! - scale [7] : 1 felt +#! - origin_network [8] : 1 felt (LE-packed u32, paired with origin_token_addr in the +#! token-registry key per agglayer #2860) +#! - is_native [9] : 1 felt +#! - metadata_hash_lo [10] : 4 felts +#! - metadata_hash_hi [14] : 4 felts +#! +#! Panics if: +#! - The note attachment target account does not match the consuming bridge account. +#! - The note does not contain exactly 18 storage items. +#! - The account does not expose the register_faucet or store_faucet_metadata_hash procedures. +@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_CONFIG_AGG_BRIDGE_TARGET_ACCOUNT_MISMATCH + # => [pad(16)] + + # Load note storage to memory + push.STORAGE_START_PTR exec.active_note::get_storage + # => [num_storage_items, pad(16)] + + # Validate the number of storage items + push.CONFIG_AGG_BRIDGE_NUM_STORAGE_ITEMS assert_eq.err=ERR_CONFIG_AGG_BRIDGE_UNEXPECTED_STORAGE_ITEMS + # => [pad(16)] + + # --- Call 1: register_faucet --- + # Expects: [addr0, addr1, addr2, addr3, addr4, faucet_id_suffix, faucet_id_prefix, scale, origin_network, is_native, pad(6)] + + # Push the call's 6 trailing pad zeros first, then build the 10 args on top. + padw push.0.0 + # => [pad(6), pad(16)] + + mem_load.IS_NATIVE + mem_load.ORIGIN_NETWORK + mem_load.SCALE + mem_load.FAUCET_ID_PREFIX + mem_load.FAUCET_ID_SUFFIX + mem_load.ORIGIN_TOKEN_ADDR_4 + # => [addr4, faucet_id_suffix, faucet_id_prefix, scale, origin_network, is_native, pad(6), pad(16)] + + mem_load.ORIGIN_TOKEN_ADDR_3 + mem_load.ORIGIN_TOKEN_ADDR_2 + mem_load.ORIGIN_TOKEN_ADDR_1 + mem_load.ORIGIN_TOKEN_ADDR_0 + # => [addr0, addr1, addr2, addr3, addr4, faucet_id_suffix, faucet_id_prefix, scale, origin_network, is_native, pad(6), pad(16)] + + call.bridge_config::register_faucet + # => [pad(32)] + + # --- Call 2: store_faucet_metadata_hash --- + # Expects: [faucet_id_suffix, faucet_id_prefix, MH_LO, MH_HI, pad(6)] + + # Push the call's 6 trailing pad zeros first, then build the 10 args on top. + padw push.0.0 + # => [pad(6), pad(32)] + + push.METADATA_HASH_LO_0 exec.utils::mem_load_double_word_unaligned + # => [MH_LO, MH_HI, pad(6), pad(32)] + + mem_load.FAUCET_ID_PREFIX + mem_load.FAUCET_ID_SUFFIX + # => [faucet_id_suffix, faucet_id_prefix, MH_LO, MH_HI, pad(6), pad(32)] + + call.bridge_config::store_faucet_metadata_hash + # => [pad(48)] + + # Drop 32 to bring sdepth back to the 16-minimum. + repeat.8 + dropw + end + # => [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 similarity index 96% rename from crates/miden-agglayer/asm/note_scripts/UPDATE_GER.masm rename to crates/miden-agglayer/asm/note_scripts/update_ger.masm index e9168144a8..bc1e1dd504 100644 --- a/crates/miden-agglayer/asm/note_scripts/UPDATE_GER.masm +++ b/crates/miden-agglayer/asm/note_scripts/update_ger.masm @@ -51,10 +51,10 @@ pub proc main # Load note storage to memory push.STORAGE_PTR_GER_LOWER exec.active_note::get_storage - # => [num_storage_items, dest_ptr, pad(16)] + # => [num_storage_items, pad(16)] # Validate the number of storage items - push.UPDATE_GER_NOTE_NUM_STORAGE_ITEMS assert_eq.err=ERR_UPDATE_GER_UNEXPECTED_NUMBER_OF_STORAGE_ITEMS drop + push.UPDATE_GER_NOTE_NUM_STORAGE_ITEMS assert_eq.err=ERR_UPDATE_GER_UNEXPECTED_NUMBER_OF_STORAGE_ITEMS # => [pad(16)] # Load GER_LOWER and GER_UPPER from note storage diff --git a/crates/miden-agglayer/build.rs b/crates/miden-agglayer/build.rs index da8a994a50..5300fa2a9f 100644 --- a/crates/miden-agglayer/build.rs +++ b/crates/miden-agglayer/build.rs @@ -1,3 +1,4 @@ +use std::collections::{BTreeSet, HashSet}; use std::env; use std::fmt::Write; use std::path::Path; @@ -6,33 +7,34 @@ use std::sync::Arc; use fs_err as fs; use miden_assembly::diagnostics::{IntoDiagnostic, NamedSource, Result, WrapErr}; use miden_assembly::{Assembler, Library, Report}; +use miden_core::Word; use miden_crypto::hash::keccak::{Keccak256, Keccak256Digest}; -use miden_protocol::account::{ - AccountCode, - AccountComponent, - AccountComponentMetadata, - AccountType, -}; +use miden_protocol::account::{AccountCode, AccountComponent, AccountComponentMetadata}; +use miden_protocol::note::NoteScriptRoot; use miden_protocol::transaction::TransactionKernel; -use miden_standards::account::auth::NoAuth; -use miden_standards::account::mint_policies::OwnerControlled; +use miden_standards::account::access::Authority; +use miden_standards::account::auth::AuthNetworkAccount; +use miden_standards::account::policies::{ + BurnPolicyConfig, + MintPolicyConfig, + PolicyRegistration, + TokenPolicyManager, + TransferPolicy, +}; +use regex::Regex; // CONSTANTS // ================================================================================================ -/// Defines whether the build script should generate files in `/src`. -/// The docs.rs build pipeline has a read-only filesystem, so we have to avoid writing to `src`, -/// otherwise the docs will fail to build there. Note that writing to `OUT_DIR` is fine. -const BUILD_GENERATED_FILES_IN_SRC: bool = option_env!("BUILD_GENERATED_FILES_IN_SRC").is_some(); - const ASSETS_DIR: &str = "assets"; const ASM_DIR: &str = "asm"; const ASM_NOTE_SCRIPTS_DIR: &str = "note_scripts"; const ASM_AGGLAYER_DIR: &str = "agglayer"; const ASM_AGGLAYER_BRIDGE_DIR: &str = "agglayer/bridge"; +const ASM_AGGLAYER_CONSTANTS_MASM: &str = "agglayer/common/constants.masm"; const ASM_COMPONENTS_DIR: &str = "components"; -const AGGLAYER_ERRORS_FILE: &str = "src/errors/agglayer.rs"; +const AGGLAYER_ERRORS_RS_FILE: &str = "agglayer_errors.rs"; const AGGLAYER_ERRORS_ARRAY_NAME: &str = "AGGLAYER_ERRORS"; const AGGLAYER_GLOBAL_CONSTANTS_FILE_NAME: &str = "agglayer_constants.rs"; @@ -46,21 +48,17 @@ const AGGLAYER_GLOBAL_CONSTANTS_FILE_NAME: &str = "agglayer_constants.rs"; fn main() -> Result<()> { // re-build when the MASM code changes println!("cargo::rerun-if-changed={ASM_DIR}/"); - println!("cargo::rerun-if-env-changed=BUILD_GENERATED_FILES_IN_SRC"); + println!("cargo::rerun-if-env-changed=REGENERATE_CANONICAL_ZEROS"); - // Copies the MASM code to the build directory let crate_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); let build_dir = env::var("OUT_DIR").unwrap(); - let src = Path::new(&crate_dir).join(ASM_DIR); - // generate canonical zeros in `asm/agglayer/bridge/canonical_zeros.masm` - generate_canonical_zeros(&src.join(ASM_AGGLAYER_BRIDGE_DIR))?; + // validate (or regenerate) canonical zeros in `asm/agglayer/bridge/canonical_zeros.masm` + let crate_path = Path::new(&crate_dir); + ensure_canonical_zeros(&crate_path.join(ASM_DIR).join(ASM_AGGLAYER_BRIDGE_DIR))?; - let dst = Path::new(&build_dir).to_path_buf(); - shared::copy_directory(src, &dst, ASM_DIR)?; - - // set source directory to {OUT_DIR}/asm - let source_dir = dst.join(ASM_DIR); + // Read MASM sources directly from the crate's asm/ directory. + let source_dir = crate_path.join(ASM_DIR); // set target directory to {OUT_DIR}/assets let target_dir = Path::new(&build_dir).join(ASSETS_DIR); @@ -88,9 +86,14 @@ fn main() -> Result<()> { // generate agglayer specific constants let constants_out_path = Path::new(&build_dir).join(AGGLAYER_GLOBAL_CONSTANTS_FILE_NAME); - generate_agglayer_constants(constants_out_path, component_libraries)?; + let agglayer_constants_masm_path = crate_path.join(ASM_DIR).join(ASM_AGGLAYER_CONSTANTS_MASM); + generate_agglayer_constants( + constants_out_path, + component_libraries, + &agglayer_constants_masm_path, + )?; - generate_error_constants(&source_dir)?; + generate_error_constants(&source_dir, &build_dir)?; Ok(()) } @@ -214,14 +217,78 @@ fn compile_account_components( // GENERATE AGGLAYER CONSTANTS // ================================================================================================ +/// Parses every decimal `u32` constant from `asm/agglayer/common/constants.masm`. +/// +/// Recognized lines (whitespace-flexible, one definition per line, `#` comments ignored by the +/// regex): +/// +/// ```text +/// const SOME_NAME = 123 +/// ``` +/// +/// Each match is emitted to `agglayer_constants.rs` as `pub const SOME_NAME: u32`. +/// Duplicate `const` names in the same file are a build error. Non-decimal values (e.g. `word(...)` +/// or array literals) are not parsed here; add support in this function when needed. +fn parse_numeric_constants_from_constants_masm(masm_path: &Path) -> Result> { + // Read the full `constants.masm` text; parsing is line-based so we need the whole file. + let contents = fs::read_to_string(masm_path) + .into_diagnostic() + .wrap_err_with(|| format!("failed to read {}", masm_path.display()))?; + + // One line per match: optional leading space, optional `pub` visibility, `const`, identifier + // (no leading digit), `=`, decimal digits only. `(?m)^` makes `^` match after newlines so we + // skip comment-only lines. + let re = Regex::new(r"(?m)^\s*(?:pub\s+)?const\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(\d+)\s*$") + .expect("constants.masm parse regex should compile"); + + // `out` preserves declaration order; `seen` rejects duplicate const names in the same file. + let mut out = Vec::new(); + let mut seen = HashSet::new(); + + for caps in re.captures_iter(&contents) { + let name = caps.get(1).expect("group 1").as_str(); + + // Require each identifier at most once so generated Rust names are unique. + if !seen.insert(name.to_string()) { + return Err(Report::msg(format!( + "duplicate `const {name}` in {}", + masm_path.display() + ))); + } + + // Right-hand side must fit `u32` (same range we emit in Rust). + let raw = caps.get(2).expect("group 2").as_str(); + let value = raw.parse::().map_err(|_| { + Report::msg(format!( + "`const {name}` value `{raw}` is not a valid u32 in {}", + masm_path.display() + )) + })?; + + out.push((name.to_string(), value)); + } + + // Empty match set is almost certainly a misconfigured or mistyped `constants.masm`. + if out.is_empty() { + return Err(Report::msg(format!( + "{} does not contain any constants to parse", + masm_path.display() + ))); + } + + Ok(out) +} + /// Generates a Rust file containing AggLayer specific constants. /// -/// At the moment, this file contains the following constants: +/// This file contains: +/// - All the constants listed in the `constants.masm` file. /// - AggLayer Bridge code commitment. /// - AggLayer Faucet code commitment. fn generate_agglayer_constants( target_file: impl AsRef, component_libraries: Vec<(String, Library)>, + constants_masm_path: &Path, ) -> Result<()> { let mut file_contents = String::new(); @@ -239,51 +306,72 @@ fn generate_agglayer_constants( ) .unwrap(); + let masm_constants = parse_numeric_constants_from_constants_masm(constants_masm_path)?; + for (name, value) in &masm_constants { + writeln!(file_contents, "pub const {name}: u32 = {value};\n").unwrap(); + } + // Create a dummy metadata to be able to create components. We only interested in the resulting // code commitment, so it doesn't matter what does this metadata holds. - let dummy_metadata = AccountComponentMetadata::new("dummy", AccountType::all()); + let dummy_metadata = AccountComponentMetadata::new("dummy"); // iterate over the AggLayer Bridge and AggLayer Faucet libraries for (lib_name, content_library) in component_libraries { let agglayer_component = AccountComponent::new(content_library, vec![], dummy_metadata.clone()).unwrap(); - // The faucet account includes Ownable2Step and OwnerControlled components - // alongside the agglayer faucet component, since network_fungible::mint_and_send - // requires these for access control. + // The faucet account includes Ownable2Step and OwnerControlled components for mint and burn + // policies alongside the agglayer faucet component, since + // fungible::mint_and_send requires these for access control. + // + // 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) + .expect("placeholder allowlist is non-empty"); let mut components: Vec = - vec![AccountComponent::from(NoAuth), agglayer_component]; + vec![AccountComponent::from(auth_component), agglayer_component]; if lib_name == "faucet" { // Use a dummy owner for commitment computation - the actual owner is set at runtime let dummy_owner = miden_protocol::account::AccountId::try_from( - miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_NETWORK_ACCOUNT_IMMUTABLE_CODE, + miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE, ) .unwrap(); components.push(AccountComponent::from( miden_standards::account::access::Ownable2Step::new(dummy_owner), )); - components.push(AccountComponent::from(OwnerControlled::owner_only())); + components.push(AccountComponent::from(Authority::OwnerControlled)); + // Mirror the component order used by `create_agglayer_faucet_builder` in lib.rs so + // the compile-time code commitment matches the one computed at runtime. + // + // 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"); + + components.extend(token_policy_manager); } // use `AccountCode` to merge codes of agglayer and authentication components - let account_code = AccountCode::from_components(&components, AccountType::FungibleFaucet) - .expect("account code creation failed"); + let account_code = + AccountCode::from_components(&components).expect("account code creation failed"); let code_commitment = account_code.commitment(); writeln!( file_contents, - "pub const {}_CODE_COMMITMENT: Word = Word::new([ - Felt::new({}), - Felt::new({}), - Felt::new({}), - Felt::new({}), -]);", + "pub const {}_CODE_COMMITMENT: Word = miden_protocol::word!(\"{code_commitment}\");", lib_name.to_uppercase(), - code_commitment[0], - code_commitment[1], - code_commitment[2], - code_commitment[3], ) .unwrap(); } @@ -318,15 +406,7 @@ fn generate_agglayer_constants( /// /// The function ensures that a constant is not defined twice, except if their error message is the /// same. This can happen across multiple files. -/// -/// Because the error files will be written to ./src/errors, this should be a no-op if ./src is -/// read-only. To enable writing to ./src, set the `BUILD_GENERATED_FILES_IN_SRC` environment -/// variable. -fn generate_error_constants(asm_source_dir: &Path) -> Result<()> { - if !BUILD_GENERATED_FILES_IN_SRC { - return Ok(()); - } - +fn generate_error_constants(asm_source_dir: &Path, build_dir: &str) -> Result<()> { // Miden agglayer errors // ------------------------------------------ @@ -334,7 +414,7 @@ fn generate_error_constants(asm_source_dir: &Path) -> Result<()> { .context("failed to extract all masm errors")?; shared::generate_error_file( shared::ErrorModule { - file_name: AGGLAYER_ERRORS_FILE, + file_path: Path::new(build_dir).join(AGGLAYER_ERRORS_RS_FILE), array_name: AGGLAYER_ERRORS_ARRAY_NAME, is_crate_local: false, }, @@ -344,14 +424,13 @@ fn generate_error_constants(asm_source_dir: &Path) -> Result<()> { Ok(()) } -// CANONICAL ZEROS FILE GENERATION +// CANONICAL ZEROS VALIDATION // ================================================================================================ -fn generate_canonical_zeros(target_dir: &Path) -> Result<()> { - if !BUILD_GENERATED_FILES_IN_SRC { - return Ok(()); - } - +/// Validates that the committed `canonical_zeros.masm` matches the expected content computed from +/// Keccak256 canonical zeros. If the `REGENERATE_CANONICAL_ZEROS` environment variable is set, +/// the file is regenerated instead. +fn ensure_canonical_zeros(target_dir: &Path) -> Result<()> { const TREE_HEIGHT: u8 = 32; let mut zeros_by_height = Vec::with_capacity(TREE_HEIGHT as usize); @@ -371,10 +450,10 @@ fn generate_canonical_zeros(target_dir: &Path) -> Result<()> { // convert the keccak digest into the sequence of u32 values and create two word constants from // them to represent the hash let mut zero_constants = String::from( - "# This file is generated by build.rs, do not modify\n -# This file contains the canonical zeros for the Keccak hash function. + "# This file contains deterministic values. Do not modify manually.\n +# This file contains the canonical zeros for the Keccak hash function. # Zero of height `n` (ZERO_N) is the root of the binary tree of height `n` with leaves equal zero. -# +# # Since the Keccak hash is represented by eight u32 values, each constant consists of two Words.\n", ); @@ -396,7 +475,8 @@ fn generate_canonical_zeros(target_dir: &Path) -> Result<()> { zero_constants.push_str( " use ::agglayer::common::utils::mem_store_double_word - + + #! Inputs: [zeros_ptr] #! Outputs: [] pub proc load_zeros_to_memory\n", @@ -408,9 +488,23 @@ pub proc load_zeros_to_memory\n", zero_constants.push_str("\tdrop\nend\n"); - // write the resulting masm content into the file only if it changed to avoid - // invalidating the cargo fingerprint for the `asm/` directory - shared::write_if_changed(target_dir.join("canonical_zeros.masm"), zero_constants)?; + let file_path = target_dir.join("canonical_zeros.masm"); + + if option_env!("REGENERATE_CANONICAL_ZEROS").is_some() { + // Regeneration mode: write the file + shared::write_if_changed(&file_path, &zero_constants)?; + } else { + // Validation mode: ensure the committed file matches + let committed = fs::read_to_string(&file_path) + .into_diagnostic() + .wrap_err("canonical_zeros.masm not found - it should be committed in the repo")?; + if committed != zero_constants { + return Err(Report::msg( + "canonical_zeros.masm is out of date. \ + Run with REGENERATE_CANONICAL_ZEROS=1 to regenerate and commit the result.", + )); + } + } Ok(()) } @@ -429,54 +523,6 @@ mod shared { use regex::Regex; use walkdir::WalkDir; - /// Recursively copies `src` into `dst`. - /// - /// This function will overwrite the existing files if re-executed. - pub fn copy_directory, R: AsRef>( - src: T, - dst: R, - asm_dir: &str, - ) -> Result<()> { - let mut prefix = src.as_ref().canonicalize().unwrap(); - // keep all the files inside the `asm` folder - prefix.pop(); - - let target_dir = dst.as_ref().join(asm_dir); - if target_dir.exists() { - // Clear existing asm files that were copied earlier which may no longer exist. - fs::remove_dir_all(&target_dir) - .into_diagnostic() - .wrap_err("failed to remove ASM directory")?; - } - - // Recreate the directory structure. - fs::create_dir_all(&target_dir) - .into_diagnostic() - .wrap_err("failed to create ASM directory")?; - - let dst = dst.as_ref(); - let mut todo = vec![src.as_ref().to_path_buf()]; - - while let Some(goal) = todo.pop() { - for entry in fs::read_dir(goal).unwrap() { - let path = entry.unwrap().path(); - if path.is_dir() { - let src_dir = path.canonicalize().unwrap(); - let dst_dir = dst.join(src_dir.strip_prefix(&prefix).unwrap()); - if !dst_dir.exists() { - fs::create_dir_all(&dst_dir).unwrap(); - } - todo.push(src_dir); - } else { - let dst_file = dst.join(path.strip_prefix(&prefix).unwrap()); - fs::copy(&path, dst_file).unwrap(); - } - } - } - - Ok(()) - } - /// Returns a vector with paths to all MASM files in the specified directory. /// /// All non-MASM files are skipped. @@ -655,7 +701,7 @@ mod shared { .into_diagnostic()?; } - write_if_changed(module.file_name, output)?; + fs::write(module.file_path, output).into_diagnostic()?; Ok(()) } @@ -688,9 +734,9 @@ mod shared { pub message: String, } - #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] + #[derive(Debug, Clone)] pub struct ErrorModule { - pub file_name: &'static str, + pub file_path: PathBuf, pub array_name: &'static str, pub is_crate_local: bool, } diff --git a/crates/miden-agglayer/solidity-compat/README.md b/crates/miden-agglayer/solidity-compat/README.md index 9c7f3a2aeb..b4d3d3d997 100644 --- a/crates/miden-agglayer/solidity-compat/README.md +++ b/crates/miden-agglayer/solidity-compat/README.md @@ -31,7 +31,7 @@ forge install forge test -vv --match-test test_generateCanonicalZeros # Generate Merkle Tree Frontier vectors (test-vectors/merkle_tree_frontier_vectors.json) -forge test -vv --match-test test_generateVectors +forge test -vv --match-test test_generate_MTF_vectors ``` ## Generated Files @@ -46,7 +46,7 @@ The canonical zeros should match the constants in: ### Merkle Tree Frontier Vectors -The `test_generateVectors` adds 32 leaves and outputs the root after each addition. +The `test_generate_MTF_vectors` adds 32 leaves and outputs the root after each addition. Each leaf uses: - `amounts[i] = i + 1` diff --git a/crates/miden-agglayer/solidity-compat/test-vectors/claim_asset_vectors_local_tx.json b/crates/miden-agglayer/solidity-compat/test-vectors/claim_asset_vectors_l1_tx.json similarity index 91% rename from crates/miden-agglayer/solidity-compat/test-vectors/claim_asset_vectors_local_tx.json rename to crates/miden-agglayer/solidity-compat/test-vectors/claim_asset_vectors_l1_tx.json index f21309609a..ec4d6803be 100644 --- a/crates/miden-agglayer/solidity-compat/test-vectors/claim_asset_vectors_local_tx.json +++ b/crates/miden-agglayer/solidity-compat/test-vectors/claim_asset_vectors_l1_tx.json @@ -1,16 +1,16 @@ { "amount": "100000000000000000000", - "claimed_global_index_hash_chain": "0xbce0afc98c69ea85e9cfbf98c87c58a77c12d857551f1858530341392f70c22d", + "claimed_global_index_hash_chain": "0x773e8b22d485e3ca55df08387294ff925017ccc589c9945ea0349230474040d9", "deposit_count": 1, "description": "L1 bridgeAsset transaction test vectors with valid Merkle proofs", - "destination_address": "0x00000000AA0000000000bb000000cc000000Dd00", - "destination_network": 20, - "global_exit_root": "0xc84f1e3744c151b345a8899034b3677c0fdbaf45aa3aaf18a3f97dbcf70836cb", + "destination_address": "0x00000000Aa0000000000BB010000cC000000DD00", + "destination_network": 77, + "global_exit_root": "0xdf5b983dd1a006a596beb86600ef0cf07d889fe4c56af4647df56ba41d7e46d3", "global_index": "0x0000000000000000000000000000000000000000000000010000000000000000", "leaf_type": 0, - "leaf_value": "0x9d85d7c56264697df18f458b4b12a457b87b7e7f7a9b16dcb368514729ef680d", - "local_exit_root": "0xc9e095ea4cfe19b7e9a6d1aff6c55914ccc8df34954f9f6a2ad8e42d2632a0ab", - "mainnet_exit_root": "0xc9e095ea4cfe19b7e9a6d1aff6c55914ccc8df34954f9f6a2ad8e42d2632a0ab", + "leaf_value": "0x8ff1e8fb38066ba439717156ac682675972cc9cd18b8f113ed99f6f143f92ca5", + "local_exit_root": "0x21bcbedd4d747f15f7ead82c9879643a17ce7dacbc461c36ab14444a18c4adc9", + "mainnet_exit_root": "0x21bcbedd4d747f15f7ead82c9879643a17ce7dacbc461c36ab14444a18c4adc9", "metadata": "0x000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000a5465737420546f6b656e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000045445535400000000000000000000000000000000000000000000000000000000", "metadata_hash": "0x4d0d9fb7f9ab2f012da088dc1c228173723db7e09147fe4fea2657849d580161", "origin_network": 0, diff --git a/crates/miden-agglayer/solidity-compat/test-vectors/claim_asset_vectors_rollup_tx.json b/crates/miden-agglayer/solidity-compat/test-vectors/claim_asset_vectors_l2_tx.json similarity index 91% rename from crates/miden-agglayer/solidity-compat/test-vectors/claim_asset_vectors_rollup_tx.json rename to crates/miden-agglayer/solidity-compat/test-vectors/claim_asset_vectors_l2_tx.json index aafa61f593..c87b09f300 100644 --- a/crates/miden-agglayer/solidity-compat/test-vectors/claim_asset_vectors_rollup_tx.json +++ b/crates/miden-agglayer/solidity-compat/test-vectors/claim_asset_vectors_l2_tx.json @@ -1,21 +1,21 @@ { "amount": "100000000000000000000", - "claimed_global_index_hash_chain": "0x68ace2f015593d5f6de5338c9eca6e748764574491b9f0eed941a2b49db1a7a3", + "claimed_global_index_hash_chain": "0xaa360d3ef680d2442d59f4edcfb82278565c80c87f7161feda32bf8cfc20e857", "deposit_count": 3, "description": "Rollup deposit test vectors with valid two-level Merkle proofs (non-zero indices)", - "destination_address": "0x00000000AA0000000000bb000000cc000000Dd00", - "destination_network": 20, - "global_exit_root": "0x677d4ecba0ff4871f33163e70ea39a13fe97f2fa9b4dbad110e398830a324159", + "destination_address": "0x00000000Aa0000000000BB010000cC000000DD00", + "destination_network": 77, + "global_exit_root": "0xd6e0faa053417eca24b92d91bf5e923f202c3c4fe324deb588e18e28b1dcd7da", "global_index": "0x0000000000000000000000000000000000000000000000000000000500000002", "leaf_type": 0, - "leaf_value": "0x4a6a047a2b89dd9c557395833c5e34c4f72e6f9aae70779e856f14a6a2827585", - "local_exit_root": "0x985cff7ee35794b30fba700b64546b4ec240d2d78aaf356d56e83d907009367f", + "leaf_value": "0xf933dccec6325e8f50f0dd0fdf8ea7ab5d8b2acb9ace7d5347bbd3c3ac01798f", + "local_exit_root": "0x564409271562b83c22be3e8f5ff790f250556d706b8a97105c3730c5d0218390", "mainnet_exit_root": "0x4d63440b08ffffe5a049aae4161d54821a09973965a1a1728534a0f117b6d6c9", "metadata": "0x000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000a5465737420546f6b656e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000045445535400000000000000000000000000000000000000000000000000000000", "metadata_hash": "0x4d0d9fb7f9ab2f012da088dc1c228173723db7e09147fe4fea2657849d580161", "origin_network": 3, "origin_token_address": "0x2DC70fb75b88d2eB4715bc06E1595E6D97c34DFF", - "rollup_exit_root": "0x91105681934ca0791f4e760fb1f702050d81e4b7c866d42f540710999c90ea97", + "rollup_exit_root": "0x0c1d6ed0ab5ef35b2ebe2e1fc3dcb33951119001e8ba79ffab0aee887060d86a", "smt_proof_local_exit_root": [ "0x0000000000000000000000000000000000000000000000000000000000000000", "0xa8367b4263332f7e5453faa770f07ef4ce3e74fc411e0a788a98b38b91fd3b3e", diff --git a/crates/miden-agglayer/solidity-compat/test-vectors/claim_asset_vectors_real_tx.json b/crates/miden-agglayer/solidity-compat/test-vectors/claim_asset_vectors_real_tx.json deleted file mode 100644 index e4423417fb..0000000000 --- a/crates/miden-agglayer/solidity-compat/test-vectors/claim_asset_vectors_real_tx.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "amount": 100000000000000, - "claimed_global_index_hash_chain": "0xd2bb2f0231ee9ea0c88e89049bea6dbcf7dd96a1015ca9e66ab38ef3c8dc928e", - "destination_address": "0x00000000b0E79c68cafC54802726C6F102Cca300", - "destination_network": 20, - "global_exit_root": "0xe1cbfbde30bd598ee9aa2ac913b60d53e3297e51ed138bf86c500dd7d2391e7d", - "global_index": "0x0000000000000000000000000000000000000000000000010000000000039e88", - "leaf_type": 0, - "leaf_value": "0xc58420b9b4ba439bb5f6f68096270f4df656553ec67150d4d087416b9ef6ea9d", - "mainnet_exit_root": "0x31d3268d3a0145d65482b336935fa07dab0822f7dccd865f361d2bf122c4905c", - "metadata_hash": "0x945d61756eddd06a335ceff22d61480fc2086e85e74a55db5485f814626247d5", - "origin_network": 0, - "origin_token_address": "0x2DC70fb75b88d2eB4715bc06E1595E6D97c34DFF", - "rollup_exit_root": "0x8452a95fd710163c5fa8ca2b2fe720d8781f0222bb9e82c2a442ec986c374858", - "smt_proof_local_exit_root": [ - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5", - "0xb4c11951957c6f8f642c4af61cd6b24640fec6dc7fc607ee8206a99e92410d30", - "0xe37d456460231cf80063f57ee83a02f70d810c568b3bfb71156d52445f7a885a", - "0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344", - "0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d", - "0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968", - "0x3236bf576fca1adf85917ec7888c4b89cce988564b6028f7d66807763aaa7b04", - "0x9867cc5f7f196b93bae1e27e6320742445d290f2263827498b54fec539f756af", - "0x054ba828046324ff4794fce22adefb23b3ce749cd4df75ade2dc9f41dd327c31", - "0x4e9220076c344bf223c7e7cb2d47c9f0096c48def6a9056e41568de4f01d2716", - "0xca6369acd49a7515892f5936227037cc978a75853409b20f1145f1d44ceb7622", - "0x5a925caf7bfdf31344037ba5b42657130d049f7cb9e87877317e79fce2543a0c", - "0xc1df82d9c4b87413eae2ef048f94b4d3554cea73d92b0f7af96e0271c691e2bb", - "0x5c67add7c6caf302256adedf7ab114da0acfe870d449a3a489f781d659e8becc", - "0x4111a1a05cc06ad682bb0f213170d7d57049920d20fc4e0f7556a21b283a7e2a", - "0x77a0f8b0e0b4e5a57f5e381b3892bb41a0bcdbfdf3c7d591fae02081159b594d", - "0x361122b4b1d18ab577f2aeb6632c690713456a66a5670649ceb2c0a31e43ab46", - "0x5a2dce0a8a7f68bb74560f8f71837c2c2ebbcbf7fffb42ae1896f13f7c7479a0", - "0xb46a28b6f55540f89444f63de0378e3d121be09e06cc9ded1c20e65876d36aa0", - "0xc65e9645644786b620e2dd2ad648ddfcbf4a7e5b1a3a4ecfe7f64667a3f0b7e2", - "0xf4418588ed35a2458cffeb39b93d26f18d2ab13bdce6aee58e7b99359ec2dfd9", - "0x5a9c16dc00d6ef18b7933a6f8dc65ccb55667138776f7dea101070dc8796e377", - "0x4df84f40ae0c8229d0d6069e5c8f39a7c299677a09d367fc7b05e3bc380ee652", - "0xcdc72595f74c7b1043d0e1ffbab734648c838dfb0527d971b602bc216c9619ef", - "0x0abf5ac974a1ed57f4050aa510dd9c74f508277b39d7973bb2dfccc5eeb0618d", - "0xb8cd74046ff337f0a7bf2c8e03e10f642c1886798d71806ab1e888d9e5ee87d0", - "0x838c5655cb21c6cb83313b5a631175dff4963772cce9108188b34ac87c81c41e", - "0x662ee4dd2dd7b2bc707961b1e646c4047669dcb6584f0d8d770daf5d7e7deb2e", - "0x388ab20e2573d171a88108e79d820e98f26c0b84aa8b2f4aa4968dbb818ea322", - "0x93237c50ba75ee485f4c22adf2f741400bdf8d6a9cc7df7ecae576221665d735", - "0x8448818bb4ae4562849e949e17ac16e0be16688e156b5cf15e098c627c0056a9" - ], - "smt_proof_rollup_exit_root": [ - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000" - ] -} \ No newline at end of file diff --git a/crates/miden-agglayer/solidity-compat/test/ClaimAssetTestVectorsLocalTx.t.sol b/crates/miden-agglayer/solidity-compat/test/ClaimAssetTestVectorsMainnetTx.t.sol similarity index 92% rename from crates/miden-agglayer/solidity-compat/test/ClaimAssetTestVectorsLocalTx.t.sol rename to crates/miden-agglayer/solidity-compat/test/ClaimAssetTestVectorsMainnetTx.t.sol index 94abe9ae54..8ae7e541ee 100644 --- a/crates/miden-agglayer/solidity-compat/test/ClaimAssetTestVectorsLocalTx.t.sol +++ b/crates/miden-agglayer/solidity-compat/test/ClaimAssetTestVectorsMainnetTx.t.sol @@ -6,7 +6,7 @@ import "@agglayer/lib/GlobalExitRootLib.sol"; import "@agglayer/interfaces/IBasePolygonZkEVMGlobalExitRoot.sol"; import "./DepositContractTestHelpers.sol"; -contract MockGlobalExitRootManagerLocal is IBasePolygonZkEVMGlobalExitRoot { +contract MockGlobalExitRootManagerMainnet is IBasePolygonZkEVMGlobalExitRoot { mapping(bytes32 => uint256) public override globalExitRootMap; function updateExitRoot(bytes32) external override {} @@ -17,24 +17,24 @@ contract MockGlobalExitRootManagerLocal is IBasePolygonZkEVMGlobalExitRoot { } /** - * @title ClaimAssetTestVectorsLocalTx + * @title ClaimAssetTestVectorsMainnetTx * @notice Test contract that generates test vectors for an L1 bridgeAsset transaction. * This simulates calling bridgeAsset() on the PolygonZkEVMBridgeV2 contract * and captures all relevant data including VALID Merkle proofs. * Uses BridgeL2SovereignChain to get the authoritative claimedGlobalIndexHashChain. * - * Run with: forge test -vv --match-contract ClaimAssetTestVectorsLocalTx + * Run with: forge test -vv --match-contract ClaimAssetTestVectorsMainnetTx * * The output can be used to verify Miden's ability to process L1 bridge transactions. */ -contract ClaimAssetTestVectorsLocalTx is Test, DepositContractTestHelpers { +contract ClaimAssetTestVectorsMainnetTx is Test, DepositContractTestHelpers { /** * @notice Generates bridge asset test vectors with VALID Merkle proofs. * Simulates a user calling bridgeAsset() to bridge tokens from L1 to Miden. * * Output file: test-vectors/bridge_asset_vectors.json */ - function test_generateClaimAssetVectorsLocalTx() public { + function test_generateClaimAssetVectorsMainnetTx() public { string memory obj = "root"; // ====== BRIDGE TRANSACTION PARAMETERS ====== @@ -42,8 +42,8 @@ contract ClaimAssetTestVectorsLocalTx is Test, DepositContractTestHelpers { uint8 leafType = 0; uint32 originNetwork = 0; address originTokenAddress = 0x2DC70fb75b88d2eB4715bc06E1595E6D97c34DFF; - uint32 destinationNetwork = 20; - address destinationAddress = 0x00000000AA0000000000bb000000cc000000Dd00; + uint32 destinationNetwork = 77; + address destinationAddress = 0x00000000Aa0000000000BB010000cC000000DD00; uint256 amount = 100000000000000000000; bytes memory metadata = abi.encode("Test Token", "TEST", uint8(18)); @@ -110,7 +110,7 @@ contract ClaimAssetTestVectorsLocalTx is Test, DepositContractTestHelpers { // Use the actual BridgeL2SovereignChain to compute the authoritative value // Set up the global exit root manager - MockGlobalExitRootManagerLocal gerManager = new MockGlobalExitRootManagerLocal(); + MockGlobalExitRootManagerMainnet gerManager = new MockGlobalExitRootManagerMainnet(); gerManager.setGlobalExitRoot(globalExitRoot); globalExitRootManager = IBasePolygonZkEVMGlobalExitRoot(address(gerManager)); @@ -166,10 +166,10 @@ contract ClaimAssetTestVectorsLocalTx is Test, DepositContractTestHelpers { obj, "description", "L1 bridgeAsset transaction test vectors with valid Merkle proofs" ); - string memory outputPath = "test-vectors/claim_asset_vectors_local_tx.json"; + string memory outputPath = "test-vectors/claim_asset_vectors_l1_tx.json"; vm.writeJson(json, outputPath); - console.log("Generated claim asset local tx test vectors with valid Merkle proofs"); + console.log("Generated claim asset mainnet tx test vectors with valid Merkle proofs"); console.log("Output file:", outputPath); console.log("Leaf index:", leafIndex); console.log("Deposit count:", depositCountValue); diff --git a/crates/miden-agglayer/solidity-compat/test/ClaimAssetTestVectorsRealTx.t.sol b/crates/miden-agglayer/solidity-compat/test/ClaimAssetTestVectorsRealTx.t.sol deleted file mode 100644 index 8674e6cb06..0000000000 --- a/crates/miden-agglayer/solidity-compat/test/ClaimAssetTestVectorsRealTx.t.sol +++ /dev/null @@ -1,191 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import "forge-std/Test.sol"; -import "@agglayer/lib/GlobalExitRootLib.sol"; -import "@agglayer/interfaces/IBasePolygonZkEVMGlobalExitRoot.sol"; -import "./DepositContractTestHelpers.sol"; - -contract MockGlobalExitRootManagerReal is IBasePolygonZkEVMGlobalExitRoot { - mapping(bytes32 => uint256) public override globalExitRootMap; - - function updateExitRoot(bytes32) external override {} - - function setGlobalExitRoot(bytes32 globalExitRoot) external { - globalExitRootMap[globalExitRoot] = block.number; - } -} - -/** - * @title ClaimAssetTestVectorsRealTx - * @notice Test contract that generates comprehensive test vectors for verifying - * compatibility between Solidity's claimAsset and Miden's implementation. - * Uses BridgeL2SovereignChain to get the authoritative claimedGlobalIndexHashChain. - * - * Generates vectors for both LeafData and ProofData from a real transaction. - * - * Run with: forge test -vv --match-contract ClaimAssetTestVectorsRealTx - * - * The output can be compared against the Rust ClaimNoteStorage implementation. - */ -contract ClaimAssetTestVectorsRealTx is Test, DepositContractTestHelpers { - /** - * @notice Generates claim asset test vectors from real Katana transaction and saves to JSON. - * Uses real transaction data from Katana explorer: - * https://katanascan.com/tx/0x685f6437c4a54f5d6c59ea33de74fe51bc2401fea65dc3d72a976def859309bf - * - * Output file: test-vectors/claim_asset_vectors.json - */ - function test_generateClaimAssetVectors() public { - string memory obj = "root"; - - // ====== PROOF DATA ====== - bytes32[32] memory smtProofLocalExitRoot; - bytes32[32] memory smtProofRollupExitRoot; - uint256 globalIndex; - bytes32 mainnetExitRoot; - bytes32 rollupExitRoot; - bytes32 globalExitRoot; - - // Scoped block keeps stack usage under Solidity limits. - { - // SMT proof for local exit root (32 nodes) - smtProofLocalExitRoot = [ - bytes32(0x0000000000000000000000000000000000000000000000000000000000000000), - bytes32(0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5), - bytes32(0xb4c11951957c6f8f642c4af61cd6b24640fec6dc7fc607ee8206a99e92410d30), - bytes32(0xe37d456460231cf80063f57ee83a02f70d810c568b3bfb71156d52445f7a885a), - bytes32(0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344), - bytes32(0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d), - bytes32(0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968), - bytes32(0x3236bf576fca1adf85917ec7888c4b89cce988564b6028f7d66807763aaa7b04), - bytes32(0x9867cc5f7f196b93bae1e27e6320742445d290f2263827498b54fec539f756af), - bytes32(0x054ba828046324ff4794fce22adefb23b3ce749cd4df75ade2dc9f41dd327c31), - bytes32(0x4e9220076c344bf223c7e7cb2d47c9f0096c48def6a9056e41568de4f01d2716), - bytes32(0xca6369acd49a7515892f5936227037cc978a75853409b20f1145f1d44ceb7622), - bytes32(0x5a925caf7bfdf31344037ba5b42657130d049f7cb9e87877317e79fce2543a0c), - bytes32(0xc1df82d9c4b87413eae2ef048f94b4d3554cea73d92b0f7af96e0271c691e2bb), - bytes32(0x5c67add7c6caf302256adedf7ab114da0acfe870d449a3a489f781d659e8becc), - bytes32(0x4111a1a05cc06ad682bb0f213170d7d57049920d20fc4e0f7556a21b283a7e2a), - bytes32(0x77a0f8b0e0b4e5a57f5e381b3892bb41a0bcdbfdf3c7d591fae02081159b594d), - bytes32(0x361122b4b1d18ab577f2aeb6632c690713456a66a5670649ceb2c0a31e43ab46), - bytes32(0x5a2dce0a8a7f68bb74560f8f71837c2c2ebbcbf7fffb42ae1896f13f7c7479a0), - bytes32(0xb46a28b6f55540f89444f63de0378e3d121be09e06cc9ded1c20e65876d36aa0), - bytes32(0xc65e9645644786b620e2dd2ad648ddfcbf4a7e5b1a3a4ecfe7f64667a3f0b7e2), - bytes32(0xf4418588ed35a2458cffeb39b93d26f18d2ab13bdce6aee58e7b99359ec2dfd9), - bytes32(0x5a9c16dc00d6ef18b7933a6f8dc65ccb55667138776f7dea101070dc8796e377), - bytes32(0x4df84f40ae0c8229d0d6069e5c8f39a7c299677a09d367fc7b05e3bc380ee652), - bytes32(0xcdc72595f74c7b1043d0e1ffbab734648c838dfb0527d971b602bc216c9619ef), - bytes32(0x0abf5ac974a1ed57f4050aa510dd9c74f508277b39d7973bb2dfccc5eeb0618d), - bytes32(0xb8cd74046ff337f0a7bf2c8e03e10f642c1886798d71806ab1e888d9e5ee87d0), - bytes32(0x838c5655cb21c6cb83313b5a631175dff4963772cce9108188b34ac87c81c41e), - bytes32(0x662ee4dd2dd7b2bc707961b1e646c4047669dcb6584f0d8d770daf5d7e7deb2e), - bytes32(0x388ab20e2573d171a88108e79d820e98f26c0b84aa8b2f4aa4968dbb818ea322), - bytes32(0x93237c50ba75ee485f4c22adf2f741400bdf8d6a9cc7df7ecae576221665d735), - bytes32(0x8448818bb4ae4562849e949e17ac16e0be16688e156b5cf15e098c627c0056a9) - ]; - - // SMT proof for rollup exit root (32 nodes - all zeros for this rollup claim). - for (uint256 i = 0; i < 32; i++) { - smtProofRollupExitRoot[i] = bytes32(0); - } - - // Global index (uint256) - encodes rollup_id and deposit_count. - globalIndex = 18446744073709788808; - - // Exit roots - mainnetExitRoot = 0x31d3268d3a0145d65482b336935fa07dab0822f7dccd865f361d2bf122c4905c; - rollupExitRoot = 0x8452a95fd710163c5fa8ca2b2fe720d8781f0222bb9e82c2a442ec986c374858; - - // Compute global exit root: keccak256(mainnetExitRoot || rollupExitRoot) - globalExitRoot = GlobalExitRootLib.calculateGlobalExitRoot(mainnetExitRoot, rollupExitRoot); - - // forge-std JSON serialization supports `bytes32[]` but not `bytes32[32]`. - bytes32[] memory smtProofLocalExitRootDyn = new bytes32[](32); - bytes32[] memory smtProofRollupExitRootDyn = new bytes32[](32); - for (uint256 i = 0; i < 32; i++) { - smtProofLocalExitRootDyn[i] = smtProofLocalExitRoot[i]; - smtProofRollupExitRootDyn[i] = smtProofRollupExitRoot[i]; - } - - vm.serializeBytes32(obj, "smt_proof_local_exit_root", smtProofLocalExitRootDyn); - vm.serializeBytes32(obj, "smt_proof_rollup_exit_root", smtProofRollupExitRootDyn); - vm.serializeBytes32(obj, "global_index", bytes32(globalIndex)); - vm.serializeBytes32(obj, "mainnet_exit_root", mainnetExitRoot); - vm.serializeBytes32(obj, "rollup_exit_root", rollupExitRoot); - vm.serializeBytes32(obj, "global_exit_root", globalExitRoot); - } - - // ====== LEAF DATA ====== - // Scoped block keeps stack usage under Solidity limits. - { - uint8 leafType = 0; // 0 for ERC20/ETH transfer - uint32 originNetwork = 0; - address originTokenAddress = 0x2DC70fb75b88d2eB4715bc06E1595E6D97c34DFF; - uint32 destinationNetwork = 20; - address destinationAddress = 0x00000000b0E79c68cafC54802726C6F102Cca300; - uint256 amount = 100000000000000; // 1e14 (0.0001 vbETH) - - // Original metadata from the transaction (ABI encoded: name, symbol, decimals) - // name = "Vault Bridge ETH", symbol = "vbETH", decimals = 18 - bytes memory metadata = - hex"000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000105661756c7420427269646765204554480000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000057662455448000000000000000000000000000000000000000000000000000000"; - bytes32 metadataHash = keccak256(metadata); - - // Compute the leaf value using the official DepositContractV2 implementation - bytes32 leafValue = getLeafValue( - leafType, - originNetwork, - originTokenAddress, - destinationNetwork, - destinationAddress, - amount, - metadataHash - ); - - // ====== COMPUTE CLAIMED GLOBAL INDEX HASH CHAIN ====== - // Use the actual BridgeL2SovereignChain to compute the authoritative value - - // Set up the global exit root manager - MockGlobalExitRootManagerReal gerManager = new MockGlobalExitRootManagerReal(); - gerManager.setGlobalExitRoot(globalExitRoot); - globalExitRootManager = IBasePolygonZkEVMGlobalExitRoot(address(gerManager)); - - // Use a non-zero network ID to match sovereign-chain requirements - networkID = 10; - - // Call _verifyLeafBridge to update claimedGlobalIndexHashChain - this.verifyLeafBridgeHarness( - smtProofLocalExitRoot, - smtProofRollupExitRoot, - globalIndex, - mainnetExitRoot, - rollupExitRoot, - leafType, - originNetwork, - originTokenAddress, - destinationNetwork, - destinationAddress, - amount, - metadataHash - ); - - // Read the updated claimedGlobalIndexHashChain - bytes32 claimedHashChain = claimedGlobalIndexHashChain; - - vm.serializeUint(obj, "leaf_type", leafType); - vm.serializeUint(obj, "origin_network", originNetwork); - vm.serializeAddress(obj, "origin_token_address", originTokenAddress); - vm.serializeUint(obj, "destination_network", destinationNetwork); - vm.serializeAddress(obj, "destination_address", destinationAddress); - vm.serializeUint(obj, "amount", amount); - vm.serializeBytes32(obj, "metadata_hash", metadataHash); - vm.serializeBytes32(obj, "leaf_value", leafValue); - string memory json = vm.serializeBytes32(obj, "claimed_global_index_hash_chain", claimedHashChain); - - // Save to file - string memory outputPath = "test-vectors/claim_asset_vectors_real_tx.json"; - vm.writeJson(json, outputPath); - } - } -} diff --git a/crates/miden-agglayer/solidity-compat/test/ClaimAssetTestVectorsRollupTx.t.sol b/crates/miden-agglayer/solidity-compat/test/ClaimAssetTestVectorsRollupTx.t.sol index edc867f29d..665a669402 100644 --- a/crates/miden-agglayer/solidity-compat/test/ClaimAssetTestVectorsRollupTx.t.sol +++ b/crates/miden-agglayer/solidity-compat/test/ClaimAssetTestVectorsRollupTx.t.sol @@ -56,7 +56,7 @@ contract ClaimAssetTestVectorsRollupTx is Test, DepositContractV2, DepositContra /** * @notice Generates rollup deposit test vectors with valid two-level Merkle proofs. * - * Output file: test-vectors/claim_asset_vectors_rollup_tx.json + * Output file: test-vectors/claim_asset_vectors_l2_tx.json */ function test_generateClaimAssetVectorsRollupTx() public { string memory obj = "root"; @@ -66,9 +66,9 @@ contract ClaimAssetTestVectorsRollupTx is Test, DepositContractV2, DepositContra uint8 leafType = 0; uint32 originNetwork = 3; // rollup network ID address originTokenAddress = 0x2DC70fb75b88d2eB4715bc06E1595E6D97c34DFF; - uint32 destinationNetwork = 20; + uint32 destinationNetwork = 77; // Destination address with zero MSB (embeds a Miden AccountId) - address destinationAddress = 0x00000000AA0000000000bb000000cc000000Dd00; + address destinationAddress = 0x00000000Aa0000000000BB010000cC000000DD00; uint256 amount = 100000000000000000000; bytes memory metadata = abi.encode("Test Token", "TEST", uint8(18)); @@ -200,7 +200,7 @@ contract ClaimAssetTestVectorsRollupTx is Test, DepositContractV2, DepositContra obj, "description", "Rollup deposit test vectors with valid two-level Merkle proofs (non-zero indices)" ); - string memory outputPath = "test-vectors/claim_asset_vectors_rollup_tx.json"; + string memory outputPath = "test-vectors/claim_asset_vectors_l2_tx.json"; vm.writeJson(json, outputPath); console.log("Generated rollup deposit test vectors with valid two-level Merkle proofs"); diff --git a/crates/miden-agglayer/solidity-compat/test/MTFTestVectors.t.sol b/crates/miden-agglayer/solidity-compat/test/MTFTestVectors.t.sol index c51f13412b..bd26da4424 100644 --- a/crates/miden-agglayer/solidity-compat/test/MTFTestVectors.t.sol +++ b/crates/miden-agglayer/solidity-compat/test/MTFTestVectors.t.sol @@ -95,7 +95,7 @@ contract MTFTestVectors is Test, DepositContractV2 { * * Output file: test-vectors/merkle_tree_frontier_vectors.json */ - function test_generateVectors() public { + function test_generate_MTF_vectors() public { bytes32[] memory leaves = new bytes32[](32); bytes32[] memory roots = new bytes32[](32); uint256[] memory counts = new uint256[](32); diff --git a/crates/miden-agglayer/src/b2agg_note.rs b/crates/miden-agglayer/src/b2agg_note.rs index 82820c2082..fbd614ae7a 100644 --- a/crates/miden-agglayer/src/b2agg_note.rs +++ b/crates/miden-agglayer/src/b2agg_note.rs @@ -3,12 +3,11 @@ //! This module provides helpers for creating B2AGG (Bridge to AggLayer) notes, //! which are used to bridge assets out from Miden to the AggLayer network. -use alloc::string::ToString; use alloc::vec::Vec; use miden_assembly::Library; use miden_assembly::serde::Deserializable; -use miden_core::{Felt, Word}; +use miden_core::Felt; use miden_protocol::account::AccountId; use miden_protocol::crypto::rand::FeltRng; use miden_protocol::errors::NoteError; @@ -16,11 +15,13 @@ use miden_protocol::note::{ Note, NoteAssets, NoteAttachment, - NoteMetadata, + NoteAttachments, NoteRecipient, NoteScript, + NoteScriptRoot, NoteStorage, NoteType, + PartialNoteMetadata, }; use miden_standards::note::{NetworkAccountTarget, NoteExecutionHint}; use miden_utils_sync::LazyLock; @@ -32,7 +33,7 @@ use crate::EthAddress; // Initialize the B2AGG note script only once static B2AGG_SCRIPT: LazyLock = LazyLock::new(|| { - let bytes = include_bytes!(concat!(env!("OUT_DIR"), "/assets/note_scripts/B2AGG.masl")); + let bytes = include_bytes!(concat!(env!("OUT_DIR"), "/assets/note_scripts/b2agg.masl")); let library = Library::read_from_bytes(bytes).expect("shipped B2AGG script library is well-formed"); NoteScript::from_library(&library).expect("shipped B2AGG script is well-formed") @@ -64,7 +65,7 @@ impl B2AggNote { } /// Returns the B2AGG note script root. - pub fn script_root() -> Word { + pub fn script_root() -> NoteScriptRoot { B2AGG_SCRIPT.root() } @@ -97,17 +98,17 @@ impl B2AggNote { ) -> Result { let note_storage = build_note_storage(destination_network, destination_address)?; - let attachment = NoteAttachment::from( - NetworkAccountTarget::new(target_account_id, NoteExecutionHint::Always) - .map_err(|e| NoteError::other(e.to_string()))?, - ); + let attachment = NetworkAccountTarget::new(target_account_id, NoteExecutionHint::Always) + .map_err(|error| { + NoteError::other_with_source("failed to create b2agg network account target", error) + })?; + let attachments = NoteAttachments::from(NoteAttachment::from(attachment)); - let metadata = - NoteMetadata::new(sender_account_id, NoteType::Public).with_attachment(attachment); + let metadata = PartialNoteMetadata::new(sender_account_id, NoteType::Public); let recipient = NoteRecipient::new(rng.draw_word(), Self::script(), note_storage); - Ok(Note::new(assets, metadata, recipient)) + Ok(Note::with_attachments(assets, metadata, recipient, attachments)) } } diff --git a/crates/miden-agglayer/src/bridge.rs b/crates/miden-agglayer/src/bridge.rs index 2ec155232b..06d1825c33 100644 --- a/crates/miden-agglayer/src/bridge.rs +++ b/crates/miden-agglayer/src/bridge.rs @@ -1,20 +1,15 @@ extern crate alloc; +use alloc::collections::BTreeSet; use alloc::vec; use alloc::vec::Vec; use miden_core::{Felt, ONE, Word, ZERO}; use miden_protocol::account::component::AccountComponentMetadata; -use miden_protocol::account::{ - Account, - AccountComponent, - AccountId, - AccountType, - StorageSlot, - StorageSlotName, -}; +use miden_protocol::account::{Account, AccountComponent, AccountId, StorageSlot, StorageSlotName}; use miden_protocol::block::account_tree::AccountIdKey; use miden_protocol::crypto::hash::poseidon2::Poseidon2; +use miden_protocol::note::NoteScriptRoot; use miden_utils_sync::LazyLock; use thiserror::Error; @@ -22,6 +17,7 @@ use super::agglayer_bridge_component_library; use crate::claim_note::CgiChainHash; pub use crate::{ B2AggNote, + ClaimNote, ClaimNoteStorage, ConfigAggBridgeNote, EthAddress, @@ -36,7 +32,6 @@ pub use crate::{ ProofData, SmtNode, UpdateGerNote, - create_claim_note, }; // CONSTANTS @@ -70,6 +65,10 @@ static TOKEN_REGISTRY_MAP_SLOT_NAME: LazyLock = LazyLock::new(| StorageSlotName::new("agglayer::bridge::token_registry_map") .expect("token registry map storage slot name should be valid") }); +static FAUCET_METADATA_MAP_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("agglayer::bridge::faucet_metadata_map") + .expect("faucet metadata map storage slot name should be valid") +}); // bridge in // ------------------------------------------------------------------------------------------------ @@ -125,17 +124,24 @@ static LET_NUM_LEAVES_SLOT_NAME: LazyLock = LazyLock::new(|| { /// - [`Self::ger_map_slot_name`]: Stores the GERs. /// - [`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 +/// network, scale, metadata hash) for all registered faucets, keyed by sub-key scheme based on +/// faucet ID. /// - [`Self::claim_nullifiers_slot_name`]: Stores the CLAIM note nullifiers map (RPO(leaf_index, /// source_bridge_network) → \[1, 0, 0, 0\]). /// - [`Self::cgi_chain_hash_lo_slot_name`]: Stores the lower 128 bits of the CGI chain hash. /// - [`Self::cgi_chain_hash_hi_slot_name`]: Stores the upper 128 bits of the CGI chain hash. /// - [`Self::let_frontier_slot_name`]: Stores the Local Exit Tree (LET) frontier. -/// - [`Self::let_root_lo_slot_name`]: Stores the lower 32 bits of the LET root. -/// - [`Self::let_root_hi_slot_name`]: Stores the upper 32 bits of the LET root. +/// - [`Self::let_root_lo_slot_name`]: Stores the lower 128 bits of the LET root. +/// - [`Self::let_root_hi_slot_name`]: Stores the upper 128 bits of the LET root. /// - [`Self::let_num_leaves_slot_name`]: Stores the number of leaves in the LET frontier. /// /// The bridge starts with an empty faucet registry; faucets are registered at runtime via /// CONFIG_AGG_BRIDGE notes. +/// +/// Claim validation compares the leaf's `destination_network` to the global MASM constant +/// `agglayer::common::constants::MIDEN_NETWORK_ID`. Rust exposes the same value as +/// [`Self::MIDEN_NETWORK_ID`] from generated `agglayer_constants.rs` file. #[derive(Debug, Clone)] pub struct AggLayerBridge { bridge_admin_id: AccountId, @@ -146,6 +152,11 @@ impl AggLayerBridge { // CONSTANTS // -------------------------------------------------------------------------------------------- + /// AggLayer-assigned network ID for this Miden chain. + /// + /// Matches `const MIDEN_NETWORK_ID` in `asm/agglayer/common/constants.masm`. + pub const MIDEN_NETWORK_ID: u32 = MIDEN_NETWORK_ID; + const REGISTERED_GER_MAP_VALUE: Word = Word::new([ONE, ZERO, ZERO, ZERO]); // CONSTRUCTORS @@ -186,6 +197,14 @@ impl AggLayerBridge { &TOKEN_REGISTRY_MAP_SLOT_NAME } + /// Storage slot name for the faucet metadata map. + /// + /// This map stores conversion metadata (origin address, origin network, scale, metadata hash) + /// for all registered faucets, keyed by sub-key scheme based on faucet ID. + pub fn faucet_metadata_map_slot_name() -> &'static StorageSlotName { + &FAUCET_METADATA_MAP_SLOT_NAME + } + // --- bridge in -------- /// Storage slot name for the CLAIM note nullifiers map. @@ -225,6 +244,25 @@ impl AggLayerBridge { &LET_NUM_LEAVES_SLOT_NAME } + // ALLOWED NOTES + // -------------------------------------------------------------------------------------------- + + /// Returns the set of input-note script roots that AggLayer bridge accounts accept. + /// + /// The bridge's [`AuthNetworkAccount`] component is initialized with this allowlist, which + /// means any transaction consuming a note outside this set is rejected before reaching + /// `output_note::create`. + /// + /// [`AuthNetworkAccount`]: miden_standards::account::auth::AuthNetworkAccount + pub fn allowed_notes() -> BTreeSet { + BTreeSet::from([ + ClaimNote::script_root(), + B2AggNote::script_root(), + ConfigAggBridgeNote::script_root(), + UpdateGerNote::script_root(), + ]) + } + /// Returns a boolean indicating whether the provided GER is present in storage of the provided /// bridge account. /// @@ -234,10 +272,10 @@ impl AggLayerBridge { /// - the provided account is not an [`AggLayerBridge`] account. pub fn is_ger_registered( ger: ExitRoot, - bridge_account: Account, + bridge_account: &Account, ) -> Result { // check that the provided account is a bridge account - Self::assert_bridge_account(&bridge_account)?; + Self::assert_bridge_account(bridge_account)?; // Compute the expected GER hash: poseidon2::merge(GER_LOWER, GER_UPPER) let ger_lower: Word = ger.to_elements()[0..4].try_into().unwrap(); @@ -412,6 +450,7 @@ impl AggLayerBridge { &*LET_NUM_LEAVES_SLOT_NAME, &*FAUCET_REGISTRY_MAP_SLOT_NAME, &*TOKEN_REGISTRY_MAP_SLOT_NAME, + &*FAUCET_METADATA_MAP_SLOT_NAME, &*BRIDGE_ADMIN_ID_SLOT_NAME, &*GER_MANAGER_ID_SLOT_NAME, &*CGI_CHAIN_HASH_LO_SLOT_NAME, @@ -434,6 +473,7 @@ impl From for AccountComponent { StorageSlot::with_value(LET_NUM_LEAVES_SLOT_NAME.clone(), Word::empty()), StorageSlot::with_empty_map(FAUCET_REGISTRY_MAP_SLOT_NAME.clone()), 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(CGI_CHAIN_HASH_LO_SLOT_NAME.clone(), Word::empty()), @@ -466,7 +506,7 @@ pub enum AgglayerBridgeError { /// Creates an AggLayer Bridge component with the specified storage slots. fn bridge_component(storage_slots: Vec) -> AccountComponent { let library = agglayer_bridge_component_library(); - let metadata = AccountComponentMetadata::new("agglayer::bridge", AccountType::all()) + let metadata = AccountComponentMetadata::new("agglayer::bridge") .with_description("Bridge component for AggLayer"); AccountComponent::new(library, storage_slots, metadata) diff --git a/crates/miden-agglayer/src/claim_note.rs b/crates/miden-agglayer/src/claim_note.rs index a3c5702bae..405c3969b8 100644 --- a/crates/miden-agglayer/src/claim_note.rs +++ b/crates/miden-agglayer/src/claim_note.rs @@ -1,17 +1,102 @@ -use alloc::string::ToString; use alloc::vec; use alloc::vec::Vec; +use miden_assembly::Library; +use miden_assembly::serde::Deserializable; use miden_core::{Felt, Word}; use miden_protocol::account::AccountId; use miden_protocol::crypto::SequentialCommit; use miden_protocol::crypto::rand::FeltRng; use miden_protocol::errors::NoteError; -use miden_protocol::note::{Note, NoteAssets, NoteMetadata, NoteRecipient, NoteStorage, NoteType}; +use miden_protocol::note::{ + Note, + NoteAssets, + NoteAttachment, + NoteAttachments, + NoteRecipient, + NoteScript, + NoteScriptRoot, + NoteStorage, + NoteType, + PartialNoteMetadata, +}; use miden_standards::note::{NetworkAccountTarget, NoteExecutionHint}; +use miden_utils_sync::LazyLock; use crate::utils::Keccak256Output; -use crate::{EthAddress, EthAmount, GlobalIndex, MetadataHash, claim_script}; +use crate::{EthAddress, EthAmount, GlobalIndex, MetadataHash}; + +// NOTE SCRIPT +// ================================================================================================ + +// Initialize the CLAIM note script only once +static CLAIM_SCRIPT: LazyLock = LazyLock::new(|| { + let bytes = include_bytes!(concat!(env!("OUT_DIR"), "/assets/note_scripts/claim.masl")); + let library = + Library::read_from_bytes(bytes).expect("shipped CLAIM script library is well-formed"); + NoteScript::from_library(&library).expect("shipped CLAIM script is well-formed") +}); + +// CLAIM NOTE +// ================================================================================================ + +/// CLAIM (Bridge from AggLayer) note. +/// +/// This note instructs the AggLayer bridge to validate a claim against its registered GERs and +/// emit a corresponding MINT note for the AggLayer faucet. CLAIM notes are always public. +pub struct ClaimNote; + +impl ClaimNote { + // PUBLIC ACCESSORS + // -------------------------------------------------------------------------------------------- + + /// Returns the CLAIM (Bridge from AggLayer) note script. + pub fn script() -> NoteScript { + CLAIM_SCRIPT.clone() + } + + /// Returns the CLAIM note script root. + pub fn script_root() -> NoteScriptRoot { + CLAIM_SCRIPT.root() + } + + // BUILDERS + // -------------------------------------------------------------------------------------------- + + /// Creates a CLAIM note — a note that instructs the bridge to validate a claim and create + /// a MINT note for the AggLayer Faucet. CLAIM notes are always public. + /// + /// # Parameters + /// - `storage`: The core storage for creating the CLAIM note + /// - `target_bridge_id`: The account ID of the bridge that should consume this note. Encoded as + /// a `NetworkAccountTarget` attachment on the note metadata. + /// - `sender_account_id`: The account ID of the CLAIM note creator + /// - `rng`: Random number generator for creating the CLAIM note serial number + /// + /// # Errors + /// Returns an error if note creation fails. + pub fn create( + storage: ClaimNoteStorage, + target_bridge_id: AccountId, + sender_account_id: AccountId, + rng: &mut R, + ) -> Result { + let note_storage = NoteStorage::try_from(storage)?; + + let attachment = NetworkAccountTarget::new(target_bridge_id, NoteExecutionHint::Always) + .map_err(|error| { + NoteError::other_with_source("failed to create claim network account target", error) + })?; + let attachments = NoteAttachments::from(NoteAttachment::from(attachment)); + + let metadata = PartialNoteMetadata::new(sender_account_id, NoteType::Public); + + let recipient = NoteRecipient::new(rng.draw_word(), Self::script(), note_storage); + let assets = NoteAssets::new(vec![])?; + + Ok(Note::with_attachments(assets, metadata, recipient, attachments)) + } +} // CLAIM NOTE TYPE ALIASES // ================================================================================================ @@ -157,39 +242,3 @@ impl TryFrom for NoteStorage { NoteStorage::new(claim_storage) } } - -// CLAIM NOTE CREATION -// ================================================================================================ - -/// Generates a CLAIM note - a note that instructs the bridge to validate a claim and create -/// a MINT note for the AggLayer Faucet. -/// -/// # Parameters -/// - `storage`: The core storage for creating the CLAIM note -/// - `target_bridge_id`: The account ID of the bridge that should consume this note. Encoded as a -/// `NetworkAccountTarget` attachment on the note metadata. -/// - `sender_account_id`: The account ID of the CLAIM note creator -/// - `rng`: Random number generator for creating the CLAIM note serial number -/// -/// # Errors -/// Returns an error if note creation fails. -pub fn create_claim_note( - storage: ClaimNoteStorage, - target_bridge_id: AccountId, - sender_account_id: AccountId, - rng: &mut R, -) -> Result { - let note_storage = NoteStorage::try_from(storage.clone())?; - - let attachment = NetworkAccountTarget::new(target_bridge_id, NoteExecutionHint::Always) - .map_err(|e| NoteError::other(e.to_string()))? - .into(); - - let metadata = - NoteMetadata::new(sender_account_id, NoteType::Public).with_attachment(attachment); - - let recipient = NoteRecipient::new(rng.draw_word(), claim_script(), note_storage); - let assets = NoteAssets::new(vec![])?; - - Ok(Note::new(assets, metadata, recipient)) -} diff --git a/crates/miden-agglayer/src/config_note.rs b/crates/miden-agglayer/src/config_note.rs index f5c8b4ef01..c672a2aced 100644 --- a/crates/miden-agglayer/src/config_note.rs +++ b/crates/miden-agglayer/src/config_note.rs @@ -11,7 +11,7 @@ use alloc::vec::Vec; use miden_assembly::Library; use miden_assembly::serde::Deserializable; -use miden_core::{Felt, Word}; +use miden_core::Felt; use miden_protocol::account::AccountId; use miden_protocol::crypto::rand::FeltRng; use miden_protocol::errors::NoteError; @@ -19,16 +19,18 @@ use miden_protocol::note::{ Note, NoteAssets, NoteAttachment, - NoteMetadata, + NoteAttachments, NoteRecipient, NoteScript, + NoteScriptRoot, NoteStorage, NoteType, + PartialNoteMetadata, }; use miden_standards::note::{NetworkAccountTarget, NoteExecutionHint}; use miden_utils_sync::LazyLock; -use crate::EthAddress; +use crate::{EthAddress, MetadataHash}; // NOTE SCRIPT // ================================================================================================ @@ -36,19 +38,68 @@ use crate::EthAddress; // Initialize the CONFIG_AGG_BRIDGE note script only once static CONFIG_AGG_BRIDGE_SCRIPT: LazyLock = LazyLock::new(|| { let bytes = - include_bytes!(concat!(env!("OUT_DIR"), "/assets/note_scripts/CONFIG_AGG_BRIDGE.masl")); + include_bytes!(concat!(env!("OUT_DIR"), "/assets/note_scripts/config_agg_bridge.masl")); let library = Library::read_from_bytes(bytes) .expect("shipped CONFIG_AGG_BRIDGE script library is well-formed"); NoteScript::from_library(&library).expect("shipped CONFIG_AGG_BRIDGE script is well-formed") }); +// CONVERSION METADATA +// ================================================================================================ + +/// The conversion metadata registered on the bridge for a single faucet. +/// +/// Encapsulates the origin-chain identity and bridge-side policy of a faucet: the EVM token +/// address, network id, decimal scale, whether the faucet is Miden-native (lock/unlock) or +/// bridge-owned (burn/mint), and the keccak256 metadata hash. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversionMetadata { + /// Account ID of the faucet being registered. + pub faucet_account_id: AccountId, + /// Origin EVM token address the faucet wraps. + pub origin_token_address: EthAddress, + /// Decimal scaling factor between the origin-chain unit and the Miden-side unit + /// (e.g. 0 for USDC, 8 for ETH). + pub scale: u8, + /// Origin network / chain ID the token lives on. + pub origin_network: u32, + /// `true` for Miden-native faucets (bridge-in unlocks from the bridge vault, bridge-out + /// locks into it); `false` for bridge-owned faucets (bridge-in mints via the faucet, + /// bridge-out burns via the faucet). + pub is_native: bool, + /// keccak256 hash of the ABI-encoded token metadata (`name`, `symbol`, `decimals`). + pub metadata_hash: MetadataHash, +} + +impl ConversionMetadata { + /// Serializes the metadata to the 18-felt layout consumed by `CONFIG_AGG_BRIDGE`. + /// + /// `origin_network` is written in raw u32 form (no byte swap). The bridge stores it as-is + /// in `faucet_metadata_map`; `bridge_out::convert_asset` later applies `swap_u32_bytes` to + /// produce the leaf-side representation. The token-registry side of registration applies + /// the matching swap inside `register_faucet`'s MASM before hashing, keeping the hash + /// byte-identical with the leaf-side `lookup_faucet_by_token_address` input. + pub fn to_elements(&self) -> Vec { + let mut v = Vec::with_capacity(ConfigAggBridgeNote::NUM_STORAGE_ITEMS); + v.extend(self.origin_token_address.to_elements()); + v.push(self.faucet_account_id.suffix()); + v.push(self.faucet_account_id.prefix().as_felt()); + v.push(Felt::from(self.scale)); + v.push(Felt::from(self.origin_network)); + v.push(Felt::from(u8::from(self.is_native))); + v.extend(self.metadata_hash.to_elements()); + v + } +} + // CONFIG_AGG_BRIDGE NOTE // ================================================================================================ /// CONFIG_AGG_BRIDGE note. /// -/// This note is used to register a faucet in the bridge's faucet and token registries. -/// It carries the origin token address and faucet account ID, and is always public. +/// This note is used to register a faucet in the bridge's faucet and token registries, +/// and to store full conversion metadata (origin address, origin network, scale, metadata hash) +/// in the bridge's faucet metadata map. pub struct ConfigAggBridgeNote; impl ConfigAggBridgeNote { @@ -56,8 +107,19 @@ impl ConfigAggBridgeNote { // -------------------------------------------------------------------------------------------- /// Expected number of storage items for a CONFIG_AGG_BRIDGE note. - /// Layout: [origin_token_addr(5), faucet_id_suffix, faucet_id_prefix] - pub const NUM_STORAGE_ITEMS: usize = 7; + /// + /// Layout (18 felts): + /// - `[0..4]` origin_token_addr (5 felts) + /// - `[5]` faucet_id_suffix + /// - `[6]` faucet_id_prefix + /// - `[7]` scale + /// - `[8]` origin_network (raw u32; the MASM register flow byte-swaps it before hashing + /// into the token-registry key, and `bridge_out` byte-swaps it before placing it in the LET + /// leaf) + /// - `[9]` is_native (0 or 1) + /// - `[10..13]` METADATA_HASH_LO (4 felts) + /// - `[14..17]` METADATA_HASH_HI (4 felts) + pub const NUM_STORAGE_ITEMS: usize = 18; // PUBLIC ACCESSORS // -------------------------------------------------------------------------------------------- @@ -68,7 +130,7 @@ impl ConfigAggBridgeNote { } /// Returns the CONFIG_AGG_BRIDGE note script root. - pub fn script_root() -> Word { + pub fn script_root() -> NoteScriptRoot { CONFIG_AGG_BRIDGE_SCRIPT.root() } @@ -77,33 +139,28 @@ impl ConfigAggBridgeNote { /// Creates a CONFIG_AGG_BRIDGE note to register a faucet in the bridge's registry. /// - /// The note storage contains 7 felts: - /// - `origin_token_addr[0..5]`: The 5 u32 felts of the origin EVM token address - /// - `faucet_id_suffix`: The suffix of the faucet account ID - /// - `faucet_id_prefix`: The prefix of the faucet account ID - /// /// # Parameters - /// - `faucet_account_id`: The account ID of the faucet to register - /// - `origin_token_address`: The origin EVM token address for the token registry - /// - `sender_account_id`: The account ID of the note creator - /// - `target_account_id`: The bridge account ID that will consume this note - /// - `rng`: Random number generator for creating the note serial number + /// - `metadata`: The conversion metadata to register for the faucet. + /// - `sender_account_id`: The account ID of the note creator. + /// - `target_account_id`: The bridge account ID that will consume this note. + /// - `rng`: Random number generator for creating the note serial number. /// /// # Errors /// Returns an error if note creation fails. pub fn create( - faucet_account_id: AccountId, - origin_token_address: &EthAddress, + metadata: ConversionMetadata, sender_account_id: AccountId, target_account_id: AccountId, rng: &mut R, ) -> Result { - // Create note storage with 7 felts: [origin_token_addr(5), faucet_id_suffix, - // faucet_id_prefix] - let addr_elements = origin_token_address.to_elements(); - let mut storage_values: Vec = addr_elements; - storage_values.push(faucet_account_id.suffix()); - storage_values.push(faucet_account_id.prefix().as_felt()); + let storage_values = metadata.to_elements(); + + debug_assert_eq!( + storage_values.len(), + Self::NUM_STORAGE_ITEMS, + "CONFIG_AGG_BRIDGE storage must have exactly {} felts", + Self::NUM_STORAGE_ITEMS + ); let note_storage = NoteStorage::new(storage_values)?; @@ -112,16 +169,58 @@ impl ConfigAggBridgeNote { let recipient = NoteRecipient::new(serial_num, Self::script(), note_storage); - let attachment = NoteAttachment::from( - NetworkAccountTarget::new(target_account_id, NoteExecutionHint::Always) - .map_err(|e| NoteError::other(e.to_string()))?, - ); - let metadata = - NoteMetadata::new(sender_account_id, NoteType::Public).with_attachment(attachment); + 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); // CONFIG_AGG_BRIDGE notes don't carry assets let assets = NoteAssets::new(vec![])?; - Ok(Note::new(assets, metadata, recipient)) + Ok(Note::with_attachments(assets, metadata, recipient, attachments)) + } +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET; + + use super::*; + + /// Locks in the 18-felt wire layout of `CONFIG_AGG_BRIDGE` note storage. Any reordering in + /// `to_elements` would silently desync from the indices the MASM `CONFIG_AGG_BRIDGE` script + /// reads from (`ORIGIN_TOKEN_ADDR_0..4`, `FAUCET_ID_SUFFIX=5`, ... `METADATA_HASH_HI_3=17`). + #[test] + fn to_elements_layout_matches_masm_storage_indices() { + let faucet = AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET) + .expect("valid faucet account id"); + let origin_token_address = + EthAddress::from_hex("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); + let metadata_hash = MetadataHash::from_token_info("USD Coin", "USDC", 6); + + let metadata = ConversionMetadata { + faucet_account_id: faucet, + origin_token_address, + scale: 6, + origin_network: 42, + is_native: true, + metadata_hash, + }; + + let elements = metadata.to_elements(); + + assert_eq!(elements.len(), ConfigAggBridgeNote::NUM_STORAGE_ITEMS); + assert_eq!(&elements[0..5], origin_token_address.to_elements().as_slice()); + assert_eq!(elements[5], faucet.suffix()); + assert_eq!(elements[6], faucet.prefix().as_felt()); + assert_eq!(elements[7], Felt::from(6_u8)); + // origin_network is stored raw (the MASM bridge-side does any required byte-swap + // before hashing into the token-registry or placing into the LET leaf). + assert_eq!(elements[8], Felt::from(42_u32)); + assert_eq!(elements[9], Felt::from(1_u8)); + assert_eq!(&elements[10..18], metadata_hash.to_elements().as_slice()); } } diff --git a/crates/miden-agglayer/src/errors/agglayer.rs b/crates/miden-agglayer/src/errors/agglayer.rs deleted file mode 100644 index 045c3226d6..0000000000 --- a/crates/miden-agglayer/src/errors/agglayer.rs +++ /dev/null @@ -1,93 +0,0 @@ -use miden_protocol::errors::MasmError; - -// This file is generated by build.rs, do not modify manually. -// It is generated by extracting errors from the MASM files in the `./asm` directory. -// -// To add a new error, define a constant in MASM of the pattern `const ERR__...`. -// Try to fit the error into a pre-existing category if possible (e.g. Account, Note, ...). - -// AGGLAYER ERRORS -// ================================================================================================ - -/// Error Message: "B2AGG note attachment target account does not match consuming account" -pub const ERR_B2AGG_TARGET_ACCOUNT_MISMATCH: MasmError = MasmError::from_static_str("B2AGG note attachment target account does not match consuming account"); -/// Error Message: "B2AGG script expects exactly 6 note storage items" -pub const ERR_B2AGG_UNEXPECTED_NUMBER_OF_STORAGE_ITEMS: MasmError = MasmError::from_static_str("B2AGG script expects exactly 6 note storage items"); -/// Error Message: "B2AGG script requires exactly 1 note asset" -pub const ERR_B2AGG_WRONG_NUMBER_OF_ASSETS: MasmError = MasmError::from_static_str("B2AGG script requires exactly 1 note asset"); - -/// Error Message: "mainnet flag must be 1 for a mainnet deposit" -pub const ERR_BRIDGE_NOT_MAINNET: MasmError = MasmError::from_static_str("mainnet flag must be 1 for a mainnet deposit"); -/// Error Message: "mainnet flag must be 0 for a rollup deposit" -pub const ERR_BRIDGE_NOT_ROLLUP: MasmError = MasmError::from_static_str("mainnet flag must be 0 for a rollup deposit"); - -/// Error Message: "claim note has already been spent" -pub const ERR_CLAIM_ALREADY_SPENT: MasmError = MasmError::from_static_str("claim note has already been spent"); -/// Error Message: "CLAIM note attachment target account does not match consuming account" -pub const ERR_CLAIM_TARGET_ACCT_MISMATCH: MasmError = MasmError::from_static_str("CLAIM note attachment target account does not match consuming account"); - -/// Error Message: "CONFIG_AGG_BRIDGE note attachment target account does not match consuming account" -pub const ERR_CONFIG_AGG_BRIDGE_TARGET_ACCOUNT_MISMATCH: MasmError = MasmError::from_static_str("CONFIG_AGG_BRIDGE note attachment target account does not match consuming account"); -/// Error Message: "CONFIG_AGG_BRIDGE expects exactly 7 note storage items" -pub const ERR_CONFIG_AGG_BRIDGE_UNEXPECTED_STORAGE_ITEMS: MasmError = MasmError::from_static_str("CONFIG_AGG_BRIDGE expects exactly 7 note storage items"); - -/// Error Message: "faucet is not registered in the bridge's faucet registry" -pub const ERR_FAUCET_NOT_REGISTERED: MasmError = MasmError::from_static_str("faucet is not registered in the bridge's faucet registry"); - -/// Error Message: "combined u64 doesn't fit in field" -pub const ERR_FELT_OUT_OF_FIELD: MasmError = MasmError::from_static_str("combined u64 doesn't fit in field"); - -/// Error Message: "GER not found in storage" -pub const ERR_GER_NOT_FOUND: MasmError = MasmError::from_static_str("GER not found in storage"); - -/// Error Message: "leading bits of global index must be zero" -pub const ERR_LEADING_BITS_NON_ZERO: MasmError = MasmError::from_static_str("leading bits of global index must be zero"); - -/// Error Message: "mainnet flag must be 0 or 1" -pub const ERR_MAINNET_FLAG_INVALID: MasmError = MasmError::from_static_str("mainnet flag must be 0 or 1"); - -/// Error Message: "most-significant 4 bytes must be zero for AccountId" -pub const ERR_MSB_NONZERO: MasmError = MasmError::from_static_str("most-significant 4 bytes must be zero for AccountId"); - -/// Error Message: "number of leaves in the MTF would exceed 4294967295 (2^32 - 1)" -pub const ERR_MTF_LEAVES_NUM_EXCEED_LIMIT: MasmError = MasmError::from_static_str("number of leaves in the MTF would exceed 4294967295 (2^32 - 1)"); - -/// Error Message: "address limb is not u32" -pub const ERR_NOT_U32: MasmError = MasmError::from_static_str("address limb is not u32"); - -/// Error Message: "remainder z must be < 10^s" -pub const ERR_REMAINDER_TOO_LARGE: MasmError = MasmError::from_static_str("remainder z must be < 10^s"); - -/// Error Message: "rollup index must be zero for a mainnet deposit" -pub const ERR_ROLLUP_INDEX_NON_ZERO: MasmError = MasmError::from_static_str("rollup index must be zero for a mainnet deposit"); - -/// Error Message: "maximum scaling factor is 18" -pub const ERR_SCALE_AMOUNT_EXCEEDED_LIMIT: MasmError = MasmError::from_static_str("maximum scaling factor is 18"); - -/// Error Message: "note sender is not the bridge admin" -pub const ERR_SENDER_NOT_BRIDGE_ADMIN: MasmError = MasmError::from_static_str("note sender is not the bridge admin"); -/// Error Message: "note sender is not the global exit root manager" -pub const ERR_SENDER_NOT_GER_MANAGER: MasmError = MasmError::from_static_str("note sender is not the global exit root manager"); - -/// Error Message: "merkle proof verification failed: provided SMT root does not match the computed root" -pub const ERR_SMT_ROOT_VERIFICATION_FAILED: MasmError = MasmError::from_static_str("merkle proof verification failed: provided SMT root does not match the computed root"); - -/// Error Message: "source bridge network overflowed u32" -pub const ERR_SOURCE_BRIDGE_NETWORK_OVERFLOW: MasmError = MasmError::from_static_str("source bridge network overflowed u32"); - -/// Error Message: "token address is not registered in the bridge's token registry" -pub const ERR_TOKEN_NOT_REGISTERED: MasmError = MasmError::from_static_str("token address is not registered in the bridge's token registry"); - -/// Error Message: "x < y*10^s (underflow detected)" -pub const ERR_UNDERFLOW: MasmError = MasmError::from_static_str("x < y*10^s (underflow detected)"); - -/// Error Message: "UPDATE_GER note attachment target account does not match consuming account" -pub const ERR_UPDATE_GER_TARGET_ACCOUNT_MISMATCH: MasmError = MasmError::from_static_str("UPDATE_GER note attachment target account does not match consuming account"); -/// Error Message: "UPDATE_GER script expects exactly 8 note storage items" -pub const ERR_UPDATE_GER_UNEXPECTED_NUMBER_OF_STORAGE_ITEMS: MasmError = MasmError::from_static_str("UPDATE_GER script expects exactly 8 note storage items"); - -/// Error Message: "the agglayer bridge in u256 value is larger than 2**128 and cannot be verifiably scaled to u64" -pub const ERR_X_TOO_LARGE: MasmError = MasmError::from_static_str("the agglayer bridge in u256 value is larger than 2**128 and cannot be verifiably scaled to u64"); - -/// Error Message: "y exceeds max fungible token amount" -pub const ERR_Y_TOO_LARGE: MasmError = MasmError::from_static_str("y exceeds max fungible token amount"); diff --git a/crates/miden-agglayer/src/errors/mod.rs b/crates/miden-agglayer/src/errors/mod.rs index 2a86602e6f..3156528f2b 100644 --- a/crates/miden-agglayer/src/errors/mod.rs +++ b/crates/miden-agglayer/src/errors/mod.rs @@ -1,3 +1,3 @@ // Include generated error constants #[cfg(any(feature = "testing", test))] -include!("agglayer.rs"); +include!(concat!(env!("OUT_DIR"), "/agglayer_errors.rs")); diff --git a/crates/miden-agglayer/src/eth_types/amount.rs b/crates/miden-agglayer/src/eth_types/amount.rs index 9fda836856..2daf615bea 100644 --- a/crates/miden-agglayer/src/eth_types/amount.rs +++ b/crates/miden-agglayer/src/eth_types/amount.rs @@ -135,7 +135,7 @@ impl EthAmount { // y must fit into u64; canonical Felt is guaranteed by max amount bound let y_u64: u64 = y_u256.try_into().map_err(|_| EthAmountError::ScaledValueDoesNotFitU64)?; - if y_u64 > FungibleAsset::MAX_AMOUNT { + if y_u64 > FungibleAsset::MAX_AMOUNT.as_u64() { return Err(EthAmountError::ScaledValueExceedsMaxFungibleAmount); } diff --git a/crates/miden-agglayer/src/eth_types/eth_embedded_account_id.rs b/crates/miden-agglayer/src/eth_types/eth_embedded_account_id.rs index aa62d04e87..bc27f70ed5 100644 --- a/crates/miden-agglayer/src/eth_types/eth_embedded_account_id.rs +++ b/crates/miden-agglayer/src/eth_types/eth_embedded_account_id.rs @@ -20,7 +20,8 @@ use super::eth_address::{AddressConversionError, EthAddress}; /// - suffix = bytes[12..20] as a big-endian u64 /// /// Note: prefix/suffix are *conceptual* 64-bit words; when converting to [`Felt`], we must ensure -/// `Felt::new(u64)` does not reduce mod p (checked explicitly in [`Self::try_from_eth_address`]). +/// `Felt::new_unchecked(u64)` does not reduce mod p (checked explicitly in +/// [`Self::try_from_eth_address`]). /// /// This type is used by integrators (Gateway, claim managers) to convert between Miden AccountIds /// and the Ethereum address format when constructing CLAIM notes or calling the AggLayer Bridge diff --git a/crates/miden-agglayer/src/eth_types/global_index.rs b/crates/miden-agglayer/src/eth_types/global_index.rs index 96aa1f6e09..5a9f307f74 100644 --- a/crates/miden-agglayer/src/eth_types/global_index.rs +++ b/crates/miden-agglayer/src/eth_types/global_index.rs @@ -194,7 +194,7 @@ mod tests { assert_eq!(elements[0..5], [Felt::ZERO; 5]); // mainnet flag: BE value 1 → LE-packed as 0x01000000 - assert_eq!(elements[5], Felt::new(u32::from_le_bytes(1u32.to_be_bytes()) as u64)); + assert_eq!(elements[5], Felt::from(u32::from_le_bytes(1_u32.to_be_bytes()))); // rollup index assert_eq!(elements[6], Felt::ZERO); @@ -202,7 +202,7 @@ mod tests { // leaf index: BE value → LE-packed assert_eq!( elements[7], - Felt::new(u32::from_le_bytes(expected_leaf_index.to_be_bytes()) as u64) + Felt::from(u32::from_le_bytes(expected_leaf_index.to_be_bytes())) ); } } diff --git a/crates/miden-agglayer/src/eth_types/metadata_hash.rs b/crates/miden-agglayer/src/eth_types/metadata_hash.rs index d0f02d83fa..617c6ea5b0 100644 --- a/crates/miden-agglayer/src/eth_types/metadata_hash.rs +++ b/crates/miden-agglayer/src/eth_types/metadata_hash.rs @@ -92,7 +92,7 @@ mod tests { use super::*; - /// Partial deserialization of claim_asset_vectors_local_tx.json + /// Partial deserialization of claim_asset_vectors_l1_tx.json #[derive(Deserialize)] struct ClaimAssetVectors { metadata: std::string::String, @@ -101,7 +101,7 @@ mod tests { fn load_test_vectors() -> ClaimAssetVectors { let path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("solidity-compat/test-vectors/claim_asset_vectors_local_tx.json"); + .join("solidity-compat/test-vectors/claim_asset_vectors_l1_tx.json"); let data = std::fs::read_to_string(path).expect("failed to read test vectors"); serde_json::from_str(&data).expect("failed to parse test vectors") } diff --git a/crates/miden-agglayer/src/faucet.rs b/crates/miden-agglayer/src/faucet.rs index 306a8acc99..2d1c28ca46 100644 --- a/crates/miden-agglayer/src/faucet.rs +++ b/crates/miden-agglayer/src/faucet.rs @@ -1,24 +1,20 @@ extern crate alloc; +use alloc::collections::BTreeSet; +use alloc::string::ToString; use alloc::vec; use alloc::vec::Vec; use miden_core::{Felt, Word}; use miden_protocol::account::component::AccountComponentMetadata; -use miden_protocol::account::{ - Account, - AccountComponent, - AccountId, - AccountType, - StorageSlot, - StorageSlotName, -}; -use miden_protocol::asset::TokenSymbol; +use miden_protocol::account::{Account, AccountComponent, AccountId, StorageSlot, StorageSlotName}; +use miden_protocol::asset::{AssetAmount, TokenSymbol}; use miden_protocol::errors::AccountIdError; -use miden_standards::account::access::Ownable2Step; -use miden_standards::account::faucets::{FungibleFaucetError, TokenMetadata}; -use miden_standards::account::mint_policies::OwnerControlled; -use miden_utils_sync::LazyLock; +use miden_protocol::note::NoteScriptRoot; +use miden_standards::account::access::{Authority, Ownable2Step}; +use miden_standards::account::faucets::{FungibleFaucet, FungibleFaucetError, TokenName}; +use miden_standards::account::policies::TokenPolicyManager; +use miden_standards::note::{BurnNote, MintNote}; use thiserror::Error; use super::agglayer_faucet_component_library; @@ -39,7 +35,6 @@ pub use crate::{ ProofData, SmtNode, UpdateGerNote, - create_claim_note, }; // CONSTANTS @@ -50,55 +45,30 @@ include!(concat!(env!("OUT_DIR"), "/agglayer_constants.rs")); // AGGLAYER FAUCET STRUCT // ================================================================================================ -static CONVERSION_INFO_1_SLOT_NAME: LazyLock = LazyLock::new(|| { - StorageSlotName::new("agglayer::faucet::conversion_info_1") - .expect("conversion info 1 storage slot name should be valid") -}); -static CONVERSION_INFO_2_SLOT_NAME: LazyLock = LazyLock::new(|| { - StorageSlotName::new("agglayer::faucet::conversion_info_2") - .expect("conversion info 2 storage slot name should be valid") -}); -static METADATA_HASH_LO_SLOT_NAME: LazyLock = LazyLock::new(|| { - StorageSlotName::new("agglayer::faucet::metadata_hash_lo") - .expect("metadata hash lo storage slot name should be valid") -}); -static METADATA_HASH_HI_SLOT_NAME: LazyLock = LazyLock::new(|| { - StorageSlotName::new("agglayer::faucet::metadata_hash_hi") - .expect("metadata hash hi storage slot name should be valid") -}); /// An [`AccountComponent`] implementing the AggLayer Faucet. /// -/// It reexports the procedures from `agglayer::faucet`. When linking against this -/// component, the `agglayer` library must be available to the assembler. -/// The procedures of this component are: -/// - `distribute`, which mints assets and creates output notes (with owner verification). -/// - `asset_to_origin_asset`, which converts an asset to the origin asset (used in FPI from -/// bridge). -/// - `burn`, which burns an asset. +/// It re-exports `mint_and_send` and `receive_and_burn` from the agglayer faucet library. +/// Conversion metadata (origin address, origin network, scale, metadata hash) is held by the +/// bridge, not the faucet — see +/// [`AggLayerBridge`] and the `faucet_metadata_map` populated on registration. /// /// ## Storage Layout /// -/// - [`Self::metadata_slot`]: Stores [`TokenMetadata`]. -/// - [`Self::conversion_info_1_slot`]: Stores the first 4 felts of the origin token address. -/// - [`Self::conversion_info_2_slot`]: Stores the remaining 5th felt of the origin token address + -/// origin network + scale. -/// - [`Self::metadata_hash_lo_slot`]: Stores the first 4 u32 felts of the metadata hash. -/// - [`Self::metadata_hash_hi_slot`]: Stores the last 4 u32 felts of the metadata hash. +/// - All [`FungibleFaucet`] storage slots (token config + name + mutability + description + logo +/// URI + external link). Conversion metadata is no longer stored on the faucet; the bridge holds +/// it in `faucet_metadata_map`. /// /// ## Required Companion Components /// -/// This component re-exports `network_fungible::mint_and_send`, which requires: +/// This component re-exports `fungible::mint_and_send`, which requires: /// - [`Ownable2Step`]: Provides ownership data (bridge account ID as owner). -/// - [`miden_standards::account::mint_policies::OwnerControlled`]: Provides mint policy management. +/// - [`miden_standards::account::policies::TokenPolicyManager`]: Provides mint and burn policy +/// management. /// /// These must be added as separate components when building the faucet account. #[derive(Debug, Clone)] pub struct AggLayerFaucet { - metadata: TokenMetadata, - origin_token_address: EthAddress, - origin_network: u32, - scale: u8, - metadata_hash: MetadataHash, + faucet: FungibleFaucet, } impl AggLayerFaucet { @@ -107,30 +77,43 @@ impl AggLayerFaucet { /// Creates a new AggLayer faucet component from the given configuration. /// + /// The faucet's display name is derived from the symbol (an AggLayer faucet is identified by + /// its symbol; the human-readable name is not used in the bridge protocol). + /// /// # Errors /// Returns an error if: - /// - The decimals parameter exceeds maximum value of [`TokenMetadata::MAX_DECIMALS`]. + /// - The decimals parameter exceeds maximum value of [`FungibleFaucet::MAX_DECIMALS`]. /// - The max supply exceeds maximum possible amount for a fungible asset. /// - The token supply exceeds the max supply. - #[allow(clippy::too_many_arguments)] pub fn new( symbol: TokenSymbol, decimals: u8, max_supply: Felt, token_supply: Felt, - origin_token_address: EthAddress, - origin_network: u32, - scale: u8, - metadata_hash: MetadataHash, ) -> Result { - let metadata = TokenMetadata::with_supply(symbol, decimals, max_supply, token_supply)?; - Ok(Self { - metadata, - origin_token_address, - origin_network, - scale, - metadata_hash, - }) + // Use the symbol as the display name; AggLayer faucets do not use a separate token name. + let name = TokenName::new(symbol.to_string().as_str()) + .expect("symbol fits within token name capacity"); + let max_supply_amount = AssetAmount::try_from(max_supply).map_err(|_| { + FungibleFaucetError::MaxSupplyTooLarge { + actual: max_supply.as_canonical_u64(), + max: AssetAmount::MAX.as_u64(), + } + })?; + let token_supply_amount = AssetAmount::try_from(token_supply).map_err(|_| { + FungibleFaucetError::MaxSupplyTooLarge { + actual: token_supply.as_canonical_u64(), + max: AssetAmount::MAX.as_u64(), + } + })?; + let faucet = FungibleFaucet::builder() + .name(name) + .symbol(symbol) + .decimals(decimals) + .max_supply(max_supply_amount) + .token_supply(token_supply_amount) + .build()?; + Ok(Self { faucet }) } /// Sets the token supply for an existing faucet (e.g. for testing scenarios). @@ -138,158 +121,75 @@ impl AggLayerFaucet { /// # Errors /// Returns an error if the token supply exceeds the max supply. pub fn with_token_supply(mut self, token_supply: Felt) -> Result { - self.metadata = self.metadata.with_token_supply(token_supply)?; + let token_supply_amount = AssetAmount::try_from(token_supply).map_err(|_| { + FungibleFaucetError::MaxSupplyTooLarge { + actual: token_supply.as_canonical_u64(), + max: AssetAmount::MAX.as_u64(), + } + })?; + self.faucet = self.faucet.with_token_supply(token_supply_amount)?; Ok(self) } // PUBLIC ACCESSORS // -------------------------------------------------------------------------------------------- - /// Storage slot name for [`TokenMetadata`]. - pub fn metadata_slot() -> &'static StorageSlotName { - TokenMetadata::metadata_slot() + /// Storage slot name for the token config word + /// `[token_supply, max_supply, decimals, token_symbol]`. + pub fn token_config_slot() -> &'static StorageSlotName { + FungibleFaucet::token_config_slot() } - /// Storage slot name for the first 4 felts of the origin token address. - pub fn conversion_info_1_slot() -> &'static StorageSlotName { - &CONVERSION_INFO_1_SLOT_NAME - } - - /// Storage slot name for the 5th felt of the origin token address, origin network, and scale. - pub fn conversion_info_2_slot() -> &'static StorageSlotName { - &CONVERSION_INFO_2_SLOT_NAME - } - - /// Storage slot name for the first 4 u32 felts of the metadata hash. - pub fn metadata_hash_lo_slot() -> &'static StorageSlotName { - &METADATA_HASH_LO_SLOT_NAME - } - - /// Storage slot name for the last 4 u32 felts of the metadata hash. - pub fn metadata_hash_hi_slot() -> &'static StorageSlotName { - &METADATA_HASH_HI_SLOT_NAME - } /// Storage slot name for the owner account ID (bridge), provided by the /// [`Ownable2Step`] companion component. pub fn owner_config_slot() -> &'static StorageSlotName { Ownable2Step::slot_name() } - /// Extracts the token metadata from the corresponding storage slot of the provided account. - /// - /// # Errors - /// - /// Returns an error if: - /// - the provided account is not an [`AggLayerFaucet`] account. - pub fn metadata(faucet_account: &Account) -> Result { - // check that the provided account is a faucet account - Self::assert_faucet_account(faucet_account)?; - - let metadata_word = faucet_account - .storage() - .get_item(TokenMetadata::metadata_slot()) - .expect("should be able to read metadata slot"); - TokenMetadata::try_from(metadata_word).map_err(AgglayerFaucetError::FungibleFaucetError) - } + // ALLOWED NOTES + // -------------------------------------------------------------------------------------------- - /// Extracts the bridge account ID from the [`Ownable2Step`] owner config storage slot - /// of the provided account. + /// Returns the set of input-note script roots that AggLayer faucet accounts accept. /// - /// # Errors + /// The faucet's [`AuthNetworkAccount`] component is initialized with this allowlist so only + /// MINT and BURN notes can drive the faucet. /// - /// Returns an error if: - /// - the provided account is not an [`AggLayerFaucet`] account. - pub fn owner_account_id(faucet_account: &Account) -> Result { - // check that the provided account is a faucet account - Self::assert_faucet_account(faucet_account)?; - - let ownership = Ownable2Step::try_from_storage(faucet_account.storage()) - .map_err(AgglayerFaucetError::Ownable2StepError)?; - ownership.owner().ok_or(AgglayerFaucetError::OwnershipRenounced) + /// [`AuthNetworkAccount`]: miden_standards::account::auth::AuthNetworkAccount + pub fn allowed_notes() -> BTreeSet { + BTreeSet::from([MintNote::script_root(), BurnNote::script_root()]) } - /// Extracts the origin token address from the corresponding storage slot of the provided - /// account. + /// Extracts the underlying [`FungibleFaucet`] component (which holds the token metadata) + /// from the storage slots of the provided account. /// /// # Errors /// /// Returns an error if: /// - the provided account is not an [`AggLayerFaucet`] account. - pub fn origin_token_address( + pub fn try_faucet_from_account( faucet_account: &Account, - ) -> Result { - // check that the provided account is a faucet account - Self::assert_faucet_account(faucet_account)?; - - let conversion_info_1 = faucet_account - .storage() - .get_item(&CONVERSION_INFO_1_SLOT_NAME) - .expect("should be able to read the first conversion info slot"); - - let conversion_info_2 = faucet_account - .storage() - .get_item(&CONVERSION_INFO_2_SLOT_NAME) - .expect("should be able to read the second conversion info slot"); - - let addr_bytes_vec = conversion_info_1 - .iter() - .chain([&conversion_info_2[0]]) - .flat_map(|felt| { - u32::try_from(felt.as_canonical_u64()) - .expect("Felt value does not fit into u32") - .to_le_bytes() - }) - .collect::>(); - - Ok(EthAddress::new( - addr_bytes_vec - .try_into() - .expect("origin token addr vector should consist of exactly 20 bytes"), - )) - } - - /// Extracts the origin network ID in form of the u32 from the corresponding storage slot of the - /// provided account. - /// - /// # Errors - /// - /// Returns an error if: - /// - the provided account is not an [`AggLayerFaucet`] account. - pub fn origin_network(faucet_account: &Account) -> Result { + ) -> Result { // check that the provided account is a faucet account Self::assert_faucet_account(faucet_account)?; - let conversion_info_2 = faucet_account - .storage() - .get_item(&CONVERSION_INFO_2_SLOT_NAME) - .expect("should be able to read the second conversion info slot"); - - Ok(conversion_info_2[1] - .as_canonical_u64() - .try_into() - .expect("origin network ID should fit into u32")) + FungibleFaucet::try_from(faucet_account.storage()) + .map_err(AgglayerFaucetError::FungibleFaucetError) } - /// Extracts the scaling factor in form of the u8 from the corresponding storage slot of the - /// provided account. + /// Extracts the bridge account ID from the [`Ownable2Step`] owner config storage slot + /// of the provided account. /// /// # Errors /// /// Returns an error if: /// - the provided account is not an [`AggLayerFaucet`] account. - pub fn scale(faucet_account: &Account) -> Result { + pub fn owner_account_id(faucet_account: &Account) -> Result { // check that the provided account is a faucet account Self::assert_faucet_account(faucet_account)?; - let conversion_info_2 = faucet_account - .storage() - .get_item(&CONVERSION_INFO_2_SLOT_NAME) - .expect("should be able to read the second conversion info slot"); - - Ok(conversion_info_2[2] - .as_canonical_u64() - .try_into() - .expect("scaling factor should fit into u8")) + let ownership = Ownable2Step::try_from_storage(faucet_account.storage()) + .map_err(AgglayerFaucetError::Ownable2StepError)?; + ownership.owner().ok_or(AgglayerFaucetError::OwnershipRenounced) } // HELPER FUNCTIONS @@ -357,51 +257,24 @@ impl AggLayerFaucet { /// Returns a vector of all [`AggLayerFaucet`] storage slot names. fn slot_names() -> Vec<&'static StorageSlotName> { vec![ - &*CONVERSION_INFO_1_SLOT_NAME, - &*CONVERSION_INFO_2_SLOT_NAME, - &*METADATA_HASH_LO_SLOT_NAME, - &*METADATA_HASH_HI_SLOT_NAME, - TokenMetadata::metadata_slot(), + FungibleFaucet::token_config_slot(), Ownable2Step::slot_name(), - OwnerControlled::active_policy_proc_root_slot(), - OwnerControlled::allowed_policy_proc_roots_slot(), - OwnerControlled::policy_authority_slot(), + Authority::authority_slot(), + TokenPolicyManager::active_mint_policy_slot(), + TokenPolicyManager::active_burn_policy_slot(), + TokenPolicyManager::allowed_mint_policies_slot(), + TokenPolicyManager::allowed_burn_policies_slot(), + TokenPolicyManager::allowed_send_policies_slot(), + TokenPolicyManager::allowed_receive_policies_slot(), ] } } impl From for AccountComponent { - fn from(faucet: AggLayerFaucet) -> Self { - let metadata_slot = StorageSlot::from(faucet.metadata); - - let (conversion_slot1_word, conversion_slot2_word) = agglayer_faucet_conversion_slots( - &faucet.origin_token_address, - faucet.origin_network, - faucet.scale, - ); - let conversion_slot1 = - StorageSlot::with_value(CONVERSION_INFO_1_SLOT_NAME.clone(), conversion_slot1_word); - let conversion_slot2 = - StorageSlot::with_value(CONVERSION_INFO_2_SLOT_NAME.clone(), conversion_slot2_word); - - let hash_elements = faucet.metadata_hash.to_elements(); - let metadata_hash_lo = StorageSlot::with_value( - METADATA_HASH_LO_SLOT_NAME.clone(), - Word::new([hash_elements[0], hash_elements[1], hash_elements[2], hash_elements[3]]), - ); - let metadata_hash_hi = StorageSlot::with_value( - METADATA_HASH_HI_SLOT_NAME.clone(), - Word::new([hash_elements[4], hash_elements[5], hash_elements[6], hash_elements[7]]), - ); - - let agglayer_storage_slots = vec![ - metadata_slot, - conversion_slot1, - conversion_slot2, - metadata_hash_lo, - metadata_hash_hi, - ]; - agglayer_faucet_component(agglayer_storage_slots) + fn from(agglayer_faucet: AggLayerFaucet) -> Self { + // Bring in all of the FungibleFaucet's storage slots (token config + name + + // mutability + description + logo URI + external link). + agglayer_faucet_component(agglayer_faucet.faucet.into_storage_slots()) } } @@ -427,51 +300,14 @@ pub enum AgglayerFaucetError { OwnershipRenounced, } -// FAUCET CONVERSION STORAGE HELPERS -// ================================================================================================ - -/// Builds the two storage slot values for faucet conversion metadata. -/// -/// The conversion metadata is stored in two value storage slots: -/// - Slot 1 (`agglayer::faucet::conversion_info_1`): `[addr0, addr1, addr2, addr3]` — first 4 felts -/// of the origin token address (5 × u32 limbs). -/// - Slot 2 (`agglayer::faucet::conversion_info_2`): `[addr4, origin_network, scale, 0]` — -/// remaining address felt + origin network + scale factor. -/// -/// # Parameters -/// - `origin_token_address`: The EVM token address in Ethereum format -/// - `origin_network`: The origin network/chain ID -/// - `scale`: The decimal scaling factor (exponent for 10^scale) -/// -/// # Returns -/// A tuple of two `Word` values representing the two storage slot contents. -fn agglayer_faucet_conversion_slots( - origin_token_address: &EthAddress, - origin_network: u32, - scale: u8, -) -> (Word, Word) { - let addr_elements = origin_token_address.to_elements(); - - let slot1 = Word::new([addr_elements[0], addr_elements[1], addr_elements[2], addr_elements[3]]); - - let slot2 = - Word::new([addr_elements[4], Felt::from(origin_network), Felt::from(scale), Felt::ZERO]); - - (slot1, slot2) -} - // HELPER FUNCTIONS // ================================================================================================ /// Creates an Agglayer Faucet component with the specified storage slots. -/// -/// This component combines network faucet functionality with bridge validation -/// via Foreign Procedure Invocation (FPI). It provides a "claim" procedure that -/// validates CLAIM notes against a bridge MMR account before minting assets. fn agglayer_faucet_component(storage_slots: Vec) -> AccountComponent { let library = agglayer_faucet_component_library(); - let metadata = AccountComponentMetadata::new("agglayer::faucet", [AccountType::FungibleFaucet]) - .with_description("AggLayer faucet component with bridge validation"); + let metadata = AccountComponentMetadata::new("agglayer::faucet") + .with_description("AggLayer faucet component"); AccountComponent::new(library, storage_slots, metadata).expect( "agglayer_faucet component should satisfy the requirements of a valid account component", diff --git a/crates/miden-agglayer/src/lib.rs b/crates/miden-agglayer/src/lib.rs index 7d7945f2a6..0826a1e777 100644 --- a/crates/miden-agglayer/src/lib.rs +++ b/crates/miden-agglayer/src/lib.rs @@ -5,19 +5,18 @@ extern crate alloc; use miden_assembly::Library; use miden_assembly::serde::Deserializable; use miden_core::{Felt, Word}; -use miden_protocol::account::{ - Account, - AccountBuilder, - AccountComponent, - AccountId, - AccountStorageMode, - AccountType, -}; +use miden_protocol::account::{Account, AccountBuilder, AccountComponent, AccountId, AccountType}; use miden_protocol::asset::TokenSymbol; -use miden_protocol::note::NoteScript; -use miden_standards::account::access::Ownable2Step; -use miden_standards::account::auth::NoAuth; -use miden_standards::account::mint_policies::OwnerControlled; +use miden_standards::account::access::{Authority, Ownable2Step}; +use miden_standards::account::auth::AuthNetworkAccount; +use miden_standards::account::policies::{ + BurnAllowAll, + BurnPolicyConfig, + MintPolicyConfig, + PolicyRegistration, + TokenPolicyManager, + TransferPolicy, +}; use miden_utils_sync::LazyLock; pub mod b2agg_note; @@ -36,15 +35,15 @@ pub use b2agg_note::B2AggNote; pub use bridge::{AggLayerBridge, AgglayerBridgeError}; pub use claim_note::{ CgiChainHash, + ClaimNote, ClaimNoteStorage, ExitRoot, LeafData, LeafValue, ProofData, SmtNode, - create_claim_note, }; -pub use config_note::ConfigAggBridgeNote; +pub use config_note::{ConfigAggBridgeNote, ConversionMetadata}; #[cfg(any(test, feature = "testing"))] pub use eth_types::GlobalIndexExt; pub use eth_types::{ @@ -60,22 +59,6 @@ pub use faucet::{AggLayerFaucet, AgglayerFaucetError}; pub use update_ger_note::UpdateGerNote; pub use utils::Keccak256Output; -// AGGLAYER NOTE SCRIPTS -// ================================================================================================ - -// Initialize the CLAIM note script only once -static CLAIM_SCRIPT: LazyLock = LazyLock::new(|| { - let bytes = include_bytes!(concat!(env!("OUT_DIR"), "/assets/note_scripts/CLAIM.masl")); - let library = - Library::read_from_bytes(bytes).expect("shipped CLAIM script library is well-formed"); - NoteScript::from_library(&library).expect("shipped CLAIM script is well-formed") -}); - -/// Returns the CLAIM (Bridge from AggLayer) note script. -pub fn claim_script() -> NoteScript { - CLAIM_SCRIPT.clone() -} - // AGGLAYER ACCOUNT COMPONENTS // ================================================================================================ @@ -114,65 +97,51 @@ fn agglayer_faucet_component_library() -> Library { /// Creates an agglayer faucet account component with the specified configuration. /// -/// This function creates all the necessary storage slots for an agglayer faucet: -/// - Network faucet metadata slot (token_supply, max_supply, decimals, token_symbol) -/// - Conversion info slot 1: first 4 felts of origin token address -/// - Conversion info slot 2: 5th address felt + origin network + scale -/// - Owner config slot: bridge account ID for MINT note authorization +/// The faucet holds only token metadata; conversion metadata (origin address, origin network, +/// scale, metadata hash) lives on the bridge and is populated at registration time. /// /// # Parameters /// - `token_symbol`: The symbol for the fungible token (e.g., "AGG") /// - `decimals`: Number of decimal places for the token /// - `max_supply`: Maximum supply of the token /// - `token_supply`: Initial outstanding token supply (0 for new faucets) -/// - `bridge_account_id`: The account ID of the bridge account for validation -/// - `origin_token_address`: The EVM origin token address -/// - `origin_network`: The origin network/chain ID -/// - `scale`: The decimal scaling factor (exponent for 10^scale) /// /// # Returns /// Returns an [`AccountComponent`] configured for agglayer faucet operations. /// /// # Panics /// Panics if the token symbol is invalid or metadata validation fails. -#[allow(clippy::too_many_arguments)] fn create_agglayer_faucet_component( token_symbol: &str, decimals: u8, max_supply: Felt, token_supply: Felt, - origin_token_address: &EthAddress, - origin_network: u32, - scale: u8, - metadata_hash: MetadataHash, ) -> AccountComponent { let symbol = TokenSymbol::new(token_symbol).expect("token symbol should be valid"); - AggLayerFaucet::new( - symbol, - decimals, - max_supply, - token_supply, - *origin_token_address, - origin_network, - scale, - metadata_hash, - ) - .expect("agglayer faucet metadata should be valid") - .into() + AggLayerFaucet::new(symbol, decimals, max_supply, token_supply) + .expect("agglayer faucet metadata should be valid") + .into() } /// Creates a complete bridge account builder with the standard configuration. /// /// The bridge starts with an empty faucet registry. Faucets are registered at runtime /// via CONFIG_AGG_BRIDGE notes that call `bridge_config::register_faucet`. +/// +/// The builder is pre-wired with the [`AuthNetworkAccount`] auth component, initialized with +/// [`AggLayerBridge::allowed_notes()`] so the bridge only accepts its sanctioned input notes. fn create_bridge_account_builder( seed: Word, bridge_admin_id: AccountId, ger_manager_id: AccountId, ) -> AccountBuilder { Account::builder(seed.into()) - .storage_mode(AccountStorageMode::Network) + .account_type(AccountType::Public) .with_component(AggLayerBridge::new(bridge_admin_id, ger_manager_id)) + .with_auth_component( + AuthNetworkAccount::with_allowlist(AggLayerBridge::allowed_notes()) + .expect("bridge note allowlist is non-empty"), + ) } /// Creates a new bridge account with the standard configuration. @@ -184,7 +153,6 @@ pub fn create_bridge_account( ger_manager_id: AccountId, ) -> Account { create_bridge_account_builder(seed, bridge_admin_id, ger_manager_id) - .with_auth_component(AccountComponent::from(NoAuth)) .build() .expect("bridge account should be valid") } @@ -199,7 +167,6 @@ pub fn create_existing_bridge_account( ger_manager_id: AccountId, ) -> Account { create_bridge_account_builder(seed, bridge_admin_id, ger_manager_id) - .with_auth_component(AccountComponent::from(NoAuth)) .build_existing() .expect("bridge account should be valid") } @@ -207,11 +174,16 @@ pub fn create_existing_bridge_account( /// Creates a complete agglayer faucet account builder with the specified configuration. /// /// The builder includes: -/// - The `AggLayerFaucet` component (conversion metadata + token metadata). +/// - The `AggLayerFaucet` component (token metadata only). /// - The `Ownable2Step` component (bridge account ID as owner for mint authorization). -/// - The `OwnerControlled` component (mint policy management required by -/// `network_fungible::mint_and_send`). -#[allow(clippy::too_many_arguments)] +/// - A [`TokenPolicyManager`] (owner-controlled) configured with `MintPolicyConfig::OwnerOnly` and +/// `BurnPolicyConfig::OwnerOnly`. 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 +/// policy procedure. +/// - The [`AuthNetworkAccount`] auth component, initialized with +/// [`AggLayerFaucet::allowed_notes()`] so the faucet only accepts MINT and BURN notes. fn create_agglayer_faucet_builder( seed: Word, token_symbol: &str, @@ -219,44 +191,46 @@ fn create_agglayer_faucet_builder( max_supply: Felt, token_supply: Felt, bridge_account_id: AccountId, - origin_token_address: &EthAddress, - origin_network: u32, - scale: u8, - metadata_hash: MetadataHash, ) -> AccountBuilder { - let agglayer_component = create_agglayer_faucet_component( - token_symbol, - decimals, - max_supply, - token_supply, - origin_token_address, - origin_network, - scale, - metadata_hash, - ); + let agglayer_component = + create_agglayer_faucet_component(token_symbol, decimals, max_supply, token_supply); + + // `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"); Account::builder(seed.into()) - .account_type(AccountType::FungibleFaucet) - .storage_mode(AccountStorageMode::Network) + .account_type(AccountType::Public) .with_component(agglayer_component) .with_component(Ownable2Step::new(bridge_account_id)) - .with_component(OwnerControlled::owner_only()) + .with_component(Authority::OwnerControlled) + .with_components(token_policy_manager) + .with_component(BurnAllowAll) + .with_auth_component( + AuthNetworkAccount::with_allowlist(AggLayerFaucet::allowed_notes()) + .expect("faucet note allowlist is non-empty"), + ) } /// Creates a new agglayer faucet account with the specified configuration. /// /// This creates a new account suitable for production use. -#[allow(clippy::too_many_arguments)] pub fn create_agglayer_faucet( seed: Word, token_symbol: &str, decimals: u8, max_supply: Felt, bridge_account_id: AccountId, - origin_token_address: &EthAddress, - origin_network: u32, - scale: u8, - metadata_hash: MetadataHash, ) -> Account { create_agglayer_faucet_builder( seed, @@ -265,12 +239,7 @@ pub fn create_agglayer_faucet( max_supply, Felt::ZERO, bridge_account_id, - origin_token_address, - origin_network, - scale, - metadata_hash, ) - .with_auth_component(AccountComponent::from(NoAuth)) .build() .expect("agglayer faucet account should be valid") } @@ -279,7 +248,6 @@ pub fn create_agglayer_faucet( /// /// This creates an existing account suitable for testing scenarios. #[cfg(any(feature = "testing", test))] -#[allow(clippy::too_many_arguments)] pub fn create_existing_agglayer_faucet( seed: Word, token_symbol: &str, @@ -287,10 +255,6 @@ pub fn create_existing_agglayer_faucet( max_supply: Felt, token_supply: Felt, bridge_account_id: AccountId, - origin_token_address: &EthAddress, - origin_network: u32, - scale: u8, - metadata_hash: MetadataHash, ) -> Account { create_agglayer_faucet_builder( seed, @@ -299,12 +263,7 @@ pub fn create_existing_agglayer_faucet( max_supply, token_supply, bridge_account_id, - origin_token_address, - origin_network, - scale, - metadata_hash, ) - .with_auth_component(AccountComponent::from(NoAuth)) .build_existing() .expect("agglayer faucet account should be valid") } diff --git a/crates/miden-agglayer/src/testing/mod.rs b/crates/miden-agglayer/src/testing/mod.rs index ee7a17ea9b..fb120ccc76 100644 --- a/crates/miden-agglayer/src/testing/mod.rs +++ b/crates/miden-agglayer/src/testing/mod.rs @@ -22,19 +22,14 @@ use crate::{CgiChainHash, EthAddress, EthAmount, ExitRoot, GlobalIndex, LeafData // EMBEDDED TEST VECTOR JSON FILES // ================================================================================================ -/// Claim asset test vectors JSON — contains both LeafData and ProofData from a real claimAsset -/// transaction. -pub const CLAIM_ASSET_VECTORS_JSON: &str = - include_str!("../../solidity-compat/test-vectors/claim_asset_vectors_real_tx.json"); - /// Bridge asset test vectors JSON — contains test data for an L1 bridgeAsset transaction. pub const BRIDGE_ASSET_VECTORS_JSON: &str = - include_str!("../../solidity-compat/test-vectors/claim_asset_vectors_local_tx.json"); + include_str!("../../solidity-compat/test-vectors/claim_asset_vectors_l1_tx.json"); /// Rollup deposit test vectors JSON — contains test data for a rollup deposit with two-level /// Merkle proofs. pub const ROLLUP_ASSET_VECTORS_JSON: &str = - include_str!("../../solidity-compat/test-vectors/claim_asset_vectors_rollup_tx.json"); + include_str!("../../solidity-compat/test-vectors/claim_asset_vectors_l2_tx.json"); /// Leaf data test vectors JSON from the Foundry-generated file. pub const LEAF_VALUE_VECTORS_JSON: &str = @@ -227,20 +222,14 @@ pub struct MtfVectorsFile { // LAZY-PARSED TEST VECTORS // ================================================================================================ -/// Lazily parsed claim asset test vector from the JSON file. -pub static CLAIM_ASSET_VECTOR: LazyLock = LazyLock::new(|| { - serde_json::from_str(CLAIM_ASSET_VECTORS_JSON) - .expect("failed to parse claim asset vectors JSON") -}); - /// Lazily parsed bridge asset test vector from the JSON file (locally simulated L1 transaction). -pub static CLAIM_ASSET_VECTOR_LOCAL: LazyLock = LazyLock::new(|| { +pub static CLAIM_ASSET_VECTOR_L1: LazyLock = LazyLock::new(|| { serde_json::from_str(BRIDGE_ASSET_VECTORS_JSON) .expect("failed to parse bridge asset vectors JSON") }); -/// Lazily parsed rollup deposit test vector from the JSON file. -pub static CLAIM_ASSET_VECTOR_ROLLUP: LazyLock = LazyLock::new(|| { +/// Lazily parsed rollup deposit test vector from the JSON file (locally simulated L2 transaction). +pub static CLAIM_ASSET_VECTOR_L2: LazyLock = LazyLock::new(|| { serde_json::from_str(ROLLUP_ASSET_VECTORS_JSON) .expect("failed to parse rollup asset vectors JSON") }); @@ -265,24 +254,21 @@ pub static SOLIDITY_MTF_VECTORS: LazyLock = LazyLock::new(|| { // CLAIM DATA SOURCE // ================================================================================================ -/// Identifies the source of claim data used in bridge-in tests and benchmarks. +/// Identifies the source of simulated claim data used in bridge-in tests and benchmarks. #[derive(Debug, Clone, Copy)] pub enum ClaimDataSource { - /// Real on-chain claimAsset data from claim_asset_vectors_real_tx.json (L1 to Miden). - RealL1ToMiden, - /// Locally simulated bridgeAsset data from claim_asset_vectors_local_tx.json (L1 to Miden). - SimulatedL1ToMiden, - /// Rollup deposit data from claim_asset_vectors_rollup_tx.json (L2 to Miden). - SimulatedL2ToMiden, + /// Mainnet `bridgeAsset` data from `claim_asset_vectors_l1_tx.json` (L1 to Miden). + L1ToMiden, + /// Rollup deposit data from `claim_asset_vectors_l2_tx.json` (L2 to Miden). + L2ToMiden, } impl ClaimDataSource { /// Returns the `(ProofData, LeafData, ExitRoot, CgiChainHash)` tuple for this data source. pub fn get_data(self) -> (ProofData, LeafData, ExitRoot, CgiChainHash) { let vector = match self { - ClaimDataSource::RealL1ToMiden => &*CLAIM_ASSET_VECTOR, - ClaimDataSource::SimulatedL1ToMiden => &*CLAIM_ASSET_VECTOR_LOCAL, - ClaimDataSource::SimulatedL2ToMiden => &*CLAIM_ASSET_VECTOR_ROLLUP, + ClaimDataSource::L1ToMiden => &*CLAIM_ASSET_VECTOR_L1, + ClaimDataSource::L2ToMiden => &*CLAIM_ASSET_VECTOR_L2, }; let ger = ExitRoot::new( hex_to_bytes(&vector.proof.global_exit_root).expect("valid global exit root hex"), diff --git a/crates/miden-agglayer/src/update_ger_note.rs b/crates/miden-agglayer/src/update_ger_note.rs index f20e5b2669..3f1e7ef89a 100644 --- a/crates/miden-agglayer/src/update_ger_note.rs +++ b/crates/miden-agglayer/src/update_ger_note.rs @@ -10,7 +10,6 @@ use alloc::vec; use miden_assembly::Library; use miden_assembly::serde::Deserializable; -use miden_core::Word; use miden_protocol::account::AccountId; use miden_protocol::crypto::rand::FeltRng; use miden_protocol::errors::NoteError; @@ -18,11 +17,13 @@ use miden_protocol::note::{ Note, NoteAssets, NoteAttachment, - NoteMetadata, + NoteAttachments, NoteRecipient, NoteScript, + NoteScriptRoot, NoteStorage, NoteType, + PartialNoteMetadata, }; use miden_standards::note::{NetworkAccountTarget, NoteExecutionHint}; use miden_utils_sync::LazyLock; @@ -34,7 +35,7 @@ use crate::ExitRoot; // Initialize the UPDATE_GER note script only once static UPDATE_GER_SCRIPT: LazyLock = LazyLock::new(|| { - let bytes = include_bytes!(concat!(env!("OUT_DIR"), "/assets/note_scripts/UPDATE_GER.masl")); + let bytes = include_bytes!(concat!(env!("OUT_DIR"), "/assets/note_scripts/update_ger.masl")); let library = Library::read_from_bytes(bytes).expect("shipped UPDATE_GER script library is well-formed"); NoteScript::from_library(&library).expect("shipped UPDATE_GER script is well-formed") @@ -65,7 +66,7 @@ impl UpdateGerNote { } /// Returns the UPDATE_GER note script root. - pub fn script_root() -> Word { + pub fn script_root() -> NoteScriptRoot { UPDATE_GER_SCRIPT.root() } @@ -100,16 +101,14 @@ impl UpdateGerNote { let recipient = NoteRecipient::new(serial_num, Self::script(), note_storage); - let attachment = NoteAttachment::from( - NetworkAccountTarget::new(target_account_id, NoteExecutionHint::Always) - .map_err(|e| NoteError::other(e.to_string()))?, - ); - let metadata = - NoteMetadata::new(sender_account_id, NoteType::Public).with_attachment(attachment); + 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::new(assets, metadata, recipient)) + Ok(Note::with_attachments(assets, metadata, recipient, attachments)) } } diff --git a/crates/miden-block-prover/Cargo.toml b/crates/miden-block-prover/Cargo.toml index be8732f5d7..165d259431 100644 --- a/crates/miden-block-prover/Cargo.toml +++ b/crates/miden-block-prover/Cargo.toml @@ -13,7 +13,8 @@ rust-version.workspace = true version.workspace = true [lib] -bench = false +bench = false +doctest = false [features] testing = [] diff --git a/crates/miden-protocol-macros/Cargo.toml b/crates/miden-protocol-macros/Cargo.toml deleted file mode 100644 index 0ec8930785..0000000000 --- a/crates/miden-protocol-macros/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -authors.workspace = true -categories = ["development-tools::procedural-macro-helpers"] -description = "Procedural macros for Miden protocol" -edition.workspace = true -homepage.workspace = true -keywords = ["macros", "miden", "protocol"] -license.workspace = true -name = "miden-protocol-macros" -readme = "README.md" -repository.workspace = true -rust-version.workspace = true -version.workspace = true - -[lib] -proc-macro = true - -[dependencies] -proc-macro2 = "1.0" -quote = "1.0" -syn = { features = ["extra-traits", "full"], version = "2.0" } - -[dev-dependencies] -miden-protocol = { path = "../miden-protocol" } - -[package.metadata.cargo-machete] -ignored = ["proc-macro2"] diff --git a/crates/miden-protocol-macros/README.md b/crates/miden-protocol-macros/README.md deleted file mode 100644 index 1b7676ec70..0000000000 --- a/crates/miden-protocol-macros/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# miden-protocol-macros - -A collection of procedural macros for the Miden protocol. - -## WordWrapper - -The `WordWrapper` derive macro automatically implements helpful accessor methods and conversions for tuple structs that wrap a `Word` type. - -### Usage - -Add the derive macro to any tuple struct with a single `Word` field: - -```rust -use miden_protocol_macros::WordWrapper; -use miden_crypto::word::Word; - -#[derive(WordWrapper)] -pub struct NoteId(Word); -``` - -### Generated Methods - -The macro automatically generates the following methods: - -#### Accessor Methods - -- **`new_unchecked(Word) -> Self`** - Construct without any checks -- **`as_elements(&self) -> &[Felt]`** - Returns the elements representation of the wrapped Word -- **`as_bytes(&self) -> [u8; 32]`** - Returns the byte representation -- **`to_hex(&self) -> String`** - Returns a big-endian, hex-encoded string -- **`as_word(&self) -> Word`** - Returns the underlying Word value - -### Example - -```rust -use miden_protocol_macros::WordWrapper; -use miden_crypto::word::Word; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, WordWrapper)] -pub struct NoteId(Word); - -// Create using new_unchecked (generated by the macro) -let word = Word::from([Felt::ONE, Felt::ZERO, Felt::ONE, Felt::ZERO]); -let note_id = NoteId::from_raw(word); - -// Use accessor methods -let elements = note_id.as_elements(); -let bytes = note_id.as_bytes(); -let hex = note_id.to_hex(); -let word_back = note_id.as_word(); -``` - -### Requirements - -The macro can only be applied to: -- Tuple structs (e.g., `struct Foo(Word)`) -- With exactly one field -- Where that field is of type `Word` - -### Benefits - -Using this macro eliminates boilerplate code. Instead of manually writing ~50 lines of implementation code for each Word wrapper type, you can simply add `#[derive(WordWrapper)]` to your struct definition. - -This is particularly useful in the Miden codebase where many types like `NoteId`, `TransactionId`, `Nullifier`, `BatchId`, etc. all follow the same pattern of wrapping a `Word` and providing similar accessor methods. - -### Important Notes - -- The macro generates the `new_unchecked` constructor. You should not manually implement this method. -- Previously, the macro also generated `From` and `From<&T>` trait implementations for `Word` and `[u8; 32]`. These have been **removed** to give types more control over their conversions. If you need these conversions, implement them manually for your specific type. diff --git a/crates/miden-protocol-macros/src/lib.rs b/crates/miden-protocol-macros/src/lib.rs deleted file mode 100644 index 100ccac410..0000000000 --- a/crates/miden-protocol-macros/src/lib.rs +++ /dev/null @@ -1,171 +0,0 @@ -//! Procedural macros for the Miden project. -//! -//! Provides derive macros and other procedural macros to reduce boilerplate -//! and ensure consistency across the Miden codebase. -//! -//! ## Available Macros -//! -//! ### `WordWrapper` -//! -//! A derive macro for tuple structs wrapping a `Word` type. Automatically generates -//! accessor methods and `From` trait implementations. - -use proc_macro::TokenStream; -use quote::quote; -use syn::{Data, DeriveInput, Fields, Type, parse_macro_input}; - -/// Generates accessor methods for tuple structs wrapping a `Word` type. -/// -/// Automatically implements: -/// - `new_unchecked(Word) -> Self` - Construct without further checks -/// - `as_elements(&self) -> &[Felt]` - Returns the elements representation -/// - `as_bytes(&self) -> [u8; 32]` - Returns the byte representation -/// - `to_hex(&self) -> String` - Returns a big-endian, hex-encoded string -/// - `as_word(&self) -> Word` - Returns the underlying Word -/// -/// Note: This macro does NOT generate `From` trait implementations. If you need conversions -/// to/from `Word` or `[u8; 32]`, implement them manually for your type. -/// -/// # Example -/// -/// ```ignore -/// use miden_protocol_macros::WordWrapper; -/// use miden_crypto::word::Word; -/// -/// #[derive(WordWrapper)] -/// pub struct NoteId(Word); -/// ``` -/// -/// This will generate implementations equivalent to: -/// -/// ```ignore -/// impl NoteId { -/// /// Construct without further checks from a given `Word` -/// /// -/// /// # Warning -/// /// -/// /// This requires the caller to uphold the guarantees/invariants of this type (if any). -/// /// Check the type-level documentation for guarantees/invariants. -/// pub fn new_unchecked(word: Word) -> Self { -/// Self(word) -/// } -/// -/// pub fn as_elements(&self) -> &[Felt] { -/// self.0.as_elements() -/// } -/// -/// pub fn as_bytes(&self) -> [u8; 32] { -/// self.0.as_bytes() -/// } -/// -/// pub fn to_hex(&self) -> String { -/// self.0.to_hex() -/// } -/// -/// pub fn as_word(&self) -> Word { -/// self.0 -/// } -/// } -/// ``` -#[proc_macro_derive(WordWrapper)] -pub fn word_wrapper_derive(input: TokenStream) -> TokenStream { - let input = parse_macro_input!(input as DeriveInput); - - let name = &input.ident; - let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); - - // Validate that this is a tuple struct with a single field - let field_type = match &input.data { - Data::Struct(data_struct) => match &data_struct.fields { - Fields::Unnamed(fields) if fields.unnamed.len() == 1 => match fields.unnamed.first() { - Some(field) => &field.ty, - None => { - return syn::Error::new_spanned( - &input, - "WordWrapper requires exactly one field", - ) - .to_compile_error() - .into(); - }, - }, - _ => { - return syn::Error::new_spanned( - &input, - "WordWrapper can only be derived for tuple structs with exactly one field", - ) - .to_compile_error() - .into(); - }, - }, - _ => { - return syn::Error::new_spanned(&input, "WordWrapper can only be derived for structs") - .to_compile_error() - .into(); - }, - }; - - // Verify that the field type is 'Word' (or a path ending in 'Word') - if let Type::Path(type_path) = field_type { - let last_segment = type_path.path.segments.last(); - if let Some(segment) = last_segment { - if segment.ident != "Word" { - return syn::Error::new_spanned( - field_type, - "WordWrapper can only be derived for types wrapping a 'Word' field", - ) - .to_compile_error() - .into(); - } - } else { - return syn::Error::new_spanned( - field_type, - "WordWrapper can only be derived for types wrapping a 'Word' field", - ) - .to_compile_error() - .into(); - } - } else { - return syn::Error::new_spanned( - field_type, - "WordWrapper can only be derived for types wrapping a 'Word' field", - ) - .to_compile_error() - .into(); - } - - let expanded = quote! { - impl #impl_generics #name #ty_generics #where_clause { - /// Construct without further checks from a given `Word` - /// - /// # Warning - /// - /// This requires the caller to uphold the guarantees/invariants of this type (if any). - /// Check the type-level documentation for guarantees/invariants. - pub fn from_raw(word: Word) -> Self { - Self(word) - } - - /// Returns the elements representation of this value. - pub fn as_elements(&self) -> &[Felt] { - self.0.as_elements() - } - - /// Returns the byte representation of this value. - pub fn as_bytes(&self) -> [u8; 32] { - self.0.as_bytes() - } - - /// Returns a big-endian, hex-encoded string. - pub fn to_hex(&self) -> String { - self.0.to_hex() - } - - /// Returns the underlying word of this value. - pub fn as_word(&self) -> Word { - self.0 - } - } - }; - - TokenStream::from(expanded) -} diff --git a/crates/miden-protocol-macros/tests/integration_test.rs b/crates/miden-protocol-macros/tests/integration_test.rs deleted file mode 100644 index 46f807852f..0000000000 --- a/crates/miden-protocol-macros/tests/integration_test.rs +++ /dev/null @@ -1,42 +0,0 @@ -#[cfg(test)] -mod tests { - use miden_protocol::{Felt, Word}; - use miden_protocol_macros::WordWrapper; - - #[derive(Debug, Clone, Copy, PartialEq, Eq, WordWrapper)] - pub struct TestId(Word); - - #[test] - fn test_word_wrapper_accessors() { - // Create a test Word - let word = Word::from([Felt::ONE, Felt::ONE, Felt::ZERO, Felt::ZERO]); - // Use the new_unchecked method generated by the macro - let test_id = TestId::from_raw(word); - - // Test as_elements - let elements = test_id.as_elements(); - assert_eq!(elements.len(), 4); - assert_eq!(elements[0], Felt::ONE); - assert_eq!(elements[1], Felt::ONE); - - // Test as_bytes - let bytes = test_id.as_bytes(); - assert_eq!(bytes.len(), 32); - - // Test to_hex - let hex = test_id.to_hex(); - assert!(!hex.is_empty()); - - // Test as_word - let retrieved_word = test_id.as_word(); - assert_eq!(retrieved_word, word); - } - - #[test] - fn test_new_unchecked_is_generated() { - // This test verifies that new_unchecked is generated by the macro - let word = Word::from([Felt::ONE, Felt::ONE, Felt::ZERO, Felt::ZERO]); - let test_id = TestId::from_raw(word); - assert_eq!(test_id.as_word(), word); - } -} diff --git a/crates/miden-protocol/Cargo.toml b/crates/miden-protocol/Cargo.toml index 74f08d132b..695d757d69 100644 --- a/crates/miden-protocol/Cargo.toml +++ b/crates/miden-protocol/Cargo.toml @@ -40,9 +40,9 @@ miden-assembly-syntax = { workspace = true } miden-core = { workspace = true } miden-core-lib = { workspace = true } miden-crypto = { workspace = true } +miden-crypto-derive = { workspace = true } miden-mast-package = { workspace = true } miden-processor = { workspace = true } -miden-protocol-macros = { workspace = true } miden-utils-sync = { workspace = true } miden-verifier = { workspace = true } @@ -63,8 +63,7 @@ getrandom = { features = ["wasm_js"], version = "0.3" } [dev-dependencies] anyhow = { features = ["backtrace", "std"], workspace = true } assert_matches = { workspace = true } -color-eyre = { version = "0.5" } -criterion = { default-features = false, features = ["html_reports"], version = "0.5" } +criterion = { default-features = false, features = ["html_reports"], workspace = true } miden-protocol = { features = ["testing"], path = "." } pprof = { default-features = false, features = ["criterion", "flamegraph"], version = "0.15" } rstest = { workspace = true } @@ -75,5 +74,8 @@ fs-err = { workspace = true } miden-assembly = { workspace = true } miden-core = { workspace = true } miden-core-lib = { workspace = true } -regex = { version = "1.11" } -walkdir = { version = "2.5" } +regex = { workspace = true } +walkdir = { workspace = true } + +[package.metadata.cargo-shear] +ignored = ["getrandom"] diff --git a/crates/miden-protocol/asm/kernels/transaction/api.masm b/crates/miden-protocol/asm/kernels/transaction/api.masm index 703e3cdab3..518de702df 100644 --- a/crates/miden-protocol/asm/kernels/transaction/api.masm +++ b/crates/miden-protocol/asm/kernels/transaction/api.masm @@ -7,10 +7,10 @@ use $kernel::input_note use $kernel::memory use $kernel::output_note use $kernel::tx -# use $kernel::types::AccountId use $kernel::memory::UPCOMING_FOREIGN_PROCEDURE_PTR use $kernel::memory::UPCOMING_FOREIGN_PROC_INPUT_VALUE_15_PTR +use $kernel::memory::KERNEL_PROCEDURES_PTR use miden::core::word @@ -42,6 +42,8 @@ const ERR_NOTE_ATTEMPT_TO_ACCESS_NOTE_SCRIPT_ROOT_WHILE_NO_NOTE_BEING_PROCESSED= const ERR_NOTE_ATTEMPT_TO_ACCESS_NOTE_SERIAL_NUMBER_WHILE_NO_NOTE_BEING_PROCESSED="failed to access note serial number of active note because no note is currently being processed" +const ERR_NOTE_ATTEMPT_TO_ACCESS_NOTE_ATTACHMENTS_WHILE_NO_NOTE_BEING_PROCESSED="failed to access note attachments of active note because no note is currently being processed" + const ERR_FOREIGN_ACCOUNT_PROCEDURE_ROOT_IS_ZERO="root of the provided foreign procedure equals zero indicating that tx_prepare_fpi was not called" const ERR_FOREIGN_ACCOUNT_ID_IS_ZERO="ID of the provided foreign account equals zero indicating that tx_prepare_fpi was not called" @@ -172,7 +174,8 @@ end #! Where: #! - is_native is a boolean flag that indicates whether the account ID was requested for the native #! or the active account. -#! - account_id_{prefix,suffix} are the prefix and suffix felts of the ID of the active account. +#! - account_id_{prefix,suffix} are the prefix and suffix felts of the ID of the requested account +#! (native or active, depending on the is_native flag). #! #! Invocation: dynexec pub proc account_get_id @@ -741,14 +744,11 @@ end #! Mint an asset from the faucet the transaction is being executed against. #! #! Inputs: [ASSET_KEY, ASSET_VALUE, pad(8)] -#! Outputs: [NEW_ASSET_VALUE, pad(12)] +#! Outputs: [pad(16)] #! #! Where: #! - ASSET_KEY is the vault key of the asset to mint. -#! - ASSET_VALUE is the value of the asset that was minted. -#! - NEW_ASSET_VALUE is: -#! - For fungible assets: the ASSET_VALUE merged with the existing vault asset value, if any. -#! - For non-fungible assets: identical to ASSET_VALUE. +#! - ASSET_VALUE is the value of the asset to mint. #! #! Panics if: #! - the transaction is not being executed against a faucet. @@ -773,7 +773,7 @@ pub proc faucet_mint_asset # mint the asset exec.faucet::mint - # => [NEW_ASSET_VALUE, pad(12)] + # => [pad(16)] end #! Burn an asset from the faucet the transaction is being executed against. @@ -874,7 +874,7 @@ end #! Returns the recipient of the specified input note. #! -#! Inputs: [is_active_note, note_index, pad(15)] +#! Inputs: [is_active_note, note_index, pad(14)] #! Outputs: [RECIPIENT, pad(12)] #! #! Where: @@ -913,15 +913,14 @@ end #! Returns the metadata of the specified input note. #! #! Inputs: [is_active_note, note_index, pad(14)] -#! Outputs: [NOTE_ATTACHMENT, METADATA_HEADER, pad(8)] +#! Outputs: [METADATA, pad(12)] #! #! Where: #! - is_active_note is the boolean flag indicating whether we should return the metadata from #! the active note or from the note with the specified index. #! - note_index is the index of the input note whose metadata should be returned. Notice that if #! is_active_note is 1, note_index is ignored. -#! - METADATA_HEADER is the metadata header of the specified input note. -#! - NOTE_ATTACHMENT is the attachment of the specified input note. +#! - METADATA is the metadata of the specified input note. #! #! Panics if: #! - the note index is greater or equal to the total number of input notes. @@ -930,7 +929,7 @@ end #! #! Invocation: dynexec pub proc input_note_get_metadata - # get the input note pointer depending on whether the requested note is current or it was + # get the input note pointer depending on whether the requested note is current or it was # requested by index. exec.get_requested_note_ptr # => [input_note_ptr, pad(15)] @@ -945,16 +944,12 @@ pub proc input_note_get_metadata # => [input_note_ptr, pad(16)] # get the metadata - dup exec.memory::get_input_note_metadata_header - # => [METADATA_HEADER, input_note_ptr, pad(16)] - - # get the attachment - movup.4 exec.memory::get_input_note_attachment - # => [NOTE_ATTACHMENT, METADATA_HEADER, pad(16)] + exec.memory::get_input_note_metadata + # => [METADATA, pad(16)] # truncate the stack - swapdw dropw dropw - # => [NOTE_ATTACHMENT, METADATA_HEADER, pad(8)] + swapw dropw + # => [METADATA, pad(12)] end #! Returns the serial number of the specified input note. @@ -1082,6 +1077,46 @@ pub proc input_note_get_script_root # => [SCRIPT_ROOT, pad(12)] end +#! Returns the attachments commitment of the specified input note. +#! +#! Inputs: [is_active_note, note_index, pad(14)] +#! Outputs: [ATTACHMENTS_COMMITMENT, pad(12)] +#! +#! Where: +#! - is_active_note is the boolean flag indicating whether we should return the attachments +#! commitment from the active note or from the note with the specified index. +#! - note_index is the index of the input note whose attachments commitment should be returned. +#! Notice that if is_active_note is 1, note_index is ignored. +#! - ATTACHMENTS_COMMITMENT is the commitment to all attachments of the specified input note. +#! +#! Panics if: +#! - the note index is greater or equal to the total number of input notes. +#! - is_active_note is 1 and no input note is not being processed (attempted to access note +#! attachments from incorrect context). +#! +#! Invocation: dynexec +pub proc input_note_get_attachments_commitment + # get the input note pointer depending on whether the requested note is current or it was + # requested by index. + exec.get_requested_note_ptr + # => [input_note_ptr, pad(15)] + + # assert the pointer is not zero - this would suggest the procedure has been called from an + # incorrect context + dup neq.0 assert.err=ERR_NOTE_ATTEMPT_TO_ACCESS_NOTE_ATTACHMENTS_WHILE_NO_NOTE_BEING_PROCESSED + # => [input_note_ptr, pad(15)] + + # get the attachments commitment + exec.memory::get_input_note_attachments_commitment + # => [ATTACHMENTS_COMMITMENT, pad(15)] + + # truncate the stack + repeat.3 + movup.4 drop + end + # => [ATTACHMENTS_COMMITMENT, pad(12)] +end + # OUTPUT NOTE # ------------------------------------------------------------------------------------------------- @@ -1118,6 +1153,7 @@ end #! #! Panics if: #! - the procedure is called when the active account is not the native one. +#! - the note index points to a non-existent output note. #! #! Invocation: dynexec pub proc output_note_add_asset @@ -1125,34 +1161,41 @@ pub proc output_note_add_asset exec.memory::assert_native_account # => [ASSET_KEY, ASSET_VALUE, note_idx, pad(7)] + dup.8 exec.output_note::assert_note_index_in_bounds drop + # => [ASSET_KEY, ASSET_VALUE, note_idx, pad(7)] + exec.output_note::add_asset # => [pad(16)] end -#! Sets the attachment of the note specified by the index. +#! Adds an attachment to the note specified by the index. #! -#! Inputs: [note_idx, attachment_scheme, attachment_kind, ATTACHMENT, pad(9)] +#! Inputs: [attachment_scheme, ATTACHMENT, note_idx, pad(9)] #! Outputs: [pad(16)] #! #! Where: -#! - note_idx is the index of the note on which the attachment is set. #! - attachment_scheme is the user-defined scheme of the attachment. -#! - attachment_kind is the kind of the attachment content. -#! - ATTACHMENT is the attachment to be set. +#! - ATTACHMENT is the attachment word to store. +#! - note_idx is the index of the note to which the attachment is added. #! #! Panics if: #! - the procedure is called when the active account is not the native one. #! - the note index points to a non-existent output note. -#! - the attachment kind or scheme does not fit into a u32. -#! - the attachment kind is an unknown variant. +#! - the attachment scheme is 0 or exceeds 65534. +#! - the attachment num_words exceeds 256 or is zero. +#! - the note already has 4 attachments. #! #! Invocation: dynexec -pub proc output_note_set_attachment +pub proc output_note_add_attachment + # assert that the provided note index is less than the total number of output notes + dup.5 exec.output_note::assert_note_index_in_bounds drop + # => [attachment_scheme, ATTACHMENT, note_idx, pad(9)] + # check that this procedure was executed against the native account exec.memory::assert_native_account - # => [note_idx, attachment_scheme, attachment_kind, ATTACHMENT, pad(9)] + # => [attachment_scheme, ATTACHMENT, note_idx, pad(9)] - exec.output_note::set_attachment + exec.output_note::add_attachment # => [pad(16)] end @@ -1186,6 +1229,33 @@ pub proc output_note_get_assets_info # => [ASSETS_COMMITMENT, num_assets, pad(11)] end +#! Returns the commitment over all attachments. +#! +#! Inputs: [note_index, pad(15)] +#! Outputs: [ATTACHMENTS_COMMITMENT, pad(12)] +#! +#! Where: +#! - note_index is the index of the output note whose attachments commitment should be returned. +#! - ATTACHMENTS_COMMITMENT is the commitment to all attachments of the note. +#! +#! Panics if: +#! - the note index is greater or equal to the total number of output notes. +#! +#! Invocation: dynexec +pub proc output_note_get_attachments_commitment + # assert that the provided note index is less than the total number of output notes + exec.output_note::assert_note_index_in_bounds + # => [note_index, pad(15)] + + # get the attachments commitment + exec.output_note::get_attachments_commitment + # => [ATTACHMENTS_COMMITMENT, pad(16)] + + # truncate the stack + swapw dropw + # => [ATTACHMENTS_COMMITMENT, pad(12)] +end + #! Returns the recipient of the output note with the specified index. #! #! Inputs: [note_index, pad(15)] @@ -1220,12 +1290,11 @@ end #! Returns the metadata of the output note with the specified index. #! #! Inputs: [note_index, pad(15)] -#! Outputs: [NOTE_ATTACHMENT, METADATA_HEADER, pad(8)] +#! Outputs: [METADATA, pad(12)] #! #! Where: #! - note_index is the index of the output note whose metadata should be returned. -#! - METADATA_HEADER is the metadata header of the specified output note. -#! - NOTE_ATTACHMENT is the attachment of the specified output note. +#! - METADATA is the metadata of the specified output note. #! #! Panics if: #! - the note index is greater or equal to the total number of output notes. @@ -1240,21 +1309,13 @@ pub proc output_note_get_metadata exec.memory::get_output_note_ptr # => [note_ptr, pad(15)] - # make stack truncation at the end of the procedure easier - push.0 swap - # => [note_ptr, pad(16)] - # get the metadata - dup exec.memory::get_output_note_metadata_header - # => [METADATA_HEADER, note_ptr, pad(16)] - - # get the attachment - movup.4 exec.memory::get_output_note_attachment - # => [NOTE_ATTACHMENT, METADATA_HEADER, pad(16)] + exec.memory::get_output_note_metadata + # => [METADATA, pad(15)] # truncate the stack - swapdw dropw dropw - # => [NOTE_ATTACHMENT, METADATA_HEADER, pad(8)] + swapw drop drop drop movdn.4 + # => [METADATA, pad(12)] end # TRANSACTION @@ -1262,8 +1323,8 @@ end #! Returns the input notes commitment. #! -#! This is computed as a sequential hash of `(NULLIFIER, EMPTY_WORD_OR_NOTE_COMMITMENT)` over all -#! input notes. The data `EMPTY_WORD_OR_NOTE_COMMITMENT` functions as a flag, if the value is set to +#! This is computed as a sequential hash of `(NULLIFIER, EMPTY_WORD_OR_NOTE_ID)` over all +#! input notes. The data `EMPTY_WORD_OR_NOTE_ID` functions as a flag, if the value is set to #! zero, then the notes are authenticated by the transaction kernel. If the value is non-zero, then #! note authentication will be delayed to the batch/block kernel. The delayed authentication allows #! a transaction to consume a public note that is not yet included to a block. @@ -1285,7 +1346,7 @@ pub proc tx_get_input_notes_commitment end #! Returns the output notes commitment. This is computed as a sequential hash of -#! (note_id, note_metadata) tuples over all output notes. +#! (note_details_commitment, note_metadata_commitment) tuples over all output notes. #! #! Inputs: [pad(16)] #! Outputs: [OUTPUT_NOTES_COMMITMENT, pad(12)] @@ -1323,6 +1384,26 @@ pub proc tx_get_num_input_notes # => [num_input_notes, pad(15)] end +#! Returns the transaction script root. +#! +#! Inputs: [pad(16)] +#! Outputs: [TX_SCRIPT_ROOT, pad(12)] +#! +#! Where: +#! - TX_SCRIPT_ROOT is the transaction script root, or the empty word if no transaction script was +#! executed. +#! +#! Invocation: dynexec +pub proc tx_get_tx_script_root + # get the tx script root + exec.tx::get_tx_script_root + # => [TX_SCRIPT_ROOT, pad(16)] + + # truncate the stack + swapw dropw + # => [TX_SCRIPT_ROOT, pad(12)] +end + #! Returns the current number of output notes created in this transaction. #! #! Inputs: [pad(16)] @@ -1565,7 +1646,7 @@ pub proc exec_kernel_proc # => [procedure_offset, , ] # compute the memory pointer at which desired procedure is stored - mul.4 exec.memory::get_kernel_procedures_ptr add + mul.4 push.KERNEL_PROCEDURES_PTR add # => [procedure_pointer, , ] # execute loaded procedure diff --git a/crates/miden-protocol/asm/kernels/transaction/lib/account.masm b/crates/miden-protocol/asm/kernels/transaction/lib/account.masm index 40003cfe92..a11013385d 100644 --- a/crates/miden-protocol/asm/kernels/transaction/lib/account.masm +++ b/crates/miden-protocol/asm/kernels/transaction/lib/account.masm @@ -11,6 +11,9 @@ use $kernel::constants::STORAGE_SLOT_TYPE_VALUE use $kernel::memory use $kernel::memory::ACCT_ID_SUFFIX_OFFSET use $kernel::memory::ACCT_ID_PREFIX_OFFSET +use $kernel::memory::ACCOUNT_DATA_LENGTH +use $kernel::memory::MAX_FOREIGN_ACCOUNT_PTR +use $kernel::memory::NATIVE_ACCOUNT_DATA_PTR use miden::core::collections::smt use miden::core::collections::sorted_array use miden::core::crypto::hashes::poseidon2 @@ -54,14 +57,12 @@ const ERR_ACCOUNT_STORAGE_COMMITMENT_MISMATCH="computed account storage commitme const ERR_ACCOUNT_STORAGE_MAP_ENTRIES_DO_NOT_MATCH_MAP_ROOT="storage map entries provided as advice inputs do not have the same storage map root as the root of the map the new account commits to" -const ERR_FOREIGN_ACCOUNT_MAX_NUMBER_EXCEEDED="maximum allowed number of foreign account to be loaded (64) was exceeded" +const ERR_FOREIGN_ACCOUNT_MAX_NUMBER_EXCEEDED="maximum allowed number of foreign accounts to be loaded (63) was exceeded" const ERR_FOREIGN_ACCOUNT_INVALID_COMMITMENT="commitment of the foreign account in the advice provider does not match the commitment in the account tree" const ERR_ACCOUNT_ID_UNKNOWN_VERSION="unknown version in account ID" -const ERR_ACCOUNT_ID_UNKNOWN_STORAGE_MODE="unknown account storage mode in account ID" - const ERR_ACCOUNT_READING_MAP_VALUE_FROM_NON_MAP_SLOT="failed to read an account map item from a non-map storage slot" const ERR_FOREIGN_ACCOUNT_ID_MISMATCH="foreign account ID provided with advice map doesn't match the ID provided by the operand stack" @@ -81,42 +82,6 @@ const MIN_NUM_PROCEDURES=2 # The maximum number of account interface procedures. const MAX_NUM_PROCEDURES=256 -# Given the least significant 32 bits of an account ID's prefix, this mask defines the bits used -# to determine the account version. -const ACCOUNT_VERSION_MASK_U32=0x0f # 0b1111 - -# Given the least significant 32 bits of an account ID's prefix, this mask defines the bits used -# to determine the account type. -const ACCOUNT_ID_TYPE_MASK_U32=0x30 # 0b11_0000 - -# Given the least significant 32 bits of an account ID's first felt, this mask defines the bits used -# to determine the account storage mode. -const ACCOUNT_ID_STORAGE_MODE_MASK_U32=0xC0 # 0b1100_0000 - -# Given the least significant 32 bits of an account ID's first felt with the storage mode mask -# applied, this value defines the public storage mode. -const ACCOUNT_ID_STORAGE_MODE_PUBLIC_U32=0 # 0b0000_0000 - -# Given the least significant 32 bits of an account ID's first felt with the storage mode mask -# applied, this value defines the private storage mode. -const ACCOUNT_ID_STORAGE_MODE_PRIVATE_U32=0x80 # 0b1000_0000 - -# Bit pattern for an account w/ immutable code, after the account type mask has been applied. -const REGULAR_ACCOUNT_IMMUTABLE_CODE=0 # 0b00_0000 - -# Bit pattern for an account w/ updatable code, after the account type mask has been applied. -const REGULAR_ACCOUNT_UPDATABLE_CODE=0x10 # 0b01_0000 - -# Bit pattern for a fungible faucet w/ immutable code, after the account type mask has been applied. -const FUNGIBLE_FAUCET_ACCOUNT=0x20 # 0b10_0000 - -# Bit pattern for a non-fungible faucet w/ immutable code, after the account type mask has been -# applied. -const NON_FUNGIBLE_FAUCET_ACCOUNT=0x30 # 0b11_0000 - -# Bit pattern for a faucet account, after the account type mask has been applied. -const FAUCET_ACCOUNT=0x20 # 0b10_0000 - # Depth of the account database tree. const ACCOUNT_TREE_DEPTH=64 @@ -845,7 +810,7 @@ end #! - the procedure root is not part of the account code. pub proc authenticate_and_track_procedure # load procedure index - emit.ACCOUNT_PUSH_PROCEDURE_INDEX_EVENT adv_push.1 + emit.ACCOUNT_PUSH_PROCEDURE_INDEX_EVENT adv_push # => [index, PROC_ROOT] dup movdn.5 exec.get_procedure_root @@ -899,8 +864,15 @@ end #! 2. Assert the two least significant elements of the digest are equal to the account ID of the #! account the transaction is being executed against. #! -#! Inputs: [] -#! Outputs: [] +#! Inputs: +#! Operand stack: [] +#! Advice map: { ACCOUNT_ID_KEY: [SEED] } +#! Outputs: +#! Operand stack: [] +#! +#! Where: +#! - ACCOUNT_ID_KEY is the map key constructed from the native account ID. +#! - SEED is the account seed used to derive the account ID. pub proc validate_seed # Compute the hash of (SEED, CODE_COMMITMENT, STORAGE_COMMITMENT, EMPTY_WORD). # --------------------------------------------------------------------------------------------- @@ -1074,7 +1046,7 @@ end #! - VAULT_ROOT is the commitment of the account's vault. #! - STORAGE_COMMITMENT is the commitment to the account's storage. #! - STORAGE_SLOT_DATA is the data contained in the storage slot which is constructed as follows: -#! [SLOT_VALUE, slot_type, 0, 0, 0]. +#! [0, slot_type, slot_id_suffix, slot_id_prefix, SLOT_VALUE]. #! - CODE_COMMITMENT is the commitment to the account's code. #! - ACCOUNT_PROCEDURE_DATA are the roots of the public procedures of the foreign account. #! @@ -1164,7 +1136,7 @@ end #! Where: #! - STORAGE_COMMITMENT is the commitment of the active account's storage. #! - STORAGE_SLOT_DATA is the data contained in the storage slot which is constructed as follows: -#! [SLOT_VALUE, slot_type, 0, 0, 0] +#! [0, slot_type, slot_id_suffix, slot_id_prefix, SLOT_VALUE] #! #! Panics if: #! - the number of account storage slots exceeded the maximum limit of 255. @@ -1177,7 +1149,7 @@ pub proc save_account_storage_data # push the length of the storage slot data onto the operand stack and compute the number of # storage slots from it - adv_push.1 div.ACCOUNT_STORAGE_SLOT_DATA_LENGTH + adv_push div.ACCOUNT_STORAGE_SLOT_DATA_LENGTH # OS => [num_storage_slots, STORAGE_COMMITMENT] # AS => [[STORAGE_SLOT_DATA]] @@ -1247,7 +1219,7 @@ pub proc save_account_procedure_data # push the length of the account procedure data onto the operand stack and compute the number of # procedures from it - adv_push.1 div.ACCOUNT_PROCEDURE_DATA_LENGTH + adv_push div.ACCOUNT_PROCEDURE_DATA_LENGTH # OS => [num_procs, CODE_COMMITMENT] # AS => [[ACCOUNT_PROCEDURE_DATA]] @@ -1376,7 +1348,7 @@ proc insert_and_validate_storage_map # OS => [slot_ptr, MAP_ROOT] # AS => [num_elements, [MAP_ENTRIES]] - adv_push.1 + adv_push # OS => [num_elements, slot_ptr, MAP_ROOT] # AS => [[MAP_ENTRIES]] @@ -1552,7 +1524,6 @@ end #! Locals: #! - 0: slot_ptr #! - 4..8: OLD_MAP_VALUE -#! - 8..12: OLD_MAP_ROOT @locals(8) proc set_map_item_raw # store slot_ptr until the end of the procedure @@ -1636,6 +1607,7 @@ end #! Outputs: [VALUE] #! #! Where: +#! - storage_slots_ptr is the pointer to the storage slots section in which to look up the slot. #! - KEY is the key to look up in the map. #! - 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. @@ -1759,7 +1731,7 @@ end #! #! Panics if: #! - the procedure index is out of bounds. -proc get_procedure_root +pub proc get_procedure_root dup exec.memory::get_num_account_procedures u32assert2.err=ERR_ACCOUNT_PROC_INDEX_OUT_OF_BOUNDS u32lt assert.err=ERR_ACCOUNT_PROC_INDEX_OUT_OF_BOUNDS @@ -1789,14 +1761,14 @@ end #! data, depending on the value of the was_loaded flag. #! #! Panics if: -#! - the maximum allowed number of foreign account to be loaded (64) was exceeded. +#! - the maximum allowed number of foreign accounts to be loaded (63) was exceeded. pub proc get_account_data_ptr # move pointer one account block back so that the first account pointer in the cycle will point # to the native account - exec.memory::get_native_account_data_ptr exec.memory::get_account_data_length sub + push.NATIVE_ACCOUNT_DATA_PTR push.ACCOUNT_DATA_LENGTH sub # => [curr_account_ptr, foreign_account_id_suffix, foreign_account_id_prefix] - # push the pad element onto the stack: it will represent the `is_equal_id` flag during the cycle + # push the initial `is_equal_id` flag (0 = not found yet) onto the stack push.0 movdn.3 # => [curr_account_ptr, foreign_account_id_suffix, foreign_account_id_prefix, is_equal_id=0] @@ -1804,13 +1776,13 @@ pub proc get_account_data_ptr push.1 while.true - # drop the flag left from the previous loop - # in the first iteration this will be a pad element + # drop the `is_equal_id` flag left from the previous iteration + # in the first iteration this is the initial is_equal_id=0 pushed above movup.3 drop # => [curr_account_ptr, foreign_account_id_suffix, foreign_account_id_prefix] # move the current account pointer to the next account data block - exec.memory::get_account_data_length add + push.ACCOUNT_DATA_LENGTH add # => [curr_account_ptr', foreign_account_id_suffix, foreign_account_id_prefix] dup add.ACCT_ID_PREFIX_OFFSET mem_load @@ -1834,11 +1806,11 @@ pub proc get_account_data_ptr # check that the loading of one more account won't exceed the maximum number of the foreign # accounts which can be loaded. - dup exec.memory::get_max_foreign_account_ptr lte + dup push.MAX_FOREIGN_ACCOUNT_PTR lte assert.err=ERR_FOREIGN_ACCOUNT_MAX_NUMBER_EXCEEDED # => [curr_account_ptr, foreign_account_id_suffix, foreign_account_id_prefix, is_equal_id] - # the resulting `was_loaded` flag is essentially equal to the `is_equal_id` flag + # the resulting `was_loaded` flag is equal to the `is_equal_id` flag movup.3 # => [was_loaded, curr_account_ptr, foreign_account_id_suffix, foreign_account_id_prefix] end @@ -1962,7 +1934,7 @@ end #! - the procedure root is not part of the account code. pub proc was_procedure_called # load procedure index - emit.ACCOUNT_PUSH_PROCEDURE_INDEX_EVENT adv_push.1 + emit.ACCOUNT_PUSH_PROCEDURE_INDEX_EVENT adv_push # => [index, PROC_ROOT] dup movdn.5 diff --git a/crates/miden-protocol/asm/kernels/transaction/lib/account_delta.masm b/crates/miden-protocol/asm/kernels/transaction/lib/account_delta.masm index b2b44e81c3..7df7931432 100644 --- a/crates/miden-protocol/asm/kernels/transaction/lib/account_delta.masm +++ b/crates/miden-protocol/asm/kernels/transaction/lib/account_delta.masm @@ -103,7 +103,7 @@ end #! Inputs: [RATE0, RATE1, CAPACITY] #! Outputs: [RATE0, RATE1, CAPACITY] proc update_storage_delta - exec.memory::get_num_storage_slots movdn.12 + exec.memory::get_native_num_storage_slots movdn.12 # => [RATE0, RATE1, CAPACITY, num_storage_slots] push.0 movdn.12 @@ -317,6 +317,10 @@ end #! #! 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 @@ -394,6 +398,10 @@ end #! #! 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 diff --git a/crates/miden-protocol/asm/kernels/transaction/lib/asset.masm b/crates/miden-protocol/asm/kernels/transaction/lib/asset.masm index bc15b27b10..b3e294ef86 100644 --- a/crates/miden-protocol/asm/kernels/transaction/lib/asset.masm +++ b/crates/miden-protocol/asm/kernels/transaction/lib/asset.masm @@ -6,7 +6,7 @@ use $kernel::util::asset->util_asset # ERRORS # ================================================================================================= -const ERR_VAULT_ASSET_KEY_ACCOUNT_ID_MUST_BE_FAUCET="account ID in asset vault key must be either of type fungible or non-fungible faucet" +const ERR_VAULT_UNSUPPORTED_ASSET_COMPOSITION="asset composition Custom is not yet supported" # CONSTANT ACCESSORS # ================================================================================================= @@ -14,13 +14,18 @@ const ERR_VAULT_ASSET_KEY_ACCOUNT_ID_MUST_BE_FAUCET="account ID in asset vault k pub use $kernel::util::asset::FUNGIBLE_ASSET_MAX_AMOUNT pub use $kernel::util::asset::ASSET_SIZE pub use $kernel::util::asset::ASSET_VALUE_MEMORY_OFFSET +pub use $kernel::util::asset::COMPOSITION_NONE +pub use $kernel::util::asset::COMPOSITION_FUNGIBLE +pub use $kernel::util::asset::COMPOSITION_CUSTOM pub use $kernel::util::asset::key_to_faucet_id pub use $kernel::util::asset::key_into_faucet_id pub use $kernel::util::asset::key_to_asset_id pub use $kernel::util::asset::key_into_asset_id pub use $kernel::util::asset::key_to_callbacks_enabled +pub use $kernel::util::asset::key_to_composition pub use $kernel::util::asset::store pub use $kernel::util::asset::load +pub use $kernel::util::asset::validate_metadata # PROCEDURES # ================================================================================================= @@ -34,9 +39,11 @@ pub use $kernel::util::asset::load #! - ASSET_KEY is the vault key of the asset to check. #! - is_fungible_asset is a boolean indicating whether the asset is fungible. pub proc is_fungible_asset_key - # => [asset_id_suffix, asset_id_prefix, faucet_id_suffix, faucet_id_prefix] + # => [ASSET_KEY] + exec.key_to_composition + # => [asset_composition, ASSET_KEY] - dup.3 exec.account_id::is_fungible_faucet + eq.COMPOSITION_FUNGIBLE # => [is_fungible_asset, ASSET_KEY] end @@ -99,12 +106,33 @@ end #! - ASSET_KEY is the vault key of the asset to check. #! - is_non_fungible_asset is a boolean indicating whether the asset is non-fungible. pub proc is_non_fungible_asset_key - # => [asset_id_suffix, asset_id_prefix, faucet_id_suffix, faucet_id_prefix] + # => [ASSET_KEY] + exec.key_to_composition + # => [asset_composition, ASSET_KEY] - dup.3 exec.account_id::is_non_fungible_faucet + eq.COMPOSITION_NONE # => [is_non_fungible_asset, ASSET_KEY] end +#! Asserts that the asset's composition is supported by the kernel today (i.e. not Custom). +#! +#! Inputs: [ASSET_KEY] +#! Outputs: [ASSET_KEY] +#! +#! Where: +#! - ASSET_KEY is the vault key of the asset to check. +#! +#! Panics if: +#! - the asset's composition is Custom. +pub proc assert_supported_composition + exec.key_to_composition + # => [asset_composition, ASSET_KEY] + + neq.COMPOSITION_CUSTOM + assert.err=ERR_VAULT_UNSUPPORTED_ASSET_COMPOSITION + # => [ASSET_KEY] +end + #! Validates that an asset is well formed. #! #! Inputs: [ASSET_KEY, ASSET_VALUE] 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 51444b0773..1808579a32 100644 --- a/crates/miden-protocol/asm/kernels/transaction/lib/asset_vault.masm +++ b/crates/miden-protocol/asm/kernels/transaction/lib/asset_vault.masm @@ -224,6 +224,10 @@ end #! - the total value of two fungible assets is greater than FUNGIBLE_ASSET_MAX_AMOUNT. #! - the vault already contains the same non-fungible asset. pub proc add_asset + # reject assets with a Custom composition; only None and Fungible are supported today + exec.asset::assert_supported_composition + # => [ASSET_KEY, ASSET_VALUE, vault_root_ptr] + # check if the asset is a fungible asset exec.asset::is_fungible_asset_key # => [is_fungible_asset, ASSET_KEY, ASSET_VALUE, vault_root_ptr] @@ -375,6 +379,10 @@ end #! - 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 + # reject assets with a Custom composition; only None and Fungible are supported today + exec.asset::assert_supported_composition + # => [ASSET_KEY, ASSET_VALUE, vault_root_ptr] + # check if the asset is a fungible asset exec.asset::is_fungible_asset_key # => [is_fungible_asset, ASSET_KEY, ASSET_VALUE, vault_root_ptr] diff --git a/crates/miden-protocol/asm/kernels/transaction/lib/constants.masm b/crates/miden-protocol/asm/kernels/transaction/lib/constants.masm index 2d64bb61ad..5293b915ee 100644 --- a/crates/miden-protocol/asm/kernels/transaction/lib/constants.masm +++ b/crates/miden-protocol/asm/kernels/transaction/lib/constants.masm @@ -1,20 +1,19 @@ # CONSTANTS # ================================================================================================= -# The number of elements in a Word -pub const WORD_SIZE = 4 +pub use $kernel::util::constants::WORD_NUM_ELEMENTS # The maximum number of storage items associated with a single note. pub const MAX_NOTE_STORAGE_ITEMS = 1024 # The maximum number of assets that can be stored in a single note. -pub const MAX_ASSETS_PER_NOTE = 256 +pub const MAX_ASSETS_PER_NOTE = 64 # The maximum number of notes that can be consumed in a single transaction. pub const MAX_INPUT_NOTES_PER_TX = 1024 # The size of the memory segment allocated to each note. -pub const NOTE_MEM_SIZE = 3072 +pub const NOTE_MEM_SIZE = 1024 # The depth of the Merkle tree used to commit to notes produced in a block. pub const NOTE_TREE_DEPTH = 16 @@ -29,7 +28,7 @@ pub const ACCOUNT_PROCEDURE_DATA_LENGTH = 4 # ================================================================================================= # Root of an empty Sparse Merkle Tree -pub const EMPTY_SMT_ROOT = [11569107685829756166, 7187477731240244145, 8326334713638926095, 2239973196746300865] +pub const EMPTY_SMT_ROOT = [3975378004049472045, 2532873049833957132, 2640800763532531478, 11158234471980764993] # Type of storage slot item in the account storage pub const STORAGE_SLOT_TYPE_VALUE = 0 diff --git a/crates/miden-protocol/asm/kernels/transaction/lib/epilogue.masm b/crates/miden-protocol/asm/kernels/transaction/lib/epilogue.masm index 4ac2b7ceea..baf2ae2495 100644 --- a/crates/miden-protocol/asm/kernels/transaction/lib/epilogue.masm +++ b/crates/miden-protocol/asm/kernels/transaction/lib/epilogue.masm @@ -6,6 +6,8 @@ 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 use $kernel::note use miden::core::crypto::hashes::poseidon2 @@ -60,8 +62,8 @@ const ESTIMATED_AFTER_COMPUTE_FEE_CYCLES=NUM_POST_COMPUTE_FEE_CYCLES+SMT_SET_ADD #! Copies the entire output note data to the advice map. If no notes were created by a transaction, #! nothing is copied to the advice map. #! -#! The output note data includes the note id, metadata, recipient, assets commitment, number of assets and -#! a set of assets for each note. +#! The output note data includes the note details commitment, metadata, recipient, assets +#! commitment, number of assets, and a set of assets for each note. #! #! Inputs: #! Operand stack: [OUTPUT_NOTES_COMMITMENT] @@ -72,7 +74,7 @@ const ESTIMATED_AFTER_COMPUTE_FEE_CYCLES=NUM_POST_COMPUTE_FEE_CYCLES+SMT_SET_ADD #! } #! #! Where: -#! - OUTPUT_NOTES_COMMITMENT is the note commitment computed from note's id and metadata. +#! - OUTPUT_NOTES_COMMITMENT is the note commitment computed from note's details 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. @@ -92,7 +94,7 @@ proc copy_output_notes_to_advice_map # => [OUTPUT_NOTES_COMMITMENT, output_notes_end_ptr] # compute the start boundary of the output notes section - exec.memory::get_output_note_data_offset movdn.4 + push.OUTPUT_NOTE_SECTION_OFFSET movdn.4 # => [OUTPUT_NOTES_COMMITMENT, output_note_ptr, output_notes_end_ptr] # insert created data into the advice map @@ -149,7 +151,7 @@ proc build_output_vault # => [num_assets, note_data_ptr, output_notes_end_ptr] # prepare stack for reading output note assets - exec.memory::get_output_vault_root_ptr dup.2 exec.memory::get_output_note_asset_data_ptr dup + push.OUTPUT_VAULT_ROOT_PTR dup.2 exec.memory::get_output_note_asset_data_ptr dup # => [assets_start_ptr, assets_start_ptr, output_vault_root_ptr, num_assets, note_data_ptr, # output_notes_end_ptr] @@ -250,7 +252,7 @@ end #! Outputs: [fee_amount] #! #! Where: -#! - fee_amount is the computed fee amount of the transaction in the native asset. +#! - fee_amount is the computed fee amount of the transaction in the fee asset. proc compute_fee # get the number of cycles the transaction has taken to execute up this point clk @@ -276,27 +278,27 @@ proc compute_fee # => [verification_cost] end -#! Creates the fee asset with the provided fee amount and the native asset ID of the transaction's +#! 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 native asset. +#! - 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 native asset. -proc create_native_fee_asset - exec.memory::get_native_asset_id - # => [native_asset_id_suffix, native_asset_id_prefix, fee_amount] +#! 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, native_asset_id_suffix, native_asset_id_prefix, fee_amount] + # => [enable_callbacks, fee_faucet_id_suffix, fee_faucet_id_prefix, fee_amount] - # SAFETY: native asset ID should be fungible and amount should not be exceeded + # 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 @@ -309,12 +311,12 @@ end #! check. That's okay, because the logic is entirely determined by the transaction kernel. #! #! Inputs: [] -#! Outputs: [native_asset_id_suffix, native_asset_id_prefix, fee_amount] +#! Outputs: [fee_faucet_id_suffix, fee_faucet_id_prefix, fee_amount] #! #! Where: -#! - fee_amount is the computed fee amount of the transaction in the native asset. -#! - native_asset_id_{prefix,suffix} are the prefix and suffix felts of the faucet that issues the -#! native asset. +#! - 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. @@ -323,8 +325,8 @@ proc compute_and_remove_fee exec.compute_fee dup # => [fee_amount, fee_amount] - # build the native asset from the fee amount - exec.create_native_fee_asset + # 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 @@ -332,10 +334,10 @@ proc compute_and_remove_fee # prepare the return value exec.asset::key_to_faucet_id - # => [native_asset_id_suffix, native_asset_id_prefix, FEE_ASSET_KEY, FEE_ASSET_VALUE, fee_amount] + # => [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, native_asset_id_suffix, native_asset_id_prefix, fee_amount] + # => [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 @@ -347,11 +349,11 @@ proc compute_and_remove_fee # fetch the vault root ptr exec.memory::get_account_vault_root_ptr movdn.8 - # => [FEE_ASSET_KEY, FEE_ASSET_VALUE, account_vault_root_ptr, native_asset_id_suffix, native_asset_id_prefix, fee_amount] + # => [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 - # => [native_asset_id_suffix, native_asset_id_prefix, fee_amount] + # => [fee_faucet_id_suffix, fee_faucet_id_prefix, fee_amount] end # TRANSACTION EPILOGUE PROCEDURE @@ -373,16 +375,16 @@ end #! Inputs: [] #! Outputs: [ #! OUTPUT_NOTES_COMMITMENT, ACCOUNT_UPDATE_COMMITMENT, -#! native_asset_id_suffix, native_asset_id_prefix, fee_amount, tx_expiration_block_num +#! fee_faucet_id_suffix, fee_faucet_id_prefix, fee_amount, 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 native asset. -#! - native_asset_id_{prefix,suffix} are the prefix and suffix felts of the faucet that issues the -#! native asset. +#! - 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. #! - tx_expiration_block_num is the transaction expiration block number. #! #! Locals: @@ -471,11 +473,11 @@ pub proc finalize_transaction # ------ Compute fees ------ exec.compute_and_remove_fee - # => [native_asset_id_suffix, native_asset_id_prefix, fee_amount] + # => [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 - # => [native_asset_id_suffix, native_asset_id_prefix, fee_amount, 0] + # => [fee_faucet_id_suffix, fee_faucet_id_prefix, fee_amount, 0] # store fee info in local memory loc_storew_le.4 dropw @@ -521,14 +523,14 @@ pub proc finalize_transaction # load fee asset from local padw loc_loadw_le.4 swapw - # => [ACCOUNT_UPDATE_COMMITMENT, [native_asset_id_suffix, native_asset_id_prefix, fee_amount, 0]] + # => [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, [native_asset_id_suffix, native_asset_id_prefix, fee_amount, tx_expiration_block_num]] + # => [ACCOUNT_UPDATE_COMMITMENT, [fee_faucet_id_suffix, fee_faucet_id_prefix, fee_amount, tx_expiration_block_num]] # load output notes commitment from local padw loc_loadw_le.0 # => [OUTPUT_NOTES_COMMITMENT, ACCOUNT_UPDATE_COMMITMENT, - # native_asset_id_suffix, native_asset_id_prefix, fee_amount, tx_expiration_block_num] + # fee_faucet_id_suffix, fee_faucet_id_prefix, fee_amount, 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 c3d4223641..2283f96726 100644 --- a/crates/miden-protocol/asm/kernels/transaction/lib/faucet.masm +++ b/crates/miden-protocol/asm/kernels/transaction/lib/faucet.masm @@ -3,7 +3,7 @@ use $kernel::asset use $kernel::asset_vault use $kernel::fungible_asset use $kernel::non_fungible_asset -use $kernel::memory +use $kernel::memory::INPUT_VAULT_ROOT_PTR # FUNGIBLE ASSETS # ================================================================================================== @@ -30,7 +30,7 @@ pub proc mint_fungible_asset exec.fungible_asset::validate_origin # => [ASSET_KEY, ASSET_VALUE] - exec.memory::get_input_vault_root_ptr + push.INPUT_VAULT_ROOT_PTR movdn.8 # => [ASSET_KEY, ASSET_VALUE, input_vault_root_ptr] @@ -61,7 +61,7 @@ proc burn_fungible_asset exec.fungible_asset::validate_origin # => [ASSET_KEY, ASSET_VALUE] - exec.memory::get_input_vault_root_ptr + push.INPUT_VAULT_ROOT_PTR movdn.8 # => [ASSET_KEY, ASSET_VALUE, input_vault_root_ptr] @@ -100,7 +100,7 @@ proc mint_non_fungible_asset exec.non_fungible_asset::validate_origin # => [ASSET_KEY, ASSET_VALUE] - exec.memory::get_input_vault_root_ptr + push.INPUT_VAULT_ROOT_PTR movdn.8 # => [ASSET_KEY, ASSET_VALUE, input_vault_root_ptr] @@ -131,7 +131,7 @@ proc burn_non_fungible_asset # => [ASSET_KEY, ASSET_VALUE] # remove the non-fungible asset from the input vault for asset preservation - exec.memory::get_input_vault_root_ptr + push.INPUT_VAULT_ROOT_PTR movdn.8 # => [ASSET_KEY, ASSET_VALUE, input_vault_root_ptr] @@ -167,6 +167,10 @@ end #! allowed. #! - For non-fungible faucets if the non-fungible asset being minted already exists. pub proc mint + # reject assets with a Custom composition; only None and Fungible are supported today + exec.asset::assert_supported_composition + # => [ASSET_KEY, ASSET_VALUE] + # check if the asset is a fungible asset exec.asset::is_fungible_asset_key # => [is_fungible_asset, ASSET_KEY, ASSET_VALUE] @@ -201,6 +205,10 @@ end #! - For non-fungible faucets if 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. pub proc burn + # reject assets with a Custom composition; only None and Fungible are supported today + exec.asset::assert_supported_composition + # => [ASSET_KEY, ASSET_VALUE] + # check if the asset is a fungible asset exec.asset::is_fungible_asset_key # => [is_fungible_asset, ASSET_KEY, ASSET_VALUE] 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 a5e5f8d202..0670e57f94 100644 --- a/crates/miden-protocol/asm/kernels/transaction/lib/fungible_asset.masm +++ b/crates/miden-protocol/asm/kernels/transaction/lib/fungible_asset.masm @@ -17,7 +17,7 @@ pub use $kernel::util::asset::fungible_value_into_amount->value_into_amount const ERR_VAULT_FUNGIBLE_MAX_AMOUNT_EXCEEDED="adding the fungible asset to the vault would exceed the max amount" -const ERR_FUNGIBLE_ASSET_KEY_ACCOUNT_ID_MUST_BE_FUNGIBLE = "fungible asset vault key's account ID must be of type fungible faucet" +const ERR_FUNGIBLE_ASSET_KEY_COMPOSITION_MUST_BE_FUNGIBLE = "fungible asset vault key's composition must be fungible" const ERR_FUNGIBLE_ASSET_FAUCET_IS_NOT_ORIGIN="the origin of the fungible asset is not this faucet" @@ -149,13 +149,13 @@ end #! Panics if: #! - the asset key's account ID is not valid. #! - the asset key's metadata is not valid. -#! - the asset key's faucet ID is not a fungible one. +#! - the asset key's composition is not fungible. pub proc validate_key exec.asset::validate_issuer # => [ASSET_KEY] exec.asset::is_fungible_asset_key - assert.err=ERR_FUNGIBLE_ASSET_KEY_ACCOUNT_ID_MUST_BE_FUNGIBLE + assert.err=ERR_FUNGIBLE_ASSET_KEY_COMPOSITION_MUST_BE_FUNGIBLE # => [ASSET_KEY] exec.asset::key_to_asset_id diff --git a/crates/miden-protocol/asm/kernels/transaction/lib/link_map.masm b/crates/miden-protocol/asm/kernels/transaction/lib/link_map.masm index edc3d78314..39876016c3 100644 --- a/crates/miden-protocol/asm/kernels/transaction/lib/link_map.masm +++ b/crates/miden-protocol/asm/kernels/transaction/lib/link_map.masm @@ -1,5 +1,8 @@ use miden::core::word use $kernel::memory +use $kernel::memory::LINK_MAP_ENTRY_SIZE +use $kernel::memory::LINK_MAP_REGION_END_PTR +use $kernel::memory::LINK_MAP_REGION_START_PTR # A link map is a map data structure based on a sorted linked list. # @@ -156,12 +159,18 @@ const INSERT_OPERATION_UPDATE=0 # The value of the InsertAtHead operation for a set event. const INSERT_OPERATION_AT_HEAD=1 +# The value of the InsertAfterEntry operation for a set event. +const INSERT_OPERATION_AFTER_ENTRY=2 + # The value of the Found operation for a get event. const GET_OPERATION_FOUND=0 # The value of the AbsentAtHead operation for a get event. const GET_OPERATION_ABSENT_AT_HEAD=1 +# The value of the AbsentAfterEntry operation for a get event. +const GET_OPERATION_ABSENT_AFTER_ENTRY=2 + # EVENTS # ================================================================================================= @@ -199,7 +208,7 @@ const LINK_MAP_GET_EVENT=event("miden::protocol::link_map::get") #! update_entry, insert_at_head, insert_after_entry and assert_empty_map_op_is_at_head. pub proc set emit.LINK_MAP_SET_EVENT - adv_push.2 + adv_push adv_push # => [operation, entry_ptr, map_ptr, KEY, VALUE0, VALUE1] dup eq.INSERT_OPERATION_UPDATE swap eq.INSERT_OPERATION_AT_HEAD @@ -275,7 +284,7 @@ end #! assert_empty_map_op_is_at_head. pub proc get emit.LINK_MAP_GET_EVENT - adv_push.2 + adv_push adv_push # => [get_operation, entry_ptr, map_ptr, KEY] dup eq.GET_OPERATION_FOUND swap eq.GET_OPERATION_ABSENT_AT_HEAD @@ -480,7 +489,7 @@ proc insert_at_head # => [] end -#! Updates the VALUE0 in the given entry. +#! Updates the VALUE0 and VALUE1 in the given entry. #! #! Inputs: [entry_ptr, KEY, VALUE0, VALUE1] #! Outputs: [] @@ -550,7 +559,7 @@ proc insert_after_entry # set entry_ptr.prev_entry = prev_entry_ptr exec.set_prev_entry_ptr - # => [entry_ptr, next_entry_ptr, entry_ptr] + # => [next_entry_ptr, entry_ptr] dup eq.0 not # => [has_next_entry, next_entry_ptr, entry_ptr] @@ -924,7 +933,7 @@ proc assert_entry_ptr_is_valid u32assert.err=ERR_LINK_MAP_ENTRY_PTR_IS_OUTSIDE_VALID_MEMORY_REGION # => [entry_ptr, map_ptr] - exec.memory::get_link_map_region_start_ptr dup.1 + push.LINK_MAP_REGION_START_PTR dup.1 # => [entry_ptr, region_start_ptr, entry_ptr, map_ptr] # compute region_start_ptr <= entry_ptr @@ -932,7 +941,7 @@ proc assert_entry_ptr_is_valid u32lte # => [is_entry_ptr_gt_start, entry_ptr, map_ptr] - dup.1 exec.memory::get_link_map_region_end_ptr + dup.1 push.LINK_MAP_REGION_END_PTR # => [region_end_ptr, entry_ptr, is_entry_ptr_gt_start, entry_ptr, map_ptr] # compute entry_ptr < region_end_ptr @@ -950,7 +959,7 @@ proc assert_entry_ptr_is_valid # any valid entry pointer is a multiple of LINK_MAP_ENTRY_SIZE. So to check validity, # we assert that entry_ptr % LINK_MAP_ENTRY_SIZE == 0. # note: we previously asserted that entry_ptr fits in a u32 - dup exec.memory::get_link_map_entry_size u32mod eq.0 + dup push.LINK_MAP_ENTRY_SIZE u32mod eq.0 # => [is_entry_ptr_aligned, entry_ptr, map_ptr] assert.err=ERR_LINK_MAP_ENTRY_PTR_IS_NOT_ENTRY_ALIGNED diff --git a/crates/miden-protocol/asm/kernels/transaction/lib/memory.masm b/crates/miden-protocol/asm/kernels/transaction/lib/memory.masm index 4efa51d1e9..3030e6cafd 100644 --- a/crates/miden-protocol/asm/kernels/transaction/lib/memory.masm +++ b/crates/miden-protocol/asm/kernels/transaction/lib/memory.masm @@ -1,6 +1,7 @@ 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 @@ -9,7 +10,7 @@ pub type AccountId = struct { prefix: felt, suffix: felt } # ERRORS # ================================================================================================= -const ERR_NOTE_NUM_OF_ASSETS_EXCEED_LIMIT="number of assets in a note exceed 255" +const ERR_NOTE_NUM_OF_ASSETS_EXCEED_LIMIT="number of assets in a note exceeds 64" const ERR_ACCOUNT_IS_NOT_NATIVE="the active account is not native" @@ -44,10 +45,10 @@ const TX_EXPIRATION_BLOCK_NUM_PTR = 2 const NATIVE_ACCT_STORAGE_COMMITMENT_DIRTY_FLAG_PTR = 3 # The memory address at which the input vault root is stored. -const INPUT_VAULT_ROOT_PTR = 4 +pub const INPUT_VAULT_ROOT_PTR = 4 # The memory address at which the output vault root is stored. -const OUTPUT_VAULT_ROOT_PTR = 8 +pub const OUTPUT_VAULT_ROOT_PTR = 8 # Pointer to the prefix and suffix of the ID of the foreign account which will be loaded during the # upcoming FPI call. This ID is updated during the `prepare_fpi_call` kernel procedure. @@ -58,11 +59,11 @@ const UPCOMING_FOREIGN_ACCOUNT_PREFIX_PTR = UPCOMING_FOREIGN_ACCOUNT_SUFFIX_PTR # during the upcoming FPI call. This "buffer" value helps to work around the 15 value limitation of # the `exec_kernel_proc` kernel procedure, so that any account procedure, even if it has 16 input # values, could be executed as foreign. -const UPCOMING_FOREIGN_PROC_INPUT_VALUE_15_PTR = 14 +pub const UPCOMING_FOREIGN_PROC_INPUT_VALUE_15_PTR = 14 # Pointer to the root of the foreign procedure which will be executed during the upcoming FPI call. # This root is updated during the `prepare_fpi_call` kernel procedure. -const UPCOMING_FOREIGN_PROCEDURE_PTR = 16 +pub const UPCOMING_FOREIGN_PROCEDURE_PTR = 16 # The memory address at which the pointer to the account stack element containing the pointer to the # currently accessing account (active account) data is stored. @@ -104,7 +105,7 @@ const INIT_NATIVE_ACCOUNT_STORAGE_COMMITMENT_PTR=420 const INPUT_NOTES_COMMITMENT_PTR=424 # The memory address at which the transaction script mast root is stored. -const TX_SCRIPT_ROOT_PTR=428 +pub const TX_SCRIPT_ROOT_PTR=428 # The memory address at which the transaction script arguments are stored. const TX_SCRIPT_ARGS_PTR=432 @@ -116,7 +117,7 @@ const AUTH_ARGS_PTR=436 # ------------------------------------------------------------------------------------------------- # The memory address at which the block data section begins -const BLOCK_DATA_SECTION_OFFSET=800 +pub const BLOCK_DATA_SECTION_OFFSET=800 # The memory address at which the previous block commitment is stored const PREV_BLOCK_COMMITMENT_PTR=800 @@ -143,16 +144,16 @@ const VALIDATOR_KEY_COMMITMENT_PTR=824 const BLOCK_METADATA_PTR=828 # The memory address at which the fee parameters are stored. These occupy a double word. -# [0, verification_base_fee, native_asset_id_suffix, native_asset_id_prefix] +# [0, verification_base_fee, fee_faucet_id_suffix, fee_faucet_id_prefix] # [0, 0, 0, 0] const FEE_PARAMETERS_PTR=832 # The memory address at which the verification base fee is stored. const VERIFICATION_BASE_FEE_PTR = FEE_PARAMETERS_PTR + 1 -# The memory address at which the native asset ID is stored. -const NATIVE_ASSET_ID_SUFFIX_PTR = FEE_PARAMETERS_PTR + 2 -const NATIVE_ASSET_ID_PREFIX_PTR = FEE_PARAMETERS_PTR + 3 +# The memory address at which the fee faucet ID is stored. +const FEE_FAUCET_ID_SUFFIX_PTR = FEE_PARAMETERS_PTR + 2 +const FEE_FAUCET_ID_PREFIX_PTR = FEE_PARAMETERS_PTR + 3 # The memory address at which the note root is stored const NOTE_ROOT_PTR=840 @@ -161,7 +162,7 @@ const NOTE_ROOT_PTR=840 # ------------------------------------------------------------------------------------------------- # The memory address at which the chain data section begins -const PARTIAL_BLOCKCHAIN_PTR=1200 +pub const PARTIAL_BLOCKCHAIN_PTR=1200 # The memory address at which the total number of leaves in the partial blockchain is stored const PARTIAL_BLOCKCHAIN_NUM_LEAVES_PTR=1200 @@ -176,7 +177,7 @@ const PARTIAL_BLOCKCHAIN_PEAKS_PTR=1204 const NUM_KERNEL_PROCEDURES_PTR=1600 # The memory address at which the hashes of kernel procedures begin. -const KERNEL_PROCEDURES_PTR=1604 +pub const KERNEL_PROCEDURES_PTR=1604 # ACCOUNT DATA # ------------------------------------------------------------------------------------------------- @@ -184,21 +185,22 @@ const KERNEL_PROCEDURES_PTR=1604 # The largest memory address which can be used to load the foreign account data. # It is computed as `2048 * 64 * 4` -- this is the memory address where the data block of the 64th # account starts. -const MAX_FOREIGN_ACCOUNT_PTR=524288 +pub const MAX_FOREIGN_ACCOUNT_PTR=524288 # The memory address at which the native account data is stored. -const NATIVE_ACCOUNT_DATA_PTR=8192 +pub const NATIVE_ACCOUNT_DATA_PTR=8192 const NATIVE_ACCOUNT_ID_SUFFIX_PTR = NATIVE_ACCOUNT_DATA_PTR + ACCT_ID_SUFFIX_OFFSET const NATIVE_ACCOUNT_ID_PREFIX_PTR = NATIVE_ACCOUNT_DATA_PTR + ACCT_ID_PREFIX_OFFSET +const NATIVE_ACCOUNT_NUM_STORAGE_SLOTS_PTR = NATIVE_ACCOUNT_DATA_PTR + ACCT_NUM_STORAGE_SLOTS_OFFSET # The length of the memory interval that the account data occupies. -const ACCOUNT_DATA_LENGTH=8192 +pub const ACCOUNT_DATA_LENGTH=8192 # The offsets at which the account data is stored relative to the start of the account data segment. -const ACCT_NONCE_OFFSET=0 -const ACCT_ID_AND_NONCE_OFFSET=0 -const ACCT_ID_SUFFIX_OFFSET=2 -const ACCT_ID_PREFIX_OFFSET=3 +pub const ACCT_NONCE_OFFSET=0 +pub const ACCT_ID_AND_NONCE_OFFSET=0 +pub const ACCT_ID_SUFFIX_OFFSET=2 +pub const ACCT_ID_PREFIX_OFFSET=3 const ACCT_VAULT_ROOT_OFFSET=4 const ACCT_STORAGE_COMMITMENT_OFFSET=8 const ACCT_CODE_COMMITMENT_OFFSET=12 @@ -245,15 +247,15 @@ const INPUT_NOTE_DATA_SECTION_OFFSET=4259840 const NUM_INPUT_NOTES_PTR=INPUT_NOTE_SECTION_OFFSET # The offsets at which data of an input note is stored relative to the start of its data segment -const INPUT_NOTE_ID_OFFSET=0 +const INPUT_NOTE_DETAILS_COMMITMENT_OFFSET=0 const INPUT_NOTE_CORE_DATA_OFFSET=4 const INPUT_NOTE_SERIAL_NUM_OFFSET=4 const INPUT_NOTE_SCRIPT_ROOT_OFFSET=8 const INPUT_NOTE_STORAGE_COMMITMENT_OFFSET=12 const INPUT_NOTE_ASSETS_COMMITMENT_OFFSET=16 -const INPUT_NOTE_RECIPIENT_OFFSET=20 -const INPUT_NOTE_METADATA_HEADER_OFFSET=24 -const INPUT_NOTE_ATTACHMENT_OFFSET=28 +const INPUT_NOTE_METADATA_OFFSET=20 +const INPUT_NOTE_ATTACHMENTS_COMMITMENT_OFFSET=24 +const INPUT_NOTE_RECIPIENT_OFFSET=28 const INPUT_NOTE_ARGS_OFFSET=32 const INPUT_NOTE_NUM_STORAGE_ITEMS_OFFSET=36 const INPUT_NOTE_NUM_ASSETS_OFFSET=40 @@ -263,18 +265,22 @@ const INPUT_NOTE_ASSETS_OFFSET=44 # ------------------------------------------------------------------------------------------------- # The memory address at which the output notes section begins. -const OUTPUT_NOTE_SECTION_OFFSET=16777216 +pub const OUTPUT_NOTE_SECTION_OFFSET=16777216 # The offsets at which data of an output note is stored relative to the start of its data segment. -const OUTPUT_NOTE_ID_OFFSET=0 -const OUTPUT_NOTE_METADATA_HEADER_OFFSET=4 -const OUTPUT_NOTE_METADATA_ATTACHMENT_KIND_SCHEME_OFFSET=OUTPUT_NOTE_METADATA_HEADER_OFFSET + 3 -const OUTPUT_NOTE_ATTACHMENT_OFFSET=8 -const OUTPUT_NOTE_RECIPIENT_OFFSET=12 -const OUTPUT_NOTE_ASSETS_COMMITMENT_OFFSET=16 -const OUTPUT_NOTE_NUM_ASSETS_OFFSET=20 -const OUTPUT_NOTE_DIRTY_FLAG_OFFSET=21 -const OUTPUT_NOTE_ASSETS_OFFSET=24 +const OUTPUT_NOTE_DETAILS_COMMITMENT_OFFSET=0 +const OUTPUT_NOTE_METADATA_OFFSET=4 +const OUTPUT_NOTE_RECIPIENT_OFFSET=8 +const OUTPUT_NOTE_DIRTY_FLAG_OFFSET=12 +const OUTPUT_NOTE_NUM_ASSETS_OFFSET=13 +const OUTPUT_NOTE_NUM_ATTACHMENTS_OFFSET=14 +const OUTPUT_NOTE_TOTAL_ATTACHMENT_WORDS_OFFSET=15 +const OUTPUT_NOTE_ATTACHMENT_0_OFFSET=16 +const OUTPUT_NOTE_ATTACHMENT_1_OFFSET=20 +const OUTPUT_NOTE_ATTACHMENT_2_OFFSET=24 +const OUTPUT_NOTE_ATTACHMENT_3_OFFSET=28 +const OUTPUT_NOTE_ASSETS_COMMITMENT_OFFSET=32 +const OUTPUT_NOTE_ASSETS_OFFSET=36 # LINK MAP MEMORY # ------------------------------------------------------------------------------------------------- @@ -282,21 +288,21 @@ const OUTPUT_NOTE_ASSETS_OFFSET=24 # The inclusive start of the link map dynamic memory region. # Chosen as a number greater than 2^25 such that all entry pointers are multiples of # LINK_MAP_ENTRY_SIZE. That enables a simpler check in assert_entry_ptr_is_valid. -const LINK_MAP_REGION_START_PTR=33554448 +pub const LINK_MAP_REGION_START_PTR=33554448 # The non-inclusive end of the link map dynamic memory region. # This happens to be 2^26, but if it is changed, it should be chosen as a number such that # LINK_MAP_REGION_END_PTR - LINK_MAP_REGION_START_PTR is a multiple of LINK_MAP_ENTRY_SIZE, # because that enables checking whether a newly allocated entry pointer is at the end of the range # using equality rather than lt/gt in link_map_malloc. -const LINK_MAP_REGION_END_PTR=67108864 +pub const LINK_MAP_REGION_END_PTR=67108864 # LINK_MAP_REGION_START_PTR + the currently used size stored at this pointer defines the next # entry pointer that will be allocated. const LINK_MAP_USED_MEMORY_SIZE=33554432 # The size of each map entry, i.e. four words. -const LINK_MAP_ENTRY_SIZE=16 +pub const LINK_MAP_ENTRY_SIZE=16 # MEMORY PROCEDURES # ================================================================================================= @@ -348,18 +354,6 @@ pub proc set_active_input_note_ptr mem_store.ACTIVE_INPUT_NOTE_PTR end -#! Returns the pointer to the memory address at which the input vault root is stored. -#! -#! Inputs: [] -#! Outputs: [input_vault_root_ptr] -#! -#! Where: -#! - input_vault_root_ptr is a pointer to the memory address at which the input vault root is -#! stored. -pub proc get_input_vault_root_ptr - push.INPUT_VAULT_ROOT_PTR -end - #! Returns the input vault root. #! #! Inputs: [] @@ -382,18 +376,6 @@ pub proc set_input_vault_root mem_storew_le.INPUT_VAULT_ROOT_PTR end -#! Returns the pointer to the memory address at which the output vault root is stored. -#! -#! Inputs: [] -#! Outputs: [output_vault_root_ptr] -#! -#! Where: -#! - output_vault_root_ptr is the pointer to the memory address at which the output vault root is -#! stored. -pub proc get_output_vault_root_ptr - push.OUTPUT_VAULT_ROOT_PTR -end - #! Returns the output vault root. #! #! Inputs: [] @@ -638,17 +620,6 @@ pub proc set_nullifier_commitment mem_storew_le.INPUT_NOTES_COMMITMENT_PTR end -#! Returns the memory address of the transaction script root. -#! -#! Inputs: [] -#! Outputs: [tx_script_root_ptr] -#! -#! Where: -#! - tx_script_root_ptr is the pointer to the memory where transaction script root is stored. -pub proc get_tx_script_root_ptr - push.TX_SCRIPT_ROOT_PTR -end - #! Sets the transaction script root. #! #! Inputs: [TX_SCRIPT_ROOT] @@ -660,6 +631,18 @@ pub proc set_tx_script_root mem_storew_le.TX_SCRIPT_ROOT_PTR end +#! Returns the transaction script root. +#! +#! Inputs: [] +#! Outputs: [TX_SCRIPT_ROOT] +#! +#! Where: +#! - TX_SCRIPT_ROOT is the transaction script root, or the empty word if no transaction script was +#! executed. +pub proc get_tx_script_root + padw mem_loadw_le.TX_SCRIPT_ROOT_PTR +end + #! Returns the transaction script arguments. #! #! Inputs: [] @@ -709,17 +692,6 @@ end # BLOCK DATA # ------------------------------------------------------------------------------------------------- -#! Returns a pointer to the block data section. -#! -#! Inputs: [] -#! Outputs: [ptr] -#! -#! Where: -#! - ptr is a pointer to the block data section. -pub proc get_block_data_ptr - push.BLOCK_DATA_SECTION_OFFSET -end - #! Returns the previous block commitment of the transaction reference block. #! #! Inputs: [] @@ -766,18 +738,18 @@ pub proc get_blk_timestamp mem_load.BLOCK_METADATA_TIMESTAMP_PTR end -#! Returns the faucet ID of the native asset as defined in the transaction's reference block. +#! Returns the faucet ID of the fee asset as defined in the transaction's reference block. #! #! Inputs: [] -#! Outputs: [native_asset_id_suffix, native_asset_id_prefix] +#! Outputs: [fee_faucet_id_suffix, fee_faucet_id_prefix] #! #! Where: -#! - native_asset_id_{prefix,suffix} are the prefix and suffix felts of the faucet ID that defines -#! the native asset. -pub proc get_native_asset_id - mem_load.NATIVE_ASSET_ID_PREFIX_PTR - mem_load.NATIVE_ASSET_ID_SUFFIX_PTR - # => [native_asset_id_suffix, native_asset_id_prefix] +#! - 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. @@ -883,17 +855,6 @@ end # CHAIN DATA # ------------------------------------------------------------------------------------------------- -#! Returns a pointer to the partial blockchain section. -#! -#! Inputs: [] -#! Outputs: [ptr] -#! -#! Where: -#! - ptr is the pointer to the partial blockchain section. -pub proc get_partial_blockchain_ptr - push.PARTIAL_BLOCKCHAIN_PTR -end - #! Sets the number of leaves in the partial blockchain. #! #! Inputs: [num_leaves] @@ -905,54 +866,9 @@ pub proc set_partial_blockchain_num_leaves mem_store.PARTIAL_BLOCKCHAIN_NUM_LEAVES_PTR end -#! Returns a pointer to start of the partial blockchain peaks section. -#! -#! Inputs: [] -#! Outputs: [ptr] -#! -#! Where: -#! - ptr is the pointer to the start of the partial blockchain peaks section. -pub proc get_partial_blockchain_peaks_ptr - push.PARTIAL_BLOCKCHAIN_PEAKS_PTR -end - # ACCOUNT DATA # ------------------------------------------------------------------------------------------------- -#! Returns the memory pointer at which the native account data is stored. -#! -#! Inputs: [] -#! Outputs: [ptr] -#! -#! Where: -#! - ptr is the memory address at which the native account data is stored. -pub proc get_native_account_data_ptr - push.NATIVE_ACCOUNT_DATA_PTR -end - -#! Returns the length of the memory interval that the account data occupies. -#! -#! Inputs: [] -#! Outputs: [account_data_length] -#! -#! Where: -#! - account_data_length is the length of the memory interval that the account data occupies. -pub proc get_account_data_length - push.ACCOUNT_DATA_LENGTH -end - -#! Returns the largest memory address which can be used to load the foreign account data. -#! -#! Inputs: [] -#! Outputs: [max_foreign_account_ptr] -#! -#! Where: -#! - max_foreign_account_ptr is the largest memory address which can be used to load the foreign -#! account data. -pub proc get_max_foreign_account_ptr - push.MAX_FOREIGN_ACCOUNT_PTR -end - #! Sets the memory pointer of the active account data to the native account (8192). #! #! Inputs: [] @@ -960,7 +876,7 @@ end pub proc set_active_account_data_ptr_to_native_account # store the native account data pointer into the first account stack element. push.NATIVE_ACCOUNT_DATA_PTR mem_store.MIN_ACCOUNT_STACK_PTR - # => [native_acct_stack_ptr, account_stack_top_ptr] + # => [] # store the pointer to the first account stack element into the account stack top pointer. push.MIN_ACCOUNT_STACK_PTR mem_store.ACCOUNT_STACK_TOP_PTR @@ -1423,6 +1339,17 @@ pub proc get_num_storage_slots mem_load end +#! Returns the number of storage slots contained in the native account's storage. +#! +#! Inputs: [] +#! Outputs: [num_storage_slots] +#! +#! Where: +#! - num_storage_slots is the number of storage slots contained in the native account's storage. +pub proc get_native_num_storage_slots + mem_load.NATIVE_ACCOUNT_NUM_STORAGE_SLOTS_PTR +end + #! Sets the number of storage slots contained in the account storage. #! #! Inputs: [num_storage_slots] @@ -1454,7 +1381,7 @@ end #! Where: #! - storage_slots_section_ptr is the memory pointer to the native account's storage slots section. pub proc get_native_account_active_storage_slots_ptr - exec.get_native_account_data_ptr add.ACCT_ACTIVE_STORAGE_SLOTS_SECTION_OFFSET + push.NATIVE_ACCOUNT_DATA_PTR add.ACCT_ACTIVE_STORAGE_SLOTS_SECTION_OFFSET end #! Returns the memory pointer to the initial storage slots of the native account. @@ -1465,7 +1392,7 @@ end #! Where: #! - account_initial_storage_slots_ptr is the memory pointer to the initial storage slot values. pub proc get_native_account_initial_storage_slots_ptr - exec.get_native_account_data_ptr add.ACCT_INITIAL_STORAGE_SLOTS_SECTION_OFFSET + push.NATIVE_ACCOUNT_DATA_PTR add.ACCT_INITIAL_STORAGE_SLOTS_SECTION_OFFSET end #! Returns the memory pointer to the initial storage slots of the active account. @@ -1567,41 +1494,28 @@ pub proc get_input_note_ptr push.NOTE_MEM_SIZE mul add.INPUT_NOTE_DATA_SECTION_OFFSET end -#! Set the note id of the input note. +#! Set the note details commitment of the input note. #! -#! Inputs: [note_ptr, NOTE_ID] -#! Outputs: [NOTE_ID] +#! Inputs: [note_ptr, NOTE_DETAILS_COMMITMENT] +#! Outputs: [NOTE_DETAILS_COMMITMENT] #! #! Where: -#! - note_ptr is the input note's the memory address. -#! - NOTE_ID is the note's id. -pub proc set_input_note_id +#! - note_ptr is the memory address at which the input note data segment begins. +#! - NOTE_DETAILS_COMMITMENT is the commitment to the note's details. +pub proc set_input_note_details_commitment mem_storew_le end -#! Computes a pointer to the memory address at which the nullifier associated a note with `idx` is -#! stored. +#! Stores the nullifier of the input note with `idx` in the nullifier memory section. #! -#! Inputs: [idx] -#! Outputs: [nullifier_ptr] +#! Inputs: [idx, NULLIFIER] +#! Outputs: [NULLIFIER] #! #! Where: #! - idx is the index of the input note. -#! - nullifier_ptr is the memory address of the nullifier for note idx. -pub proc get_input_note_nullifier_ptr - mul.4 add.INPUT_NOTE_NULLIFIER_SECTION_PTR -end - -#! Returns the nullifier of an input note with `idx`. -#! -#! Inputs: [idx] -#! Outputs: [nullifier] -#! -#! Where: -#! - idx is the index of the input note. -#! - nullifier is the nullifier of the input note. -pub proc get_input_note_nullifier - mul.4 padw movup.4 add.INPUT_NOTE_NULLIFIER_SECTION_PTR mem_loadw_le +#! - NULLIFIER is the nullifier of the input note. +pub proc set_input_note_nullifier + mul.4 add.INPUT_NOTE_NULLIFIER_SECTION_PTR mem_storew_le end #! Returns a pointer to the start of the input note core data segment for the note located at the @@ -1660,57 +1574,31 @@ end #! Returns the metadata of an input note located at the specified memory address. #! #! Inputs: [note_ptr] -#! Outputs: [NOTE_METADATA_HEADER] +#! Outputs: [NOTE_METADATA] #! #! Where: #! - note_ptr is the memory address at which the input note data begins. -#! - NOTE_METADATA_HEADER is the metadata header of the input note. -pub proc get_input_note_metadata_header +#! - NOTE_METADATA is the metadata of the input note. +pub proc get_input_note_metadata padw - movup.4 add.INPUT_NOTE_METADATA_HEADER_OFFSET + movup.4 add.INPUT_NOTE_METADATA_OFFSET mem_loadw_le end -#! Sets the metadata for an input note located at the specified memory address. -#! -#! Inputs: [note_ptr, NOTE_METADATA_HEADER] -#! Outputs: [NOTE_METADATA_HEADER] -#! -#! Where: -#! - note_ptr is the memory address at which the input note data begins. -#! - NOTE_METADATA_HEADER is the metadata header of the input note. -pub proc set_input_note_metadata_header - add.INPUT_NOTE_METADATA_HEADER_OFFSET - mem_storew_le -end - #! Returns the attachment of an input note located at the specified memory address. #! #! Inputs: [note_ptr] -#! Outputs: [NOTE_ATTACHMENT] +#! Outputs: [NOTE_ATTACHMENTS_COMMITMENT] #! #! Where: #! - note_ptr is the memory address at which the input note data begins. -#! - NOTE_ATTACHMENT is the attachment of the input note. -pub proc get_input_note_attachment +#! - NOTE_ATTACHMENTS_COMMITMENT is the commitment to all attachments of the input note. +pub proc get_input_note_attachments_commitment padw - movup.4 add.INPUT_NOTE_ATTACHMENT_OFFSET + movup.4 add.INPUT_NOTE_ATTACHMENTS_COMMITMENT_OFFSET mem_loadw_le end -#! Sets the attachment for an input note located at the specified memory address. -#! -#! Inputs: [note_ptr, NOTE_ATTACHMENT] -#! Outputs: [NOTE_ATTACHMENT] -#! -#! Where: -#! - note_ptr is the memory address at which the input note data begins. -#! - NOTE_ATTACHMENT is the attachment of the input note. -pub proc set_input_note_attachment - add.INPUT_NOTE_ATTACHMENT_OFFSET - mem_storew_le -end - #! Returns the note's args. #! #! Inputs: [note_ptr] @@ -1861,17 +1749,6 @@ end # OUTPUT NOTES # ------------------------------------------------------------------------------------------------- -#! Returns the offset of the output note data segment. -#! -#! Inputs: [] -#! Outputs: [offset] -#! -#! Where: -#! - offset is the offset of the output note data segment. -pub proc get_output_note_data_offset - push.OUTPUT_NOTE_SECTION_OFFSET -end - #! Computes a pointer to the memory address at which the data associated with an output note with #! index `i` is stored. #! @@ -1915,73 +1792,135 @@ end #! Returns the output note's metadata. #! #! Inputs: [note_ptr] -#! Outputs: [METADATA_HEADER] +#! Outputs: [METADATA] #! #! Where: -#! - METADATA_HEADER is the note metadata header. +#! - METADATA is the note metadata. #! - note_ptr is the memory address at which the output note data begins. -pub proc get_output_note_metadata_header +pub proc get_output_note_metadata padw # => [0, 0, 0, 0, note_ptr] - movup.4 add.OUTPUT_NOTE_METADATA_HEADER_OFFSET + movup.4 add.OUTPUT_NOTE_METADATA_OFFSET # => [(note_ptr + offset), 0, 0, 0, 0] mem_loadw_le - # => [METADATA_HEADER] + # => [METADATA] end -#! Sets the output note's metadata header. +#! Sets the output note's metadata. #! -#! Inputs: [note_ptr, METADATA_HEADER] -#! Outputs: [METADATA_HEADER] +#! Inputs: [note_ptr, METADATA] +#! Outputs: [METADATA] #! #! Where: -#! - METADATA_HEADER is the note metadata header. +#! - METADATA is the note metadata. #! - note_ptr is the memory address at which the output note data begins. -pub proc set_output_note_metadata_header - add.OUTPUT_NOTE_METADATA_HEADER_OFFSET +pub proc set_output_note_metadata + add.OUTPUT_NOTE_METADATA_OFFSET mem_storew_le end -#! Sets the output note's attachment kind and scheme in the metadata header. +#! Returns the output note's attachment commitment at the given attachment index. +#! +#! This is the commitment to the raw attachment data in the advice inputs. +#! +#! WARNING: Does not check the attachment_idx is within bounds. +#! +#! Inputs: [note_ptr, attachment_idx] +#! Outputs: [ATTACHMENT_COMMITMENT] +#! +#! Where: +#! - note_ptr is the memory address at which the output note data begins. +#! - attachment_idx is the index of the attachment in the note. +#! - ATTACHMENT_COMMITMENT is the note attachment commitment. +pub proc get_output_note_attachment_commitment + add.OUTPUT_NOTE_ATTACHMENT_0_OFFSET + # => [note_ptr + attachment_0_offset, attachment_idx] + + swap mul.WORD_NUM_ELEMENTS add + # => [attachment_ptr] + + padw movup.4 mem_loadw_le + # => [ATTACHMENT_COMMITMENT] +end + +#! Returns a pointer to the start of the attachment data region for the output note. +#! +#! Inputs: [note_ptr] +#! Outputs: [attachment_data_ptr] +#! +#! Where: +#! - note_ptr is the memory address at which the output note data begins. +#! - attachment_data_ptr is the memory address of the first attachment slot. +pub proc get_output_note_attachment_commitment_ptr + add.OUTPUT_NOTE_ATTACHMENT_0_OFFSET +end + +#! Sets the output note's attachment at the given slot index. #! -#! Inputs: [note_ptr, attachment_kind_scheme] +#! Inputs: [note_ptr, attachment_idx, ATTACHMENT_COMMITMENT] #! Outputs: [] #! #! Where: -#! - attachment_kind_scheme is the type information of the attachment that will be overwritten. #! - note_ptr is the memory address at which the output note data begins. -pub proc set_output_note_attachment_kind_scheme - add.OUTPUT_NOTE_METADATA_ATTACHMENT_KIND_SCHEME_OFFSET - mem_store +#! - attachment_idx is the index of the attachment slot (0..3). +#! - ATTACHMENT_COMMITMENT is the note attachment word. +pub proc set_output_note_attachment_commitment + add.OUTPUT_NOTE_ATTACHMENT_0_OFFSET + # => [note_ptr + base_offset, attachment_idx, ATTACHMENT_COMMITMENT] + + swap mul.WORD_NUM_ELEMENTS add + # => [attachment_ptr, ATTACHMENT_COMMITMENT] + + mem_storew_le dropw + # => [] end -#! Returns the output note's attachment. +#! Returns the number of attachments for the output note. #! #! Inputs: [note_ptr] -#! Outputs: [ATTACHMENT] +#! Outputs: [num_attachments] #! #! Where: -#! - ATTACHMENT is the note attachment. #! - note_ptr is the memory address at which the output note data begins. -pub proc get_output_note_attachment - padw - movup.4 add.OUTPUT_NOTE_ATTACHMENT_OFFSET - mem_loadw_le - # => [ATTACHMENT] +#! - num_attachments is the number of attachments in the output note. +pub proc get_output_note_num_attachments + add.OUTPUT_NOTE_NUM_ATTACHMENTS_OFFSET mem_load end -#! Sets the output note's attachment. +#! Sets the number of attachments for the output note. #! -#! Inputs: [note_ptr, ATTACHMENT] +#! Inputs: [note_ptr, num_attachments] #! Outputs: [] #! #! Where: -#! - ATTACHMENT is the note attachment. #! - note_ptr is the memory address at which the output note data begins. -pub proc set_output_note_attachment - add.OUTPUT_NOTE_ATTACHMENT_OFFSET - mem_storew_le - dropw +#! - num_attachments is the number of attachments in the output note. +pub proc set_output_note_num_attachments + add.OUTPUT_NOTE_NUM_ATTACHMENTS_OFFSET mem_store +end + +#! Returns the total number of attachment words for the output note. +#! +#! Inputs: [note_ptr] +#! Outputs: [total_num_attachment_words] +#! +#! Where: +#! - note_ptr is the memory address at which the output note data begins. +#! - total_num_attachment_words is the total number of words across all attachments. +pub proc get_output_note_total_attachment_words + add.OUTPUT_NOTE_TOTAL_ATTACHMENT_WORDS_OFFSET mem_load +end + +#! Sets the total number of attachment words for the output note. +#! +#! Inputs: [note_ptr, total_num_attachment_words] +#! Outputs: [] +#! +#! Where: +#! - note_ptr is the memory address at which the output note data begins. +#! - total_num_attachment_words is the total number of words across all attachments. +pub proc set_output_note_total_attachment_words + add.OUTPUT_NOTE_TOTAL_ATTACHMENT_WORDS_OFFSET mem_store end #! Returns the number of assets in the output note. @@ -2012,7 +1951,7 @@ pub proc set_output_note_num_assets # => [note_ptr + offset, num_assets] # check note number of assets limit - dup.1 push.MAX_ASSETS_PER_NOTE lt assert.err=ERR_NOTE_NUM_OF_ASSETS_EXCEED_LIMIT + dup.1 lte.MAX_ASSETS_PER_NOTE assert.err=ERR_NOTE_NUM_OF_ASSETS_EXCEED_LIMIT mem_store end @@ -2130,45 +2069,9 @@ pub proc get_num_kernel_procedures mem_load.NUM_KERNEL_PROCEDURES_PTR end -#! Returns a pointer to the memory where hashes of the kernel procedures are stored. -#! -#! Inputs: [] -#! Outputs: [kernel_procedures_ptr] -#! -#! Where: -#! - kernel_procedures_ptr is the memory address where the hashes of the kernel procedures are -#! stored. -pub proc get_kernel_procedures_ptr - push.KERNEL_PROCEDURES_PTR -end - # LINK MAP # ------------------------------------------------------------------------------------------------- -#! Returns the link map memory start ptr constant. -#! -#! Inputs: [] -#! Outputs: [start_ptr] -pub proc get_link_map_region_start_ptr - push.LINK_MAP_REGION_START_PTR -end - -#! Returns the link map memory end ptr constant. -#! -#! Inputs: [] -#! Outputs: [end_ptr] -pub proc get_link_map_region_end_ptr - push.LINK_MAP_REGION_END_PTR -end - -#! Returns the link map entry size constant. -#! -#! Inputs: [] -#! Outputs: [entry_size] -pub proc get_link_map_entry_size - push.LINK_MAP_ENTRY_SIZE -end - #! Returns the next pointer to an empty link map entry. #! #! Inputs: [] diff --git a/crates/miden-protocol/asm/kernels/transaction/lib/non_fungible_asset.masm b/crates/miden-protocol/asm/kernels/transaction/lib/non_fungible_asset.masm index 9f825ce733..935f596d72 100644 --- a/crates/miden-protocol/asm/kernels/transaction/lib/non_fungible_asset.masm +++ b/crates/miden-protocol/asm/kernels/transaction/lib/non_fungible_asset.masm @@ -4,7 +4,7 @@ use $kernel::asset # ERRORS # ================================================================================================= -const ERR_NON_FUNGIBLE_ASSET_KEY_ACCOUNT_ID_MUST_BE_NON_FUNGIBLE = "non-fungible asset vault key's account ID must be of type non-fungible faucet" +const ERR_NON_FUNGIBLE_ASSET_KEY_COMPOSITION_MUST_BE_NON_FUNGIBLE = "non-fungible asset vault key's composition must be non-fungible" const ERR_NON_FUNGIBLE_ASSET_FAUCET_IS_NOT_ORIGIN="the origin of the non-fungible asset is not this faucet" @@ -52,13 +52,13 @@ end #! Panics if: #! - the asset key's account ID is not valid. #! - the asset key's metadata is not valid. -#! - the asset key's faucet ID is not a non-fungible one. +#! - the asset key's composition is not non-fungible. pub proc validate_key exec.asset::validate_issuer # => [ASSET_KEY] exec.asset::is_non_fungible_asset_key - assert.err=ERR_NON_FUNGIBLE_ASSET_KEY_ACCOUNT_ID_MUST_BE_NON_FUNGIBLE + assert.err=ERR_NON_FUNGIBLE_ASSET_KEY_COMPOSITION_MUST_BE_NON_FUNGIBLE # => [ASSET_KEY] end diff --git a/crates/miden-protocol/asm/kernels/transaction/lib/note.masm b/crates/miden-protocol/asm/kernels/transaction/lib/note.masm index a2fa70b19b..448f3a66ae 100644 --- a/crates/miden-protocol/asm/kernels/transaction/lib/note.masm +++ b/crates/miden-protocol/asm/kernels/transaction/lib/note.masm @@ -2,19 +2,27 @@ use miden::core::crypto::hashes::poseidon2 use $kernel::asset::ASSET_SIZE use $kernel::constants::NOTE_MEM_SIZE +use $kernel::constants::WORD_NUM_ELEMENTS use $kernel::memory +pub use $kernel::util::note::NOTE_TYPE_PUBLIC +pub use $kernel::util::note::NOTE_TYPE_PRIVATE +pub use $kernel::util::note::MAX_ATTACHMENT_SCHEME +pub use $kernel::util::note::MAX_ATTACHMENT_WORDS +pub use $kernel::util::note::MAX_ATTACHMENT_TOTAL_WORDS +pub use $kernel::util::note::ATTACHMENT_SCHEME_NONE + # ERRORS # ================================================================================================= -const ERR_NOTE_NUM_OF_ASSETS_EXCEED_LIMIT="number of assets in a note exceed 255" +const ERR_NOTE_NUM_OF_ASSETS_EXCEED_LIMIT="number of assets in a note exceeds 64" # CONSTANTS # ================================================================================================= # The diff between the memory address after first mem_stream operation and the next target when # generating the output notes commitment. Must be NOTE_MEM_SIZE - 8; -const OUTPUT_NOTE_HASHING_MEM_DIFF=2040 +const OUTPUT_NOTE_HASHING_MEM_DIFF=1016 # ACTIVE NOTE PROCEDURES # ================================================================================================= @@ -78,6 +86,34 @@ end # OUTPUT NOTE PROCEDURES # ================================================================================================= +#! Computes the commitment to the output note's attachments. +#! +#! The commitment is defined as: +#! - 0 attachments: EMPTY_WORD +#! - 1+ attachments: hash(ATTACHMENT_0_COMMITMENT || ... || ATTACHMENT_N_COMMITMENT) +#! i.e., the sequential hash over the individual attachment commitments. +#! +#! Inputs: [note_ptr] +#! Outputs: [ATTACHMENTS_COMMITMENT] +#! +#! Where: +#! - note_ptr is a pointer to the data section of the output note. +#! - ATTACHMENTS_COMMITMENT is the commitment of the note's attachments. +pub proc compute_attachments_commitment + dup exec.memory::get_output_note_num_attachments + # => [num_attachments, note_ptr] + + # end_ptr = attachment_data_ptr + num_attachments * WORD_NUM_ELEMENTS + swap exec.memory::get_output_note_attachment_commitment_ptr + # => [start_ptr, num_attachments] + + swap mul.WORD_NUM_ELEMENTS dup.1 add swap + # => [start_ptr, end_ptr] + + exec.poseidon2::hash_words + # => [ATTACHMENTS_COMMITMENT] +end + #! Computes the assets commitment of the output note located at the specified memory address. #! #! The hash is computed as a sequential hash of the assets contained in the note. If there is an @@ -131,21 +167,22 @@ pub proc compute_output_note_assets_commitment # => [ASSETS_COMMITMENT] end -#! Computes the ID of an output note located at the specified memory address. +#! Computes the note details commitment for an output note located at the specified memory address +#! and saves it to memory. #! -#! The note ID is computed as follows: +#! The commitment is computed as follows: #! - we define, recipient = #! hash(hash(hash(serial_num, [0; 4]), script_root), storage_commitment) -#! - we then compute the output note ID as: +#! - we then compute the output note details commitment as: #! hash(recipient, assets_commitment) #! #! Inputs: [note_data_ptr] -#! Outputs: [NOTE_ID] +#! Outputs: [NOTE_DETAILS_COMMITMENT] #! #! Where: #! - note_data_ptr is a pointer to the data section of the output note. -#! - NOTE_ID is the ID of the output note located at note_data_ptr. -proc compute_output_note_id +#! - NOTE_DETAILS_COMMITMENT is the commitment to the note's details. +proc compute_output_note_details_commitment # compute assets commitment dup exec.compute_output_note_assets_commitment # => [ASSETS_COMMITMENT, note_data_ptr] @@ -153,17 +190,17 @@ proc compute_output_note_id dup.4 exec.memory::get_output_note_recipient # => [RECIPIENT, ASSETS_COMMITMENT, note_data_ptr] - # compute output note ID + # compute note details commitment exec.poseidon2::merge - # => [NOTE_ID, note_data_ptr] + # => [NOTE_DETAILS_COMMITMENT, note_data_ptr] - # save the output note commitment (note ID) to memory + # save the output note details commitment to memory movup.4 mem_storew_le - # => [NOTE_ID] + # => [NOTE_DETAILS_COMMITMENT] end #! Computes a commitment to the output notes. This is computed as a sequential hash of -#! (note_id, note_metadata) tuples. +#! (note_details_commitment, note_metadata_commitment) tuples. #! #! Inputs: [] #! Outputs: [OUTPUT_NOTES_COMMITMENT] @@ -192,29 +229,31 @@ pub proc compute_output_notes_commitment dup.12 exec.memory::get_output_note_ptr # => [current_note_ptr, RATE0, RATE1, CAPACITY, current_index, num_notes] - # compute and save output note ID to memory (this also computes the note's asset commitment) - dup exec.compute_output_note_id - # => [NOTE_ID, current_note_ptr, RATE0, RATE1, CAPACITY, current_index, num_notes] + # compute and save output note details commitment to memory (this also computes the note's + # asset commitment) + dup exec.compute_output_note_details_commitment + # => [NOTE_DETAILS_COMMITMENT, current_note_ptr, RATE0, RATE1, CAPACITY, current_index, num_notes] - dup.4 exec.memory::get_output_note_attachment - # => [NOTE_ATTACHMENT, NOTE_ID, current_note_ptr, RATE0, RATE1, CAPACITY, current_index, num_notes] + # compute attachments commitment + dup.4 exec.compute_attachments_commitment + # => [ATTACHMENTS_COMMITMENT, NOTE_DETAILS_COMMITMENT, current_note_ptr, RATE0, RATE1, CAPACITY, current_index, num_notes] - movup.8 exec.memory::get_output_note_metadata_header - # => [NOTE_METADATA_HEADER, NOTE_ATTACHMENT, NOTE_ID, RATE0, RATE1, CAPACITY, current_index, num_notes] + movup.8 exec.memory::get_output_note_metadata + # => [NOTE_METADATA, ATTACHMENTS_COMMITMENT, NOTE_DETAILS_COMMITMENT, RATE0, RATE1, CAPACITY, current_index, num_notes] - # compute hash(NOTE_METADATA_HEADER || NOTE_ATTACHMENT) + # compute hash(NOTE_METADATA || ATTACHMENTS_COMMITMENT) exec.poseidon2::merge - # => [NOTE_METADATA_COMMITMENT, NOTE_ID, RATE0, RATE1, CAPACITY, current_index, num_notes] + # => [NOTE_METADATA_COMMITMENT, NOTE_DETAILS_COMMITMENT, RATE0, RATE1, CAPACITY, current_index, num_notes] - # replace rate words with note ID and metadata commitment + # replace rate words with note details commitment and metadata commitment swapdw dropw dropw - # => [NOTE_METADATA_COMMITMENT, NOTE_ID, CAPACITY, current_index, num_notes] + # => [NOTE_METADATA_COMMITMENT, NOTE_DETAILS_COMMITMENT, CAPACITY, current_index, num_notes] - # move note ID to the top of the stack + # move note details commitment to the top of the stack swapw - # => [NOTE_ID, NOTE_METADATA_COMMITMENT, CAPACITY, current_index, num_notes] + # => [NOTE_DETAILS_COMMITMENT, NOTE_METADATA_COMMITMENT, CAPACITY, current_index, num_notes] - # permute over (NOTE_ID, NOTE_METADATA_COMMITMENT) + # permute over (NOTE_DETAILS_COMMITMENT, NOTE_METADATA_COMMITMENT) exec.poseidon2::permute # => [RATE0, RATE1, CAPACITY, current_index, num_notes] diff --git a/crates/miden-protocol/asm/kernels/transaction/lib/output_note.masm b/crates/miden-protocol/asm/kernels/transaction/lib/output_note.masm index c8a80d5f06..3b65ae9516 100644 --- a/crates/miden-protocol/asm/kernels/transaction/lib/output_note.masm +++ b/crates/miden-protocol/asm/kernels/transaction/lib/output_note.masm @@ -4,28 +4,32 @@ use $kernel::callbacks use $kernel::fungible_asset use $kernel::memory use $kernel::note +use $kernel::note::NOTE_TYPE_PUBLIC +use $kernel::note::MAX_ATTACHMENT_SCHEME +use $kernel::note::MAX_ATTACHMENT_WORDS +use $kernel::note::MAX_ATTACHMENT_TOTAL_WORDS use $kernel::constants::MAX_OUTPUT_NOTES_PER_TX -use $kernel::util::note::ATTACHMENT_KIND_NONE -use $kernel::util::note::ATTACHMENT_KIND_ARRAY +use $kernel::constants::WORD_NUM_ELEMENTS +use $kernel::util::note::NOTE_METADATA_VERSION_1 use $kernel::asset::ASSET_SIZE use $kernel::asset::ASSET_VALUE_MEMORY_OFFSET use miden::core::word +use miden::core::mem -# CONSTANTS +# CONSTANTS # ================================================================================================= -# Constants for different note types -const PUBLIC_NOTE=1 # 0b01 -const PRIVATE_NOTE=2 # 0b10 +# The maximum number of attachments per note. +const MAX_ATTACHMENTS_PER_NOTE=4 -# The default value of the felt at index 3 in the note metadata header when a new note is created. -# All zeros sets the attachment kind to None and the user-defined attachment scheme to "none". -const ATTACHMENT_DEFAULT_KIND_AND_SCHEME=0 +# The default value of felt[3] in the metadata when a new note is created. +# All zeros means no attachment schemes are set. +const ATTACHMENT_DEFAULT_SCHEMES=0 -#! The default attachment scheme, representing the absence of an attachment scheme. -const ATTACHMENT_SCHEME_NONE=0 +#! The attachment scheme value that is reserved to represent an absent attachment. +const ATTACHMENT_RESERVED_SCHEME = 0 -# ERRORS +# ERRORS # ================================================================================================= const ERR_TX_NUMBER_OF_OUTPUT_NOTES_EXCEEDS_LIMIT="number of output notes in the transaction exceeds the maximum limit of 1024" @@ -34,15 +38,23 @@ const ERR_NOTE_INVALID_TYPE="invalid note type" const ERR_OUTPUT_NOTE_INDEX_OUT_OF_BOUNDS="requested output note index should be less than the total number of created output notes" -const ERR_OUTPUT_NOTE_INVALID_ATTACHMENT_SCHEMES="attachment scheme and attachment kind must fit into u32s" +const ERR_OUTPUT_NOTE_ATTACHMENT_SCHEME_MAX_EXCEEDED="attachment scheme must not exceed 65534" -const ERR_OUTPUT_NOTE_UNKNOWN_ATTACHMENT_KIND="attachment kind variant must be between 0 and 2" +const ERR_OUTPUT_NOTE_ATTACHMENT_SCHEME_CANNOT_BE_ZERO = "attachment scheme must not be 0" -const ERR_OUTPUT_NOTE_ATTACHMENT_KIND_NONE_MUST_HAVE_ATTACHMENT_SCHEME_NONE="attachment kind none must have attachment scheme none" +const ERR_OUTPUT_NOTE_ATTACHMENT_SIZE_MAX_EXCEEDED="attachment num_words must not exceed 256" -const ERR_OUTPUT_NOTE_ATTACHMENT_KIND_NONE_MUST_BE_EMPTY_WORD="attachment kind None requires ATTACHMENT to be set to an empty word" +const ERR_OUTPUT_NOTE_ATTACHMENT_SIZE_MUST_BE_MULTIPLE_OF_WORD_SIZE="number of elements in an attachment must be a multiple of 4" -const ERR_NOTE_INVALID_INDEX="failed to find note at the given index; index must be within [0, num_of_notes]" +const ERR_OUTPUT_NOTE_ATTACHMENT_SIZE_CANNOT_BE_ZERO="attachment num_words cannot be zero" + +const ERR_OUTPUT_NOTE_TOO_MANY_ATTACHMENTS="number of attachments per note cannot exceed 4" + +const ERR_OUTPUT_NOTE_TOTAL_ATTACHMENT_WORDS_EXCEEDED="total number of attachment words per note cannot exceed 512" + +const ERR_NOTE_FUNGIBLE_MAX_AMOUNT_EXCEEDED="adding a fungible asset to a note cannot exceed the max_amount of 9223372036854775807" + +const ERR_OUTPUT_NOTE_ATTACHMENT_COMMITMENT_MISMATCH="the computed hash of fetched attachment elements does not match the provided commitment" const ERR_NON_FUNGIBLE_ASSET_ALREADY_EXISTS="non-fungible asset that already exists in the note cannot be added again" @@ -56,19 +68,21 @@ const NOTE_BEFORE_CREATED_EVENT=event("miden::protocol::note::before_created") # Event emitted after a new note is created. const NOTE_AFTER_CREATED_EVENT=event("miden::protocol::note::after_created") -# Event emitted before an asset is added to a note +# Event emitted before an asset is added to a note. const NOTE_BEFORE_ADD_ASSET_EVENT=event("miden::protocol::note::before_add_asset") -# Event emitted after an asset is added to a note +# Event emitted after an asset is added to a note. const NOTE_AFTER_ADD_ASSET_EVENT=event("miden::protocol::note::after_add_asset") -# Event emitted before an ATTACHMENT is added to a note -const NOTE_BEFORE_SET_ATTACHMENT_EVENT=event("miden::protocol::note::before_set_attachment") +# Event emitted before an attachment is added to a note. +const NOTE_BEFORE_ADD_ATTACHMENT_EVENT=event("miden::protocol::note::before_add_attachment") # OUTPUT NOTE PROCEDURES # ================================================================================================= #! Creates a new note and returns the index of the note. #! +#! All attachments are by default set to empty words and the number of attachments is 0. +#! #! Inputs: [tag, note_type, RECIPIENT] #! Outputs: [note_idx] #! @@ -87,36 +101,28 @@ pub proc create emit.NOTE_BEFORE_CREATED_EVENT # => [tag, note_type, RECIPIENT] - exec.build_metadata_header - # => [NOTE_METADATA_HEADER, RECIPIENT] + exec.build_metadata + # => [NOTE_METADATA, RECIPIENT] # get the index for the next note to be created and increment counter exec.increment_num_output_notes dup movdn.9 - # => [note_idx, NOTE_METADATA_HEADER, RECIPIENT, note_idx] + # => [note_idx, NOTE_METADATA, RECIPIENT, note_idx] # get a pointer to the memory address at which the note will be stored exec.memory::get_output_note_ptr - # => [note_ptr, NOTE_METADATA_HEADER, RECIPIENT, note_idx] + # => [note_ptr, NOTE_METADATA, RECIPIENT, note_idx] movdn.4 - # => [NOTE_METADATA_HEADER, note_ptr, RECIPIENT, note_idx] + # => [NOTE_METADATA, note_ptr, RECIPIENT, note_idx] - # emit event to signal that a new note is created + # emit event to signal that a new note is created emit.NOTE_AFTER_CREATED_EVENT # set the metadata for the output note dup.4 - # => [note_ptr, NOTE_METADATA_HEADER, note_ptr, RECIPIENT, note_idx] + # => [note_ptr, NOTE_METADATA, note_ptr, RECIPIENT, note_idx] - exec.memory::set_output_note_metadata_header dropw - # => [note_ptr, RECIPIENT, note_idx] - - # set the attachment value of a new note to an empty word - # note that the attachment kind is set to None by build_metadata_header - padw dup.4 - # => [note_ptr, EMPTY_WORD, note_ptr, RECIPIENT, note_idx] - - exec.memory::set_output_note_attachment + exec.memory::set_output_note_metadata dropw # => [note_ptr, RECIPIENT, note_idx] # set the RECIPIENT for the output note @@ -177,6 +183,41 @@ pub proc get_assets_info # => [ASSETS_COMMITMENT, num_assets] end +#! Returns the commitment over all attachments in the note with the provided index. +#! +#! Inputs: [note_index] +#! Outputs: [ATTACHMENTS_COMMITMENT] +#! +#! Where: +#! - note_index is the index of the output note whose attachments commitment should be returned. +#! - ATTACHMENTS_COMMITMENT is the commitment to all attachments of the note. +pub proc get_attachments_commitment + # get the note data pointer based on the index of the requested note + exec.memory::get_output_note_ptr + # => [note_ptr] + + dup exec.memory::get_output_note_num_attachments + # => [num_attachments, note_ptr] + + dup.1 exec.memory::get_output_note_attachment_commitment_ptr + # => [start_ptr, num_attachments, note_ptr] + + # compute start_ptr and end_ptr for the attachment commitments in memory + dup movup.2 mul.WORD_NUM_ELEMENTS add swap + # => [start_ptr, end_ptr, note_ptr] + + movup.2 exec.note::compute_attachments_commitment + # => [ATTACHMENTS_COMMITMENT, start_ptr, end_ptr] + + # store the attachment commitments to the advice map using ATTACHMENTS_COMMITMENT as a key + adv.insert_mem + # => [ATTACHMENTS_COMMITMENT, start_ptr, end_ptr] + + # remove pointers from the stack + movup.4 drop movup.4 drop + # => [ATTACHMENTS_COMMITMENT] +end + #! Adds the asset to the note specified by the index. #! #! Inputs: [ASSET_KEY, ASSET_VALUE, note_idx] @@ -188,14 +229,13 @@ end #! - note_idx is the index of the note to which the asset is added. #! #! Panics if: -#! - the note index points to a non-existent output note. #! - the asset key or value are malformed (e.g., invalid faucet ID). #! - the max amount of fungible assets is exceeded. #! - the non-fungible asset already exists in the note. -#! - the total number of ASSETs exceeds the maximum of 256. +#! - the total number of ASSETs exceeds the maximum of 64. pub proc add_asset - # check if the note exists, it must be within [0, num_of_notes] - dup.8 exec.memory::get_num_output_notes lte assert.err=ERR_NOTE_INVALID_INDEX + # reject assets with a Custom composition; only None and Fungible are supported today + exec.asset::assert_supported_composition # => [ASSET_KEY, ASSET_VALUE, note_idx] # validate the asset @@ -238,48 +278,98 @@ pub proc add_asset # => [] end -#! Sets the attachment of the note specified by the index. +#! Adds an attachment to the note specified by the index. Attachments are append-only. #! -#! Inputs: [note_idx, attachment_scheme, attachment_kind, ATTACHMENT] +#! The attachment elements are fetched from the advice map using ATTACHMENT_COMMITMENT as the key. +#! The number of words (num_words) is derived from the element count and the commitment is verified +#! by hashing the fetched elements. +#! +#! Inputs: +#! Operand Stack: [attachment_scheme, ATTACHMENT_COMMITMENT, note_idx] +#! Advice map: { +#! ATTACHMENT_COMMITMENT: [[ATTACHMENT_ELEMENTS]], +#! } #! Outputs: [] #! #! Where: -#! - note_idx is the index of the note on which the attachment is set. #! - attachment_scheme is the user-defined scheme of the attachment. -#! - attachment_kind is the kind of the attachment content. -#! - ATTACHMENT is the attachment to be set. +#! - ATTACHMENT_COMMITMENT is the hash commitment to the attachment elements. +#! - note_idx is the index of the note to which the attachment is added. #! #! Panics if: -#! - the note index points to a non-existent output note. -#! - the attachment kind or scheme does not fit into a u32. -#! - the attachment kind is an unknown variant. -pub proc set_attachment - dup exec.memory::get_num_output_notes lte assert.err=ERR_NOTE_INVALID_INDEX - # => [note_idx, attachment_scheme, attachment_kind, ATTACHMENT] +#! - the attachment scheme is 0 or exceeds 65534. +#! - the number of elements is not a multiple of 4, or num_words is zero or exceeds 256. +#! - the computed hash of fetched attachment elements does not match ATTACHMENT_COMMITMENT. +#! - the note already has 4 attachments. +pub proc add_attachment + # validate attachment_scheme does not exceed max + dup u32assert.err=ERR_OUTPUT_NOTE_ATTACHMENT_SCHEME_MAX_EXCEEDED + u32lte.MAX_ATTACHMENT_SCHEME assert.err=ERR_OUTPUT_NOTE_ATTACHMENT_SCHEME_MAX_EXCEEDED + # => [attachment_scheme, ATTACHMENT_COMMITMENT, note_idx] + + # assert the scheme is not 0 which the kernel uses to represent absent attachments + dup neq.ATTACHMENT_RESERVED_SCHEME assert.err=ERR_OUTPUT_NOTE_ATTACHMENT_SCHEME_CANNOT_BE_ZERO + # => [attachment_scheme, ATTACHMENT_COMMITMENT, note_idx] + + movdn.4 + # => [ATTACHMENT_COMMITMENT, attachment_scheme, note_idx] + + # validate preimage for commitment is available and number of committed words is within limits + dupw exec.validate_attachment + # => [num_words, ATTACHMENT_COMMITMENT, attachment_scheme, note_idx] + + movup.5 + # => [attachment_scheme, num_words, ATTACHMENT_COMMITMENT, note_idx] - exec.memory::get_output_note_ptr dup - # => [note_ptr, note_ptr, attachment_scheme, attachment_kind, ATTACHMENT] + # get note_ptr from note_idx + movup.6 exec.memory::get_output_note_ptr + # => [note_ptr, attachment_scheme, num_words, ATTACHMENT_COMMITMENT] - dupw.1 - # => [ATTACHMENT, note_ptr, note_ptr, attachment_scheme, attachment_kind, ATTACHMENT] + # update and validate total attachment words + dup exec.memory::get_output_note_total_attachment_words + # => [total_num_words, note_ptr, attachment_scheme, num_words, ATTACHMENT_COMMITMENT] - dup.7 dup.7 - # => [attachment_scheme, attachment_kind, ATTACHMENT, note_ptr, note_ptr, - # attachment_scheme, attachment_kind, ATTACHMENT] + movup.3 add + # => [new_total_num_words, note_ptr, attachment_scheme, ATTACHMENT_COMMITMENT] + + dup u32lte.MAX_ATTACHMENT_TOTAL_WORDS assert.err=ERR_OUTPUT_NOTE_TOTAL_ATTACHMENT_WORDS_EXCEEDED + # => [new_total_num_words, note_ptr, attachment_scheme, ATTACHMENT_COMMITMENT] + + dup.1 exec.memory::set_output_note_total_attachment_words + # => [note_ptr, attachment_scheme, ATTACHMENT_COMMITMENT] + + # validate current number of attachments < 4 + dup exec.memory::get_output_note_num_attachments + # => [num_attachments, note_ptr, attachment_scheme, ATTACHMENT_COMMITMENT] + + dup lt.MAX_ATTACHMENTS_PER_NOTE assert.err=ERR_OUTPUT_NOTE_TOO_MANY_ATTACHMENTS + # => [num_attachments, note_ptr, attachment_scheme, ATTACHMENT_COMMITMENT] - exec.validate_attachment - # => [note_ptr, note_ptr, attachment_scheme, attachment_kind, ATTACHMENT] + # emit event + emit.NOTE_BEFORE_ADD_ATTACHMENT_EVENT + # => [num_attachments, note_ptr, attachment_scheme, ATTACHMENT_COMMITMENT] - movdn.3 movdn.3 - # => [attachment_scheme, attachment_kind, note_ptr, note_ptr, ATTACHMENT] + # --- Add attachment in output note memory --- - emit.NOTE_BEFORE_SET_ATTACHMENT_EVENT - # => [attachment_scheme, attachment_kind, note_ptr, note_ptr, ATTACHMENT] + movup.6 movup.6 movup.6 movup.6 + # => [ATTACHMENT_COMMITMENT, num_attachments, note_ptr, attachment_scheme] - exec.set_attachment_kind_scheme - # => [note_ptr, ATTACHMENT] + # use attachment_idx = num_attachments + dup.4 dup.6 + # => [note_ptr, attachment_idx, ATTACHMENT_COMMITMENT, num_attachments, note_ptr, attachment_scheme] - exec.memory::set_output_note_attachment + # store commitment in note memory + exec.memory::set_output_note_attachment_commitment + # => [num_attachments, note_ptr, attachment_scheme] + + # increment number of attachments + dup add.1 + # => [new_num_attachments, num_attachments, note_ptr, attachment_scheme] + + dup.2 exec.memory::set_output_note_num_attachments + # => [num_attachments, note_ptr, attachment_scheme] + + exec.set_attachment_schemes # => [] end @@ -297,141 +387,173 @@ pub proc assert_note_index_in_bounds # => [note_index] end -# HELPER PROCEDURES +# HELPER PROCEDURES # ================================================================================================= -#! Builds the provided inputs into the NOTE_METADATA_HEADER word. +#! Validates the attachment commitment against the advice data. #! -#! - The sender ID is set to the native account's ID. -#! - The attachment scheme is set to 0 (meaning none by convention) and the attachment content -#! type is set to None. +#! Fetches the attachment elements from the advice map using ATTACHMENT_COMMITMENT as the key, +#! derives num_words from the element count, pipes the elements to local memory to compute the +#! commitment, and asserts the computed commitment matches the provided commitment. #! -#! Note that this procedure is only exported so it can be tested. It should not be called from -#! non-test code. +#! Inputs: +#! Operand Stack: [ATTACHMENT_COMMITMENT] +#! Advice map: { +#! ATTACHMENT_COMMITMENT: [[ATTACHMENT_ELEMENTS]], +#! } +#! Outputs: [num_words] +#! +#! Panics if: +#! - the number of elements is not a multiple of 4, or num_words is zero or exceeds 256. +#! - the computed hash of fetched elements does not match ATTACHMENT_COMMITMENT. +@locals(1024) +proc validate_attachment + # push the attachment elements from the advice map onto the advice stack + adv.push_mapvaln + # OS => [ATTACHMENT_COMMITMENT] + # AS => [num_elements, [ATTACHMENT_ELEMENTS]] + + # derive num_words from num_elements + adv_push u32assert.err=ERR_OUTPUT_NOTE_ATTACHMENT_SIZE_MAX_EXCEEDED + u32divmod.WORD_NUM_ELEMENTS + # OS => [remainder, num_words, ATTACHMENT_COMMITMENT] + # AS => [[ATTACHMENT_ELEMENTS]] + + # assert the number of elements is a multiple of WORD_NUM_ELEMENTS + eq.0 assert.err=ERR_OUTPUT_NOTE_ATTACHMENT_SIZE_MUST_BE_MULTIPLE_OF_WORD_SIZE + # OS => [num_words, ATTACHMENT_COMMITMENT] + # AS => [[ATTACHMENT_ELEMENTS]] + + # validate 0 < num_words <= 256 + dup neq.0 assert.err=ERR_OUTPUT_NOTE_ATTACHMENT_SIZE_CANNOT_BE_ZERO + dup u32lte.MAX_ATTACHMENT_WORDS assert.err=ERR_OUTPUT_NOTE_ATTACHMENT_SIZE_MAX_EXCEEDED + # OS => [num_words, ATTACHMENT_COMMITMENT] + # AS => [[ATTACHMENT_ELEMENTS]] + + # --- Pipe elements from advice stack to local memory while hashing --- + + # we use local memory because pipe_preimage_to_memory needs to write to memory, but we only + # need to assert that the preimage is available, the exact content itself is unimportant + + # save num_words to return it later + dup movdn.5 + # OS => [num_words, ATTACHMENT_COMMITMENT, num_words] + # AS => [[ATTACHMENT_ELEMENTS]] + + # SAFETY: we have allocated 1024 elements of local memory which is sufficient for the largest + # possible attachment with 256 words + locaddr.0 swap + # OS => [num_words, dest_ptr, ATTACHMENT_COMMITMENT, num_words] + # AS => [[ATTACHMENT_ELEMENTS]] + + # validate the sequential hash over the attachment elements is ATTACHMENT_COMMITMENT + exec.mem::pipe_preimage_to_memory drop + # OS => [num_words] +end + +#! Builds the provided inputs into the NOTE_METADATA word. +#! +#! - The sender ID is set to the native account's ID. +#! - Attachment num_words and schemes are initialized to 0 (no attachments). #! #! Inputs: [tag, note_type] -#! Outputs: [NOTE_METADATA_HEADER] +#! Outputs: [NOTE_METADATA] #! #! Where: #! - tag is the note tag which can be used by the recipient(s) to identify notes intended for them. #! - note_type is the type of the note, which defines how the note is to be stored (e.g., on-chain #! or off-chain). -#! - NOTE_METADATA_HEADER is the metadata associated with a note. -pub proc build_metadata_header - # Validate that note type is private or public. +#! - NOTE_METADATA is the metadata associated with a note. +pub proc build_metadata + # Validate that note type is private (0) or public (1). # -------------------------------------------------------------------------------------------- - dup.1 eq.PRIVATE_NOTE dup.2 eq.PUBLIC_NOTE or assert.err=ERR_NOTE_INVALID_TYPE + dup.1 + u32assert.err=ERR_NOTE_INVALID_TYPE u32lte.NOTE_TYPE_PUBLIC + assert.err=ERR_NOTE_INVALID_TYPE # => [tag, note_type] # Validate the note tag fits into a u32. # -------------------------------------------------------------------------------------------- + # this implies the upper 32 bits are zero, which initializes the attachment num_words to 0 u32assert.err=ERR_NOTE_TAG_MUST_BE_U32 - # => [tag, note_type] + # => [attachment_num_words_and_tag, note_type] - # Merge note type and sender ID suffix. + # Merge note type, version, and sender ID suffix. # -------------------------------------------------------------------------------------------- exec.account::get_id - # => [sender_id_suffix, sender_id_prefix, tag, note_type] + # => [sender_id_suffix, sender_id_prefix, attachment_num_words_and_tag, note_type] - # the lower bits of an account ID suffix are guaranteed to be zero, so we can safely use that - # space to encode the note type - movup.3 add - # => [sender_id_suffix_and_note_type, sender_id_prefix, tag] + # The lower 8 bits of the account ID suffix are guaranteed to be zero by construction. + # Encode note_type at bit 4 and version at bits 0..=3. + # Shifting note_type left by 4 is equivalent to multiplying by 16. + movup.3 mul.16 add.NOTE_METADATA_VERSION_1 add + # => [sender_id_suffix_type_version, sender_id_prefix, tag] - # Build metadata header. + # Build metadata. # -------------------------------------------------------------------------------------------- - push.ATTACHMENT_DEFAULT_KIND_AND_SCHEME movdn.3 - # => [sender_id_suffix_and_note_type, sender_id_prefix, tag, attachment_kind_scheme] - # => [NOTE_METADATA_HEADER] + # push default absent attachment schemes (four u16 zeros encoded into a zero felt) + push.ATTACHMENT_DEFAULT_SCHEMES movdn.3 + # => [sender_id_suffix_type_version, sender_id_prefix, attachment_num_words_and_tag, attachment_schemes] + # => [NOTE_METADATA] end -#! Validate the ATTACHMENT against the attachment kind. +#! Sets an output note's attachment scheme in the note metadata. #! -#! Inputs: [attachment_scheme, attachment_kind, ATTACHMENT] +#! WARNING: The attachment scheme must be valid. +#! +#! Inputs: [num_attachments, note_ptr, attachment_scheme] #! Outputs: [] #! #! Where: +#! - num_attachments is the number of attachments the note had before this attachment was added. #! - attachment_scheme is the user-defined scheme of the attachment. -#! - attachment_kind is the kind of the attachment content. -#! - ATTACHMENT is the attachment to validate. -#! -#! Panics if: -#! - the attachment kind or scheme does not fit into a u32. -#! - the attachment kind is an unknown variant. -#! - the attachment kind is None and the ATTACHMENT is not an empty word. -proc validate_attachment - u32assert2.err=ERR_OUTPUT_NOTE_INVALID_ATTACHMENT_SCHEMES - # => [attachment_scheme, attachment_kind, ATTACHMENT] - - # assert that the attachment kind is valid - swap dup u32lte.ATTACHMENT_KIND_ARRAY - assert.err=ERR_OUTPUT_NOTE_UNKNOWN_ATTACHMENT_KIND - # => [attachment_kind, attachment_scheme, ATTACHMENT] +#! - note_ptr is the memory address at which the output note data begins. +proc set_attachment_schemes + # the schemes are stored as follows in the third felt: + # 3rd felt: [ + # attachment_3_scheme (16 bits) | attachment_2_scheme (16 bits) | + # attachment_1_scheme (16 bits) | attachment_0_scheme (16 bits) + # ] + # -> current scheme needs to be shifted left by num_attachments * 16 + + # Prepare scheme and num_words. + # -------------------------------------------------------------------------------------------- - eq.ATTACHMENT_KIND_NONE - # => [is_attachment_none, attachment_scheme, ATTACHMENT] + movup.2 swap + # => [num_attachments, attachment_scheme, note_ptr] - if.true - eq.ATTACHMENT_SCHEME_NONE - assert.err=ERR_OUTPUT_NOTE_ATTACHMENT_KIND_NONE_MUST_HAVE_ATTACHMENT_SCHEME_NONE - # => [ATTACHMENT] + # shift the scheme left by num_attachments * 16 bits using felt multiplication + # u32shl cannot be used because the shift may be >= 32 + # left shift is done with multiplication by 2^(num_attachments * 16) + mul.16 pow2 mul + # => [attachment_scheme_shifted, note_ptr] - padw assert_eqw.err=ERR_OUTPUT_NOTE_ATTACHMENT_KIND_NONE_MUST_BE_EMPTY_WORD - # => [] - else - drop dropw - # => [] - end - # => [] -end - -#! Sets an output note's attachment kind and scheme in the note metadata header. -#! -#! WARNING: The attachment scheme and kind must be valid. -#! -#! Inputs: [attachment_scheme, attachment_kind, note_ptr] -#! Outputs: [] -#! -#! Where: -#! - attachment_scheme is the user-defined type of the attachment. -#! - attachment_kind is the kind of the attachment content. -#! - note_ptr is the memory address at which the output note data begins. -proc set_attachment_kind_scheme - exec.merge_attachment_kind_and_scheme - # => [attachment_kind_scheme, note_ptr] - - swap - # => [note_ptr, attachment_kind_scheme] + # Fetch and update metadata. + # -------------------------------------------------------------------------------------------- - exec.memory::set_output_note_attachment_kind_scheme + dup.1 exec.memory::get_output_note_metadata + # => [METADATA, attachment_scheme_shifted, note_ptr] + # => [ + # [sender_id_suffix_type_version, sender_id_prefix, tag, attachment_schemes], + # attachment_scheme_shifted, note_ptr + # ] + + # merge scheme into existing schemes + # (using add instead of u32or because the shifted values exceed u32; this is safe because the + # bit ranges of the tag, existing sizes, and the new size do not overlap) + movup.3 movup.4 + add movdn.3 + # => [[sender_id_suffix_type_version, sender_id_prefix, tag, new_attachment_schemes], note_ptr] + # => [METADATA, note_ptr] + + movup.4 exec.memory::set_output_note_metadata dropw # => [] end -#! Merges the attachment kind and scheme into a single felt with the following layout: -#! -#! [30 zero bits | attachment_kind (2 bits) | attachment_scheme (32 bits)] -#! -#! WARNING: The attachment scheme and kind must be valid. -#! -#! Inputs: [attachment_scheme, attachment_kind] -#! Outputs: [attachment_kind_scheme] -#! -#! Where: -#! - attachment_scheme is the user-defined scheme of the attachment. -#! - attachment_kind is the kind of the attachment content. -#! - attachment_kind_scheme is the felt constructed from the inputs. -proc merge_attachment_kind_and_scheme - # shift the attachment_kind 32 bits to the left, which is the same as multiplying by 2^32 - # and set the lower bits to the attachment_scheme, which is done by adding the values together - swap mul.0x100000000 - add - # => [attachment_kind_scheme] -end - #! Increments the number of output notes by one. Returns the index of the next note to be created. #! #! Inputs: [] diff --git a/crates/miden-protocol/asm/kernels/transaction/lib/prologue.masm b/crates/miden-protocol/asm/kernels/transaction/lib/prologue.masm index 9381fb6359..06533bba66 100644 --- a/crates/miden-protocol/asm/kernels/transaction/lib/prologue.masm +++ b/crates/miden-protocol/asm/kernels/transaction/lib/prologue.masm @@ -14,6 +14,10 @@ use $kernel::constants::MAX_INPUT_NOTES_PER_TX use $kernel::constants::MAX_NOTE_STORAGE_ITEMS use $kernel::constants::NOTE_TREE_DEPTH use $kernel::memory +use $kernel::memory::BLOCK_DATA_SECTION_OFFSET +use $kernel::memory::INPUT_VAULT_ROOT_PTR +use $kernel::memory::KERNEL_PROCEDURES_PTR +use $kernel::memory::PARTIAL_BLOCKCHAIN_PTR # CONSTS # ================================================================================================= @@ -34,8 +38,6 @@ const ERR_PROLOGUE_GLOBAL_INPUTS_PROVIDED_DO_NOT_MATCH_BLOCK_COMMITMENT="the pro const ERR_PROLOGUE_GLOBAL_INPUTS_PROVIDED_DO_NOT_MATCH_BLOCK_NUMBER_COMMITMENT="the provided global inputs do not match the block number commitment" -const ERR_PROLOGUE_NATIVE_ASSET_ID_IS_NOT_FUNGIBLE="native asset account ID in reference block is not of type fungible faucet" - const ERR_PROLOGUE_VERIFICATION_BASE_FEE_MUST_BE_U32="verification base fee must fit into a u32" const ERR_PROLOGUE_NEW_ACCOUNT_VAULT_MUST_BE_EMPTY="new account must have an empty vault" @@ -48,7 +50,7 @@ const ERR_PROLOGUE_MISMATCH_OF_ACCOUNT_IDS_FROM_GLOBAL_INPUTS_AND_ADVICE_PROVIDE const ERR_PROLOGUE_MISMATCH_OF_REFERENCE_BLOCK_MMR_AND_NOTE_AUTHENTICATION_MMR="reference block MMR and note's authentication MMR must match" -const ERR_PROLOGUE_NUMBER_OF_NOTE_ASSETS_EXCEEDS_LIMIT="number of note assets exceeds the maximum limit of 256" +const ERR_PROLOGUE_NUMBER_OF_NOTE_ASSETS_EXCEEDS_LIMIT="number of note assets exceeds the maximum limit of 64" const ERR_PROLOGUE_PROVIDED_INPUT_ASSETS_INFO_DOES_NOT_MATCH_ITS_COMMITMENT="provided info about assets of an input does not match its commitment" @@ -124,7 +126,7 @@ proc process_kernel_data # move the number of felt elements in the [KERNEL_PROCEDURE_ROOTS] array to the stack and get # the number of procedures from it (which is essentially the number of words) - adv_push.1 div.4 + adv_push div.4 # OS => [num_kernel_procedures, TX_KERNEL_COMMITMENT] # AS => [[KERNEL_PROCEDURE_ROOTS]] @@ -134,7 +136,7 @@ proc process_kernel_data # AS => [[KERNEL_PROCEDURE_ROOTS]] # get the pointer to the memory where hashes of the kernel procedures will be stored - exec.memory::get_kernel_procedures_ptr swap + push.KERNEL_PROCEDURES_PTR swap # OS => [num_kernel_procedures, kernel_procs_ptr, TX_KERNEL_COMMITMENT] # AS => [[KERNEL_PROCEDURE_ROOTS]] @@ -170,12 +172,13 @@ end #! TX_KERNEL_COMMITMENT #! VALIDATOR_KEY_COMMITMENT, #! [block_num, version, timestamp, 0], -#! [0, verification_base_fee, native_asset_id_suffix, native_asset_id_prefix], +#! [0, verification_base_fee, fee_faucet_id_suffix, fee_faucet_id_prefix], #! [0, 0, 0, 0], #! NOTE_ROOT, #! ] #! Outputs: #! Operand stack: [] +#! Advice stack: [] #! #! Where: #! - PREV_BLOCK_COMMITMENT is the commitment to the previous block. @@ -189,13 +192,13 @@ end #! - block_num is the reference block number. #! - version is the current protocol version. #! - timestamp is the current timestamp. -#! - native_asset_id_{prefix, suffix} are the prefix and suffix felts of the faucet ID that defines -#! the native asset. +#! - fee_faucet_id_{prefix, suffix} are the prefix and suffix felts of the faucet ID that defines +#! the fee asset. #! - verification_base_fee is the base fee capturing the cost for the verification of a #! transaction. #! - NOTE_ROOT is the root of the tree with all notes created in the block. proc process_block_data - exec.memory::get_block_data_ptr + push.BLOCK_DATA_SECTION_OFFSET # => [block_data_ptr, block_num] # read block data and compute its sub commitment @@ -259,7 +262,7 @@ end #! - num_blocks is the number of blocks in the MMR. #! - PEAK_1 .. PEAK_N are the MMR peaks. proc process_chain_data - exec.memory::get_partial_blockchain_ptr dup + push.PARTIAL_BLOCKCHAIN_PTR dup # => [partial_blockchain_ptr, partial_blockchain_ptr] # save the MMR peaks to memory and verify it matches the block's CHAIN_COMMITMENT @@ -284,7 +287,7 @@ end #! for a new account. #! #! Applies the following validation to the new account: -#! - assert that the account ID is valid. +#! - assert that the account ID uses the supported version and is otherwise valid. #! - assert that the account vault is empty. #! - assert that the account nonce is set to 0. #! - read the account seed from the advice provider and assert it satisfies seed requirements. @@ -462,14 +465,14 @@ end #! created in. #! #! Inputs: -#! Operand stack: [NOTE_COMMITMENT] +#! Operand stack: [NOTE_ID] #! Advice stack: [block_num, BLOCK_SUB_COMMITMENT, NOTE_ROOT, note_index] #! Outputs: #! Operand stack: [] #! Advice stack: [] #! #! Where: -#! - NOTE_COMMITMENT is the input note's commitment computed as `hash(NOTE_ID || NOTE_METADATA_COMMITMENT)`. +#! - NOTE_ID is the ID of the input note. #! - block_num is the leaf position in the MMR chain of the block which created the input note. #! - BLOCK_SUB_COMMITMENT is the sub_commitment of the block which created the input note. #! - NOTE_ROOT is the merkle root of the notes tree containing the input note. @@ -479,14 +482,14 @@ proc authenticate_note # Load the BLOCK_COMMITMENT from the PARTIAL_BLOCKCHAIN # --------------------------------------------------------------------------------------------- - exec.memory::get_partial_blockchain_ptr adv_push.1 - # => [block_num, partial_blockchain_ptr, NOTE_COMMITMENT] + push.PARTIAL_BLOCKCHAIN_PTR adv_push + # => [block_num, partial_blockchain_ptr, NOTE_ID] exec.mmr::get - # => [BLOCK_COMMITMENT, NOTE_COMMITMENT] + # => [BLOCK_COMMITMENT, NOTE_ID] locaddr.0 - # => [mem_ptr, BLOCK_COMMITMENT, NOTE_COMMITMENT] + # => [mem_ptr, BLOCK_COMMITMENT, NOTE_ID] # Load and authenticate the NOTE_ROOT # --------------------------------------------------------------------------------------------- @@ -495,107 +498,126 @@ proc authenticate_note exec.poseidon2::init_no_padding adv_pipe exec.poseidon2::permute exec.poseidon2::squeeze_digest - # => [COMPUTED_BLOCK_COMMITMENT, mem_ptr', BLOCK_COMMITMENT, NOTE_COMMITMENT] + # => [COMPUTED_BLOCK_COMMITMENT, mem_ptr', BLOCK_COMMITMENT, NOTE_ID] # assert the computed block commitment matches movup.4 drop assert_eqw.err=ERR_PROLOGUE_MISMATCH_OF_REFERENCE_BLOCK_MMR_AND_NOTE_AUTHENTICATION_MMR - # => [NOTE_COMMITMENT] + # => [NOTE_ID] - # Authenticate the NOTE_COMMITMENT + # Authenticate the NOTE_ID # --------------------------------------------------------------------------------------------- # load the note root from memory padw loc_loadw_le.4 swapw - # => [NOTE_COMMITMENT, NOTE_ROOT] + # => [NOTE_ID, NOTE_ROOT] # load the index of the note - adv_push.1 movdn.4 - # => [NOTE_COMMITMENT, note_index, NOTE_ROOT] + adv_push movdn.4 + # => [NOTE_ID, note_index, NOTE_ROOT] # get the depth of the note tree push.NOTE_TREE_DEPTH movdn.4 - # => [NOTE_COMMITMENT, depth, note_index, NOTE_ROOT] + # => [NOTE_ID, depth, note_index, NOTE_ROOT] - # verify the note commitment + # verify the note ID mtree_verify.err=ERR_PROLOGUE_NOTE_AUTHENTICATION_FAILED - # => [NOTE_COMMITMENT, depth, note_index, NOTE_ROOT] + # => [NOTE_ID, depth, note_index, NOTE_ROOT] dropw drop drop dropw # => [] end -#! Copies the input note's details from the advice stack to memory and computes its nullifier. +#! Copies the input note's nullifier preimage from the advice stack into the note's core data +#! in memory, computes the nullifier, stores it in the nullifier memory section, and returns it +#! on the operand stack. #! #! Inputs: -#! Operand stack: [note_ptr] +#! Operand stack: [idx] #! Advice stack: [ #! SERIAL_NUMBER, #! SCRIPT_ROOT, #! STORAGE_COMMITMENT, #! ASSETS_COMMITMENT, +#! NOTE_METADATA, +#! NOTE_ATTACHMENTS_COMMITMENT, #! ] #! Outputs: #! Operand stack: [NULLIFIER] #! Advice stack: [] #! #! Where: -#! - note_ptr is the memory location for the input note. +#! - idx is the index of the input note. #! - SERIAL_NUMBER is the note's serial. #! - SCRIPT_ROOT is the note's script root. #! - STORAGE_COMMITMENT is the sequential hash of the padded note's storage. #! - ASSETS_COMMITMENT is the sequential hash of the padded note's assets. -#! - NULLIFIER is the result of -#! `hash(SERIAL_NUMBER || SCRIPT_ROOT || STORAGE_COMMITMENT || ASSETS_COMMITMENT)`. +#! - NOTE_METADATA is the note's metadata. +#! - NOTE_ATTACHMENTS_COMMITMENT is the note's attachments commitment. +#! - NULLIFIER is the nullifier of the input note. proc process_input_note_details - exec.memory::get_input_note_core_ptr - # => [note_data_ptr] + # get the input note core data pointer + dup exec.memory::get_input_note_ptr exec.memory::get_input_note_core_ptr + # => [note_data_ptr, idx] - # read input note's data and compute its digest. See `Advice stack` above for details. + # read input note's nullifier preimage from the advice stack and absorb it into the nullifier + # sponge. See `Advice stack` above for details. exec.poseidon2::init_no_padding adv_pipe exec.poseidon2::permute adv_pipe exec.poseidon2::permute + adv_pipe exec.poseidon2::permute exec.poseidon2::squeeze_digest - # => [NULLIFIER, note_data_ptr + 16] + # => [NULLIFIER, note_data_ptr + 24, idx] movup.4 drop + # => [NULLIFIER, idx] + + # save NULLIFIER to memory + movup.4 exec.memory::set_input_note_nullifier # => [NULLIFIER] end -#! Copies the note's metadata header and attachment as well as note args from the advice stack to -#! memory. +#! Copies the note args from the advice stack to memory. #! #! Notes: #! - The note's ARGS are not authenticated, these are optional arguments the user can provide when #! consuming the note. -#! - The note's metadata is authenticated, so the data is returned in the stack. The value is used -#! to compute the NOTE_COMMITMENT as `hash(NOTE_ID || NOTE_METADATA_COMMITMENT)`, where -#! `NOTE_METADATA_COMMITMENT` is `hash(NOTE_METADATA_HEADER || NOTE_METADATA_ATTACHMENT)`. The -#! NOTE_COMMITMENT is the leaf value of the block note tree contained in the block header and is -#! either verified by this kernel, or delayed to be verified by another kernel (e.g. batch or -#! block kernels). #! #! Inputs: #! Operand stack: [note_ptr] -#! Advice stack: [NOTE_ARGS, NOTE_ATTACHMENT, NOTE_METADATA_HEADER] +#! Advice stack: [NOTE_ARGS] #! Outputs: -#! Operand stack: [NOTE_METADATA_HEADER, NOTE_ATTACHMENT] -#! Advice stack: [] +#! Operand stack: [note_ptr] +#! Advice stack: [] #! #! Where: #! - note_ptr is the memory location for the input note. #! - NOTE_ARGS are the user arguments passed to the note. -#! - NOTE_METADATA_HEADER is the note's metadata header. -#! - NOTE_ATTACHMENT is the note's attachment. -proc process_note_args_and_metadata +proc process_note_args padw adv_loadw dup.4 exec.memory::set_input_note_args dropw # => [note_ptr] +end + +#! Computes the note metadata commitment from metadata word and attachments commitment. +#! +#! Inputs: [note_ptr] +#! Outputs: [NOTE_METADATA_COMMITMENT] +#! +#! Where: +#! - note_ptr is the memory location for the input note. +#! - NOTE_METADATA_COMMITMENT is the commitment to the note metadata computed as +#! `hash(NOTE_METADATA || NOTE_ATTACHMENTS_COMMITMENT)`. +proc compute_note_metadata_commitment + # load the attachments commitment onto the stack + dup exec.memory::get_input_note_attachments_commitment + # => [NOTE_ATTACHMENTS_COMMITMENT, note_ptr] - padw adv_loadw dup.4 exec.memory::set_input_note_attachment - # => [NOTE_ATTACHMENT] + # load the metadata onto the stack + movup.4 exec.memory::get_input_note_metadata + # => [NOTE_METADATA, NOTE_ATTACHMENTS_COMMITMENT] - padw adv_loadw movup.8 exec.memory::set_input_note_metadata_header - # => [NOTE_METADATA_HEADER, NOTE_ATTACHMENT, note_ptr] + exec.poseidon2::merge + # => [NOTE_METADATA_COMMITMENT] end #! Checks that the number of note storage is within limit and stores it to memory. @@ -612,7 +634,7 @@ end #! - num_storage_items is the note's number of storage items. proc process_note_num_storage_items # move the number of storage items from the advice stack to the operand stack - adv_push.1 + adv_push # => [num_storage_items, note_ptr] # validate the number of storage items @@ -642,7 +664,7 @@ proc process_note_assets # Validate num_assets and setup commitment computation. # --------------------------------------------------------------------------------------------- - adv_push.1 + adv_push # => [num_assets, note_ptr] dup lte.MAX_ASSETS_PER_NOTE @@ -688,7 +710,7 @@ proc add_input_note_assets_to_vault # prepare the stack # --------------------------------------------------------------------------------------------- - exec.memory::get_input_vault_root_ptr + push.INPUT_VAULT_ROOT_PTR # => [input_vault_root_ptr, note_ptr] dup.1 exec.memory::get_input_note_assets_ptr @@ -729,15 +751,15 @@ proc add_input_note_assets_to_vault # => [] end -#! Computes an input note's id. +#! Computes an input note's details commitment. #! #! Inputs: [note_ptr] -#! Outputs: [NOTE_ID] +#! Outputs: [NOTE_DETAILS_COMMITMENT] #! #! Where: #! - note_ptr is the memory location for the input note. -#! - NOTE_ID is the note's id, i.e. `hash(RECIPIENT || ASSETS_COMMITMENT)`. -proc compute_input_note_id +#! - NOTE_DETAILS_COMMITMENT is the commitment of the input note details. +proc compute_input_note_details_commitment # load all inputs on the stack dup exec.memory::get_input_note_assets_commitment dup.4 exec.memory::get_input_note_storage_commitment @@ -766,28 +788,28 @@ proc compute_input_note_id movup.8 exec.memory::set_input_note_recipient # => [RECIPIENT, ASSETS_COMMITMENT, note_ptr] - # compute NOTE_ID: hash(RECIPIENT || ASSETS_COMMITMENT) + # compute NOTE_DETAILS_COMMITMENT: hash(RECIPIENT || ASSETS_COMMITMENT) exec.poseidon2::merge - # => [NOTE_ID] + # => [NOTE_DETAILS_COMMITMENT] end #! Reads data for the input note from the advice provider and stores it in memory at the appropriate #! memory address. #! -#! This procedures will also compute the note's nullifier. Store the note's nullifier, metadata, -#! args, assets, id, and hash to memory. And return the hasher state in the stack so that the -#! commitment can be extracted. +#! This procedure also computes the note's nullifier and ID. Stores the note's nullifier, metadata, +#! args, assets, and note details commitment to memory, and returns the hasher state in the stack so +#! that the input notes commitment can be extracted. #! #! Inputs: #! Operand stack: [idx, CAPACITY] #! Advice stack: [ +#! NOTE_ARGS, #! SERIAL_NUMBER, #! SCRIPT_ROOT, #! STORAGE_COMMITMENT, #! ASSETS_COMMITMENT, -#! NOTE_ARGS, -#! NOTE_ATTACHMENT, -#! NOTE_METADATA_HEADER, +#! NOTE_METADATA, +#! NOTE_ATTACHMENTS_COMMITMENT, #! num_assets, #! ASSET_0, ..., ASSET_N, #! is_authenticated, @@ -795,6 +817,7 @@ end #! block_num, #! BLOCK_SUB_COMMITMENT, #! NOTE_ROOT, +#! note_index, #! )? #! ] #! Outputs: @@ -808,8 +831,8 @@ end #! - SCRIPT_ROOT is the note's script root. #! - STORAGE_COMMITMENT is the sequential hash of the padded note's storage. #! - ASSETS_COMMITMENT is the sequential hash of the padded note's assets. -#! - NOTE_METADATA_HEADER is the note's metadata header. -#! - NOTE_ATTACHMENT is the note's attachment. +#! - NOTE_METADATA is the note's metadata. +#! - NOTE_ATTACHMENTS_COMMITMENT is the note's attachments commitment. #! - NOTE_ARGS are the user arguments passed to the note. #! - num_assets is the number of note assets. #! - ASSET_0, ..., ASSET_N are the padded note's assets. @@ -818,71 +841,65 @@ end #! - block_num is the note's creation block number. #! - BLOCK_SUB_COMMITMENT is the block's sub_commitment for which the note was created. #! - NOTE_ROOT is the merkle root of the note's tree. +#! - note_index is the input note's position in the notes tree. proc process_input_note - # note details + # note args # --------------------------------------------------------------------------------------------- - dup exec.memory::get_input_note_ptr dup - # => [note_ptr, note_ptr, idx, CAPACITY] - - exec.process_input_note_details - # => [NULLIFIER, note_ptr, idx, CAPACITY] + dup exec.memory::get_input_note_ptr + # => [note_ptr, idx, CAPACITY] - # save NULLIFIER to memory - movup.5 exec.memory::get_input_note_nullifier_ptr mem_storew_le - # => [NULLIFIER, note_ptr, CAPACITY] + exec.process_note_args + # => [note_ptr, idx, CAPACITY] - # note metadata & args + # note details and nullifier # --------------------------------------------------------------------------------------------- - movup.4 + swap exec.process_input_note_details movup.4 # => [note_ptr, NULLIFIER, CAPACITY] - dup exec.process_note_args_and_metadata - # => [NOTE_METADATA_HEADER, NOTE_ATTACHMENT, note_ptr, NULLIFIER, CAPACITY] - - # compute hash(NOTE_METADATA_HEADER || NOTE_ATTACHMENT) - exec.poseidon2::merge - # => [NOTE_METADATA_COMMITMENT, note_ptr, NULLIFIER, CAPACITY] - - movup.4 - # => [note_ptr, NOTE_METADATA_COMMITMENT, NULLIFIER, CAPACITY] - # note number of storage items # --------------------------------------------------------------------------------------------- exec.process_note_num_storage_items - # => [note_ptr, NOTE_METADATA_COMMITMENT, NULLIFIER, CAPACITY] + # => [note_ptr, NULLIFIER, CAPACITY] # note assets # --------------------------------------------------------------------------------------------- dup exec.process_note_assets dup exec.add_input_note_assets_to_vault - # => [note_ptr, NOTE_METADATA_COMMITMENT, NULLIFIER, CAPACITY] + # => [note_ptr, NULLIFIER, CAPACITY] + + # note metadata commitment + # --------------------------------------------------------------------------------------------- + + # compute NOTE_METADATA_COMMITMENT + dup exec.compute_note_metadata_commitment + # => [NOTE_METADATA_COMMITMENT, note_ptr, NULLIFIER, CAPACITY] - # note id + # note details commitment # --------------------------------------------------------------------------------------------- - dup exec.compute_input_note_id - # => [NOTE_ID, note_ptr, NOTE_METADATA_COMMITMENT, NULLIFIER, CAPACITY] + dup.4 exec.compute_input_note_details_commitment + # => [NOTE_DETAILS_COMMITMENT, NOTE_METADATA_COMMITMENT, note_ptr, NULLIFIER, CAPACITY] - # save note id to memory - movup.4 exec.memory::set_input_note_id - # => [NOTE_ID, NOTE_METADATA_COMMITMENT, NULLIFIER, CAPACITY] + # save note details commitment to memory + movup.8 exec.memory::set_input_note_details_commitment + # => [NOTE_DETAILS_COMMITMENT, NOTE_METADATA_COMMITMENT, NULLIFIER, CAPACITY] # note authentication # --------------------------------------------------------------------------------------------- - # NOTE_COMMITMENT: `hash(NOTE_ID || NOTE_METADATA_COMMITMENT)` + # NOTE_ID: `hash(NOTE_DETAILS_COMMITMENT || NOTE_METADATA_COMMITMENT)` exec.poseidon2::merge - # => [NOTE_COMMITMENT, NULLIFIER, CAPACITY] + # => [NOTE_ID, NULLIFIER, CAPACITY] - adv_push.1 - # => [is_authenticated, NOTE_COMMITMENT, NULLIFIER, CAPACITY] + adv_push + # => [is_authenticated, NOTE_ID, NULLIFIER, CAPACITY] if.true - # => [NOTE_COMMITMENT, NULLIFIER, CAPACITY] + # => [NOTE_ID, NULLIFIER, CAPACITY] exec.authenticate_note # => [NULLIFIER, CAPACITY] @@ -890,20 +907,20 @@ proc process_input_note padw # => [EMPTY_WORD, NULLIFIER, CAPACITY] end - # => [EMPTY_WORD_OR_NOTE_COMMITMENT, NULLIFIER, CAPACITY] + # => [EMPTY_WORD_OR_NOTE_ID, NULLIFIER, CAPACITY] swapw - # => [NULLIFIER, EMPTY_WORD_OR_NOTE_COMMITMENT, CAPACITY] + # => [NULLIFIER, EMPTY_WORD_OR_NOTE_ID, CAPACITY] - # update the input notes commitment with hash(NULLIFIER || EMPTY_WORD_OR_NOTE_COMMITMENT) + # update the input notes commitment with hash(NULLIFIER || EMPTY_WORD_OR_NOTE_ID) exec.poseidon2::permute # => [RATE0, RATE1, CAPACITY] end #! Process the input notes data provided via the advice provider. This involves reading the data #! from the advice provider and storing it at the appropriate memory addresses. As each note is -#! processed its hash and nullifier are computed. The transaction input notes commitment is -#! computed via a sequential hash of all (NULLIFIER, EMPTY_WORD_NOTE_COMMITMENT) pairs for all input +#! processed its ID and nullifier are computed. The transaction input notes commitment is +#! computed via a sequential hash of all (NULLIFIER, EMPTY_WORD_OR_NOTE_ID) pairs for all input #! notes. #! #! Inputs: @@ -919,10 +936,11 @@ end #! Where: #! - num_notes is the number of input notes. #! - INPUT_NOTES_COMMITMENT, see `transaction::api::get_input_notes_commitment`. -#! - NOTE_DATA is the input notes' details, for format see `prologue::process_input_note`. +#! - NOTE_DATA is the input notes' args, nullifier preimages, assets, and authentication data; for +#! format see `prologue::process_input_note`. proc process_input_notes_data # get the number of input notes from the advice stack - adv_push.1 + adv_push # => [num_notes] # assert the number of input notes is within limits; since max number of input notes is @@ -1089,10 +1107,9 @@ end #! TX_KERNEL_COMMITMENT #! VALIDATOR_KEY_COMMITMENT, #! [block_num, version, timestamp, 0], -#! [0, verification_base_fee, native_asset_id_suffix, native_asset_id_prefix] +#! [0, verification_base_fee, fee_faucet_id_suffix, fee_faucet_id_prefix] #! [0, 0, 0, 0] #! NOTE_ROOT, -#! kernel_version #! [account_nonce, 0, account_id_suffix, account_id_prefix] #! ACCOUNT_VAULT_ROOT, #! ACCOUNT_STORAGE_COMMITMENT, @@ -1133,8 +1150,10 @@ end #! - version is the current protocol version. #! - timestamp is the current timestamp. #! - NOTE_ROOT is the root of the tree with all notes created in the block. -#! - kernel_version is the index of the desired kernel in the array of all kernels available for the -#! current transaction. +#! - verification_base_fee is the base fee capturing the cost for the verification of a +#! transaction. +#! - native_asset_id_{prefix, suffix} are the prefix and suffix felts of the faucet ID that defines +#! the native asset. #! - account_nonce is the account's nonce. #! - ACCOUNT_VAULT_ROOT is the account's vault root. #! - ACCOUNT_STORAGE_COMMITMENT is the account's storage commitment. @@ -1145,7 +1164,8 @@ end #! - TX_SCRIPT_ROOT is the transaction's script root. #! - TX_SCRIPT_ARGS are the arguments provided to the stack, see prologue::process_tx_script_data. #! - MMR_PEAKS is the MMR peak data, see process_chain_data. -#! - NOTE_DATA is the input notes' details, for format see prologue::process_input_note. +#! - NOTE_DATA is the input notes' args, nullifier preimages, assets, and authentication data; for +#! format see prologue::process_input_note. #! #! Panics if: #! - data provided by the advice provider does not match global inputs. diff --git a/crates/miden-protocol/asm/kernels/transaction/lib/tx.masm b/crates/miden-protocol/asm/kernels/transaction/lib/tx.masm index aaed2d01fc..fe49d70823 100644 --- a/crates/miden-protocol/asm/kernels/transaction/lib/tx.masm +++ b/crates/miden-protocol/asm/kernels/transaction/lib/tx.masm @@ -58,7 +58,7 @@ pub use memory::get_blk_timestamp->get_block_timestamp pub use memory::get_input_notes_commitment #! Returns the output notes commitment hash. This is computed as a sequential hash of -#! (note_id, note_metadata) tuples over all output notes. +#! (note_details_commitment, note_metadata_commitment) tuples over all output notes. #! #! Inputs: [] #! Outputs: [OUTPUT_NOTES_COMMITMENT] @@ -85,6 +85,16 @@ pub use memory::get_num_input_notes #! - num_output_notes is the number of output notes created in this transaction so far. pub use memory::get_num_output_notes +#! Returns the transaction script root. +#! +#! Inputs: [] +#! Outputs: [TX_SCRIPT_ROOT] +#! +#! Where: +#! - TX_SCRIPT_ROOT is the transaction script root, or the empty word if no transaction script was +#! executed. +pub use memory::get_tx_script_root + #! Updates the transaction expiration block delta. #! #! The input block_height_delta is added to the block reference number in order to output an upper @@ -171,7 +181,7 @@ end #! - VAULT_ROOT is the commitment of the foreign account's vault. #! - STORAGE_ROOT is the commitment of the foreign account's storage. #! - STORAGE_SLOT_DATA is the data contained in the storage slot which is constructed as follows: -#! [SLOT_VALUE, slot_type, 0, 0, 0]. +#! [0, slot_type, slot_id_suffix, slot_id_prefix, SLOT_VALUE]. #! - CODE_COMMITMENT is the commitment of the foreign account's code. #! - ACCOUNT_PROCEDURE_DATA are the roots of the public procedures of the foreign account. #! @@ -224,7 +234,7 @@ end #! #! Inputs: [] #! Outputs: [] -proc clear_fpi_memory +pub proc clear_fpi_memory # set the upcoming foreign account ID to zero push.0 push.0 exec.memory::set_fpi_account_id # => [] diff --git a/crates/miden-protocol/asm/kernels/transaction/main.masm b/crates/miden-protocol/asm/kernels/transaction/main.masm index e0f0313ebb..04e07c9404 100644 --- a/crates/miden-protocol/asm/kernels/transaction/main.masm +++ b/crates/miden-protocol/asm/kernels/transaction/main.masm @@ -2,6 +2,7 @@ use miden::core::word use $kernel::epilogue use $kernel::memory +use $kernel::memory::TX_SCRIPT_ROOT_PTR use $kernel::note use $kernel::prologue @@ -56,7 +57,7 @@ const EPILOGUE_END_EVENT=event("miden::protocol::tx::epilogue_end") #! ] #! Outputs: [ #! OUTPUT_NOTES_COMMITMENT, ACCOUNT_UPDATE_COMMITMENT, -#! native_asset_id_suffix, native_asset_id_prefix, fee_amount, tx_expiration_block_num, +#! fee_faucet_id_suffix, fee_faucet_id_prefix, fee_amount, tx_expiration_block_num, #! pad(4) #! ] #! @@ -69,9 +70,9 @@ const EPILOGUE_END_EVENT=event("miden::protocol::tx::epilogue_end") #! - 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 native asset. -#! - native_asset_id_{prefix,suffix} are the prefix and suffix felts of the faucet that issues the -#! native asset. +#! - 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. #! - tx_expiration_block_num is the transaction expiration block number. @locals(1) proc main @@ -136,7 +137,7 @@ proc main emit.TX_SCRIPT_PROCESSING_START_EVENT # get the memory address of the transaction script root and load it to the stack - exec.memory::get_tx_script_root_ptr + push.TX_SCRIPT_ROOT_PTR padw dup.4 mem_loadw_le # => [TX_SCRIPT_ROOT, tx_script_root_ptr, pad(16)] @@ -175,12 +176,12 @@ proc main # execute the transaction epilogue exec.epilogue::finalize_transaction # => [OUTPUT_NOTES_COMMITMENT, ACCOUNT_UPDATE_COMMITMENT, - # native_asset_id_suffix, native_asset_id_prefix, fee_amount, tx_expiration_block_num, pad(16)] + # fee_faucet_id_suffix, fee_faucet_id_prefix, fee_amount, 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, - # native_asset_id_suffix, native_asset_id_prefix, fee_amount, tx_expiration_block_num, pad(4)] + # fee_faucet_id_suffix, fee_faucet_id_prefix, fee_amount, tx_expiration_block_num, pad(4)] emit.EPILOGUE_END_EVENT end diff --git a/crates/miden-protocol/asm/kernels/transaction/tx_script_main.masm b/crates/miden-protocol/asm/kernels/transaction/tx_script_main.masm index b51ea9a44f..958427842c 100644 --- a/crates/miden-protocol/asm/kernels/transaction/tx_script_main.masm +++ b/crates/miden-protocol/asm/kernels/transaction/tx_script_main.masm @@ -1,6 +1,6 @@ use miden::core::word -use $kernel::memory +use $kernel::memory::TX_SCRIPT_ROOT_PTR use $kernel::prologue # ERRORS @@ -45,7 +45,7 @@ proc main # --------------------------------------------------------------------------------------------- # get the memory address of the transaction script root and load it to the stack - exec.memory::get_tx_script_root_ptr + push.TX_SCRIPT_ROOT_PTR padw dup.4 mem_loadw_le # => [TX_SCRIPT_ROOT, tx_script_root_ptr] @@ -53,7 +53,8 @@ proc main exec.word::eqz assertz.err=ERR_TX_TRANSACTION_SCRIPT_IS_MISSING # => [tx_script_root_ptr] - # execute the transaction script + # Execute the transaction script via `dyncall`. Note that the program arguments passed to + # the script are always the empty word (i.e. [0, 0, 0, 0]). dyncall # => [OUTPUT_3, OUTPUT_2, OUTPUT_1, OUTPUT_0] end diff --git a/crates/miden-protocol/asm/protocol/active_account.masm b/crates/miden-protocol/asm/protocol/active_account.masm index c128a7d9aa..652c115ace 100644 --- a/crates/miden-protocol/asm/protocol/active_account.masm +++ b/crates/miden-protocol/asm/protocol/active_account.masm @@ -1,5 +1,6 @@ -use miden::protocol::account_id use miden::protocol::asset +use miden::protocol::asset::COMPOSITION_FUNGIBLE +use miden::protocol::asset::COMPOSITION_NONE use miden::protocol::kernel_proc_offsets::ACCOUNT_GET_ID_OFFSET use miden::protocol::kernel_proc_offsets::ACCOUNT_GET_NONCE_OFFSET use miden::protocol::kernel_proc_offsets::ACCOUNT_GET_INITIAL_COMMITMENT_OFFSET @@ -491,32 +492,25 @@ pub proc get_initial_asset # => [ASSET_VALUE] end -#! Returns the balance of the fungible asset associated with the provided faucet_id in the active -#! account's vault. +#! Returns the balance of the fungible asset associated with the provided asset vault key in the +#! active account's vault. #! -#! Inputs: [faucet_id_suffix, faucet_id_prefix] +#! Inputs: [ASSET_KEY] #! Outputs: [balance] #! #! Where: -#! - faucet_id_{suffix,prefix} are the suffix and prefix felts of the faucet ID of the fungible -#! asset of interest. +#! - ASSET_KEY is the vault key of the fungible asset of interest. #! - balance is the vault balance of the fungible asset. #! #! Panics if: -#! - the provided faucet ID is not an ID of a fungible faucet. +#! - the asset composition encoded in ASSET_KEY is not fungible. #! #! Invocation: exec pub proc get_balance - # assert that the faucet id is a fungible faucet - dup.1 exec.account_id::is_fungible_faucet - assert.err=ERR_VAULT_GET_BALANCE_CAN_ONLY_BE_CALLED_ON_FUNGIBLE_ASSET - # => [faucet_id_suffix, faucet_id_prefix] - - # TODO(callbacks): This should take ASSET_KEY as input to avoid hardcoding the callbacks flag. - push.0 - # => [enable_callbacks = 0, faucet_id_suffix, faucet_id_prefix] - - exec.asset::create_fungible_key + # assert that the asset composition is fungible + exec.asset::key_to_composition + push.COMPOSITION_FUNGIBLE + assert_eq.err=ERR_VAULT_GET_BALANCE_CAN_ONLY_BE_CALLED_ON_FUNGIBLE_ASSET # => [ASSET_KEY] exec.get_asset @@ -527,32 +521,25 @@ pub proc get_balance # => [balance] end -#! Returns the balance of the fungible asset associated with the provided faucet_id in the active -#! account's vault at the beginning of the transaction. +#! Returns the balance of the fungible asset associated with the provided asset vault key in the +#! active account's vault at the beginning of the transaction. #! -#! Inputs: [faucet_id_suffix, faucet_id_prefix] +#! Inputs: [ASSET_KEY] #! Outputs: [init_balance] #! #! Where: -#! - faucet_id_{suffix, prefix} are the suffix and prefix felts of the faucet id of the fungible -#! asset of interest. +#! - ASSET_KEY is the vault key of the fungible asset of interest. #! - init_balance is the vault balance of the fungible asset at the beginning of the transaction. #! #! Panics if: -#! - the provided faucet ID is not an ID of a fungible faucet. +#! - the asset composition encoded in ASSET_KEY is not fungible. #! #! Invocation: exec pub proc get_initial_balance - # assert that the faucet id is a fungible faucet - dup.1 exec.account_id::is_fungible_faucet - assert.err=ERR_VAULT_GET_BALANCE_CAN_ONLY_BE_CALLED_ON_FUNGIBLE_ASSET - # => [faucet_id_suffix, faucet_id_prefix] - - # TODO(callbacks): This should take ASSET_KEY as input to avoid hardcoding the callbacks flag. - push.0 - # => [enable_callbacks = 0, faucet_id_suffix, faucet_id_prefix] - - exec.asset::create_fungible_key + # assert that the asset composition is fungible + exec.asset::key_to_composition + push.COMPOSITION_FUNGIBLE + assert_eq.err=ERR_VAULT_GET_BALANCE_CAN_ONLY_BE_CALLED_ON_FUNGIBLE_ASSET # => [ASSET_KEY] exec.get_initial_asset @@ -578,11 +565,10 @@ end #! #! Invocation: exec pub proc has_non_fungible_asset - # => [faucet_id_prefix, faucet_id_suffix, asset_id_prefix, asset_id_suffix] - - # assert that the faucet id is a non-fungible faucet - dup.3 exec.account_id::is_non_fungible_faucet - assert.err=ERR_VAULT_HAS_NON_FUNGIBLE_ASSET_PROC_CAN_BE_CALLED_ONLY_WITH_NON_FUNGIBLE_ASSET + # assert that the asset's composition is non-fungible + exec.asset::key_to_composition + push.COMPOSITION_NONE + assert_eq.err=ERR_VAULT_HAS_NON_FUNGIBLE_ASSET_PROC_CAN_BE_CALLED_ONLY_WITH_NON_FUNGIBLE_ASSET # => [ASSET_KEY] exec.get_asset diff --git a/crates/miden-protocol/asm/protocol/active_note.masm b/crates/miden-protocol/asm/protocol/active_note.masm index cf47a36aa1..58b2f2e8dc 100644 --- a/crates/miden-protocol/asm/protocol/active_note.masm +++ b/crates/miden-protocol/asm/protocol/active_note.masm @@ -8,6 +8,7 @@ use miden::protocol::kernel_proc_offsets::INPUT_NOTE_GET_METADATA_OFFSET 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 # ERRORS # ================================================================================================= @@ -25,7 +26,7 @@ const ERR_NOTE_INVALID_NUMBER_OF_STORAGE_ITEMS="the specified number of note sto #! Writes the assets of the active note into memory starting at the specified address. #! #! Inputs: [dest_ptr] -#! Outputs: [num_assets, dest_ptr] +#! Outputs: [num_assets] #! #! Where: #! - dest_ptr is the memory address to write the assets. @@ -54,9 +55,13 @@ pub proc get_assets swapdw dropw dropw movup.7 movup.7 movup.7 drop drop drop # => [ASSETS_COMMITMENT, num_assets, dest_ptr] + # save num_assets for the return value + dup.4 movdn.6 + # => [ASSETS_COMMITMENT, num_assets, dest_ptr, num_assets] + # write the assets from the advice map into memory exec.note::write_assets_to_memory - # => [num_assets, dest_ptr] + # => [num_assets] end #! Returns the recipient of the active note. @@ -97,7 +102,7 @@ end #! Stack: [dest_ptr] #! Advice Map: { NOTE_STORAGE_COMMITMENT: [STORAGE] } #! Outputs: -#! Stack: [num_storage_items, dest_ptr] +#! Stack: [num_storage_items] #! #! Where: #! - dest_ptr is the memory address to write the note storage. @@ -128,19 +133,22 @@ pub proc get_storage movup.5 drop movup.5 drop movup.5 drop # => [NOTE_STORAGE_COMMITMENT, num_storage_items, dest_ptr] - # write the inputs to the memory using the provided destination pointer + # save num_storage_items for the return value + dup.4 movdn.6 + # => [NOTE_STORAGE_COMMITMENT, num_storage_items, dest_ptr, num_storage_items] + + # write the inputs to the provided destination pointer exec.write_storage_to_memory - # => [num_storage_items, dest_ptr] + # => [num_storage_items] end #! Returns the metadata of the active note. #! #! Inputs: [] -#! Outputs: [NOTE_ATTACHMENT, METADATA_HEADER] +#! Outputs: [METADATA] #! #! Where: -#! - METADATA_HEADER is the metadata header of the specified input note. -#! - NOTE_ATTACHMENT is the attachment of the specified input note. +#! - METADATA is the metadata of the active note. #! #! Panics if: #! - no note is currently active. @@ -159,11 +167,11 @@ pub proc get_metadata # => [offset, is_active_note = 1, pad(14)] syscall.exec_kernel_proc - # => [NOTE_ATTACHMENT, METADATA_HEADER, pad(8)] + # => [METADATA, pad(12)] # clean the stack - swapdw dropw dropw - # => [NOTE_ATTACHMENT, METADATA_HEADER] + swapdw dropw dropw swapw dropw + # => [METADATA] end #! Returns the sender of the active note. @@ -179,12 +187,11 @@ end #! #! Invocation: exec pub proc get_sender - # get metadata and drop attachment - exec.get_metadata dropw - # => [METADATA_HEADER] + exec.get_metadata + # => [METADATA] - # extract the sender ID from the metadata header - exec.note::extract_sender_from_metadata + # extract the sender ID from the metadata + exec.note::metadata_into_sender # => [sender_id_suffix, sender_id_prefix] end @@ -264,7 +271,7 @@ end #! NOTE_STORAGE_COMMITMENT: [[INPUT_VALUES]] #! } #! Outputs: -#! Operand stack: [num_storage_items, dest_ptr] +#! Operand stack: [] proc write_storage_to_memory # load the inputs from the advice map to the advice stack # we pad the number of inputs to the next multiple of 8 so that we can use the @@ -276,7 +283,7 @@ proc write_storage_to_memory # AS => [advice_num_storage_items, [INPUT_VALUES]] # move the number of inputs obtained from advice map to the operand stack - adv_push.1 dup.5 + adv_push dup.5 # OS => [num_storage_items, advice_num_storage_items, NOTE_STORAGE_COMMITMENT, num_storage_items, dest_ptr] # AS => [[INPUT_VALUES]] @@ -303,36 +310,146 @@ proc write_storage_to_memory # # To match `poseidon2::hash_elements` (used for NOTE_STORAGE_COMMITMENT), we set the first capacity # element to `num_storage_items % 8`. - dup.6 dup.6 - # OS => [num_storage_items, write_ptr, end_ptr, NOTE_STORAGE_COMMITMENT, num_storage_items, dest_ptr] + movup.6 movup.6 + # OS => [num_storage_items, write_ptr, end_ptr, NOTE_STORAGE_COMMITMENT] # AS => [[INPUT_VALUES]] u32divmod.8 swap drop - # OS => [num_storage_items_mod_8, write_ptr, end_ptr, NOTE_STORAGE_COMMITMENT, num_storage_items, dest_ptr] + # OS => [num_storage_items_mod_8, write_ptr, end_ptr, NOTE_STORAGE_COMMITMENT] # AS => [[INPUT_VALUES]] push.0.0.0 movup.3 - # OS => [CAPACITY = [num_storage_items_mod_8, 0, 0, 0], write_ptr, end_ptr, NOTE_STORAGE_COMMITMENT, num_storage_items, dest_ptr] + # OS => [CAPACITY = [num_storage_items_mod_8, 0, 0, 0], write_ptr, end_ptr, NOTE_STORAGE_COMMITMENT] # AS => [[INPUT_VALUES]] padw padw - # OS => [RATE0, RATE1, CAPACITY, write_ptr, end_ptr, NOTE_STORAGE_COMMITMENT, num_storage_items, dest_ptr] + # OS => [RATE0, RATE1, CAPACITY, write_ptr, end_ptr, NOTE_STORAGE_COMMITMENT] # AS => [[INPUT_VALUES]] # write the inputs from the advice stack into memory exec.mem::pipe_double_words_to_memory - # OS => [RATE0, RATE1, CAPACITY, end_ptr', NOTE_STORAGE_COMMITMENT, num_storage_items, dest_ptr] + # OS => [RATE0, RATE1, CAPACITY, end_ptr', NOTE_STORAGE_COMMITMENT] # AS => [] # extract the computed commitment from the hasher state exec.poseidon2::squeeze_digest - # OS => [COMPUTED_COMMITMENT, end_ptr', NOTE_STORAGE_COMMITMENT, num_storage_items, dest_ptr] + # OS => [COMPUTED_COMMITMENT, end_ptr', NOTE_STORAGE_COMMITMENT] # drop end_ptr' movup.4 drop - # OS => [COMPUTED_COMMITMENT, NOTE_STORAGE_COMMITMENT, num_storage_items, dest_ptr] + # OS => [COMPUTED_COMMITMENT, NOTE_STORAGE_COMMITMENT] # validate that the inputs written to memory match the inputs commitment assert_eqw.err=ERR_NOTE_DATA_DOES_NOT_MATCH_COMMITMENT - # => [num_storage_items, dest_ptr] + # => [] +end + +# ATTACHMENTS +# ================================================================================================= + +#! Returns the commitment over all attachments of the active note. +#! +#! Inputs: [] +#! Outputs: [ATTACHMENTS_COMMITMENT] +#! +#! Where: +#! - ATTACHMENTS_COMMITMENT is the commitment to all attachments of the note, or the EMPTY_WORD if +#! the note does not have any attachments. +#! +#! Panics if: +#! - no note is currently active. +#! +#! Invocation: exec +pub proc get_attachments_commitment + # push a placeholder note_index (ignored when is_active_note = 1) and the active note flag + push.0.1 + # => [is_active_note = 1, note_index = 0] + + exec.input_note::get_attachments_commitment_raw + # => [ATTACHMENTS_COMMITMENT] +end + +#! Writes the attachment commitments of the active note to the provided destination pointer. +#! +#! Inputs: [dest_ptr] +#! Outputs: [num_attachments] +#! +#! Where: +#! - dest_ptr is the memory address to which to write the attachment commitments. +#! - num_attachments is the number of attachments in the note. +#! +#! Panics if: +#! - no note is currently active. +#! - the sequential hash over the attachment commitments in the advice inputs does not match the +#! attachments commitment. +#! +#! Invocation: exec +pub proc write_attachment_commitments_to_memory + exec.get_attachments_commitment + # => [ATTACHMENTS_COMMITMENT, dest_ptr] + + exec.note::write_attachment_commitments_to_memory + # => [num_attachments] +end + +#! Writes the attachment with the provided index from the active note the provided destination +#! pointer. +#! +#! Inputs: [dest_ptr, attachment_idx] +#! Outputs: [num_words] +#! +#! Where: +#! - dest_ptr is the memory address to which to write the attachment data. +#! - attachment_idx is the index of the attachment to retrieve. +#! - num_words is the number of words in the attachment. +#! +#! Panics if: +#! - no note is currently active. +#! - the attachment index is greater or equal to the number of attachments. +#! - the sequential hash over the attachment data in the advice inputs does not match the +#! attachment commitment. +#! +#! Invocation: exec +@locals(16) +pub proc write_attachment_to_memory + # write the attachment commitments to local memory + # we allocate 16 elements of memory (max num attachments * WORD_SIZE) to store all + # four attachment commitments + swap locaddr.0 + # => [attachment_commitments_ptr, attachment_idx, dest_ptr] + + exec.write_attachment_commitments_to_memory + # => [num_attachments, attachment_idx, dest_ptr] + + locaddr.0 swap + # => [num_attachments, attachment_commitments_ptr, attachment_idx, dest_ptr] + + exec.note::write_indexed_attachment_to_memory + # => [num_words] +end + +#! Searches the metadata of the active note for the specified attachment scheme and returns +#! the index of the first matching slot. +#! +#! Inputs: [attachment_scheme] +#! Outputs: [is_found, attachment_idx] +#! +#! Where: +#! - attachment_scheme is the scheme of the attachment to find. +#! - is_found is 1 if the attachment with the provided scheme was found, 0 otherwise. +#! - attachment_idx is the index (0-3) of the first matching slot, or undefined if not found. +#! +#! Panics if: +#! - no note is currently active. +#! +#! Invocation: exec +pub proc find_attachment + exec.get_metadata + # => [METADATA, attachment_scheme] + + movup.4 + # => [attachment_scheme, METADATA] + + exec.note::find_attachment_idx + # => [is_found, attachment_idx] end diff --git a/crates/miden-protocol/asm/protocol/asset.masm b/crates/miden-protocol/asm/protocol/asset.masm index 47fe82b727..525ba69927 100644 --- a/crates/miden-protocol/asm/protocol/asset.masm +++ b/crates/miden-protocol/asm/protocol/asset.masm @@ -1,4 +1,3 @@ -use miden::protocol::account_id use miden::protocol::util::asset # RE-EXPORTS @@ -12,26 +11,29 @@ pub use miden::protocol::util::asset::key_into_faucet_id pub use miden::protocol::util::asset::key_to_asset_id pub use miden::protocol::util::asset::key_into_asset_id pub use miden::protocol::util::asset::key_to_callbacks_enabled +pub use miden::protocol::util::asset::key_to_composition +pub use miden::protocol::util::asset::COMPOSITION_NONE +pub use miden::protocol::util::asset::COMPOSITION_FUNGIBLE +pub use miden::protocol::util::asset::COMPOSITION_CUSTOM pub use miden::protocol::util::asset::store pub use miden::protocol::util::asset::load pub use miden::protocol::util::asset::fungible_value_into_amount pub use miden::protocol::util::asset::fungible_to_amount pub use miden::protocol::util::asset::create_fungible_key +pub use miden::protocol::util::asset::create_non_fungible_asset_unchecked->create_non_fungible_asset # ERRORS # ================================================================================================= const ERR_FUNGIBLE_ASSET_AMOUNT_EXCEEDS_MAX_ALLOWED_AMOUNT="fungible asset build operation called with amount that exceeds the maximum allowed asset amount" -const ERR_FUNGIBLE_ASSET_PROVIDED_FAUCET_ID_IS_INVALID="failed to build the fungible asset because the provided faucet id is not from a fungible faucet" - -const ERR_NON_FUNGIBLE_ASSET_PROVIDED_FAUCET_ID_IS_INVALID="failed to build the non-fungible asset because the provided faucet id is not from a non-fungible faucet" - # PROCEDURES # ================================================================================================= #! Creates a fungible asset for the specified fungible faucet and amount. #! +#! The provided asset key will be created with asset composition set to COMPOSITION_FUNGIBLE. +#! #! Inputs: [enable_callbacks, faucet_id_suffix, faucet_id_prefix, amount] #! Outputs: [ASSET_KEY, ASSET_VALUE] #! @@ -50,45 +52,12 @@ const ERR_NON_FUNGIBLE_ASSET_PROVIDED_FAUCET_ID_IS_INVALID="failed to build the #! #! Invocation: exec pub proc create_fungible_asset - # assert the faucet is a fungible faucet - dup.2 exec.account_id::is_fungible_faucet assert.err=ERR_FUNGIBLE_ASSET_PROVIDED_FAUCET_ID_IS_INVALID - # => [enable_callbacks, faucet_id_suffix, faucet_id_prefix, amount] - # assert the amount is valid dup.3 lte.FUNGIBLE_ASSET_MAX_AMOUNT assert.err=ERR_FUNGIBLE_ASSET_AMOUNT_EXCEEDS_MAX_ALLOWED_AMOUNT # => [enable_callbacks, faucet_id_suffix, faucet_id_prefix, amount] - # SAFETY: faucet ID and amount were validated + # SAFETY: amount was validated exec.asset::create_fungible_asset_unchecked # => [ASSET_KEY, ASSET_VALUE] end - -#! Creates a non fungible asset for the specified non-fungible faucet. -#! -#! Inputs: [enable_callbacks, faucet_id_suffix, faucet_id_prefix, DATA_HASH] -#! Outputs: [ASSET_KEY, ASSET_VALUE] -#! -#! Where: -#! - enable_callbacks is a flag (0 or 1) indicating whether asset callbacks are enabled. -#! - faucet_id_{suffix,prefix} are the suffix and prefix felts of the faucet to create the asset -#! for. -#! - DATA_HASH is the data hash of the non-fungible asset to create. -#! - ASSET_KEY is the vault key of the created non-fungible asset. -#! - ASSET_VALUE is the value of the created non-fungible asset, which is identical to DATA_HASH. -#! -#! Panics if: -#! - the provided faucet ID is not a non-fungible faucet. -#! - enable_callbacks is not 0 or 1. -#! -#! Invocation: exec -pub proc create_non_fungible_asset - # assert the faucet is a non-fungible faucet - dup.2 exec.account_id::is_non_fungible_faucet - assert.err=ERR_NON_FUNGIBLE_ASSET_PROVIDED_FAUCET_ID_IS_INVALID - # => [enable_callbacks, faucet_id_suffix, faucet_id_prefix, DATA_HASH] - - # SAFETY: faucet ID was validated - exec.::miden::protocol::util::asset::create_non_fungible_asset_unchecked - # => [ASSET_KEY, ASSET_VALUE] -end diff --git a/crates/miden-protocol/asm/protocol/faucet.masm b/crates/miden-protocol/asm/protocol/faucet.masm index b05a305c22..f630b6a134 100644 --- a/crates/miden-protocol/asm/protocol/faucet.masm +++ b/crates/miden-protocol/asm/protocol/faucet.masm @@ -63,14 +63,11 @@ end #! Mint an asset from the faucet the transaction is being executed against. #! #! Inputs: [ASSET_KEY, ASSET_VALUE] -#! Outputs: [NEW_ASSET_VALUE] +#! Outputs: [] #! #! Where: #! - ASSET_KEY is the vault key of the asset to mint. #! - ASSET_VALUE is the value of the asset that was minted. -#! - NEW_ASSET_VALUE is: -#! - For fungible assets: the ASSET_VALUE merged with the existing vault asset value, if any. -#! - For non-fungible assets: identical to ASSET_VALUE. #! #! Panics if: #! - the transaction is not being executed against a faucet. @@ -94,8 +91,8 @@ pub proc mint # => [ASSET_VALUE, pad(12)] # clean the stack - swapdw dropw dropw swapw dropw - # => [ASSET_VALUE] + dropw dropw dropw dropw + # => [] end #! Burn an asset from the faucet the transaction is being executed against. diff --git a/crates/miden-protocol/asm/protocol/input_note.masm b/crates/miden-protocol/asm/protocol/input_note.masm index be9ae33d03..984e480b94 100644 --- a/crates/miden-protocol/asm/protocol/input_note.masm +++ b/crates/miden-protocol/asm/protocol/input_note.masm @@ -4,6 +4,7 @@ use miden::protocol::kernel_proc_offsets::INPUT_NOTE_GET_METADATA_OFFSET use miden::protocol::kernel_proc_offsets::INPUT_NOTE_GET_STORAGE_INFO_OFFSET use miden::protocol::kernel_proc_offsets::INPUT_NOTE_GET_SCRIPT_ROOT_OFFSET use miden::protocol::kernel_proc_offsets::INPUT_NOTE_GET_SERIAL_NUMBER_OFFSET +use miden::protocol::kernel_proc_offsets::INPUT_NOTE_GET_ATTACHMENTS_COMMITMENT_OFFSET use miden::protocol::note # PROCEDURES @@ -65,7 +66,7 @@ end #! information about the layout of each asset see the description of the `Asset` Rust type. #! #! Inputs: [dest_ptr, note_index] -#! Outputs: [num_assets, dest_ptr, note_index] +#! Outputs: [num_assets] #! #! Where: #! - dest_ptr is the memory address to write the assets. @@ -78,12 +79,16 @@ end #! Invocation: exec pub proc get_assets # get the assets commitment and assets number - dup.1 exec.get_assets_info - # => [ASSETS_COMMITMENT, num_assets, dest_ptr, note_index] + swap exec.get_assets_info + # => [ASSETS_COMMITMENT, num_assets, dest_ptr] + + # save num_assets for the return value + dup.4 movdn.6 + # => [ASSETS_COMMITMENT, num_assets, dest_ptr, num_assets] # write the assets stored in the advice map to the specified memory pointer exec.note::write_assets_to_memory - # => [num_assets, dest_ptr, note_index] + # => [num_assets] end #! Returns the recipient of the input note with the specified index. @@ -127,12 +132,11 @@ end #! Returns the metadata of the input note with the specified index. #! #! Inputs: [note_index] -#! Outputs: [NOTE_ATTACHMENT, METADATA_HEADER] +#! Outputs: [METADATA] #! #! Where: #! - note_index is the index of the input note whose metadata should be returned. -#! - METADATA_HEADER is the metadata header of the specified input note. -#! - NOTE_ATTACHMENT is the attachment of the specified input note. +#! - METADATA is the metadata of the specified input note. #! #! Panics if: #! - the note index is greater or equal to the total number of input notes. @@ -156,11 +160,11 @@ pub proc get_metadata # => [offset, is_active_note = 0, note_index, pad(13)] syscall.exec_kernel_proc - # => [NOTE_ATTACHMENT, METADATA_HEADER, pad(8)] + # => [METADATA, pad(12)] # clean the stack - swapdw dropw dropw - # => [NOTE_ATTACHMENT, METADATA_HEADER] + swapdw dropw dropw swapw dropw + # => [METADATA] end #! Returns the sender of the input note with the specified index. @@ -177,12 +181,11 @@ end #! #! Invocation: exec pub proc get_sender - # get metadata and drop attachment - exec.get_metadata dropw - # => [METADATA_HEADER] + exec.get_metadata + # => [METADATA] - # extract the sender ID from the metadata header - exec.note::extract_sender_from_metadata + # extract the sender ID from the metadata + exec.note::metadata_into_sender # => [sender_id_suffix, sender_id_prefix] end @@ -303,3 +306,154 @@ pub proc get_serial_number swapdw dropw dropw swapw dropw # => [SERIAL_NUMBER] end + +# ATTACHMENTS +# ================================================================================================= + +#! Returns the commitment over all attachments of the input note with the specified index. +#! +#! Inputs: [note_index] +#! Outputs: [ATTACHMENTS_COMMITMENT] +#! +#! Where: +#! - note_index is the index of the input note whose attachments commitment should be returned. +#! - ATTACHMENTS_COMMITMENT is the commitment to all attachments of the note, or the EMPTY_WORD if +#! the note does not have any attachments. +#! +#! Panics if: +#! - the note index is greater or equal to the total number of input notes. +#! +#! Invocation: exec +pub proc get_attachments_commitment + push.0 + # => [is_active_note = 0, note_index] + + exec.get_attachments_commitment_raw + # => [ATTACHMENTS_COMMITMENT] +end + +#! Returns the commitment over all attachments of the input note with the specified index or the +#! active note, depending on the provided flag. +#! +#! This is the shared implementation used by both `input_note::get_attachments_commitment` and +#! `active_note::get_attachments_commitment`. +#! +#! Inputs: [is_active_note, note_index] +#! Outputs: [ATTACHMENTS_COMMITMENT] +#! +#! Where: +#! - is_active_note is 0 for indexed access, 1 for the currently active note. +#! - note_index is the index of the input note (ignored when is_active_note = 1). +#! - ATTACHMENTS_COMMITMENT is the commitment to all attachments of the note, or the EMPTY_WORD if +#! the note does not have any attachments. +#! +#! Panics if: +#! - the note index is greater or equal to the total number of input notes. +#! +#! Invocation: exec +pub proc get_attachments_commitment_raw + push.0 movdn.2 + # => [is_active_note, note_index, 0] + + push.INPUT_NOTE_GET_ATTACHMENTS_COMMITMENT_OFFSET + # => [offset, is_active_note, note_index, 0] + + padw swapw padw padw swapdw + # => [offset, is_active_note, note_index, pad(13)] + + syscall.exec_kernel_proc + # => [ATTACHMENTS_COMMITMENT, pad(12)] + + # clean the stack, keeping only the commitment + swapdw dropw dropw swapw dropw + # => [ATTACHMENTS_COMMITMENT] +end + +#! Writes the attachment commitments of the input note with the specified index to the provided +#! destination pointer. +#! +#! Inputs: [dest_ptr, note_index] +#! Outputs: [num_attachments] +#! +#! Where: +#! - dest_ptr is the memory address to which to write the attachment commitments. +#! - note_index is the index of the input note. +#! - num_attachments is the number of attachments in the note. +#! +#! Panics if: +#! - the note index is greater or equal to the total number of input notes. +#! - the sequential hash over the attachment commitments in the advice inputs does not match the +#! attachments commitment. +#! +#! Invocation: exec +pub proc write_attachment_commitments_to_memory + swap exec.get_attachments_commitment + # => [ATTACHMENTS_COMMITMENT, dest_ptr] + + exec.note::write_attachment_commitments_to_memory + # => [num_attachments] +end + +#! Writes the attachment with the provided index from the input note with the specified index +#! to the provided destination pointer. +#! +#! Inputs: [dest_ptr, attachment_idx, note_index] +#! Outputs: [num_words] +#! +#! Where: +#! - dest_ptr is the memory address to which to write the attachment data. +#! - attachment_idx is the index of the attachment to retrieve. +#! - note_index is the index of the input note. +#! - num_words is the number of words in the attachment. +#! +#! Panics if: +#! - the note index is greater or equal to the total number of input notes. +#! - the attachment index is greater or equal to the number of attachments. +#! - the sequential hash over the attachment data in the advice inputs does not match the +#! attachment commitment. +#! +#! Invocation: exec +@locals(16) +pub proc write_attachment_to_memory + # set up call to write the attachment commitments to local memory + # we allocate 16 elements of memory (max num attachments * WORD_SIZE) to store all + # four attachment commitments + movdn.2 swap locaddr.0 + # => [attachment_commitments_ptr, note_index, attachment_idx, dest_ptr] + + exec.write_attachment_commitments_to_memory + # => [num_attachments, attachment_idx, dest_ptr] + + locaddr.0 swap + # => [num_attachments, attachment_commitments_ptr, attachment_idx, dest_ptr] + + exec.note::write_indexed_attachment_to_memory + # => [num_words] +end + +#! Searches the metadata of the input note with the specified index for the specified +#! attachment scheme and returns the index of the first matching slot. +#! +#! Inputs: [attachment_scheme, note_index] +#! Outputs: [is_found, attachment_idx] +#! +#! Where: +#! - attachment_scheme is the scheme of the attachment to find. +#! - note_index is the index of the input note. +#! - is_found is 1 if the attachment with the provided scheme was found, 0 otherwise. +#! - attachment_idx is the index (0-3) of the first matching slot, or undefined if not found. +#! +#! Panics if: +#! - the note index is greater or equal to the total number of input notes. +#! +#! Invocation: exec +pub proc find_attachment + swap exec.get_metadata + # => [METADATA, attachment_scheme] + + movup.4 + # => [attachment_scheme, METADATA] + + exec.note::find_attachment_idx + # => [is_found, attachment_idx] +end diff --git a/crates/miden-protocol/asm/protocol/kernel_proc_offsets.masm b/crates/miden-protocol/asm/protocol/kernel_proc_offsets.masm index eeb370179c..b5b8f305f3 100644 --- a/crates/miden-protocol/asm/protocol/kernel_proc_offsets.masm +++ b/crates/miden-protocol/asm/protocol/kernel_proc_offsets.masm @@ -53,39 +53,44 @@ pub const FAUCET_HAS_CALLBACKS_OFFSET=27 # input notes pub const INPUT_NOTE_GET_METADATA_OFFSET=28 -pub const INPUT_NOTE_GET_ASSETS_INFO_OFFSET=29 -pub const INPUT_NOTE_GET_SCRIPT_ROOT_OFFSET=30 -pub const INPUT_NOTE_GET_STORAGE_INFO_OFFSET=31 -pub const INPUT_NOTE_GET_SERIAL_NUMBER_OFFSET=32 -pub const INPUT_NOTE_GET_RECIPIENT_OFFSET=33 +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 # output notes -pub const OUTPUT_NOTE_CREATE_OFFSET=34 -pub const OUTPUT_NOTE_GET_METADATA_OFFSET=35 -pub const OUTPUT_NOTE_GET_ASSETS_INFO_OFFSET=36 +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_ADD_ASSET_OFFSET=38 -pub const OUTPUT_NOTE_SET_ATTACHMENT_OFFSET=39 +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 ### Tx ########################################## # input notes -pub const TX_GET_NUM_INPUT_NOTES_OFFSET=40 -pub const TX_GET_INPUT_NOTES_COMMITMENT_OFFSET=41 +pub const TX_GET_NUM_INPUT_NOTES_OFFSET=42 +pub const TX_GET_INPUT_NOTES_COMMITMENT_OFFSET=43 # output notes -pub const TX_GET_NUM_OUTPUT_NOTES_OFFSET=42 -pub const TX_GET_OUTPUT_NOTES_COMMITMENT_OFFSET=43 +pub const TX_GET_NUM_OUTPUT_NOTES_OFFSET=44 +pub const TX_GET_OUTPUT_NOTES_COMMITMENT_OFFSET=45 # block info -pub const TX_GET_BLOCK_COMMITMENT_OFFSET=44 -pub const TX_GET_BLOCK_NUMBER_OFFSET=45 -pub const TX_GET_BLOCK_TIMESTAMP_OFFSET=46 +pub const TX_GET_BLOCK_COMMITMENT_OFFSET=46 +pub const TX_GET_BLOCK_NUMBER_OFFSET=47 +pub const TX_GET_BLOCK_TIMESTAMP_OFFSET=48 # foreign context -pub const TX_PREPARE_FPI_OFFSET = 47 -pub const TX_EXEC_FOREIGN_PROC_OFFSET = 48 +pub const TX_PREPARE_FPI_OFFSET = 49 +pub const TX_EXEC_FOREIGN_PROC_OFFSET = 50 # expiration data -pub const TX_GET_EXPIRATION_DELTA_OFFSET=49 # accessor -pub const TX_UPDATE_EXPIRATION_BLOCK_DELTA_OFFSET=50 # mutator +pub const TX_GET_EXPIRATION_DELTA_OFFSET=51 # accessor +pub const TX_UPDATE_EXPIRATION_BLOCK_DELTA_OFFSET=52 # mutator + +# tx script +pub const TX_GET_TX_SCRIPT_ROOT_OFFSET=53 diff --git a/crates/miden-protocol/asm/protocol/note.masm b/crates/miden-protocol/asm/protocol/note.masm index 8962a86263..a5df9127f7 100644 --- a/crates/miden-protocol/asm/protocol/note.masm +++ b/crates/miden-protocol/asm/protocol/note.masm @@ -1,15 +1,24 @@ use miden::protocol::account_id +use miden::protocol::util::constants::WORD_NUM_ELEMENTS use miden::core::crypto::hashes::poseidon2 use miden::core::mem # Re-export the max inputs per note constant. pub use miden::protocol::util::note::MAX_NOTE_STORAGE_ITEMS +pub use miden::protocol::util::note::NOTE_TYPE_PUBLIC +pub use miden::protocol::util::note::NOTE_TYPE_PRIVATE +pub use miden::protocol::util::note::MAX_ATTACHMENT_SCHEME +pub use miden::protocol::util::note::MAX_ATTACHMENT_WORDS +pub use miden::protocol::util::note::MAX_ATTACHMENT_TOTAL_WORDS +pub use miden::protocol::util::note::ATTACHMENT_SCHEME_NONE # ERRORS # ================================================================================================= const ERR_PROLOGUE_NOTE_NUM_STORAGE_ITEMS_EXCEEDED_LIMIT="number of note storage exceeded the maximum limit of 1024" +const ERR_OUTPUT_NOTE_ATTACHMENT_IDX_OUT_OF_BOUNDS = "attachment index out of bounds" + # NOTE UTILITY PROCEDURES # ================================================================================================= @@ -59,32 +68,149 @@ pub proc write_assets_to_memory # OS => [ASSETS_COMMITMENT, num_assets, dest_ptr] # AS => [[ASSETS_DATA]] - dup.5 dup.5 - # OS => [num_assets, dest_ptr, ASSETS_COMMITMENT, num_assets, dest_ptr] + movup.5 movup.5 + # OS => [num_assets, dest_ptr, ASSETS_COMMITMENT] # AS => [[ASSETS_DATA]] # each asset takes up two words, so num_words = 2 * num_assets # this also guarantees we pass an even number to pipe_double_words_preimage_to_memory mul.2 - # OS => [num_words, dest_ptr, ASSETS_COMMITMENT, num_assets, dest_ptr] + # OS => [num_words, dest_ptr, ASSETS_COMMITMENT] # AS => [[ASSETS_DATA]] # write the data from the advice stack into memory exec.mem::pipe_double_words_preimage_to_memory drop - # OS => [num_assets, dest_ptr] + # OS => [] # AS => [] end -#! Builds the recipient hash from note storage, script root, and serial number. +#! Writes the attachment commitments stored in the advice map to memory specified by the provided +#! destination pointer. +#! +#! Inputs: +#! Operand stack: [ATTACHMENTS_COMMITMENT, dest_ptr] +#! Advice map: { +#! ATTACHMENTS_COMMITMENT: [[ATTACHMENT_COMMITMENT]] +#! } +#! Outputs: +#! Operand stack: [num_attachments] +pub proc write_attachment_commitments_to_memory + # push the individual ATTACHMENT commitments from the advice map onto the advice stack + adv.push_mapvaln + # OS => [ATTACHMENTS_COMMITMENT, dest_ptr] + # AS => [num_elements, [ATTACHMENT_COMMITMENT]] + + # SAFETY: if the provided num_elements is invalid, the commitment check would fail in + # pipe_preimage_to_memory so we assume validity and only do basic checks to protect against + # invalid advice inputs. + adv_push u32assert.err="invalid attachment num_elements advice input" + u32divmod.WORD_NUM_ELEMENTS + # OS => [remainder, num_words, ATTACHMENTS_COMMITMENT, dest_ptr] + # AS => [[ATTACHMENT_COMMITMENT]] + + # assert that num_elements is a multiple of WORD_NUM_ELEMENTS + eq.0 assert.err="attachment commitments num_elements is not a multiple of WORD_NUM_ELEMENTS" + # OS => [num_words, ATTACHMENTS_COMMITMENT, dest_ptr] + # AS => [[ATTACHMENT_COMMITMENT]] + + # store the number of words as the number of attachments for return + swap.5 dup.5 + # OS => [num_words, dest_ptr, ATTACHMENTS_COMMITMENT, num_attachments] + # AS => [[ATTACHMENT_COMMITMENT]] + + # pipe attachment commitments to memory and validate they match the ATTACHMENTS_COMMITMENT + exec.mem::pipe_preimage_to_memory drop + # => [num_attachments] +end + +#! Writes a single attachment's data stored in the advice map to the memory specified by the +#! provided destination pointer. +#! +#! Inputs: +#! Operand stack: [ATTACHMENT_COMMITMENT, dest_ptr] +#! Advice map: { +#! ATTACHMENT_COMMITMENT: [[ATTACHMENT_ELEMENTS]], +#! } +#! Outputs: +#! Operand stack: [num_words] +#! +#! Where: +#! - ATTACHMENT_COMMITMENT is the hash commitment to the attachment elements. +#! - dest_ptr is the memory address to which to write the attachment data. +#! - num_words is the number of words in the attachment. +pub proc write_attachment_to_memory + # push the number of attachment elements from the advice map onto the advice stack + adv.push_mapvaln + # OS => [ATTACHMENT_COMMITMENT, dest_ptr] + # AS => [num_elements, [ATTACHMENT_ELEMENTS]] + + # SAFETY: if the provided num_elements is invalid, the commitment check would fail in + # pipe_preimage_to_memory so we assume validity and only do basic checks to protect against + # invalid advice inputs. + adv_push u32assert.err="invalid attachment num_elements advice input" + u32divmod.WORD_NUM_ELEMENTS + # OS => [remainder, num_words, ATTACHMENT_COMMITMENT, dest_ptr] + # AS => [[ATTACHMENT_ELEMENTS]] + + # assert that num_elements is a multiple of WORD_NUM_ELEMENTS + eq.0 assert.err="attachment num_elements is not a multiple of WORD_NUM_ELEMENTS" + # OS => [num_words, ATTACHMENT_COMMITMENT, dest_ptr] + # AS => [[ATTACHMENT_ELEMENTS]] + + swap.5 dup.5 + # OS => [num_words, dest_ptr, ATTACHMENT_COMMITMENT, num_words] + # AS => [[ATTACHMENT_ELEMENTS]] + + # pipe the attachment data into memory, validating against ATTACHMENT_COMMITMENT + exec.mem::pipe_preimage_to_memory drop + # => [num_words] +end + +#! Writes the attachment with the provided index from the provided attachment commitments to the +#! memory specified by the destination pointer. +#! +#! Inputs: [num_attachments, attachment_commitments_ptr, attachment_idx, dest_ptr] +#! Outputs: [num_words] +#! +#! Where: +#! - attachment_idx is the index of the attachment to retrieve. +#! - attachment_commitments_ptr is a pointer to the attachment commitments in memory. +#! - dest_ptr is the memory address to which to write the attachment data. +#! - num_attachments is the number of attachments. +#! - num_words is the number of words in the attachment. +#! +#! Panics if: +#! - the attachment index is greater or equal to the number of attachments. +#! - the sequential hash over the attachment data in the advice inputs does not match the +#! attachment commitment. +#! +#! Invocation: exec +pub proc write_indexed_attachment_to_memory + # assert attachment_idx < num_attachments + dup.2 swap u32assert2.err=ERR_OUTPUT_NOTE_ATTACHMENT_IDX_OUT_OF_BOUNDS + u32lt assert.err=ERR_OUTPUT_NOTE_ATTACHMENT_IDX_OUT_OF_BOUNDS + # => [attachment_commitments_ptr, attachment_idx, dest_ptr] + + # compute the memory address of the attachment commitment: + # commitment_ptr = attachment_commitments_ptr + attachment_idx * WORD_NUM_ELEMENTS + swap mul.WORD_NUM_ELEMENTS add + # => [commitment_ptr, dest_ptr] + + # load the ATTACHMENT_COMMITMENT from memory + padw movup.4 mem_loadw_le + # => [ATTACHMENT_COMMITMENT, dest_ptr] + + exec.write_attachment_to_memory + # => [num_words] +end + +#! Computes the recipient hash from note storage, script root, and serial number. #! #! This procedure computes the commitment of the note storage and then uses it to calculate the note #! recipient by hashing this commitment, the provided script root, and the serial number. #! #! Inputs: #! Operand stack: [storage_ptr, num_storage_items, SERIAL_NUM, SCRIPT_ROOT] -#! Advice map: { -#! STORAGE_COMMITMENT: [INPUTS], -#! } #! Outputs: #! Operand stack: [RECIPIENT] #! Advice map: { @@ -110,7 +236,7 @@ end #! - num_storage_items is greater than 1024. #! #! Invocation: exec -pub proc build_recipient +pub proc compute_and_store_recipient dup.1 dup.1 # => [storage_ptr, num_storage_items, storage_ptr, num_storage_items, SERIAL_NUM, SCRIPT_ROOT] @@ -148,7 +274,7 @@ pub proc build_recipient # => [RECIPIENT] end -#! Returns the RECIPIENT for a specified SERIAL_NUM, SCRIPT_ROOT and STORAGE_COMMITMENT. +#! Computes the RECIPIENT for a specified SERIAL_NUM, SCRIPT_ROOT and STORAGE_COMMITMENT. #! #! Inputs: [SERIAL_NUM, SCRIPT_ROOT, STORAGE_COMMITMENT] #! Outputs: [RECIPIENT] @@ -160,7 +286,7 @@ end #! - RECIPIENT is the recipient of the note. #! #! Invocation: exec -pub proc build_recipient_hash +pub proc compute_recipient padw swapw # => [SERIAL_NUM, EMPTY_WORD, SCRIPT_ROOT, STORAGE_COMMITMENT] @@ -174,46 +300,158 @@ pub proc build_recipient_hash # => [RECIPIENT] end -#! Extracts the sender ID from the provided metadata header. +#! Extracts the sender ID from the provided metadata. #! -#! Inputs: [METADATA_HEADER] +#! Inputs: [METADATA] #! Outputs: [sender_id_suffix, sender_id_prefix] #! #! Where: -#! - METADATA_HEADER is the metadata of a note. +#! - METADATA is the metadata of a note. #! - sender_{suffix,prefix} are the suffix and prefix felts of the sender ID of the note which #! metadata was provided. -pub proc extract_sender_from_metadata - # => [sender_id_suffix_and_note_type, sender_id_prefix, tag, attachment_kind_scheme] +pub proc metadata_into_sender + # => [sender_id_suffix_type_version, sender_id_prefix, tag, attachment_kind_scheme] # drop tag and attachment_kind_scheme movup.3 drop movup.2 drop - # => [sender_id_suffix_and_note_type, sender_id_prefix] + # => [sender_id_suffix_type_version, sender_id_prefix] # extract suffix of sender from merged layout, which means clearing the least significant byte exec.account_id::shape_suffix # => [sender_id_suffix, sender_id_prefix] end -#! Extracts the attachment kind and scheme from the provided metadata header. +#! Extracts the attachment's schemes from the provided metadata. #! -#! Inputs: [METADATA_HEADER] -#! Outputs: [attachment_kind, attachment_scheme] +#! Inputs: [METADATA] +#! Outputs: [attachment_0_scheme, attachment_1_scheme, attachment_2_scheme, attachment_3_scheme] #! #! Where: -#! - METADATA_HEADER is the metadata of a note. -#! - attachment_kind is the attachment kind of the note. -#! - attachment_scheme is the attachment scheme of the note. +#! - METADATA is the metadata word of a note. +#! - attachment_n_scheme is the scheme of the nth attachment (0 if absent). #! #! Invocation: exec -pub proc extract_attachment_info_from_metadata - # => [sender_id_suffix_and_note_type, sender_id_prefix, tag, attachment_kind_scheme] +pub proc metadata_into_attachment_schemes + # => [sender_id_suffix_type_version, sender_id_prefix, tag, schemes] + drop drop drop - # => [attachment_kind_scheme] + # => [schemes] - # deconstruct the attachment_kind_scheme to extract the attachment_scheme - # attachment_kind_scheme = [30 zero bits | attachment_kind (2 bits) | attachment_scheme (32 bits)] - # u32split splits into [lo, hi] where lo is attachment_scheme u32split swap - # => [attachment_kind, attachment_scheme] + # => [schemes_hi, schemes_lo] + + # extract attachment scheme 3 from bits 48..64 + dup u32and.0xffff0000 u32shr.16 + # => [attachment_3_scheme, schemes_hi, schemes_lo] + + # extract attachment scheme 2 from bits 32..48 + swap u32and.0xffff + # => [attachment_2_scheme, attachment_3_scheme, schemes_lo] + + # extract attachment scheme 1 from bits 16..32 + dup.2 u32and.0xffff0000 u32shr.16 + # => [attachment_1_scheme, attachment_2_scheme, attachment_3_scheme, schemes_lo] + + movup.3 u32and.0xffff + # => [attachment_0_scheme, attachment_1_scheme, attachment_2_scheme, attachment_3_scheme] +end + +#! Extracts the note type from the provided metadata. +#! +#! The note type is encoded as a single bit at the 4th position from the right side (LSB) of the +#! first felt of the metadata, where 0 = Private and 1 = Public. +#! +#! Inputs: [METADATA] +#! Outputs: [note_type] +#! +#! Where: +#! - METADATA is the metadata of a note, laid out on the stack as +#! [sender_id_suffix_type_version, sender_id_prefix, tag, attachment_schemes]. +#! The first felt (sender_id_suffix_type_version) has the following bit layout: +#! [sender_id_suffix (56 bits) | reserved (3 bits) | note_type (1 bit) | version (4 bits)] +#! - note_type is the type of the note (0 for private, 1 for public). +#! +#! Invocation: exec +pub proc metadata_into_note_type + movdn.3 drop drop drop + # => [sender_id_suffix_type_version] + + u32split swap drop + # => [lo32] + + u32shr.4 + # => [shifted] + + u32and.1 + # => [note_type] +end + +#! Extracts the tag from the provided metadata. +#! +#! The tag is stored in the lower 32 bits of the tag element. The upper 32 bits are reserved. +#! +#! Inputs: [METADATA] +#! Outputs: [tag] +#! +#! Where: +#! - METADATA is the metadata word of a note. +#! - tag is the lower 32 bits of the tag element. +#! +#! Invocation: exec +pub proc metadata_into_tag + drop drop swap drop + # => [tag_element] + + # extract the lower 32 bits as the tag + u32split swap drop + # => [tag] +end + +#! Searches the metadata for the specified attachment scheme and returns the index of the +#! first matching slot. +#! +#! Inputs: [attachment_scheme, METADATA] +#! Outputs: [is_found, attachment_idx] +#! +#! Where: +#! - attachment_scheme is the scheme to search for. +#! - METADATA is the metadata word of a note. +#! - is_found is 1 if the scheme was found, 0 otherwise. +#! - attachment_idx is the index (0-3) of the first matching slot, or undefined if not found. +#! +#! Invocation: exec +pub proc find_attachment_idx + movdn.4 exec.metadata_into_attachment_schemes + # => [scheme_0, scheme_1, scheme_2, scheme_3, attachment_scheme] + + # initialize is_found = 0 and attachment_idx = 0 + movup.4 push.0 push.0 + # => [attachment_idx = 0, is_found = 0, attachment_scheme, scheme_0, scheme_1, scheme_2, scheme_3] + + # iterate over the four schemes + repeat.4 + # => [attachment_idx, is_found, attachment_scheme, scheme_n, ...] + + # check if scheme_n is the scheme we're trying to find + dup.2 movup.4 eq + # => [is_scheme_n, attachment_idx, is_found, attachment_scheme, scheme_n+1, ...] + + # set is_found = is_found || is_scheme_n + movup.2 or swap + # => [attachment_idx, is_found', attachment_scheme, scheme_n+1, ...] + + # create prospective attachment idx by incrementing the current one + dup add.1 swap dup.2 + # => [is_found', attachment_idx, attachment_idx+1, is_found', attachment_scheme, scheme_n+1, ...] + + # if is_found' attachment_idx remains. + # if !is_found' attachment_idx+1 remains. + # this essentially increments the attachment idx as long as no match was found. + cdrop + # => [attachment_idx', is_found', attachment_scheme, scheme_n+1, ...] + end + # => [attachment_idx', is_found', attachment_scheme] + + movup.2 drop swap + # => [is_found', attachment_idx'] end diff --git a/crates/miden-protocol/asm/protocol/output_note.masm b/crates/miden-protocol/asm/protocol/output_note.masm index d9ce865024..ae202f7873 100644 --- a/crates/miden-protocol/asm/protocol/output_note.masm +++ b/crates/miden-protocol/asm/protocol/output_note.masm @@ -1,18 +1,13 @@ use miden::protocol::kernel_proc_offsets::OUTPUT_NOTE_CREATE_OFFSET use miden::protocol::kernel_proc_offsets::OUTPUT_NOTE_GET_ASSETS_INFO_OFFSET use miden::protocol::kernel_proc_offsets::OUTPUT_NOTE_ADD_ASSET_OFFSET -use miden::protocol::kernel_proc_offsets::OUTPUT_NOTE_SET_ATTACHMENT_OFFSET +use miden::protocol::kernel_proc_offsets::OUTPUT_NOTE_ADD_ATTACHMENT_OFFSET use miden::protocol::kernel_proc_offsets::OUTPUT_NOTE_GET_RECIPIENT_OFFSET use miden::protocol::kernel_proc_offsets::OUTPUT_NOTE_GET_METADATA_OFFSET +use miden::protocol::kernel_proc_offsets::OUTPUT_NOTE_GET_ATTACHMENTS_COMMITMENT_OFFSET +use miden::protocol::util::constants::WORD_NUM_ELEMENTS use miden::protocol::note - -# CONSTANTS -# ================================================================================================= - -# Re-export constants for note attachment kinds -pub use miden::protocol::util::note::ATTACHMENT_KIND_NONE -pub use miden::protocol::util::note::ATTACHMENT_KIND_WORD -pub use miden::protocol::util::note::ATTACHMENT_KIND_ARRAY +use miden::core::crypto::hashes::poseidon2 # PROCEDURES # ================================================================================================= @@ -105,7 +100,7 @@ end #! information about the layout of each asset see the description of the `Asset` Rust type. #! #! Inputs: [dest_ptr, note_index] -#! Outputs: [num_assets, dest_ptr, note_index] +#! Outputs: [num_assets] #! #! Where: #! - dest_ptr is the memory address to write the assets. @@ -118,12 +113,50 @@ end #! Invocation: exec pub proc get_assets # get the assets commitment and assets number - dup.1 exec.get_assets_info - # => [ASSETS_COMMITMENT, num_assets, dest_ptr, note_index] + swap exec.get_assets_info + # => [ASSETS_COMMITMENT, num_assets, dest_ptr] + + # save num_assets for the return value + dup.4 movdn.6 + # => [ASSETS_COMMITMENT, num_assets, dest_ptr, num_assets] # write the assets stored in the advice map to the specified memory pointer exec.note::write_assets_to_memory - # => [num_assets, dest_ptr, note_index] + # => [num_assets] +end + +#! Returns the commitment over all attachments of the output note with the specified index. +#! +#! Inputs: [note_index] +#! Outputs: [ATTACHMENTS_COMMITMENT] +#! +#! Where: +#! - note_index is the index of the output note whose attachments commitment should be returned. +#! - ATTACHMENTS_COMMITMENT is the commitment to all attachments of the note, or the EMPTY_WORD if +#! the note does not have any attachments. +#! +#! Panics if: +#! - the note index is greater or equal to the total number of output notes. +#! +#! Invocation: exec +pub proc get_attachments_commitment + # start padding the stack + push.0.0 movup.2 + # => [note_index, 0, 0] + + push.OUTPUT_NOTE_GET_ATTACHMENTS_COMMITMENT_OFFSET + # => [offset, note_index, 0, 0] + + # pad the stack + padw swapw padw padw swapdw + # => [offset, note_index, pad(14)] + + syscall.exec_kernel_proc + # => [ATTACHMENTS_COMMITMENT, pad(12)] + + # clean the stack + swapdw dropw dropw swapw dropw + # => [ATTACHMENTS_COMMITMENT] end #! Adds the asset to the note specified by the index. @@ -153,39 +186,41 @@ pub proc add_asset # => [] end -#! Sets the attachment of the note specified by the index. +#! Adds an attachment to the note specified by the note index. #! -#! If attachment_kind == Array, there must be an advice map entry for ATTACHMENT (see below). +#! There must be an advice map entry for ATTACHMENT_COMMITMENT that maps to the raw attachment +#! elements. #! #! Inputs: -#! Operand Stack: [note_idx, attachment_scheme, attachment_kind, ATTACHMENT] +#! Operand Stack: [attachment_scheme, ATTACHMENT_COMMITMENT, note_idx] #! Advice map: { -#! ATTACHMENT?: [[ATTACHMENT_ELEMENTS]], +#! ATTACHMENT_COMMITMENT: [[ATTACHMENT_ELEMENTS]], #! } #! Outputs: [] #! #! Where: -#! - note_idx is the index of the note on which the attachment is set. #! - attachment_scheme is the user-defined scheme of the attachment. -#! - attachment_kind is the kind of the attachment content. -#! - ATTACHMENT is the attachment to be set. -#! - ATTACHMENT_ELEMENTS are the elements for which ATTACHMENT is the sequential commitment (only -#! needed if attachment_kind == Array). +#! - ATTACHMENT_COMMITMENT is the hash commitment to the attachment elements. +#! - note_idx is the index of the note to which the attachment is added. +#! - ATTACHMENT_ELEMENTS are the elements for which ATTACHMENT_COMMITMENT is the sequential +#! commitment. #! #! Panics if: #! - the procedure is called when the active account is not the native one. #! - the note index points to a non-existent output note. -#! - the attachment kind or scheme does not fit into a u32. -#! - the attachment kind is an unknown variant. +#! - the attachment scheme is 0 or exceeds 65534. +#! - the note already has 4 attachments. +#! - the number of words in the attachment exceeds 256. +#! - the total number of attachment words in the note exceeds 512. #! #! Invocation: exec -pub proc set_attachment - push.OUTPUT_NOTE_SET_ATTACHMENT_OFFSET - # => [offset, note_idx, attachment_scheme, attachment_kind, ATTACHMENT] +pub proc add_attachment + push.OUTPUT_NOTE_ADD_ATTACHMENT_OFFSET + # => [offset, attachment_scheme, ATTACHMENT_COMMITMENT, note_idx] # pad the stack before the syscall - padw padw swapdw - # => [offset, note_idx, attachment_scheme, attachment_kind, ATTACHMENT, pad(8)] + push.0 movdn.7 padw padw swapdw + # => [offset, attachment_scheme, ATTACHMENT_COMMITMENT, note_idx, pad(9)] syscall.exec_kernel_proc # => [pad(16)] @@ -195,61 +230,114 @@ pub proc set_attachment # => [] end -#! Sets the attachment of the note specified by the note index to the provided word. +#! Adds a single-word attachment to the note specified by the note index. #! -#! This overwrites any previously set attachment. +#! Hashes the raw attachment word to produce the commitment, inserts the raw elements into the +#! advice map keyed by that commitment, then delegates to `add_attachment`. #! -#! Inputs: [note_idx, attachment_scheme, ATTACHMENT] +#! Inputs: [attachment_scheme, ATTACHMENT, note_idx] #! Outputs: [] #! #! Where: -#! - note_idx is the index of the note on which the attachment is set. #! - attachment_scheme is the user-defined scheme of the attachment. -#! - ATTACHMENT is the raw attachment to set. +#! - ATTACHMENT is the raw attachment word. +#! - note_idx is the index of the note to which the attachment is added. #! #! Panics if: #! - the procedure is called when the active account is not the native one. #! - the note index points to a non-existent output note. -#! - the attachment_scheme does not fit into a u32. +#! - the attachment scheme is 0 or exceeds 65534. +#! - the note already has 4 attachments. +#! - the total number of attachment words in the note exceeds 512. #! #! Invocation: exec -pub proc set_word_attachment - push.ATTACHMENT_KIND_WORD movdn.2 - # => [note_idx, attachment_scheme, attachment_kind, ATTACHMENT] +@locals(4) +pub proc add_word_attachment + # => [attachment_scheme, ATTACHMENT, note_idx] + + # Store ATTACHMENT to local memory for hashing and advice map insertion + movdn.4 + # => [ATTACHMENT, attachment_scheme, note_idx] + + loc_storew_le.0 + # => [ATTACHMENT, attachment_scheme, note_idx] + + exec.poseidon2::hash + # => [ATTACHMENT_COMMITMENT, attachment_scheme, note_idx] - exec.set_attachment + locaddr.0 dup add.4 + # => [end_ptr, start_ptr, ATTACHMENT_COMMITMENT, attachment_scheme, note_idx] + + movdn.5 movdn.4 + # => [ATTACHMENT_COMMITMENT, start_ptr, end_ptr, attachment_scheme, note_idx] + + # Insert the raw attachment elements into the advice map keyed by the commitment. + adv.insert_mem + # => [ATTACHMENT_COMMITMENT, start_ptr, end_ptr, attachment_scheme, note_idx] + + # Clean up and arrange stack for add_attachment + movup.4 drop movup.4 drop + # => [ATTACHMENT_COMMITMENT, attachment_scheme, note_idx] + + movup.4 + # => [attachment_scheme, ATTACHMENT_COMMITMENT, note_idx] + + exec.add_attachment # => [] end -#! Sets the attachment of the note specified by the note index to the provided ATTACHMENT which -#! commits to an array of felts. +#! Adds a multi-word attachment to the note specified by the note index. #! -#! This overwrites any previously set attachment. +#! Hashes the raw attachment words starting at attachment_ptr to produce the commitment, inserts the raw +#! elements into the advice map keyed by that commitment, then delegates to `add_attachment`. #! -#! Inputs: -#! Operand Stack: [note_idx, attachment_scheme, ATTACHMENT] -#! Advice map: { -#! ATTACHMENT: [[ATTACHMENT_ELEMENTS]], -#! } +#! To add a single word as an attachment, prefer add_word_attachment. +#! +#! Inputs: [attachment_scheme, num_words, attachment_ptr, note_idx] #! Outputs: [] #! #! Where: -#! - note_idx is the index of the note on which the attachment is set. #! - attachment_scheme is the user-defined scheme of the attachment. -#! - ATTACHMENT is the commitment of the set of elements that form the note attachment. -#! - ATTACHMENT_ELEMENTS are the elements for which ATTACHMENT is the sequential commitment. +#! - attachment_ptr is the pointer to the first word of the raw attachment data in memory. +#! - num_words is the number of words of the attachment data. +#! - note_idx is the index of the note to which the attachment is added. #! #! Panics if: #! - the procedure is called when the active account is not the native one. #! - the note index points to a non-existent output note. -#! - the attachment_scheme does not fit into a u32. +#! - the attachment scheme is 0 or exceeds 65534. +#! - the note already has 4 attachments. +#! - the number of words in the attachment exceeds 256. +#! - the total number of attachment words in the note exceeds 512. #! #! Invocation: exec -pub proc set_array_attachment - push.ATTACHMENT_KIND_ARRAY movdn.2 - # => [note_idx, attachment_scheme, attachment_kind, ATTACHMENT] +pub proc add_attachment_from_memory + # Compute num_elements = num_words * WORD_NUM_ELEMENTS + movdn.3 mul.WORD_NUM_ELEMENTS + # => [num_elements, attachment_ptr, note_idx, attachment_scheme] + + # Compute end_ptr = attachment_ptr + num_elements + dup.1 dup.1 add + # => [end_ptr, num_elements, attachment_ptr, note_idx, attachment_scheme] - exec.set_attachment + movdn.2 dup.1 + # => [attachment_ptr, num_elements, attachment_ptr, end_ptr, note_idx, attachment_scheme] + + exec.poseidon2::hash_elements + # => [ATTACHMENT_COMMITMENT, attachment_ptr, end_ptr, note_idx, attachment_scheme] + + # Insert the raw attachment elements into the advice map keyed by the commitment + adv.insert_mem + # => [ATTACHMENT_COMMITMENT, attachment_ptr, end_ptr, note_idx, attachment_scheme] + + # Clean up attachment_ptr and end_ptr + movup.4 drop movup.4 drop + # => [ATTACHMENT_COMMITMENT, note_idx, attachment_scheme] + + movup.5 + # => [attachment_scheme, ATTACHMENT_COMMITMENT, note_idx] + + exec.add_attachment # => [] end @@ -289,12 +377,11 @@ end #! Returns the metadata of the output note with the specified index. #! #! Inputs: [note_index] -#! Outputs: [NOTE_ATTACHMENT, METADATA_HEADER] +#! Outputs: [METADATA] #! #! Where: #! - note_index is the index of the output note whose metadata should be returned. -#! - METADATA_HEADER is the metadata header of the specified output note. -#! - NOTE_ATTACHMENT is the attachment of the specified output note. +#! - METADATA is the metadata of the specified output note. #! #! Panics if: #! - the note index is greater or equal to the total number of output notes. @@ -313,9 +400,98 @@ pub proc get_metadata # => [offset, note_index, pad(14)] syscall.exec_kernel_proc - # => [NOTE_ATTACHMENT, METADATA_HEADER, pad(8)] + # => [METADATA, pad(12)] # clean the stack - swapdw dropw dropw - # => [NOTE_ATTACHMENT, METADATA_HEADER] + swapdw dropw dropw swapw dropw + # => [METADATA] +end + +#! Searches the metadata of the output note with the specified index for the specified +#! attachment scheme and returns the index of the first matching slot. +#! +#! Inputs: [attachment_scheme, note_index] +#! Outputs: [is_found, attachment_idx] +#! +#! Where: +#! - attachment_scheme is the scheme of the attachment to find. +#! - note_index is the index of the output note. +#! - is_found is 1 if the attachment with the provided scheme was found, 0 otherwise. +#! - attachment_idx is the index (0-3) of the first matching slot, or undefined if not found. +#! +#! Panics if: +#! - the note index is greater or equal to the total number of output notes. +#! +#! Invocation: exec +pub proc find_attachment + swap exec.get_metadata + # => [METADATA, attachment_scheme] + + movup.4 + # => [attachment_scheme, METADATA] + + exec.note::find_attachment_idx + # => [is_found, attachment_idx] +end + +#! Writes the attachment commitments of the output note with the specified index to the provided +#! destination pointer. +#! +#! Inputs: [dest_ptr, note_index] +#! Outputs: [num_attachments] +#! +#! Where: +#! - dest_ptr is the memory address to which to write the attachment commitments. +#! - note_index is the index of the output note. +#! - num_attachments is the number of attachments in the note. +#! +#! Panics if: +#! - the note index is greater or equal to the total number of output notes. +#! - the sequential hash over the attachment commitments in the advice inputs does not match the +#! attachments commitment. +#! +#! Invocation: exec +pub proc write_attachment_commitments_to_memory + swap exec.get_attachments_commitment + # => [ATTACHMENTS_COMMITMENT, dest_ptr] + + exec.note::write_attachment_commitments_to_memory + # => [num_attachments] +end + +#! Writes the attachment with the provided index from the output note with the specified index +#! to the provided destination pointer. +#! +#! Inputs: [dest_ptr, attachment_idx, note_index] +#! Outputs: [num_words] +#! +#! Where: +#! - dest_ptr is the memory address to which to write the attachment data. +#! - attachment_idx is the index of the attachment to retrieve. +#! - note_index is the index of the output note. +#! - num_words is the number of words in the attachment. +#! +#! Panics if: +#! - the note index is greater or equal to the total number of output notes. +#! - the attachment index is greater or equal to the number of attachments. +#! - the sequential hash over the attachment data in the advice inputs does not match the +#! attachment commitment. +#! +#! Invocation: exec +@locals(16) +pub proc write_attachment_to_memory + # set up call to write the attachment commitments to local memory + # we allocate 16 elements of memory (max num attachments * WORD_SIZE) to store all + # four attachment commitments + movdn.2 swap locaddr.0 + # => [attachment_commitments_ptr, note_index, attachment_idx, dest_ptr] + + exec.write_attachment_commitments_to_memory + # => [num_attachments, attachment_idx, dest_ptr] + + locaddr.0 swap + # => [num_attachments, attachment_commitments_ptr, attachment_idx, dest_ptr] + + exec.note::write_indexed_attachment_to_memory + # => [num_words] end diff --git a/crates/miden-protocol/asm/protocol/tx.masm b/crates/miden-protocol/asm/protocol/tx.masm index 8dfe18d13d..7d36dc951f 100644 --- a/crates/miden-protocol/asm/protocol/tx.masm +++ b/crates/miden-protocol/asm/protocol/tx.masm @@ -9,6 +9,7 @@ use miden::protocol::kernel_proc_offsets::TX_PREPARE_FPI_OFFSET use miden::protocol::kernel_proc_offsets::TX_EXEC_FOREIGN_PROC_OFFSET use miden::protocol::kernel_proc_offsets::TX_UPDATE_EXPIRATION_BLOCK_DELTA_OFFSET use miden::protocol::kernel_proc_offsets::TX_GET_EXPIRATION_DELTA_OFFSET +use miden::protocol::kernel_proc_offsets::TX_GET_TX_SCRIPT_ROOT_OFFSET #! Returns the block number of the transaction reference block. #! @@ -130,8 +131,8 @@ pub proc get_input_notes_commitment # => [INPUT_NOTES_COMMITMENT] end -#! Returns the output notes commitment. This is computed as a sequential hash of (note_id, note_metadata) -#! tuples over all output notes. +#! Returns the output notes commitment. This is computed as a sequential hash of +#! (note_details_commitment, note_metadata_commitment) tuples over all output notes. #! #! Inputs: [0, 0, 0, 0] #! Outputs: [OUTPUT_NOTES_COMMITMENT] @@ -319,3 +320,29 @@ pub proc get_expiration_block_delta swapdw dropw dropw swapw dropw movdn.3 drop drop drop # => [expiration_delta] end + +#! Returns the transaction script root, or the empty word if no transaction script was executed. +#! +#! Inputs: [] +#! Outputs: [TX_SCRIPT_ROOT] +#! +#! Where: +#! - TX_SCRIPT_ROOT is the root of the transaction script executed in this transaction, or the +#! empty word if no transaction script was executed. +#! +#! Invocation: exec +pub proc get_tx_script_root + # pad the stack + padw padw padw push.0.0.0 + # => [pad(15)] + + push.TX_GET_TX_SCRIPT_ROOT_OFFSET + # => [offset, pad(15)] + + syscall.exec_kernel_proc + # => [TX_SCRIPT_ROOT, pad(12)] + + # clean the stack + swapdw dropw dropw swapw dropw + # => [TX_SCRIPT_ROOT] +end diff --git a/crates/miden-protocol/asm/shared_modules/account_id.masm b/crates/miden-protocol/asm/shared_modules/account_id.masm index 9f1637a9cc..15f2a3764b 100644 --- a/crates/miden-protocol/asm/shared_modules/account_id.masm +++ b/crates/miden-protocol/asm/shared_modules/account_id.masm @@ -5,76 +5,21 @@ const ERR_ACCOUNT_ID_UNKNOWN_VERSION="unknown version in account ID" const ERR_ACCOUNT_ID_SUFFIX_MOST_SIGNIFICANT_BIT_MUST_BE_ZERO="most significant bit of the account ID suffix must be zero" -const ERR_ACCOUNT_ID_UNKNOWN_STORAGE_MODE="unknown account storage mode in account ID" - const ERR_ACCOUNT_ID_SUFFIX_LEAST_SIGNIFICANT_BYTE_MUST_BE_ZERO="least significant byte of the account ID suffix must be zero" -const ERR_ACCOUNT_ID_NON_PUBLIC_NETWORK_ACCOUNT="the account ID must have storage mode public if the network flag is set" - # CONSTANTS # ================================================================================================= -# Bit pattern for a faucet account, after the account type mask has been applied. -const FAUCET_ACCOUNT=0x20 # 0b10_0000 - -# Bit pattern for an account w/ updatable code, after the account type mask has been applied. -const REGULAR_ACCOUNT_UPDATABLE_CODE=0x10 # 0b01_0000 - -# Bit pattern for an account w/ immutable code, after the account type mask has been applied. -const REGULAR_ACCOUNT_IMMUTABLE_CODE=0 # 0b00_0000 - -# Bit pattern for a fungible faucet w/ immutable code, after the account type mask has been applied. -const FUNGIBLE_FAUCET_ACCOUNT=0x20 # 0b10_0000 - -# Bit pattern for a non-fungible faucet w/ immutable code, after the account type mask has been -# applied. -const NON_FUNGIBLE_FAUCET_ACCOUNT=0x30 # 0b11_0000 - -# Given the least significant 32 bits of an account id's prefix, this mask defines the bits used -# to determine the account type. -const ACCOUNT_ID_TYPE_MASK_U32=0x30 # 0b11_0000 - # Given the least significant 32 bits of an account id's prefix, this mask defines the bits used # to determine the account version. const ACCOUNT_VERSION_MASK_U32=0x0f # 0b1111 -# Given the least significant 32 bits of an account ID's prefix, this mask defines the bits used -# to determine the account storage mode. -const ACCOUNT_ID_STORAGE_MODE_MASK_U32=0xC0 # 0b1100_0000 - -# Given the least significant 32 bits of an account ID's first felt with the storage mode mask -# applied, this value defines the non-existent, invalid storage mode. -const ACCOUNT_ID_STORAGE_MODE_INVALID_U32=0xc0 # 0b1100_0000 +# Version 1 of the account ID. +const VERSION_1=1 # PROCEDURES # ================================================================================================= -#! Returns a boolean indicating whether the account is a fungible faucet. -#! -#! Inputs: [account_id_prefix] -#! Outputs: [is_fungible_faucet] -#! -#! Where: -#! - account_id_prefix is the prefix of the account ID. -#! - is_fungible_faucet is a boolean indicating whether the account is a fungible faucet. -pub proc is_fungible_faucet - exec.id_type eq.FUNGIBLE_FAUCET_ACCOUNT - # => [is_fungible_faucet] -end - -#! Returns a boolean indicating whether the account is a non-fungible faucet. -#! -#! Inputs: [account_id_prefix] -#! Outputs: [is_non_fungible_faucet] -#! -#! Where: -#! - account_id_prefix is the prefix of the account ID. -#! - is_non_fungible_faucet is a boolean indicating whether the account is a non-fungible faucet. -pub proc is_non_fungible_faucet - exec.id_type eq.NON_FUNGIBLE_FAUCET_ACCOUNT - # => [is_non_fungible_faucet] -end - #! Returns a boolean indicating whether the given account_ids are equal. #! #! Inputs: [account_id_suffix, account_id_prefix, other_account_id_suffix, other_account_id_prefix] @@ -94,49 +39,10 @@ pub proc is_equal # => [is_id_equal] end -#! Returns a boolean indicating whether the account is a faucet. -#! -#! Inputs: [account_id_prefix] -#! Outputs: [is_faucet] +#! Validates an account ID. #! -#! Where: -#! - account_id_prefix is the prefix of the account ID. -#! - is_faucet is a boolean indicating whether the account is a faucet. -pub proc is_faucet - u32split swap drop u32and.FAUCET_ACCOUNT neq.0 - # => [is_faucet] -end - -#! Returns a boolean indicating whether the account is a regular updatable account. -#! -#! Inputs: [account_id_prefix] -#! Outputs: [is_updatable_account] -#! -#! Where: -#! - account_id_prefix is the prefix of the account ID. -#! - is_updatable_account is a boolean indicating whether the account is a regular updatable -#! account. -pub proc is_updatable_account - exec.id_type eq.REGULAR_ACCOUNT_UPDATABLE_CODE - # => [is_updatable_account] -end - -#! Returns a boolean indicating whether the account is a regular immutable account. -#! -#! Inputs: [account_id_prefix] -#! Outputs: [is_immutable_account] -#! -#! Where: -#! - account_id_prefix is the prefix of the account ID. -#! - is_immutable_account is a boolean indicating whether the account is a regular immutable -#! account. -pub proc is_immutable_account - exec.id_type eq.REGULAR_ACCOUNT_IMMUTABLE_CODE - # => [is_immutable_account] -end - -#! Validates an account ID. Note that this does not validate anything about the account type, -#! since any 2-bit pattern is a valid account type. +#! Note that this does not validate anything about the account type, since any 1-bit pattern is a +#! valid account type. #! #! Inputs: [account_id_suffix, account_id_prefix] #! Outputs: [] @@ -145,8 +51,7 @@ end #! - account_id_{suffix,prefix} are the suffix and prefix felts of the account ID. #! #! Panics if: -#! - account_id_prefix does not contain version zero. -#! - account_id_prefix does not contain either the public, network or private storage mode. +#! - account_id_prefix does not contain version one. #! - account_id_suffix does not have its most significant bit set to zero. #! - account_id_suffix does not have its lower 8 bits set to zero. pub proc validate @@ -168,27 +73,14 @@ pub proc validate assert.err=ERR_ACCOUNT_ID_SUFFIX_MOST_SIGNIFICANT_BIT_MUST_BE_ZERO # => [account_id_prefix] - # Validate version in prefix. For now only version 0 is supported. - # --------------------------------------------------------------------------------------------- - - dup exec.id_version - # => [id_version, account_id_prefix] - assertz.err=ERR_ACCOUNT_ID_UNKNOWN_VERSION - # => [account_id_prefix] - - # Validate storage mode in prefix. + # Validate version in prefix. For now only version 1 is supported. # --------------------------------------------------------------------------------------------- - # there are 3 valid and 1 invalid storage mode - # instead of checking the presence of any of the valid modes, we check the absence of the - # invalid mode - u32split swap drop - # => [account_id_prefix_lo] - u32and.ACCOUNT_ID_STORAGE_MODE_MASK_U32 - # => [id_storage_mode_masked] - eq.ACCOUNT_ID_STORAGE_MODE_INVALID_U32 - # => [is_storage_mode_invalid] - assertz.err=ERR_ACCOUNT_ID_UNKNOWN_STORAGE_MODE + exec.id_version + # => [id_version] + eq.VERSION_1 + # => [is_supported_version] + assert.err=ERR_ACCOUNT_ID_UNKNOWN_VERSION # => [] end @@ -235,22 +127,3 @@ proc id_version u32and.ACCOUNT_VERSION_MASK_U32 # => [id_version] end - -#! Returns the least significant half of an account ID prefix with the account type bits masked out. -#! -#! The account type can be obtained by comparing this value with the following constants: -#! - REGULAR_ACCOUNT_UPDATABLE_CODE -#! - REGULAR_ACCOUNT_IMMUTABLE_CODE -#! - FUNGIBLE_FAUCET_ACCOUNT -#! - NON_FUNGIBLE_FAUCET_ACCOUNT -#! -#! Inputs: [account_id_prefix] -#! Outputs: [account_type] -#! -#! Where: -#! - account_id_prefix is the prefix of the account ID. -#! - account_type is the account type. -proc id_type - u32split swap drop u32and.ACCOUNT_ID_TYPE_MASK_U32 - # => [account_type] -end diff --git a/crates/miden-protocol/asm/shared_utils/util/asset.masm b/crates/miden-protocol/asm/shared_utils/util/asset.masm index 5e0a254c31..c6ebf0ab53 100644 --- a/crates/miden-protocol/asm/shared_utils/util/asset.masm +++ b/crates/miden-protocol/asm/shared_utils/util/asset.masm @@ -1,7 +1,11 @@ # ERRORS # ================================================================================================= -const ERR_VAULT_INVALID_ENABLE_CALLBACKS = "enable_callbacks must be 0 or 1" +const ERR_VAULT_ASSET_METADATA_NOT_U32 = "asset metadata is not a u32" + +const ERR_VAULT_ASSET_METADATA_NON_ZERO_RESERVED_BITS = "reserved asset metadata bits are non-zero" + +const ERR_VAULT_ASSET_METADATA_UNKNOWN_COMPOSITION = "unknown asset metadata composition value" # CONSTANTS # ================================================================================================= @@ -17,11 +21,30 @@ pub const ASSET_SIZE = 8 # The offset of the asset value in an asset stored in memory. pub const ASSET_VALUE_MEMORY_OFFSET = 4 -# The flag representing disabled callbacks. -pub const CALLBACKS_DISABLED = 0 +#! The mask for the callback bits in the asset metadata. +const CALLBACKS_MASK = 4 # 0b100 + +#! The number of bits by which the callback bits must be shifted to the right to get the encoded +#! value. +const CALLBACKS_SHIFT = 2 + +#! The mask for the composition bits in the asset metadata. +const COMPOSITION_MASK = 3 # 0b11 + +# The flag representing the AssetComposition::None composition. +pub const COMPOSITION_NONE = 0 + +# The flag representing the AssetComposition::Fungible composition. +pub const COMPOSITION_FUNGIBLE = 1 -# The flag representing enabled callbacks. -pub const CALLBACKS_ENABLED = 1 +# The flag representing the AssetComposition::Custom composition. +pub const COMPOSITION_CUSTOM = 2 + +#! The composition value that is not valid. +const COMPOSITION_INVALID = 3 + +#! The u32 mask for the reserved bits in the asset metadata. +const METADATA_RESERVED_MASK = 0xfffffff8 # lower 8 bits: 0b1111_1000 # PROCEDURES # ================================================================================================= @@ -202,10 +225,14 @@ end #! - ASSET_KEY is the vault key for the fungible asset. #! #! Panics if: -#! - enable_callbacks is not 0 or 1. +#! - the resulting metadata byte is not well-formed. #! #! Invocation: exec pub proc create_fungible_key + # push the fungible composition for create_metadata + push.COMPOSITION_FUNGIBLE + # => [asset_composition, enable_callbacks, faucet_id_suffix, faucet_id_prefix] + exec.create_metadata # => [asset_metadata, faucet_id_suffix, faucet_id_prefix] @@ -221,7 +248,7 @@ end #! Creates a fungible asset for the specified fungible faucet and amount. #! -#! WARNING: Does not validate the faucet ID or amount. +#! WARNING: Does not validate the amount. #! #! Inputs: [enable_callbacks, faucet_id_suffix, faucet_id_prefix, amount] #! Outputs: [ASSET_KEY, ASSET_VALUE] @@ -251,7 +278,7 @@ end #! Creates a non fungible asset for the specified non-fungible faucet. #! -#! WARNING: Does not validate its inputs. +#! The provided asset key will be created with asset composition set to COMPOSITION_NONE. #! #! Inputs: [enable_callbacks, faucet_id_suffix, faucet_id_prefix, DATA_HASH] #! Outputs: [ASSET_KEY, ASSET_VALUE] @@ -265,10 +292,14 @@ end #! - ASSET_VALUE is the value of the created non-fungible asset, which is identical to DATA_HASH. #! #! Panics if: -#! - enable_callbacks is not 0 or 1. +#! - the resulting metadata byte is not well-formed. #! #! Invocation: exec pub proc create_non_fungible_asset_unchecked + # push the non-fungible (None) composition for create_metadata + push.COMPOSITION_NONE + # => [asset_composition, enable_callbacks, faucet_id_suffix, faucet_id_prefix, DATA_HASH] + exec.create_metadata # => [asset_metadata, faucet_id_suffix, faucet_id_prefix, DATA_HASH] @@ -318,27 +349,47 @@ end #! Outputs: [] #! #! Panics if: -#! - asset_metadata is not a valid u32 or exceeds CALLBACKS_ENABLED. +#! - asset_metadata is not a valid u32 +#! - has reserved bits 3-7 set. +#! - encodes an unknown asset composition. pub proc validate_metadata - u32assert.err=ERR_VAULT_INVALID_ENABLE_CALLBACKS - u32lte.CALLBACKS_ENABLED - assert.err=ERR_VAULT_INVALID_ENABLE_CALLBACKS + # assert that the metadata fits in a u8 + u32split swap + # => [hi, asset_metadata] + eq.0 assert.err=ERR_VAULT_ASSET_METADATA_NOT_U32 + # => [asset_metadata] + + # assert the reserved bits are all zero (bits 3..32) + dup u32and.METADATA_RESERVED_MASK + eq.0 assert.err=ERR_VAULT_ASSET_METADATA_NON_ZERO_RESERVED_BITS + # => [asset_metadata] + + exec.metadata_into_composition + # => [asset_composition] + + # assert the value is NOT the invalid composition, which implies it must be one of the valid + # ones + neq.COMPOSITION_INVALID assert.err=ERR_VAULT_ASSET_METADATA_UNKNOWN_COMPOSITION # => [] end #! Creates asset metadata from the provided inputs. #! -#! Inputs: [enable_callbacks] +#! Inputs: [asset_composition, enable_callbacks] #! Outputs: [asset_metadata] #! #! Where: +#! - asset_composition is the composition value (see COMPOSITION_* constants). #! - enable_callbacks is a flag (0 or 1) indicating whether the asset callbacks flag should be set. #! - asset_metadata is the asset metadata. #! #! Panics if: -#! - enable_callbacks is not 0 or 1. +#! - the resulting metadata byte has invalid reserved bits set. proc create_metadata - # for now, enable_callbacks is identical to asset_metadata + # merge (enable_callbacks << 2) | asset_composition + swap u32shl.CALLBACKS_SHIFT u32or + # => [asset_metadata] + dup exec.validate_metadata # => [asset_metadata] end @@ -354,7 +405,44 @@ end #! - asset_metadata is the asset metadata. #! - callbacks_enabled is 1 if callbacks are enabled and 0 if disabled. proc metadata_into_callbacks_enabled - # extract the least significant bit of the metadata - u32and.1 + # extract callback bit from the metadata + u32and.CALLBACKS_MASK u32shr.CALLBACKS_SHIFT # => [callbacks_enabled] end + +#! Extracts the asset composition from asset metadata. +#! +#! WARNING: asset_metadata is assumed to be a byte (in particular a valid u32) +#! +#! Inputs: [asset_metadata] +#! Outputs: [asset_composition] +#! +#! Where: +#! - asset_metadata is the asset metadata. +#! - asset_composition is the composition value (see COMPOSITION_* constants). +proc metadata_into_composition + # extract composition bits from the metadata + u32and.COMPOSITION_MASK + # => [asset_composition] +end + +#! Returns the asset composition from an asset vault key. +#! +#! Inputs: [ASSET_KEY] +#! Outputs: [asset_composition, ASSET_KEY] +#! +#! Where: +#! - ASSET_KEY is the vault key from which to extract the composition. +#! - asset_composition is the composition value (see COMPOSITION_* constants). +pub proc key_to_composition + # => [asset_id_suffix, asset_id_prefix, faucet_id_suffix_and_metadata, faucet_id_prefix] + + dup.2 + # => [faucet_id_suffix_and_metadata, ASSET_KEY] + + exec.split_suffix_and_metadata swap drop + # => [asset_metadata, ASSET_KEY] + + exec.metadata_into_composition + # => [asset_composition, ASSET_KEY] +end diff --git a/crates/miden-protocol/asm/shared_utils/util/constants.masm b/crates/miden-protocol/asm/shared_utils/util/constants.masm new file mode 100644 index 0000000000..585a45a453 --- /dev/null +++ b/crates/miden-protocol/asm/shared_utils/util/constants.masm @@ -0,0 +1,5 @@ +# CONSTANTS +# ================================================================================================= + +#! The number of field elements in a Word. +pub const WORD_NUM_ELEMENTS = 4 diff --git a/crates/miden-protocol/asm/shared_utils/util/note.masm b/crates/miden-protocol/asm/shared_utils/util/note.masm index 066dfcd2fb..81eb263373 100644 --- a/crates/miden-protocol/asm/shared_utils/util/note.masm +++ b/crates/miden-protocol/asm/shared_utils/util/note.masm @@ -4,9 +4,26 @@ # The maximum number of storage values associated with a single note. pub const MAX_NOTE_STORAGE_ITEMS = 1024 -#! Signals the absence of a note attachment. -pub const ATTACHMENT_KIND_NONE=0 -#! A note attachment consisting of a single Word. -pub const ATTACHMENT_KIND_WORD=1 -#! A note attachment consisting of the commitment to a set of felts. -pub const ATTACHMENT_KIND_ARRAY=2 +# Note type constants. These encode the note type in the lower byte of the metadata. +# See NoteType in the Rust protocol crate for details. + +#! Version 1 of the note metadata encoding. +pub const NOTE_METADATA_VERSION_1=1 + +#! The note type of private notes. +pub const NOTE_TYPE_PRIVATE=0 + +#! The note type of public notes. +pub const NOTE_TYPE_PUBLIC=1 + +#! The maximum attachment scheme value. +pub const MAX_ATTACHMENT_SCHEME=65534 + +#! The maximum number of words in an attachment. +pub const MAX_ATTACHMENT_WORDS=256 + +#! The maximum total number of words across all attachments in a note. +pub const MAX_ATTACHMENT_TOTAL_WORDS=512 + +#! The reserved value to signal a `None` note attachment scheme. +pub const ATTACHMENT_SCHEME_NONE = 1 diff --git a/crates/miden-protocol/benches/account_seed.rs b/crates/miden-protocol/benches/account_seed.rs index 77f6c107a2..1f67d1fad5 100644 --- a/crates/miden-protocol/benches/account_seed.rs +++ b/crates/miden-protocol/benches/account_seed.rs @@ -2,7 +2,7 @@ use std::time::Duration; use criterion::{Criterion, criterion_group, criterion_main}; use miden_protocol::Word; -use miden_protocol::account::{AccountId, AccountIdVersion, AccountStorageMode, AccountType}; +use miden_protocol::account::{AccountId, AccountIdVersion, AccountType}; use rand::{Rng, SeedableRng}; /// Running this benchmark with --no-default-features will use the single-threaded account seed @@ -35,9 +35,8 @@ fn grind_account_seed(c: &mut Criterion) { bench.iter(|| { AccountId::compute_account_seed( rng.random(), - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Public, - AccountIdVersion::Version0, + AccountType::Public, + AccountIdVersion::Version1, Word::empty(), Word::empty(), ) diff --git a/crates/miden-protocol/src/account/access.rs b/crates/miden-protocol/src/account/access.rs new file mode 100644 index 0000000000..1f0241bdd6 --- /dev/null +++ b/crates/miden-protocol/src/account/access.rs @@ -0,0 +1,131 @@ +use alloc::fmt; + +use crate::Felt; +use crate::errors::RoleSymbolError; +use crate::utils::ShortCapitalString; + +/// Represents a role symbol for role-based access control. +/// +/// Role symbols can consist of up to 12 uppercase Latin characters and underscores, e.g. +/// "MINTER", "BURNER", "MINTER_ADMIN". +/// +/// The label is stored internally as a validated short string (`A`–`Z` and `_`) and can be +/// converted to a [`Felt`] encoding via [`as_element()`](Self::as_element). +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct RoleSymbol(ShortCapitalString); + +impl RoleSymbol { + /// Alphabet used for role symbols (`A-Z` and `_`). + pub const ALPHABET: &'static str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ_"; + + /// The minimum integer value of an encoded [`RoleSymbol`]. + /// + /// This value encodes the "A" role symbol. + pub const MIN_ENCODED_VALUE: u64 = 1; + + /// The maximum integer value of an encoded [`RoleSymbol`]. + /// + /// This value encodes the "____________" role symbol (12 underscores). + pub const MAX_ENCODED_VALUE: u64 = 4052555153018976252; + + /// Constructs a new [`RoleSymbol`] from a string, panicking on invalid input. + /// + /// # Panics + /// + /// Panics if: + /// - The length of the provided string is less than 1 or greater than 12. + /// - The provided role symbol contains characters outside `A-Z` and `_`. + pub fn new_unchecked(role_symbol: &str) -> Self { + Self::new(role_symbol).expect("invalid role symbol") + } + + /// Creates a new [`RoleSymbol`] from the provided role symbol string. + /// + /// # Errors + /// Returns an error if: + /// - The length of the provided string is less than 1 or greater than 12. + /// - The provided role symbol contains characters outside `A-Z` and `_`. + pub fn new(role_symbol: &str) -> Result { + ShortCapitalString::from_ascii_uppercase_and_underscore(role_symbol) + .map(Self) + .map_err(Into::into) + } + + /// Returns the [`Felt`] encoding of this role symbol. + pub fn as_element(&self) -> Felt { + self.0.as_element(Self::ALPHABET).expect("RoleSymbol alphabet is always valid") + } +} + +impl fmt::Display for RoleSymbol { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl From for Felt { + fn from(role_symbol: RoleSymbol) -> Self { + role_symbol.as_element() + } +} + +impl From<&RoleSymbol> for Felt { + fn from(role_symbol: &RoleSymbol) -> Self { + role_symbol.as_element() + } +} + +impl TryFrom<&str> for RoleSymbol { + type Error = RoleSymbolError; + + fn try_from(role_symbol: &str) -> Result { + Self::new(role_symbol) + } +} + +impl TryFrom for RoleSymbol { + type Error = RoleSymbolError; + + /// Decodes a [`Felt`] representation of the role symbol into a [`RoleSymbol`]. + fn try_from(felt: Felt) -> Result { + ShortCapitalString::try_from_encoded_felt( + felt, + Self::ALPHABET, + Self::MIN_ENCODED_VALUE, + Self::MAX_ENCODED_VALUE, + ) + .map(Self) + .map_err(Into::into) + } +} + +#[cfg(test)] +mod tests { + use alloc::string::ToString; + + use assert_matches::assert_matches; + + use super::{Felt, RoleSymbol}; + use crate::errors::RoleSymbolError; + + #[test] + fn test_role_symbol_roundtrip_and_validation() { + let role_symbols = ["MINTER", "BURNER", "MINTER_ADMIN", "A", "A_B_C"]; + for role_symbol in role_symbols { + let encoded: Felt = RoleSymbol::new(role_symbol).unwrap().into(); + let decoded = RoleSymbol::try_from(encoded).unwrap(); + assert_eq!(decoded.to_string(), role_symbol); + } + + assert_matches!(RoleSymbol::new("").unwrap_err(), RoleSymbolError::InvalidLength(0)); + assert_matches!( + RoleSymbol::new("ABCDEFGHIJKLM").unwrap_err(), + RoleSymbolError::InvalidLength(13) + ); + assert_matches!( + RoleSymbol::new("MINTER-ADMIN").unwrap_err(), + RoleSymbolError::InvalidCharacter + ); + assert_matches!(RoleSymbol::new("mINTER").unwrap_err(), RoleSymbolError::InvalidCharacter); + } +} diff --git a/crates/miden-protocol/src/account/account_id/account_type.rs b/crates/miden-protocol/src/account/account_id/account_type.rs index 1ea4c02f98..1494621ff6 100644 --- a/crates/miden-protocol/src/account/account_id/account_type.rs +++ b/crates/miden-protocol/src/account/account_id/account_type.rs @@ -1,170 +1,89 @@ +use alloc::string::String; use core::fmt; use core::str::FromStr; use crate::errors::AccountIdError; -use crate::utils::serde::{ - ByteReader, - ByteWriter, - Deserializable, - DeserializationError, - Serializable, -}; // ACCOUNT TYPE // ================================================================================================ -pub(super) const FUNGIBLE_FAUCET: u8 = 0b10; -pub(super) const NON_FUNGIBLE_FAUCET: u8 = 0b11; -pub(super) const REGULAR_ACCOUNT_IMMUTABLE_CODE: u8 = 0b00; -pub(super) const REGULAR_ACCOUNT_UPDATABLE_CODE: u8 = 0b01; - -/// Represents the different account types recognized by the protocol. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +/// The type of an account, which determines where the account state is stored. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] #[repr(u8)] pub enum AccountType { - FungibleFaucet = FUNGIBLE_FAUCET, - NonFungibleFaucet = NON_FUNGIBLE_FAUCET, - RegularAccountImmutableCode = REGULAR_ACCOUNT_IMMUTABLE_CODE, - RegularAccountUpdatableCode = REGULAR_ACCOUNT_UPDATABLE_CODE, + #[default] + /// The account's state is stored off-chain, and only a commitment to it is stored on-chain. + Private = Self::PRIVATE, + + /// The account's full state is stored on-chain. + Public = Self::PUBLIC, } impl AccountType { - /// Returns all account types. - pub fn all() -> [AccountType; 4] { - [ - AccountType::FungibleFaucet, - AccountType::NonFungibleFaucet, - AccountType::RegularAccountImmutableCode, - AccountType::RegularAccountUpdatableCode, - ] - } + pub(crate) const PRIVATE: u8 = 0; + pub(crate) const PUBLIC: u8 = 1; - /// Returns the regular account types (immutable and updatable code). - pub fn regular() -> [AccountType; 2] { - [ - AccountType::RegularAccountImmutableCode, - AccountType::RegularAccountUpdatableCode, - ] + /// Returns the account type encoded to a 1-bit flag, where private is 0 and public is 1. + pub const fn as_u8(self) -> u8 { + self as u8 } - /// Returns `true` if the account is a faucet. - pub fn is_faucet(&self) -> bool { - matches!(self, Self::FungibleFaucet | Self::NonFungibleFaucet) + /// Returns `true` if the account type is [`Self::Public`], `false` otherwise. + pub fn is_public(&self) -> bool { + matches!(self, Self::Public) } - /// Returns `true` if the account is a regular account. - pub fn is_regular_account(&self) -> bool { - matches!(self, Self::RegularAccountImmutableCode | Self::RegularAccountUpdatableCode) + /// Returns `true` if the account type is [`Self::Private`], `false` otherwise. + pub fn is_private(&self) -> bool { + matches!(self, Self::Private) } +} - /// Returns the string representation of the [`AccountType`]. - fn as_str(&self) -> &'static str { +impl fmt::Display for AccountType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - AccountType::FungibleFaucet => "FungibleFaucet", - AccountType::NonFungibleFaucet => "NonFungibleFaucet", - AccountType::RegularAccountImmutableCode => "RegularAccountImmutableCode", - AccountType::RegularAccountUpdatableCode => "RegularAccountUpdatableCode", + AccountType::Private => write!(f, "private"), + AccountType::Public => write!(f, "public"), } } } -#[cfg(any(feature = "testing", test))] -impl rand::distr::Distribution for rand::distr::StandardUniform { - /// Samples a uniformly random [`AccountType`] from the given `rng`. - fn sample(&self, rng: &mut R) -> AccountType { - match rng.random_range(0..4) { - 0 => AccountType::RegularAccountImmutableCode, - 1 => AccountType::RegularAccountUpdatableCode, - 2 => AccountType::FungibleFaucet, - 3 => AccountType::NonFungibleFaucet, - _ => unreachable!("gen_range should not produce higher values"), +impl TryFrom<&str> for AccountType { + type Error = AccountIdError; + + fn try_from(value: &str) -> Result { + match value.to_lowercase().as_str() { + "private" => Ok(AccountType::Private), + "public" => Ok(AccountType::Public), + _ => Err(AccountIdError::UnknownAccountType(value.into())), } } } -// SERIALIZATION -// ================================================================================================ +impl TryFrom for AccountType { + type Error = AccountIdError; -impl Serializable for AccountType { - fn write_into(&self, target: &mut W) { - target.write_u8(*self as u8); - } -} - -impl Deserializable for AccountType { - fn read_from(source: &mut R) -> Result { - let num: u8 = source.read()?; - match num { - FUNGIBLE_FAUCET => Ok(AccountType::FungibleFaucet), - NON_FUNGIBLE_FAUCET => Ok(AccountType::NonFungibleFaucet), - REGULAR_ACCOUNT_IMMUTABLE_CODE => Ok(AccountType::RegularAccountImmutableCode), - REGULAR_ACCOUNT_UPDATABLE_CODE => Ok(AccountType::RegularAccountUpdatableCode), - _ => Err(DeserializationError::InvalidValue(format!("invalid account type: {num}"))), - } + fn try_from(value: String) -> Result { + AccountType::from_str(&value) } } impl FromStr for AccountType { type Err = AccountIdError; - fn from_str(string: &str) -> Result { - match string { - "FungibleFaucet" => Ok(AccountType::FungibleFaucet), - "NonFungibleFaucet" => Ok(AccountType::NonFungibleFaucet), - "RegularAccountImmutableCode" => Ok(AccountType::RegularAccountImmutableCode), - "RegularAccountUpdatableCode" => Ok(AccountType::RegularAccountUpdatableCode), - other => Err(AccountIdError::UnknownAccountType(other.into())), - } - } -} - -impl core::fmt::Display for AccountType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } -} - -#[cfg(feature = "std")] -impl serde::Serialize for AccountType { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_str(self.as_str()) - } -} - -#[cfg(feature = "std")] -impl<'de> serde::Deserialize<'de> for AccountType { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - use alloc::string::String; - - use serde::de::Error; - - let string: String = serde::Deserialize::deserialize(deserializer)?; - string.parse().map_err(D::Error::custom) + fn from_str(input: &str) -> Result { + AccountType::try_from(input) } } -#[cfg(test)] -mod tests { - use super::*; - - /// The following test ensure there is a bit available to identify an account as a faucet or - /// normal. - #[test] - fn test_account_id_faucet_bit() { - const ACCOUNT_IS_FAUCET_MASK: u8 = 0b10; - - // faucets have a bit set - assert_ne!((FUNGIBLE_FAUCET) & ACCOUNT_IS_FAUCET_MASK, 0); - assert_ne!((NON_FUNGIBLE_FAUCET) & ACCOUNT_IS_FAUCET_MASK, 0); - - // normal accounts do not have the faucet bit set - assert_eq!((REGULAR_ACCOUNT_IMMUTABLE_CODE) & ACCOUNT_IS_FAUCET_MASK, 0); - assert_eq!((REGULAR_ACCOUNT_UPDATABLE_CODE) & ACCOUNT_IS_FAUCET_MASK, 0); +#[cfg(any(feature = "testing", test))] +impl rand::distr::Distribution for rand::distr::StandardUniform { + /// Samples a uniformly random [`AccountType`] from the given `rng`. + fn sample(&self, rng: &mut R) -> AccountType { + match rng.random_range(0..2) { + 0 => AccountType::Private, + 1 => AccountType::Public, + _ => unreachable!("gen_range should not produce higher values"), + } } } diff --git a/crates/miden-protocol/src/account/account_id/id_prefix.rs b/crates/miden-protocol/src/account/account_id/id_prefix.rs index 63669c59ee..74c9fe4d4d 100644 --- a/crates/miden-protocol/src/account/account_id/id_prefix.rs +++ b/crates/miden-protocol/src/account/account_id/id_prefix.rs @@ -1,10 +1,10 @@ use alloc::string::{String, ToString}; use core::fmt; -use super::v0; +use super::v1; use crate::Felt; -use crate::account::account_id::AccountIdPrefixV0; -use crate::account::{AccountIdVersion, AccountStorageMode, AccountType}; +use crate::account::account_id::AccountIdPrefixV1; +use crate::account::{AccountIdVersion, AccountType}; use crate::errors::AccountIdError; use crate::utils::serde::{ ByteReader, @@ -27,7 +27,7 @@ use crate::utils::serde::{ /// [id]: crate::account::AccountId #[derive(Debug, Copy, Clone, Eq, PartialEq)] pub enum AccountIdPrefix { - V0(AccountIdPrefixV0), + V1(AccountIdPrefixV1), } impl AccountIdPrefix { @@ -57,10 +57,10 @@ impl AccountIdPrefix { pub fn new_unchecked(prefix: Felt) -> Self { // The prefix contains the metadata. // If we add more versions in the future, we may need to generalize this. - match v0::extract_version(prefix.as_canonical_u64()) + match v1::extract_version(prefix.as_canonical_u64()) .expect("prefix should contain a valid account ID version") { - AccountIdVersion::Version0 => Self::V0(AccountIdPrefixV0::new_unchecked(prefix)), + AccountIdVersion::Version1 => Self::V1(AccountIdPrefixV1::new_unchecked(prefix)), } } @@ -73,8 +73,8 @@ impl AccountIdPrefix { pub fn new(prefix: Felt) -> Result { // The prefix contains the metadata. // If we add more versions in the future, we may need to generalize this. - match v0::extract_version(prefix.as_canonical_u64())? { - AccountIdVersion::Version0 => AccountIdPrefixV0::new(prefix).map(Self::V0), + match v1::extract_version(prefix.as_canonical_u64())? { + AccountIdVersion::Version1 => AccountIdPrefixV1::new(prefix).map(Self::V1), } } @@ -84,73 +84,45 @@ impl AccountIdPrefix { /// Returns the [`Felt`] that represents this prefix. pub const fn as_felt(&self) -> Felt { match self { - AccountIdPrefix::V0(id_prefix) => id_prefix.as_felt(), + AccountIdPrefix::V1(id_prefix) => id_prefix.as_felt(), } } /// Returns the prefix as a [`u64`]. pub fn as_u64(&self) -> u64 { match self { - AccountIdPrefix::V0(id_prefix) => id_prefix.as_u64(), + AccountIdPrefix::V1(id_prefix) => id_prefix.as_u64(), } } - /// Returns the type of this account ID. + /// Returns the account type of this account ID. pub fn account_type(&self) -> AccountType { match self { - AccountIdPrefix::V0(id_prefix) => id_prefix.account_type(), + AccountIdPrefix::V1(id_prefix) => id_prefix.account_type(), } } - /// Returns true if an account with this ID is a faucet (can issue assets). - pub fn is_faucet(&self) -> bool { - self.account_type().is_faucet() - } - - /// Returns true if an account with this ID is a regular account. - pub fn is_regular_account(&self) -> bool { - self.account_type().is_regular_account() - } - - /// Returns the storage mode of this account ID. - pub fn storage_mode(&self) -> AccountStorageMode { - match self { - AccountIdPrefix::V0(id_prefix) => id_prefix.storage_mode(), - } - } - - /// Returns `true` if the full state of the account is public on chain, i.e. if the modes are - /// [`AccountStorageMode::Public`] or [`AccountStorageMode::Network`], `false` otherwise. - pub fn has_public_state(&self) -> bool { - self.storage_mode().has_public_state() - } - - /// Returns `true` if the storage mode is [`AccountStorageMode::Public`], `false` otherwise. + /// Returns `true` if the account type is [`AccountType::Public`], `false` otherwise. pub fn is_public(&self) -> bool { - self.storage_mode().is_public() - } - - /// Returns `true` if the storage mode is [`AccountStorageMode::Network`], `false` otherwise. - pub fn is_network(&self) -> bool { - self.storage_mode().is_network() + self.account_type().is_public() } /// Returns `true` if self is a private account, `false` otherwise. pub fn is_private(&self) -> bool { - self.storage_mode().is_private() + self.account_type().is_private() } /// Returns the version of this account ID. pub fn version(&self) -> AccountIdVersion { match self { - AccountIdPrefix::V0(_) => AccountIdVersion::Version0, + AccountIdPrefix::V1(_) => AccountIdVersion::Version1, } } /// Returns the prefix as a big-endian, hex-encoded string. pub fn to_hex(self) -> String { match self { - AccountIdPrefix::V0(id_prefix) => id_prefix.to_hex(), + AccountIdPrefix::V1(id_prefix) => id_prefix.to_hex(), } } } @@ -158,16 +130,16 @@ impl AccountIdPrefix { // CONVERSIONS FROM ACCOUNT ID PREFIX // ================================================================================================ -impl From for AccountIdPrefix { - fn from(id: AccountIdPrefixV0) -> Self { - Self::V0(id) +impl From for AccountIdPrefix { + fn from(id: AccountIdPrefixV1) -> Self { + Self::V1(id) } } impl From for Felt { fn from(id: AccountIdPrefix) -> Self { match id { - AccountIdPrefix::V0(id_prefix) => id_prefix.into(), + AccountIdPrefix::V1(id_prefix) => id_prefix.into(), } } } @@ -175,7 +147,7 @@ impl From for Felt { impl From for [u8; 8] { fn from(id: AccountIdPrefix) -> Self { match id { - AccountIdPrefix::V0(id_prefix) => id_prefix.into(), + AccountIdPrefix::V1(id_prefix) => id_prefix.into(), } } } @@ -183,7 +155,7 @@ impl From for [u8; 8] { impl From for u64 { fn from(id: AccountIdPrefix) -> Self { match id { - AccountIdPrefix::V0(id_prefix) => id_prefix.into(), + AccountIdPrefix::V1(id_prefix) => id_prefix.into(), } } } @@ -205,10 +177,10 @@ impl TryFrom<[u8; 8]> for AccountIdPrefix { let metadata_byte = value[7]; // We only have one supported version for now, so we use the extractor from that version. // If we add more versions in the future, we may need to generalize this. - let version = v0::extract_version(metadata_byte as u64)?; + let version = v1::extract_version(metadata_byte as u64)?; match version { - AccountIdVersion::Version0 => AccountIdPrefixV0::try_from(value).map(Self::V0), + AccountIdVersion::Version1 => AccountIdPrefixV1::try_from(value).map(Self::V1), } } } @@ -273,13 +245,13 @@ impl fmt::Display for AccountIdPrefix { impl Serializable for AccountIdPrefix { fn write_into(&self, target: &mut W) { match self { - AccountIdPrefix::V0(id_prefix) => id_prefix.write_into(target), + AccountIdPrefix::V1(id_prefix) => id_prefix.write_into(target), } } fn get_size_hint(&self) -> usize { match self { - AccountIdPrefix::V0(id_prefix) => id_prefix.get_size_hint(), + AccountIdPrefix::V1(id_prefix) => id_prefix.get_size_hint(), } } } @@ -291,41 +263,3 @@ impl Deserializable for AccountIdPrefix { .map_err(|err: AccountIdError| DeserializationError::InvalidValue(err.to_string())) } } - -// TESTS -// ================================================================================================ - -#[cfg(test)] -mod tests { - use super::*; - use crate::account::AccountIdV0; - - #[test] - fn account_id_prefix_construction() { - // Use the highest possible input to check if the constructed id is a valid Felt in that - // scenario. - // Use the lowest possible input to check whether the constructor produces valid IDs with - // all-zeroes input. - for input in [[0xff; 15], [0; 15]] { - for account_type in [ - AccountType::FungibleFaucet, - AccountType::NonFungibleFaucet, - AccountType::RegularAccountImmutableCode, - AccountType::RegularAccountUpdatableCode, - ] { - for storage_mode in [AccountStorageMode::Private, AccountStorageMode::Public] { - let id = AccountIdV0::dummy(input, account_type, storage_mode); - let prefix = id.prefix(); - assert_eq!(prefix.account_type(), account_type); - assert_eq!(prefix.storage_mode(), storage_mode); - assert_eq!(prefix.version(), AccountIdVersion::Version0); - - // Do a serialization roundtrip to ensure validity. - let serialized_prefix = prefix.to_bytes(); - AccountIdPrefix::read_from_bytes(&serialized_prefix).unwrap(); - assert_eq!(serialized_prefix.len(), AccountIdPrefix::SERIALIZED_SIZE); - } - } - } - } -} diff --git a/crates/miden-protocol/src/account/account_id/id_version.rs b/crates/miden-protocol/src/account/account_id/id_version.rs index 63659c1187..c689a2c033 100644 --- a/crates/miden-protocol/src/account/account_id/id_version.rs +++ b/crates/miden-protocol/src/account/account_id/id_version.rs @@ -3,13 +3,13 @@ use crate::errors::AccountIdError; // ACCOUNT ID VERSION // ================================================================================================ -const VERSION_0_NUMBER: u8 = 0; +const VERSION_1_NUMBER: u8 = 1; /// The version of an [`AccountId`](crate::account::AccountId). #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] pub enum AccountIdVersion { - Version0 = VERSION_0_NUMBER, + Version1 = VERSION_1_NUMBER, } impl AccountIdVersion { @@ -27,7 +27,7 @@ impl TryFrom for AccountIdVersion { fn try_from(value: u8) -> Result { match value { - VERSION_0_NUMBER => Ok(AccountIdVersion::Version0), + VERSION_1_NUMBER => Ok(AccountIdVersion::Version1), other_version => Err(AccountIdError::UnknownAccountIdVersion(other_version)), } } diff --git a/crates/miden-protocol/src/account/account_id/mod.rs b/crates/miden-protocol/src/account/account_id/mod.rs index 0b0c9c137a..30ae08e07c 100644 --- a/crates/miden-protocol/src/account/account_id/mod.rs +++ b/crates/miden-protocol/src/account/account_id/mod.rs @@ -1,5 +1,5 @@ -pub(crate) mod v0; -pub use v0::{AccountIdPrefixV0, AccountIdV0}; +pub(crate) mod v1; +pub use v1::{AccountIdPrefixV1, AccountIdV1}; mod id_prefix; pub use id_prefix::AccountIdPrefix; @@ -9,9 +9,6 @@ mod seed; mod account_type; pub use account_type::AccountType; -mod storage_mode; -pub use storage_mode::AccountStorageMode; - mod id_version; use alloc::string::{String, ToString}; use core::fmt; @@ -34,7 +31,7 @@ use crate::utils::serde::{ /// The identifier of an [`Account`](crate::account::Account). /// -/// This enum is a wrapper around concrete versions of IDs. The following documents version 0. +/// This enum is a wrapper around concrete versions of IDs. The following documents version 1. /// /// # Layout /// @@ -42,7 +39,7 @@ use crate::utils::serde::{ /// second is called the suffix. It is laid out as follows: /// /// ```text -/// prefix: [hash (56 bits) | storage mode (2 bits) | type (2 bits) | version (4 bits)] +/// prefix: [hash (59 bits) | account type (1 bit) | version (4 bits)] /// suffix: [zero bit | hash (55 bits) | 8 zero bits] /// ``` /// @@ -51,23 +48,22 @@ use crate::utils::serde::{ /// An `AccountId` is a commitment to a user-generated seed and the code and storage of an account. /// An id is generated by first creating the account's initial storage and code. Then a random seed /// is picked and the hash of `(SEED, CODE_COMMITMENT, STORAGE_COMMITMENT, EMPTY_WORD)` is computed. -/// This process is repeated until the hash's first element has the desired storage mode, account -/// type and version and the suffix' most significant bit is zero. +/// This process is repeated until the hash's first element has the desired account type and +/// version and the suffix' most significant bit is zero. /// /// The prefix of the ID is exactly the first element of the hash. The suffix of the ID is the /// second element of the hash, but its lower 8 bits are zeroed. Thus, the prefix of the ID must /// derive exactly from the hash, while only the first 56 bits of the suffix are derived from the /// hash. /// -/// In total, due to requiring specific bits for storage mode, type, version and the most -/// significant bit in the suffix, generating an ID requires 9 bits of Proof-of-Work. +/// In total, due to requiring specific bits for account type, version and the most significant bit +/// in the suffix, generating an ID requires 6 bits of Proof-of-Work. /// /// # Constraints /// /// Constructors will return an error if: /// -/// - The prefix contains account ID metadata (storage mode, type or version) that does not match -/// any of the known values. +/// - The prefix contains an account ID version that does not match any of the known values. /// - The most significant bit of the suffix is not zero. /// - The lower 8 bits of the suffix are not zero, although [`AccountId::new`] ensures this is the /// case rather than return an error. @@ -79,14 +75,14 @@ use crate::utils::serde::{ /// - The prefix is the output of a hash function so it will be a valid field element without /// requiring additional constraints. /// - The version is placed at a static offset such that future ID versions which may change the -/// number of type or storage mode bits will not cause the version to be at a different offset. -/// This is important so that a parser can always reliably read the version and then parse the -/// remainder of the ID depending on the version. Having only 4 bits for the version is a trade -/// off between future proofing to allow introducing more versions and the version requiring Proof -/// of Work as part of the ID generation. -/// - The version, type and storage mode are part of the prefix which is included in the -/// representation of a non-fungible asset. The prefix alone is enough to determine all of these -/// properties about the ID. +/// number of account type bits will not cause the version to be at a different offset. This is +/// important so that a parser can always reliably read the version and then parse the remainder +/// of the ID depending on the version. Having only 4 bits for the version is a trade off between +/// future proofing to allow introducing more versions and the version requiring Proof of Work as +/// part of the ID generation. +/// - The version and account type are part of the prefix which is included in the representation of +/// a non-fungible asset. The prefix alone is enough to determine all of these properties about +/// the ID. /// - The most significant bit of the suffix must be zero to ensure the value of the suffix is /// always a valid felt, even if the lower 8 bits are all set to `1`. The lower 8 bits of the /// suffix may be overwritten when the ID is embedded in other layouts such as the @@ -94,7 +90,7 @@ use crate::utils::serde::{ /// of the encoded suffix are one, so having the zero bit constraint is important for validity. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum AccountId { - V0(AccountIdV0), + V1(AccountIdV1), } impl AccountId { @@ -124,8 +120,8 @@ impl AccountId { storage_commitment: Word, ) -> Result { match version { - AccountIdVersion::Version0 => { - AccountIdV0::new(seed, code_commitment, storage_commitment).map(Self::V0) + AccountIdVersion::Version1 => { + AccountIdV1::new(seed, code_commitment, storage_commitment).map(Self::V1) }, } } @@ -147,10 +143,10 @@ impl AccountId { pub fn new_unchecked(elements: [Felt; 2]) -> Self { // The prefix contains the metadata. // If we add more versions in the future, we may need to generalize this. - match v0::extract_version(elements[0].as_canonical_u64()) + match v1::extract_version(elements[0].as_canonical_u64()) .expect("prefix should contain a valid account ID version") { - AccountIdVersion::Version0 => Self::V0(AccountIdV0::new_unchecked(elements)), + AccountIdVersion::Version1 => Self::V1(AccountIdV1::new_unchecked(elements)), } } @@ -163,21 +159,19 @@ impl AccountId { pub fn try_from_elements(suffix: Felt, prefix: Felt) -> Result { // The prefix contains the metadata. // If we add more versions in the future, we may need to generalize this. - match v0::extract_version(prefix.as_canonical_u64())? { - AccountIdVersion::Version0 => { - AccountIdV0::try_from_elements(suffix, prefix).map(Self::V0) + match v1::extract_version(prefix.as_canonical_u64())? { + AccountIdVersion::Version1 => { + AccountIdV1::try_from_elements(suffix, prefix).map(Self::V1) }, } } - /// Constructs an [`AccountId`] for testing purposes with the given account type, storage - /// mode. + /// Constructs an [`AccountId`] for testing purposes with the given account type. /// /// This function does the following: /// - Split the given bytes into a `prefix = bytes[0..8]` and `suffix = bytes[8..]` part to be /// used for the prefix and suffix felts, respectively. - /// - The least significant byte of the prefix is set to the given version, type and storage - /// mode. + /// - The least significant byte of the prefix is set to the given version and account type. /// - The 32nd most significant bit in the prefix is cleared to ensure it is a valid felt. The /// 32nd is chosen as it is the lowest bit that we can clear and still ensure felt validity. /// This leaves the upper 31 bits to be set by the input `bytes` which makes it simpler to @@ -189,34 +183,29 @@ impl AccountId { bytes: [u8; 15], version: AccountIdVersion, account_type: AccountType, - storage_mode: AccountStorageMode, ) -> AccountId { match version { - AccountIdVersion::Version0 => { - Self::V0(AccountIdV0::dummy(bytes, account_type, storage_mode)) - }, + AccountIdVersion::Version1 => Self::V1(AccountIdV1::dummy(bytes, account_type)), } } - /// Grinds an account seed until its hash matches the given `account_type`, `storage_mode` and - /// `version` and returns it as a [`Word`]. The input to the hash function next to the seed are - /// the `code_commitment` and `storage_commitment`. + /// Grinds an account seed until its hash matches the given `account_type` and `version` and + /// returns it as a [`Word`]. The input to the hash function next to the seed are the + /// `code_commitment` and `storage_commitment`. /// /// The grinding process is started from the given `init_seed` which should be a random seed /// generated from a cryptographically secure source. pub fn compute_account_seed( init_seed: [u8; 32], account_type: AccountType, - storage_mode: AccountStorageMode, version: AccountIdVersion, code_commitment: Word, storage_commitment: Word, ) -> Result { match version { - AccountIdVersion::Version0 => AccountIdV0::compute_account_seed( + AccountIdVersion::Version1 => AccountIdV1::compute_account_seed( init_seed, account_type, - storage_mode, version, code_commitment, storage_commitment, @@ -227,55 +216,27 @@ impl AccountId { // PUBLIC ACCESSORS // -------------------------------------------------------------------------------------------- - /// Returns the type of this account ID. + /// Returns the account type of this account ID. pub fn account_type(&self) -> AccountType { match self { - AccountId::V0(account_id) => account_id.account_type(), - } - } - - /// Returns `true` if an account with this ID is a faucet which can issue assets. - pub fn is_faucet(&self) -> bool { - self.account_type().is_faucet() - } - - /// Returns `true` if an account with this ID is a regular account. - pub fn is_regular_account(&self) -> bool { - self.account_type().is_regular_account() - } - - /// Returns the storage mode of this account ID. - pub fn storage_mode(&self) -> AccountStorageMode { - match self { - AccountId::V0(account_id) => account_id.storage_mode(), + AccountId::V1(account_id) => account_id.account_type(), } } - /// Returns `true` if the full state of the account is public on chain, i.e. if the modes are - /// [`AccountStorageMode::Public`] or [`AccountStorageMode::Network`], `false` otherwise. - pub fn has_public_state(&self) -> bool { - self.storage_mode().has_public_state() - } - - /// Returns `true` if the storage mode is [`AccountStorageMode::Public`], `false` otherwise. + /// Returns `true` if the account type is [`AccountType::Public`], `false` otherwise. pub fn is_public(&self) -> bool { - self.storage_mode().is_public() - } - - /// Returns `true` if the storage mode is [`AccountStorageMode::Network`], `false` otherwise. - pub fn is_network(&self) -> bool { - self.storage_mode().is_network() + self.account_type().is_public() } - /// Returns `true` if the storage mode is [`AccountStorageMode::Private`], `false` otherwise. + /// Returns `true` if the account type is [`AccountType::Private`], `false` otherwise. pub fn is_private(&self) -> bool { - self.storage_mode().is_private() + self.account_type().is_private() } /// Returns the version of this account ID. pub fn version(&self) -> AccountIdVersion { match self { - AccountId::V0(_) => AccountIdVersion::Version0, + AccountId::V1(_) => AccountIdVersion::Version1, } } @@ -291,7 +252,7 @@ impl AccountId { /// it encodes 15 bytes. pub fn to_hex(self) -> String { match self { - AccountId::V0(account_id) => account_id.to_hex(), + AccountId::V1(account_id) => account_id.to_hex(), } } @@ -325,7 +286,7 @@ impl AccountId { /// conveniently human-readable. pub fn to_bech32(&self, network_id: NetworkId) -> String { match self { - AccountId::V0(account_id_v0) => account_id_v0.to_bech32(network_id), + AccountId::V1(account_id_v1) => account_id_v1.to_bech32(network_id), } } @@ -334,8 +295,8 @@ impl AccountId { /// See [`AccountId::to_bech32`] for details on the format. The procedure for decoding the /// bech32 data into the ID consists of the inverse operations of encoding. pub fn from_bech32(bech32_string: &str) -> Result<(NetworkId, Self), AccountIdError> { - AccountIdV0::from_bech32(bech32_string) - .map(|(network_id, account_id)| (network_id, AccountId::V0(account_id))) + AccountIdV1::from_bech32(bech32_string) + .map(|(network_id, account_id)| (network_id, AccountId::V1(account_id))) } /// Parses a string into an [`AccountId`]. @@ -363,7 +324,7 @@ impl AccountId { /// Decodes the data from the bech32 byte iterator into an [`AccountId`]. pub(crate) fn from_bech32_byte_iter(byte_iter: ByteIter<'_>) -> Result { - AccountIdV0::from_bech32_byte_iter(byte_iter).map(AccountId::V0) + AccountIdV1::from_bech32_byte_iter(byte_iter).map(AccountId::V1) } /// Returns the [`AccountIdPrefix`] of this ID. @@ -371,14 +332,14 @@ impl AccountId { /// The prefix of an account ID is guaranteed to be unique. pub fn prefix(&self) -> AccountIdPrefix { match self { - AccountId::V0(account_id) => AccountIdPrefix::V0(account_id.prefix()), + AccountId::V1(account_id) => AccountIdPrefix::V1(account_id.prefix()), } } /// Returns the suffix of this ID as a [`Felt`]. pub const fn suffix(&self) -> Felt { match self { - AccountId::V0(account_id) => account_id.suffix(), + AccountId::V1(account_id) => account_id.suffix(), } } } @@ -389,7 +350,7 @@ impl AccountId { impl From for [Felt; 2] { fn from(id: AccountId) -> Self { match id { - AccountId::V0(account_id) => account_id.into(), + AccountId::V1(account_id) => account_id.into(), } } } @@ -397,7 +358,7 @@ impl From for [Felt; 2] { impl From for [u8; 15] { fn from(id: AccountId) -> Self { match id { - AccountId::V0(account_id) => account_id.into(), + AccountId::V1(account_id) => account_id.into(), } } } @@ -405,7 +366,7 @@ impl From for [u8; 15] { impl From for u128 { fn from(id: AccountId) -> Self { match id { - AccountId::V0(account_id) => account_id.into(), + AccountId::V1(account_id) => account_id.into(), } } } @@ -413,9 +374,9 @@ impl From for u128 { // CONVERSIONS TO ACCOUNT ID // ================================================================================================ -impl From for AccountId { - fn from(id: AccountIdV0) -> Self { - Self::V0(id) +impl From for AccountId { + fn from(id: AccountIdV1) -> Self { + Self::V1(id) } } @@ -433,10 +394,10 @@ impl TryFrom<[u8; 15]> for AccountId { let metadata_byte = bytes[7]; // We only have one supported version for now, so we use the extractor from that version. // If we add more versions in the future, we may need to generalize this. - let version = v0::extract_version(metadata_byte as u64)?; + let version = v1::extract_version(metadata_byte as u64)?; match version { - AccountIdVersion::Version0 => AccountIdV0::try_from(bytes).map(Self::V0), + AccountIdVersion::Version1 => AccountIdV1::try_from(bytes).map(Self::V1), } } } @@ -485,7 +446,7 @@ impl fmt::Display for AccountId { impl Serializable for AccountId { fn write_into(&self, target: &mut W) { match self { - AccountId::V0(account_id) => { + AccountId::V1(account_id) => { account_id.write_into(target); }, } @@ -493,7 +454,7 @@ impl Serializable for AccountId { fn get_size_hint(&self) -> usize { match self { - AccountId::V0(account_id) => account_id.get_size_hint(), + AccountId::V1(account_id) => account_id.get_size_hint(), } } } @@ -517,14 +478,15 @@ mod tests { use bech32::{Bech32, Bech32m, NoChecksum}; use super::*; - use crate::account::account_id::v0::{extract_storage_mode, extract_type, extract_version}; + use crate::account::account_id::v1::{extract_account_type, extract_version}; use crate::address::{AddressType, CustomNetworkId}; use crate::errors::Bech32Error; use crate::testing::account_id::{ - ACCOUNT_ID_NETWORK_NON_FUNGIBLE_FAUCET, + ACCOUNT_ID_MAX_ZEROES, ACCOUNT_ID_PRIVATE_NON_FUNGIBLE_FAUCET, ACCOUNT_ID_PRIVATE_SENDER, ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, + ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET, ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE, ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE, AccountIdBuilder, @@ -538,7 +500,8 @@ mod tests { ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, ACCOUNT_ID_PRIVATE_NON_FUNGIBLE_FAUCET, ACCOUNT_ID_PRIVATE_SENDER, - ACCOUNT_ID_NETWORK_NON_FUNGIBLE_FAUCET, + ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET, + ACCOUNT_ID_MAX_ZEROES, ] .into_iter() .enumerate() @@ -593,11 +556,7 @@ mod tests { // Raw bech32 data should contain the metadata byte at index 8. assert_eq!(extract_version(data[8] as u64).unwrap(), account_id.version()); - assert_eq!(extract_type(data[8] as u64), account_id.account_type()); - assert_eq!( - extract_storage_mode(data[8] as u64).unwrap(), - account_id.storage_mode() - ); + assert_eq!(extract_account_type(data[8] as u64), account_id.account_type()); } } diff --git a/crates/miden-protocol/src/account/account_id/seed.rs b/crates/miden-protocol/src/account/account_id/seed.rs index ba3f285bd8..fad40f6471 100644 --- a/crates/miden-protocol/src/account/account_id/seed.rs +++ b/crates/miden-protocol/src/account/account_id/seed.rs @@ -1,8 +1,8 @@ use alloc::vec::Vec; use crate::account::account_id::AccountIdVersion; -use crate::account::account_id::v0::{compute_digest, validate_prefix}; -use crate::account::{AccountIdV0, AccountStorageMode, AccountType}; +use crate::account::account_id::v1::{compute_digest, validate_prefix}; +use crate::account::{AccountIdV1, AccountType}; use crate::errors::AccountError; use crate::{Felt, Word}; @@ -15,7 +15,6 @@ use crate::{Felt, Word}; pub(super) fn compute_account_seed( init_seed: [u8; 32], account_type: AccountType, - storage_mode: AccountStorageMode, version: AccountIdVersion, code_commitment: Word, storage_commitment: Word, @@ -23,7 +22,6 @@ pub(super) fn compute_account_seed( compute_account_seed_single( init_seed, account_type, - storage_mode, version, code_commitment, storage_commitment, @@ -33,7 +31,6 @@ pub(super) fn compute_account_seed( fn compute_account_seed_single( init_seed: [u8; 32], account_type: AccountType, - storage_mode: AccountStorageMode, version: AccountIdVersion, code_commitment: Word, storage_commitment: Word, @@ -41,25 +38,23 @@ fn compute_account_seed_single( let init_seed: Vec<[u8; 8]> = init_seed.chunks(8).map(|chunk| chunk.try_into().unwrap()).collect(); let mut current_seed: Word = Word::from([ - Felt::new(u64::from_le_bytes(init_seed[0])), - Felt::new(u64::from_le_bytes(init_seed[1])), - Felt::new(u64::from_le_bytes(init_seed[2])), - Felt::new(u64::from_le_bytes(init_seed[3])), + Felt::new_unchecked(u64::from_le_bytes(init_seed[0])), + Felt::new_unchecked(u64::from_le_bytes(init_seed[1])), + Felt::new_unchecked(u64::from_le_bytes(init_seed[2])), + Felt::new_unchecked(u64::from_le_bytes(init_seed[3])), ]); let mut current_digest = compute_digest(current_seed, code_commitment, storage_commitment); - // loop until we have a seed that satisfies the specified account type. + // loop until we have a seed that satisfies the specified account parameters. loop { - // Check if the seed satisfies the specified type, storage mode and version. Additionally, - // the most significant bit of the suffix must be zero to ensure felt validity. - let suffix = current_digest[AccountIdV0::SEED_DIGEST_SUFFIX_ELEMENT_IDX]; - let prefix = current_digest[AccountIdV0::SEED_DIGEST_PREFIX_ELEMENT_IDX]; + // Check if the seed satisfies the specified account type and version. Additionally, the + // most significant bit of the suffix must be zero to ensure felt validity. + let suffix = current_digest[AccountIdV1::SEED_DIGEST_SUFFIX_ELEMENT_IDX]; + let prefix = current_digest[AccountIdV1::SEED_DIGEST_PREFIX_ELEMENT_IDX]; let is_suffix_msb_zero = suffix.as_canonical_u64() >> 63 == 0; - if let Ok((computed_account_type, computed_storage_mode, computed_version)) = - validate_prefix(prefix) + if let Ok((computed_account_type, computed_version)) = validate_prefix(prefix) && computed_account_type == account_type - && computed_storage_mode == storage_mode && computed_version == version && is_suffix_msb_zero { diff --git a/crates/miden-protocol/src/account/account_id/storage_mode.rs b/crates/miden-protocol/src/account/account_id/storage_mode.rs deleted file mode 100644 index 12670701ce..0000000000 --- a/crates/miden-protocol/src/account/account_id/storage_mode.rs +++ /dev/null @@ -1,103 +0,0 @@ -use alloc::string::String; -use core::fmt; -use core::str::FromStr; - -use crate::errors::AccountIdError; - -// ACCOUNT STORAGE MODE -// ================================================================================================ - -// This leaves room for an ENCRYPTED = 0b11. -// This way, the storage modes where the full state is public on-chain do not have the first -// bit set, which may be useful as a way to group the storage modes. -pub(super) const PUBLIC: u8 = 0b00; -pub(super) const NETWORK: u8 = 0b01; -pub(super) const PRIVATE: u8 = 0b10; - -/// Describes where the state of the account is stored. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[repr(u8)] -pub enum AccountStorageMode { - /// The account's full state is stored on-chain. - Public = PUBLIC, - /// The account's full state is stored on-chain. Additionally, the network monitors this account - /// and creates network transactions against it. It is otherwise the same as [`Self::Public`]. - Network = NETWORK, - /// The account's state is stored off-chain, and only a commitment to it is stored on-chain. - Private = PRIVATE, -} - -impl AccountStorageMode { - /// Returns `true` if the full state of the account is public on chain, i.e. if the modes are - /// [`Self::Public`] or [`Self::Network`], `false` otherwise. - pub fn has_public_state(&self) -> bool { - matches!(self, Self::Public | Self::Network) - } - - /// Returns `true` if the storage mode is [`Self::Public`], `false` otherwise. - pub fn is_public(&self) -> bool { - matches!(self, Self::Public) - } - - /// Returns `true` if the storage mode is [`Self::Network`], `false` otherwise. - pub fn is_network(&self) -> bool { - matches!(self, Self::Network) - } - - /// Returns `true` if the storage mode is [`Self::Private`], `false` otherwise. - pub fn is_private(&self) -> bool { - matches!(self, Self::Private) - } -} - -impl fmt::Display for AccountStorageMode { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - AccountStorageMode::Public => write!(f, "public"), - AccountStorageMode::Network => write!(f, "network"), - AccountStorageMode::Private => write!(f, "private"), - } - } -} - -impl TryFrom<&str> for AccountStorageMode { - type Error = AccountIdError; - - fn try_from(value: &str) -> Result { - match value.to_lowercase().as_str() { - "public" => Ok(AccountStorageMode::Public), - "network" => Ok(AccountStorageMode::Network), - "private" => Ok(AccountStorageMode::Private), - _ => Err(AccountIdError::UnknownAccountStorageMode(value.into())), - } - } -} - -impl TryFrom for AccountStorageMode { - type Error = AccountIdError; - - fn try_from(value: String) -> Result { - AccountStorageMode::from_str(&value) - } -} - -impl FromStr for AccountStorageMode { - type Err = AccountIdError; - - fn from_str(input: &str) -> Result { - AccountStorageMode::try_from(input) - } -} - -#[cfg(any(feature = "testing", test))] -impl rand::distr::Distribution for rand::distr::StandardUniform { - /// Samples a uniformly random [`AccountStorageMode`] from the given `rng`. - fn sample(&self, rng: &mut R) -> AccountStorageMode { - match rng.random_range(0..3) { - 0 => AccountStorageMode::Public, - 1 => AccountStorageMode::Network, - 2 => AccountStorageMode::Private, - _ => unreachable!("gen_range should not produce higher values"), - } - } -} diff --git a/crates/miden-protocol/src/account/account_id/v0/mod.rs b/crates/miden-protocol/src/account/account_id/v1/mod.rs similarity index 71% rename from crates/miden-protocol/src/account/account_id/v0/mod.rs rename to crates/miden-protocol/src/account/account_id/v1/mod.rs index 03fb3bc5a0..0cf18b0fbe 100644 --- a/crates/miden-protocol/src/account/account_id/v0/mod.rs +++ b/crates/miden-protocol/src/account/account_id/v1/mod.rs @@ -7,17 +7,10 @@ use core::hash::Hash; use bech32::Bech32m; use bech32::primitives::decode::{ByteIter, CheckedHrpstring}; use miden_crypto::utils::hex_to_bytes; -pub use prefix::AccountIdPrefixV0; +pub use prefix::AccountIdPrefixV1; use crate::account::account_id::NetworkId; -use crate::account::account_id::account_type::{ - FUNGIBLE_FAUCET, - NON_FUNGIBLE_FAUCET, - REGULAR_ACCOUNT_IMMUTABLE_CODE, - REGULAR_ACCOUNT_UPDATABLE_CODE, -}; -use crate::account::account_id::storage_mode::{NETWORK, PRIVATE, PUBLIC}; -use crate::account::{AccountIdVersion, AccountStorageMode, AccountType}; +use crate::account::{AccountIdVersion, AccountType}; use crate::address::AddressType; use crate::errors::{AccountError, AccountIdError, Bech32Error}; use crate::utils::serde::{ @@ -29,43 +22,39 @@ use crate::utils::serde::{ }; use crate::{EMPTY_WORD, Felt, Hasher, Word}; -// ACCOUNT ID VERSION 0 +// ACCOUNT ID VERSION 1 // ================================================================================================ -/// Version 0 of the [`Account`](crate::account::Account) identifier. +/// Version 1 of the [`Account`](crate::account::Account) identifier. /// /// See the [`AccountId`](super::AccountId) type's documentation for details. #[derive(Debug, Copy, Clone, Eq, PartialEq)] -pub struct AccountIdV0 { +pub struct AccountIdV1 { suffix: Felt, prefix: Felt, } -impl Hash for AccountIdV0 { +impl Hash for AccountIdV1 { fn hash(&self, state: &mut H) { self.prefix.as_canonical_u64().hash(state); self.suffix.as_canonical_u64().hash(state); } } -impl AccountIdV0 { +impl AccountIdV1 { // CONSTANTS // -------------------------------------------------------------------------------------------- - /// The serialized size of an [`AccountIdV0`] in bytes. + /// The serialized size of an [`AccountIdV1`] in bytes. const SERIALIZED_SIZE: usize = 15; - /// The lower two bits of the second least significant nibble encode the account type. - pub(crate) const TYPE_MASK: u8 = 0b11 << Self::TYPE_SHIFT; - pub(crate) const TYPE_SHIFT: u64 = 4; - /// The least significant nibble determines the account version. const VERSION_MASK: u64 = 0b1111; - /// The higher two bits of the second least significant nibble encode the account storage - /// mode. - pub(crate) const STORAGE_MODE_MASK: u8 = 0b11 << Self::STORAGE_MODE_SHIFT; - pub(crate) const STORAGE_MODE_SHIFT: u64 = 6; + /// The second most significant bit of the prefix's least significant byte encodes the account + /// type. + pub(crate) const ACCOUNT_TYPE_MASK: u8 = 0b1 << Self::ACCOUNT_TYPE_SHIFT; + pub(crate) const ACCOUNT_TYPE_SHIFT: u64 = 4; /// The element index in the seed digest that becomes the account ID suffix (after /// [`shape_suffix`]). @@ -113,20 +102,14 @@ impl AccountIdV0 { validate_suffix(suffix)?; validate_prefix(prefix)?; - Ok(AccountIdV0 { suffix, prefix }) + Ok(AccountIdV1 { suffix, prefix }) } /// See [`AccountId::dummy`](super::AccountId::dummy) for details. #[cfg(any(feature = "testing", test))] - pub fn dummy( - mut bytes: [u8; 15], - account_type: AccountType, - storage_mode: AccountStorageMode, - ) -> AccountIdV0 { - let version = AccountIdVersion::Version0 as u8; - let low_nibble = ((storage_mode as u8) << Self::STORAGE_MODE_SHIFT) - | ((account_type as u8) << Self::TYPE_SHIFT) - | version; + pub fn dummy(mut bytes: [u8; 15], account_type: AccountType) -> AccountIdV1 { + let version = AccountIdVersion::Version1 as u8; + let low_nibble = ((account_type as u8) << Self::ACCOUNT_TYPE_SHIFT) | version; // Set least significant byte. bytes[7] = low_nibble; @@ -144,12 +127,10 @@ impl AccountIdV0 { // shape_suffix anyway). suffix_bytes[..7].copy_from_slice(&bytes[8..]); - // If the value is too large modular reduction is performed, which is fine here. - let mut suffix = Felt::new(u64::from_be_bytes(suffix_bytes)); - - // Clear the most significant bit of the suffix. - suffix = Felt::try_from(suffix.as_canonical_u64() & 0x7fff_ffff_ffff_ffff) - .expect("no bits were set so felt should still be valid"); + // Clear the most significant bit of the suffix to make sure we get a valid felt + let suffix = u64::from_be_bytes(suffix_bytes) & 0x7fff_ffff_ffff_ffff; + let mut suffix = + Felt::try_from(suffix).expect("no bits were set so felt should still be valid"); suffix = shape_suffix(suffix); @@ -157,7 +138,6 @@ impl AccountIdV0 { .expect("we should have shaped the felts to produce a valid id"); debug_assert_eq!(account_id.account_type(), account_type); - debug_assert_eq!(account_id.storage_mode(), storage_mode); account_id } @@ -166,7 +146,6 @@ impl AccountIdV0 { pub fn compute_account_seed( init_seed: [u8; 32], account_type: AccountType, - storage_mode: AccountStorageMode, version: AccountIdVersion, code_commitment: Word, storage_commitment: Word, @@ -174,7 +153,6 @@ impl AccountIdV0 { crate::account::account_id::seed::compute_account_seed( init_seed, account_type, - storage_mode, version, code_commitment, storage_commitment, @@ -186,28 +164,12 @@ impl AccountIdV0 { /// See [`AccountId::account_type`](super::AccountId::account_type) for details. pub fn account_type(&self) -> AccountType { - extract_type(self.prefix.as_canonical_u64()) - } - - /// See [`AccountId::is_faucet`](super::AccountId::is_faucet) for details. - pub fn is_faucet(&self) -> bool { - self.account_type().is_faucet() - } - - /// See [`AccountId::is_regular_account`](super::AccountId::is_regular_account) for details. - pub fn is_regular_account(&self) -> bool { - self.account_type().is_regular_account() - } - - /// See [`AccountId::storage_mode`](super::AccountId::storage_mode) for details. - pub fn storage_mode(&self) -> AccountStorageMode { - extract_storage_mode(self.prefix().as_u64()) - .expect("account ID should have been constructed with a valid storage mode") + extract_account_type(self.prefix().as_u64()) } /// See [`AccountId::is_public`](super::AccountId::is_public) for details. pub fn is_public(&self) -> bool { - self.storage_mode() == AccountStorageMode::Public + self.account_type() == AccountType::Public } /// See [`AccountId::version`](super::AccountId::version) for details. @@ -217,10 +179,10 @@ impl AccountIdV0 { } /// See [`AccountId::from_hex`](super::AccountId::from_hex) for details. - pub fn from_hex(hex_str: &str) -> Result { + pub fn from_hex(hex_str: &str) -> Result { hex_to_bytes(hex_str) .map_err(AccountIdError::AccountIdHexParseError) - .and_then(AccountIdV0::try_from) + .and_then(AccountIdV1::try_from) } /// See [`AccountId::to_hex`](super::AccountId::to_hex) for details. @@ -311,13 +273,13 @@ impl AccountIdV0 { Ok(account_id) } - /// Returns the [`AccountIdPrefixV0`] of this account ID. + /// Returns the [`AccountIdPrefixV1`] of this account ID. /// /// See also [`AccountId::prefix`](super::AccountId::prefix) for details. - pub fn prefix(&self) -> AccountIdPrefixV0 { + pub fn prefix(&self) -> AccountIdPrefixV1 { // SAFETY: We only construct account IDs with valid prefixes, so we don't have to validate // it again. - AccountIdPrefixV0::new_unchecked(self.prefix) + AccountIdPrefixV1::new_unchecked(self.prefix) } /// See [`AccountId::suffix`](super::AccountId::suffix) for details. @@ -329,14 +291,14 @@ impl AccountIdV0 { // CONVERSIONS FROM ACCOUNT ID // ================================================================================================ -impl From for [Felt; 2] { - fn from(id: AccountIdV0) -> Self { +impl From for [Felt; 2] { + fn from(id: AccountIdV1) -> Self { [id.prefix, id.suffix] } } -impl From for [u8; 15] { - fn from(id: AccountIdV0) -> Self { +impl From for [u8; 15] { + fn from(id: AccountIdV1) -> Self { let mut result = [0_u8; 15]; result[..8].copy_from_slice(&id.prefix().as_u64().to_be_bytes()); // The last byte of the suffix is always zero so we skip it here. @@ -345,8 +307,8 @@ impl From for [u8; 15] { } } -impl From for u128 { - fn from(id: AccountIdV0) -> Self { +impl From for u128 { + fn from(id: AccountIdV1) -> Self { let mut le_bytes = [0_u8; 16]; le_bytes[..8].copy_from_slice(&id.suffix().as_canonical_u64().to_le_bytes()); le_bytes[8..].copy_from_slice(&id.prefix().as_u64().to_le_bytes()); @@ -357,7 +319,7 @@ impl From for u128 { // CONVERSIONS TO ACCOUNT ID // ================================================================================================ -impl TryFrom<[u8; 15]> for AccountIdV0 { +impl TryFrom<[u8; 15]> for AccountIdV1 { type Error = AccountIdError; /// See [`TryFrom<[u8; 15]> for @@ -396,7 +358,7 @@ impl TryFrom<[u8; 15]> for AccountIdV0 { } } -impl TryFrom for AccountIdV0 { +impl TryFrom for AccountIdV1 { type Error = AccountIdError; /// See [`TryFrom for AccountId`](super::AccountId#impl-TryFrom-for-AccountId) for @@ -412,7 +374,7 @@ impl TryFrom for AccountIdV0 { // SERIALIZATION // ================================================================================================ -impl Serializable for AccountIdV0 { +impl Serializable for AccountIdV1 { fn write_into(&self, target: &mut W) { let bytes: [u8; 15] = (*self).into(); bytes.write_into(target); @@ -423,7 +385,7 @@ impl Serializable for AccountIdV0 { } } -impl Deserializable for AccountIdV0 { +impl Deserializable for AccountIdV1 { fn read_from(source: &mut R) -> Result { <[u8; 15]>::read_from(source)? .try_into() @@ -434,22 +396,18 @@ impl Deserializable for AccountIdV0 { // HELPER FUNCTIONS // ================================================================================================ -/// Checks that the prefix: -/// - has known values for metadata (storage mode, type and version). +/// Checks that the prefix has a known value for the version. pub(crate) fn validate_prefix( prefix: Felt, -) -> Result<(AccountType, AccountStorageMode, AccountIdVersion), AccountIdError> { +) -> Result<(AccountType, AccountIdVersion), AccountIdError> { let prefix = prefix.as_canonical_u64(); - // Validate storage bits. - let storage_mode = extract_storage_mode(prefix)?; + let account_type = extract_account_type(prefix); // Validate version bits. let version = extract_version(prefix)?; - let account_type = extract_type(prefix); - - Ok((account_type, storage_mode, version)) + Ok((account_type, version)) } /// Checks that the suffix: @@ -471,39 +429,23 @@ fn validate_suffix(suffix: Felt) -> Result<(), AccountIdError> { Ok(()) } -pub(crate) fn extract_storage_mode(prefix: u64) -> Result { - let bits = (prefix & AccountIdV0::STORAGE_MODE_MASK as u64) >> AccountIdV0::STORAGE_MODE_SHIFT; - // SAFETY: `STORAGE_MODE_MASK` is u8 so casting bits is lossless +pub(crate) fn extract_account_type(prefix: u64) -> AccountType { + let bits = (prefix & AccountIdV1::ACCOUNT_TYPE_MASK as u64) >> AccountIdV1::ACCOUNT_TYPE_SHIFT; + // SAFETY: `ACCOUNT_TYPE_MASK` is u8 so casting bits is lossless match bits as u8 { - PUBLIC => Ok(AccountStorageMode::Public), - NETWORK => Ok(AccountStorageMode::Network), - PRIVATE => Ok(AccountStorageMode::Private), - _ => Err(AccountIdError::UnknownAccountStorageMode(format!("0b{bits:b}").into())), + AccountType::PRIVATE => AccountType::Private, + AccountType::PUBLIC => AccountType::Public, + _ => unreachable!("account type mask is 1 bit so every value is covered above"), } } pub(crate) fn extract_version(prefix: u64) -> Result { // SAFETY: The mask guarantees that we only mask out the least significant nibble, so casting to // u8 is safe. - let version = (prefix & AccountIdV0::VERSION_MASK) as u8; + let version = (prefix & AccountIdV1::VERSION_MASK) as u8; AccountIdVersion::try_from(version) } -pub(crate) const fn extract_type(prefix: u64) -> AccountType { - let bits = (prefix & (AccountIdV0::TYPE_MASK as u64)) >> AccountIdV0::TYPE_SHIFT; - // SAFETY: `TYPE_MASK` is u8 so casting bits is lossless - match bits as u8 { - REGULAR_ACCOUNT_UPDATABLE_CODE => AccountType::RegularAccountUpdatableCode, - REGULAR_ACCOUNT_IMMUTABLE_CODE => AccountType::RegularAccountImmutableCode, - FUNGIBLE_FAUCET => AccountType::FungibleFaucet, - NON_FUNGIBLE_FAUCET => AccountType::NonFungibleFaucet, - _ => { - // SAFETY: type mask contains only 2 bits and we've covered all 4 possible options. - panic!("type mask contains only 2 bits and we've covered all 4 possible options") - }, - } -} - /// Shapes the suffix so it meets the requirements of the account ID, by setting the lower 8 bits to /// zero. fn shape_suffix(suffix: Felt) -> Felt { @@ -519,19 +461,19 @@ fn shape_suffix(suffix: Felt) -> Felt { // COMMON TRAIT IMPLS // ================================================================================================ -impl PartialOrd for AccountIdV0 { +impl PartialOrd for AccountIdV1 { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } -impl Ord for AccountIdV0 { +impl Ord for AccountIdV1 { fn cmp(&self, other: &Self) -> core::cmp::Ordering { u128::from(*self).cmp(&u128::from(*other)) } } -impl fmt::Display for AccountIdV0 { +impl fmt::Display for AccountIdV1 { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.to_hex()) } @@ -554,6 +496,8 @@ pub(crate) fn compute_digest(seed: Word, code_commitment: Word, storage_commitme #[cfg(test)] mod tests { + use rstest::rstest; + use super::*; use crate::account::AccountIdPrefix; use crate::testing::account_id::{ @@ -567,51 +511,45 @@ mod tests { #[test] fn account_id_from_felts_with_max_pop_count() { let valid_suffix = Felt::try_from(0x7fff_ffff_ffff_ff00u64).unwrap(); - let valid_prefix = Felt::try_from(0x7fff_ffff_ffff_ff70u64).unwrap(); + let valid_prefix = Felt::try_from(0x7fff_ffff_ffff_fff1u64).unwrap(); - let id1 = AccountIdV0::new_unchecked([valid_prefix, valid_suffix]); - assert_eq!(id1.account_type(), AccountType::NonFungibleFaucet); - assert_eq!(id1.storage_mode(), AccountStorageMode::Network); - assert_eq!(id1.version(), AccountIdVersion::Version0); + let id1 = AccountIdV1::new_unchecked([valid_prefix, valid_suffix]); + assert_eq!(id1.account_type(), AccountType::Public); + assert_eq!(id1.version(), AccountIdVersion::Version1); } - #[test] - fn account_id_dummy_construction() { + #[rstest] + fn account_id_serde_roundtrip( // Use the highest possible input to check if the constructed id is a valid Felt in that // scenario. // Use the lowest possible input to check whether the constructor produces valid IDs with // all-zeroes input. - for input in [[0xff; 15], [0; 15]] { - for account_type in [ - AccountType::FungibleFaucet, - AccountType::NonFungibleFaucet, - AccountType::RegularAccountImmutableCode, - AccountType::RegularAccountUpdatableCode, - ] { - for storage_mode in [AccountStorageMode::Private, AccountStorageMode::Public] { - let id = AccountIdV0::dummy(input, account_type, storage_mode); - assert_eq!(id.account_type(), account_type); - assert_eq!(id.storage_mode(), storage_mode); - assert_eq!(id.version(), AccountIdVersion::Version0); - - // Do a serialization roundtrip to ensure validity. - let serialized_id = id.to_bytes(); - AccountIdV0::read_from_bytes(&serialized_id).unwrap(); - assert_eq!(serialized_id.len(), AccountIdV0::SERIALIZED_SIZE); - } - } - } + #[values([0xff; 15], [0; 15])] input: [u8; 15], + #[values(AccountType::Private, AccountType::Public)] account_type: AccountType, + ) { + let id = AccountIdV1::dummy(input, account_type); + assert_eq!(id.account_type(), account_type); + assert_eq!(id.version(), AccountIdVersion::Version1); + + // Do a serialization roundtrip to ensure validity. + let serialized_id = id.to_bytes(); + AccountIdV1::read_from_bytes(&serialized_id).unwrap(); + assert_eq!(serialized_id.len(), AccountIdV1::SERIALIZED_SIZE); + + let serialized_prefix = id.prefix().to_bytes(); + AccountIdPrefix::read_from_bytes(&serialized_prefix).unwrap(); + assert_eq!(serialized_prefix.len(), AccountIdPrefix::SERIALIZED_SIZE); } #[test] fn account_id_prefix_serialization_compatibility() { // Ensure that an AccountIdPrefix can be read from the serialized bytes of an AccountId. - let account_id = AccountIdV0::try_from(ACCOUNT_ID_PRIVATE_SENDER).unwrap(); + let account_id = AccountIdV1::try_from(ACCOUNT_ID_PRIVATE_SENDER).unwrap(); let id_bytes = account_id.to_bytes(); assert_eq!(account_id.prefix().to_bytes(), id_bytes[..8]); let deserialized_prefix = AccountIdPrefix::read_from_bytes(&id_bytes).unwrap(); - assert_eq!(AccountIdPrefix::V0(account_id.prefix()), deserialized_prefix); + assert_eq!(AccountIdPrefix::V1(account_id.prefix()), deserialized_prefix); // Ensure AccountId and AccountIdPrefix's hex representation are compatible. assert!(account_id.to_hex().starts_with(&account_id.prefix().to_hex())); @@ -632,10 +570,10 @@ mod tests { .into_iter() .enumerate() { - let id = AccountIdV0::try_from(account_id).expect("account ID should be valid"); - assert_eq!(id, AccountIdV0::from_hex(&id.to_hex()).unwrap(), "failed in {idx}"); - assert_eq!(id, AccountIdV0::try_from(<[u8; 15]>::from(id)).unwrap(), "failed in {idx}"); - assert_eq!(id, AccountIdV0::try_from(u128::from(id)).unwrap(), "failed in {idx}"); + let id = AccountIdV1::try_from(account_id).expect("account ID should be valid"); + assert_eq!(id, AccountIdV1::from_hex(&id.to_hex()).unwrap(), "failed in {idx}"); + assert_eq!(id, AccountIdV1::try_from(<[u8; 15]>::from(id)).unwrap(), "failed in {idx}"); + assert_eq!(id, AccountIdV1::try_from(u128::from(id)).unwrap(), "failed in {idx}"); // The u128 big-endian representation without the least significant byte and the // [u8; 15] representations should be equivalent. assert_eq!(u128::from(id).to_be_bytes()[0..15], <[u8; 15]>::from(id)); @@ -644,29 +582,21 @@ mod tests { } #[test] - fn test_account_id_tag_identifiers() { - let account_id = AccountIdV0::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE) + fn test_account_id_accessors() { + let account_id = AccountIdV1::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE) .expect("valid account ID"); - assert!(account_id.is_regular_account()); - assert_eq!(account_id.account_type(), AccountType::RegularAccountImmutableCode); assert!(account_id.is_public()); - let account_id = AccountIdV0::try_from(ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE) + let account_id = AccountIdV1::try_from(ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE) .expect("valid account ID"); - assert!(account_id.is_regular_account()); - assert_eq!(account_id.account_type(), AccountType::RegularAccountUpdatableCode); assert!(!account_id.is_public()); let account_id = - AccountIdV0::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET).expect("valid account ID"); - assert!(account_id.is_faucet()); - assert_eq!(account_id.account_type(), AccountType::FungibleFaucet); + AccountIdV1::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET).expect("valid account ID"); assert!(account_id.is_public()); - let account_id = AccountIdV0::try_from(ACCOUNT_ID_PRIVATE_NON_FUNGIBLE_FAUCET) + let account_id = AccountIdV1::try_from(ACCOUNT_ID_PRIVATE_NON_FUNGIBLE_FAUCET) .expect("valid account ID"); - assert!(account_id.is_faucet()); - assert_eq!(account_id.account_type(), AccountType::NonFungibleFaucet); assert!(!account_id.is_public()); } } diff --git a/crates/miden-protocol/src/account/account_id/v0/prefix.rs b/crates/miden-protocol/src/account/account_id/v1/prefix.rs similarity index 78% rename from crates/miden-protocol/src/account/account_id/v0/prefix.rs rename to crates/miden-protocol/src/account/account_id/v1/prefix.rs index 26a5ffb325..319b29c28c 100644 --- a/crates/miden-protocol/src/account/account_id/v0/prefix.rs +++ b/crates/miden-protocol/src/account/account_id/v1/prefix.rs @@ -4,8 +4,8 @@ use core::hash::Hash; use miden_core::Felt; -use crate::account::account_id::v0::{self, validate_prefix}; -use crate::account::{AccountIdVersion, AccountStorageMode, AccountType}; +use crate::account::account_id::v1::{self, validate_prefix}; +use crate::account::{AccountIdVersion, AccountType}; use crate::errors::AccountIdError; use crate::utils::serde::{ ByteReader, @@ -15,28 +15,28 @@ use crate::utils::serde::{ Serializable, }; -// ACCOUNT ID PREFIX VERSION 0 +// ACCOUNT ID PREFIX VERSION 1 // ================================================================================================ -/// The prefix of an [`AccountIdV0`](crate::account::AccountIdV0), i.e. its first field element. +/// The prefix of an [`AccountIdV1`](crate::account::AccountIdV1), i.e. its first field element. /// /// See the [`AccountId`](crate::account::AccountId)'s documentation for details. #[derive(Debug, Copy, Clone, Eq, PartialEq)] -pub struct AccountIdPrefixV0 { +pub struct AccountIdPrefixV1 { prefix: Felt, } -impl Hash for AccountIdPrefixV0 { +impl Hash for AccountIdPrefixV1 { fn hash(&self, state: &mut H) { self.prefix.as_canonical_u64().hash(state); } } -impl AccountIdPrefixV0 { +impl AccountIdPrefixV1 { // CONSTANTS // -------------------------------------------------------------------------------------------- - /// The serialized size of an [`AccountIdPrefixV0`] in bytes. + /// The serialized size of an [`AccountIdPrefixV1`] in bytes. const SERIALIZED_SIZE: usize = 8; // CONSTRUCTORS @@ -51,14 +51,14 @@ impl AccountIdPrefixV0 { .expect("AccountIdPrefix::new_unchecked called with invalid prefix"); } - AccountIdPrefixV0 { prefix } + AccountIdPrefixV1 { prefix } } /// See [`AccountIdPrefix::new`](crate::account::AccountIdPrefix::new) for details. pub fn new(prefix: Felt) -> Result { validate_prefix(prefix)?; - Ok(AccountIdPrefixV0 { prefix }) + Ok(AccountIdPrefixV1 { prefix }) } // PUBLIC ACCESSORS @@ -77,35 +77,17 @@ impl AccountIdPrefixV0 { /// See [`AccountIdPrefix::account_type`](crate::account::AccountIdPrefix::account_type) for /// details. pub fn account_type(&self) -> AccountType { - v0::extract_type(self.prefix.as_canonical_u64()) - } - - /// See [`AccountIdPrefix::is_faucet`](crate::account::AccountIdPrefix::is_faucet) for details. - pub fn is_faucet(&self) -> bool { - self.account_type().is_faucet() - } - - /// See [`AccountIdPrefix::is_regular_account`](crate::account::AccountIdPrefix::is_regular_account) for - /// details. - pub fn is_regular_account(&self) -> bool { - self.account_type().is_regular_account() - } - - /// See [`AccountIdPrefix::storage_mode`](crate::account::AccountIdPrefix::storage_mode) for - /// details. - pub fn storage_mode(&self) -> AccountStorageMode { - v0::extract_storage_mode(self.prefix.as_canonical_u64()) - .expect("account ID prefix should have been constructed with a valid storage mode") + v1::extract_account_type(self.prefix.as_canonical_u64()) } /// See [`AccountIdPrefix::is_public`](crate::account::AccountIdPrefix::is_public) for details. pub fn is_public(&self) -> bool { - self.storage_mode().is_public() + self.account_type().is_public() } /// See [`AccountIdPrefix::version`](crate::account::AccountIdPrefix::version) for details. pub fn version(&self) -> AccountIdVersion { - v0::extract_version(self.prefix.as_canonical_u64()) + v1::extract_version(self.prefix.as_canonical_u64()) .expect("account ID prefix should have been constructed with a valid version") } @@ -118,22 +100,22 @@ impl AccountIdPrefixV0 { // CONVERSIONS FROM ACCOUNT ID PREFIX // ================================================================================================ -impl From for Felt { - fn from(id: AccountIdPrefixV0) -> Self { +impl From for Felt { + fn from(id: AccountIdPrefixV1) -> Self { id.prefix } } -impl From for [u8; 8] { - fn from(id: AccountIdPrefixV0) -> Self { +impl From for [u8; 8] { + fn from(id: AccountIdPrefixV1) -> Self { let mut result = [0_u8; 8]; result[..8].copy_from_slice(&id.prefix.as_canonical_u64().to_be_bytes()); result } } -impl From for u64 { - fn from(id: AccountIdPrefixV0) -> Self { +impl From for u64 { + fn from(id: AccountIdPrefixV1) -> Self { id.prefix.as_canonical_u64() } } @@ -141,7 +123,7 @@ impl From for u64 { // CONVERSIONS TO ACCOUNT ID PREFIX // ================================================================================================ -impl TryFrom<[u8; 8]> for AccountIdPrefixV0 { +impl TryFrom<[u8; 8]> for AccountIdPrefixV1 { type Error = AccountIdError; /// See [`TryFrom<[u8; 8]> for @@ -162,7 +144,7 @@ impl TryFrom<[u8; 8]> for AccountIdPrefixV0 { } } -impl TryFrom for AccountIdPrefixV0 { +impl TryFrom for AccountIdPrefixV1 { type Error = AccountIdError; /// See [`TryFrom for @@ -178,7 +160,7 @@ impl TryFrom for AccountIdPrefixV0 { } } -impl TryFrom for AccountIdPrefixV0 { +impl TryFrom for AccountIdPrefixV1 { type Error = AccountIdError; /// See [`TryFrom for @@ -192,19 +174,19 @@ impl TryFrom for AccountIdPrefixV0 { // COMMON TRAIT IMPLS // ================================================================================================ -impl PartialOrd for AccountIdPrefixV0 { +impl PartialOrd for AccountIdPrefixV1 { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } -impl Ord for AccountIdPrefixV0 { +impl Ord for AccountIdPrefixV1 { fn cmp(&self, other: &Self) -> core::cmp::Ordering { self.prefix.as_canonical_u64().cmp(&other.prefix.as_canonical_u64()) } } -impl fmt::Display for AccountIdPrefixV0 { +impl fmt::Display for AccountIdPrefixV1 { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.to_hex()) } @@ -213,7 +195,7 @@ impl fmt::Display for AccountIdPrefixV0 { // SERIALIZATION // ================================================================================================ -impl Serializable for AccountIdPrefixV0 { +impl Serializable for AccountIdPrefixV1 { fn write_into(&self, target: &mut W) { let bytes: [u8; 8] = (*self).into(); bytes.write_into(target); @@ -224,7 +206,7 @@ impl Serializable for AccountIdPrefixV0 { } } -impl Deserializable for AccountIdPrefixV0 { +impl Deserializable for AccountIdPrefixV1 { fn read_from(source: &mut R) -> Result { <[u8; 8]>::read_from(source)? .try_into() diff --git a/crates/miden-protocol/src/account/auth.rs b/crates/miden-protocol/src/account/auth.rs index e4947095db..0665f2e16b 100644 --- a/crates/miden-protocol/src/account/auth.rs +++ b/crates/miden-protocol/src/account/auth.rs @@ -115,7 +115,7 @@ impl Deserializable for AuthScheme { #[repr(u8)] pub enum AuthSecretKey { Falcon512Poseidon2(falcon512_poseidon2::SecretKey) = FALCON512_POSEIDON2, - EcdsaK256Keccak(ecdsa_k256_keccak::SecretKey) = ECDSA_K256_KECCAK, + EcdsaK256Keccak(ecdsa_k256_keccak::SigningKey) = ECDSA_K256_KECCAK, } impl AuthSecretKey { @@ -133,12 +133,12 @@ impl AuthSecretKey { /// Generates an EcdsaK256Keccak secret key from the OS-provided randomness. #[cfg(feature = "std")] pub fn new_ecdsa_k256_keccak() -> Self { - Self::EcdsaK256Keccak(ecdsa_k256_keccak::SecretKey::new()) + Self::EcdsaK256Keccak(ecdsa_k256_keccak::SigningKey::new()) } /// Generates an EcdsaK256Keccak secret key using the provided random number generator. pub fn new_ecdsa_k256_keccak_with_rng(rng: &mut R) -> Self { - Self::EcdsaK256Keccak(ecdsa_k256_keccak::SecretKey::with_rng(rng)) + Self::EcdsaK256Keccak(ecdsa_k256_keccak::SigningKey::with_rng(rng)) } /// Generates a new secret key for the specified authentication scheme using the provided @@ -214,7 +214,7 @@ impl Deserializable for AuthSecretKey { Ok(AuthSecretKey::Falcon512Poseidon2(secret_key)) }, AuthScheme::EcdsaK256Keccak => { - let secret_key = ecdsa_k256_keccak::SecretKey::read_from(source)?; + let secret_key = ecdsa_k256_keccak::SigningKey::read_from(source)?; Ok(AuthSecretKey::EcdsaK256Keccak(secret_key)) }, } @@ -240,6 +240,12 @@ impl From for PublicKeyCommitment { } } +impl From for PublicKeyCommitment { + fn from(value: ecdsa_k256_keccak::PublicKey) -> Self { + Self(value.to_commitment()) + } +} + impl From for Word { fn from(value: PublicKeyCommitment) -> Self { value.0 diff --git a/crates/miden-protocol/src/account/builder/mod.rs b/crates/miden-protocol/src/account/builder/mod.rs index 2d1280f295..06e17ee067 100644 --- a/crates/miden-protocol/src/account/builder/mod.rs +++ b/crates/miden-protocol/src/account/builder/mod.rs @@ -7,10 +7,9 @@ use crate::account::{ AccountCode, AccountComponent, AccountId, - AccountIdV0, + AccountIdV1, AccountIdVersion, AccountStorage, - AccountStorageMode, AccountType, }; use crate::asset::AssetVault; @@ -23,13 +22,11 @@ use crate::{Felt, Word}; /// This will build a valid new account with these properties: /// - An empty [`AssetVault`]. /// - The nonce set to [`Felt::ZERO`]. -/// - A seed which results in an [`AccountId`] valid for the configured account type and storage -/// mode. +/// - A seed which results in an [`AccountId`] valid for the configured account type. /// /// By default, the builder is initialized with: -/// - The `account_type` set to [`AccountType::RegularAccountUpdatableCode`]. -/// - The `storage_mode` set to [`AccountStorageMode::Private`]. -/// - The `version` set to [`AccountIdVersion::Version0`]. +/// - The `account_type` set to [`AccountType::Private`]. +/// - The `version` set to [`AccountIdVersion::Version1`]. /// /// The methods that are required to be called are: /// @@ -37,18 +34,17 @@ use crate::{Felt, Word}; /// - [`AccountBuilder::with_component`], which must be called at least once. /// /// Under the `testing` feature, it is possible to: -/// - Build an existing account using [`AccountBuilder::build_existing`] which will set the -/// account's nonce to `1` by default, or to the configured value. -/// - Add assets to the account's vault, however this will only succeed when using -/// [`AccountBuilder::build_existing`]. +/// - Build an existing account using `AccountBuilder::build_existing`, which will set the account's +/// nonce to `1` by default, or to the configured value. +/// - Add assets to the account's vault; this only succeeds when using +/// `AccountBuilder::build_existing`. /// -/// **Storage Slot Order** +/// **Account Procedure Order** /// -/// Note that the components are merged together in the same order as `with_component` is called, -/// except for the auth component. It is always moved to the first position, due to the requirement -/// that the auth procedure must be at procedure index 0 within an [`AccountCode`]. That also -/// affects the storage slot order and means the auth component's storage comes first, if it has any -/// storage. +/// Note that the procedure in each components code are merged together in the same order as +/// `with_component` is called, except for the auth component. The auth procedure is always moved to +/// the first position, since the tx kernel assume procedure index 0 is the auth procedure within an +/// [`AccountCode`]. #[derive(Debug, Clone)] pub struct AccountBuilder { #[cfg(any(feature = "testing", test))] @@ -58,7 +54,6 @@ pub struct AccountBuilder { components: Vec, auth_component: Option, account_type: AccountType, - storage_mode: AccountStorageMode, init_seed: [u8; 32], id_version: AccountIdVersion, } @@ -77,9 +72,8 @@ impl AccountBuilder { components: vec![], auth_component: None, init_seed, - account_type: AccountType::RegularAccountUpdatableCode, - storage_mode: AccountStorageMode::Private, - id_version: AccountIdVersion::Version0, + account_type: AccountType::Private, + id_version: AccountIdVersion::Version1, } } @@ -89,27 +83,41 @@ impl AccountBuilder { self } - /// Sets the type of the account. + /// Sets the account type of the account. pub fn account_type(mut self, account_type: AccountType) -> Self { self.account_type = account_type; self } - /// Sets the storage mode of the account. - pub fn storage_mode(mut self, storage_mode: AccountStorageMode) -> Self { - self.storage_mode = storage_mode; - self - } - /// Adds an [`AccountComponent`] to the builder. This method can be called multiple times and /// **must be called at least once** since an account must export at least one procedure. /// /// All components will be merged to form the final code and storage of the built account. + /// + /// For composite configurations that expand into multiple components (such as + /// `AccessControl` or `TokenPolicyManager`), use [`Self::with_components`]. pub fn with_component(mut self, account_component: impl Into) -> Self { self.components.push(account_component.into()); self } + /// Adds the components yielded by `components` to the builder. + /// + /// This is a convenience wrapper around repeated [`Self::with_component`] calls. It is + /// most useful for installing the variable number of components produced by composite + /// configurations whose component count is not known at the call site (for example, a + /// configuration value that expands into one or several components depending on its + /// variant). + pub fn with_components( + mut self, + components: impl IntoIterator>, + ) -> Self { + for component in components { + self = self.with_component(component); + } + self + } + /// Adds a designated authentication [`AccountComponent`] to the builder. /// /// This component may contain multiple procedures, but is expected to contain exactly one @@ -149,13 +157,12 @@ impl AccountBuilder { let mut components = vec![auth_component]; components.append(&mut self.components); - let (code, storage) = Account::initialize_from_components(self.account_type, components) - .map_err(|err| { - AccountError::BuildError( - "account components failed to build".into(), - Some(Box::new(err)), - ) - })?; + let (code, storage) = Account::initialize_from_components(components).map_err(|err| { + AccountError::BuildError( + "account components failed to build".into(), + Some(Box::new(err)), + ) + })?; Ok((vault, code, storage)) } @@ -168,10 +175,9 @@ impl AccountBuilder { code_commitment: Word, storage_commitment: Word, ) -> Result { - let seed = AccountIdV0::compute_account_seed( + let seed = AccountIdV1::compute_account_seed( init_seed, self.account_type, - self.storage_mode, version, code_commitment, storage_commitment, @@ -189,7 +195,6 @@ impl AccountBuilder { /// /// Returns an error if: /// - The init seed is not set. - /// - Any of the components does not support the set account type. /// - The number of procedures in all merged components is 0 or exceeds /// [`AccountCode::MAX_NUM_PROCEDURES`](crate::account::AccountCode::MAX_NUM_PROCEDURES). /// - Two or more libraries export a procedure with the same MAST root. @@ -220,14 +225,13 @@ impl AccountBuilder { let account_id = AccountId::new( seed, - AccountIdVersion::Version0, + AccountIdVersion::Version1, code.commitment(), storage.to_commitment(), ) .expect("get_account_seed should provide a suitable seed"); debug_assert_eq!(account_id.account_type(), self.account_type); - debug_assert_eq!(account_id.storage_mode(), self.storage_mode); // SAFETY: The account ID was derived from the seed and the seed is provided, so it is safe // to bypass the checks of `Account::new`. @@ -269,12 +273,7 @@ impl AccountBuilder { let account_id = { let bytes = <[u8; 15]>::try_from(&self.init_seed[0..15]) .expect("we should have sliced exactly 15 bytes off"); - AccountId::dummy( - bytes, - AccountIdVersion::Version0, - self.account_type, - self.storage_mode, - ) + AccountId::dummy(bytes, AccountIdVersion::Version1, self.account_type) }; // Use the nonce value set by the Self::nonce method or Felt::ONE as a default. @@ -340,15 +339,14 @@ mod tests { }); struct CustomComponent1 { - slot0: u64, + slot0: u32, } impl From for AccountComponent { fn from(custom: CustomComponent1) -> Self { let mut value = Word::empty(); - value[0] = Felt::new(custom.slot0); + value[0] = Felt::from(custom.slot0); - let metadata = - AccountComponentMetadata::new("test::custom_component1", AccountType::all()); + let metadata = AccountComponentMetadata::new("test::custom_component1"); AccountComponent::new( CUSTOM_LIBRARY1.clone(), vec![StorageSlot::with_value(CUSTOM_COMPONENT1_SLOT_NAME.clone(), value)], @@ -359,18 +357,17 @@ mod tests { } struct CustomComponent2 { - slot0: u64, - slot1: u64, + slot0: u32, + slot1: u32, } impl From for AccountComponent { fn from(custom: CustomComponent2) -> Self { let mut value0 = Word::empty(); - value0[3] = Felt::new(custom.slot0); + value0[3] = Felt::from(custom.slot0); let mut value1 = Word::empty(); - value1[3] = Felt::new(custom.slot1); + value1[3] = Felt::from(custom.slot1); - let metadata = - AccountComponentMetadata::new("test::custom_component2", AccountType::all()); + let metadata = AccountComponentMetadata::new("test::custom_component2"); AccountComponent::new( CUSTOM_LIBRARY2.clone(), vec![ @@ -404,7 +401,7 @@ mod tests { let computed_id = AccountId::new( account.seed().unwrap(), - AccountIdVersion::Version0, + AccountIdVersion::Version1, account.code.commitment(), account.storage.to_commitment(), ) @@ -426,18 +423,72 @@ mod tests { assert_eq!( account.storage().get_item(&CUSTOM_COMPONENT1_SLOT_NAME).unwrap(), - [Felt::new(storage_slot0), Felt::new(0), Felt::new(0), Felt::new(0)].into() + Word::from([Felt::from(storage_slot0), Felt::ZERO, Felt::ZERO, Felt::ZERO]) ); assert_eq!( account.storage().get_item(&CUSTOM_COMPONENT2_SLOT_NAME0).unwrap(), - [Felt::new(0), Felt::new(0), Felt::new(0), Felt::new(storage_slot1)].into() + Word::from([Felt::ZERO, Felt::ZERO, Felt::ZERO, Felt::from(storage_slot1)]) ); assert_eq!( account.storage().get_item(&CUSTOM_COMPONENT2_SLOT_NAME1).unwrap(), - [Felt::new(0), Felt::new(0), Felt::new(0), Felt::new(storage_slot2)].into() + Word::from([Felt::ZERO, Felt::ZERO, Felt::ZERO, Felt::from(storage_slot2)]) ); } + #[test] + fn account_builder_with_components() { + let storage_slot0 = 25; + let storage_slot1 = 12; + let storage_slot2 = 42; + + let components: Vec = vec![ + CustomComponent1 { slot0: storage_slot0 }.into(), + CustomComponent2 { + slot0: storage_slot1, + slot1: storage_slot2, + } + .into(), + ]; + + let account = Account::builder([5; 32]) + .with_auth_component(NoopAuthComponent) + .with_components(components) + .build() + .unwrap(); + + // The account built via `with_components` should be identical to one built via + // chained `with_component` calls in the same order. + let expected = Account::builder([5; 32]) + .with_auth_component(NoopAuthComponent) + .with_component(CustomComponent1 { slot0: storage_slot0 }) + .with_component(CustomComponent2 { + slot0: storage_slot1, + slot1: storage_slot2, + }) + .build() + .unwrap(); + + assert_eq!(account.id(), expected.id()); + assert_eq!(account.code().commitment(), expected.code().commitment()); + assert_eq!(account.storage().to_commitment(), expected.storage().to_commitment()); + + // Empty iterators are accepted and behave as a no-op. + let account_no_extra = Account::builder([6; 32]) + .with_auth_component(NoopAuthComponent) + .with_component(CustomComponent1 { slot0: storage_slot0 }) + .with_components(core::iter::empty::()) + .build() + .unwrap(); + + let expected_no_extra = Account::builder([6; 32]) + .with_auth_component(NoopAuthComponent) + .with_component(CustomComponent1 { slot0: storage_slot0 }) + .build() + .unwrap(); + + assert_eq!(account_no_extra.id(), expected_no_extra.id()); + } + #[test] fn account_builder_non_empty_vault_on_new_account() { let storage_slot0 = 25; diff --git a/crates/miden-protocol/src/account/code/mod.rs b/crates/miden-protocol/src/account/code/mod.rs index d5c8516eb0..d62d27bc83 100644 --- a/crates/miden-protocol/src/account/code/mod.rs +++ b/crates/miden-protocol/src/account/code/mod.rs @@ -16,8 +16,6 @@ use super::{ }; use crate::Word; use crate::account::AccountComponent; -#[cfg(any(feature = "testing", test))] -use crate::account::AccountType; pub mod procedure; use procedure::{AccountProcedureRoot, PrintableProcedure}; @@ -61,15 +59,29 @@ impl AccountCode { // CONSTRUCTORS // -------------------------------------------------------------------------------------------- + /// Returns a new [`AccountCode`] instantiated from the provided [`MastForest`] and a list of + /// [`AccountProcedureRoot`]s. + /// + /// # Panics + /// + /// Panics if: + /// - The number of procedures is smaller than 2 or greater than 256. + pub fn from_parts(mast: Arc, procedures: Vec) -> Self { + assert!(procedures.len() >= Self::MIN_NUM_PROCEDURES, "not enough account procedures"); + assert!(procedures.len() <= Self::MAX_NUM_PROCEDURES, "too many account procedures"); + + Self { + commitment: build_procedure_commitment(&procedures), + procedures, + mast, + } + } + /// Creates a new [`AccountCode`] from the provided components' libraries. /// /// For testing use only. #[cfg(any(feature = "testing", test))] - pub fn from_components( - components: &[AccountComponent], - account_type: AccountType, - ) -> Result { - super::validate_components_support_account_type(components, account_type)?; + pub fn from_components(components: &[AccountComponent]) -> Result { Self::from_components_unchecked(components) } @@ -117,32 +129,6 @@ impl AccountCode { }) } - /// Returns a new [AccountCode] deserialized from the provided bytes. - /// - /// # Errors - /// Returns an error if account code deserialization fails. - pub fn from_bytes(bytes: &[u8]) -> Result { - Self::read_from_bytes(bytes).map_err(AccountError::AccountCodeDeserializationError) - } - - /// Returns a new [`AccountCode`] instantiated from the provided [`MastForest`] and a list of - /// [`AccountProcedureRoot`]s. - /// - /// # Panics - /// - /// Panics if: - /// - The number of procedures is smaller than 2 or greater than 256. - pub fn from_parts(mast: Arc, procedures: Vec) -> Self { - assert!(procedures.len() >= Self::MIN_NUM_PROCEDURES, "not enough account procedures"); - assert!(procedures.len() <= Self::MAX_NUM_PROCEDURES, "too many account procedures"); - - Self { - commitment: build_procedure_commitment(&procedures), - procedures, - mast, - } - } - // PUBLIC ACCESSORS // -------------------------------------------------------------------------------------------- @@ -190,7 +176,7 @@ impl AccountCode { /// ``` /// /// And then concatenating the resulting elements into a single vector. - pub fn as_elements(&self) -> Vec { + pub fn to_elements(&self) -> Vec { procedures_as_elements(self.procedures()) } @@ -281,13 +267,33 @@ impl Serializable for AccountCode { impl Deserializable for AccountCode { fn read_from(source: &mut R) -> Result { - let module = Arc::new(MastForest::read_from(source)?); + let mast = Arc::new(MastForest::read_from(source)?); let num_procedures = (source.read_u8()? as usize) + 1; + + // make sure the number of procedures is valid; we only check the minimum because + // u8::MAX + 1 is guaranteed to be less than or equal to 256 + if num_procedures < Self::MIN_NUM_PROCEDURES { + return Err(DeserializationError::InvalidValue(format!( + "account code must contain at least {} procedures, but has only {num_procedures} procedures", + Self::MIN_NUM_PROCEDURES + ))); + } + let procedures = source .read_many_iter(num_procedures)? .collect::, _>>()?; - Ok(Self::from_parts(module, procedures)) + // make sure that all account procedures are in the MAST forest + for procedure in procedures.iter() { + if mast.find_procedure_root(procedure.as_word()).is_none() { + return Err(DeserializationError::InvalidValue(format!( + "procedure with root {} is missing from account code's MAST forest", + procedure.as_word() + ))); + } + } + + Ok(Self::from_parts(mast, procedures)) } } @@ -391,13 +397,13 @@ impl AccountProcedureBuilder { // ================================================================================================ /// Computes the commitment to the given procedures -pub(crate) fn build_procedure_commitment(procedures: &[AccountProcedureRoot]) -> Word { +fn build_procedure_commitment(procedures: &[AccountProcedureRoot]) -> Word { let elements = procedures_as_elements(procedures); Hasher::hash_elements(&elements) } /// Converts given procedures into field elements -pub(crate) fn procedures_as_elements(procedures: &[AccountProcedureRoot]) -> Vec { +fn procedures_as_elements(procedures: &[AccountProcedureRoot]) -> Vec { procedures.iter().flat_map(AccountProcedureRoot::as_elements).copied().collect() } @@ -413,9 +419,9 @@ mod tests { use miden_assembly::Assembler; use super::{AccountCode, Deserializable, Serializable}; + use crate::account::AccountComponent; use crate::account::code::build_procedure_commitment; use crate::account::component::AccountComponentMetadata; - use crate::account::{AccountComponent, AccountType}; use crate::errors::AccountError; use crate::testing::account_code::CODE; use crate::testing::noop_auth_component::NoopAuthComponent; @@ -437,11 +443,7 @@ mod tests { #[test] fn test_account_code_only_auth_component() { - let err = AccountCode::from_components( - &[NoopAuthComponent.into()], - AccountType::RegularAccountUpdatableCode, - ) - .unwrap_err(); + let err = AccountCode::from_components(&[NoopAuthComponent.into()]).unwrap_err(); assert_matches!(err, AccountError::AccountCodeNoProcedures); } @@ -449,23 +451,19 @@ mod tests { #[test] fn test_account_code_no_auth_component() { let library = Arc::unwrap_or_clone(Assembler::default().assemble_library([CODE]).unwrap()); - let metadata = AccountComponentMetadata::new("test::no_auth", AccountType::all()); + let metadata = AccountComponentMetadata::new("test::no_auth"); let component = AccountComponent::new(library, vec![], metadata).unwrap(); - let err = - AccountCode::from_components(&[component], AccountType::RegularAccountUpdatableCode) - .unwrap_err(); + let err = AccountCode::from_components(&[component]).unwrap_err(); assert_matches!(err, AccountError::AccountCodeNoAuthComponent); } #[test] fn test_account_code_multiple_auth_components() { - let err = AccountCode::from_components( - &[NoopAuthComponent.into(), NoopAuthComponent.into()], - AccountType::RegularAccountUpdatableCode, - ) - .unwrap_err(); + let err = + AccountCode::from_components(&[NoopAuthComponent.into(), NoopAuthComponent.into()]) + .unwrap_err(); assert_matches!(err, AccountError::AccountCodeMultipleAuthComponents); } @@ -489,12 +487,10 @@ mod tests { let library = Arc::unwrap_or_clone( Assembler::default().assemble_library([code_with_multiple_auth]).unwrap(), ); - let metadata = AccountComponentMetadata::new("test::multiple_auth", AccountType::all()); + let metadata = AccountComponentMetadata::new("test::multiple_auth"); let component = AccountComponent::new(library, vec![], metadata).unwrap(); - let err = - AccountCode::from_components(&[component], AccountType::RegularAccountUpdatableCode) - .unwrap_err(); + let err = AccountCode::from_components(&[component]).unwrap_err(); assert_matches!(err, AccountError::AccountComponentMultipleAuthProcedures); } diff --git a/crates/miden-protocol/src/account/code/procedure.rs b/crates/miden-protocol/src/account/code/procedure.rs index a88fde5b0d..2c27f9fd20 100644 --- a/crates/miden-protocol/src/account/code/procedure.rs +++ b/crates/miden-protocol/src/account/code/procedure.rs @@ -3,8 +3,8 @@ use alloc::sync::Arc; use miden_core::mast::MastForest; use miden_core::prettier::PrettyPrint; +use miden_crypto_derive::WordWrapper; use miden_processor::mast::{MastNode, MastNodeExt, MastNodeId}; -use miden_protocol_macros::WordWrapper; use super::Felt; use crate::Word; diff --git a/crates/miden-protocol/src/account/component/code.rs b/crates/miden-protocol/src/account/component/code.rs index ef0aded6ad..97469eacf8 100644 --- a/crates/miden-protocol/src/account/component/code.rs +++ b/crates/miden-protocol/src/account/component/code.rs @@ -3,6 +3,7 @@ use miden_assembly::library::ProcedureExport; use miden_processor::mast::{MastForest, MastNodeExt}; use crate::account::AccountProcedureRoot; +use crate::assembly::Path; use crate::vm::AdviceMap; // ACCOUNT COMPONENT CODE @@ -44,6 +45,15 @@ impl AccountComponentCode { self.0.exports().filter_map(|export| export.as_procedure()) } + /// Returns the [`AccountProcedureRoot`] of the procedure with the specified path, or `None` + /// if it was not found in this component's library. + pub fn get_procedure_root_by_path( + &self, + proc_name: impl AsRef, + ) -> Option { + self.0.get_procedure_root_by_path(proc_name).map(AccountProcedureRoot::from_raw) + } + /// Returns a new [AccountComponentCode] with the provided advice map entries merged into the /// underlying [Library]'s [MastForest]. /// @@ -84,6 +94,7 @@ impl From for Library { #[cfg(test)] mod tests { + use alloc::string::ToString; use alloc::sync::Arc; use miden_core::{Felt, Word}; @@ -111,7 +122,7 @@ mod tests { // Non-empty advice map should add entries let key = Word::from([10u32, 20, 30, 40]); - let value = vec![Felt::new(200)]; + let value = vec![Felt::from(200_u8)]; let mut advice_map = AdviceMap::default(); advice_map.insert(key, value.clone()); @@ -121,4 +132,35 @@ mod tests { let stored = mast.advice_map().get(&key).expect("entry should be present"); assert_eq!(stored.as_ref(), value.as_slice()); } + + #[test] + fn test_get_procedure_root_by_path() { + let assembler = Assembler::default(); + let library = Arc::unwrap_or_clone( + assembler + .assemble_library(["pub proc test_proc nop end"]) + .expect("failed to assemble library"), + ); + let component_code = AccountComponentCode::from(library); + + // The test library exports exactly one procedure. + assert_eq!(component_code.procedure_roots().count(), 1); + let expected = component_code.procedure_roots().next().expect("one procedure exported"); + + let library_namespace = component_code + .as_library() + .module_infos() + .next() + .expect("library should have one module") + .path() + .to_string(); + let proc_path = alloc::format!("{library_namespace}::test_proc"); + + let root = component_code + .get_procedure_root_by_path(proc_path.as_str()) + .expect("test_proc should be present"); + assert_eq!(root, expected); + + assert!(component_code.get_procedure_root_by_path("bogus::missing").is_none()); + } } diff --git a/crates/miden-protocol/src/account/component/metadata/mod.rs b/crates/miden-protocol/src/account/component/metadata/mod.rs index b02c007014..e06f1b2767 100644 --- a/crates/miden-protocol/src/account/component/metadata/mod.rs +++ b/crates/miden-protocol/src/account/component/metadata/mod.rs @@ -1,11 +1,11 @@ -use alloc::collections::{BTreeMap, BTreeSet}; +use alloc::collections::BTreeMap; use alloc::string::{String, ToString}; use core::str::FromStr; use miden_mast_package::{Package, SectionId}; use semver::Version; -use super::{AccountType, SchemaRequirement, StorageSchema, StorageValueName}; +use super::{SchemaRequirement, StorageSchema, StorageValueName}; use crate::errors::AccountError; use crate::utils::serde::{ ByteReader, @@ -41,6 +41,7 @@ use crate::utils::serde::{ /// ``` /// use std::collections::BTreeMap; /// +/// use miden_protocol::account::StorageSlotName; /// use miden_protocol::account::component::{ /// AccountComponentMetadata, /// FeltSchema, @@ -53,7 +54,6 @@ use crate::utils::serde::{ /// WordSchema, /// WordValue, /// }; -/// use miden_protocol::account::{AccountType, StorageSlotName}; /// /// let slot_name = StorageSlotName::new("demo::test_value")?; /// @@ -69,7 +69,7 @@ use crate::utils::serde::{ /// StorageSlotSchema::Value(ValueSlotSchema::new(Some("demo slot".into()), word)), /// )])?; /// -/// let metadata = AccountComponentMetadata::new("test name", AccountType::all()) +/// let metadata = AccountComponentMetadata::new("test name") /// .with_description("description of the component") /// .with_storage_schema(storage_schema); /// @@ -96,16 +96,13 @@ pub struct AccountComponentMetadata { /// This can be used to track and manage component upgrades. version: Version, - /// A set of supported target account types for this component. - supported_types: BTreeSet, - /// Storage schema defining the component's storage layout, defaults, and init-supplied values. #[cfg_attr(feature = "std", serde(rename = "storage"))] storage_schema: StorageSchema, } impl AccountComponentMetadata { - /// Create a new [AccountComponentMetadata] with the given name and supported account types. + /// Create a new [AccountComponentMetadata] with the given name. /// /// Other fields are initialized to sensible defaults: /// - `description`: empty string @@ -113,15 +110,11 @@ impl AccountComponentMetadata { /// - `storage_schema`: default (empty) /// /// Use the `with_*` mutator methods to customize these fields. - pub fn new( - name: impl Into, - supported_types: impl IntoIterator, - ) -> Self { + pub fn new(name: impl Into) -> Self { Self { name: name.into(), description: String::new(), version: Version::new(1, 0, 0), - supported_types: supported_types.into_iter().collect(), storage_schema: StorageSchema::default(), } } @@ -169,11 +162,6 @@ impl AccountComponentMetadata { &self.version } - /// Returns the account types supported by the component. - pub fn supported_types(&self) -> &BTreeSet { - &self.supported_types - } - /// Returns the storage schema of the component. pub fn storage_schema(&self) -> &StorageSchema { &self.storage_schema @@ -214,7 +202,6 @@ impl Serializable for AccountComponentMetadata { self.name.write_into(target); self.description.write_into(target); self.version.to_string().write_into(target); - self.supported_types.write_into(target); self.storage_schema.write_into(target); } } @@ -230,14 +217,12 @@ impl Deserializable for AccountComponentMetadata { } let version = semver::Version::from_str(&String::read_from(source)?) .map_err(|err: semver::Error| DeserializationError::InvalidValue(err.to_string()))?; - let supported_types = BTreeSet::::read_from(source)?; let storage_schema = StorageSchema::read_from(source)?; Ok(Self { name, description, version, - supported_types, storage_schema, }) } diff --git a/crates/miden-protocol/src/account/component/mod.rs b/crates/miden-protocol/src/account/component/mod.rs index b7184d2db0..3578d3e18b 100644 --- a/crates/miden-protocol/src/account/component/mod.rs +++ b/crates/miden-protocol/src/account/component/mod.rs @@ -1,4 +1,3 @@ -use alloc::collections::BTreeSet; use alloc::vec::Vec; use miden_mast_package::Package; @@ -13,10 +12,10 @@ pub use storage::*; mod code; pub use code::AccountComponentCode; -use crate::account::{AccountProcedureRoot, AccountType, StorageSlot}; +use crate::MastForest; +use crate::account::{AccountProcedureRoot, StorageSlot}; use crate::assembly::Path; use crate::errors::AccountError; -use crate::{MastForest, Word}; /// The attribute name used to mark the authentication procedure in an account component. const AUTH_SCRIPT_ATTRIBUTE: &str = "auth_script"; @@ -33,12 +32,6 @@ const AUTH_SCRIPT_ATTRIBUTE: &str = "auth_script"; /// Each component is independent of other components and can only access its own storage slots. /// Each component defines its own storage layout starting at index 0 up to the length of the /// storage slots vector. -/// -/// Components define the [`AccountType`]s they support, meaning whether the component can be used -/// to instantiate an account of that type. For example, a component implementing a fungible faucet -/// would only specify support for [`AccountType::FungibleFaucet`]. Using it to instantiate a -/// regular account would fail. By default, the set of supported types is empty, so each component -/// is forced to explicitly define what it supports. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AccountComponent { pub(super) code: AccountComponentCode, @@ -179,16 +172,6 @@ impl AccountComponent { self.metadata.storage_schema() } - /// Returns a reference to the supported [`AccountType`]s. - pub fn supported_types(&self) -> &BTreeSet { - self.metadata.supported_types() - } - - /// Returns `true` if this component supports the given `account_type`, `false` otherwise. - pub fn supports_type(&self, account_type: AccountType) -> bool { - self.metadata.supported_types().contains(&account_type) - } - /// Returns an iterator over ([`AccountProcedureRoot`], is_auth) for all procedures in this /// component. /// @@ -209,10 +192,13 @@ impl AccountComponent { }) } - /// Returns the digest of the procedure with the specified path, or `None` if it was not found - /// in this component's library or its library path is malformed. - pub fn get_procedure_root_by_path(&self, proc_name: impl AsRef) -> Option { - self.code.as_library().get_procedure_root_by_path(proc_name) + /// Returns the [`AccountProcedureRoot`] of the procedure with the specified path, or `None` + /// if it was not found in this component's library. + pub fn get_procedure_root_by_path( + &self, + proc_name: impl AsRef, + ) -> Option { + self.code.get_procedure_root_by_path(proc_name) } } @@ -241,12 +227,9 @@ mod tests { let library = Assembler::default().assemble_library([CODE]).unwrap(); // Test with metadata - let metadata = AccountComponentMetadata::new( - "test_component", - [AccountType::RegularAccountImmutableCode], - ) - .with_description("A test component") - .with_version(Version::new(1, 0, 0)); + let metadata = AccountComponentMetadata::new("test_component") + .with_description("A test component") + .with_version(Version::new(1, 0, 0)); let metadata_bytes = metadata.to_bytes(); let package_with_metadata = Package { @@ -265,11 +248,6 @@ mod tests { let extracted_metadata = AccountComponentMetadata::try_from(&package_with_metadata).unwrap(); assert_eq!(extracted_metadata.name(), "test_component"); - assert!( - extracted_metadata - .supported_types() - .contains(&AccountType::RegularAccountImmutableCode) - ); // Test without metadata - should fail let package_without_metadata = Package { @@ -295,7 +273,7 @@ mod tests { let component_code = AccountComponentCode::from(Arc::unwrap_or_clone(library.clone())); // Create metadata for the component - let metadata = AccountComponentMetadata::new("test_component", AccountType::regular()) + let metadata = AccountComponentMetadata::new("test_component") .with_description("A test component") .with_version(Version::new(1, 0, 0)); @@ -307,9 +285,6 @@ mod tests { // Verify the component was created correctly assert_eq!(component.storage_size(), 0); - assert!(component.supports_type(AccountType::RegularAccountImmutableCode)); - assert!(component.supports_type(AccountType::RegularAccountUpdatableCode)); - assert!(!component.supports_type(AccountType::FungibleFaucet)); // Test without metadata - should fail let package_without_metadata = Package { diff --git a/crates/miden-protocol/src/account/component/storage/schema/tests.rs b/crates/miden-protocol/src/account/component/storage/schema/tests.rs index 5ec22680ea..5dbac0b6d7 100644 --- a/crates/miden-protocol/src/account/component/storage/schema/tests.rs +++ b/crates/miden-protocol/src/account/component/storage/schema/tests.rs @@ -10,8 +10,8 @@ fn map_slot_schema_default_values_returns_map() { let word_schema = WordSchema::new_simple(SchemaType::native_word()); let mut default_values = BTreeMap::new(); default_values.insert( - Word::from([Felt::new(1), Felt::new(0), Felt::new(0), Felt::new(0)]), - Word::from([Felt::new(10), Felt::new(11), Felt::new(12), Felt::new(13)]), + Word::from([Felt::ONE, Felt::ZERO, Felt::ZERO, Felt::ZERO]), + Word::from([10_u32, 11_u32, 12_u32, 13_u32]), ); let slot = MapSlotSchema::new( Some("static map".into()), @@ -22,8 +22,8 @@ fn map_slot_schema_default_values_returns_map() { let mut expected = BTreeMap::new(); expected.insert( - Word::from([Felt::new(1), Felt::new(0), Felt::new(0), Felt::new(0)]), - Word::from([Felt::new(10), Felt::new(11), Felt::new(12), Felt::new(13)]), + Word::from([Felt::ONE, Felt::ZERO, Felt::ZERO, Felt::ZERO]), + Word::from([10_u32, 11_u32, 12_u32, 13_u32]), ); assert_eq!(slot.default_values(), Some(expected)); @@ -84,7 +84,7 @@ fn value_slot_schema_accepts_typed_word_init_value() { init_data.set_value("demo::slot", [1u32, 2, 3, 4]).unwrap(); let built = slot.try_build_word(&init_data, &slot_name).unwrap(); - let expected = Word::from([Felt::new(1), Felt::new(2), Felt::new(3), Felt::new(4)]); + let expected = Word::from([Felt::ONE, Felt::from(2_u32), Felt::from(3_u32), Felt::from(4_u32)]); assert_eq!(built, expected); } @@ -97,16 +97,16 @@ fn value_slot_schema_accepts_felt_typed_word_init_value() { init_data.set_value("demo::u8_word", 6u8).unwrap(); let built = slot.try_build_word(&init_data, &slot_name).unwrap(); - assert_eq!(built, Word::from([Felt::new(6), Felt::new(0), Felt::new(0), Felt::new(0)])); + assert_eq!(built, Word::from([Felt::new_unchecked(6), Felt::ZERO, Felt::ZERO, Felt::ZERO])); } #[test] fn value_slot_schema_accepts_typed_felt_init_value_in_composed_word() { let word = WordSchema::new_value([ FeltSchema::u8("a"), - FeltSchema::felt("b").with_default(Felt::new(2)), - FeltSchema::felt("c").with_default(Felt::new(3)), - FeltSchema::felt("d").with_default(Felt::new(4)), + FeltSchema::felt("b").with_default(Felt::from(2_u32)), + FeltSchema::felt("c").with_default(Felt::from(3_u32)), + FeltSchema::felt("d").with_default(Felt::from(4_u32)), ]); let slot = ValueSlotSchema::new(None, word); let slot_name: StorageSlotName = "demo::slot".parse().unwrap(); @@ -115,7 +115,10 @@ fn value_slot_schema_accepts_typed_felt_init_value_in_composed_word() { init_data.set_value("demo::slot.a", 1u8).unwrap(); let built = slot.try_build_word(&init_data, &slot_name).unwrap(); - assert_eq!(built, Word::from([Felt::new(1), Felt::new(2), Felt::new(3), Felt::new(4)])); + assert_eq!( + built, + Word::from([Felt::ONE, Felt::from(2_u32), Felt::from(3_u32), Felt::from(4_u32)]) + ); } #[test] @@ -132,7 +135,7 @@ fn map_slot_schema_accepts_typed_map_init_value() { let built = slot.try_build_map(&init_data, &slot_name).unwrap(); let expected = StorageMap::with_entries([( StorageMapKey::from_array([1, 0, 0, 0]), - Word::from([Felt::new(10), Felt::new(11), Felt::new(12), Felt::new(13)]), + Word::from([10_u32, 11_u32, 12_u32, 13_u32]), )]) .unwrap(); assert_eq!(built, expected); diff --git a/crates/miden-protocol/src/account/component/storage/toml/mod.rs b/crates/miden-protocol/src/account/component/storage/toml/mod.rs index a5850d1afb..700eb12463 100644 --- a/crates/miden-protocol/src/account/component/storage/toml/mod.rs +++ b/crates/miden-protocol/src/account/component/storage/toml/mod.rs @@ -1,4 +1,4 @@ -use alloc::collections::{BTreeMap, BTreeSet}; +use alloc::collections::BTreeMap; use alloc::string::{String, ToString}; use alloc::vec::Vec; @@ -17,9 +17,9 @@ use super::super::{ WordSchema, WordValue, }; +use crate::account::StorageSlotName; use crate::account::component::storage::type_registry::SCHEMA_TYPE_REGISTRY; use crate::account::component::{AccountComponentMetadata, SchemaType}; -use crate::account::{AccountType, StorageSlotName}; use crate::errors::ComponentMetadataError; mod init_storage_data; @@ -37,7 +37,6 @@ struct RawAccountComponentMetadata { name: String, description: String, version: Version, - supported_types: BTreeSet, #[serde(rename = "storage")] #[serde(default)] storage: RawStorageSchema, @@ -69,7 +68,7 @@ impl AccountComponentMetadata { } let storage_schema = StorageSchema::new(fields)?; - Ok(Self::new(raw.name, raw.supported_types) + Ok(Self::new(raw.name) .with_description(raw.description) .with_version(raw.version) .with_storage_schema(storage_schema)) diff --git a/crates/miden-protocol/src/account/component/storage/toml/tests.rs b/crates/miden-protocol/src/account/component/storage/toml/tests.rs index f445fad344..f5a7c3180e 100644 --- a/crates/miden-protocol/src/account/component/storage/toml/tests.rs +++ b/crates/miden-protocol/src/account/component/storage/toml/tests.rs @@ -240,7 +240,6 @@ fn metadata_from_toml_parses_named_storage_schema() { name = "Test Component" description = "Test description" version = "0.1.0" - supported-types = [] [[storage.slots]] name = "demo::test_value" @@ -265,7 +264,6 @@ fn metadata_from_toml_rejects_non_ascii_component_description() { name = "Test Component" description = "Invalid \u00e9" version = "0.1.0" - supported-types = [] "#; assert_matches::assert_matches!( @@ -280,7 +278,6 @@ fn metadata_from_toml_rejects_non_ascii_slot_description() { name = "Test Component" description = "Test description" version = "0.1.0" - supported-types = [] [[storage.slots]] name = "demo::test_value" @@ -300,7 +297,6 @@ fn metadata_schema_commitment_ignores_defaults_and_ordering() { name = "Commitment Test" description = "Schema commitments are equal regardless of defaults and ordering" version = "0.1.0" - supported-types = [] [[storage.slots]] name = "demo::first" @@ -328,7 +324,6 @@ fn metadata_schema_commitment_ignores_defaults_and_ordering() { name = "Commitment Test" description = "" version = "0.1.0" - supported-types = [] [[storage.slots]] name = "demo::map" @@ -368,7 +363,6 @@ fn metadata_schema_commitment_includes_descriptions() { name = "Commitment Test" description = "Component description" version = "0.1.0" - supported-types = [] [[storage.slots]] name = "demo::value" @@ -380,7 +374,6 @@ fn metadata_schema_commitment_includes_descriptions() { name = "Commitment Test" description = "Component description" version = "0.1.0" - supported-types = [] [[storage.slots]] name = "demo::value" @@ -392,7 +385,6 @@ fn metadata_schema_commitment_includes_descriptions() { name = "Commitment Test" description = "Component description" version = "0.1.0" - supported-types = [] [[storage.slots]] name = "demo::bad_value" @@ -422,7 +414,6 @@ fn metadata_from_toml_rejects_typed_fields_in_static_map_values() { name = "Test Component" description = "Test description" version = "0.1.0" - supported-types = [] [[storage.slots]] name = "demo::my_map" @@ -444,7 +435,6 @@ fn metadata_toml_round_trip_value_and_map_slots() { name = "round trip" description = "test round-trip" version = "0.1.0" - supported-types = [] [[storage.slots]] name = "demo::single_value" @@ -475,7 +465,6 @@ fn metadata_toml_round_trip_composed_slot_with_typed_fields() { name = "round trip typed fields" description = "test composed slot typed fields" version = "0.1.0" - supported-types = [] [[storage.slots]] name = "demo::composed" @@ -513,7 +502,6 @@ fn metadata_toml_round_trip_typed_slots() { name = "typed components" description = "test typed slots" version = "0.1.0" - supported-types = [] [[storage.slots]] name = "demo::typed_value" @@ -595,7 +583,6 @@ fn extensive_schema_metadata_and_init_toml_example() { name = "Extensive Example" description = "Exercises composite slots, simple typed slots, static maps, optional init maps, and map typing." version = "0.1.0" - supported-types = ["FungibleFaucet", "RegularAccountImmutableCode"] # composed slot schema expressed via `type = [...]` [[storage.slots]] @@ -603,7 +590,7 @@ fn extensive_schema_metadata_and_init_toml_example() { description = "Token metadata: max_supply, symbol, decimals, reserved." type = [ { type = "u32", name = "max_supply", description = "Maximum supply (base units)" }, - { type = "miden::standards::fungible_faucets::metadata::token_symbol", name = "symbol", default-value = "TST" }, + { type = "miden::standards::faucets::fungible::token_symbol", name = "symbol", default-value = "TST" }, { type = "u8", name = "decimals", description = "Token decimals" }, { type = "void" }, ] @@ -707,7 +694,7 @@ fn extensive_schema_metadata_and_init_toml_example() { .expect("symbol should be reported with a default value"); assert_eq!( symbol_requirement.r#type, - SchemaType::new("miden::standards::fungible_faucets::metadata::token_symbol").unwrap() + SchemaType::new("miden::standards::faucets::fungible::token_symbol").unwrap() ); assert_eq!(symbol_requirement.default_value.as_deref(), Some("TST")); assert!( @@ -762,7 +749,7 @@ fn extensive_schema_metadata_and_init_toml_example() { }; assert_eq!( static_word, - &Word::from([Felt::new(1), Felt::new(2), Felt::new(3), Felt::new(4)]) + &Word::from([Felt::ONE, Felt::from(2_u32), Felt::from(3_u32), Felt::from(4_u32)]) ); let legacy_word_name = StorageSlotName::new("demo::legacy_word").unwrap(); @@ -784,7 +771,7 @@ fn extensive_schema_metadata_and_init_toml_example() { ); assert_eq!( static_map.get(&StorageMapKey::from_array([0, 0, 0, 2])), - Word::from([Felt::ZERO, Felt::ZERO, Felt::ZERO, Felt::new(32)]) + Word::from([0_u32, 0_u32, 0_u32, 32_u32]) ); let typed_map_new_slot = slots.iter().find(|s| s.name() == &typed_map_new_name).unwrap(); @@ -831,7 +818,7 @@ fn extensive_schema_metadata_and_init_toml_example() { assert_eq!( typed_map_new_contents.get(&StorageMapKey::from_array([1, 2, 0, 0])), - Word::from([Felt::new(16), Felt::ZERO, Felt::ZERO, Felt::ZERO]) + Word::from([16_u32, 0, 0, 0]) ); let token_metadata_slot = @@ -861,7 +848,7 @@ fn extensive_schema_metadata_and_init_toml_example() { ); assert_eq!( static_map.get(&StorageMapKey::from_array([0, 0, 0, 2])), - Word::from([Felt::ZERO, Felt::ZERO, Felt::ZERO, Felt::new(32)]) + Word::from([0_u32, 0_u32, 0_u32, 32_u32]) ); assert_eq!( static_map.get(&StorageMapKey::from_raw(Word::parse("0x3").unwrap())), @@ -875,7 +862,6 @@ fn typed_map_init_entries_are_validated() { name = "typed map validation" description = "validates init-provided map entries against type.key/type.value" version = "0.1.0" - supported-types = [] [[storage.slots]] name = "demo::typed_map" @@ -912,12 +898,11 @@ fn typed_map_supports_non_numeric_value_types() { name = "typed map token_symbol" description = "parses typed map values using slot-level type.value" version = "0.1.0" - supported-types = [] [[storage.slots]] name = "demo::symbol_map" type.key = "word" - type.value = "miden::standards::fungible_faucets::metadata::token_symbol" + type.value = "miden::standards::faucets::fungible::token_symbol" "#; let metadata = AccountComponentMetadata::from_toml(metadata_toml).unwrap(); diff --git a/crates/miden-protocol/src/account/component/storage/type_registry.rs b/crates/miden-protocol/src/account/component/storage/type_registry.rs index 88c884e9ec..6871b5b1d6 100644 --- a/crates/miden-protocol/src/account/component/storage/type_registry.rs +++ b/crates/miden-protocol/src/account/component/storage/type_registry.rs @@ -8,6 +8,7 @@ use core::str::FromStr; use miden_core::{Felt, Word}; use thiserror::Error; +use crate::account::RoleSymbol; use crate::account::auth::{AuthScheme, PublicKey}; use crate::asset::TokenSymbol; use crate::utils::serde::{ @@ -32,6 +33,7 @@ pub static SCHEMA_TYPE_REGISTRY: LazyLock = LazyLock::new(|| registry.register_felt_type::(); registry.register_felt_type::(); registry.register_felt_type::(); + registry.register_felt_type::(); registry.register_felt_type::(); registry.register_word_type::(); registry.register_word_type::(); @@ -181,10 +183,15 @@ impl SchemaType { /// Returns the schema type for fungible faucet token symbols. pub fn token_symbol() -> SchemaType { - SchemaType::new("miden::standards::fungible_faucets::metadata::token_symbol") + SchemaType::new("miden::standards::faucets::fungible::token_symbol") .expect("type is well formed") } + /// Returns the schema type for RBAC role symbols. + pub fn role_symbol() -> SchemaType { + SchemaType::new("miden::standards::access::role_symbol").expect("type is well formed") + } + /// Returns a reference to the inner string. pub fn as_str(&self) -> &str { &self.0 @@ -280,11 +287,11 @@ where fn parse_str(input: &str) -> Result { let felt = ::parse_str(input)?; - Ok(Word::from([felt, Felt::new(0), Felt::new(0), Felt::new(0)])) + Ok(Word::from([felt, Felt::ZERO, Felt::ZERO, Felt::ZERO])) } fn display_word(value: Word) -> Result { - if value[1] != Felt::new(0) || value[2] != Felt::new(0) || value[3] != Felt::new(0) { + if value[1] != Felt::ZERO || value[2] != Felt::ZERO || value[3] != Felt::ZERO { return Err(SchemaTypeError::ConversionError(format!( "expected a word of the form [, 0, 0, 0] for type `{}`", Self::type_name() @@ -307,8 +314,8 @@ impl FeltType for Bool { fn parse_str(input: &str) -> Result { match input { - "true" | "1" => Ok(Felt::new(1)), - "false" | "0" => Ok(Felt::new(0)), + "true" | "1" => Ok(Felt::ONE), + "false" | "0" => Ok(Felt::ZERO), _ => Err(SchemaTypeError::ConversionError(format!( "invalid bool value `{input}`: expected `true`, `false`, `1`, or `0`" ))), @@ -336,14 +343,14 @@ impl FeltType for Void { fn parse_str(input: &str) -> Result { let parsed = ::parse_str(input)?; - if parsed != Felt::new(0) { + if parsed != Felt::ZERO { return Err(SchemaTypeError::ConversionError("void values must be zero".to_string())); } - Ok(Felt::new(0)) + Ok(Felt::ZERO) } fn display_felt(value: Felt) -> Result { - if value != Felt::new(0) { + if value != Felt::ZERO { return Err(SchemaTypeError::ConversionError("void values must be zero".to_string())); } Ok("0".into()) @@ -488,6 +495,29 @@ impl FeltType for TokenSymbol { } } +impl FeltType for RoleSymbol { + fn type_name() -> SchemaType { + SchemaType::role_symbol() + } + + fn parse_str(input: &str) -> Result { + let role_symbol = RoleSymbol::new(input).map_err(|err| { + SchemaTypeError::parse(input.to_string(), ::type_name(), err) + })?; + Ok(Felt::from(role_symbol)) + } + + fn display_felt(value: Felt) -> Result { + let role_symbol = RoleSymbol::try_from(value).map_err(|err| { + SchemaTypeError::ConversionError(format!( + "invalid role_symbol value `{}`: {err}", + value.as_canonical_u64() + )) + })?; + Ok(role_symbol.to_string()) + } +} + // WORD IMPLS FOR NATIVE TYPES // ================================================================================================ @@ -737,7 +767,7 @@ impl SchemaTypeRegistry { // Treat any registered felt type as a word type by zero-padding the remaining felts. if let Some(converter) = self.felt.get(type_name) { let felt = converter(value)?; - return Ok(Word::from([felt, Felt::new(0), Felt::new(0), Felt::new(0)])); + return Ok(Word::from([felt, Felt::ZERO, Felt::ZERO, Felt::ZERO])); } Err(SchemaTypeError::WordTypeNotFound(type_name.clone())) @@ -808,15 +838,24 @@ mod tests { // Bool type parses "true"/"false"/"1"/"0" and rejects everything else. let bool_type = SchemaType::bool(); - assert_eq!(SCHEMA_TYPE_REGISTRY.try_parse_felt(&bool_type, "true").unwrap(), Felt::new(1)); - assert_eq!(SCHEMA_TYPE_REGISTRY.try_parse_felt(&bool_type, "false").unwrap(), Felt::new(0)); - assert_eq!(SCHEMA_TYPE_REGISTRY.try_parse_felt(&bool_type, "1").unwrap(), Felt::new(1)); - assert_eq!(SCHEMA_TYPE_REGISTRY.try_parse_felt(&bool_type, "0").unwrap(), Felt::new(0)); - assert_eq!(SCHEMA_TYPE_REGISTRY.display_felt(&bool_type, Felt::new(0)), "false"); - assert_eq!(SCHEMA_TYPE_REGISTRY.display_felt(&bool_type, Felt::new(1)), "true"); + assert_eq!(SCHEMA_TYPE_REGISTRY.try_parse_felt(&bool_type, "true").unwrap(), Felt::ONE); + assert_eq!(SCHEMA_TYPE_REGISTRY.try_parse_felt(&bool_type, "false").unwrap(), Felt::ZERO); + assert_eq!(SCHEMA_TYPE_REGISTRY.try_parse_felt(&bool_type, "1").unwrap(), Felt::ONE); + assert_eq!(SCHEMA_TYPE_REGISTRY.try_parse_felt(&bool_type, "0").unwrap(), Felt::ZERO); + assert_eq!(SCHEMA_TYPE_REGISTRY.display_felt(&bool_type, Felt::ZERO), "false"); + assert_eq!(SCHEMA_TYPE_REGISTRY.display_felt(&bool_type, Felt::ONE), "true"); assert!(SCHEMA_TYPE_REGISTRY.try_parse_felt(&bool_type, "yes").is_err()); assert!(SCHEMA_TYPE_REGISTRY.try_parse_felt(&bool_type, "2").is_err()); - assert!(SCHEMA_TYPE_REGISTRY.validate_felt_value(&bool_type, Felt::new(2)).is_err()); + assert!(SCHEMA_TYPE_REGISTRY.validate_felt_value(&bool_type, Felt::from(2_u32)).is_err()); + + let role_symbol_type = SchemaType::role_symbol(); + let role_symbol = + SCHEMA_TYPE_REGISTRY.try_parse_felt(&role_symbol_type, "MINTER_ADMIN").unwrap(); + assert_eq!( + SCHEMA_TYPE_REGISTRY.display_felt(&role_symbol_type, role_symbol), + "MINTER_ADMIN" + ); + assert!(SCHEMA_TYPE_REGISTRY.try_parse_felt(&role_symbol_type, "minter").is_err()); } } diff --git a/crates/miden-protocol/src/account/delta/mod.rs b/crates/miden-protocol/src/account/delta/mod.rs index 745e337b8b..a02c465315 100644 --- a/crates/miden-protocol/src/account/delta/mod.rs +++ b/crates/miden-protocol/src/account/delta/mod.rs @@ -605,7 +605,6 @@ mod tests { AccountCode, AccountId, AccountStorage, - AccountStorageMode, AccountType, StorageMapDelta, StorageMapKey, @@ -703,22 +702,16 @@ mod tests { )], ); - let non_fungible: Asset = NonFungibleAsset::new( - &NonFungibleAssetDetails::new( - AccountIdBuilder::new() - .account_type(AccountType::NonFungibleFaucet) - .storage_mode(AccountStorageMode::Public) - .build_with_rng(&mut rand::rng()), - vec![6], - ) - .unwrap(), - ) - .unwrap() + let non_fungible: Asset = NonFungibleAsset::new(&NonFungibleAssetDetails::new( + AccountIdBuilder::new() + .account_type(AccountType::Public) + .build_with_rng(&mut rand::rng()), + vec![6], + )) .into(); let fungible_2: Asset = FungibleAsset::new( AccountIdBuilder::new() - .account_type(AccountType::FungibleFaucet) - .storage_mode(AccountStorageMode::Public) + .account_type(AccountType::Public) .build_with_rng(&mut rand::rng()), 10, ) diff --git a/crates/miden-protocol/src/account/delta/storage.rs b/crates/miden-protocol/src/account/delta/storage.rs index e13603c746..20c0418bb6 100644 --- a/crates/miden-protocol/src/account/delta/storage.rs +++ b/crates/miden-protocol/src/account/delta/storage.rs @@ -178,8 +178,8 @@ impl AccountStorageDelta { /// Appends the storage slots delta to the given `elements` from which the delta commitment will /// be computed. pub(super) fn append_delta_elements(&self, elements: &mut Vec) { - const DOMAIN_VALUE: Felt = Felt::new(2); - const DOMAIN_MAP: Felt = Felt::new(3); + let domain_value = Felt::from_u8(2); + let domain_map = Felt::from_u8(3); for (slot_name, slot_delta) in self.deltas.iter() { let slot_id = slot_name.id(); @@ -187,7 +187,7 @@ impl AccountStorageDelta { match slot_delta { StorageSlotDelta::Value(new_value) => { elements.extend_from_slice(&[ - DOMAIN_VALUE, + domain_value, ZERO, slot_id.suffix(), slot_id.prefix(), @@ -206,7 +206,7 @@ impl AccountStorageDelta { ); elements.extend_from_slice(&[ - DOMAIN_MAP, + domain_map, num_changed_entries, slot_id.suffix(), slot_id.prefix(), diff --git a/crates/miden-protocol/src/account/delta/vault.rs b/crates/miden-protocol/src/account/delta/vault.rs index 7586337f54..871f500dca 100644 --- a/crates/miden-protocol/src/account/delta/vault.rs +++ b/crates/miden-protocol/src/account/delta/vault.rs @@ -11,7 +11,6 @@ use super::{ DeserializationError, Serializable, }; -use crate::account::AccountType; use crate::asset::{Asset, AssetVaultKey, FungibleAsset, NonFungibleAsset}; use crate::{Felt, ONE, ZERO}; @@ -19,7 +18,7 @@ use crate::{Felt, ONE, ZERO}; // ================================================================================================ /// The domain for the assets in the delta commitment. -const DOMAIN_ASSET: Felt = Felt::new(1); +const DOMAIN_ASSET: Felt = Felt::ONE; /// [AccountVaultDelta] stores the difference between the initial and final account vault states. /// @@ -205,10 +204,9 @@ impl FungibleAssetDelta { /// # Errors /// Returns an error if the delta does not pass the validation. pub fn new(map: BTreeMap) -> Result { - let delta = Self(map); - delta.validate()?; + Self::validate(&map)?; - Ok(delta) + Ok(Self(map)) } /// Adds a new fungible asset to the delta. @@ -216,7 +214,7 @@ impl FungibleAssetDelta { /// # Errors /// Returns an error if the delta would overflow. pub fn add(&mut self, asset: FungibleAsset) -> Result<(), AccountDeltaError> { - let amount: i64 = asset.amount().try_into().expect("Amount it too high"); + let amount: i64 = asset.amount().as_i64(); self.add_delta(asset.vault_key(), amount) } @@ -225,7 +223,7 @@ impl FungibleAssetDelta { /// # Errors /// Returns an error if the delta would overflow. pub fn remove(&mut self, asset: FungibleAsset) -> Result<(), AccountDeltaError> { - let amount: i64 = asset.amount().try_into().expect("Amount it too high"); + let amount: i64 = asset.amount().as_i64(); self.add_delta(asset.vault_key(), -amount) } @@ -309,9 +307,9 @@ impl FungibleAssetDelta { /// /// # Errors /// Returns an error if one or more fungible assets' faucet IDs are invalid. - fn validate(&self) -> Result<(), AccountDeltaError> { - for vault_key in self.0.keys() { - if !matches!(vault_key.faucet_id().account_type(), AccountType::FungibleFaucet) { + fn validate(map: &BTreeMap) -> Result<(), AccountDeltaError> { + for vault_key in map.keys() { + if !vault_key.composition().is_fungible() { return Err(AccountDeltaError::NotAFungibleFaucetId(vault_key.faucet_id())); } } @@ -606,8 +604,8 @@ mod tests { #[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)] + #[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 @@ -656,11 +654,9 @@ mod tests { account_id: AccountId, added: Option, ) -> AccountVaultDelta { - let asset: Asset = NonFungibleAsset::new( - &NonFungibleAssetDetails::new(account_id, vec![1, 2, 3]).unwrap(), - ) - .unwrap() - .into(); + let asset: Asset = + NonFungibleAsset::new(&NonFungibleAssetDetails::new(account_id, vec![1, 2, 3])) + .into(); match added { Some(true) => AccountVaultDelta::from_iters([asset], []), diff --git a/crates/miden-protocol/src/account/file.rs b/crates/miden-protocol/src/account/file.rs index 64e979cdb2..7c8e309c84 100644 --- a/crates/miden-protocol/src/account/file.rs +++ b/crates/miden-protocol/src/account/file.rs @@ -117,7 +117,7 @@ mod tests { // create account and auth let vault = AssetVault::new(&[]).unwrap(); let storage = AccountStorage::new(vec![]).unwrap(); - let nonce = Felt::new(1); + let nonce = Felt::from(1_u32); let account = Account::new_existing(id, vault, storage, code, nonce); let auth_secret_key = AuthSecretKey::new_falcon512_poseidon2(); let auth_secret_key_2 = AuthSecretKey::new_falcon512_poseidon2(); diff --git a/crates/miden-protocol/src/account/header.rs b/crates/miden-protocol/src/account/header.rs index 93635fa3a3..c817eb966d 100644 --- a/crates/miden-protocol/src/account/header.rs +++ b/crates/miden-protocol/src/account/header.rs @@ -254,7 +254,7 @@ mod tests { #[test] fn test_serde_account_storage() { - let init_nonce = Felt::new(1); + let init_nonce = Felt::from(1_u32); let asset_0 = FungibleAsset::mock(99); let word = Word::from([1, 2, 3, 4u32]); let storage_slot = StorageSlotContent::Value(word); diff --git a/crates/miden-protocol/src/account/interface/mod.rs b/crates/miden-protocol/src/account/interface/mod.rs new file mode 100644 index 0000000000..8a5ff7d7b6 --- /dev/null +++ b/crates/miden-protocol/src/account/interface/mod.rs @@ -0,0 +1,2 @@ +mod name; +pub use name::AccountComponentName; diff --git a/crates/miden-protocol/src/account/interface/name.rs b/crates/miden-protocol/src/account/interface/name.rs new file mode 100644 index 0000000000..67e9c0decf --- /dev/null +++ b/crates/miden-protocol/src/account/interface/name.rs @@ -0,0 +1,125 @@ +use alloc::borrow::Cow; +use alloc::string::String; +use core::fmt::Display; +use core::str::FromStr; + +use crate::account::name_validation::{self, NameValidationError}; +use crate::errors::AccountComponentNameError; + +/// The canonical name of an account component. +/// +/// Names follow the same syntax as [`StorageSlotName`](crate::account::StorageSlotName): +/// `::`-separated components consisting of ASCII alphanumeric characters or underscores, with at +/// least two components and no leading underscore on any component. See the type-level docs of +/// [`StorageSlotName`](crate::account::StorageSlotName) for the full grammar. +/// +/// The name is stored as a `Cow<'static, str>` so that hard-coded component names (e.g. the +/// `NAME` constant on each standard component) can be turned into typed names via +/// [`AccountComponentName::from_static_str`] without any runtime allocation, while runtime names +/// (parsed strings, etc.) can still be constructed via [`AccountComponentName::new`]. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct AccountComponentName(Cow<'static, str>); + +impl AccountComponentName { + // CONSTRUCTORS + // -------------------------------------------------------------------------------------------- + + /// Constructs an [`AccountComponentName`] from a `&'static str` at compile time. + /// + /// # Panics + /// + /// Panics if `name` does not satisfy the validity rules described in the type-level + /// documentation of [`AccountComponentName`]. + pub const fn from_static_str(name: &'static str) -> Self { + match name_validation::validate(name) { + Ok(()) => Self(Cow::Borrowed(name)), + Err(_) => panic!("invalid AccountComponentName: see the type-level documentation"), + } + } + + /// Constructs an [`AccountComponentName`] from any value convertible into a + /// `Cow<'static, str>`. + /// + /// # Errors + /// + /// Returns an error if `name` is not a valid component name (see the type-level docs). + pub fn new(name: impl Into>) -> Result { + let name = name.into(); + name_validation::validate(&name).map_err(AccountComponentNameError::from_internal)?; + Ok(Self(name)) + } + + // ACCESSORS + // -------------------------------------------------------------------------------------------- + + /// Returns the component name as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl AccountComponentNameError { + /// Maps each [`NameValidationError`] variant to its corresponding + /// [`AccountComponentNameError`] variant. + pub(crate) fn from_internal(error: NameValidationError) -> Self { + match error { + NameValidationError::TooShort => Self::TooShort, + NameValidationError::TooLong => Self::TooLong, + NameValidationError::UnexpectedColon => Self::UnexpectedColon, + NameValidationError::UnexpectedUnderscore => Self::UnexpectedUnderscore, + NameValidationError::InvalidCharacter => Self::InvalidCharacter, + } + } +} + +impl Display for AccountComponentName { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for AccountComponentName { + type Err = AccountComponentNameError; + + fn from_str(string: &str) -> Result { + Self::new(String::from(string)) + } +} + +impl TryFrom<&str> for AccountComponentName { + type Error = AccountComponentNameError; + + fn try_from(value: &str) -> Result { + value.parse() + } +} + +impl TryFrom for AccountComponentName { + type Error = AccountComponentNameError; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl From for String { + fn from(name: AccountComponentName) -> Self { + name.0.into_owned() + } +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + //! Note: Most tests live in crate::account::name_validation. + + use super::*; + + #[test] + #[should_panic(expected = "invalid AccountComponentName")] + fn from_static_str_panics_on_invalid_input() { + AccountComponentName::from_static_str("not_two_components"); + } +} diff --git a/crates/miden-protocol/src/account/mod.rs b/crates/miden-protocol/src/account/mod.rs index 778f36a688..89c228bf90 100644 --- a/crates/miden-protocol/src/account/mod.rs +++ b/crates/miden-protocol/src/account/mod.rs @@ -17,15 +17,19 @@ mod account_id; pub use account_id::{ AccountId, AccountIdPrefix, - AccountIdPrefixV0, - AccountIdV0, + AccountIdPrefixV1, + AccountIdV1, AccountIdVersion, - AccountStorageMode, AccountType, }; +pub(crate) mod name_validation; + pub mod auth; +mod access; +pub use access::RoleSymbol; + mod builder; pub use builder::AccountBuilder; @@ -36,6 +40,9 @@ pub use code::procedure::AccountProcedureRoot; pub mod component; pub use component::{AccountComponent, AccountComponentCode, AccountComponentMetadata}; +pub mod interface; +pub use interface::AccountComponentName; + pub mod delta; pub use delta::{ AccountDelta, @@ -164,7 +171,6 @@ impl Account { /// # Errors /// /// Returns an error if: - /// - Any of the components does not support `account_type`. /// - The number of procedures in all merged libraries is 0 or exceeds /// [`AccountCode::MAX_NUM_PROCEDURES`]. /// - Two or more libraries export a procedure with the same MAST root. @@ -173,11 +179,8 @@ impl Account { /// - The number of [`StorageSlot`]s of all components exceeds 255. /// - [`MastForest::merge`](miden_processor::MastForest::merge) fails on all libraries. pub(super) fn initialize_from_components( - account_type: AccountType, components: Vec, ) -> Result<(AccountCode, AccountStorage), AccountError> { - validate_components_support_account_type(&components, account_type)?; - let code = AccountCode::from_components_unchecked(&components)?; let storage = AccountStorage::from_components(components)?; @@ -224,11 +227,6 @@ impl Account { self.id } - /// Returns the account type - pub fn account_type(&self) -> AccountType { - self.id.account_type() - } - /// Returns a reference to the vault of this account. pub fn vault(&self) -> &AssetVault { &self.vault @@ -256,37 +254,16 @@ impl Account { self.seed } - /// Returns true if this account can issue assets. - pub fn is_faucet(&self) -> bool { - self.id.is_faucet() - } - - /// Returns true if this is a regular account. - pub fn is_regular_account(&self) -> bool { - self.id.is_regular_account() - } - - /// Returns `true` if the full state of the account is public on chain, i.e. if the modes are - /// [`AccountStorageMode::Public`] or [`AccountStorageMode::Network`], `false` otherwise. - pub fn has_public_state(&self) -> bool { - self.id().has_public_state() - } - - /// Returns `true` if the storage mode is [`AccountStorageMode::Public`], `false` otherwise. + /// Returns `true` if the account type is [`AccountType::Public`], `false` otherwise. pub fn is_public(&self) -> bool { self.id().is_public() } - /// Returns `true` if the storage mode is [`AccountStorageMode::Private`], `false` otherwise. + /// Returns `true` if the account type is [`AccountType::Private`], `false` otherwise. pub fn is_private(&self) -> bool { self.id().is_private() } - /// Returns `true` if the storage mode is [`AccountStorageMode::Network`], `false` otherwise. - pub fn is_network(&self) -> bool { - self.id().is_network() - } - /// Returns `true` if the account is new, `false` otherwise. /// /// An account is considered new if the account's nonce is zero and it hasn't been registered on @@ -527,33 +504,14 @@ pub(super) fn validate_account_seed( } } -/// Validates that all `components` support the given `account_type`. -fn validate_components_support_account_type( - components: &[AccountComponent], - account_type: AccountType, -) -> Result<(), AccountError> { - for (component_index, component) in components.iter().enumerate() { - if !component.supports_type(account_type) { - return Err(AccountError::UnsupportedComponentForAccountType { - account_type, - component_index, - }); - } - } - - Ok(()) -} - // TESTS // ================================================================================================ #[cfg(test)] mod tests { - use alloc::sync::Arc; use alloc::vec::Vec; use assert_matches::assert_matches; - use miden_assembly::Assembler; use miden_crypto::utils::{Deserializable, Serializable}; use miden_crypto::{Felt, Word}; @@ -565,12 +523,9 @@ mod tests { AccountStorageDelta, AccountVaultDelta, }; - use crate::account::AccountStorageMode::Network; - use crate::account::component::AccountComponentMetadata; use crate::account::{ Account, AccountBuilder, - AccountComponent, AccountIdVersion, AccountType, PartialAccount, @@ -592,7 +547,7 @@ mod tests { #[test] fn test_serde_account() { - let init_nonce = Felt::new(1); + let init_nonce = Felt::from(1_u32); let asset_0 = FungibleAsset::mock(99); let word = Word::from([1, 2, 3, 4u32]); let storage_slot = StorageSlotContent::Value(word); @@ -606,7 +561,7 @@ mod tests { #[test] fn test_serde_account_delta() { let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER).unwrap(); - let nonce_delta = Felt::new(2); + 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() @@ -629,7 +584,7 @@ mod tests { fn valid_account_delta_is_correctly_applied() { // build account let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER).unwrap(); - let init_nonce = Felt::new(1); + let init_nonce = Felt::from(1_u32); let asset_0 = FungibleAsset::mock(100); let asset_1 = NonFungibleAsset::mock(&[1, 2, 3]); @@ -639,16 +594,11 @@ mod tests { let mut storage_map = StorageMap::with_entries([ ( StorageMapKey::from_array([101, 102, 103, 104]), - Word::from([ - Felt::new(1_u64), - Felt::new(2_u64), - Felt::new(3_u64), - Felt::new(4_u64), - ]), + Word::from([1_u32, 2_u32, 3_u32, 4_u32]), ), ( StorageMapKey::from_array([105, 106, 107, 108]), - Word::new([Felt::new(5_u64), Felt::new(6_u64), Felt::new(7_u64), Felt::new(8_u64)]), + Word::from([5_u32, 6_u32, 7_u32, 8_u32]), ), ]) .unwrap(); @@ -668,7 +618,7 @@ mod tests { storage_map.insert(key, value).unwrap(); // build account delta - let final_nonce = Felt::new(2); + let final_nonce = Felt::from(2_u32); let storage_delta = AccountStorageDelta::new() .add_cleared_items([StorageSlotName::mock(0)]) .add_updated_values([(StorageSlotName::mock(1), Word::from([1, 2, 3, 4u32]))]) @@ -703,7 +653,7 @@ mod tests { fn valid_account_delta_with_unchanged_nonce() { // build account let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER).unwrap(); - let init_nonce = Felt::new(1); + 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())]); @@ -724,13 +674,13 @@ mod tests { fn valid_account_delta_with_decremented_nonce() { // build account let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER).unwrap(); - let init_nonce = Felt::new(2); + 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::new(1); + 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]))]); @@ -745,13 +695,13 @@ mod tests { fn empty_account_delta_with_incremented_nonce() { // build account let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER).unwrap(); - let init_nonce = Felt::new(1); + 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]); // build account delta - let nonce_delta = Felt::new(1); + let nonce_delta = Felt::from(1_u32); let account_delta = AccountDelta::new( account_id, AccountStorageDelta::new(), @@ -796,40 +746,6 @@ mod tests { Account::new_existing(id, vault, storage, code, nonce) } - /// Tests that initializing code and storage from a component which does not support the given - /// account type returns an error. - #[test] - fn test_account_unsupported_component_type() { - let code1 = "pub proc foo add end"; - let library1 = - Arc::unwrap_or_clone(Assembler::default().assemble_library([code1]).unwrap()); - - // This component support all account types except the regular account with updatable code. - let metadata = AccountComponentMetadata::new( - "test::component1", - [ - AccountType::FungibleFaucet, - AccountType::NonFungibleFaucet, - AccountType::RegularAccountImmutableCode, - ], - ); - let component1 = AccountComponent::new(library1, vec![], metadata).unwrap(); - - let err = Account::initialize_from_components( - AccountType::RegularAccountUpdatableCode, - vec![component1], - ) - .unwrap_err(); - - assert!(matches!( - err, - AccountError::UnsupportedComponentForAccountType { - account_type: AccountType::RegularAccountUpdatableCode, - component_index: 0 - } - )) - } - /// Tests all cases of account ID seed validation. #[test] fn seed_validation() -> anyhow::Result<()> { @@ -842,9 +758,8 @@ mod tests { let other_seed = AccountId::compute_account_seed( [9; 32], - AccountType::FungibleFaucet, - Network, - AccountIdVersion::Version0, + AccountType::Public, + AccountIdVersion::Version1, code.commitment(), storage.to_commitment(), )?; diff --git a/crates/miden-protocol/src/account/name_validation.rs b/crates/miden-protocol/src/account/name_validation.rs new file mode 100644 index 0000000000..066c9a173a --- /dev/null +++ b/crates/miden-protocol/src/account/name_validation.rs @@ -0,0 +1,264 @@ +//! Shared validation rules used by both [`StorageSlotName`](super::StorageSlotName) and +//! [`AccountComponentName`](crate::account::AccountComponentName). +//! +//! Both names share the same syntax: `1..=255` total bytes, `::`-separated components, each +//! component consisting of ASCII alphanumeric characters or underscores and not starting with an +//! underscore. +//! +//! Public callers should use the validation methods on the public types (which map this internal +//! error into their respective public error types). This module is `pub(crate)` to keep the +//! mapping exhaustive and the public surface clean. + +/// Errors that can be produced by the shared name validator. +/// +/// This type is intentionally internal: each public name type maps it exhaustively into its own +/// error enum so the public error surface stays type-specific. +#[derive(Debug)] +pub(crate) enum NameValidationError { + TooShort, + TooLong, + UnexpectedColon, + UnexpectedUnderscore, + InvalidCharacter, +} + +/// The minimum number of `::`-separated components a name must contain. +pub(crate) const MIN_NUM_COMPONENTS: usize = 2; + +/// The maximum number of bytes a name may contain. +pub(crate) const MAX_LENGTH: usize = u8::MAX as usize; + +/// Validates a name against the shared rules. +/// +/// We must check validity against the raw bytes of the UTF-8 string because typical character APIs +/// are not available in a const context. We can do this because any byte in a UTF-8 string that is +/// an ASCII character never represents anything other than such a character, even though UTF-8 can +/// contain multi-byte sequences: +/// +/// > UTF-8, the object of this memo, has a one-octet encoding unit. It uses all bits of an +/// > octet, but has the quality of preserving the full US-ASCII range: US-ASCII characters +/// > are encoded in one octet having the normal US-ASCII value, and any octet with such a value +/// > can only stand for a US-ASCII character, and nothing else. +/// > +pub(crate) const fn validate(name: &str) -> Result<(), NameValidationError> { + let bytes = name.as_bytes(); + let mut idx = 0; + let mut num_components = 0; + + if bytes.is_empty() { + return Err(NameValidationError::TooShort); + } + + if bytes.len() > MAX_LENGTH { + return Err(NameValidationError::TooLong); + } + + // Names must not start with a colon or underscore. + // SAFETY: We just checked that we're not dealing with an empty slice. + if bytes[0] == b':' { + return Err(NameValidationError::UnexpectedColon); + } else if bytes[0] == b'_' { + return Err(NameValidationError::UnexpectedUnderscore); + } + + while idx < bytes.len() { + let byte = bytes[idx]; + + let is_colon = byte == b':'; + + if is_colon { + // A colon must always be followed by another colon. In other words, we + // expect a double colon. + if (idx + 1) < bytes.len() { + if bytes[idx + 1] != b':' { + return Err(NameValidationError::UnexpectedColon); + } + } else { + return Err(NameValidationError::UnexpectedColon); + } + + // A component cannot end with a colon, so this allows us to validate the start of a + // component: It must not start with a colon or an underscore. + if (idx + 2) < bytes.len() { + if bytes[idx + 2] == b':' { + return Err(NameValidationError::UnexpectedColon); + } else if bytes[idx + 2] == b'_' { + return Err(NameValidationError::UnexpectedUnderscore); + } + } else { + return Err(NameValidationError::UnexpectedColon); + } + + // Advance past the double colon. + idx += 2; + + // A double colon completes a name component. + num_components += 1; + } else if is_valid_char(byte) { + idx += 1; + } else { + return Err(NameValidationError::InvalidCharacter); + } + } + + // The last component is not counted as part of the loop because no double colon follows. + num_components += 1; + + if num_components < MIN_NUM_COMPONENTS { + return Err(NameValidationError::TooShort); + } + + Ok(()) +} + +const fn is_valid_char(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +pub(super) mod tests { + use alloc::string::String; + use std::borrow::ToOwned; + use std::string::ToString; + + use assert_matches::assert_matches; + use rstest::rstest; + + use super::*; + use crate::account::{AccountComponentName, StorageSlotName}; + + // A string containing all allowed characters of a slot name. + const FULL_ALPHABET: &str = "abcdefghijklmnopqrstuvwxyz_ABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789"; + + // Invalid colon or underscore tests + // -------------------------------------------------------------------------------------------- + + #[test] + fn slot_name_fails_on_invalid_colon_placement() { + // Single colon. + assert_matches!(validate(":").unwrap_err(), NameValidationError::UnexpectedColon); + assert_matches!(validate("0::1:").unwrap_err(), NameValidationError::UnexpectedColon); + assert_matches!(validate(":0::1").unwrap_err(), NameValidationError::UnexpectedColon); + assert_matches!(validate("0::1:2").unwrap_err(), NameValidationError::UnexpectedColon); + + // Double colon (placed invalidly). + assert_matches!(validate("::").unwrap_err(), NameValidationError::UnexpectedColon); + assert_matches!(validate("1::2::").unwrap_err(), NameValidationError::UnexpectedColon); + assert_matches!(validate("::1::2").unwrap_err(), NameValidationError::UnexpectedColon); + + // Triple colon. + assert_matches!(validate(":::").unwrap_err(), NameValidationError::UnexpectedColon); + assert_matches!(validate("1::2:::").unwrap_err(), NameValidationError::UnexpectedColon); + assert_matches!(validate(":::1::2").unwrap_err(), NameValidationError::UnexpectedColon); + assert_matches!(validate("1::2:::3").unwrap_err(), NameValidationError::UnexpectedColon); + } + + #[test] + fn slot_name_fails_on_invalid_underscore_placement() { + assert_matches!( + validate("_one::two").unwrap_err(), + NameValidationError::UnexpectedUnderscore + ); + assert_matches!( + validate("one::_two").unwrap_err(), + NameValidationError::UnexpectedUnderscore + ); + } + + // Length validation tests + // -------------------------------------------------------------------------------------------- + + #[test] + fn slot_name_fails_on_empty_string() { + assert_matches!(validate("").unwrap_err(), NameValidationError::TooShort); + } + + #[test] + fn slot_name_fails_on_single_component() { + assert_matches!(validate("single_component").unwrap_err(), NameValidationError::TooShort); + } + + #[test] + fn slot_name_fails_on_string_whose_length_exceeds_max_length() { + let mut string = get_max_length_name(); + string.push('a'); + assert_matches!(validate(&string).unwrap_err(), NameValidationError::TooLong); + } + + // Alphabet validation tests + // -------------------------------------------------------------------------------------------- + + #[test] + fn slot_name_allows_ascii_alphanumeric_and_underscore() { + let name = format!("{FULL_ALPHABET}::second"); + validate(&name).unwrap(); + } + + #[test] + fn slot_name_fails_on_invalid_character() { + assert_matches!( + validate("na#me::second").unwrap_err(), + NameValidationError::InvalidCharacter + ); + assert_matches!( + validate("first_entry::secönd").unwrap_err(), + NameValidationError::InvalidCharacter + ); + assert_matches!( + validate("first::sec::th!rd").unwrap_err(), + NameValidationError::InvalidCharacter + ); + } + + // Valid slot name tests + // -------------------------------------------------------------------------------------------- + + #[test] + fn slot_name_with_min_components_is_valid() { + validate("miden::component").unwrap() + } + + #[test] + fn slot_name_with_many_components_is_valid() { + validate("miden::faucet0::fungible_1::b4sic::metadata").unwrap(); + } + + #[test] + fn slot_name_with_max_length_is_valid() { + validate(&get_max_length_name()).unwrap(); + } + + // Shared validation parity + // -------------------------------------------------------------------------------------------- + + /// Confirms that both `StorageSlotName::new` and `AccountComponentName::new` reject the same + /// inputs through the shared validator. Guards against the extraction silently dropping a + /// check. + #[rstest] + #[case("")] + #[case("single")] + #[case(":leading_colon::ok")] + #[case("_leading_underscore::ok")] + #[case("ok::_leading_underscore")] + #[case("ok::bad#char")] + #[case("triple:::colon::ok")] + fn shared_validation_parity(#[case] name: &str) { + assert!(StorageSlotName::new(name.to_string()).is_err()); + assert!(AccountComponentName::new(name.to_string()).is_err()); + } + + // Test helpers + // -------------------------------------------------------------------------------------------- + + pub(crate) fn get_max_length_name() -> String { + const MIDEN_STR: &str = "miden::"; + let remainder = ['a'; MAX_LENGTH - MIDEN_STR.len()]; + let mut string = MIDEN_STR.to_owned(); + string.extend(remainder); + assert_eq!(string.len(), MAX_LENGTH); + string + } +} diff --git a/crates/miden-protocol/src/account/partial.rs b/crates/miden-protocol/src/account/partial.rs index 414c2ef067..91d3e38b63 100644 --- a/crates/miden-protocol/src/account/partial.rs +++ b/crates/miden-protocol/src/account/partial.rs @@ -144,11 +144,6 @@ impl PartialAccount { } } - /// Returns `true` if the full state of the account is public on chain, and `false` otherwise. - pub fn has_public_state(&self) -> bool { - self.id.has_public_state() - } - /// Consumes self and returns the underlying parts of the partial account. pub fn into_parts( self, diff --git a/crates/miden-protocol/src/account/storage/header.rs b/crates/miden-protocol/src/account/storage/header.rs index cc809a28d6..a9a401c12c 100644 --- a/crates/miden-protocol/src/account/storage/header.rs +++ b/crates/miden-protocol/src/account/storage/header.rs @@ -369,7 +369,12 @@ mod tests { ( MOCK_VALUE_SLOT1.clone(), StorageSlotType::Value, - Word::from([Felt::new(5), Felt::new(6), Felt::new(7), Felt::new(8)]), + Word::from([ + Felt::from(5_u32), + Felt::from(6_u32), + Felt::from(7_u32), + Felt::from(8_u32), + ]), ), (MOCK_MAP_SLOT.clone(), StorageSlotType::Map, storage_map.root()), ]; @@ -415,7 +420,7 @@ mod tests { let slot1 = StorageSlotHeader::new( slot_name1, StorageSlotType::Value, - Word::new([Felt::new(1), Felt::new(2), Felt::new(3), Felt::new(4)]), + Word::new([Felt::ONE, Felt::from(2_u32), Felt::from(3_u32), Felt::from(4_u32)]), ); let single_slot_header = AccountStorageHeader::new(vec![slot1.clone()]).unwrap(); @@ -438,12 +443,17 @@ mod tests { let slot2 = StorageSlotHeader::new( slot_name2, StorageSlotType::Map, - Word::new([Felt::new(5), Felt::new(6), Felt::new(7), Felt::new(8)]), + Word::new([Felt::from(5_u32), Felt::from(6_u32), Felt::from(7_u32), Felt::from(8_u32)]), ); let slot3 = StorageSlotHeader::new( slot_name3, StorageSlotType::Value, - Word::new([Felt::new(9), Felt::new(10), Felt::new(11), Felt::new(12)]), + Word::new([ + Felt::from(9_u32), + Felt::from(10_u32), + Felt::from(11_u32), + Felt::from(12_u32), + ]), ); let mut slots = vec![slot2, slot3]; @@ -465,7 +475,7 @@ mod tests { #[test] fn test_from_elements_errors() { // Test with invalid length (not divisible by 8). - let invalid_elements = vec![Felt::new(1), Felt::new(2), Felt::new(3)]; + let invalid_elements = vec![Felt::ONE, Felt::new_unchecked(2), Felt::new_unchecked(3)]; let empty_slot_names = BTreeMap::new(); assert!( AccountStorageHeader::try_from_elements(&invalid_elements, &empty_slot_names).is_err() @@ -473,7 +483,7 @@ mod tests { // Test with invalid slot type. let mut invalid_type_elements = vec![crate::ZERO; 8]; - invalid_type_elements[1] = Felt::new(5); // Invalid slot type. + invalid_type_elements[1] = Felt::new_unchecked(5); // Invalid slot type. assert!( AccountStorageHeader::try_from_elements(&invalid_type_elements, &empty_slot_names) .is_err() @@ -489,7 +499,7 @@ mod tests { let slot1 = StorageSlotHeader::new( slot_name1.clone(), StorageSlotType::Value, - Word::new([Felt::new(1), Felt::new(2), Felt::new(3), Felt::new(4)]), + Word::new([Felt::ONE, Felt::from(2_u32), Felt::from(3_u32), Felt::from(4_u32)]), ); // Serialize the single slot to elements diff --git a/crates/miden-protocol/src/account/storage/map/key.rs b/crates/miden-protocol/src/account/storage/map/key.rs index dda1a09c36..5838311fc5 100644 --- a/crates/miden-protocol/src/account/storage/map/key.rs +++ b/crates/miden-protocol/src/account/storage/map/key.rs @@ -1,7 +1,7 @@ use alloc::string::String; use miden_crypto::merkle::smt::{LeafIndex, SMT_DEPTH}; -use miden_protocol_macros::WordWrapper; +use miden_crypto_derive::WordWrapper; use crate::utils::serde::{ ByteReader, @@ -22,7 +22,7 @@ use crate::{Felt, Hasher, Word}; /// /// Use [`StorageMapKey::hash`] to produce the corresponding [`StorageMapKeyHash`] that is used /// in the SMT. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, WordWrapper)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, WordWrapper)] pub struct StorageMapKey(Word); impl StorageMapKey { @@ -102,7 +102,7 @@ impl Deserializable for StorageMapKey { /// This is produced by hashing a [`StorageMapKey`] and is used as the actual key in the /// underlying SMT. Wrapping the hashed key in a distinct type prevents accidentally using a raw /// key where a hashed key is expected and vice-versa. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, WordWrapper)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, WordWrapper)] pub struct StorageMapKeyHash(Word); impl StorageMapKeyHash { diff --git a/crates/miden-protocol/src/account/storage/mod.rs b/crates/miden-protocol/src/account/storage/mod.rs index 4f3217f905..99a22c9662 100644 --- a/crates/miden-protocol/src/account/storage/mod.rs +++ b/crates/miden-protocol/src/account/storage/mod.rs @@ -15,7 +15,7 @@ use super::{ use crate::account::AccountComponent; use crate::crypto::SequentialCommit; -mod slot; +pub(crate) mod slot; pub use slot::{StorageSlot, StorageSlotContent, StorageSlotId, StorageSlotName, StorageSlotType}; mod map; diff --git a/crates/miden-protocol/src/account/storage/slot/slot_name.rs b/crates/miden-protocol/src/account/storage/slot/slot_name.rs index 08892d5de1..d1fdffdbf9 100644 --- a/crates/miden-protocol/src/account/storage/slot/slot_name.rs +++ b/crates/miden-protocol/src/account/storage/slot/slot_name.rs @@ -3,6 +3,7 @@ use alloc::sync::Arc; use core::fmt::Display; use core::str::FromStr; +use crate::account::name_validation::{self, NameValidationError}; use crate::account::storage::slot::StorageSlotId; use crate::errors::StorageSlotNameError; use crate::utils::serde::{ @@ -51,10 +52,10 @@ impl StorageSlotName { // -------------------------------------------------------------------------------------------- /// The minimum number of components that a slot name must contain. - pub(crate) const MIN_NUM_COMPONENTS: usize = 2; + pub(crate) const MIN_NUM_COMPONENTS: usize = name_validation::MIN_NUM_COMPONENTS; /// The maximum number of characters in a slot name. - pub(crate) const MAX_LENGTH: usize = u8::MAX as usize; + pub(crate) const MAX_LENGTH: usize = name_validation::MAX_LENGTH; // CONSTRUCTORS // -------------------------------------------------------------------------------------------- @@ -98,95 +99,20 @@ impl StorageSlotName { // HELPERS // -------------------------------------------------------------------------------------------- - /// Validates a slot name. - /// - /// This checks that components are separated by double colons, that each component contains - /// only valid characters and that the name is not empty or starts or ends with a colon. - /// - /// We must check the validity of a slot name against the raw bytes of the UTF-8 string because - /// typical character APIs are not available in a const version. We can do this because any byte - /// in a UTF-8 string that is an ASCII character never represents anything other than such a - /// character, even though UTF-8 can contain multi-byte sequences: - /// - /// > UTF-8, the object of this memo, has a one-octet encoding unit. It uses all bits of an - /// > octet, but has the quality of preserving the full US-ASCII range: US-ASCII characters - /// > are encoded in one octet having the normal US-ASCII value, and any octet with such a value - /// > can only stand for a US-ASCII character, and nothing else. - /// > https://www.rfc-editor.org/rfc/rfc3629 + /// Validates a slot name against the shared name validation rules. const fn validate(name: &str) -> Result<(), StorageSlotNameError> { - let bytes = name.as_bytes(); - let mut idx = 0; - let mut num_components = 0; - - if bytes.is_empty() { - return Err(StorageSlotNameError::TooShort); - } - - if bytes.len() > Self::MAX_LENGTH { - return Err(StorageSlotNameError::TooLong); - } - - // Slot names must not start with a colon or underscore. - // SAFETY: We just checked that we're not dealing with an empty slice. - if bytes[0] == b':' { - return Err(StorageSlotNameError::UnexpectedColon); - } else if bytes[0] == b'_' { - return Err(StorageSlotNameError::UnexpectedUnderscore); + match name_validation::validate(name) { + Ok(()) => Ok(()), + Err(NameValidationError::TooShort) => Err(StorageSlotNameError::TooShort), + Err(NameValidationError::TooLong) => Err(StorageSlotNameError::TooLong), + Err(NameValidationError::UnexpectedColon) => Err(StorageSlotNameError::UnexpectedColon), + Err(NameValidationError::UnexpectedUnderscore) => { + Err(StorageSlotNameError::UnexpectedUnderscore) + }, + Err(NameValidationError::InvalidCharacter) => { + Err(StorageSlotNameError::InvalidCharacter) + }, } - - while idx < bytes.len() { - let byte = bytes[idx]; - - let is_colon = byte == b':'; - - if is_colon { - // A colon must always be followed by another colon. In other words, we - // expect a double colon. - if (idx + 1) < bytes.len() { - if bytes[idx + 1] != b':' { - return Err(StorageSlotNameError::UnexpectedColon); - } - } else { - return Err(StorageSlotNameError::UnexpectedColon); - } - - // A component cannot end with a colon, so this allows us to validate the start of a - // component: It must not start with a colon or an underscore. - if (idx + 2) < bytes.len() { - if bytes[idx + 2] == b':' { - return Err(StorageSlotNameError::UnexpectedColon); - } else if bytes[idx + 2] == b'_' { - return Err(StorageSlotNameError::UnexpectedUnderscore); - } - } else { - return Err(StorageSlotNameError::UnexpectedColon); - } - - // Advance past the double colon. - idx += 2; - - // A double colon completes a slot name component. - num_components += 1; - } else if Self::is_valid_char(byte) { - idx += 1; - } else { - return Err(StorageSlotNameError::InvalidCharacter); - } - } - - // The last component is not counted as part of the loop because no double colon follows. - num_components += 1; - - if num_components < Self::MIN_NUM_COMPONENTS { - return Err(StorageSlotNameError::TooShort); - } - - Ok(()) - } - - /// Returns `true` if the given byte is a valid slot name character, `false` otherwise. - const fn is_valid_char(byte: u8) -> bool { - byte.is_ascii_alphanumeric() || byte == b'_' } } @@ -262,160 +188,12 @@ impl Deserializable for StorageSlotName { } } -// TESTS -// ================================================================================================ - #[cfg(test)] mod tests { - use std::borrow::ToOwned; - - use assert_matches::assert_matches; + //! Note: Most tests live in crate::account::name_validation. use super::*; - // A string containing all allowed characters of a slot name. - const FULL_ALPHABET: &str = "abcdefghijklmnopqrstuvwxyz_ABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789"; - - // Invalid colon or underscore tests - // -------------------------------------------------------------------------------------------- - - #[test] - fn slot_name_fails_on_invalid_colon_placement() { - // Single colon. - assert_matches!( - StorageSlotName::new(":").unwrap_err(), - StorageSlotNameError::UnexpectedColon - ); - assert_matches!( - StorageSlotName::new("0::1:").unwrap_err(), - StorageSlotNameError::UnexpectedColon - ); - assert_matches!( - StorageSlotName::new(":0::1").unwrap_err(), - StorageSlotNameError::UnexpectedColon - ); - assert_matches!( - StorageSlotName::new("0::1:2").unwrap_err(), - StorageSlotNameError::UnexpectedColon - ); - - // Double colon (placed invalidly). - assert_matches!( - StorageSlotName::new("::").unwrap_err(), - StorageSlotNameError::UnexpectedColon - ); - assert_matches!( - StorageSlotName::new("1::2::").unwrap_err(), - StorageSlotNameError::UnexpectedColon - ); - assert_matches!( - StorageSlotName::new("::1::2").unwrap_err(), - StorageSlotNameError::UnexpectedColon - ); - - // Triple colon. - assert_matches!( - StorageSlotName::new(":::").unwrap_err(), - StorageSlotNameError::UnexpectedColon - ); - assert_matches!( - StorageSlotName::new("1::2:::").unwrap_err(), - StorageSlotNameError::UnexpectedColon - ); - assert_matches!( - StorageSlotName::new(":::1::2").unwrap_err(), - StorageSlotNameError::UnexpectedColon - ); - assert_matches!( - StorageSlotName::new("1::2:::3").unwrap_err(), - StorageSlotNameError::UnexpectedColon - ); - } - - #[test] - fn slot_name_fails_on_invalid_underscore_placement() { - assert_matches!( - StorageSlotName::new("_one::two").unwrap_err(), - StorageSlotNameError::UnexpectedUnderscore - ); - assert_matches!( - StorageSlotName::new("one::_two").unwrap_err(), - StorageSlotNameError::UnexpectedUnderscore - ); - } - - // Length validation tests - // -------------------------------------------------------------------------------------------- - - #[test] - fn slot_name_fails_on_empty_string() { - assert_matches!(StorageSlotName::new("").unwrap_err(), StorageSlotNameError::TooShort); - } - - #[test] - fn slot_name_fails_on_single_component() { - assert_matches!( - StorageSlotName::new("single_component").unwrap_err(), - StorageSlotNameError::TooShort - ); - } - - #[test] - fn slot_name_fails_on_string_whose_length_exceeds_max_length() { - let mut string = get_max_length_slot_name(); - string.push('a'); - assert_matches!(StorageSlotName::new(string).unwrap_err(), StorageSlotNameError::TooLong); - } - - // Alphabet validation tests - // -------------------------------------------------------------------------------------------- - - #[test] - fn slot_name_allows_ascii_alphanumeric_and_underscore() -> anyhow::Result<()> { - let name = format!("{FULL_ALPHABET}::second"); - let slot_name = StorageSlotName::new(name.clone())?; - assert_eq!(slot_name.as_str(), name); - - Ok(()) - } - - #[test] - fn slot_name_fails_on_invalid_character() { - assert_matches!( - StorageSlotName::new("na#me::second").unwrap_err(), - StorageSlotNameError::InvalidCharacter - ); - assert_matches!( - StorageSlotName::new("first_entry::secönd").unwrap_err(), - StorageSlotNameError::InvalidCharacter - ); - assert_matches!( - StorageSlotName::new("first::sec::th!rd").unwrap_err(), - StorageSlotNameError::InvalidCharacter - ); - } - - // Valid slot name tests - // -------------------------------------------------------------------------------------------- - - #[test] - fn slot_name_with_min_components_is_valid() -> anyhow::Result<()> { - StorageSlotName::new("miden::component")?; - Ok(()) - } - - #[test] - fn slot_name_with_many_components_is_valid() -> anyhow::Result<()> { - StorageSlotName::new("miden::faucet0::fungible_1::b4sic::metadata")?; - Ok(()) - } - - #[test] - fn slot_name_with_max_length_is_valid() -> anyhow::Result<()> { - StorageSlotName::new(get_max_length_slot_name())?; - Ok(()) - } - // Serialization tests // -------------------------------------------------------------------------------------------- @@ -428,20 +206,8 @@ mod tests { #[test] fn serde_max_length_slot_name() -> anyhow::Result<()> { - let slot_name = StorageSlotName::new(get_max_length_slot_name())?; + let slot_name = StorageSlotName::new(name_validation::tests::get_max_length_name())?; assert_eq!(slot_name, StorageSlotName::read_from_bytes(&slot_name.to_bytes())?); Ok(()) } - - // Test helpers - // -------------------------------------------------------------------------------------------- - - fn get_max_length_slot_name() -> String { - const MIDEN_STR: &str = "miden::"; - let remainder = ['a'; StorageSlotName::MAX_LENGTH - MIDEN_STR.len()]; - let mut string = MIDEN_STR.to_owned(); - string.extend(remainder); - assert_eq!(string.len(), StorageSlotName::MAX_LENGTH); - string - } } diff --git a/crates/miden-protocol/src/address/mod.rs b/crates/miden-protocol/src/address/mod.rs index 8a99b8eb0d..754a19a872 100644 --- a/crates/miden-protocol/src/address/mod.rs +++ b/crates/miden-protocol/src/address/mod.rs @@ -215,7 +215,7 @@ mod tests { use bech32::{Bech32, Bech32m, NoChecksum}; use super::*; - use crate::account::{AccountId, AccountStorageMode, AccountType}; + use crate::account::{AccountId, AccountType}; use crate::address::CustomNetworkId; use crate::errors::{AccountIdError, Bech32Error}; use crate::testing::account_id::{ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, AccountIdBuilder}; @@ -236,18 +236,8 @@ mod tests { NetworkId::Custom(Box::new(CustomNetworkId::from_str(longest_possible_hrp).unwrap())), ] { for (idx, account_id) in [ - AccountIdBuilder::new() - .account_type(AccountType::FungibleFaucet) - .build_with_rng(rng), - AccountIdBuilder::new() - .account_type(AccountType::NonFungibleFaucet) - .build_with_rng(rng), - AccountIdBuilder::new() - .account_type(AccountType::RegularAccountImmutableCode) - .build_with_rng(rng), - AccountIdBuilder::new() - .account_type(AccountType::RegularAccountUpdatableCode) - .build_with_rng(rng), + AccountIdBuilder::new().account_type(AccountType::Private).build_with_rng(rng), + AccountIdBuilder::new().account_type(AccountType::Public).build_with_rng(rng), ] .into_iter() .enumerate() @@ -294,9 +284,7 @@ mod tests { #[test] fn address_decoding_fails_on_trailing_separator() -> anyhow::Result<()> { - let id = AccountIdBuilder::new() - .account_type(AccountType::FungibleFaucet) - .build_with_rng(&mut rand::rng()); + let id = AccountIdBuilder::new().build_with_rng(&mut rand::rng()); let address = Address::new(id); let mut encoded_address = address.encode(NetworkId::Devnet); @@ -389,14 +377,7 @@ mod tests { fn address_serialization() -> anyhow::Result<()> { let rng = &mut rand::rng(); - for account_type in [ - AccountType::FungibleFaucet, - AccountType::NonFungibleFaucet, - AccountType::RegularAccountImmutableCode, - AccountType::RegularAccountUpdatableCode, - ] - .into_iter() - { + for account_type in [AccountType::Private, AccountType::Public].into_iter() { let account_id = AccountIdBuilder::new().account_type(account_type).build_with_rng(rng); let address = Address::new(account_id).with_routing_parameters( RoutingParameters::new(AddressInterface::BasicWallet) @@ -414,16 +395,14 @@ mod tests { /// Tests that an address with encryption key can be created and used. #[test] fn address_with_encryption_key() -> anyhow::Result<()> { - use crate::crypto::dsa::eddsa_25519_sha512::SecretKey; + use crate::crypto::dsa::eddsa_25519_sha512::KeyExchangeKey; use crate::crypto::ies::{SealingKey, UnsealingKey}; let rng = &mut rand::rng(); - let account_id = AccountIdBuilder::new() - .account_type(AccountType::FungibleFaucet) - .build_with_rng(rng); + let account_id = AccountIdBuilder::new().build_with_rng(rng); // Create keypair using rand::rng() - let secret_key = SecretKey::with_rng(rng); + let secret_key = KeyExchangeKey::with_rng(rng); let public_key = secret_key.public_key(); let sealing_key = SealingKey::X25519XChaCha20Poly1305(public_key.clone()); let unsealing_key = UnsealingKey::X25519XChaCha20Poly1305(secret_key.clone()); @@ -453,18 +432,14 @@ mod tests { /// Tests that an address with encryption key can be encoded/decoded. #[test] fn address_encryption_key_encode_decode() -> anyhow::Result<()> { - use crate::crypto::dsa::eddsa_25519_sha512::SecretKey; + use crate::crypto::dsa::eddsa_25519_sha512::KeyExchangeKey; let rng = &mut rand::rng(); - // Use a local account type (RegularAccountImmutableCode) instead of network - // (FungibleFaucet) - let account_id = AccountIdBuilder::new() - .account_type(AccountType::RegularAccountImmutableCode) - .storage_mode(AccountStorageMode::Public) - .build_with_rng(rng); + let account_id = + AccountIdBuilder::new().account_type(AccountType::Public).build_with_rng(rng); // Create keypair - let secret_key = SecretKey::with_rng(rng); + let secret_key = KeyExchangeKey::with_rng(rng); let public_key = secret_key.public_key(); let sealing_key = SealingKey::X25519XChaCha20Poly1305(public_key); @@ -493,9 +468,7 @@ mod tests { #[test] fn address_allows_max_note_tag_len() -> anyhow::Result<()> { - let account_id = AccountIdBuilder::new() - .account_type(AccountType::RegularAccountImmutableCode) - .build_with_rng(&mut rand::rng()); + let account_id = AccountIdBuilder::new().build_with_rng(&mut rand::rng()); let address = Address::new(account_id).with_routing_parameters( RoutingParameters::new(AddressInterface::BasicWallet) diff --git a/crates/miden-protocol/src/address/routing_parameters.rs b/crates/miden-protocol/src/address/routing_parameters.rs index ed0a45fe8a..ac37c7affe 100644 --- a/crates/miden-protocol/src/address/routing_parameters.rs +++ b/crates/miden-protocol/src/address/routing_parameters.rs @@ -541,8 +541,8 @@ mod tests { // Test X25519XChaCha20Poly1305 { - use crate::crypto::dsa::eddsa_25519_sha512::SecretKey; - let secret_key = SecretKey::with_rng(&mut rand::rng()); + use crate::crypto::dsa::eddsa_25519_sha512::KeyExchangeKey; + let secret_key = KeyExchangeKey::with_rng(&mut rand::rng()); let public_key = secret_key.public_key(); let encryption_key = SealingKey::X25519XChaCha20Poly1305(public_key); test_encryption_key_roundtrip(encryption_key)?; @@ -550,8 +550,8 @@ mod tests { // Test K256XChaCha20Poly1305 { - use crate::crypto::dsa::ecdsa_k256_keccak::SecretKey; - let secret_key = SecretKey::with_rng(&mut rand::rng()); + use crate::crypto::dsa::ecdsa_k256_keccak::KeyExchangeKey; + let secret_key = KeyExchangeKey::with_rng(&mut rand::rng()); let public_key = secret_key.public_key(); let encryption_key = SealingKey::K256XChaCha20Poly1305(public_key); test_encryption_key_roundtrip(encryption_key)?; @@ -559,8 +559,8 @@ mod tests { // Test X25519AeadPoseidon2 { - use crate::crypto::dsa::eddsa_25519_sha512::SecretKey; - let secret_key = SecretKey::with_rng(&mut rand::rng()); + use crate::crypto::dsa::eddsa_25519_sha512::KeyExchangeKey; + let secret_key = KeyExchangeKey::with_rng(&mut rand::rng()); let public_key = secret_key.public_key(); let encryption_key = SealingKey::X25519AeadPoseidon2(public_key); test_encryption_key_roundtrip(encryption_key)?; @@ -568,8 +568,8 @@ mod tests { // Test K256AeadPoseidon2 { - use crate::crypto::dsa::ecdsa_k256_keccak::SecretKey; - let secret_key = SecretKey::with_rng(&mut rand::rng()); + use crate::crypto::dsa::ecdsa_k256_keccak::KeyExchangeKey; + let secret_key = KeyExchangeKey::with_rng(&mut rand::rng()); let public_key = secret_key.public_key(); let encryption_key = SealingKey::K256AeadPoseidon2(public_key); test_encryption_key_roundtrip(encryption_key)?; diff --git a/crates/miden-protocol/src/asset/asset_amount.rs b/crates/miden-protocol/src/asset/asset_amount.rs new file mode 100644 index 0000000000..521f90d086 --- /dev/null +++ b/crates/miden-protocol/src/asset/asset_amount.rs @@ -0,0 +1,250 @@ +use alloc::string::ToString; +use core::fmt; +use core::ops::{Add, Sub}; + +use super::super::errors::AssetError; +use super::super::utils::serde::{ + ByteReader, + ByteWriter, + Deserializable, + DeserializationError, + Serializable, +}; +use crate::Felt; + +// ASSET AMOUNT +// ================================================================================================ + +/// A validated fungible asset amount. +/// +/// Wraps a `u64` that is guaranteed to be at most [`AssetAmount::MAX`]. This type is used in +/// [`FungibleAsset`](super::FungibleAsset) to ensure the amount is always valid. +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct AssetAmount(u64); + +impl AssetAmount { + /// The maximum value an asset amount can represent. + /// + /// Equal to 2^63 - 2^31. This was chosen so that the amount fits as both a positive and + /// negative value in a field element. + pub const MAX: Self = Self(2u64.pow(63) - 2u64.pow(31)); + + /// The zero amount. + pub const ZERO: Self = Self(0); + + /// Returns a new `AssetAmount` if `amount` does not exceed [`Self::MAX`]. + /// + /// # Errors + /// + /// Returns an error if `amount` is greater than [`Self::MAX`]. + pub fn new(amount: u64) -> Result { + if amount > Self::MAX.0 { + return Err(AssetError::FungibleAssetAmountTooBig(amount)); + } + Ok(Self(amount)) + } + + /// Returns the underlying `u64` value. + pub const fn as_u64(&self) -> u64 { + self.0 + } + + /// Returns the underlying value as an `i64`. + /// + /// SAFETY: this cast never truncates or wraps because [`Self::MAX`] (`2^63 - 2^31`) is + /// strictly less than [`i64::MAX`] (`2^63 - 1`), so every valid `AssetAmount` fits in a + /// non-negative `i64`. + pub const fn as_i64(&self) -> i64 { + self.0 as i64 + } +} + +impl Add for AssetAmount { + type Output = Result; + + fn add(self, other: Self) -> Self::Output { + let raw = self.0.checked_add(other.0).expect("even MAX + MAX should not overflow u64"); + Self::new(raw) + } +} + +impl Sub for AssetAmount { + type Output = Result; + + fn sub(self, other: Self) -> Self::Output { + let raw = + self.0 + .checked_sub(other.0) + .ok_or(AssetError::FungibleAssetAmountNotSufficient { + minuend: self.0, + subtrahend: other.0, + })?; + Ok(Self(raw)) + } +} + +// CONVERSIONS +// ================================================================================================ + +impl From for AssetAmount { + fn from(value: u8) -> Self { + Self(value as u64) + } +} + +impl From for AssetAmount { + fn from(value: u16) -> Self { + Self(value as u64) + } +} + +impl From for AssetAmount { + fn from(value: u32) -> Self { + Self(value as u64) + } +} + +impl TryFrom for AssetAmount { + type Error = AssetError; + + fn try_from(value: u64) -> Result { + Self::new(value) + } +} + +impl TryFrom for AssetAmount { + type Error = AssetError; + + fn try_from(value: Felt) -> Result { + Self::new(value.as_canonical_u64()) + } +} + +impl From for u64 { + fn from(amount: AssetAmount) -> Self { + amount.0 + } +} + +impl From for Felt { + fn from(amount: AssetAmount) -> Self { + Felt::try_from(amount.0).expect("asset amount should guarantee felt validity") + } +} + +// DISPLAY +// ================================================================================================ + +impl fmt::Display for AssetAmount { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +// SERIALIZATION +// ================================================================================================ + +impl Serializable for AssetAmount { + 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 AssetAmount { + fn read_from(source: &mut R) -> Result { + let amount: u64 = source.read()?; + Self::new(amount).map_err(|err| DeserializationError::InvalidValue(err.to_string())) + } +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_amounts() { + let val: u64 = AssetAmount::new(0).unwrap().into(); + assert_eq!(val, 0); + let val: u64 = AssetAmount::new(1000).unwrap().into(); + assert_eq!(val, 1000); + let val: u64 = AssetAmount::new(AssetAmount::MAX.0).unwrap().into(); + assert_eq!(val, AssetAmount::MAX.0); + } + + #[test] + fn exceeds_max() { + assert!(AssetAmount::new(AssetAmount::MAX.0 + 1).is_err()); + assert!(AssetAmount::new(u64::MAX).is_err()); + } + + #[test] + fn from_small_types() { + let a: AssetAmount = 42u8.into(); + let val: u64 = a.into(); + assert_eq!(val, 42); + + let b: AssetAmount = 1000u16.into(); + let val: u64 = b.into(); + assert_eq!(val, 1000); + + let c: AssetAmount = 100_000u32.into(); + let val: u64 = c.into(); + assert_eq!(val, 100_000); + } + + #[test] + fn try_from_u64() { + assert!(AssetAmount::try_from(0u64).is_ok()); + assert!(AssetAmount::try_from(AssetAmount::MAX.0).is_ok()); + assert!(AssetAmount::try_from(AssetAmount::MAX.0 + 1).is_err()); + } + + #[test] + fn display() { + assert_eq!(AssetAmount::new(12345).unwrap().to_string(), "12345"); + } + + #[test] + fn into_u64() { + let amount = AssetAmount::new(500).unwrap(); + let raw: u64 = amount.into(); + assert_eq!(raw, 500); + } + + #[test] + fn add_amounts() { + let a = AssetAmount::new(100).unwrap(); + let b = AssetAmount::new(200).unwrap(); + let val: u64 = (a + b).unwrap().into(); + assert_eq!(val, 300); + } + + #[test] + fn add_overflow() { + let max = AssetAmount::new(AssetAmount::MAX.0).unwrap(); + let one = AssetAmount::new(1).unwrap(); + assert!((max + one).is_err()); + } + + #[test] + fn sub_amounts() { + let a = AssetAmount::new(300).unwrap(); + let b = AssetAmount::new(100).unwrap(); + let val: u64 = (a - b).unwrap().into(); + assert_eq!(val, 200); + } + + #[test] + fn sub_underflow() { + let a = AssetAmount::new(50).unwrap(); + let b = AssetAmount::new(100).unwrap(); + assert!((a - b).is_err()); + } +} diff --git a/crates/miden-protocol/src/asset/asset_callbacks_flag.rs b/crates/miden-protocol/src/asset/asset_callbacks_flag.rs index c5dfa620e4..f41e1f2a10 100644 --- a/crates/miden-protocol/src/asset/asset_callbacks_flag.rs +++ b/crates/miden-protocol/src/asset/asset_callbacks_flag.rs @@ -45,7 +45,7 @@ impl TryFrom for AssetCallbackFlag { match value { Self::DISABLED => Ok(Self::Disabled), Self::ENABLED => Ok(Self::Enabled), - _ => Err(AssetError::InvalidAssetCallbackFlag(value)), + _ => Err(AssetError::UnknownAssetCallbackFlag(value)), } } } diff --git a/crates/miden-protocol/src/asset/asset_composition.rs b/crates/miden-protocol/src/asset/asset_composition.rs new file mode 100644 index 0000000000..0dadf70c66 --- /dev/null +++ b/crates/miden-protocol/src/asset/asset_composition.rs @@ -0,0 +1,107 @@ +use alloc::string::ToString; + +use crate::errors::AssetError; +use crate::utils::serde::{ + ByteReader, + ByteWriter, + Deserializable, + DeserializationError, + Serializable, +}; + +/// Indicates how an asset is composed (i.e. how its merge/split semantics are defined). +/// +/// The composition is encoded in the metadata byte of an +/// [`AssetVaultKey`](super::AssetVaultKey) and determines how the asset is handled by the +/// asset vault, account delta and faucet logic. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[repr(u8)] +pub enum AssetComposition { + /// Instances of the asset do not compose. In other words, they are non-fungible. + #[default] + None = Self::NONE, + + /// Instances of the asset compose according to the rules of the standard fungible asset. + Fungible = Self::FUNGIBLE, + + /// Instances of the asset have a custom, faucet-defined composition logic. + /// + /// Exists for future use; currently effectively disabled. + Custom = Self::CUSTOM, +} + +impl AssetComposition { + const NONE: u8 = 0; + const FUNGIBLE: u8 = 1; + const CUSTOM: u8 = 2; + + /// The serialized size of an [`AssetComposition`] in bytes. + pub const SERIALIZED_SIZE: usize = core::mem::size_of::(); + + /// Encodes the composition as a `u8`. + pub const fn as_u8(&self) -> u8 { + *self as u8 + } + + /// Returns true if the composition is [`AssetComposition::None`]. + pub const fn is_none(&self) -> bool { + matches!(self, Self::None) + } + + /// Returns true if the composition is [`AssetComposition::Fungible`]. + pub const fn is_fungible(&self) -> bool { + matches!(self, Self::Fungible) + } + + /// Returns true if the composition is [`AssetComposition::Custom`]. + pub const fn is_custom(&self) -> bool { + matches!(self, Self::Custom) + } +} + +impl TryFrom for AssetComposition { + type Error = AssetError; + + /// Decodes a composition from a `u8`. + /// + /// # Errors + /// + /// Returns an error if the value is not a valid composition encoding. + fn try_from(value: u8) -> Result { + match value { + Self::NONE => Ok(Self::None), + Self::FUNGIBLE => Ok(Self::Fungible), + Self::CUSTOM => Ok(Self::Custom), + _ => Err(AssetError::UnknownAssetComposition(value)), + } + } +} + +impl core::fmt::Display for AssetComposition { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let string = match self { + AssetComposition::None => "none", + AssetComposition::Fungible => "fungible", + AssetComposition::Custom => "custom", + }; + + f.write_str(string) + } +} + +impl Serializable for AssetComposition { + fn write_into(&self, target: &mut W) { + target.write_u8(self.as_u8()); + } + + fn get_size_hint(&self) -> usize { + AssetComposition::SERIALIZED_SIZE + } +} + +impl Deserializable for AssetComposition { + fn read_from(source: &mut R) -> Result { + Self::try_from(source.read_u8()?) + .map_err(|err| DeserializationError::InvalidValue(err.to_string())) + } +} diff --git a/crates/miden-protocol/src/asset/fungible.rs b/crates/miden-protocol/src/asset/fungible.rs index 58b5754663..c1926318b7 100644 --- a/crates/miden-protocol/src/asset/fungible.rs +++ b/crates/miden-protocol/src/asset/fungible.rs @@ -2,7 +2,7 @@ use alloc::string::ToString; use core::fmt; use super::vault::AssetVaultKey; -use super::{AccountType, Asset, AssetCallbackFlag, AssetError, Word}; +use super::{Asset, AssetAmount, AssetCallbackFlag, AssetComposition, AssetError, Word}; use crate::Felt; use crate::account::AccountId; use crate::asset::AssetId; @@ -26,7 +26,7 @@ use crate::utils::serde::{ #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub struct FungibleAsset { faucet_id: AccountId, - amount: u64, + amount: AssetAmount, callbacks: AssetCallbackFlag, } @@ -37,12 +37,14 @@ impl FungibleAsset { /// /// 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. - pub const MAX_AMOUNT: u64 = 2u64.pow(63) - 2u64.pow(31); + pub const MAX_AMOUNT: AssetAmount = AssetAmount::MAX; /// The serialized size of a [`FungibleAsset`] in bytes. /// - /// An account ID (15 bytes) plus an amount (u64) plus a callbacks flag (u8). - pub const SERIALIZED_SIZE: usize = AccountId::SERIALIZED_SIZE + /// A composition byte (u8) plus an account ID (15 bytes) plus an amount (u64) plus a + /// callbacks flag (u8). + pub const SERIALIZED_SIZE: usize = AssetComposition::SERIALIZED_SIZE + + AccountId::SERIALIZED_SIZE + core::mem::size_of::() + AssetCallbackFlag::SERIALIZED_SIZE; @@ -54,16 +56,10 @@ impl FungibleAsset { /// # Errors /// /// Returns an error if: - /// - The faucet ID is not a valid fungible faucet ID. /// - The provided amount is greater than [`FungibleAsset::MAX_AMOUNT`]. pub fn new(faucet_id: AccountId, amount: u64) -> Result { - if !matches!(faucet_id.account_type(), AccountType::FungibleFaucet) { - return Err(AssetError::FungibleFaucetIdTypeMismatch(faucet_id)); - } - - if amount > Self::MAX_AMOUNT { - return Err(AssetError::FungibleAssetAmountTooBig(amount)); - } + // TODO: Take AssetAmount as input, then make the function infallible. + let amount = AssetAmount::new(amount)?; Ok(Self { faucet_id, @@ -78,11 +74,19 @@ impl FungibleAsset { /// /// Returns an error if: /// - The provided key does not contain a valid faucet ID. + /// - The provided key's does not have [`AssetComposition::Fungible`] set. /// - The provided key's asset ID limbs are not zero. - /// - The faucet ID is not a fungible faucet ID. /// - The provided value's amount is greater than [`FungibleAsset::MAX_AMOUNT`] or its three /// most significant elements are not zero. pub fn from_key_value(key: AssetVaultKey, value: Word) -> Result { + if !key.composition().is_fungible() { + return Err(AssetError::AssetCompositionMismatch { + faucet_id: key.faucet_id(), + expected: AssetComposition::Fungible, + actual: key.composition(), + }); + } + if !key.asset_id().is_empty() { return Err(AssetError::FungibleAssetIdMustBeZero(key.asset_id())); } @@ -104,7 +108,6 @@ impl FungibleAsset { /// # Errors /// /// Returns an error if: - /// - The provided key does not contain a valid faucet ID. /// - [`Self::from_key_value`] fails. pub fn from_key_value_words(key: Word, value: Word) -> Result { let vault_key = AssetVaultKey::try_from(key)?; @@ -126,7 +129,7 @@ impl FungibleAsset { } /// Returns the amount of this asset. - pub fn amount(&self) -> u64 { + pub fn amount(&self) -> AssetAmount { self.amount } @@ -142,8 +145,13 @@ impl FungibleAsset { /// Returns the key which is used to store this asset in the account vault. pub fn vault_key(&self) -> AssetVaultKey { - AssetVaultKey::new(AssetId::default(), self.faucet_id, self.callbacks) - .expect("faucet ID should be of type fungible") + AssetVaultKey::new( + AssetId::default(), + self.faucet_id, + AssetComposition::Fungible, + self.callbacks, + ) + .expect("default asset id should be valid for fungible composition") } /// Returns the asset's key encoded to a [`Word`]. @@ -153,13 +161,7 @@ impl FungibleAsset { /// Returns the asset's value encoded to a [`Word`]. pub fn to_value_word(&self) -> Word { - Word::new([ - Felt::try_from(self.amount) - .expect("fungible asset should only allow amounts that fit into a felt"), - Felt::ZERO, - Felt::ZERO, - Felt::ZERO, - ]) + Word::new([Felt::from(self.amount), Felt::ZERO, Felt::ZERO, Felt::ZERO]) } // OPERATIONS @@ -180,13 +182,7 @@ impl FungibleAsset { }); } - let amount = self - .amount - .checked_add(other.amount) - .expect("even MAX_AMOUNT + MAX_AMOUNT should not overflow u64"); - if amount > Self::MAX_AMOUNT { - return Err(AssetError::FungibleAssetAmountTooBig(amount)); - } + let amount = (self.amount + other.amount)?; Ok(Self { faucet_id: self.faucet_id, @@ -210,12 +206,7 @@ impl FungibleAsset { }); } - let amount = self.amount.checked_sub(other.amount).ok_or( - AssetError::FungibleAssetAmountNotSufficient { - minuend: self.amount, - subtrahend: other.amount, - }, - )?; + let amount = (self.amount - other.amount)?; Ok(FungibleAsset { faucet_id: self.faucet_id, @@ -243,34 +234,40 @@ impl fmt::Display for FungibleAsset { impl Serializable for FungibleAsset { fn write_into(&self, target: &mut W) { - // All assets should serialize their faucet ID at the first position to allow them to be - // distinguishable during deserialization. + // Lead with the asset composition byte to distinguish asset types on the wire. + target.write(AssetComposition::Fungible); target.write(self.faucet_id); - target.write(self.amount); + target.write(self.amount.as_u64()); target.write(self.callbacks); } fn get_size_hint(&self) -> usize { - self.faucet_id.get_size_hint() - + self.amount.get_size_hint() + AssetComposition::SERIALIZED_SIZE + + self.faucet_id.get_size_hint() + + self.amount.as_u64().get_size_hint() + self.callbacks.get_size_hint() } } impl Deserializable for FungibleAsset { fn read_from(source: &mut R) -> Result { - let faucet_id: AccountId = source.read()?; - FungibleAsset::deserialize_with_faucet_id(faucet_id, source) + let composition: AssetComposition = source.read()?; + if !composition.is_fungible() { + return Err(DeserializationError::InvalidValue(format!( + "expected fungible asset composition but found {composition:?}" + ))); + } + FungibleAsset::deserialize_body(source) } } impl FungibleAsset { - /// Deserializes a [`FungibleAsset`] from an [`AccountId`] and the remaining data from the given - /// `source`. - pub(super) fn deserialize_with_faucet_id( - faucet_id: AccountId, + /// Reads the remaining body of a fungible asset, after the leading composition byte has + /// already been consumed. + pub(super) fn deserialize_body( source: &mut R, ) -> Result { + let faucet_id: AccountId = source.read()?; let amount: u64 = source.read()?; let callbacks = source.read()?; @@ -291,30 +288,49 @@ mod tests { use super::*; use crate::account::AccountId; + use crate::asset::NonFungibleAsset; + use crate::asset::tests::set_asset_metadata; use crate::testing::account_id::{ ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET, - ACCOUNT_ID_PRIVATE_NON_FUNGIBLE_FAUCET, ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1, ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2, ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_3, }; + #[test] + fn fungible_asset_from_key_value_words_fails_on_invalid_composition() -> anyhow::Result<()> { + let asset_key = + set_asset_metadata(FungibleAsset::mock(25).vault_key(), AssetComposition::None.as_u8()); + + let err = + FungibleAsset::from_key_value_words(asset_key, FungibleAsset::mock(5).to_value_word()) + .unwrap_err(); + assert_matches!(err, AssetError::AssetCompositionMismatch { + faucet_id: _, expected, actual: _ + } => { + assert_eq!(expected, AssetComposition::Fungible); + }); + + Ok(()) + } + #[test] fn fungible_asset_from_key_value_words_fails_on_invalid_asset_id() -> anyhow::Result<()> { let faucet_id: AccountId = ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into()?; - let invalid_key = Word::from([ - Felt::from(1u32), - Felt::from(2u32), - faucet_id.suffix(), - faucet_id.prefix().as_felt(), - ]); - - let err = FungibleAsset::from_key_value_words( - invalid_key, - FungibleAsset::mock(5).to_value_word(), - ) - .unwrap_err(); + let mut asset_key = AssetVaultKey::new( + AssetId::default(), + faucet_id, + AssetComposition::Fungible, + AssetCallbackFlag::Disabled, + )? + .to_word(); + asset_key[0] = Felt::from(1u32); + asset_key[1] = Felt::from(2u32); + + let err = + FungibleAsset::from_key_value_words(asset_key, FungibleAsset::mock(5).to_value_word()) + .unwrap_err(); assert_matches!(err, AssetError::FungibleAssetIdMustBeZero(_)); Ok(()) @@ -358,19 +374,11 @@ mod tests { ) } - let account_id = AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_3).unwrap(); - let asset = FungibleAsset::new(account_id, 50).unwrap(); - let mut asset_bytes = asset.to_bytes(); - assert_eq!(asset_bytes.len(), asset.get_size_hint()); - assert_eq!(asset.get_size_hint(), FungibleAsset::SERIALIZED_SIZE); - - let non_fungible_faucet_id = - AccountId::try_from(ACCOUNT_ID_PRIVATE_NON_FUNGIBLE_FAUCET).unwrap(); - - // Set invalid Faucet ID. - asset_bytes[0..15].copy_from_slice(&non_fungible_faucet_id.to_bytes()); - let err = FungibleAsset::read_from_bytes(&asset_bytes).unwrap_err(); - assert!(matches!(err, DeserializationError::InvalidValue(_))); + let non_fungible_asset = NonFungibleAsset::mock(&[4]); + let err = FungibleAsset::read_from_bytes(&non_fungible_asset.to_bytes()).unwrap_err(); + assert_matches!(err, DeserializationError::InvalidValue(msg) => { + assert!(msg.contains("expected fungible asset composition but found None")); + }); Ok(()) } diff --git a/crates/miden-protocol/src/asset/mod.rs b/crates/miden-protocol/src/asset/mod.rs index 4bdec21c38..33c93d5570 100644 --- a/crates/miden-protocol/src/asset/mod.rs +++ b/crates/miden-protocol/src/asset/mod.rs @@ -1,4 +1,3 @@ -use super::account::AccountType; use super::errors::{AssetError, TokenSymbolError}; use super::utils::serde::{ ByteReader, @@ -10,6 +9,9 @@ use super::utils::serde::{ use super::{Felt, Word}; use crate::account::AccountId; +mod asset_amount; +pub use asset_amount::AssetAmount; + mod fungible; pub use fungible::FungibleAsset; @@ -27,6 +29,9 @@ pub use asset_callbacks::AssetCallbacks; mod asset_callbacks_flag; pub use asset_callbacks_flag::AssetCallbackFlag; +mod asset_composition; +pub use asset_composition::AssetComposition; + mod vault; pub use vault::{AssetId, AssetVault, AssetVaultKey, AssetWitness, PartialVault}; @@ -39,10 +44,8 @@ pub use vault::{AssetId, AssetVault, AssetVaultKey, AssetWitness, PartialVault}; /// (4 elements). This makes it is easy to determine the type of an asset both inside and outside /// Miden VM. Specifically: /// -/// The vault key of an asset contains the [`AccountId`] of the faucet that issues the asset. It can -/// be used to distinguish assets based on the encoded [`AccountId::account_type`]. In the vault -/// keys of assets, the account type bits at index 4 and 5 determine whether the asset is fungible -/// or non-fungible. +/// The vault key of an asset contains the [`AssetComposition`] which describes how assets compose, +/// meaning whether they can be merged or split. /// /// This property guarantees that there can never be a collision between a fungible and a /// non-fungible asset. @@ -52,41 +55,47 @@ pub use vault::{AssetId, AssetVault, AssetVaultKey, AssetWitness, PartialVault}; /// # Fungible assets /// /// - A fungible asset's value layout is: `[amount, 0, 0, 0]`. -/// - A fungible asset's vault key layout is: `[0, 0, faucet_id_suffix, faucet_id_prefix]`. -/// -/// The most significant elements of a fungible asset's key are set to the prefix -/// (`faucet_id_prefix`) and suffix (`faucet_id_suffix`) of the ID of the faucet which issues the -/// asset. The asset ID limbs are set to zero, which means two instances of the same fungible asset -/// have the same asset key and will be merged together when stored in the same account's vault. +/// - A fungible asset's vault key layout is: `[0, 0, faucet_id_suffix_and_metadata, +/// faucet_id_prefix]`. /// -/// The least significant element of the value is set to the amount of the asset and the remaining -/// felts are zero. This amount cannot be greater than [`FungibleAsset::MAX_AMOUNT`] and thus fits -/// into a felt. +/// Where: +/// - `amount` is the [`AssetAmount`] that the asset holds and cannot be greater than +/// [`AssetAmount::MAX`] and thus fits into a felt. +/// - the remaining elements in the value word must be zero. +/// - `faucet_id_prefix` is the prefix of the faucet ID which issues the asset. +/// - `faucet_id_suffix_and_metadata` is the suffix of the faucet ID which issues the asset and the +/// asset metadata ([`AssetCallbackFlag`] and [`AssetComposition`]). See [`AssetVaultKey`] for +/// more details on the key's layout. +/// - the asset ID limbs must be zero, which means two instances of the same fungible asset have the +/// same asset key and will be merged together when stored in the same account's vault. /// /// It is impossible to find a collision between two fungible assets issued by different faucets as -/// the faucet ID is included in the description of the asset and this is guaranteed to be different -/// for each faucet as per the faucet creation logic. +/// the faucet ID is part of the asset's vault key and this is guaranteed to be different for each +/// faucet as per the faucet creation logic. /// /// # Non-fungible assets /// /// - A non-fungible asset's data layout is: `[hash0, hash1, hash2, hash3]`. -/// - A non-fungible asset's vault key layout is: `[hash0, hash1, faucet_id_suffix, +/// - A non-fungible asset's vault key layout is: `[hash0, hash1, faucet_id_suffix_and_metadata, /// faucet_id_prefix]`. /// -/// The 4 elements of non-fungible assets are computed by hashing the asset data. This compresses an -/// asset of an arbitrary length to 4 field elements: `[hash0, hash1, hash2, hash3]`. +/// Where: +/// - the 4 elements of non-fungible asset values are computed by hashing the asset data. This +/// compresses an asset of an arbitrary length to 4 field elements. +/// - `faucet_id_prefix` is the prefix of the faucet ID which issues the asset. +/// - `faucet_id_suffix_and_metadata` is the suffix of the faucet ID which issues the asset and the +/// asset metadata ([`AssetCallbackFlag`] and [`AssetComposition`]). See [`AssetVaultKey`] for +/// more details on the key's layout. +/// - The asset ID limbs are set to hashes from the asset's value (`hash0` and `hash1`). /// /// It is impossible to find a collision between two non-fungible assets issued by different faucets -/// as the faucet ID is included in the description of the non-fungible asset and this is guaranteed -/// to be different as per the faucet creation logic. +/// as the faucet ID is part of the asset's vault key and this is guaranteed to be different as per +/// the faucet creation logic. /// -/// The most significant elements of a non-fungible asset's key are set to the prefix -/// (`faucet_id_prefix`) and suffix (`faucet_id_suffix`) of the ID of the faucet which issues the -/// asset. The asset ID limbs are set to hashes from the asset's value. This means the collision -/// resistance of non-fungible assets issued by the same faucet is ~2^64, due to the 128-bit asset -/// ID that is unique per non-fungible asset. In other words, two non-fungible assets issued by the -/// same faucet are very unlikely to have the same asset key and thus should not collide when stored -/// in the same account's vault. +/// The collision resistance of non-fungible assets issued by the same faucet is ~2^64, due to the +/// 128-bit asset ID that is unique per non-fungible asset. In other words, two non-fungible assets +/// issued by the same faucet are very unlikely to have the same asset key and thus should not +/// collide when stored in the same account's vault. #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum Asset { Fungible(FungibleAsset), @@ -101,10 +110,16 @@ impl Asset { /// Returns an error if: /// - [`FungibleAsset::from_key_value`] or [`NonFungibleAsset::from_key_value`] fails. pub fn from_key_value(key: AssetVaultKey, value: Word) -> Result { - if matches!(key.faucet_id().account_type(), AccountType::FungibleFaucet) { - FungibleAsset::from_key_value(key, value).map(Asset::Fungible) - } else { - NonFungibleAsset::from_key_value(key, value).map(Asset::NonFungible) + match key.composition() { + AssetComposition::Fungible => { + FungibleAsset::from_key_value(key, value).map(Asset::Fungible) + }, + AssetComposition::None => { + NonFungibleAsset::from_key_value(key, value).map(Asset::NonFungible) + }, + AssetComposition::Custom => { + Err(AssetError::UnsupportedAssetComposition(AssetComposition::Custom)) + }, } } @@ -235,20 +250,15 @@ impl Serializable for Asset { impl Deserializable for Asset { fn read_from(source: &mut R) -> Result { - // Both asset types have their faucet ID as the first element, so we can use it to inspect - // what type of asset it is. - let faucet_id: AccountId = source.read()?; - - match faucet_id.account_type() { - AccountType::FungibleFaucet => { - FungibleAsset::deserialize_with_faucet_id(faucet_id, source).map(Asset::from) - }, - AccountType::NonFungibleFaucet => { - NonFungibleAsset::deserialize_with_faucet_id(faucet_id, source).map(Asset::from) - }, - other_type => Err(DeserializationError::InvalidValue(format!( - "failed to deserialize asset: expected an account ID prefix of type faucet, found {other_type}" - ))), + // All assets have their composition serialized as the first byte, so we can use it to + // inspect what type of asset it is. + let composition: AssetComposition = source.read()?; + match composition { + AssetComposition::Fungible => FungibleAsset::deserialize_body(source).map(Asset::from), + AssetComposition::None => NonFungibleAsset::deserialize_body(source).map(Asset::from), + AssetComposition::Custom => Err(DeserializationError::InvalidValue( + "Custom asset composition is not supported".into(), + )), } } } @@ -259,10 +269,15 @@ impl Deserializable for Asset { #[cfg(test)] mod tests { + use assert_matches::assert_matches; + use miden_core::Word; use miden_crypto::utils::{Deserializable, Serializable}; use super::{Asset, FungibleAsset, NonFungibleAsset, NonFungibleAssetDetails}; + use crate::Felt; use crate::account::AccountId; + use crate::asset::{AssetCallbackFlag, AssetComposition, AssetId, AssetVaultKey}; + use crate::errors::AssetError; use crate::testing::account_id::{ ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET, ACCOUNT_ID_PRIVATE_NON_FUNGIBLE_FAUCET, @@ -274,6 +289,20 @@ mod tests { ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET_1, }; + /// Returns the metadata byte encoded in a vault-key word. + pub(super) fn asset_metadata(key: AssetVaultKey) -> u8 { + (key.to_word()[2].as_canonical_u64() & AssetVaultKey::METADATA_BYTE_MASK as u64) as u8 + } + + /// Overwrites the metadata byte of the third element of a key word. + pub(super) fn set_asset_metadata(key: AssetVaultKey, byte: u8) -> Word { + let mut key = key.to_word(); + let raw = key[2].as_canonical_u64(); + let new_raw = (raw & !(AssetVaultKey::METADATA_BYTE_MASK as u64)) | byte as u64; + key[2] = Felt::try_from(new_raw).expect("clearing lower bits should produce a valid felt"); + key + } + /// Tests the serialization roundtrip for assets for assets <-> bytes and assets <-> words. #[test] fn test_asset_serde() -> anyhow::Result<()> { @@ -302,8 +331,8 @@ 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]).unwrap(); - let non_fungible_asset: Asset = NonFungibleAsset::new(&details).unwrap().into(); + let details = NonFungibleAssetDetails::new(account_id, vec![1, 2, 3]); + let non_fungible_asset: Asset = NonFungibleAsset::new(&details).into(); assert_eq!( non_fungible_asset, Asset::read_from_bytes(&non_fungible_asset.to_bytes()).unwrap() @@ -320,15 +349,31 @@ mod tests { Ok(()) } - /// This test asserts that account ID's is serialized in the first felt of assets. - /// Asset deserialization relies on that fact and if this changes the serialization must - /// be updated. + /// Asserts that every fully-serialized asset leads with an [`AssetComposition`] byte that + /// reflects the asset variant. Asset deserialization relies on this discriminator. #[test] - fn test_account_id_is_serialized_first() { - for asset in [FungibleAsset::mock(300), NonFungibleAsset::mock(&[0xaa, 0xbb])] { - let serialized_asset = asset.to_bytes(); - let prefix = AccountId::read_from_bytes(&serialized_asset).unwrap(); - assert_eq!(prefix, asset.faucet_id()); - } + fn test_composition_byte_is_serialized_first() { + let fungible_bytes = FungibleAsset::mock(300).to_bytes(); + assert_eq!(fungible_bytes[0], AssetComposition::Fungible.as_u8()); + + let non_fungible_bytes = NonFungibleAsset::mock(&[0xaa, 0xbb]).to_bytes(); + assert_eq!(non_fungible_bytes[0], AssetComposition::None.as_u8()); + } + + /// `Asset::from_key_value` must reject a [`AssetComposition::Custom`] key with + /// `UnsupportedAssetComposition`. + #[test] + fn test_from_key_value_rejects_custom_composition() -> anyhow::Result<()> { + let err = AssetVaultKey::new( + AssetId::default(), + ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into()?, + AssetComposition::Custom, + AssetCallbackFlag::Disabled, + ) + .unwrap_err(); + + assert_matches!(err, AssetError::UnsupportedAssetComposition(AssetComposition::Custom)); + + Ok(()) } } diff --git a/crates/miden-protocol/src/asset/nonfungible.rs b/crates/miden-protocol/src/asset/nonfungible.rs index c6fcec2297..9bcf672614 100644 --- a/crates/miden-protocol/src/asset/nonfungible.rs +++ b/crates/miden-protocol/src/asset/nonfungible.rs @@ -1,9 +1,8 @@ -use alloc::string::ToString; use alloc::vec::Vec; use core::fmt; use super::vault::AssetVaultKey; -use super::{AccountType, Asset, AssetCallbackFlag, AssetError, Word}; +use super::{Asset, AssetCallbackFlag, AssetComposition, AssetError, Word}; use crate::Hasher; use crate::account::AccountId; use crate::asset::vault::AssetId; @@ -40,18 +39,18 @@ impl NonFungibleAsset { /// The serialized size of a [`NonFungibleAsset`] in bytes. /// - /// An account ID (15 bytes) plus a word (32 bytes) plus a callbacks flag (1 byte). - pub const SERIALIZED_SIZE: usize = - AccountId::SERIALIZED_SIZE + Word::SERIALIZED_SIZE + AssetCallbackFlag::SERIALIZED_SIZE; + /// A composition byte (u8) plus an account ID (15 bytes) plus a word (32 bytes) plus a + /// callbacks flag (1 byte). + pub const SERIALIZED_SIZE: usize = AssetComposition::SERIALIZED_SIZE + + AccountId::SERIALIZED_SIZE + + Word::SERIALIZED_SIZE + + AssetCallbackFlag::SERIALIZED_SIZE; // CONSTRUCTORS // -------------------------------------------------------------------------------------------- /// Returns a non-fungible asset created from the specified asset details. - /// - /// # Errors - /// Returns an error if the provided faucet ID is not for a non-fungible asset faucet. - pub fn new(details: &NonFungibleAssetDetails) -> Result { + pub fn new(details: &NonFungibleAssetDetails) -> Self { let data_hash = Hasher::hash(details.asset_data()); Self::from_parts(details.faucet_id(), data_hash) } @@ -61,19 +60,12 @@ impl NonFungibleAsset { /// /// Hash of the asset's data is expected to be computed from the binary representation of the /// asset's data. - /// - /// # Errors - /// Returns an error if the provided faucet ID is not for a non-fungible asset faucet. - pub fn from_parts(faucet_id: AccountId, value: Word) -> Result { - if !matches!(faucet_id.account_type(), AccountType::NonFungibleFaucet) { - return Err(AssetError::NonFungibleFaucetIdTypeMismatch(faucet_id)); - } - - Ok(Self { + pub fn from_parts(faucet_id: AccountId, value: Word) -> Self { + Self { faucet_id, value, callbacks: AssetCallbackFlag::default(), - }) + } } /// Creates a non-fungible asset from the provided key and value. @@ -81,11 +73,19 @@ impl NonFungibleAsset { /// # Errors /// /// Returns an error if: - /// - The provided key does not contain a valid faucet ID. + /// - The provided key does not have [`AssetComposition::None`] set. /// - The provided key's asset ID limbs are not equal to the provided value's first and second /// element. /// - The faucet ID is not a non-fungible faucet ID. pub fn from_key_value(key: AssetVaultKey, value: Word) -> Result { + if !key.composition().is_none() { + return Err(AssetError::AssetCompositionMismatch { + faucet_id: key.faucet_id(), + expected: AssetComposition::None, + actual: key.composition(), + }); + } + if key.asset_id().suffix() != value[0] || key.asset_id().prefix() != value[1] { return Err(AssetError::NonFungibleAssetIdMustMatchValue { asset_id: key.asset_id(), @@ -93,7 +93,7 @@ impl NonFungibleAsset { }); } - let mut asset = Self::from_parts(key.faucet_id(), value)?; + let mut asset = Self::from_parts(key.faucet_id(), value); asset.callbacks = key.callback_flag(); Ok(asset) @@ -106,7 +106,6 @@ impl NonFungibleAsset { /// # Errors /// /// Returns an error if: - /// - The provided key does not contain a valid faucet ID. /// - [`Self::from_key_value`] fails. pub fn from_key_value_words(key: Word, value: Word) -> Result { let vault_key = AssetVaultKey::try_from(key)?; @@ -130,8 +129,8 @@ impl NonFungibleAsset { let asset_id_prefix = self.value[1]; let asset_id = AssetId::new(asset_id_suffix, asset_id_prefix); - AssetVaultKey::new(asset_id, self.faucet_id, self.callbacks) - .expect("constructors should ensure account ID is of type non-fungible faucet") + AssetVaultKey::new(asset_id, self.faucet_id, AssetComposition::None, self.callbacks) + .expect("non-fungible composition is always valid") } /// Returns the ID of the faucet which issued this asset. @@ -173,40 +172,44 @@ impl From for Asset { impl Serializable for NonFungibleAsset { fn write_into(&self, target: &mut W) { - // All assets should serialize their faucet ID at the first position to allow them to be - // easily distinguishable during deserialization. + // Lead with the asset composition byte to distinguish asset types on the wire. + target.write(AssetComposition::None); target.write(self.faucet_id()); target.write(self.value); target.write(self.callbacks); } fn get_size_hint(&self) -> usize { - self.faucet_id.get_size_hint() + self.value.get_size_hint() + self.callbacks.get_size_hint() + AssetComposition::SERIALIZED_SIZE + + self.faucet_id.get_size_hint() + + self.value.get_size_hint() + + self.callbacks.get_size_hint() } } impl Deserializable for NonFungibleAsset { fn read_from(source: &mut R) -> Result { - let faucet_id: AccountId = source.read()?; - - Self::deserialize_with_faucet_id(faucet_id, source) - .map_err(|err| DeserializationError::InvalidValue(err.to_string())) + let composition: AssetComposition = source.read()?; + if !composition.is_none() { + return Err(DeserializationError::InvalidValue(format!( + "expected non-fungible asset composition but found {composition:?}" + ))); + } + NonFungibleAsset::deserialize_body(source) } } impl NonFungibleAsset { - /// Deserializes a [`NonFungibleAsset`] from an [`AccountId`] and the remaining data from the - /// given `source`. - pub(super) fn deserialize_with_faucet_id( - faucet_id: AccountId, + /// Reads the remaining body of a non-fungible asset, after the leading composition byte has + /// already been consumed. + pub(super) fn deserialize_body( source: &mut R, ) -> Result { + let faucet_id: AccountId = source.read()?; let value: Word = source.read()?; let callbacks: AssetCallbackFlag = source.read()?; - NonFungibleAsset::from_parts(faucet_id, value) - .map(|asset| asset.with_callbacks(callbacks)) - .map_err(|err| DeserializationError::InvalidValue(err.to_string())) + Ok(NonFungibleAsset::from_parts(faucet_id, value).with_callbacks(callbacks)) } } @@ -224,15 +227,8 @@ pub struct NonFungibleAssetDetails { impl NonFungibleAssetDetails { /// Returns asset details instantiated from the specified faucet ID and asset data. - /// - /// # Errors - /// Returns an error if the provided faucet ID is not for a non-fungible asset faucet. - pub fn new(faucet_id: AccountId, asset_data: Vec) -> Result { - if !matches!(faucet_id.account_type(), AccountType::NonFungibleFaucet) { - return Err(AssetError::NonFungibleFaucetIdTypeMismatch(faucet_id)); - } - - Ok(Self { faucet_id, asset_data }) + pub fn new(faucet_id: AccountId, asset_data: Vec) -> Self { + Self { faucet_id, asset_data } } /// Returns ID of the faucet which issued this asset. @@ -256,18 +252,39 @@ mod tests { use super::*; use crate::Felt; use crate::account::AccountId; + use crate::asset::FungibleAsset; use crate::testing::account_id::{ - ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET, ACCOUNT_ID_PRIVATE_NON_FUNGIBLE_FAUCET, ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET, ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET_1, }; + #[test] + fn non_fungible_asset_from_key_value_words_fails_on_invalid_composition() -> anyhow::Result<()> + { + // Use a fungible asset's key-value words where the the composition is set to `Fungible`. + let asset = FungibleAsset::mock(20); + + let err = + NonFungibleAsset::from_key_value_words(asset.to_key_word(), asset.to_value_word()) + .unwrap_err(); + assert_matches!(err, AssetError::AssetCompositionMismatch { + faucet_id: _, expected, actual, + } => { + assert_eq!(actual, AssetComposition::Fungible); + assert_eq!(expected, AssetComposition::None); + }); + + Ok(()) + } + #[test] fn fungible_asset_from_key_value_fails_on_invalid_asset_id() -> anyhow::Result<()> { - let invalid_key = AssetVaultKey::new_native( + let invalid_key = AssetVaultKey::new( AssetId::new(Felt::from(1u32), Felt::from(2u32)), ACCOUNT_ID_PRIVATE_NON_FUNGIBLE_FAUCET.try_into()?, + AssetComposition::None, + AssetCallbackFlag::Disabled, )?; let err = NonFungibleAsset::from_key_value(invalid_key, Word::from([4, 5, 6, 7u32])).unwrap_err(); @@ -285,8 +302,8 @@ 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]).unwrap(); - let non_fungible_asset = NonFungibleAsset::new(&details).unwrap(); + let details = NonFungibleAssetDetails::new(account_id, vec![1, 2, 3]); + let non_fungible_asset = NonFungibleAsset::new(&details); assert_eq!( non_fungible_asset, NonFungibleAsset::read_from_bytes(&non_fungible_asset.to_bytes()).unwrap() @@ -302,18 +319,11 @@ mod tests { ) } - let account = AccountId::try_from(ACCOUNT_ID_PRIVATE_NON_FUNGIBLE_FAUCET).unwrap(); - let details = NonFungibleAssetDetails::new(account, vec![4, 5, 6, 7]).unwrap(); - let asset = NonFungibleAsset::new(&details).unwrap(); - let mut asset_bytes = asset.to_bytes(); - - let fungible_faucet_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET).unwrap(); - - // Set invalid faucet ID. - asset_bytes[0..AccountId::SERIALIZED_SIZE].copy_from_slice(&fungible_faucet_id.to_bytes()); - - let err = NonFungibleAsset::read_from_bytes(&asset_bytes).unwrap_err(); - assert_matches!(err, DeserializationError::InvalidValue(msg) if msg.contains("must be of type NonFungibleFaucet")); + let fungible_asset = FungibleAsset::mock(42); + let err = NonFungibleAsset::read_from_bytes(&fungible_asset.to_bytes()).unwrap_err(); + assert_matches!(err, DeserializationError::InvalidValue(msg) => { + assert!(msg.contains("expected non-fungible asset composition but found Fungible")); + }); Ok(()) } diff --git a/crates/miden-protocol/src/asset/token_symbol.rs b/crates/miden-protocol/src/asset/token_symbol.rs index 7189d6805b..0471bf821c 100644 --- a/crates/miden-protocol/src/asset/token_symbol.rs +++ b/crates/miden-protocol/src/asset/token_symbol.rs @@ -1,24 +1,24 @@ use alloc::fmt; -use alloc::string::String; use super::{Felt, TokenSymbolError}; +use crate::utils::ShortCapitalString; /// Represents a token symbol (e.g. "POL", "ETH"). /// /// Token Symbols can consist of up to 12 capital Latin characters, e.g. "C", "ETH", "MIDEN". /// -/// The symbol is stored as a [`String`] and can be converted to a [`Felt`] encoding via -/// [`as_element()`](Self::as_element). +/// The label is stored internally as a validated short uppercase string and can be converted to a +/// [`Felt`] encoding via [`as_element()`](Self::as_element). #[derive(Clone, Debug, PartialEq, Eq)] -pub struct TokenSymbol(String); +pub struct TokenSymbol(ShortCapitalString); impl TokenSymbol { + /// Alphabet used for token symbols (`A`–`Z`). + pub const ALPHABET: &'static str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + /// Maximum allowed length of the token string. pub const MAX_SYMBOL_LENGTH: usize = 12; - /// The length of the set of characters that can be used in a token's name. - pub const ALPHABET_LENGTH: u64 = 26; - /// The minimum integer value of an encoded [`TokenSymbol`]. /// /// This value encodes the "A" token symbol. @@ -47,19 +47,7 @@ impl TokenSymbol { /// - The length of the provided string is less than 1 or greater than 12. /// - The provided token string contains characters that are not uppercase ASCII. pub fn new(symbol: &str) -> Result { - let len = symbol.len(); - - if len == 0 || len > Self::MAX_SYMBOL_LENGTH { - return Err(TokenSymbolError::InvalidLength(len)); - } - - for byte in symbol.as_bytes() { - if !byte.is_ascii_uppercase() { - return Err(TokenSymbolError::InvalidCharacter); - } - } - - Ok(Self(String::from(symbol))) + ShortCapitalString::from_ascii_uppercase(symbol).map(Self).map_err(Into::into) } /// Returns the [`Felt`] encoding of this token symbol. @@ -75,29 +63,13 @@ impl TokenSymbol { /// from the index of the currently processing character, e.g., `A = 65 - 65 = 0`, /// `B = 66 - 65 = 1`, `...` , `Z = 90 - 65 = 25`. pub fn as_element(&self) -> Felt { - let bytes = self.0.as_bytes(); - let len = bytes.len(); - - let mut encoded_value: u64 = 0; - let mut idx = 0; - - while idx < len { - let digit = (bytes[idx] - b'A') as u64; - encoded_value = encoded_value * Self::ALPHABET_LENGTH + digit; - idx += 1; - } - - // add token length to the encoded value to be able to decode the exact number of - // characters - encoded_value = encoded_value * Self::ALPHABET_LENGTH + len as u64; - - Felt::new(encoded_value) + self.0.as_element(Self::ALPHABET).expect("TokenSymbol alphabet is always valid") } } impl fmt::Display for TokenSymbol { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) + self.0.fmt(f) } } @@ -140,38 +112,14 @@ impl TryFrom for TokenSymbol { type Error = TokenSymbolError; fn try_from(felt: Felt) -> Result { - let encoded_value = felt.as_canonical_u64(); - if encoded_value < Self::MIN_ENCODED_VALUE { - return Err(TokenSymbolError::ValueTooSmall(encoded_value)); - } - if encoded_value > Self::MAX_ENCODED_VALUE { - return Err(TokenSymbolError::ValueTooLarge(encoded_value)); - } - - let mut decoded_string = String::new(); - let mut remaining_value = encoded_value; - - // get the token symbol length - let token_len = (remaining_value % Self::ALPHABET_LENGTH) as usize; - if token_len == 0 || token_len > Self::MAX_SYMBOL_LENGTH { - return Err(TokenSymbolError::InvalidLength(token_len)); - } - remaining_value /= Self::ALPHABET_LENGTH; - - for _ in 0..token_len { - let digit = (remaining_value % Self::ALPHABET_LENGTH) as u8; - let char = (digit + b'A') as char; - decoded_string.insert(0, char); - remaining_value /= Self::ALPHABET_LENGTH; - } - - // return an error if some data still remains after specified number of characters have - // been decoded. - if remaining_value != 0 { - return Err(TokenSymbolError::DataNotFullyDecoded); - } - - Ok(TokenSymbol(decoded_string)) + ShortCapitalString::try_from_encoded_felt( + felt, + Self::ALPHABET, + Self::MIN_ENCODED_VALUE, + Self::MAX_ENCODED_VALUE, + ) + .map(Self) + .map_err(Into::into) } } @@ -233,7 +181,8 @@ mod test { let invalid_encoded_symbol_u64 = Felt::from(encoded_symbol).as_canonical_u64() - 3; // check that decoding returns an error for a token with invalid length - let err = TokenSymbol::try_from(Felt::new(invalid_encoded_symbol_u64)).unwrap_err(); + let err = + TokenSymbol::try_from(Felt::new_unchecked(invalid_encoded_symbol_u64)).unwrap_err(); assert_matches!(err, TokenSymbolError::DataNotFullyDecoded); } diff --git a/crates/miden-protocol/src/asset/vault/asset_witness.rs b/crates/miden-protocol/src/asset/vault/asset_witness.rs index f289b3e92a..bfa54d185a 100644 --- a/crates/miden-protocol/src/asset/vault/asset_witness.rs +++ b/crates/miden-protocol/src/asset/vault/asset_witness.rs @@ -120,8 +120,8 @@ mod tests { use crate::Word; use crate::asset::{AssetVault, FungibleAsset, NonFungibleAsset}; use crate::testing::account_id::{ - ACCOUNT_ID_NETWORK_FUNGIBLE_FAUCET, 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. @@ -165,7 +165,7 @@ mod tests { #[test] fn asset_witness_authenticates_asset_vault_key() -> anyhow::Result<()> { let fungible_asset0 = - FungibleAsset::new(ACCOUNT_ID_NETWORK_FUNGIBLE_FAUCET.try_into()?, 200)?; + FungibleAsset::new(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_3.try_into()?, 200)?; let fungible_asset1 = FungibleAsset::new(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into()?, 100)?; diff --git a/crates/miden-protocol/src/asset/vault/mod.rs b/crates/miden-protocol/src/asset/vault/mod.rs index 9ba8c652e8..8c530a6b24 100644 --- a/crates/miden-protocol/src/asset/vault/mod.rs +++ b/crates/miden-protocol/src/asset/vault/mod.rs @@ -4,8 +4,9 @@ use alloc::vec::Vec; use miden_crypto::merkle::InnerNodeInfo; use super::{ - AccountType, Asset, + AssetAmount, + AssetComposition, ByteReader, ByteWriter, Deserializable, @@ -15,9 +16,9 @@ use super::{ Serializable, }; use crate::Word; -use crate::account::{AccountId, AccountVaultDelta, NonFungibleDeltaAction}; +use crate::account::{AccountVaultDelta, NonFungibleDeltaAction}; use crate::crypto::merkle::smt::{SMT_DEPTH, Smt}; -use crate::errors::AssetVaultError; +use crate::errors::{AssetError, AssetVaultError}; mod partial; pub use partial::PartialVault; @@ -102,18 +103,22 @@ impl AssetVault { } } - /// Returns the balance of the asset issued by the specified faucet. If the vault does not - /// contain such an asset, 0 is returned. + /// Returns the balance of the fungible asset identified by `vault_key`. + /// + /// If the vault does not contain the asset, zero is returned. /// /// # Errors - /// Returns an error if the specified ID is not an ID of a fungible asset faucet. - pub fn get_balance(&self, faucet_id: AccountId) -> Result { - if !matches!(faucet_id.account_type(), AccountType::FungibleFaucet) { - return Err(AssetVaultError::NotAFungibleFaucetId(faucet_id)); + /// + /// Returns an error if `vault_key`'s composition is not [`AssetComposition::Fungible`]. + pub fn get_balance(&self, vault_key: AssetVaultKey) -> Result { + if !vault_key.composition().is_fungible() { + return Err(AssetError::AssetCompositionMismatch { + faucet_id: vault_key.faucet_id(), + expected: AssetComposition::Fungible, + actual: vault_key.composition(), + }); } - let vault_key = - AssetVaultKey::new_fungible(faucet_id).expect("faucet ID should be of type fungible"); let asset_value = self.asset_tree.get_value(&vault_key.to_word()); let asset = FungibleAsset::from_key_value(vault_key, asset_value) .expect("asset vault should only store valid assets"); @@ -312,7 +317,7 @@ impl AssetVault { .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() == 0 { + if current_asset.amount() == AssetAmount::ZERO { return Err(AssetVaultError::FungibleAssetNotFound(other_asset)); } @@ -325,7 +330,7 @@ impl AssetVault { // leaf. #[cfg(debug_assertions)] { - if new_asset.amount() == 0 { + if new_asset.amount() == AssetAmount::ZERO { assert!(new_asset.to_value_word().is_empty()) } } diff --git a/crates/miden-protocol/src/asset/vault/vault_key.rs b/crates/miden-protocol/src/asset/vault/vault_key.rs index 2204114f6c..1ca7747fc3 100644 --- a/crates/miden-protocol/src/asset/vault/vault_key.rs +++ b/crates/miden-protocol/src/asset/vault/vault_key.rs @@ -5,9 +5,8 @@ use core::fmt; use miden_crypto::merkle::smt::LeafIndex; use crate::account::AccountId; -use crate::account::AccountType::{self}; use crate::asset::vault::AssetId; -use crate::asset::{Asset, AssetCallbackFlag, FungibleAsset, NonFungibleAsset}; +use crate::asset::{Asset, AssetCallbackFlag, AssetComposition, FungibleAsset, NonFungibleAsset}; use crate::crypto::merkle::smt::SMT_DEPTH; use crate::errors::AssetError; use crate::utils::serde::{ @@ -26,10 +25,14 @@ use crate::{Felt, Word}; /// [ /// asset_id_suffix (64 bits), /// asset_id_prefix (64 bits), -/// [faucet_id_suffix (56 bits) | 7 zero bits | callbacks_enabled (1 bit)], +/// [faucet_id_suffix (56 bits) | reserved (5 bits) | callback_flag (1 bit) | composition (2 bits)], /// faucet_id_prefix (64 bits) /// ] /// ``` +/// +/// 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. #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub struct AssetVaultKey { /// The asset ID of the vault key. @@ -38,6 +41,9 @@ pub struct AssetVaultKey { /// The ID of the faucet that issued the asset. faucet_id: AccountId, + /// The composition of the asset. + composition: AssetComposition, + /// Determines whether callbacks are enabled. callback_flag: AssetCallbackFlag, } @@ -48,50 +54,64 @@ impl AssetVaultKey { /// Serialized as its [`Word`] representation (4 field elements). pub const SERIALIZED_SIZE: usize = Word::SERIALIZED_SIZE; - // CONSTRUCTORS + // BIT LAYOUT CONSTANTS // -------------------------------------------------------------------------------------------- - /// Creates an [`AssetVaultKey`] for a native asset with callbacks disabled. - /// - /// # Errors - /// - /// Returns an error if: - /// - the provided ID is not of type - /// [`AccountType::FungibleFaucet`](crate::account::AccountType::FungibleFaucet) or - /// [`AccountType::NonFungibleFaucet`](crate::account::AccountType::NonFungibleFaucet) - /// - the asset ID limbs are not zero when `faucet_id` is of type - /// [`AccountType::FungibleFaucet`](crate::account::AccountType::FungibleFaucet). - pub fn new_native(asset_id: AssetId, faucet_id: AccountId) -> Result { - Self::new(asset_id, faucet_id, AssetCallbackFlag::Disabled) - } + /// The metadata byte occupies the lower 8 bits of the third element of the key word. + pub(in crate::asset) const METADATA_BYTE_MASK: u8 = 0xff; + + /// Bits 0-1 of the metadata byte encode the [`AssetComposition`]. The composition occupies + /// the lowest bits so its position remains stable as new metadata bits are added, since it + /// identifies the asset's type. + pub(in crate::asset) const COMPOSITION_MASK: u8 = 0b11; - /// Creates an [`AssetVaultKey`] from its parts with the given [`AssetCallbackFlag`]. + /// Bit 2 of the metadata byte encodes the [`AssetCallbackFlag`]. + pub(in crate::asset) const CALLBACK_FLAG_MASK: u8 = 0b1 << Self::CALLBACK_FLAG_SHIFT; + pub(in crate::asset) const CALLBACK_FLAG_SHIFT: u8 = 2; + + /// Bits 3-7 of the metadata byte are reserved and must be zero. + pub(in crate::asset) const METADATA_RESERVED_MASK: u8 = 0b1111_1000; + + // CONSTRUCTORS + // -------------------------------------------------------------------------------------------- + + /// Creates an [`AssetVaultKey`] from its parts with the given [`AssetComposition`] and + /// [`AssetCallbackFlag`]. /// /// # Errors /// /// Returns an error if: - /// - the provided ID is not of type - /// [`AccountType::FungibleFaucet`](crate::account::AccountType::FungibleFaucet) or - /// [`AccountType::NonFungibleFaucet`](crate::account::AccountType::NonFungibleFaucet) - /// - the asset ID limbs are not zero when `faucet_id` is of type - /// [`AccountType::FungibleFaucet`](crate::account::AccountType::FungibleFaucet). + /// - the asset ID limbs are not zero when `composition` is [`AssetComposition::Fungible`]. + /// - the composition is [`AssetComposition::Custom`], which is disallowed until its support is + /// enabled in the tx kernel. pub fn new( asset_id: AssetId, faucet_id: AccountId, + composition: AssetComposition, callback_flag: AssetCallbackFlag, ) -> Result { - if !faucet_id.is_faucet() { - return Err(AssetError::InvalidFaucetAccountId(Box::from(format!( - "expected account ID of type faucet, found account type {}", - faucet_id.account_type() - )))); + // For now, reject custom composition. + if composition.is_custom() { + return Err(AssetError::UnsupportedAssetComposition(AssetComposition::Custom)); } - if matches!(faucet_id.account_type(), AccountType::FungibleFaucet) && !asset_id.is_empty() { + if composition.is_fungible() && !asset_id.is_empty() { return Err(AssetError::FungibleAssetIdMustBeZero(asset_id)); } - Ok(Self { asset_id, faucet_id, callback_flag }) + Ok(Self { + asset_id, + faucet_id, + composition, + callback_flag, + }) + } + + /// Constructs a fungible asset's key from a faucet ID. + pub fn new_fungible(faucet_id: AccountId, callback_flag: AssetCallbackFlag) -> Self { + Self::new(AssetId::default(), faucet_id, AssetComposition::Fungible, callback_flag).expect( + "passing AssetComposition::Fungible together with AssetId::default should be valid", + ) } // PUBLIC ACCESSORS @@ -104,8 +124,13 @@ impl AssetVaultKey { let faucet_suffix = self.faucet_id.suffix().as_canonical_u64(); // The lower 8 bits of the faucet suffix are guaranteed to be zero and so it is used to // encode the asset metadata. - debug_assert!(faucet_suffix & 0xff == 0, "lower 8 bits of faucet suffix must be zero"); - let faucet_id_suffix_and_metadata = faucet_suffix | self.callback_flag.as_u8() as u64; + debug_assert!( + faucet_suffix & Self::METADATA_BYTE_MASK as u64 == 0, + "lower 8 bits of faucet suffix must be zero", + ); + let metadata_byte = + self.composition.as_u8() | (self.callback_flag.as_u8() << Self::CALLBACK_FLAG_SHIFT); + let faucet_id_suffix_and_metadata = faucet_suffix | metadata_byte as u64; let faucet_id_suffix_and_metadata = Felt::try_from(faucet_id_suffix_and_metadata) .expect("highest bit should still be zero resulting in a valid felt"); @@ -133,20 +158,9 @@ impl AssetVaultKey { self.callback_flag } - /// Constructs a fungible asset's key from a faucet ID. - /// - /// Returns `None` if the provided ID is not of type - /// [`AccountType::FungibleFaucet`](crate::account::AccountType::FungibleFaucet) - pub fn new_fungible(faucet_id: AccountId) -> Option { - if matches!(faucet_id.account_type(), AccountType::FungibleFaucet) { - let asset_id = AssetId::new(Felt::ZERO, Felt::ZERO); - Some( - Self::new_native(asset_id, faucet_id) - .expect("we should have account type fungible faucet"), - ) - } else { - None - } + /// Returns the [`AssetComposition`] of the vault key. + pub fn composition(&self) -> AssetComposition { + self.composition } /// Returns the leaf index of a vault key. @@ -185,9 +199,9 @@ impl TryFrom for AssetVaultKey { /// # Errors /// /// Returns an error if: - /// - the faucet ID in the key is invalid or not of a faucet type. - /// - the asset ID limbs are not zero when `faucet_id` is of type - /// [`AccountType::FungibleFaucet`](crate::account::AccountType::FungibleFaucet). + /// - the asset ID limbs are not zero when asset composition is [`AssetComposition::Fungible`]. + /// - the metadata byte has reserved bits set. + /// - the composition encoded in the metadata byte is invalid. fn try_from(key: Word) -> Result { let asset_id_suffix = key[0]; let asset_id_prefix = key[1]; @@ -195,15 +209,26 @@ impl TryFrom for AssetVaultKey { let faucet_id_prefix = key[3]; let raw = faucet_id_suffix_and_metadata.as_canonical_u64(); - let callback_flag = AssetCallbackFlag::try_from((raw & 0xff) as u8)?; - let faucet_id_suffix = Felt::try_from(raw & 0xffff_ffff_ffff_ff00) + let metadata_byte = (raw & Self::METADATA_BYTE_MASK as u64) as u8; + + // Make sure the reserved bits of the metadata are zero. + if metadata_byte & Self::METADATA_RESERVED_MASK != 0 { + return Err(AssetError::ReservedAssetMetadata(metadata_byte)); + } + + let callback_flag = AssetCallbackFlag::try_from( + (metadata_byte & Self::CALLBACK_FLAG_MASK) >> Self::CALLBACK_FLAG_SHIFT, + )?; + let composition = AssetComposition::try_from(metadata_byte & Self::COMPOSITION_MASK)?; + + let faucet_id_suffix = Felt::try_from(raw & !(Self::METADATA_BYTE_MASK as u64)) .expect("clearing lower bits should not produce an invalid felt"); let asset_id = AssetId::new(asset_id_suffix, asset_id_prefix); let faucet_id = AccountId::try_from_elements(faucet_id_suffix, faucet_id_prefix) .map_err(|err| AssetError::InvalidFaucetAccountId(Box::new(err)))?; - Self::new(asset_id, faucet_id, callback_flag) + Self::new(asset_id, faucet_id, composition, callback_flag) } } @@ -256,37 +281,74 @@ impl Deserializable for AssetVaultKey { #[cfg(test)] mod tests { + use assert_matches::assert_matches; + use rstest::rstest; + use super::*; - use crate::asset::AssetCallbackFlag; + use crate::asset::tests::{asset_metadata, set_asset_metadata}; + use crate::asset::{AssetCallbackFlag, AssetComposition}; use crate::testing::account_id::{ ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET, }; - #[test] - fn asset_vault_key_word_roundtrip() -> anyhow::Result<()> { + #[rstest] + fn asset_vault_key_word_roundtrip( + #[values(AssetCallbackFlag::Disabled, AssetCallbackFlag::Enabled)] + callback_flag: AssetCallbackFlag, + ) -> anyhow::Result<()> { let fungible_faucet = AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?; let nonfungible_faucet = AccountId::try_from(ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET)?; - for callback_flag in [AssetCallbackFlag::Disabled, AssetCallbackFlag::Enabled] { - // Fungible: asset_id must be zero. - let key = AssetVaultKey::new(AssetId::default(), fungible_faucet, callback_flag)?; + // Fungible: asset_id must be zero. + let key = AssetVaultKey::new( + AssetId::default(), + fungible_faucet, + AssetComposition::Fungible, + callback_flag, + )?; + assert_eq!(key.composition(), AssetComposition::Fungible); + let roundtripped = AssetVaultKey::try_from(key.to_word())?; + assert_eq!(key, roundtripped); + assert_eq!(key, AssetVaultKey::read_from_bytes(&key.to_bytes())?); + + // Non-fungible: asset_id can be non-zero. + let key = AssetVaultKey::new( + AssetId::new(Felt::from(42u32), Felt::from(99u32)), + nonfungible_faucet, + AssetComposition::None, + callback_flag, + )?; + assert_eq!(key.composition(), AssetComposition::None); + let roundtripped = AssetVaultKey::try_from(key.to_word())?; + assert_eq!(key, roundtripped); + assert_eq!(key, AssetVaultKey::read_from_bytes(&key.to_bytes())?); - let roundtripped = AssetVaultKey::try_from(key.to_word())?; - assert_eq!(key, roundtripped); - assert_eq!(key, AssetVaultKey::read_from_bytes(&key.to_bytes())?); + Ok(()) + } - // Non-fungible: asset_id can be non-zero. - let key = AssetVaultKey::new( - AssetId::new(Felt::from(42u32), Felt::from(99u32)), - nonfungible_faucet, - callback_flag, - )?; + #[test] + fn decoding_word_with_reserved_bits_set_fails() -> anyhow::Result<()> { + let key = FungibleAsset::mock(42).vault_key(); + let valid_metadata = asset_metadata(key); + // Set the reserved bits so the reserved-bits check fires. + let word = set_asset_metadata(key, valid_metadata | AssetVaultKey::METADATA_RESERVED_MASK); - let roundtripped = AssetVaultKey::try_from(key.to_word())?; - assert_eq!(key, roundtripped); - assert_eq!(key, AssetVaultKey::read_from_bytes(&key.to_bytes())?); - } + let err = AssetVaultKey::try_from(word).unwrap_err(); + assert_matches!(err, AssetError::ReservedAssetMetadata(_)); + + Ok(()) + } + + #[test] + fn decoding_word_with_invalid_composition_value_fails() -> anyhow::Result<()> { + let key = FungibleAsset::mock(42).vault_key(); + // Set all composition bits — value 3 is the invalid bit pattern within the 2-bit field. + let invalid_metadata = AssetVaultKey::COMPOSITION_MASK; + let word = set_asset_metadata(key, invalid_metadata); + + let err = AssetVaultKey::try_from(word).unwrap_err(); + assert_matches!(err, AssetError::UnknownAssetComposition(_)); Ok(()) } diff --git a/crates/miden-protocol/src/batch/batch_id.rs b/crates/miden-protocol/src/batch/batch_id.rs index b84769cbc8..39cfe42255 100644 --- a/crates/miden-protocol/src/batch/batch_id.rs +++ b/crates/miden-protocol/src/batch/batch_id.rs @@ -1,7 +1,7 @@ use alloc::string::String; use alloc::vec::Vec; -use miden_protocol_macros::WordWrapper; +use miden_crypto_derive::WordWrapper; use crate::account::AccountId; use crate::transaction::{ProvenTransaction, TransactionId}; diff --git a/crates/miden-protocol/src/batch/input_output_note_tracker.rs b/crates/miden-protocol/src/batch/input_output_note_tracker.rs index eca98d2325..99ae0cbfcd 100644 --- a/crates/miden-protocol/src/batch/input_output_note_tracker.rs +++ b/crates/miden-protocol/src/batch/input_output_note_tracker.rs @@ -1,7 +1,6 @@ use alloc::collections::BTreeMap; use alloc::vec::Vec; -use crate::Word; use crate::batch::{BatchId, ProvenBatch}; use crate::block::{BlockHeader, BlockNumber}; use crate::crypto::merkle::MerkleError; @@ -210,7 +209,7 @@ impl InputOutputNoteTracker { match input_note_commitment.header() { Some(input_note_header) => { let is_output_note = - Self::remove_output_note(input_note_header, &mut self.output_notes)?; + 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 @@ -234,35 +233,11 @@ impl InputOutputNoteTracker { /// /// Returns `true` if the given note existed in the output note set and was removed from it, /// `false` otherwise. - /// - /// # Errors - /// - /// Returns an error if: - /// - the given note has a corresponding note in the output note set with the same [`NoteId`] - /// but their hashes differ (i.e. their metadata is different). fn remove_output_note( input_note_header: &NoteHeader, output_notes: &mut BTreeMap, - ) -> Result> { - let id = input_note_header.id(); - if let Some((_, output_note)) = output_notes.remove(&id) { - // Check if the notes with the same ID have differing hashes. - // This could happen if the metadata of the notes is different, which we consider an - // error. - let input_commitment = input_note_header.to_commitment(); - let output_commitment = output_note.to_commitment(); - if output_commitment != input_commitment { - return Err(InputOutputNoteTrackerError::NoteCommitmentMismatch { - id, - input_commitment, - output_commitment, - }); - } - - return Ok(true); - } - - Ok(false) + ) -> bool { + output_notes.remove(&input_note_header.id()).is_some() } /// Verifies the note inclusion proof for the given input note commitment parts (nullifier and @@ -291,10 +266,10 @@ impl InputOutputNoteTracker { }; let note_index = proof.location().block_note_tree_index().into(); - let note_commitment = note_header.to_commitment(); + let note_id = note_header.id().as_word(); proof .note_path() - .verify(note_index, note_commitment, ¬e_block_header.note_root()) + .verify(note_index, note_id, ¬e_block_header.note_root()) .map_err(|source| { InputOutputNoteTrackerError::UnauthenticatedNoteAuthenticationFailed { note_id: note_header.id(), @@ -324,11 +299,6 @@ enum InputOutputNoteTrackerError { first_container_id: ContainerId, second_container_id: ContainerId, }, - NoteCommitmentMismatch { - id: NoteId, - input_commitment: Word, - output_commitment: Word, - }, UnauthenticatedInputNoteBlockNotInPartialBlockchain { block_number: BlockNumber, note_id: NoteId, @@ -361,15 +331,6 @@ impl From> for ProposedBlockError { first_batch_id: first_container_id, second_batch_id: second_container_id, }, - InputOutputNoteTrackerError::NoteCommitmentMismatch { - id, - input_commitment, - output_commitment, - } => ProposedBlockError::NoteCommitmentMismatch { - id, - input_commitment, - output_commitment, - }, InputOutputNoteTrackerError::UnauthenticatedInputNoteBlockNotInPartialBlockchain { block_number, note_id, @@ -411,15 +372,6 @@ impl From> for ProposedBatchError { first_transaction_id: first_container_id, second_transaction_id: second_container_id, }, - InputOutputNoteTrackerError::NoteCommitmentMismatch { - id, - input_commitment, - output_commitment, - } => ProposedBatchError::NoteCommitmentMismatch { - id, - input_commitment, - output_commitment, - }, InputOutputNoteTrackerError::UnauthenticatedInputNoteBlockNotInPartialBlockchain { block_number, note_id, diff --git a/crates/miden-protocol/src/batch/note_tree.rs b/crates/miden-protocol/src/batch/note_tree.rs index e0aa847f01..dd1d9a6f88 100644 --- a/crates/miden-protocol/src/batch/note_tree.rs +++ b/crates/miden-protocol/src/batch/note_tree.rs @@ -2,7 +2,7 @@ use alloc::vec::Vec; use crate::crypto::merkle::MerkleError; use crate::crypto::merkle::smt::{LeafIndex, SimpleSmt}; -use crate::note::{NoteId, NoteMetadata, compute_note_commitment}; +use crate::note::NoteHeader; use crate::utils::serde::{ ByteReader, ByteWriter, @@ -14,7 +14,8 @@ use crate::{BATCH_NOTE_TREE_DEPTH, EMPTY_WORD, Word}; /// Wrapper over [SimpleSmt] for batch note tree. /// -/// Value of each leaf is computed as: `hash(note_id || note_metadata_commitment)`. +/// Value of each leaf is the note ID, computed as +/// `hash(note_details_commitment || note_metadata_commitment)`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BatchNoteTree(SimpleSmt); @@ -26,11 +27,9 @@ impl BatchNoteTree { /// Returns an error if the number of entries exceeds the maximum tree capacity, that is /// 2^{depth}. pub fn with_contiguous_leaves<'a>( - entries: impl IntoIterator, + entries: impl IntoIterator, ) -> Result { - let leaves = entries - .into_iter() - .map(|(note_id, metadata)| compute_note_commitment(note_id, metadata)); + let leaves = entries.into_iter().map(|header| header.id().as_word()); SimpleSmt::with_contiguous_leaves(leaves).map(Self) } diff --git a/crates/miden-protocol/src/batch/proposed_batch.rs b/crates/miden-protocol/src/batch/proposed_batch.rs index b0a96439ba..38026961cd 100644 --- a/crates/miden-protocol/src/batch/proposed_batch.rs +++ b/crates/miden-protocol/src/batch/proposed_batch.rs @@ -165,30 +165,53 @@ impl ProposedBatch { // // Note that some block X is only added to the blockchain by block X + 1. This // is because block X cannot compute its own block commitment and thus cannot add - // itself to the chain. So, more generally, a previous block is added to the - // blockchain by its child block. + // itself to the chain. So, more generally, a block is added to the blockchain by its child + // block. // // The reference block of a batch may be the latest block in the chain and, as mentioned, - // block is not yet part of the blockchain, so its inclusion cannot be proven. - // Since the inclusion cannot be proven in all cases, the batch kernel instead - // commits to this reference block's commitment as a public input, which means the - // block kernel will prove this block's inclusion when including this batch and - // verifying its ZK proof. + // the block is not yet part of the blockchain, so its inclusion cannot be proven. + // Since the inclusion cannot be proven, the batch kernel instead commits to this reference + // block's commitment as a public input, which means the block kernel will prove + // this block's inclusion when including this batch and verifying its ZK proof. // // Finally, note that we don't verify anything cryptographically here. We have previously - // verified that the batch reference block's chain commitment matches the hashed peaks of - // the `PartialBlockchain`, and so we only have to check if the partial blockchain contains - // the block here. + // verified that the chain commitment of the batch's reference block matches the hashed + // peaks of the `PartialBlockchain`. This means the provided blockchain is consistent with + // the batch's reference block and that all blocks contained in the blockchain are + // consistent, too. So, as long as each transaction's reference block (number and + // commitment) is contained in the partial blockchain, we know the transaction's + // block header is consistent with the batch's reference block, too. // -------------------------------------------------------------------------------------------- for tx in transactions.iter() { - if reference_block_header.block_num() != tx.ref_block_num() - && !partial_blockchain.contains_block(tx.ref_block_num()) - { - return Err(ProposedBatchError::MissingTransactionBlockReference { - block_reference: tx.ref_block_commitment(), - transaction_id: tx.id(), - }); + // Differentiate between validation against the batch's reference block or a block from + // the chain (see above). + if reference_block_header.block_num() == tx.ref_block_num() { + if reference_block_header.commitment() != tx.ref_block_commitment() { + return Err(ProposedBatchError::TransactionReferenceBlockCommitmentMismatch { + transaction_id: tx.id(), + block_num: tx.ref_block_num(), + actual_block_commitment: tx.ref_block_commitment(), + expected_block_commitment: reference_block_header.commitment(), + }); + } + } else { + let block_header = + partial_blockchain.get_block(tx.ref_block_num()).ok_or_else(|| { + ProposedBatchError::MissingTransactionReferenceBlock { + transaction_id: tx.id(), + block_num: tx.ref_block_num(), + } + })?; + + if block_header.commitment() != tx.ref_block_commitment() { + return Err(ProposedBatchError::TransactionReferenceBlockCommitmentMismatch { + transaction_id: tx.id(), + block_num: tx.ref_block_num(), + actual_block_commitment: tx.ref_block_commitment(), + expected_block_commitment: block_header.commitment(), + }); + } } } @@ -439,7 +462,7 @@ mod tests { use super::*; use crate::Word; use crate::account::delta::AccountUpdateDetails; - use crate::account::{AccountIdVersion, AccountStorageMode, AccountType}; + use crate::account::{AccountIdVersion, AccountType}; use crate::asset::FungibleAsset; use crate::transaction::{InputNoteCommitment, OutputNote, ProvenTransaction, TxAccountUpdate}; @@ -449,7 +472,8 @@ mod tests { let mut mmr = Mmr::default(); for i in 0..3 { let block_header = BlockHeader::mock(i, None, None, &[], Word::empty()); - mmr.add(block_header.commitment()); + mmr.add(block_header.commitment()) + .expect("mmr leaf count exceeds forest leaf bound"); } let partial_mmr: PartialMmr = mmr.peaks().into(); let partial_blockchain = PartialBlockchain::new(partial_mmr, Vec::new()).unwrap(); @@ -465,12 +489,8 @@ mod tests { tx_kernel_commitment, ); - let account_id = AccountId::dummy( - [1; 15], - AccountIdVersion::Version0, - AccountType::FungibleFaucet, - AccountStorageMode::Private, - ); + let account_id = + AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Private); let initial_account_commitment = [2; 32].try_into().expect("failed to create initial account commitment"); let final_account_commitment = diff --git a/crates/miden-protocol/src/batch/proven_batch.rs b/crates/miden-protocol/src/batch/proven_batch.rs index eb8aae5495..32a0bf9d18 100644 --- a/crates/miden-protocol/src/batch/proven_batch.rs +++ b/crates/miden-protocol/src/batch/proven_batch.rs @@ -36,13 +36,16 @@ impl ProvenBatch { // CONSTRUCTORS // -------------------------------------------------------------------------------------------- - /// Creates a new [`ProvenBatch`] from the provided parts. + /// Creates a new [`ProvenBatch`] from the provided parts without checking any constraints + /// except the ones listed in the errors section below. + /// + /// This should essentially never be called by users. /// /// # Errors /// /// Returns an error if the batch expiration block number is not greater than the reference /// block number. - pub fn new( + pub fn new_unchecked( id: BatchId, reference_block_commitment: Word, reference_block_num: BlockNumber, @@ -177,7 +180,7 @@ impl Deserializable for ProvenBatch { let batch_expiration_block_num = BlockNumber::read_from(source)?; let transactions = OrderedTransactionHeaders::read_from(source)?; - Self::new( + Self::new_unchecked( id, reference_block_commitment, reference_block_num, 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 1974e866b5..4dca3054f2 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 @@ -73,15 +73,10 @@ mod tests { use miden_core::ZERO; use super::{AccountId, *}; - use crate::account::{AccountIdVersion, AccountStorageMode, AccountType}; + use crate::account::{AccountIdVersion, AccountType}; #[test] fn test_as_word_layout() { - let id = AccountId::dummy( - [1u8; 15], - AccountIdVersion::Version0, - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Private, - ); + let id = AccountId::dummy([1u8; 15], AccountIdVersion::Version1, AccountType::Private); let key = AccountIdKey::from(id); let word = key.as_word(); @@ -93,12 +88,7 @@ mod tests { #[test] fn test_roundtrip_word_conversion() { - let id = AccountId::dummy( - [1u8; 15], - AccountIdVersion::Version0, - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Private, - ); + let id = AccountId::dummy([1u8; 15], AccountIdVersion::Version1, AccountType::Private); let key = AccountIdKey::from(id); let recovered = @@ -109,12 +99,7 @@ mod tests { #[test] fn test_leaf_index_consistency() { - let id = AccountId::dummy( - [1u8; 15], - AccountIdVersion::Version0, - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Private, - ); + let id = AccountId::dummy([1u8; 15], AccountIdVersion::Version1, AccountType::Private); let key = AccountIdKey::from(id); let idx1 = key.to_leaf_index(); @@ -125,12 +110,7 @@ mod tests { #[test] fn test_from_conversion() { - let id = AccountId::dummy( - [1u8; 15], - AccountIdVersion::Version0, - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Private, - ); + let id = AccountId::dummy([1u8; 15], AccountIdVersion::Version1, AccountType::Private); let key: AccountIdKey = id.into(); assert_eq!(key.account_id(), id); @@ -139,12 +119,7 @@ mod tests { #[test] fn test_multiple_roundtrips() { for _ in 0..100 { - let id = AccountId::dummy( - [1u8; 15], - AccountIdVersion::Version0, - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Private, - ); + let id = AccountId::dummy([1u8; 15], AccountIdVersion::Version1, AccountType::Private); let key = AccountIdKey::from(id); let recovered = diff --git a/crates/miden-protocol/src/block/account_tree/mod.rs b/crates/miden-protocol/src/block/account_tree/mod.rs index e594684be1..e21220831a 100644 --- a/crates/miden-protocol/src/block/account_tree/mod.rs +++ b/crates/miden-protocol/src/block/account_tree/mod.rs @@ -205,6 +205,7 @@ where /// Returns an error if: /// - an insertion of an account ID would violate the uniqueness of account ID prefixes in the /// tree. + /// - the list of provided account updates contains duplicates. pub fn compute_mutations( &self, account_commitments: impl IntoIterator, @@ -412,22 +413,12 @@ pub(super) mod tests { use assert_matches::assert_matches; use super::*; - use crate::account::{AccountStorageMode, AccountType}; + use crate::account::AccountType; use crate::testing::account_id::{AccountIdBuilder, account_id}; pub(crate) fn setup_duplicate_prefix_ids() -> [(AccountId, Word); 2] { - let id0 = AccountId::try_from(account_id( - AccountType::FungibleFaucet, - AccountStorageMode::Public, - 0xaabb_ccdd, - )) - .unwrap(); - let id1 = AccountId::try_from(account_id( - AccountType::FungibleFaucet, - AccountStorageMode::Public, - 0xaabb_ccff, - )) - .unwrap(); + let id0 = AccountId::try_from(account_id(AccountType::Public, 0xaabb_ccdd)).unwrap(); + let id1 = AccountId::try_from(account_id(AccountType::Public, 0xaabb_ccff)).unwrap(); assert_eq!(id0.prefix(), id1.prefix(), "test requires that these ids have the same prefix"); let commitment0 = Word::from([0, 0, 0, 42u32]); diff --git a/crates/miden-protocol/src/block/block_body.rs b/crates/miden-protocol/src/block/block_body.rs index 4b10460edd..3d0a6ff2cd 100644 --- a/crates/miden-protocol/src/block/block_body.rs +++ b/crates/miden-protocol/src/block/block_body.rs @@ -112,9 +112,7 @@ impl BlockBody { /// Computes the [`BlockNoteTree`] containing all [`OutputNote`]s created in this block. pub fn compute_block_note_tree(&self) -> BlockNoteTree { - let entries = self - .output_notes() - .map(|(note_index, note)| (note_index, note.id(), note.metadata())); + let entries = self.output_notes().map(|(note_index, note)| (note_index, note.into())); // SAFETY: We only construct block bodies that: // - do not contain duplicates diff --git a/crates/miden-protocol/src/block/block_number.rs b/crates/miden-protocol/src/block/block_number.rs index aec6613a48..8a001c5104 100644 --- a/crates/miden-protocol/src/block/block_number.rs +++ b/crates/miden-protocol/src/block/block_number.rs @@ -72,6 +72,12 @@ impl BlockNumber { pub fn checked_sub(&self, rhs: u32) -> Option { self.0.checked_sub(rhs).map(Self) } + + /// Saturating integer subtraction. Computes `self - rhs`, saturating at + /// [`BlockNumber::GENESIS`] instead of underflowing. + pub fn saturating_sub(&self, rhs: u32) -> Self { + Self(self.0.saturating_sub(rhs)) + } } impl Add for BlockNumber { diff --git a/crates/miden-protocol/src/block/blockchain.rs b/crates/miden-protocol/src/block/blockchain.rs index 17c96bbad9..99bc0ddffb 100644 --- a/crates/miden-protocol/src/block/blockchain.rs +++ b/crates/miden-protocol/src/block/blockchain.rs @@ -89,7 +89,12 @@ impl Blockchain { /// /// Returns an error if the specified `block` exceeds the number of blocks in the chain. pub fn peaks_at(&self, checkpoint: BlockNumber) -> Result { - self.mmr.peaks_at(Forest::new(checkpoint.as_usize())) + let forest = + Forest::new(checkpoint.as_usize()).map_err(|_| MmrError::ForestSizeExceeded { + requested: checkpoint.as_usize(), + max: Forest::MAX_LEAVES, + })?; + self.mmr.peaks_at(forest) } /// Returns an [`MmrProof`] for the `block` with the given number. @@ -115,7 +120,12 @@ impl Blockchain { block: BlockNumber, checkpoint: BlockNumber, ) -> Result { - self.mmr.open_at(block.as_usize(), Forest::new(checkpoint.as_usize())) + let forest = + Forest::new(checkpoint.as_usize()).map_err(|_| MmrError::ForestSizeExceeded { + requested: checkpoint.as_usize(), + max: Forest::MAX_LEAVES, + })?; + self.mmr.open_at(block.as_usize(), forest) } /// Returns a reference to the underlying [`Mmr`]. @@ -164,8 +174,14 @@ impl Blockchain { /// Adds a block commitment to the MMR. /// /// The caller must ensure that this commitent is the one for the next block in the chain. + /// + /// # Panics + /// + /// Panics if the number of blocks in the chain exceeds [Forest::MAX_LEAVES]. pub fn push(&mut self, block_commitment: Word) { - self.mmr.add(block_commitment); + self.mmr + .add(block_commitment) + .expect("mmr leaf count exceeds forest leaf bound"); } } diff --git a/crates/miden-protocol/src/block/header.rs b/crates/miden-protocol/src/block/header.rs index fc13578258..81574c16bd 100644 --- a/crates/miden-protocol/src/block/header.rs +++ b/crates/miden-protocol/src/block/header.rs @@ -1,10 +1,8 @@ -use alloc::string::ToString; use alloc::vec::Vec; -use crate::account::{AccountId, AccountType}; +use crate::account::AccountId; use crate::block::BlockNumber; use crate::crypto::dsa::ecdsa_k256_keccak::PublicKey; -use crate::errors::FeeError; use crate::utils::serde::{ ByteReader, ByteWriter, @@ -33,7 +31,7 @@ use crate::{Felt, Hasher, Word, ZERO}; /// 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. -/// - `fee_parameters` are the parameters defining the base fees and the native asset, see +/// - `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 /// representation is sufficient to represent time up to year 2106. @@ -218,7 +216,7 @@ impl BlockHeader { /// The sub commitment is computed as a sequential hash of the following fields: /// `prev_block_commitment`, `chain_commitment`, `account_root`, `nullifier_root`, `note_root`, /// `tx_commitment`, `tx_kernel_commitment`, `validator_key_commitment`, `version`, `timestamp`, - /// `block_num`, `native_asset_id`, `verification_base_fee` (all fields except the `note_root`). + /// `block_num`, `fee_faucet_id`, `verification_base_fee` (all fields except the `note_root`). #[allow(clippy::too_many_arguments)] fn compute_sub_commitment( version: u32, @@ -245,8 +243,8 @@ impl BlockHeader { elements.extend([ ZERO, Felt::from(fee_parameters.verification_base_fee()), - fee_parameters.native_asset_id().suffix(), - fee_parameters.native_asset_id().prefix().as_felt(), + fee_parameters.fee_faucet_id().suffix(), + fee_parameters.fee_faucet_id().prefix().as_felt(), ]); elements.extend([ZERO, ZERO, ZERO, ZERO]); Hasher::hash_elements(&elements) @@ -329,11 +327,15 @@ impl Deserializable for BlockHeader { /// The fee-related parameters of a block. /// /// This defines how to compute the fees of a transaction and which asset fees can be paid in. +/// +/// The fee asset is assumed to be a fungible asset +/// ([`AssetComposition::Fungible`](crate::asset::AssetComposition::Fungible)). #[derive(Debug, Clone, PartialEq, Eq)] pub struct FeeParameters { - /// The [`AccountId`] of the fungible faucet whose assets are accepted for fee payments in the - /// transaction kernel, or in other words, the native asset of the blockchain. - native_asset_id: AccountId, + /// The [`AccountId`] of the faucet whose assets are accepted for fee payments in the + /// transaction kernel, or in other words, the fee faucet of the blockchain. + fee_faucet_id: AccountId, + /// The base fee (in base units) capturing the cost for the verification of a transaction. verification_base_fee: u32, } @@ -342,29 +344,18 @@ impl FeeParameters { // CONSTRUCTORS // -------------------------------------------------------------------------------------------- - /// Creates a new [`FeeParameters`] from the provided inputs. - /// - /// # Errors - /// - /// Returns an error if: - /// - the provided native asset ID is not a fungible faucet account ID. - pub fn new(native_asset_id: AccountId, verification_base_fee: u32) -> Result { - if !matches!(native_asset_id.account_type(), AccountType::FungibleFaucet) { - return Err(FeeError::NativeAssetIdNotFungible { - account_type: native_asset_id.account_type(), - }); - } - - Ok(Self { native_asset_id, verification_base_fee }) + /// Creates [`FeeParameters`] from the provided inputs. + pub fn new(fee_faucet_id: AccountId, verification_base_fee: u32) -> Self { + Self { fee_faucet_id, verification_base_fee } } // PUBLIC ACCESSORS // -------------------------------------------------------------------------------------------- /// Returns the [`AccountId`] of the faucet whose assets are accepted for fee payments in the - /// transaction kernel, or in other words, the native asset of the blockchain. - pub fn native_asset_id(&self) -> AccountId { - self.native_asset_id + /// transaction kernel, or in other words, the fee faucet of the blockchain. + pub fn fee_faucet_id(&self) -> AccountId { + self.fee_faucet_id } /// Returns the base fee capturing the cost for the verification of a transaction. @@ -378,18 +369,17 @@ impl FeeParameters { impl Serializable for FeeParameters { fn write_into(&self, target: &mut W) { - self.native_asset_id.write_into(target); + self.fee_faucet_id.write_into(target); self.verification_base_fee.write_into(target); } } impl Deserializable for FeeParameters { fn read_from(source: &mut R) -> Result { - let native_asset_id = source.read()?; + let fee_faucet_id = source.read()?; let verification_base_fee = source.read()?; - Self::new(native_asset_id, verification_base_fee) - .map_err(|err| DeserializationError::InvalidValue(err.to_string())) + Ok(Self::new(fee_faucet_id, verification_base_fee)) } } @@ -398,12 +388,10 @@ impl Deserializable for FeeParameters { #[cfg(test)] mod tests { - use assert_matches::assert_matches; use miden_core::Word; use miden_crypto::rand::test_utils::rand_value; use super::*; - use crate::testing::account_id::ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET; #[test] fn test_serde() { @@ -422,15 +410,4 @@ mod tests { assert_eq!(deserialized, header); } - - /// Tests that the fee parameters constructor fails when the provided account ID is not a - /// fungible faucet. - #[test] - fn fee_parameters_fail_when_native_asset_is_not_fungible() { - assert_matches!( - FeeParameters::new(ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET.try_into().unwrap(), 0) - .unwrap_err(), - FeeError::NativeAssetIdNotFungible { .. } - ); - } } diff --git a/crates/miden-protocol/src/block/note_tree.rs b/crates/miden-protocol/src/block/note_tree.rs index 81665b238a..127a8721a7 100644 --- a/crates/miden-protocol/src/block/note_tree.rs +++ b/crates/miden-protocol/src/block/note_tree.rs @@ -6,7 +6,7 @@ use miden_crypto::merkle::SparseMerklePath; use crate::batch::BatchNoteTree; use crate::crypto::merkle::MerkleError; use crate::crypto::merkle::smt::{LeafIndex, SimpleSmt}; -use crate::note::{NoteId, NoteMetadata, compute_note_commitment}; +use crate::note::NoteHeader; use crate::utils::serde::{ ByteReader, ByteWriter, @@ -24,9 +24,7 @@ use crate::{ /// Wrapper over [SimpleSmt] for notes tree. /// -/// Each note is stored as two adjacent leaves: odd leaf for id, even leaf for metadata hash. -/// ID's leaf index is calculated as [(batch_idx * MAX_NOTES_PER_BATCH + note_idx_in_batch) * 2]. -/// Metadata hash leaf is stored the next after id leaf: [id_index + 1]. +/// Each note leaf is the note ID: `hash(note_details_commitment || metadata_commitment)`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BlockNoteTree(SimpleSmt); @@ -34,21 +32,22 @@ impl BlockNoteTree { /// Returns a new [`BlockNoteTree`] instantiated with entries set as specified by the provided /// entries. /// - /// Entry format: (note_index, note_id, note_metadata). + /// Entry format: (note_index, note_header). /// - /// Value of each leaf is computed as: `hash(note_id || note_metadata_commitment)`. + /// Value of each leaf is computed as: + /// `hash(note_details_commitment || note_metadata_commitment)`. /// All leaves omitted from the entries list are set to [crate::EMPTY_WORD]. /// /// # Errors /// Returns an error if: /// - The number of entries exceeds the maximum notes tree capacity, that is 2^16. /// - The provided entries contain multiple values for the same key. - pub fn with_entries<'metadata>( - entries: impl IntoIterator, + pub fn with_entries<'a>( + entries: impl IntoIterator, ) -> Result { - let leaves = entries.into_iter().map(|(index, note_id, metadata)| { - (index.leaf_index_value() as u64, compute_note_commitment(note_id, metadata)) - }); + let leaves = entries + .into_iter() + .map(|(index, header)| (index.leaf_index_value() as u64, header.id().as_word())); SimpleSmt::with_leaves(leaves).map(Self) } diff --git a/crates/miden-protocol/src/block/nullifier_tree/mod.rs b/crates/miden-protocol/src/block/nullifier_tree/mod.rs index b85a4aebd7..48d058d086 100644 --- a/crates/miden-protocol/src/block/nullifier_tree/mod.rs +++ b/crates/miden-protocol/src/block/nullifier_tree/mod.rs @@ -124,6 +124,7 @@ where /// /// Returns an error if: /// - a nullifier in the provided iterator was already spent. + /// - the list of provided nullifiers contains duplicates. pub fn compute_mutations( &self, nullifiers: impl IntoIterator, @@ -380,10 +381,8 @@ mod tests { let mut tree = NullifierTree::with_entries([(nullifier1, block1)]).unwrap(); - // Check that passing nullifier2 twice with different values will use the last value. - let mutations = tree - .compute_mutations([(nullifier2, block1), (nullifier3, block3), (nullifier2, block2)]) - .unwrap(); + let mutations = + tree.compute_mutations([(nullifier2, block2), (nullifier3, block3)]).unwrap(); tree.apply_mutations(mutations).unwrap(); diff --git a/crates/miden-protocol/src/block/proposed_block.rs b/crates/miden-protocol/src/block/proposed_block.rs index 2147577ec1..6dd28fdbe6 100644 --- a/crates/miden-protocol/src/block/proposed_block.rs +++ b/crates/miden-protocol/src/block/proposed_block.rs @@ -422,8 +422,7 @@ impl ProposedBlock { BlockNoteIndex::new(batch_idx, *note_idx_in_batch).expect( "max batches in block and max notes in batches should be enforced", ), - note.id(), - note.metadata(), + note.into(), ) }) }); diff --git a/crates/miden-protocol/src/constants.rs b/crates/miden-protocol/src/constants.rs index c10d263a2d..0d8b423dfb 100644 --- a/crates/miden-protocol/src/constants.rs +++ b/crates/miden-protocol/src/constants.rs @@ -10,7 +10,7 @@ pub const ACCOUNT_UPDATE_MAX_SIZE: u32 = 2u32.pow(18); pub const NOTE_MAX_SIZE: u32 = 2u32.pow(18); /// The maximum number of assets that can be stored in a single note. -pub const MAX_ASSETS_PER_NOTE: usize = 255; +pub const MAX_ASSETS_PER_NOTE: usize = 64; /// The maximum number of storage items that can accompany a single note. /// @@ -32,9 +32,6 @@ pub const MAX_TX_EXECUTION_CYCLES: u32 = ExecutionOptions::MAX_CYCLES; /// The minimum number of VM cycles a transaction needs to execute. pub const MIN_TX_EXECUTION_CYCLES: u32 = 1 << 12; -/// Maximum number of the foreign accounts that can be loaded. -pub const MAX_NUM_FOREIGN_ACCOUNTS: u8 = 64; - // TRANSACTION BATCH // ================================================================================================ diff --git a/crates/miden-protocol/src/errors/mod.rs b/crates/miden-protocol/src/errors/mod.rs index d4d6169f7e..618c8649a9 100644 --- a/crates/miden-protocol/src/errors/mod.rs +++ b/crates/miden-protocol/src/errors/mod.rs @@ -5,15 +5,14 @@ use core::error::Error; use miden_assembly::Report; use miden_assembly::diagnostics::reporting::PrintDiagnostic; -use miden_core::Felt; use miden_core::mast::MastForestError; use miden_crypto::merkle::mmr::MmrError; use miden_crypto::merkle::smt::{SmtLeafError, SmtProofError}; use miden_crypto::utils::HexParseError; use thiserror::Error; -use super::account::AccountId; -use super::asset::{AssetVaultKey, FungibleAsset, NonFungibleAsset, TokenSymbol}; +use super::account::{AccountId, RoleSymbol}; +use super::asset::{AssetComposition, AssetVaultKey, FungibleAsset, NonFungibleAsset, TokenSymbol}; use super::crypto::merkle::MerkleError; use super::note::NoteId; use super::{MAX_BATCHES_PER_BLOCK, MAX_OUTPUT_NOTES_PER_BATCH, Word}; @@ -22,7 +21,6 @@ use crate::account::{ AccountCode, AccountIdPrefix, AccountStorage, - AccountType, StorageMapKey, StorageSlotId, StorageSlotName, @@ -33,9 +31,9 @@ use crate::batch::BatchId; use crate::block::BlockNumber; use crate::note::{ NoteAssets, - NoteAttachmentArray, - NoteAttachmentKind, + NoteAttachment, NoteAttachmentScheme, + NoteAttachments, NoteTag, NoteType, Nullifier, @@ -45,6 +43,7 @@ use crate::utils::serde::DeserializationError; use crate::vm::EventId; use crate::{ ACCOUNT_UPDATE_MAX_SIZE, + Felt, MAX_ACCOUNTS_PER_BATCH, MAX_INPUT_NOTES_PER_BATCH, MAX_INPUT_NOTES_PER_TX, @@ -108,8 +107,6 @@ pub enum ComponentMetadataError { #[derive(Debug, Error)] pub enum AccountError { - #[error("failed to deserialize account code")] - AccountCodeDeserializationError(#[source] DeserializationError), #[error("account code does not contain an auth component")] AccountCodeNoAuthComponent, #[error("account code contains multiple auth components")] @@ -170,13 +167,6 @@ pub enum AccountError { UnsortedStorageSlots, #[error("number of storage slots is {0} but max possible number is {max}", max = AccountStorage::MAX_NUM_STORAGE_SLOTS)] StorageTooManySlots(u64), - #[error( - "account component at index {component_index} is incompatible with account of type {account_type}" - )] - UnsupportedComponentForAccountType { - account_type: AccountType, - component_index: usize, - }, #[error( "failed to apply full state delta to existing account; full state deltas can be converted to accounts directly" )] @@ -225,9 +215,7 @@ pub enum AccountIdError { AccountIdInvalidPrefixFieldElement(#[source] DeserializationError), #[error("failed to convert bytes into account ID suffix field element")] AccountIdInvalidSuffixFieldElement(#[source] DeserializationError), - #[error("`{0}` is not a known account storage mode")] - UnknownAccountStorageMode(Box), - #[error(r#"`{0}` is not a known account type, expected one of "FungibleFaucet", "NonFungibleFaucet", "RegularAccountImmutableCode" or "RegularAccountUpdatableCode""#)] + #[error("`{0}` is not a known account type")] UnknownAccountType(Box), #[error("failed to parse hex string into account ID")] AccountIdHexParseError(#[source] HexParseError), @@ -261,6 +249,31 @@ pub enum StorageSlotNameError { TooLong, } +// ACCOUNT COMPONENT NAME ERROR +// ================================================================================================ + +#[derive(Debug, Error)] +pub enum AccountComponentNameError { + #[error( + "account component name must only contain characters a..z, A..Z, 0..9, double colon or underscore" + )] + InvalidCharacter, + #[error("account component names must be separated by double colons")] + UnexpectedColon, + #[error("account component name components must not start with an underscore")] + UnexpectedUnderscore, + #[error( + "account component names must contain at least {} components separated by double colons", + StorageSlotName::MIN_NUM_COMPONENTS + )] + TooShort, + #[error( + "account component names must contain at most {} characters", + StorageSlotName::MAX_LENGTH + )] + TooLong, +} + // ACCOUNT TREE ERROR // ================================================================================================ @@ -401,7 +414,9 @@ pub enum AccountDeltaError { increment: Felt, new: Felt, }, - #[error("account ID {0} in fungible asset delta is not of type fungible faucet")] + #[error( + "asset issued by faucet {0} in fungible asset delta does not have fungible composition" + )] NotAFungibleFaucetId(AccountId), #[error("cannot merge two full state deltas")] MergingFullStateDeltas, @@ -464,12 +479,6 @@ pub enum AssetError { }, #[error("faucet account ID in asset is invalid")] InvalidFaucetAccountId(#[source] Box), - #[error( - "faucet id {0} of type {id_type} must be of type {expected_ty} for fungible assets", - id_type = .0.account_type(), - expected_ty = AccountType::FungibleFaucet - )] - FungibleFaucetIdTypeMismatch(AccountId), #[error( "asset ID prefix and suffix in a non-fungible asset's vault key must match indices 0 and 1 in the value, but asset ID was {asset_id} and value was {value}" )] @@ -480,16 +489,24 @@ pub enum AssetError { "the three most significant elements in a fungible asset's value must be zero but provided value was {0}" )] FungibleAssetValueMostSignificantElementsMustBeZero(Word), - #[error( - "faucet id {0} of type {id_type} must be of type {expected_ty} for non fungible assets", - id_type = .0.account_type(), - expected_ty = AccountType::NonFungibleFaucet - )] - NonFungibleFaucetIdTypeMismatch(AccountId), #[error("smt proof in asset witness contains invalid key or value")] AssetWitnessInvalid(#[source] Box), - #[error("invalid native asset callbacks encoding: {0}")] - InvalidAssetCallbackFlag(u8), + #[error("unknown native asset callbacks encoding: {0}")] + UnknownAssetCallbackFlag(u8), + #[error("unknown asset composition encoding: {0}")] + UnknownAssetComposition(u8), + #[error("asset composition {0:?} is not supported at this operational site")] + UnsupportedAssetComposition(AssetComposition), + #[error( + "asset composition mismatch for faucet {faucet_id}: expected {expected:?}, found {actual:?}" + )] + AssetCompositionMismatch { + faucet_id: AccountId, + expected: AssetComposition, + actual: AssetComposition, + }, + #[error("asset metadata byte 0x{0:02x} has reserved bits set to non-zero values")] + ReservedAssetMetadata(u8), } // TOKEN SYMBOL ERROR @@ -512,6 +529,66 @@ pub enum TokenSymbolError { DataNotFullyDecoded, } +impl From for TokenSymbolError { + fn from(value: ShortCapitalStringError) -> Self { + match value { + ShortCapitalStringError::ValueTooLarge(v) => Self::ValueTooLarge(v), + ShortCapitalStringError::ValueTooSmall(v) => Self::ValueTooSmall(v), + ShortCapitalStringError::InvalidLength(v) => Self::InvalidLength(v), + ShortCapitalStringError::InvalidCharacter => Self::InvalidCharacter, + ShortCapitalStringError::DataNotFullyDecoded => Self::DataNotFullyDecoded, + } + } +} + +// ROLE ERROR +// ================================================================================================ + +#[derive(Debug, Error)] +pub enum RoleSymbolError { + #[error("role symbol value {0} cannot exceed {max}", max = RoleSymbol::MAX_ENCODED_VALUE)] + ValueTooLarge(u64), + #[error("role symbol value {0} cannot be less than {min}", min = RoleSymbol::MIN_ENCODED_VALUE)] + ValueTooSmall(u64), + #[error("role symbol should have length between 1 and 12 characters, but {0} was provided")] + InvalidLength(usize), + #[error("role symbol contains a character that is not uppercase ASCII or underscore")] + InvalidCharacter, + #[error("role symbol data left after decoding the specified number of characters")] + DataNotFullyDecoded, +} + +impl From for RoleSymbolError { + fn from(value: ShortCapitalStringError) -> Self { + match value { + ShortCapitalStringError::ValueTooLarge(v) => Self::ValueTooLarge(v), + ShortCapitalStringError::ValueTooSmall(v) => Self::ValueTooSmall(v), + ShortCapitalStringError::InvalidLength(v) => Self::InvalidLength(v), + ShortCapitalStringError::InvalidCharacter => Self::InvalidCharacter, + ShortCapitalStringError::DataNotFullyDecoded => Self::DataNotFullyDecoded, + } + } +} + +// SHORT CAPITAL STRING ERROR +// ================================================================================================ + +#[derive(Debug, Error)] +pub(crate) enum ShortCapitalStringError { + #[error("short capital string value {0} is too large")] + ValueTooLarge(u64), + #[error("short capital string value {0} is too small")] + ValueTooSmall(u64), + #[error( + "short capital string should have length between 1 and 12 characters, but {0} was provided" + )] + InvalidLength(usize), + #[error("short capital string contains an invalid character")] + InvalidCharacter, + #[error("short capital string data left after decoding the specified number of characters")] + DataNotFullyDecoded, +} + // ASSET VAULT ERROR // ================================================================================================ @@ -525,8 +602,6 @@ pub enum AssetVaultError { DuplicateNonFungibleAsset(NonFungibleAsset), #[error("fungible asset {0} does not exist in the vault")] FungibleAssetNotFound(FungibleAsset), - #[error("faucet id {0} is not a fungible faucet id")] - NotAFungibleFaucetId(AccountId), #[error("non fungible asset {0} does not exist in the vault")] NonFungibleAssetNotFound(NonFungibleAsset), #[error("subtracting fungible asset amounts would underflow")] @@ -602,29 +677,29 @@ pub enum NoteError { InvalidNoteStorageLength { expected: usize, actual: usize }, #[error("note tag requires a public note but the note is of type {0}")] PublicNoteRequired(NoteType), + #[error("note attachment content must have at least one word")] + NoteAttachmentContentEmpty, #[error( - "note attachment cannot commit to more than {} elements", - NoteAttachmentArray::MAX_NUM_ELEMENTS + "note attachment content contains {0} words, but the maximum is {max} words", + max = NoteAttachment::MAX_NUM_WORDS )] - NoteAttachmentArraySizeExceeded(usize), - #[error("unknown note attachment kind {0}")] - UnknownNoteAttachmentKind(u8), - #[error("note attachment of kind None must have attachment scheme None")] - AttachmentKindNoneMustHaveAttachmentSchemeNone, + NoteAttachmentContentTooManyWords(usize), #[error( - "note attachment kind mismatch: header has {header_kind:?} but attachment has {attachment_kind:?}" + "note attachments contain a total of {0} words, but the maximum allowed is {max} words", + max = NoteAttachments::MAX_NUM_WORDS )] - AttachmentKindMismatch { - header_kind: NoteAttachmentKind, - attachment_kind: NoteAttachmentKind, - }, + NoteAttachmentsTooManyWords(usize), #[error( - "note attachment scheme mismatch: header has {header_scheme:?} but attachment has {attachment_scheme:?}" - )] - AttachmentSchemeMismatch { - header_scheme: NoteAttachmentScheme, - attachment_scheme: NoteAttachmentScheme, - }, + "attachment size {0} exceeds maximum {max}", + max = NoteAttachment::MAX_NUM_WORDS + )] + NoteAttachmentHeaderSizeExceeded(u8), + #[error("{0} attachments were provided but maximum is {max}", max = NoteAttachments::MAX_COUNT)] + TooManyAttachments(usize), + #[error("attachment scheme {0} exceeds maximum value of {max}", max = NoteAttachmentScheme::MAX)] + NoteAttachmentSchemeExceeded(u32), + #[error("attachment scheme value 0 is reserved")] + NoteAttachmentSchemeZeroReserved, #[error("{error_msg}")] Other { error_msg: Box, @@ -704,6 +779,8 @@ impl PartialBlockchainError { pub enum TransactionScriptError { #[error("failed to assemble transaction script:\n{}", PrintDiagnostic::new(.0))] AssemblyError(Report), + #[error("failed to convert package to transaction script:\n{}", PrintDiagnostic::new(.0))] + PackageNotProgram(Report), } // TRANSACTION INPUT ERROR @@ -799,7 +876,7 @@ pub enum TransactionOutputError { /// Errors that can occur when creating a /// [`PublicOutputNote`](crate::transaction::PublicOutputNote) or -/// [`PrivateNoteHeader`](crate::transaction::PrivateNoteHeader). +/// [`PrivateOutputNote`](crate::transaction::PrivateOutputNote). #[derive(Debug, Error)] pub enum OutputNoteError { #[error("note with id {0} is private but expected a public note")] @@ -973,11 +1050,21 @@ pub enum ProposedBatchError { InconsistentChainRoot { expected: Word, actual: Word }, #[error( - "block {block_reference} referenced by transaction {transaction_id} is not in the partial blockchain" + "block {block_num} referenced by transaction {transaction_id} is not in the partial blockchain" )] - MissingTransactionBlockReference { - block_reference: Word, + MissingTransactionReferenceBlock { transaction_id: TransactionId, + block_num: BlockNumber, + }, + + #[error( + "transaction {transaction_id} references block {block_num} with commitment {actual_block_commitment}, but the block in the chain with the same number has commitment {expected_block_commitment}" + )] + TransactionReferenceBlockCommitmentMismatch { + transaction_id: TransactionId, + block_num: BlockNumber, + expected_block_commitment: Word, + actual_block_commitment: Word, }, } @@ -1166,15 +1253,6 @@ pub enum ProposedBlockError { NullifierWitnessRootMismatch(NullifierTreeError), } -// FEE ERROR -// ================================================================================================ - -#[derive(Debug, Error)] -pub enum FeeError { - #[error("native asset of the chain must be a fungible faucet but was of type {account_type}")] - NativeAssetIdNotFungible { account_type: AccountType }, -} - // NULLIFIER TREE ERROR // ================================================================================================ diff --git a/crates/miden-protocol/src/lib.rs b/crates/miden-protocol/src/lib.rs index 39144b75a2..93dedc2ae3 100644 --- a/crates/miden-protocol/src/lib.rs +++ b/crates/miden-protocol/src/lib.rs @@ -60,23 +60,7 @@ pub mod crypto { pub use miden_crypto::{SequentialCommit, dsa, hash, ies, merkle, rand, utils}; } -pub mod utils { - pub use miden_core::utils::*; - pub use miden_crypto::utils::{HexParseError, bytes_to_hex_string, hex_to_bytes}; - pub use miden_utils_sync as sync; - - pub mod serde { - pub use miden_crypto::utils::{ - BudgetedReader, - ByteReader, - ByteWriter, - Deserializable, - DeserializationError, - Serializable, - SliceReader, - }; - } -} +pub mod utils; pub mod vm { pub use miden_assembly_syntax::ast::{AttributeSet, QualifiedProcedureName}; @@ -93,6 +77,6 @@ pub mod vm { TargetType, }; pub use miden_processor::trace::RowIndex; - pub use miden_processor::{FutureMaybeSend, StackInputs, StackOutputs}; + pub use miden_processor::{FutureMaybeSend, MIN_STACK_DEPTH, StackInputs, StackOutputs}; pub use miden_verifier::ExecutionProof; } diff --git a/crates/miden-protocol/src/note/assets.rs b/crates/miden-protocol/src/note/assets.rs index d8f0ec17f9..9daa28fe68 100644 --- a/crates/miden-protocol/src/note/assets.rs +++ b/crates/miden-protocol/src/note/assets.rs @@ -18,7 +18,7 @@ use crate::{Felt, Hasher, MAX_ASSETS_PER_NOTE, WORD_SIZE, Word}; /// An asset container for a note. /// -/// A note can contain between 0 and 255 assets. No duplicates are allowed, but the order of assets +/// A note can contain between 0 and 64 assets. No duplicates are allowed, but the order of assets /// is unspecified. /// /// All the assets in a note can be reduced to a single commitment which is computed by @@ -44,7 +44,7 @@ impl NoteAssets { /// /// # Errors /// Returns an error if: - /// - The list contains more than 256 assets. + /// - The list contains more than 64 assets. /// - There are duplicate assets in the list. pub fn new(assets: Vec) -> Result { if assets.len() > Self::MAX_NUM_ASSETS { @@ -184,25 +184,44 @@ impl Deserializable for NoteAssets { #[cfg(test)] mod tests { + use alloc::vec; + use alloc::vec::Vec; + + use assert_matches::assert_matches; + use super::NoteAssets; use crate::account::AccountId; use crate::asset::{Asset, FungibleAsset, NonFungibleAsset, NonFungibleAssetDetails}; + use crate::errors::NoteError; use crate::testing::account_id::{ ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET, ACCOUNT_ID_PRIVATE_NON_FUNGIBLE_FAUCET, ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, }; + /// Helper to create `n` unique non-fungible assets. + fn make_non_fungible_assets(n: usize) -> Vec { + let faucet_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_NON_FUNGIBLE_FAUCET).unwrap(); + (0..n) + .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); + Asset::NonFungible(NonFungibleAsset::new(&details)) + }) + .collect() + } + #[test] fn iter_fungible_asset() { 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]).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 non_fungible_asset = Asset::NonFungible(NonFungibleAsset::new(&details).unwrap()); + let non_fungible_asset = Asset::NonFungible(NonFungibleAsset::new(&details)); // Create NoteAsset from assets let assets = NoteAssets::new([asset1, asset2, non_fungible_asset].to_vec()).unwrap(); @@ -212,4 +231,22 @@ mod tests { assert_eq!(fungible_assets.next().unwrap(), asset2.unwrap_fungible()); assert_eq!(fungible_assets.next(), None); } + + #[test] + fn note_assets_at_max_succeeds() { + let assets = make_non_fungible_assets(NoteAssets::MAX_NUM_ASSETS); + assert_eq!(assets.len(), NoteAssets::MAX_NUM_ASSETS); + + let note_assets = NoteAssets::new(assets).unwrap(); + assert_eq!(note_assets.num_assets(), NoteAssets::MAX_NUM_ASSETS); + } + + #[test] + fn note_assets_exceeding_max_fails() { + let assets = make_non_fungible_assets(NoteAssets::MAX_NUM_ASSETS + 1); + assert_eq!(assets.len(), NoteAssets::MAX_NUM_ASSETS + 1); + + let result = NoteAssets::new(assets); + assert_matches!(result, Err(NoteError::TooManyAssets(n)) if n == NoteAssets::MAX_NUM_ASSETS + 1); + } } diff --git a/crates/miden-protocol/src/note/attachment.rs b/crates/miden-protocol/src/note/attachment.rs deleted file mode 100644 index fa8e567341..0000000000 --- a/crates/miden-protocol/src/note/attachment.rs +++ /dev/null @@ -1,550 +0,0 @@ -use alloc::string::ToString; -use alloc::vec::Vec; - -use crate::crypto::SequentialCommit; -use crate::errors::NoteError; -use crate::utils::serde::{ - ByteReader, - ByteWriter, - Deserializable, - DeserializationError, - Serializable, -}; -use crate::{Felt, Hasher, Word}; - -// NOTE ATTACHMENT -// ================================================================================================ - -/// The optional attachment for a [`Note`](super::Note). -/// -/// An attachment is a _public_ extension to a note's [`NoteMetadata`](super::NoteMetadata). -/// -/// Example use cases: -/// - Communicate the [`NoteDetails`](super::NoteDetails) of a private note in encrypted form. -/// - In the context of network transactions, encode the ID of the network account that should -/// consume the note. -/// - Communicate details to the receiver of a _private_ note to allow deriving the -/// [`NoteDetails`](super::NoteDetails) of that note. For instance, the payback note of a partial -/// swap note can be private, but the receiver needs to know additional details to fully derive -/// the content of the payback note. They can neither fetch those details from the network, since -/// the note is private, nor is a side-channel available. The note attachment can encode those -/// details. -/// -/// These use cases require different amounts of data, e.g. an account ID takes up just two felts -/// while the details of an encrypted note require many felts. To accommodate these cases, both a -/// computationally efficient [`NoteAttachmentContent::Word`] as well as a more flexible -/// [`NoteAttachmentContent::Array`] variant are available. See the type's docs for more -/// details. -/// -/// Next to the content, a note attachment can optionally specify a [`NoteAttachmentScheme`]. This -/// allows a note attachment to describe itself. For example, a network account target attachment -/// can be identified by a standardized type. For cases when the attachment scheme is known from -/// content or typing is otherwise undesirable, [`NoteAttachmentScheme::none`] can be used. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct NoteAttachment { - attachment_scheme: NoteAttachmentScheme, - content: NoteAttachmentContent, -} - -impl NoteAttachment { - // CONSTRUCTORS - // -------------------------------------------------------------------------------------------- - - /// Creates a new [`NoteAttachment`] from a user-defined type and the provided content. - /// - /// # Errors - /// - /// Returns an error if: - /// - The attachment content is [`NoteAttachmentKind::None`] but the scheme is not - /// [`NoteAttachmentScheme::none`]. - pub fn new( - attachment_scheme: NoteAttachmentScheme, - content: NoteAttachmentContent, - ) -> Result { - if content.attachment_kind().is_none() && !attachment_scheme.is_none() { - return Err(NoteError::AttachmentKindNoneMustHaveAttachmentSchemeNone); - } - - Ok(Self { attachment_scheme, content }) - } - - /// Creates a new note attachment with content [`NoteAttachmentContent::Word`] from the provided - /// word. - pub fn new_word(attachment_scheme: NoteAttachmentScheme, word: Word) -> Self { - Self { - attachment_scheme, - content: NoteAttachmentContent::new_word(word), - } - } - - /// Creates a new note attachment with content [`NoteAttachmentContent::Array`] from the - /// provided set of elements. - /// - /// # Errors - /// - /// Returns an error if: - /// - The maximum number of elements exceeds [`NoteAttachmentArray::MAX_NUM_ELEMENTS`]. - pub fn new_array( - attachment_scheme: NoteAttachmentScheme, - elements: Vec, - ) -> Result { - NoteAttachmentContent::new_array(elements) - .map(|content| Self { attachment_scheme, content }) - } - - // ACCESSORS - // -------------------------------------------------------------------------------------------- - - /// Returns the attachment scheme. - pub fn attachment_scheme(&self) -> NoteAttachmentScheme { - self.attachment_scheme - } - - /// Returns the attachment kind. - pub fn attachment_kind(&self) -> NoteAttachmentKind { - self.content.attachment_kind() - } - - /// Returns a reference to the attachment content. - pub fn content(&self) -> &NoteAttachmentContent { - &self.content - } -} - -impl Serializable for NoteAttachment { - fn write_into(&self, target: &mut W) { - self.attachment_scheme().write_into(target); - self.content().write_into(target); - } - - fn get_size_hint(&self) -> usize { - self.attachment_scheme().get_size_hint() + self.content().get_size_hint() - } -} - -impl Deserializable for NoteAttachment { - fn read_from(source: &mut R) -> Result { - let attachment_scheme = NoteAttachmentScheme::read_from(source)?; - let content = NoteAttachmentContent::read_from(source)?; - - Self::new(attachment_scheme, content) - .map_err(|err| DeserializationError::InvalidValue(err.to_string())) - } -} - -/// The content of a [`NoteAttachment`]. -/// -/// If a note attachment is not required, [`NoteAttachmentContent::None`] should be used. -/// -/// When a single [`Word`] has sufficient space, [`NoteAttachmentContent::Word`] should be used, as -/// it does not require any hashing. The word itself is encoded into the -/// [`NoteMetadata`](super::NoteMetadata). -/// -/// If the space of a [`Word`] is insufficient, the more flexible -/// [`NoteAttachmentContent::Array`] variant can be used. It contains a set of field elements -/// where only their sequential hash is encoded into the [`NoteMetadata`](super::NoteMetadata). -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub enum NoteAttachmentContent { - /// Signals the absence of a note attachment. - #[default] - None, - - /// A note attachment consisting of a single [`Word`]. - Word(Word), - - /// A note attachment consisting of the commitment to a set of felts. - Array(NoteAttachmentArray), -} - -impl NoteAttachmentContent { - // CONSTRUCTORS - // -------------------------------------------------------------------------------------------- - - /// Creates a new [`NoteAttachmentContent::Word`] containing an empty word. - pub fn empty_word() -> Self { - Self::Word(Word::empty()) - } - - /// Creates a new [`NoteAttachmentContent::Word`] from the provided word. - pub fn new_word(word: Word) -> Self { - Self::Word(word) - } - - /// Creates a new [`NoteAttachmentContent::Array`] from the provided elements. - /// - /// # Errors - /// - /// Returns an error if: - /// - The maximum number of elements exceeds [`NoteAttachmentArray::MAX_NUM_ELEMENTS`]. - pub fn new_array(elements: Vec) -> Result { - NoteAttachmentArray::new(elements).map(Self::from) - } - - // ACCESSORS - // -------------------------------------------------------------------------------------------- - - /// Returns the [`NoteAttachmentKind`]. - pub fn attachment_kind(&self) -> NoteAttachmentKind { - match self { - NoteAttachmentContent::None => NoteAttachmentKind::None, - NoteAttachmentContent::Word(_) => NoteAttachmentKind::Word, - NoteAttachmentContent::Array(_) => NoteAttachmentKind::Array, - } - } - - /// Returns the [`NoteAttachmentContent`] encoded to a [`Word`]. - /// - /// See the type-level documentation for more details. - pub fn to_word(&self) -> Word { - match self { - NoteAttachmentContent::None => Word::empty(), - NoteAttachmentContent::Word(word) => *word, - NoteAttachmentContent::Array(attachment_commitment) => { - attachment_commitment.commitment() - }, - } - } -} - -impl Serializable for NoteAttachmentContent { - fn write_into(&self, target: &mut W) { - self.attachment_kind().write_into(target); - - match self { - NoteAttachmentContent::None => (), - NoteAttachmentContent::Word(word) => { - word.write_into(target); - }, - NoteAttachmentContent::Array(attachment_commitment) => { - attachment_commitment.num_elements().write_into(target); - target.write_many(&attachment_commitment.elements); - }, - } - } - - fn get_size_hint(&self) -> usize { - let kind_size = self.attachment_kind().get_size_hint(); - match self { - NoteAttachmentContent::None => kind_size, - NoteAttachmentContent::Word(word) => kind_size + word.get_size_hint(), - NoteAttachmentContent::Array(attachment_commitment) => { - kind_size - + attachment_commitment.num_elements().get_size_hint() - + attachment_commitment.elements.len() * crate::ZERO.get_size_hint() - }, - } - } -} - -impl Deserializable for NoteAttachmentContent { - fn read_from(source: &mut R) -> Result { - let attachment_kind = NoteAttachmentKind::read_from(source)?; - - match attachment_kind { - NoteAttachmentKind::None => Ok(NoteAttachmentContent::None), - NoteAttachmentKind::Word => { - let word = Word::read_from(source)?; - Ok(NoteAttachmentContent::Word(word)) - }, - NoteAttachmentKind::Array => { - let num_elements = u16::read_from(source)?; - let elements = - source.read_many_iter(num_elements as usize)?.collect::>()?; - Self::new_array(elements) - .map_err(|err| DeserializationError::InvalidValue(err.to_string())) - }, - } - } -} - -// NOTE ATTACHMENT COMMITMENT -// ================================================================================================ - -/// The type contained in [`NoteAttachmentContent::Array`] that commits to a set of field -/// elements. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct NoteAttachmentArray { - elements: Vec, - commitment: Word, -} - -impl NoteAttachmentArray { - // CONSTANTS - // -------------------------------------------------------------------------------------------- - - /// The maximum size of a note attachment that commits to a set of elements. - /// - /// Each element holds roughly 8 bytes of data and so this allows for a maximum of - /// 2048 * 8 = 2^14 = 16384 bytes. - pub const MAX_NUM_ELEMENTS: u16 = 2048; - - // CONSTRUCTORS - // -------------------------------------------------------------------------------------------- - - /// Creates a new [`NoteAttachmentArray`] from the provided elements. - /// - /// # Errors - /// - /// Returns an error if: - /// - The maximum number of elements exceeds [`NoteAttachmentArray::MAX_NUM_ELEMENTS`]. - pub fn new(elements: Vec) -> Result { - if elements.len() > Self::MAX_NUM_ELEMENTS as usize { - return Err(NoteError::NoteAttachmentArraySizeExceeded(elements.len())); - } - - let commitment = Hasher::hash_elements(&elements); - Ok(Self { elements, commitment }) - } - - // ACCESSORS - // -------------------------------------------------------------------------------------------- - - /// Returns a reference to the elements this note attachment commits to. - pub fn as_slice(&self) -> &[Felt] { - &self.elements - } - - /// Returns the number of elements this note attachment commits to. - pub fn num_elements(&self) -> u16 { - u16::try_from(self.elements.len()).expect("type should enforce that size fits in u16") - } - - /// Returns the commitment over the contained field elements. - pub fn commitment(&self) -> Word { - self.commitment - } -} - -impl SequentialCommit for NoteAttachmentArray { - type Commitment = Word; - - fn to_elements(&self) -> Vec { - self.elements.clone() - } - - fn to_commitment(&self) -> Self::Commitment { - self.commitment - } -} - -impl From for NoteAttachmentContent { - fn from(array: NoteAttachmentArray) -> Self { - NoteAttachmentContent::Array(array) - } -} - -// NOTE ATTACHMENT SCHEME -// ================================================================================================ - -/// The user-defined type of a [`NoteAttachment`]. -/// -/// A note attachment scheme is an arbitrary 32-bit unsigned integer. -/// -/// Value `0` is reserved to signal that the scheme is none or absent. Whenever the kind of -/// attachment is not standardized or interoperability is unimportant, this none value can be -/// used. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct NoteAttachmentScheme(u32); - -impl NoteAttachmentScheme { - // CONSTANTS - // -------------------------------------------------------------------------------------------- - - /// The reserved value to signal an absent note attachment scheme. - const NONE: u32 = 0; - - // CONSTRUCTORS - // -------------------------------------------------------------------------------------------- - - /// Creates a new [`NoteAttachmentScheme`] from a `u32`. - pub const fn new(attachment_scheme: u32) -> Self { - Self(attachment_scheme) - } - - /// Returns the [`NoteAttachmentScheme`] that signals the absence of an attachment scheme. - pub const fn none() -> Self { - Self(Self::NONE) - } - - /// Returns `true` if the attachment scheme is the reserved value that signals an absent scheme, - /// `false` otherwise. - pub const fn is_none(&self) -> bool { - self.0 == Self::NONE - } - - // ACCESSORS - // -------------------------------------------------------------------------------------------- - - /// Returns the note attachment scheme as a u32. - pub const fn as_u32(&self) -> u32 { - self.0 - } -} - -impl Default for NoteAttachmentScheme { - /// Returns [`NoteAttachmentScheme::none`]. - fn default() -> Self { - Self::none() - } -} - -impl core::fmt::Display for NoteAttachmentScheme { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.write_fmt(format_args!("{}", self.0)) - } -} - -impl Serializable for NoteAttachmentScheme { - fn write_into(&self, target: &mut W) { - self.as_u32().write_into(target); - } - - fn get_size_hint(&self) -> usize { - core::mem::size_of::() - } -} - -impl Deserializable for NoteAttachmentScheme { - fn read_from(source: &mut R) -> Result { - let attachment_scheme = u32::read_from(source)?; - Ok(Self::new(attachment_scheme)) - } -} - -// NOTE ATTACHMENT KIND -// ================================================================================================ - -/// The type of [`NoteAttachmentContent`]. -/// -/// See its docs for more details on each type. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -#[repr(u8)] -pub enum NoteAttachmentKind { - /// Signals the absence of a note attachment. - #[default] - None = Self::NONE, - - /// A note attachment consisting of a single [`Word`]. - Word = Self::WORD, - - /// A note attachment consisting of the commitment to a set of felts. - Array = Self::ARRAY, -} - -impl NoteAttachmentKind { - // CONSTANTS - // -------------------------------------------------------------------------------------------- - - const NONE: u8 = 0; - const WORD: u8 = 1; - const ARRAY: u8 = 2; - - // ACCESSORS - // -------------------------------------------------------------------------------------------- - - /// Returns the attachment kind as a u8. - pub const fn as_u8(&self) -> u8 { - *self as u8 - } - - /// Returns `true` if the attachment kind is `None`, `false` otherwise. - pub const fn is_none(&self) -> bool { - matches!(self, Self::None) - } - - /// Returns `true` if the attachment kind is `Word`, `false` otherwise. - pub const fn is_word(&self) -> bool { - matches!(self, Self::Word) - } - - /// Returns `true` if the attachment kind is `Array`, `false` otherwise. - pub const fn is_array(&self) -> bool { - matches!(self, Self::Array) - } -} - -impl TryFrom for NoteAttachmentKind { - type Error = NoteError; - - fn try_from(value: u8) -> Result { - match value { - Self::NONE => Ok(Self::None), - Self::WORD => Ok(Self::Word), - Self::ARRAY => Ok(Self::Array), - _ => Err(NoteError::UnknownNoteAttachmentKind(value)), - } - } -} - -impl core::fmt::Display for NoteAttachmentKind { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - let output = match self { - NoteAttachmentKind::None => "None", - NoteAttachmentKind::Word => "Word", - NoteAttachmentKind::Array => "Array", - }; - - f.write_str(output) - } -} - -impl Serializable for NoteAttachmentKind { - fn write_into(&self, target: &mut W) { - self.as_u8().write_into(target); - } - - fn get_size_hint(&self) -> usize { - core::mem::size_of::() - } -} - -impl Deserializable for NoteAttachmentKind { - fn read_from(source: &mut R) -> Result { - let attachment_kind = u8::read_from(source)?; - Self::try_from(attachment_kind) - .map_err(|err| DeserializationError::InvalidValue(err.to_string())) - } -} - -// TESTS -// ================================================================================================ - -#[cfg(test)] -mod tests { - use assert_matches::assert_matches; - - use super::*; - - #[rstest::rstest] - #[case::attachment_none(NoteAttachment::default())] - #[case::attachment_word(NoteAttachment::new_word(NoteAttachmentScheme::new(1), Word::from([3, 4, 5, 6u32])))] - #[case::attachment_array(NoteAttachment::new_array( - NoteAttachmentScheme::new(u32::MAX), - vec![Felt::new(5), Felt::new(6), Felt::new(7)], - )?)] - #[test] - fn note_attachment_serde(#[case] attachment: NoteAttachment) -> anyhow::Result<()> { - assert_eq!(attachment, NoteAttachment::read_from_bytes(&attachment.to_bytes())?); - Ok(()) - } - - #[test] - fn note_attachment_commitment_fails_on_too_many_elements() -> anyhow::Result<()> { - let too_many_elements = (NoteAttachmentArray::MAX_NUM_ELEMENTS as usize) + 1; - let elements = vec![Felt::from(1u32); too_many_elements]; - let err = NoteAttachmentArray::new(elements).unwrap_err(); - - assert_matches!(err, NoteError::NoteAttachmentArraySizeExceeded(len) => { - len == too_many_elements - }); - - Ok(()) - } - - #[test] - fn note_attachment_kind_fails_on_unknown_variant() -> anyhow::Result<()> { - let err = NoteAttachmentKind::try_from(3u8).unwrap_err(); - assert_matches!(err, NoteError::UnknownNoteAttachmentKind(3u8)); - Ok(()) - } -} diff --git a/crates/miden-protocol/src/note/attachment/mod.rs b/crates/miden-protocol/src/note/attachment/mod.rs new file mode 100644 index 0000000000..fc066718f3 --- /dev/null +++ b/crates/miden-protocol/src/note/attachment/mod.rs @@ -0,0 +1,629 @@ +#[cfg(test)] +mod tests; + +use alloc::string::ToString; +use alloc::vec::Vec; + +use crate::crypto::SequentialCommit; +use crate::errors::NoteError; +use crate::utils::serde::{ + ByteReader, + ByteWriter, + Deserializable, + DeserializationError, + Serializable, +}; +use crate::{Felt, Hasher, Word}; + +// NOTE ATTACHMENT +// ================================================================================================ + +/// The optional attachment for a [`Note`](super::Note). +/// +/// An attachment is a _public_ extension to a note. +/// +/// Example use cases: +/// - Communicate the [`NoteDetails`](super::NoteDetails) of a private note in encrypted form. +/// - In the context of network transactions, encode the ID of the network account that should +/// consume the note. +/// - Communicate details to the receiver of a _private_ note to allow deriving the +/// [`NoteDetails`](super::NoteDetails) of that note. For instance, the payback note of a partial +/// swap note can be private, but the receiver needs to know additional details to fully derive +/// the content of the payback note. They can neither fetch those details from the network, since +/// the note is private, nor is a side-channel available. The note attachment can encode those +/// details. +/// +/// Next to the content, a note attachment can optionally specify a [`NoteAttachmentScheme`]. This +/// allows a note attachment to describe itself. For example, a network account target attachment +/// can be identified by a standardized type. For cases when the attachment scheme is known from +/// content or typing is otherwise undesirable, [`NoteAttachmentScheme::none`] can be used. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NoteAttachment { + attachment_scheme: NoteAttachmentScheme, + content: NoteAttachmentContent, +} + +impl NoteAttachment { + // CONSTANTS + // -------------------------------------------------------------------------------------------- + + /// The maximum number of words in an attachment. + /// + /// Each element holds roughly 8 bytes of data and so this allows for a maximum of + /// 256 * 32 = 2^13 = 8192 bytes. + pub const MAX_NUM_WORDS: u16 = 256; + + // CONSTRUCTORS + // -------------------------------------------------------------------------------------------- + + /// Creates a new [`NoteAttachment`] from a user-defined scheme and the provided content. + pub fn new(attachment_scheme: NoteAttachmentScheme, content: NoteAttachmentContent) -> Self { + Self { attachment_scheme, content } + } + + /// Creates a new note attachment from a single word. + pub fn with_word(attachment_scheme: NoteAttachmentScheme, word: Word) -> Self { + Self { + attachment_scheme, + content: NoteAttachmentContent::new(vec![word]).expect("single word is always valid"), + } + } + + /// Creates a new note attachment from the provided words. + /// + /// # Errors + /// + /// Returns an error if: + /// - `words` is empty. + /// - The number of words exceeds [`NoteAttachment::MAX_NUM_WORDS`]. + pub fn with_words( + attachment_scheme: NoteAttachmentScheme, + words: Vec, + ) -> Result { + NoteAttachmentContent::new(words).map(|content| Self { attachment_scheme, content }) + } + + // ACCESSORS + // -------------------------------------------------------------------------------------------- + + /// Returns the attachment scheme. + pub fn attachment_scheme(&self) -> NoteAttachmentScheme { + self.attachment_scheme + } + + /// Returns a reference to the attachment content. + pub fn content(&self) -> &NoteAttachmentContent { + &self.content + } + + /// Computes the commitment of the attachment. + pub fn to_commitment(&self) -> Word { + self.content().to_commitment() + } + + /// Returns the raw elements of this attachment content. + pub fn as_elements(&self) -> &[Felt] { + self.content.as_elements() + } + + /// Returns the raw elements of this attachment content. + pub fn to_elements(&self) -> Vec { + self.content().to_elements() + } + + /// Returns the size of this attachment in words (1 to [`Self::MAX_NUM_WORDS`]). + pub fn num_words(&self) -> u16 { + self.content.num_words() + } +} + +impl Serializable for NoteAttachment { + fn write_into(&self, target: &mut W) { + self.attachment_scheme().write_into(target); + self.content().write_into(target); + } + + fn get_size_hint(&self) -> usize { + self.attachment_scheme().get_size_hint() + self.content().get_size_hint() + } +} + +impl Deserializable for NoteAttachment { + fn read_from(source: &mut R) -> Result { + let attachment_scheme = NoteAttachmentScheme::read_from(source)?; + let content = NoteAttachmentContent::read_from(source)?; + + Ok(Self::new(attachment_scheme, content)) + } +} + +// NOTE ATTACHMENT CONTENT +// ================================================================================================ + +/// The content of a [`NoteAttachment`]. +/// +/// Contains between 1 and [`NoteAttachment::MAX_NUM_WORDS`] words of data. The commitment is +/// the sequential hash over the flattened field elements and is cached at construction time. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NoteAttachmentContent { + words: Vec, + commitment: Word, +} + +impl NoteAttachmentContent { + // CONSTRUCTORS + // -------------------------------------------------------------------------------------------- + + /// Creates a new [`NoteAttachmentContent`] from the provided words. + /// + /// # Errors + /// + /// Returns an error if: + /// - `words` is empty. + /// - The number of words exceeds [`NoteAttachment::MAX_NUM_WORDS`]. + pub fn new(words: Vec) -> Result { + if words.is_empty() { + return Err(NoteError::NoteAttachmentContentEmpty); + } + + if words.len() > NoteAttachment::MAX_NUM_WORDS as usize { + return Err(NoteError::NoteAttachmentContentTooManyWords(words.len())); + } + + let elements = Word::words_as_elements(&words).to_vec(); + let commitment = Hasher::hash_elements(&elements); + + Ok(Self { words, commitment }) + } + + // ACCESSORS + // -------------------------------------------------------------------------------------------- + + /// Returns a reference to the words in this attachment content. + pub fn as_words(&self) -> &[Word] { + &self.words + } + + /// Returns the size of this attachment content in words. + pub fn num_words(&self) -> u16 { + u16::try_from(self.words.len()).expect("num words should fit in u16") + } + + /// Returns the raw elements of this attachment content. + pub fn as_elements(&self) -> &[Felt] { + Word::words_as_elements(&self.words) + } + + /// Returns the raw elements of this attachment content. + pub fn to_elements(&self) -> Vec { + ::to_elements(self) + } + + /// Returns the sequential commitment over the content's elements. + pub fn to_commitment(&self) -> Word { + ::to_commitment(self) + } +} + +impl Serializable for NoteAttachmentContent { + fn write_into(&self, target: &mut W) { + // Subtract 1 from num words so we can serialize it as a u8. + let num_words_minus_1 = + u8::try_from(self.num_words().checked_sub(1).expect("num_words should be at least 1")) + .expect("num_words - 1 should fit in u8"); + num_words_minus_1.write_into(target); + target.write_many(self.as_words()); + } + + fn get_size_hint(&self) -> usize { + core::mem::size_of::() + usize::from(self.num_words()) * Word::empty().get_size_hint() + } +} + +impl Deserializable for NoteAttachmentContent { + fn read_from(source: &mut R) -> Result { + // Add one to the serialized num words to get the original. + let num_words_minus_1 = u8::read_from(source)?; + let num_words = u16::from(num_words_minus_1) + 1; + + let words: Vec = + source.read_many_iter(num_words as usize)?.collect::>()?; + Self::new(words).map_err(|err| DeserializationError::InvalidValue(err.to_string())) + } +} + +impl SequentialCommit for NoteAttachmentContent { + type Commitment = Word; + + fn to_elements(&self) -> Vec { + Word::words_as_elements(&self.words).to_vec() + } + + fn to_commitment(&self) -> Self::Commitment { + self.commitment + } +} + +// NOTE ATTACHMENT SCHEME +// ================================================================================================ + +/// The user-defined scheme of a [`NoteAttachment`]. +/// +/// A note attachment scheme is an arbitrary 16-bit unsigned integer (max [`Self::MAX`]). It is +/// intended to be used to distinguish one attachment from another, or find a specific attachment in +/// a note's attachments. +/// +/// The scheme is purely a hint, and there is no validation with respect to the attachment content. +/// In other words, any scheme can be associated with any attachment content. Hence, users should +/// always validate the contents of an attachment, just like with +/// [`NoteStorage`](super::NoteStorage). +/// +/// Value `0` is reserved to signal that the entire attachment is absent and so it is not a valid +/// scheme. +/// +/// Value `1` is reserved to signal that the scheme is none. Whenever the kind of attachment is not +/// standardized or interoperability is unimportant, this none value can be used. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NoteAttachmentScheme(u16); + +impl NoteAttachmentScheme { + // CONSTANTS + // -------------------------------------------------------------------------------------------- + + /// The reserved value to signal an absent attachment. This is not a valid attachment scheme. + const RESERVED: u16 = 0; + + /// The reserved value to signal a `None` note attachment scheme. + const NONE: u16 = 1; + + /// The maximum value for a note attachment scheme. + /// + /// Limited to `2^16 - 2 = 65534` to ensure the felt encoding remains valid when four + /// schemes are packed into a single felt in the note metadata. Limiting schemes to this value + /// means at least one bit is always unset which ensures felt validity. + pub const MAX: NoteAttachmentScheme = NoteAttachmentScheme(65534); + + // CONSTRUCTORS + // -------------------------------------------------------------------------------------------- + + /// Creates a new [`NoteAttachmentScheme`] from a `u16`. + /// + /// # Errors + /// + /// Returns an error if `attachment_scheme` is equal to 0 or exceeds [`Self::MAX`]. + pub fn new(attachment_scheme: u16) -> Result { + if attachment_scheme == Self::RESERVED { + return Err(NoteError::NoteAttachmentSchemeZeroReserved); + } + + if attachment_scheme > Self::MAX.as_u16() { + return Err(NoteError::NoteAttachmentSchemeExceeded(attachment_scheme as u32)); + } + Ok(Self(attachment_scheme)) + } + + /// Creates a new [`NoteAttachmentScheme`] from a `u16`. + /// + /// # Panics + /// + /// Panics if `attachment_scheme` is 0 or exceeds [`Self::MAX`]. + pub const fn new_const(attachment_scheme: u16) -> Self { + assert!(attachment_scheme != Self::RESERVED, "attachment scheme must not be 0"); + assert!(attachment_scheme <= Self::MAX.as_u16(), "attachment scheme exceeds maximum"); + Self(attachment_scheme) + } + + /// Returns the [`NoteAttachmentScheme`] that signals the absence of an attachment scheme. + pub const fn none() -> Self { + Self(Self::NONE) + } + + /// Returns `true` if the attachment scheme is the reserved value that signals an absent scheme, + /// `false` otherwise. + pub const fn is_none(&self) -> bool { + self.0 == Self::NONE + } + + // ACCESSORS + // -------------------------------------------------------------------------------------------- + + /// Returns the note attachment scheme as a u16. + pub const fn as_u16(&self) -> u16 { + self.0 + } +} + +impl TryFrom for NoteAttachmentScheme { + type Error = NoteError; + + fn try_from(value: u16) -> Result { + Self::new(value) + } +} + +impl Default for NoteAttachmentScheme { + /// Returns [`NoteAttachmentScheme::none`]. + fn default() -> Self { + Self::none() + } +} + +impl core::fmt::Display for NoteAttachmentScheme { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_fmt(format_args!("{}", self.0)) + } +} + +impl Serializable for NoteAttachmentScheme { + fn write_into(&self, target: &mut W) { + self.as_u16().write_into(target); + } + + fn get_size_hint(&self) -> usize { + core::mem::size_of::() + } +} + +impl Deserializable for NoteAttachmentScheme { + fn read_from(source: &mut R) -> Result { + let value = u16::read_from(source)?; + Self::try_from(value).map_err(|err| DeserializationError::InvalidValue(err.to_string())) + } +} + +// NOTE ATTACHMENT HEADER +// ================================================================================================ + +/// The header metadata for a single note attachment. +/// +/// Contains the scheme of an attachment, without the actual content data. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NoteAttachmentHeader { + /// `None` represents an absent note attachment and `Some` a present one. + scheme: Option, +} + +impl NoteAttachmentHeader { + // CONSTRUCTORS + // -------------------------------------------------------------------------------------------- + + /// Creates a new [`NoteAttachmentHeader`] from a [`NoteAttachmentScheme`]. + pub fn new(scheme: NoteAttachmentScheme) -> Self { + Self { scheme: Some(scheme) } + } + + /// Creates a new [`NoteAttachmentHeader`] from a [`NoteAttachmentScheme`]. + pub fn new_maybe(scheme: Option) -> Self { + Self { scheme } + } + + /// Returns a header representing the absence of an attachment. + pub const fn absent() -> Self { + Self { scheme: None } + } + + // ACCESSORS + // -------------------------------------------------------------------------------------------- + + /// Returns the attachment scheme. + pub const fn scheme(&self) -> Option { + self.scheme + } + + /// Returns the header encoded as a u16. + /// + /// Encodes `None` to 0 using the niche provided by [`NoteAttachmentScheme`]. + pub(super) fn as_u16(&self) -> u16 { + match self.scheme { + None => 0, + Some(scheme) => scheme.as_u16(), + } + } + + /// Returns `true` if this header represents an absent attachment, `false` otherwise. + pub const fn is_absent(&self) -> bool { + self.scheme.is_none() + } +} + +impl Default for NoteAttachmentHeader { + fn default() -> Self { + Self::absent() + } +} + +impl From for NoteAttachmentHeader { + fn from(scheme: NoteAttachmentScheme) -> Self { + NoteAttachmentHeader::new(scheme) + } +} + +impl Serializable for NoteAttachmentHeader { + fn write_into(&self, target: &mut W) { + self.scheme.write_into(target); + } + + fn get_size_hint(&self) -> usize { + self.scheme.get_size_hint() + } +} + +impl Deserializable for NoteAttachmentHeader { + fn read_from(source: &mut R) -> Result { + let scheme = Option::::read_from(source)?; + Ok(Self::new_maybe(scheme)) + } +} + +// NOTE ATTACHMENTS +// ================================================================================================ + +/// A collection of note attachments. +/// +/// Notes can have up to [`Self::MAX_COUNT`] attachments. +/// +/// The commitment to the attachments is defined as: +/// - 0 attachments: `EMPTY_WORD` +/// - 1+ attachments: `hash(ATTACHMENT_0_COMMITMENT || ... || ATTACHMENT_N_COMMITMENT)`, i.e., the +/// sequential hash over the individual attachment commitments. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NoteAttachments { + attachments: Vec, +} + +impl NoteAttachments { + // CONSTANTS + // -------------------------------------------------------------------------------------------- + + /// The maximum number of attachments per note. + pub const MAX_COUNT: usize = 4; + + /// The maximum total number of elements across all attachments in a note. + /// + /// Each element holds roughly 8 bytes of data and so this allows for a maximum of + /// 512 * 32 = 2^14 = 16384 bytes. + pub const MAX_NUM_WORDS: u16 = 512; + + // CONSTRUCTORS + // -------------------------------------------------------------------------------------------- + + /// Creates a new empty [`NoteAttachments`] collection. + pub fn empty() -> Self { + Self { attachments: Vec::new() } + } + + /// Creates a [`NoteAttachments`] from a vector of attachments. + /// + /// # Errors + /// + /// Returns an error if: + /// - The number of attachments exceeds [`Self::MAX_COUNT`]. + /// - The total number of words across all attachments exceeds [`Self::MAX_NUM_WORDS`]. + pub fn new(attachments: Vec) -> Result { + if attachments.len() > Self::MAX_COUNT { + return Err(NoteError::TooManyAttachments(attachments.len())); + } + + let total_num_words = attachments + .iter() + .map(|attachment| attachment.num_words() as usize) + .sum::(); + + if total_num_words > Self::MAX_NUM_WORDS as usize { + return Err(NoteError::NoteAttachmentsTooManyWords(total_num_words)); + } + + Ok(Self { attachments }) + } + + // ACCESSORS + // -------------------------------------------------------------------------------------------- + + /// Returns the attachment at the given index, if it exists. + pub fn get(&self, index: usize) -> Option<&NoteAttachment> { + self.attachments.get(index) + } + + /// Returns the first attachment with the provided scheme, if any. + pub fn find(&self, scheme: NoteAttachmentScheme) -> Option<&NoteAttachment> { + self.attachments + .iter() + .find(|attachment| attachment.attachment_scheme == scheme) + } + + /// Returns the number of attachments. + pub fn num_attachments(&self) -> u8 { + u8::try_from(self.attachments.len()) + .expect("constructor should ensure num attachment fits in u8") + } + + /// Returns `true` if there are no attachments. + pub fn is_empty(&self) -> bool { + self.attachments.is_empty() + } + + /// Returns an iterator over the attachments. + pub fn iter(&self) -> impl Iterator { + self.attachments.iter() + } + + /// Returns the individual commitment of each contained attachment. + pub fn commitments(&self) -> Vec { + self.attachments + .iter() + .map(|attachment| attachment.content().to_commitment()) + .collect() + } + + /// Returns the commitment over the contained attachments. + pub fn to_commitment(&self) -> Word { + ::to_commitment(self) + } + + /// Returns the attachment headers for all attachment slots. + /// + /// Returns a fixed-size array of [`Self::MAX_COUNT`] headers. Unused slots are filled with + /// [`NoteAttachmentHeader::absent`]. + pub fn to_headers(&self) -> [NoteAttachmentHeader; Self::MAX_COUNT] { + let mut headers = [NoteAttachmentHeader::absent(); Self::MAX_COUNT]; + for (i, attachment) in self.attachments.iter().enumerate() { + headers[i] = NoteAttachmentHeader::new(attachment.attachment_scheme()); + } + headers + } + + // CONVERSIONS + // -------------------------------------------------------------------------------------------- + + /// Consumes self and returns the inner vector of attachments. + pub fn into_vec(self) -> Vec { + self.attachments + } +} + +impl Default for NoteAttachments { + fn default() -> Self { + Self::empty() + } +} + +impl SequentialCommit for NoteAttachments { + type Commitment = Word; + + /// Collects all attachment commitments into a flat vector of field elements. + fn to_elements(&self) -> Vec { + let mut elements = Vec::new(); + for commitment in self.attachments.iter().map(NoteAttachment::to_commitment) { + elements.extend_from_slice(commitment.as_elements()); + } + elements + } +} + +impl From for NoteAttachments { + fn from(attachment: NoteAttachment) -> Self { + Self::new(vec![attachment]).expect("one attachment does not exceed the max of four") + } +} + +impl Serializable for NoteAttachments { + fn write_into(&self, target: &mut W) { + self.num_attachments().write_into(target); + target.write_many(&self.attachments); + } + + fn get_size_hint(&self) -> usize { + self.num_attachments().get_size_hint() + + self.iter().map(NoteAttachment::get_size_hint).sum::() + } +} + +impl Deserializable for NoteAttachments { + fn read_from(source: &mut R) -> Result { + let num_attachments = u8::read_from(source)? as usize; + let attachments = source + .read_many_iter::(num_attachments)? + .collect::, _>>()?; + Self::new(attachments).map_err(|err| DeserializationError::InvalidValue(err.to_string())) + } +} diff --git a/crates/miden-protocol/src/note/attachment/tests.rs b/crates/miden-protocol/src/note/attachment/tests.rs new file mode 100644 index 0000000000..1c075f54ed --- /dev/null +++ b/crates/miden-protocol/src/note/attachment/tests.rs @@ -0,0 +1,157 @@ +use assert_matches::assert_matches; + +use super::*; + +#[rstest::rstest] +#[case::attachment_word(NoteAttachment::with_word(NoteAttachmentScheme::new(1)?, Word::from([3, 4, 5, 6u32])))] +#[case::attachment_words(NoteAttachment::with_words( + NoteAttachmentScheme::MAX, + vec![Word::from([1, 1, 1, 1u32]); 2], + )?)] +#[test] +fn note_attachment_serde(#[case] attachment: NoteAttachment) -> anyhow::Result<()> { + assert_eq!(attachment, NoteAttachment::read_from_bytes(&attachment.to_bytes())?); + Ok(()) +} + +#[test] +fn note_attachment_content_fails_on_too_many_words() -> anyhow::Result<()> { + let too_many_words = NoteAttachment::MAX_NUM_WORDS as usize + 1; + let words = vec![Word::from([1, 1, 1, 1u32]); too_many_words]; + let err = NoteAttachmentContent::new(words).unwrap_err(); + + assert_matches!(err, NoteError::NoteAttachmentContentTooManyWords(len) => { + len == too_many_words + }); + + Ok(()) +} + +#[test] +fn note_attachment_scheme_max_is_valid() { + let scheme = NoteAttachmentScheme::MAX; + assert_eq!(scheme.as_u16(), 65534); +} + +#[test] +fn note_attachment_scheme_exceeding_max_fails() { + let err = NoteAttachmentScheme::new(u16::MAX).unwrap_err(); + assert_matches!(err, NoteError::NoteAttachmentSchemeExceeded(_)); +} + +#[test] +fn note_attachment_header_serde() -> anyhow::Result<()> { + let header = NoteAttachmentHeader::new(NoteAttachmentScheme::new(42)?); + let deserialized = NoteAttachmentHeader::read_from_bytes(&header.to_bytes())?; + assert_eq!(header, deserialized); + Ok(()) +} + +#[test] +fn note_attachment_header_absent() { + let header = NoteAttachmentHeader::absent(); + assert!(header.is_absent()); + assert!(header.scheme().is_none()); +} + +#[test] +fn note_attachments_up_to_max() -> anyhow::Result<()> { + let scheme = NoteAttachmentScheme::new(1)?; + let attachment = NoteAttachment::with_word(scheme, Word::from([1, 2, 3, 4u32])); + let attachments = NoteAttachments::new(vec![attachment; NoteAttachments::MAX_COUNT])?; + assert_eq!(attachments.num_attachments() as usize, NoteAttachments::MAX_COUNT); + + // Exceeding MAX_COUNT should fail. + let err = + NoteAttachments::new(vec![ + NoteAttachment::with_word(scheme, Word::from([1, 2, 3, 4u32])); + NoteAttachments::MAX_COUNT + 1 + ]) + .unwrap_err(); + assert_matches!(err, NoteError::TooManyAttachments(5)); + + Ok(()) +} + +#[test] +fn note_attachments_serde() -> anyhow::Result<()> { + let attachments = NoteAttachments::new(vec![ + NoteAttachment::with_word(NoteAttachmentScheme::new(1)?, Word::from([1, 2, 3, 4u32])), + NoteAttachment::with_words( + NoteAttachmentScheme::new(100)?, + vec![Word::from([1, 1, 1, 1u32]); 2], + )?, + ])?; + + let deserialized = NoteAttachments::read_from_bytes(&attachments.to_bytes())?; + assert_eq!(attachments, deserialized); + + Ok(()) +} + +#[test] +fn note_attachments_commitment_empty() { + let attachments = NoteAttachments::empty(); + assert_eq!(attachments.to_commitment(), Word::empty()); +} + +#[test] +fn note_attachments_commitment_single_word() -> anyhow::Result<()> { + let word = Word::from([10, 20, 30, 40u32]); + let attachments = + NoteAttachments::new(vec![NoteAttachment::with_word(NoteAttachmentScheme::new(1)?, word)])?; + // Single word attachment: the attachment commitment is hash(word), so the overall + // attachments commitment is hash(hash(word)). + let word_commitment = Hasher::hash_elements(word.as_elements()); + assert_eq!( + attachments.to_commitment(), + Hasher::hash_elements(word_commitment.as_elements()) + ); + + Ok(()) +} + +#[test] +fn note_attachments_to_headers() -> anyhow::Result<()> { + let attachments = NoteAttachments::new(vec![ + NoteAttachment::with_word(NoteAttachmentScheme::new(42)?, Word::from([1, 2, 3, 4u32])), + NoteAttachment::with_words( + NoteAttachmentScheme::new(100)?, + vec![Word::from([1, 1, 1, 1u32]); 2], + )?, + ])?; + + let headers = attachments.to_headers(); + assert_eq!(headers[0].scheme(), Some(NoteAttachmentScheme::new(42)?)); + assert_eq!(headers[1].scheme(), Some(NoteAttachmentScheme::new(100)?)); + assert!(headers[2].is_absent()); + assert!(headers[3].is_absent()); + + Ok(()) +} + +#[test] +fn note_attachments_into_vec() -> anyhow::Result<()> { + let word_att = + NoteAttachment::with_word(NoteAttachmentScheme::new(1)?, Word::from([1, 2, 3, 4u32])); + let attachments = NoteAttachments::new(vec![word_att.clone()])?; + let vec = attachments.into_vec(); + assert_eq!(vec, vec![word_att]); + + Ok(()) +} + +#[test] +fn note_attachment_num_words() { + // 1 word + let content = NoteAttachmentContent::new(vec![Word::from([1, 2, 3, 4u32])]).unwrap(); + assert_eq!(content.num_words(), 1); + + // 2 words + let content = NoteAttachmentContent::new(vec![Word::from([1, 1, 1, 1u32]); 2]).unwrap(); + assert_eq!(content.num_words(), 2); + + // 3 words + let content = NoteAttachmentContent::new(vec![Word::from([1, 1, 1, 1u32]); 3]).unwrap(); + assert_eq!(content.num_words(), 3); +} diff --git a/crates/miden-protocol/src/note/details.rs b/crates/miden-protocol/src/note/details.rs index 14bad33b70..f76ec22880 100644 --- a/crates/miden-protocol/src/note/details.rs +++ b/crates/miden-protocol/src/note/details.rs @@ -1,4 +1,4 @@ -use super::{NoteAssets, NoteId, NoteRecipient, NoteScript, NoteStorage, Nullifier}; +use super::{NoteAssets, NoteDetailsCommitment, NoteRecipient, NoteScript, NoteStorage}; use crate::Word; use crate::utils::serde::{ ByteReader, @@ -32,11 +32,11 @@ impl NoteDetails { // PUBLIC ACCESSORS // -------------------------------------------------------------------------------------------- - /// Returns the note's unique identifier. + /// Returns the commitment of this [`NoteDetails`]. /// - /// This value is both an unique identifier and a commitment to the note. - pub fn id(&self) -> NoteId { - NoteId::from(self) + /// This value is used as part of the note's public identifier. + pub fn commitment(&self) -> NoteDetailsCommitment { + NoteDetailsCommitment::new(self.recipient(), self.assets()) } /// Returns the note's assets. @@ -64,13 +64,6 @@ impl NoteDetails { &self.recipient } - /// Returns the note's nullifier. - /// - /// This is public data, used to prevent double spend. - pub fn nullifier(&self) -> Nullifier { - Nullifier::from(self) - } - // MUTATORS // -------------------------------------------------------------------------------------------- diff --git a/crates/miden-protocol/src/note/file.rs b/crates/miden-protocol/src/note/file.rs index 44aac4ddfe..2db7e4521c 100644 --- a/crates/miden-protocol/src/note/file.rs +++ b/crates/miden-protocol/src/note/file.rs @@ -25,6 +25,7 @@ const MAGIC: &str = "note"; /// A serialized representation of a note. #[derive(Clone, Debug, PartialEq, Eq)] +#[allow(clippy::large_enum_variant)] pub enum NoteFile { /// The note's details aren't known. NoteId(NoteId), @@ -150,12 +151,12 @@ mod tests { NoteAssets, NoteFile, NoteInclusionProof, - NoteMetadata, NoteRecipient, NoteScript, NoteStorage, NoteTag, NoteType, + PartialNoteMetadata, }; use crate::testing::account_id::{ ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, @@ -174,7 +175,8 @@ mod tests { let recipient = NoteRecipient::new(serial_num, script, note_storage); let asset = Asset::Fungible(FungibleAsset::new(faucet, 100).unwrap()); - let metadata = NoteMetadata::new(faucet, NoteType::Public).with_tag(NoteTag::from(123)); + let metadata = + PartialNoteMetadata::new(faucet, NoteType::Public).with_tag(NoteTag::from(123)); Note::new(NoteAssets::new(vec![asset]).unwrap(), metadata, recipient) } diff --git a/crates/miden-protocol/src/note/header.rs b/crates/miden-protocol/src/note/header.rs index f65b277a2e..6bf3663813 100644 --- a/crates/miden-protocol/src/note/header.rs +++ b/crates/miden-protocol/src/note/header.rs @@ -3,91 +3,73 @@ use super::{ ByteWriter, Deserializable, DeserializationError, + NoteDetailsCommitment, NoteId, NoteMetadata, Serializable, - Word, }; -use crate::Hasher; // NOTE HEADER // ================================================================================================ /// Holds the strictly required, public information of a note. /// -/// See [NoteId] and [NoteMetadata] for additional details. -#[derive(Debug, Clone, PartialEq, Eq)] +/// See [NoteDetailsCommitment] and [NoteMetadata] for additional details. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct NoteHeader { - note_id: NoteId, - note_metadata: NoteMetadata, + details_commitment: NoteDetailsCommitment, + metadata: NoteMetadata, } impl NoteHeader { - /// Returns a new [NoteHeader] instantiated from the specified note ID and metadata. - pub fn new(note_id: NoteId, note_metadata: NoteMetadata) -> Self { - Self { note_id, note_metadata } + /// Returns a new [NoteHeader] instantiated from the specified note details commitment and + /// metadata. + pub fn new(details_commitment: NoteDetailsCommitment, metadata: NoteMetadata) -> Self { + Self { details_commitment, metadata } } /// Returns the note's identifier. /// - /// The [NoteId] value is both an unique identifier and a commitment to the note. + /// The [NoteId] commits to both the note details and the note metadata. pub fn id(&self) -> NoteId { - self.note_id + NoteId::new(self.details_commitment(), self.metadata()) } - /// Returns the note's metadata. + /// Returns the commitment to the note's details, excluding metadata. + pub fn details_commitment(&self) -> NoteDetailsCommitment { + self.details_commitment + } + + /// Returns a reference to the note's metadata. pub fn metadata(&self) -> &NoteMetadata { - &self.note_metadata + &self.metadata } /// Consumes self and returns the note header's metadata. pub fn into_metadata(self) -> NoteMetadata { - self.note_metadata - } - - /// Returns a commitment to the note and its metadata. - /// - /// > hash(NOTE_ID || NOTE_METADATA_COMMITMENT) - /// - /// This value is used primarily for authenticating notes consumed when they are consumed - /// in a transaction. - pub fn to_commitment(&self) -> Word { - compute_note_commitment(self.id(), self.metadata()) + self.metadata } } -// UTILITIES -// ================================================================================================ - -/// Returns a commitment to the note and its metadata. -/// -/// > hash(NOTE_ID || NOTE_METADATA_COMMITMENT) -/// -/// This value is used primarily for authenticating notes consumed when they are consumed -/// in a transaction. -pub fn compute_note_commitment(id: NoteId, metadata: &NoteMetadata) -> Word { - Hasher::merge(&[id.as_word(), metadata.to_commitment()]) -} - // SERIALIZATION // ================================================================================================ impl Serializable for NoteHeader { fn write_into(&self, target: &mut W) { - self.note_id.write_into(target); - self.note_metadata.write_into(target); + self.details_commitment.write_into(target); + self.metadata.write_into(target); } fn get_size_hint(&self) -> usize { - self.note_id.get_size_hint() + self.note_metadata.get_size_hint() + self.details_commitment.get_size_hint() + self.metadata.get_size_hint() } } impl Deserializable for NoteHeader { fn read_from(source: &mut R) -> Result { - let note_id = NoteId::read_from(source)?; - let note_metadata = NoteMetadata::read_from(source)?; + let details_commitment = NoteDetailsCommitment::read_from(source)?; + let metadata = NoteMetadata::read_from(source)?; - Ok(Self { note_id, note_metadata }) + Ok(Self::new(details_commitment, metadata)) } } diff --git a/crates/miden-protocol/src/note/location.rs b/crates/miden-protocol/src/note/location.rs index ddf3f4dd2f..1d5ed153d9 100644 --- a/crates/miden-protocol/src/note/location.rs +++ b/crates/miden-protocol/src/note/location.rs @@ -10,7 +10,8 @@ use super::{ }; use crate::block::BlockNumber; use crate::crypto::merkle::InnerNodeInfo; -use crate::{MAX_BATCHES_PER_BLOCK, MAX_OUTPUT_NOTES_PER_BATCH, Word}; +use crate::note::NoteId; +use crate::{MAX_BATCHES_PER_BLOCK, MAX_OUTPUT_NOTES_PER_BATCH}; /// Contains information about the location of a note. #[derive(Clone, Debug, PartialEq, Eq)] @@ -85,15 +86,12 @@ impl NoteInclusionProof { &self.note_path } - /// Returns an iterator over inner nodes of this proof assuming that `note_commitment` is the - /// value of the node to which this proof opens. - pub fn authenticated_nodes( - &self, - note_commitment: Word, - ) -> impl Iterator { + /// Returns an iterator over inner nodes of this proof assuming that `note_id` is the value of + /// the node to which this proof opens. + pub fn authenticated_nodes(&self, note_id: NoteId) -> impl Iterator { // SAFETY: expect() is fine here because we check index consistency in the constructor self.note_path - .authenticated_nodes(self.location.block_note_tree_index().into(), note_commitment) + .authenticated_nodes(self.location.block_note_tree_index().into(), note_id.as_word()) .expect("note index is not out of bounds") } } diff --git a/crates/miden-protocol/src/note/metadata.rs b/crates/miden-protocol/src/note/metadata.rs index 04c36b9c08..4acac325c6 100644 --- a/crates/miden-protocol/src/note/metadata.rs +++ b/crates/miden-protocol/src/note/metadata.rs @@ -11,52 +11,17 @@ use super::{ Word, }; use crate::Hasher; -use crate::errors::NoteError; -use crate::note::{NoteAttachment, NoteAttachmentKind, NoteAttachmentScheme}; +use crate::note::{NoteAttachmentHeader, NoteAttachments}; -// NOTE METADATA +// PARTIAL NOTE METADATA // ================================================================================================ -/// The metadata associated with a note. -/// -/// Note metadata consists of two parts: -/// - The header of the metadata, which consists of: -/// - the sender of the note -/// - the [`NoteType`] -/// - the [`NoteTag`] -/// - type information about the [`NoteAttachment`]. -/// - The optional [`NoteAttachment`]. -/// -/// # Word layout & validity -/// -/// [`NoteMetadata`] can be encoded into two words, a header and an attachment word. -/// -/// The header word has the following layout: -/// -/// ```text -/// 0th felt: [sender_id_suffix (56 bits) | 6 zero bits | note_type (2 bit)] -/// 1st felt: [sender_id_prefix (64 bits)] -/// 2nd felt: [32 zero bits | note_tag (32 bits)] -/// 3rd felt: [30 zero bits | attachment_kind (2 bits) | attachment_scheme (32 bits)] -/// ``` +/// The user-facing metadata associated with a note. /// -/// The felt validity of each part of the layout is guaranteed: -/// - 1st felt: The lower 8 bits of the account ID suffix are `0` by construction, so that they can -/// be overwritten with other data. The suffix' most significant bit must be zero such that the -/// entire felt retains its validity even if all of its lower 8 bits are set to `1`. So the note -/// type can be comfortably encoded. -/// - 2nd felt: Is equivalent to the prefix of the account ID so it inherits its validity. -/// - 3rd felt: The upper 32 bits are always zero. -/// - 4th felt: The upper 30 bits are always zero. -/// -/// The value of the attachment word depends on the -/// [`NoteAttachmentKind`](crate::note::NoteAttachmentKind): -/// - [`NoteAttachmentKind::None`](crate::note::NoteAttachmentKind::None): Empty word. -/// - [`NoteAttachmentKind::Word`](crate::note::NoteAttachmentKind::Word): The raw word itself. -/// - [`NoteAttachmentKind::Array`](crate::note::NoteAttachmentKind::Array): The commitment to the -/// elements. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct NoteMetadata { +/// Contains the sender, note type, and tag. For the full protocol-level encoding (including +/// attachment headers and commitment computation), see [`NoteMetadata`]. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct PartialNoteMetadata { /// The ID of the account which created the note. sender: AccountId, @@ -65,60 +30,22 @@ pub struct NoteMetadata { /// A value which can be used by the recipient(s) to identify notes intended for them. tag: NoteTag, - - /// The optional attachment of a note's metadata. - /// - /// Defaults to [`NoteAttachment::default`]. - attachment: NoteAttachment, } -impl NoteMetadata { +impl PartialNoteMetadata { // CONSTRUCTORS // -------------------------------------------------------------------------------------------- - /// Returns a new [`NoteMetadata`] instantiated with the specified parameters. + /// Returns a new [`PartialNoteMetadata`] instantiated with the specified parameters. /// - /// The tag defaults to [`NoteTag::default()`]. Use [`NoteMetadata::with_tag`] to set a + /// The tag defaults to [`NoteTag::default()`]. Use [`PartialNoteMetadata::with_tag`] to set a /// specific tag if needed. pub fn new(sender: AccountId, note_type: NoteType) -> Self { Self { sender, note_type, tag: NoteTag::default(), - attachment: NoteAttachment::default(), - } - } - - /// Reconstructs a [`NoteMetadata`] from a [`NoteMetadataHeader`] and a - /// [`NoteAttachment`]. - /// - /// # Errors - /// - /// Returns an error if the attachment's kind or scheme do not match those in the header. - pub fn try_from_header( - header: NoteMetadataHeader, - attachment: NoteAttachment, - ) -> Result { - if header.attachment_kind != attachment.attachment_kind() { - return Err(NoteError::AttachmentKindMismatch { - header_kind: header.attachment_kind, - attachment_kind: attachment.attachment_kind(), - }); } - - if header.attachment_scheme != attachment.attachment_scheme() { - return Err(NoteError::AttachmentSchemeMismatch { - header_scheme: header.attachment_scheme, - attachment_scheme: attachment.attachment_scheme(), - }); - } - - Ok(Self { - sender: header.sender, - note_type: header.note_type, - tag: header.tag, - attachment, - }) } // ACCESSORS @@ -139,50 +66,14 @@ impl NoteMetadata { self.tag } - /// Returns the attachment of the note. - pub fn attachment(&self) -> &NoteAttachment { - &self.attachment - } - - /// Returns `true` if the note is private. + /// Returns `true` if the note is private, `false` otherwise. pub fn is_private(&self) -> bool { self.note_type == NoteType::Private } - /// Returns the header of a [`NoteMetadata`] as a [`Word`]. - /// - /// See [`NoteMetadata`] docs for more details. - pub fn to_header(&self) -> NoteMetadataHeader { - NoteMetadataHeader { - sender: self.sender, - note_type: self.note_type, - tag: self.tag, - attachment_kind: self.attachment().content().attachment_kind(), - attachment_scheme: self.attachment.attachment_scheme(), - } - } - - /// Returns the [`Word`] that represents the header of a [`NoteMetadata`]. - /// - /// See [`NoteMetadata`] docs for more details. - pub fn to_header_word(&self) -> Word { - Word::from(self.to_header()) - } - - /// Returns the [`Word`] that represents the attachment of a [`NoteMetadata`]. - /// - /// See [`NoteMetadata`] docs for more details. - pub fn to_attachment_word(&self) -> Word { - self.attachment.content().to_word() - } - - /// Returns the commitment to the note metadata, which is defined as: - /// - /// ```text - /// hash(NOTE_METADATA_HEADER || NOTE_METADATA_ATTACHMENT) - /// ``` - pub fn to_commitment(&self) -> Word { - Hasher::merge(&[self.to_header_word(), self.to_attachment_word()]) + /// Returns `true` if the note is public, `false` otherwise. + pub fn is_public(&self) -> bool { + self.note_type == NoteType::Public } // MUTATORS @@ -193,156 +84,248 @@ impl NoteMetadata { self.tag = tag; } - /// Returns a new [`NoteMetadata`] with the tag set to the provided value. + /// Returns a new [`PartialNoteMetadata`] with the tag set to the provided value. /// /// This is a builder method that consumes self and returns a new instance for method chaining. pub fn with_tag(mut self, tag: NoteTag) -> Self { self.tag = tag; self } - - /// Mutates the note's attachment by setting it to the provided value. - pub fn set_attachment(&mut self, attachment: NoteAttachment) { - self.attachment = attachment; - } - - /// Returns a new [`NoteMetadata`] with the attachment set to the provided value. - /// - /// This is a builder method that consumes self and returns a new instance for method chaining. - pub fn with_attachment(mut self, attachment: NoteAttachment) -> Self { - self.attachment = attachment; - self - } } // SERIALIZATION // ================================================================================================ -impl Serializable for NoteMetadata { +impl Serializable for PartialNoteMetadata { fn write_into(&self, target: &mut W) { self.note_type().write_into(target); self.sender().write_into(target); self.tag().write_into(target); - self.attachment().write_into(target); } fn get_size_hint(&self) -> usize { self.note_type().get_size_hint() + self.sender().get_size_hint() + self.tag().get_size_hint() - + self.attachment().get_size_hint() } } -impl Deserializable for NoteMetadata { +impl Deserializable for PartialNoteMetadata { fn read_from(source: &mut R) -> Result { let note_type = NoteType::read_from(source)?; let sender = AccountId::read_from(source)?; let tag = NoteTag::read_from(source)?; - let attachment = NoteAttachment::read_from(source)?; - Ok(NoteMetadata::new(sender, note_type).with_tag(tag).with_attachment(attachment)) + Ok(PartialNoteMetadata::new(sender, note_type).with_tag(tag)) } } -// NOTE METADATA HEADER +// NOTE METADATA // ================================================================================================ -/// The header representation of [`NoteMetadata`]. +/// Protocol-level note metadata that combines [`PartialNoteMetadata`] with attachment information. /// -/// See the metadata's type for details on this type's [`Word`] layout. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct NoteMetadataHeader { - sender: AccountId, - note_type: NoteType, - tag: NoteTag, - attachment_kind: NoteAttachmentKind, - attachment_scheme: NoteAttachmentScheme, +/// This type wraps `PartialNoteMetadata` together with attachment headers and an attachment +/// commitment, and knows how to encode them into a [`Word`] and compute commitments. +/// +/// The metadata word is encoded as a single [`Word`] (4 felts) with the following layout: +/// +/// ```text +/// 0th felt: [sender_id_suffix (56 bits) | reserved (3 bits) | note_type (1 bit) | version (4 bits)] +/// 1st felt: [sender_id_prefix (64 bits)] +/// 2nd felt: [reserved (32 bits) | note_tag (32 bits)] +/// 3rd felt: [attachment_3_scheme (16 bits) | attachment_2_scheme (16 bits) | +/// attachment_1_scheme (16 bits) | attachment_0_scheme (16 bits)] +/// ``` +/// +/// Felt validity is guaranteed: +/// - 0th felt: The lower 8 bits of the account ID suffix are `0` by construction, so they can be +/// overwritten. The suffix's MSB is zero so the felt stays valid when lower bits are set. +/// - 1st felt: Equivalent to the account ID prefix, so it inherits its validity. +/// - 2nd felt: The tag is a u32 and the reserved bits are _currently_ set to zero, however users +/// shouldn't assume these are zero. +/// - 3rd felt: Max value is `0xFFFEFFFE_FFFEFFFE` (schemes capped at 65534), which is less than +/// `p`. +/// +/// The version is hardcoded to 0 and is reserved for forward compatibility. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct NoteMetadata { + partial_metadata: PartialNoteMetadata, + attachment_headers: [NoteAttachmentHeader; NoteAttachments::MAX_COUNT], + attachments_commitment: Word, } -impl NoteMetadataHeader { +impl NoteMetadata { + // CONSTANTS + // -------------------------------------------------------------------------------------------- + + /// The number of bits by which the note type is offset in the first felt of the metadata word. + const NOTE_TYPE_SHIFT: u64 = 4; + + /// Version 1 of the note metadata encoding. + /// + /// If we make this public, we may want to instead consider introducing a `NoteMetadataVersion` + /// struct, similar to `AccountIdVersion`. + const VERSION_1: u8 = 1; + + // CONSTRUCTORS + // -------------------------------------------------------------------------------------------- + + /// Returns a new [`NoteMetadata`] derived from the given partial metadata and attachments. + /// + /// The attachment headers and commitment are derived from the provided attachments. + pub fn new(partial_metadata: PartialNoteMetadata, attachments: &NoteAttachments) -> Self { + Self::from_parts(partial_metadata, attachments.to_headers(), attachments.to_commitment()) + } + + /// Creates a [`NoteMetadata`] from its raw parts. + /// + /// Prefer [`Self::new`] whenever possible. + pub fn from_parts( + partial_metadata: PartialNoteMetadata, + attachment_headers: [NoteAttachmentHeader; NoteAttachments::MAX_COUNT], + attachments_commitment: Word, + ) -> Self { + Self { + partial_metadata, + attachment_headers, + attachments_commitment, + } + } + // ACCESSORS // -------------------------------------------------------------------------------------------- + /// Returns the inner [`PartialNoteMetadata`]. + pub fn partial_metadata(&self) -> &PartialNoteMetadata { + &self.partial_metadata + } + /// Returns the account which created the note. pub fn sender(&self) -> AccountId { - self.sender + self.partial_metadata.sender() } /// Returns the note's type. pub fn note_type(&self) -> NoteType { - self.note_type + self.partial_metadata.note_type() } /// Returns the tag associated with the note. pub fn tag(&self) -> NoteTag { - self.tag + self.partial_metadata.tag() + } + + /// Returns the attachment headers. + pub fn attachment_headers(&self) -> &[NoteAttachmentHeader; NoteAttachments::MAX_COUNT] { + &self.attachment_headers } - /// Returns the attachment kind. - pub fn attachment_kind(&self) -> NoteAttachmentKind { - self.attachment_kind + /// Returns the attachments commitment. + pub fn attachments_commitment(&self) -> Word { + self.attachments_commitment } - /// Returns the attachment scheme. - pub fn attachment_scheme(&self) -> NoteAttachmentScheme { - self.attachment_scheme + /// Returns `true` if the note is private, `false` otherwise. + pub fn is_private(&self) -> bool { + self.partial_metadata.is_private() + } + + /// Returns `true` if the note is public, `false` otherwise. + pub fn is_public(&self) -> bool { + self.partial_metadata.is_public() + } + + /// Returns the metadata encoded as a [`Word`]. + /// + /// See [`NoteMetadata`] docs for the layout. + pub fn to_metadata_word(&self) -> Word { + let mut word = Word::empty(); + word[0] = merge_sender_suffix_and_note_type( + self.partial_metadata.sender.suffix(), + self.partial_metadata.note_type, + ); + word[1] = self.partial_metadata.sender.prefix().as_felt(); + word[2] = self.partial_metadata.tag.into(); + word[3] = merge_schemes(self.attachment_headers); + word + } + + /// Returns the commitment to the note metadata, which is defined as: + /// + /// ```text + /// hash(NOTE_METADATA_WORD || ATTACHMENTS_COMMITMENT) + /// ``` + pub fn to_commitment(&self) -> Word { + Hasher::merge(&[self.to_metadata_word(), self.attachments_commitment]) + } + + /// Consumes self and returns the inner [`PartialNoteMetadata`]. + pub fn into_partial_metadata(self) -> PartialNoteMetadata { + self.partial_metadata } } -impl From for Word { - fn from(header: NoteMetadataHeader) -> Self { - let mut metadata = Word::empty(); +impl Serializable for NoteMetadata { + fn write_into(&self, target: &mut W) { + self.partial_metadata.write_into(target); - metadata[0] = merge_sender_suffix_and_note_type(header.sender.suffix(), header.note_type); - metadata[1] = header.sender.prefix().as_felt(); - metadata[2] = Felt::from(header.tag); - metadata[3] = - merge_attachment_kind_scheme(header.attachment_kind, header.attachment_scheme); + let present_headers_iter = + self.attachment_headers.iter().filter(|header| !header.is_absent()); - metadata + let num_headers_present = u8::try_from(present_headers_iter.clone().count()) + .expect("num attachments is validated to be at most 4"); + num_headers_present.write_into(target); + target.write_many(present_headers_iter); + + self.attachments_commitment.write_into(target); + } + + fn get_size_hint(&self) -> usize { + self.partial_metadata.get_size_hint() + + core::mem::size_of::() + + self + .attachment_headers + .iter() + .filter(|header| !header.is_absent()) + .map(NoteAttachmentHeader::get_size_hint) + .sum::() + + self.attachments_commitment.get_size_hint() } } -impl TryFrom for NoteMetadataHeader { - type Error = NoteError; - - /// Decodes a [`NoteMetadataHeader`] from a [`Word`]. - fn try_from(word: Word) -> Result { - let (sender_suffix, note_type) = unmerge_sender_suffix_and_note_type(word[0])?; - let sender_prefix = word[1]; - let tag = u32::try_from(word[2].as_canonical_u64()).map(NoteTag::new).map_err(|_| { - NoteError::other("failed to convert note tag from metadata header to u32") - })?; - let (attachment_kind, attachment_scheme) = unmerge_attachment_kind_scheme(word[3])?; - - let sender = - AccountId::try_from_elements(sender_suffix, sender_prefix).map_err(|source| { - NoteError::other_with_source( - "failed to decode account ID from metadata header", - source, - ) - })?; - - Ok(Self { - sender, - note_type, - tag, - attachment_kind, - attachment_scheme, - }) +impl Deserializable for NoteMetadata { + fn read_from(source: &mut R) -> Result { + let partial_metadata = PartialNoteMetadata::read_from(source)?; + + let num_headers_present = u8::read_from(source)? as usize; + if num_headers_present > NoteAttachments::MAX_COUNT { + return Err(DeserializationError::InvalidValue(format!( + "number of attachment headers ({num_headers_present}) exceeds maximum ({})", + NoteAttachments::MAX_COUNT + ))); + } + + let mut attachment_headers = [NoteAttachmentHeader::absent(); NoteAttachments::MAX_COUNT]; + for header in attachment_headers.iter_mut().take(num_headers_present) { + *header = NoteAttachmentHeader::read_from(source)?; + } + + let attachment_commitment = Word::read_from(source)?; + + Ok(Self::from_parts(partial_metadata, attachment_headers, attachment_commitment)) } } // HELPER FUNCTIONS // ================================================================================================ -/// Merges the suffix of an [`AccountId`] and the [`NoteType`] into a single [`Felt`]. +/// Merges the suffix of an [`AccountId`] and note metadata into a single [`Felt`]. /// /// The layout is as follows: /// /// ```text -/// [sender_id_suffix (56 bits) | 6 zero bits | note_type (2 bits)] +/// [sender_id_suffix (56 bits) | reserved (3 bits) | note_type (1 bit) | version (4 bits)] /// ``` /// /// The most significant bit of the suffix is guaranteed to be zero, so the felt retains its @@ -353,67 +336,33 @@ fn merge_sender_suffix_and_note_type(sender_id_suffix: Felt, note_type: NoteType let mut merged = sender_id_suffix.as_canonical_u64(); let note_type_byte = note_type as u8; - debug_assert!(note_type_byte < 4, "note type must not contain values >= 4"); - merged |= note_type_byte as u64; + debug_assert!(note_type_byte < 2, "note type must not contain values >= 2"); + // note_type at bit 4, version at bits 0..=3 (hardcoded to NoteMetadata::VERSION_1) + merged |= (note_type_byte as u64) << NoteMetadata::NOTE_TYPE_SHIFT; + merged |= NoteMetadata::VERSION_1 as u64; // SAFETY: The most significant bit of the suffix is zero by construction so the u64 will be a // valid felt. Felt::try_from(merged).expect("encoded value should be a valid felt") } -/// Unmerges the sender ID suffix and note type. -fn unmerge_sender_suffix_and_note_type(element: Felt) -> Result<(Felt, NoteType), NoteError> { - const NOTE_TYPE_MASK: u8 = 0b11; - // Inverts the note type mask. - const SENDER_SUFFIX_MASK: u64 = !(NOTE_TYPE_MASK as u64); - - let note_type_byte = element.as_canonical_u64() as u8 & NOTE_TYPE_MASK; - let note_type = NoteType::try_from(note_type_byte).map_err(|source| { - NoteError::other_with_source("failed to decode note type from metadata header", source) - })?; - - // No bits were set so felt should still be valid. - let sender_suffix = Felt::try_from(element.as_canonical_u64() & SENDER_SUFFIX_MASK) - .expect("felt should still be valid"); - - Ok((sender_suffix, note_type)) -} - -/// Merges the [`NoteAttachmentScheme`] and [`NoteAttachmentKind`] into a single [`Felt`]. +/// Merges four attachment schemes into a single [`Felt`]. /// /// The layout is as follows: /// /// ```text -/// [30 zero bits | attachment_kind (2 bits) | attachment_scheme (32 bits)] +/// [attachment_3_scheme (16 bits) | attachment_2_scheme (16 bits) | +/// attachment_1_scheme (16 bits) | attachment_0_scheme (16 bits)] /// ``` -fn merge_attachment_kind_scheme( - attachment_kind: NoteAttachmentKind, - attachment_scheme: NoteAttachmentScheme, -) -> Felt { - debug_assert!(attachment_kind.as_u8() < 4, "attachment kind should fit into two bits"); - let mut merged = (attachment_kind.as_u8() as u64) << 32; - let attachment_scheme = attachment_scheme.as_u32(); - merged |= attachment_scheme as u64; - - Felt::try_from(merged).expect("the upper bit should be zero and the felt therefore valid") -} - -/// Unmerges the attachment kind and attachment scheme. -fn unmerge_attachment_kind_scheme( - element: Felt, -) -> Result<(NoteAttachmentKind, NoteAttachmentScheme), NoteError> { - let attachment_scheme = element.as_canonical_u64() as u32; - let attachment_kind = (element.as_canonical_u64() >> 32) as u8; - - let attachment_scheme = NoteAttachmentScheme::new(attachment_scheme); - let attachment_kind = NoteAttachmentKind::try_from(attachment_kind).map_err(|source| { - NoteError::other_with_source( - "failed to decode attachment kind from metadata header", - source, - ) - })?; - - Ok((attachment_kind, attachment_scheme)) +/// +/// Max value: `0xFFFEFFFE_FFFEFFFE` < p. Schemes are capped at 65534. +fn merge_schemes(headers: [NoteAttachmentHeader; NoteAttachments::MAX_COUNT]) -> Felt { + let mut merged: u64 = headers[0].as_u16() as u64; + merged |= (headers[1].as_u16() as u64) << 16; + merged |= (headers[2].as_u16() as u64) << 32; + merged |= (headers[3].as_u16() as u64) << 48; + + Felt::try_from(merged).expect("encoded value should be a valid felt (schemes <= 65534)") } // TESTS @@ -423,34 +372,88 @@ fn unmerge_attachment_kind_scheme( mod tests { use super::*; - use crate::note::NoteAttachmentScheme; + use crate::note::{NoteAttachment, NoteAttachmentScheme}; use crate::testing::account_id::ACCOUNT_ID_MAX_ONES; + #[test] + fn note_metadata_word_encodes_attachment_header() -> anyhow::Result<()> { + let sender = AccountId::try_from(ACCOUNT_ID_MAX_ONES).unwrap(); + let partial_metadata = + PartialNoteMetadata::new(sender, NoteType::Public).with_tag(NoteTag::new(0xff)); + let attachment0 = NoteAttachment::with_word( + NoteAttachmentScheme::new(1)?, + Word::from([10, 20, 30, 40u32]), + ); + let attachment1 = NoteAttachment::with_words( + NoteAttachmentScheme::new(0xfffe)?, + vec![Word::from([10, 20, 30, 40u32]), Word::from([10, 20, 30, 40u32])], + )?; + let attachments = NoteAttachments::new(vec![attachment0, attachment1])?; + let metadata = NoteMetadata::new(partial_metadata, &attachments); + + let encoded = metadata.to_metadata_word(); + + let tag = encoded[2].as_canonical_u64(); + assert_eq!(tag, 0x0000_0000_0000_00ff); + + let schemes = encoded[3].as_canonical_u64(); + // scheme 3 and 4 are 0, 2 is 0xfffe, 1 is 0x1 + assert_eq!(schemes, 0x0000_0000_fffe_0001); + + Ok(()) + } + #[rstest::rstest] - #[case::attachment_none(NoteAttachment::default())] - #[case::attachment_raw(NoteAttachment::new_word(NoteAttachmentScheme::new(0), Word::from([3, 4, 5, 6u32])))] - #[case::attachment_commitment(NoteAttachment::new_array( - NoteAttachmentScheme::new(u32::MAX), - vec![Felt::new(5), Felt::new(6), Felt::new(7)], - )?)] + #[case::attachment_none([])] + #[case::attachment_two_words([ + NoteAttachment::with_word(NoteAttachmentScheme::none(), Word::from([3, 4, 5, 6u32])), + NoteAttachment::with_word(NoteAttachmentScheme::none(), Word::from([3, 4, 5, 6u32])), + ])] + #[case::attachment_word_and_two_arrays([ + NoteAttachment::with_word(NoteAttachmentScheme::none(), Word::from([3, 4, 5, 6u32])), + NoteAttachment::with_words( + NoteAttachmentScheme::MAX, + vec![Word::from([5, 5, 5, 5u32]); 2], + )?, + NoteAttachment::with_words( + NoteAttachmentScheme::MAX, + vec![Word::from([10, 10, 10, 10u32]); NoteAttachment::MAX_NUM_WORDS as usize], + )?, + ])] #[test] - fn note_metadata_serde(#[case] attachment: NoteAttachment) -> anyhow::Result<()> { + fn note_metadata_serde( + #[case] attachments: impl IntoIterator, + ) -> anyhow::Result<()> { // Use the Account ID with the maximum one bits to test if the merge function always // produces valid felts. let sender = AccountId::try_from(ACCOUNT_ID_MAX_ONES).unwrap(); let note_type = NoteType::Public; let tag = NoteTag::new(u32::MAX); - let metadata = - NoteMetadata::new(sender, note_type).with_tag(tag).with_attachment(attachment); + let partial_metadata = PartialNoteMetadata::new(sender, note_type).with_tag(tag); + let attachments = NoteAttachments::new(attachments.into_iter().collect())?; + let metadata = NoteMetadata::new(partial_metadata, &attachments); - // Serialization Roundtrip - let deserialized = NoteMetadata::read_from_bytes(&metadata.to_bytes())?; - assert_eq!(deserialized, metadata); + // Partial Metadata Roundtrip + let deserialized = PartialNoteMetadata::read_from_bytes(&partial_metadata.to_bytes())?; + assert_eq!(deserialized, partial_metadata); - // Metadata Header Roundtrip - let header = NoteMetadataHeader::try_from(metadata.to_header_word())?; - assert_eq!(header, metadata.to_header()); + // Metadata Roundtrip + let roundtripped = NoteMetadata::read_from_bytes(&metadata.to_bytes())?; + assert_eq!(roundtripped, metadata); Ok(()) } + + #[test] + fn note_metadata_header_encodes_v1_as_one() { + let sender = AccountId::try_from(ACCOUNT_ID_MAX_ONES).unwrap(); + let metadata = PartialNoteMetadata::new(sender, NoteType::Private); + let metadata = NoteMetadata::new(metadata, &NoteAttachments::default()); + + let metadata = metadata.to_metadata_word(); + let version = metadata[0].as_canonical_u64() & 0b1111; + + assert_eq!(version, NoteMetadata::VERSION_1 as u64); + assert_eq!(version, 1); + } } diff --git a/crates/miden-protocol/src/note/mod.rs b/crates/miden-protocol/src/note/mod.rs index ab0c9eb3f0..14c9e2ce04 100644 --- a/crates/miden-protocol/src/note/mod.rs +++ b/crates/miden-protocol/src/note/mod.rs @@ -18,26 +18,29 @@ mod details; pub use details::NoteDetails; mod header; -pub use header::{NoteHeader, compute_note_commitment}; +pub use header::NoteHeader; mod storage; pub use storage::NoteStorage; mod metadata; -pub use metadata::{NoteMetadata, NoteMetadataHeader}; +pub use metadata::{NoteMetadata, PartialNoteMetadata}; mod attachment; pub use attachment::{ NoteAttachment, - NoteAttachmentArray, NoteAttachmentContent, - NoteAttachmentKind, + NoteAttachmentHeader, NoteAttachmentScheme, + NoteAttachments, }; mod note_id; pub use note_id::NoteId; +mod note_details_commitment; +pub use note_details_commitment::NoteDetailsCommitment; + mod note_tag; pub use note_tag::NoteTag; @@ -57,7 +60,7 @@ mod recipient; pub use recipient::NoteRecipient; mod script; -pub use script::NoteScript; +pub use script::{NoteScript, NoteScriptRoot}; mod file; pub use file::NoteFile; @@ -68,13 +71,14 @@ pub use file::NoteFile; /// A note with all the data required for it to be consumed by executing it against the transaction /// kernel. /// -/// Notes consist of note metadata and details. Note metadata is always public, but details may be -/// either public, encrypted, or private, depending on the note type. Note details consist of note -/// assets, script, storage, and a serial number, the three latter grouped into a recipient object. +/// Notes consist of note metadata, attachments and details. Note metadata and attachments are +/// always public, but details are either private or public, depending on the note type. Note +/// details consist of note assets, script, storage, and a serial number, the three latter grouped +/// into a recipient object. /// -/// Note details can be reduced to two unique identifiers: [NoteId] and [Nullifier]. The former is -/// publicly associated with a note, while the latter is known only to entities which have access -/// to full note details. +/// Note details can be reduced to a [NoteDetailsCommitment]. Together with the note metadata, +/// this commitment determines the public [NoteId]. Full note details and metadata can also be +/// reduced to a [Nullifier], which is known only to entities which have access to full note data. /// /// Fungible and non-fungible asset transfers are done by moving assets to the note's assets. The /// note's script determines the conditions required for the note consumption, i.e. the target @@ -90,6 +94,7 @@ pub use file::NoteFile; pub struct Note { header: NoteHeader, details: NoteDetails, + attachments: NoteAttachments, nullifier: Nullifier, } @@ -98,13 +103,28 @@ impl Note { // CONSTRUCTOR // -------------------------------------------------------------------------------------------- - /// Returns a new [Note] created with the specified parameters. - pub fn new(assets: NoteAssets, metadata: NoteMetadata, recipient: NoteRecipient) -> Self { + /// Returns a new [Note] created with the specified parameters and empty attachments. + pub fn new( + assets: NoteAssets, + partial_metadata: PartialNoteMetadata, + recipient: NoteRecipient, + ) -> Self { + Self::with_attachments(assets, partial_metadata, recipient, NoteAttachments::default()) + } + + /// Returns a new [Note] created with the specified parameters and attachments. + pub fn with_attachments( + assets: NoteAssets, + partial_metadata: PartialNoteMetadata, + recipient: NoteRecipient, + attachments: NoteAttachments, + ) -> Self { let details = NoteDetails::new(assets, recipient); - let header = NoteHeader::new(details.id(), metadata); - let nullifier = details.nullifier(); + let metadata = NoteMetadata::new(partial_metadata, &attachments); + let header = NoteHeader::new(details.commitment(), metadata); + let nullifier = Nullifier::from_details_and_metadata(&details, &metadata); - Self { header, details, nullifier } + Self { header, details, attachments, nullifier } } // PUBLIC ACCESSORS @@ -117,14 +137,14 @@ impl Note { /// Returns the note's unique identifier. /// - /// This value is both an unique identifier and a commitment to the note. + /// This value commits to the note details and metadata. pub fn id(&self) -> NoteId { self.header.id() } - /// Returns the note's metadata. - pub fn metadata(&self) -> &NoteMetadata { - self.header.metadata() + /// Returns the commitment to the note's details, excluding metadata. + pub fn details_commitment(&self) -> NoteDetailsCommitment { + self.header.details_commitment() } /// Returns the note's assets. @@ -159,14 +179,14 @@ impl Note { self.nullifier } - /// Returns a commitment to the note and its metadata. - /// - /// > hash(NOTE_ID || NOTE_METADATA_COMMITMENT) - /// - /// This value is used primarily for authenticating notes consumed when the are consumed - /// in a transaction. - pub fn commitment(&self) -> Word { - self.header.to_commitment() + /// Returns the note's attachments. + pub fn attachments(&self) -> &NoteAttachments { + &self.attachments + } + + /// Returns a reference to the note's metadata. + pub fn metadata(&self) -> &NoteMetadata { + self.header.metadata() } // MUTATORS @@ -178,10 +198,10 @@ impl Note { } /// Consumes self and returns the underlying parts of the [`Note`]. - pub fn into_parts(self) -> (NoteAssets, NoteMetadata, NoteRecipient) { + pub fn into_parts(self) -> (NoteAssets, NoteMetadata, NoteRecipient, NoteAttachments) { let (assets, recipient) = self.details.into_parts(); let metadata = self.header.into_metadata(); - (assets, metadata, recipient) + (assets, metadata, recipient, self.attachments) } } @@ -218,7 +238,18 @@ impl From for NoteDetails { impl From for PartialNote { fn from(note: Note) -> Self { let (assets, recipient, ..) = note.details.into_parts(); - PartialNote::new(note.header.into_metadata(), recipient.digest(), assets) + PartialNote::new( + note.header.into_metadata().into_partial_metadata(), + recipient.digest(), + assets, + note.attachments, + ) + } +} + +impl From<&Note> for NoteHeader { + fn from(note: &Note) -> Self { + note.header } } @@ -230,27 +261,33 @@ impl Serializable for Note { let Self { header, details, + attachments, // nullifier is not serialized as it can be computed from the rest of the data nullifier: _, } = self; - // only metadata is serialized as note ID can be computed from note details - header.metadata().write_into(target); + // Serialize only partial metadata since note ID can be recomputed from the note details and + // attachment schemes and commitments can be reconstructed from attachments + header.metadata().partial_metadata().write_into(target); details.write_into(target); + attachments.write_into(target); } fn get_size_hint(&self) -> usize { - self.header.metadata().get_size_hint() + self.details.get_size_hint() + self.header.metadata().partial_metadata().get_size_hint() + + self.details.get_size_hint() + + self.attachments.get_size_hint() } } impl Deserializable for Note { fn read_from(source: &mut R) -> Result { - let metadata = NoteMetadata::read_from(source)?; + let partial_metadata = PartialNoteMetadata::read_from(source)?; let details = NoteDetails::read_from(source)?; + let attachments = NoteAttachments::read_from(source)?; let (assets, recipient) = details.into_parts(); - Ok(Self::new(assets, metadata, recipient)) + Ok(Self::with_attachments(assets, partial_metadata, recipient, attachments)) } } diff --git a/crates/miden-protocol/src/note/note_details_commitment.rs b/crates/miden-protocol/src/note/note_details_commitment.rs new file mode 100644 index 0000000000..0b1326cdc8 --- /dev/null +++ b/crates/miden-protocol/src/note/note_details_commitment.rs @@ -0,0 +1,59 @@ +use alloc::string::String; + +use miden_crypto_derive::WordWrapper; + +use super::{Felt, Hasher, Word}; +use crate::note::{NoteAssets, NoteRecipient}; +use crate::utils::serde::{ + ByteReader, + ByteWriter, + Deserializable, + DeserializationError, + Serializable, +}; + +// NOTE DETAILS COMMITMENT +// ================================================================================================ + +/// A commitment to a note's details, without note metadata. +/// +/// This commitment is computed as: +/// > hash(NOTE_RECIPIENT_DIGEST || NOTE_ASSETS_COMMITMENT) +/// +/// Together with the note metadata commitment it is used to derive the note's +/// [`NoteId`](super::NoteId). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, WordWrapper)] +pub struct NoteDetailsCommitment(Word); + +impl NoteDetailsCommitment { + /// Returns a new [`NoteDetailsCommitment`] instantiated from the provided note components. + pub fn new(recipient: &NoteRecipient, assets: &NoteAssets) -> Self { + Self::from_raw_commitments(recipient.digest(), assets.commitment()) + } + + /// Returns a new [`NoteDetailsCommitment`] by merging the provided recipient and asset + /// commitments. + pub fn from_raw_commitments(recipient: Word, asset_commitment: Word) -> Self { + Self(Hasher::merge(&[recipient, asset_commitment])) + } +} + +// SERIALIZATION +// ================================================================================================ + +impl Serializable for NoteDetailsCommitment { + fn write_into(&self, target: &mut W) { + target.write_bytes(&self.0.to_bytes()); + } + + fn get_size_hint(&self) -> usize { + Word::SERIALIZED_SIZE + } +} + +impl Deserializable for NoteDetailsCommitment { + fn read_from(source: &mut R) -> Result { + let commitment = Word::read_from(source)?; + Ok(Self(commitment)) + } +} diff --git a/crates/miden-protocol/src/note/note_id.rs b/crates/miden-protocol/src/note/note_id.rs index 343285a81e..1a50e613ea 100644 --- a/crates/miden-protocol/src/note/note_id.rs +++ b/crates/miden-protocol/src/note/note_id.rs @@ -1,10 +1,9 @@ use alloc::string::String; use core::fmt::Display; -use miden_protocol_macros::WordWrapper; +use miden_crypto_derive::WordWrapper; -use super::{Felt, Hasher, NoteDetails, Word}; -use crate::WordError; +use super::{Felt, NoteDetailsCommitment, NoteMetadata}; use crate::utils::serde::{ ByteReader, ByteWriter, @@ -12,31 +11,23 @@ use crate::utils::serde::{ DeserializationError, Serializable, }; +use crate::{Hasher, Word, WordError}; // NOTE ID // ================================================================================================ -/// Returns a unique identifier of a note, which is simultaneously a commitment to the note. +/// The unique identifier of a note. /// -/// Note ID is computed as: +/// The note ID is computed as: /// -/// > hash(recipient, asset_commitment), -/// -/// where `recipient` is defined as: -/// -/// > hash(hash(hash(serial_num, ZERO), script_root), storage_commitment) -/// -/// This achieves the following properties: -/// - Every note can be reduced to a single unique ID. -/// - To compute a note ID, we do not need to know the note's serial_num. Knowing the hash of the -/// serial_num (as well as script root, input commitment, and note assets) is sufficient. +/// > hash(NOTE_DETAILS_COMMITMENT || NOTE_METADATA_COMMITMENT) #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, WordWrapper)] pub struct NoteId(Word); impl NoteId { - /// Returns a new [NoteId] instantiated from the provided note components. - pub fn new(recipient: Word, asset_commitment: Word) -> Self { - Self(Hasher::merge(&[recipient, asset_commitment])) + /// Returns a new [`NoteId`] from the provided details commitment and metadata. + pub fn new(details_commitment: NoteDetailsCommitment, metadata: &NoteMetadata) -> Self { + Self(Hasher::merge(&[details_commitment.as_word(), metadata.to_commitment()])) } } @@ -46,15 +37,6 @@ impl Display for NoteId { } } -// CONVERSIONS INTO NOTE ID -// ================================================================================================ - -impl From<&NoteDetails> for NoteId { - fn from(note: &NoteDetails) -> Self { - Self::new(note.recipient().digest(), note.assets().commitment()) - } -} - impl NoteId { /// Attempts to convert from a hexadecimal string to [NoteId]. /// diff --git a/crates/miden-protocol/src/note/note_tag.rs b/crates/miden-protocol/src/note/note_tag.rs index 2611f6988b..1d4e815cd6 100644 --- a/crates/miden-protocol/src/note/note_tag.rs +++ b/crates/miden-protocol/src/note/note_tag.rs @@ -176,10 +176,8 @@ impl Deserializable for NoteTag { mod tests { use super::NoteTag; - use crate::account::{AccountId, AccountStorageMode}; + use crate::account::{AccountId, AccountType}; use crate::testing::account_id::{ - ACCOUNT_ID_NETWORK_FUNGIBLE_FAUCET, - ACCOUNT_ID_NETWORK_NON_FUNGIBLE_FAUCET, ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET, ACCOUNT_ID_PRIVATE_NON_FUNGIBLE_FAUCET, ACCOUNT_ID_PRIVATE_SENDER, @@ -189,7 +187,6 @@ mod tests { ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_3, ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET, ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET_1, - ACCOUNT_ID_REGULAR_NETWORK_ACCOUNT_IMMUTABLE_CODE, ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE, ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE, ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2, @@ -208,7 +205,7 @@ mod tests { AccountId::try_from(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET).unwrap(), AccountId::try_from(ACCOUNT_ID_PRIVATE_NON_FUNGIBLE_FAUCET).unwrap(), AccountIdBuilder::new() - .storage_mode(AccountStorageMode::Private) + .account_type(AccountType::Private) .build_with_seed([2; 32]), ]; let public_accounts = [ @@ -224,29 +221,18 @@ mod tests { AccountId::try_from(ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET).unwrap(), AccountId::try_from(ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET_1).unwrap(), AccountIdBuilder::new() - .storage_mode(AccountStorageMode::Public) + .account_type(AccountType::Public) .build_with_seed([3; 32]), ]; - let network_accounts = [ - AccountId::try_from(ACCOUNT_ID_REGULAR_NETWORK_ACCOUNT_IMMUTABLE_CODE).unwrap(), - AccountId::try_from(ACCOUNT_ID_NETWORK_FUNGIBLE_FAUCET).unwrap(), - AccountId::try_from(ACCOUNT_ID_NETWORK_NON_FUNGIBLE_FAUCET).unwrap(), - AccountIdBuilder::new() - .storage_mode(AccountStorageMode::Network) - .build_with_seed([4; 32]), - ]; - - for account_id in private_accounts - .iter() - .chain(public_accounts.iter()) - .chain(network_accounts.iter()) - { + for account_id in private_accounts.iter().chain(public_accounts.iter()) { let tag = NoteTag::with_account_target(*account_id); - assert_eq!(tag.as_u32() << 16, 0, "16 least significant bits should be zero"); - let expected = ((account_id.prefix().as_u64() >> 32) as u32) >> 16; - let actual = tag.as_u32() >> 16; + assert_eq!(tag.as_u32() << 14, 0, "18 least significant bits should be zero"); + // The expected tag is the account ID prefix with the 18 least significant bits masked + // out, leaving the 14 most significant bits. + let expected = ((account_id.prefix().as_u64() >> 32) as u32) + & 0b1111_1111_1111_1100_0000_0000_0000_0000; - assert_eq!(actual, expected, "14 most significant bits should match"); + assert_eq!(tag.as_u32(), expected); } } diff --git a/crates/miden-protocol/src/note/note_type.rs b/crates/miden-protocol/src/note/note_type.rs index d72b953f86..204746d336 100644 --- a/crates/miden-protocol/src/note/note_type.rs +++ b/crates/miden-protocol/src/note/note_type.rs @@ -14,9 +14,10 @@ use crate::utils::serde::{ // NOTE TYPE // ================================================================================================ -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[repr(u8)] pub enum NoteType { + #[default] /// Notes with this type have only their hash published to the network. Private = Self::PRIVATE, @@ -25,17 +26,21 @@ pub enum NoteType { } impl NoteType { - // Keep these masks in sync with `miden-lib/asm/miden/kernels/tx/tx.masm` - pub const PUBLIC: u8 = 0b01; - pub const PRIVATE: u8 = 0b10; + const PRIVATE: u8 = 0; + const PUBLIC: u8 = 1; + + /// Returns the note type encoded to a 1-bit flag, where private is 0 and public is 1. + pub const fn as_u8(self) -> u8 { + self as u8 + } } // CONVERSIONS FROM NOTE TYPE // ================================================================================================ impl From for Felt { - fn from(id: NoteType) -> Self { - Felt::new(id as u64) + fn from(note_type: NoteType) -> Self { + Felt::from(note_type.as_u8()) } } @@ -54,38 +59,27 @@ impl TryFrom for NoteType { } } -impl TryFrom for NoteType { - type Error = NoteError; - - fn try_from(value: u16) -> Result { - Self::try_from(value as u64) - } -} - -impl TryFrom for NoteType { - type Error = NoteError; - - fn try_from(value: u32) -> Result { - Self::try_from(value as u64) - } -} - -impl TryFrom for NoteType { +impl TryFrom for NoteType { type Error = NoteError; - fn try_from(value: u64) -> Result { - let value: u8 = value - .try_into() - .map_err(|_| NoteError::UnknownNoteType(format!("0b{value:b}").into()))?; - value.try_into() + fn try_from(value: Felt) -> Result { + let byte = value.as_canonical_u64(); + Self::try_from( + u8::try_from(byte) + .map_err(|_| NoteError::UnknownNoteType(format!("0b{byte:b}").into()))?, + ) } } -impl TryFrom for NoteType { - type Error = NoteError; +// STRING CONVERSION +// ================================================================================================ - fn try_from(value: Felt) -> Result { - value.as_canonical_u64().try_into() +impl Display for NoteType { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + NoteType::Private => write!(f, "private"), + NoteType::Public => write!(f, "public"), + } } } @@ -132,32 +126,47 @@ impl Deserializable for NoteType { } } -// DISPLAY +// TESTS // ================================================================================================ -impl Display for NoteType { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - match self { - NoteType::Private => write!(f, "private"), - NoteType::Public => write!(f, "public"), - } - } -} - -#[test] -fn test_from_str_note_type() { +#[cfg(test)] +mod tests { use assert_matches::assert_matches; + use super::*; use crate::alloc::string::ToString; - for string in ["private", "public"] { - let parsed_note_type = NoteType::from_str(string).unwrap(); - assert_eq!(parsed_note_type.to_string(), string); + #[rstest::rstest] + #[case::private(NoteType::Private)] + #[case::public(NoteType::Public)] + #[test] + fn test_note_type_roundtrip(#[case] note_type: NoteType) -> anyhow::Result<()> { + // String roundtrip + assert_eq!(note_type, note_type.to_string().parse()?); + + // Serialization roundtrip + assert_eq!(note_type, NoteType::read_from_bytes(¬e_type.to_bytes())?); + + // Byte conversion roundtrip + assert_eq!(note_type, NoteType::try_from(note_type.as_u8())?); + + // Felt conversion roundtrip + assert_eq!(note_type, NoteType::try_from(Felt::from(note_type))?); + + Ok(()) } - let public_type_invalid_err = NoteType::from_str("puBlIc").unwrap_err(); - assert_matches!(public_type_invalid_err, NoteError::UnknownNoteType(_)); + #[test] + fn test_from_str_note_type() { + for string in ["private", "public"] { + let parsed_note_type = NoteType::from_str(string).unwrap(); + assert_eq!(parsed_note_type.to_string(), string); + } - let invalid_type = NoteType::from_str("invalid").unwrap_err(); - assert_matches!(invalid_type, NoteError::UnknownNoteType(_)); + let public_type_invalid_err = NoteType::from_str("puBlIc").unwrap_err(); + assert_matches!(public_type_invalid_err, NoteError::UnknownNoteType(_)); + + let invalid_type = NoteType::from_str("invalid").unwrap_err(); + assert_matches!(invalid_type, NoteError::UnknownNoteType(_)); + } } diff --git a/crates/miden-protocol/src/note/nullifier.rs b/crates/miden-protocol/src/note/nullifier.rs index 2f728b4123..79ea1d2aad 100644 --- a/crates/miden-protocol/src/note/nullifier.rs +++ b/crates/miden-protocol/src/note/nullifier.rs @@ -3,7 +3,7 @@ use core::fmt::{Debug, Display, Formatter}; use miden_core::WORD_SIZE; use miden_crypto::WordError; -use miden_protocol_macros::WordWrapper; +use miden_crypto_derive::WordWrapper; use super::{ ByteReader, @@ -12,11 +12,11 @@ use super::{ DeserializationError, Felt, Hasher, - NoteDetails, Serializable, Word, ZERO, }; +use crate::note::{NoteDetails, NoteMetadata, NoteScriptRoot}; // CONSTANTS // ================================================================================================ @@ -30,32 +30,49 @@ const NULLIFIER_PREFIX_SHIFT: u8 = 48; /// /// A note's nullifier is computed as: /// -/// > hash(serial_num, script_root, storage_commitment, asset_commitment). +/// > `hash(SERIAL_NUM, SCRIPT_ROOT, STORAGE_COMMITMENT, ASSET_COMMITMENT, METADATA, +/// > ATTACHMENTS_COMMITMENT)`. /// /// This achieves the following properties: /// - Every note can be reduced to a single unique nullifier. -/// - We cannot derive a note's commitment from its nullifier, or a note's nullifier from its hash. +/// - We cannot derive a note's ID from its nullifier, or a note's nullifier from its ID. /// - To compute the nullifier we must know all components of the note: serial_num, script_root, -/// storage_commitment and asset_commitment. +/// storage_commitment, asset_commitment, metadata and attachments_commitment. #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, WordWrapper)] pub struct Nullifier(Word); impl Nullifier { - /// Returns a new note [Nullifier] instantiated from the provided digest. + /// Returns a new note [Nullifier] instantiated from the provided note components. pub fn new( - script_root: Word, + script_root: NoteScriptRoot, storage_commitment: Word, asset_commitment: Word, serial_num: Word, + metadata_word: Word, + attachments_commitment: Word, ) -> Self { - let mut elements = [ZERO; 4 * WORD_SIZE]; + let mut elements = [ZERO; 6 * WORD_SIZE]; elements[..4].copy_from_slice(serial_num.as_elements()); elements[4..8].copy_from_slice(script_root.as_elements()); elements[8..12].copy_from_slice(storage_commitment.as_elements()); - elements[12..].copy_from_slice(asset_commitment.as_elements()); + elements[12..16].copy_from_slice(asset_commitment.as_elements()); + elements[16..20].copy_from_slice(metadata_word.as_elements()); + elements[20..24].copy_from_slice(attachments_commitment.as_elements()); Self(Hasher::hash_elements(&elements)) } + /// Returns a new note [Nullifier] instantiated from the provided note details and metadata. + pub fn from_details_and_metadata(details: &NoteDetails, metadata: &NoteMetadata) -> Self { + Self::new( + details.script().root(), + details.storage().commitment(), + details.assets().commitment(), + details.serial_num(), + metadata.to_metadata_word(), + metadata.attachments_commitment(), + ) + } + /// Returns the most significant felt (the last element in array) pub fn most_significant_felt(&self) -> Felt { self.as_elements()[3] @@ -78,7 +95,7 @@ impl Nullifier { #[cfg(any(feature = "testing", test))] pub fn dummy(n: u64) -> Self { - Self(Word::new([Felt::ZERO, Felt::ZERO, Felt::ZERO, Felt::new(n)])) + Self(Word::new([Felt::ZERO, Felt::ZERO, Felt::ZERO, Felt::new_unchecked(n)])) } } @@ -94,20 +111,6 @@ impl Debug for Nullifier { } } -// CONVERSIONS INTO NULLIFIER -// ================================================================================================ - -impl From<&NoteDetails> for Nullifier { - fn from(note: &NoteDetails) -> Self { - Self::new( - note.script().root(), - note.storage().commitment(), - note.assets().commitment(), - note.serial_num(), - ) - } -} - // SERIALIZATION // ================================================================================================ diff --git a/crates/miden-protocol/src/note/partial.rs b/crates/miden-protocol/src/note/partial.rs index a75dfa6591..3626bc8d3b 100644 --- a/crates/miden-protocol/src/note/partial.rs +++ b/crates/miden-protocol/src/note/partial.rs @@ -4,9 +4,12 @@ use super::{ Deserializable, DeserializationError, NoteAssets, + NoteAttachments, + NoteDetailsCommitment, NoteHeader, NoteId, NoteMetadata, + PartialNoteMetadata, Serializable, }; use crate::Word; @@ -14,38 +17,64 @@ use crate::Word; // PARTIAL NOTE // ================================================================================================ -/// Partial information about a note. +/// A note without detailed recipient information. /// -/// Partial note consists of [NoteMetadata], [NoteAssets], and a recipient digest (see -/// [super::NoteRecipient]). However, it does not contain detailed recipient info, including -/// note script, note storage, and note's serial number. This means that a partial note is -/// sufficient to compute note ID and note header, but not sufficient to compute note nullifier, -/// and generally does not have enough info to execute the note. +/// A partial note consists of [`PartialNoteMetadata`], [`NoteAssets`], [`NoteAttachments`], and a +/// commitment to the [`NoteRecipient`](super::NoteRecipient)). However, it does not contain +/// the full recipient, including note script, note storage, and note's serial number. This +/// means that a partial note is sufficient to compute note ID and note header, but not sufficient +/// to compute note nullifier, and generally does not have enough info to execute the note. +/// +/// One use case for the [`PartialNote`] is to return the details of a private note created during a +/// transaction, where the assets and attachments are known, but the recipient is not. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PartialNote { header: NoteHeader, recipient_digest: Word, assets: NoteAssets, + attachments: NoteAttachments, } impl PartialNote { /// Returns a new [PartialNote] instantiated from the provided parameters. - pub fn new(metadata: NoteMetadata, recipient_digest: Word, assets: NoteAssets) -> Self { - let note_id = NoteId::new(recipient_digest, assets.commitment()); - let header = NoteHeader::new(note_id, metadata); - Self { header, recipient_digest, assets } + pub fn new( + partial_metadata: PartialNoteMetadata, + recipient_digest: Word, + assets: NoteAssets, + attachments: NoteAttachments, + ) -> Self { + let details_commitment = + NoteDetailsCommitment::from_raw_commitments(recipient_digest, assets.commitment()); + let metadata = NoteMetadata::new(partial_metadata, &attachments); + let header = NoteHeader::new(details_commitment, metadata); + Self { + header, + recipient_digest, + assets, + attachments, + } } /// Returns the ID corresponding to this note. pub fn id(&self) -> NoteId { - NoteId::new(self.recipient_digest, self.assets.commitment()) + self.header.id() + } + + /// Returns the commitment to the note's details, excluding metadata. + pub fn details_commitment(&self) -> NoteDetailsCommitment { + self.header.details_commitment() } - /// Returns the metadata associated with this note. + /// Returns a reference to the [`NoteMetadata`] of this note. pub fn metadata(&self) -> &NoteMetadata { self.header.metadata() } + /// Returns the partial metadata associated with this note. + pub fn partial_metadata(&self) -> &PartialNoteMetadata { + self.header.metadata().partial_metadata() + } + /// Returns the digest of the recipient associated with this note. /// /// See [super::NoteRecipient] for more info. @@ -58,14 +87,19 @@ impl PartialNote { &self.assets } + /// Returns the note's attachments. + pub fn attachments(&self) -> &NoteAttachments { + &self.attachments + } + /// Returns the [`NoteHeader`] of this note. pub fn header(&self) -> &NoteHeader { &self.header } /// Consumes self and returns the non-Copy parts of this note. - pub fn into_parts(self) -> (NoteAssets, NoteHeader) { - (self.assets, self.header) + pub fn into_parts(self) -> (NoteAssets, NoteHeader, NoteAttachments) { + (self.assets, self.header, self.attachments) } } @@ -74,24 +108,29 @@ impl PartialNote { impl Serializable for PartialNote { fn write_into(&self, target: &mut W) { - // Serialize only metadata since the note ID in the header can be recomputed from the - // remaining data. - self.header().metadata().write_into(target); + // Serialize only partial metadata since note ID can be recomputed from the note details and + // attachment schemes and commitments can be reconstructed from attachments + self.header().metadata().partial_metadata().write_into(target); self.recipient_digest.write_into(target); - self.assets.write_into(target) + self.assets.write_into(target); + self.attachments.write_into(target); } fn get_size_hint(&self) -> usize { - self.metadata().get_size_hint() + Word::SERIALIZED_SIZE + self.assets.get_size_hint() + self.partial_metadata().get_size_hint() + + Word::SERIALIZED_SIZE + + self.assets.get_size_hint() + + self.attachments.get_size_hint() } } impl Deserializable for PartialNote { fn read_from(source: &mut R) -> Result { - let metadata = NoteMetadata::read_from(source)?; + let partial_metadata = PartialNoteMetadata::read_from(source)?; let recipient_digest = Word::read_from(source)?; let assets = NoteAssets::read_from(source)?; + let attachments = NoteAttachments::read_from(source)?; - Ok(Self::new(metadata, recipient_digest, assets)) + Ok(Self::new(partial_metadata, recipient_digest, assets, attachments)) } } diff --git a/crates/miden-protocol/src/note/recipient.rs b/crates/miden-protocol/src/note/recipient.rs index 36e20db762..2a75067e93 100644 --- a/crates/miden-protocol/src/note/recipient.rs +++ b/crates/miden-protocol/src/note/recipient.rs @@ -77,7 +77,7 @@ impl NoteRecipient { fn compute_recipient_digest(serial_num: Word, script: &NoteScript, storage: &NoteStorage) -> Word { let serial_num_hash = Hasher::merge(&[serial_num, Word::empty()]); - let merge_script = Hasher::merge(&[serial_num_hash, script.root()]); + let merge_script = Hasher::merge(&[serial_num_hash, script.root().into()]); Hasher::merge(&[merge_script, storage.commitment()]) } diff --git a/crates/miden-protocol/src/note/script.rs b/crates/miden-protocol/src/note/script.rs index 8661d62954..e6b08fcc0a 100644 --- a/crates/miden-protocol/src/note/script.rs +++ b/crates/miden-protocol/src/note/script.rs @@ -1,10 +1,11 @@ -use alloc::string::ToString; +use alloc::string::{String, ToString}; use alloc::sync::Arc; use alloc::vec::Vec; use core::fmt::Display; use core::num::TryFromIntError; use miden_core::mast::MastNodeExt; +use miden_crypto_derive::WordWrapper; use miden_mast_package::Package; use super::Felt; @@ -24,6 +25,42 @@ use crate::{PrettyPrint, Word}; /// The attribute name used to mark the entrypoint procedure in a note script library. const NOTE_SCRIPT_ATTRIBUTE: &str = "note_script"; +// NOTE SCRIPT ROOT +// ================================================================================================ + +/// The MAST root of a [`NoteScript`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, WordWrapper)] +pub struct NoteScriptRoot(Word); + +impl From for Word { + fn from(root: NoteScriptRoot) -> Self { + root.0 + } +} + +impl Display for NoteScriptRoot { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + Display::fmt(&self.0, f) + } +} + +impl Serializable for NoteScriptRoot { + 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 NoteScriptRoot { + fn read_from(source: &mut R) -> Result { + let word: Word = source.read()?; + Ok(Self::from_raw(word)) + } +} + // NOTE SCRIPT // ================================================================================================ @@ -161,8 +198,8 @@ impl NoteScript { // -------------------------------------------------------------------------------------------- /// Returns the commitment of this note script (i.e., the script's MAST root). - pub fn root(&self) -> Word { - self.mast[self.entrypoint].digest() + pub fn root(&self) -> NoteScriptRoot { + NoteScriptRoot::from_raw(self.mast[self.entrypoint].digest()) } /// Returns a reference to the [MastForest] backing this note script. @@ -221,7 +258,7 @@ impl From<&NoteScript> for Vec { // Push the length, this is used to remove the padding later result.push(Felt::from(u32::from(script.entrypoint))); - result.push(Felt::new(len as u64)); + result.push(Felt::new_unchecked(len as u64)); // A Felt can not represent all u64 values, so the data is encoded using u32. let mut encoded: &[u8] = &bytes; @@ -229,7 +266,7 @@ impl From<&NoteScript> for Vec { let (data, rest) = encoded.split_first_chunk::<4>().expect("The length has been checked"); let number = u32::from_le_bytes(*data); - result.push(Felt::new(number.into())); + result.push(Felt::from(number)); encoded = rest; } @@ -397,7 +434,7 @@ mod tests { // Non-empty advice map should add entries let key = Word::from([5u32, 6, 7, 8]); - let value = vec![Felt::new(100)]; + let value = vec![Felt::new_unchecked(100)]; let mut advice_map = AdviceMap::default(); advice_map.insert(key, value.clone()); diff --git a/crates/miden-protocol/src/note/storage.rs b/crates/miden-protocol/src/note/storage.rs index 0b8b73a976..01af8614ad 100644 --- a/crates/miden-protocol/src/note/storage.rs +++ b/crates/miden-protocol/src/note/storage.rs @@ -148,9 +148,9 @@ mod tests { #[test] fn test_storage_item_ordering() { // storage items are provided in reverse stack order - let storage_items = vec![Felt::new(1), Felt::new(2), Felt::new(3)]; + let storage_items = vec![Felt::ONE, Felt::new_unchecked(2), Felt::new_unchecked(3)]; // we expect the storage items to remain in reverse stack order. - let expected_ordering = vec![Felt::new(1), Felt::new(2), Felt::new(3)]; + let expected_ordering = vec![Felt::ONE, Felt::new_unchecked(2), Felt::new_unchecked(3)]; let note_storage = NoteStorage::new(storage_items).expect("note created should succeed"); assert_eq!(&expected_ordering, note_storage.items()); @@ -158,7 +158,7 @@ mod tests { #[test] fn test_storage_serialization() { - let storage_items = vec![Felt::new(1), Felt::new(2), Felt::new(3)]; + let storage_items = vec![Felt::ONE, Felt::new_unchecked(2), Felt::new_unchecked(3)]; let note_storage = NoteStorage::new(storage_items).unwrap(); let bytes = note_storage.to_bytes(); diff --git a/crates/miden-protocol/src/testing/account_code.rs b/crates/miden-protocol/src/testing/account_code.rs index ff3ffcd4f7..c463d78b3c 100644 --- a/crates/miden-protocol/src/testing/account_code.rs +++ b/crates/miden-protocol/src/testing/account_code.rs @@ -6,7 +6,7 @@ use alloc::sync::Arc; use miden_assembly::Assembler; use crate::account::component::AccountComponentMetadata; -use crate::account::{AccountCode, AccountComponent, AccountType}; +use crate::account::{AccountCode, AccountComponent}; use crate::testing::noop_auth_component::NoopAuthComponent; pub const CODE: &str = " @@ -27,13 +27,9 @@ impl AccountCode { .assemble_library([CODE]) .expect("mock account component should assemble"), ); - let metadata = AccountComponentMetadata::new("miden::testing::mock", AccountType::all()); + let metadata = AccountComponentMetadata::new("miden::testing::mock"); let component = AccountComponent::new(library, vec![], metadata).unwrap(); - Self::from_components( - &[NoopAuthComponent.into(), component], - AccountType::RegularAccountUpdatableCode, - ) - .unwrap() + Self::from_components(&[NoopAuthComponent.into(), component]).unwrap() } } diff --git a/crates/miden-protocol/src/testing/account_id.rs b/crates/miden-protocol/src/testing/account_id.rs index 6147ff8546..a26833d587 100644 --- a/crates/miden-protocol/src/testing/account_id.rs +++ b/crates/miden-protocol/src/testing/account_id.rs @@ -1,53 +1,24 @@ use rand_xoshiro::rand_core::SeedableRng; -use crate::account::{AccountId, AccountIdV0, AccountIdVersion, AccountStorageMode, AccountType}; +use crate::account::{AccountId, AccountIdV1, AccountIdVersion, AccountType}; // CONSTANTS // -------------------------------------------------------------------------------------------- // REGULAR ACCOUNTS - PRIVATE -pub const ACCOUNT_ID_SENDER: u128 = account_id( - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Private, - 0xfabb_ccde, -); -pub const ACCOUNT_ID_PRIVATE_SENDER: u128 = account_id( - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Private, - 0xbfcc_dcee, -); -pub const ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE: u128 = account_id( - AccountType::RegularAccountUpdatableCode, - AccountStorageMode::Private, - 0xccdd_eeff, -); +pub const ACCOUNT_ID_SENDER: u128 = account_id(AccountType::Private, 0xfabb_ccde); +pub const ACCOUNT_ID_PRIVATE_SENDER: u128 = account_id(AccountType::Private, 0xbfcc_dcee); +pub const ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE: u128 = + account_id(AccountType::Private, 0xccdd_eeff); // REGULAR ACCOUNTS - PUBLIC -pub const ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE: u128 = account_id( - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Public, - 0xaabb_ccdd, -); -pub const ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2: u128 = account_id( - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Public, - 0xbbcc_ddee, -); -pub const ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE: u128 = account_id( - AccountType::RegularAccountUpdatableCode, - AccountStorageMode::Public, - 0xacdd_eefc, -); -pub const ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE_ON_CHAIN_2: u128 = account_id( - AccountType::RegularAccountUpdatableCode, - AccountStorageMode::Public, - 0xeeff_ccdd, -); -// REGULAR ACCOUNTS - NETWORK -pub const ACCOUNT_ID_REGULAR_NETWORK_ACCOUNT_IMMUTABLE_CODE: u128 = account_id( - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Network, - 0xaacc_bbdd, -); +pub const ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE: u128 = + account_id(AccountType::Public, 0xaabb_ccdd); +pub const ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2: u128 = + account_id(AccountType::Public, 0xbbcc_ddee); +pub const ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE: u128 = + account_id(AccountType::Public, 0xacdd_eefc); +pub const ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE_ON_CHAIN_2: u128 = + account_id(AccountType::Public, 0xeeff_ccdd); // These faucet IDs all have a unique prefix and suffix felts. This is to ensure that when they // are used to issue an asset they don't cause us to run into the "multiple leaf" case when @@ -55,51 +26,37 @@ pub const ACCOUNT_ID_REGULAR_NETWORK_ACCOUNT_IMMUTABLE_CODE: u128 = account_id( // this time. // FUNGIBLE TOKENS - PRIVATE -pub const ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET: u128 = - account_id(AccountType::FungibleFaucet, AccountStorageMode::Private, 0xfabb_cddd); +pub const ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET: u128 = account_id(AccountType::Private, 0xfabb_cddd); // FUNGIBLE TOKENS - PUBLIC -/// A native asset faucet ID for use in testing scenarios. -pub const ACCOUNT_ID_NATIVE_ASSET_FAUCET: u128 = - account_id(AccountType::FungibleFaucet, AccountStorageMode::Public, 0xabcd_acde); -pub const ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET: u128 = - account_id(AccountType::FungibleFaucet, AccountStorageMode::Public, 0xaabc_bcde); -pub const ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1: u128 = - account_id(AccountType::FungibleFaucet, AccountStorageMode::Public, 0xbaca_ddef); -pub const ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2: u128 = - account_id(AccountType::FungibleFaucet, AccountStorageMode::Public, 0xccdb_eefa); -pub const ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_3: u128 = - account_id(AccountType::FungibleFaucet, AccountStorageMode::Public, 0xeeff_cc99); -// FUNGIBLE TOKENS - NETWORK -pub const ACCOUNT_ID_NETWORK_FUNGIBLE_FAUCET: u128 = - account_id(AccountType::FungibleFaucet, AccountStorageMode::Network, 0xaabc_bcdf); +/// A fee faucet ID for use in testing scenarios. +pub const ACCOUNT_ID_FEE_FAUCET: u128 = account_id(AccountType::Public, 0xabcd_acde); +pub const ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET: u128 = account_id(AccountType::Public, 0xaabc_bcde); +pub const ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1: u128 = account_id(AccountType::Public, 0xbaca_ddef); +pub const ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2: u128 = account_id(AccountType::Public, 0xccdb_eefa); +pub const ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_3: u128 = account_id(AccountType::Public, 0xeeff_cc99); // NON-FUNGIBLE TOKENS - PRIVATE pub const ACCOUNT_ID_PRIVATE_NON_FUNGIBLE_FAUCET: u128 = - account_id(AccountType::NonFungibleFaucet, AccountStorageMode::Private, 0xaabc_ccde); + account_id(AccountType::Private, 0xaabc_ccde); // NON-FUNGIBLE TOKENS - PUBLIC pub const ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET: u128 = - account_id(AccountType::NonFungibleFaucet, AccountStorageMode::Public, 0xbcca_ddef); + account_id(AccountType::Public, 0xbcca_ddef); pub const ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET_1: u128 = - account_id(AccountType::NonFungibleFaucet, AccountStorageMode::Public, 0xccdf_eefa); -// NON-FUNGIBLE TOKENS - NETWORK -pub const ACCOUNT_ID_NETWORK_NON_FUNGIBLE_FAUCET: u128 = - account_id(AccountType::NonFungibleFaucet, AccountStorageMode::Network, 0xabbc_ffde); + account_id(AccountType::Public, 0xccdf_eefa); // TEST ACCOUNT IDs WITH CERTAIN PROPERTIES /// The Account Id with the maximum possible one bits. pub const ACCOUNT_ID_MAX_ONES: u128 = - account_id(AccountType::NonFungibleFaucet, AccountStorageMode::Public, 0) - | 0x7fff_ffff_ffff_ff00_7fff_ffff_ffff_ff00; + account_id(AccountType::Public, 0) | 0x7fff_ffff_ffff_ff00_7fff_ffff_ffff_ff00; /// The Account Id with the maximum possible zero bits. -pub const ACCOUNT_ID_MAX_ZEROES: u128 = - account_id(AccountType::NonFungibleFaucet, AccountStorageMode::Private, 0x001f_0000); +pub const ACCOUNT_ID_MAX_ZEROES: u128 = account_id(AccountType::Private, 0x0000_0000); // UTILITIES // -------------------------------------------------------------------------------------------- -/// Produces a valid account ID with the given account type and storage mode. +/// Produces a valid account ID with the given account type. /// -/// - Version is set to 0. +/// - Version is set to 1. /// /// Finally, distributes the given `random` value over the ID to produce non-trivial values for /// testing. This is easiest explained with an example. Suppose `random` is `0xaabb_ccdd`, @@ -109,15 +66,11 @@ pub const ACCOUNT_ID_MAX_ZEROES: u128 = /// prefix: [0xaa | 5 zero bytes | 0xbb | metadata byte] /// suffix: [2 zero bytes | 0xcc | 3 zero bytes | 0xdd | zero byte] /// ``` -pub const fn account_id( - account_type: AccountType, - storage_mode: AccountStorageMode, - random: u32, -) -> u128 { +pub const fn account_id(account_type: AccountType, random: u32) -> u128 { let mut prefix: u64 = 0; - prefix |= (account_type as u64) << AccountIdV0::TYPE_SHIFT; - prefix |= (storage_mode as u64) << AccountIdV0::STORAGE_MODE_SHIFT; + prefix |= AccountIdVersion::Version1 as u64; + prefix |= (account_type as u64) << AccountIdV1::ACCOUNT_TYPE_SHIFT; // Produce non-trivial IDs by distributing the random value. let random_1st_felt_upper = random & 0xff00_0000; @@ -145,31 +98,28 @@ pub const fn account_id( /// # Example /// /// ``` -/// # use miden_protocol::account::{AccountType, AccountStorageMode, AccountId}; +/// # use miden_protocol::account::{AccountType, AccountId}; /// # use miden_protocol::testing::account_id::{AccountIdBuilder}; /// /// let mut rng = rand::rng(); /// -/// // A random AccountId with random AccountType and AccountStorageMode. +/// // A random AccountId with a random AccountType. /// let random_id1: AccountId = AccountIdBuilder::new().build_with_rng(&mut rng); /// -/// // A random AccountId with the given AccountType and AccountStorageMode. +/// // A random AccountId with the given AccountType. /// let random_id2: AccountId = AccountIdBuilder::new() -/// .account_type(AccountType::FungibleFaucet) -/// .storage_mode(AccountStorageMode::Private) +/// .account_type(AccountType::Private) /// .build_with_rng(&mut rng); -/// assert_eq!(random_id2.account_type(), AccountType::FungibleFaucet); -/// assert_eq!(random_id2.storage_mode(), AccountStorageMode::Private); +/// assert_eq!(random_id2.account_type(), AccountType::Private); /// ``` pub struct AccountIdBuilder { account_type: Option, - storage_mode: Option, } impl AccountIdBuilder { /// Creates a new [`AccountIdBuilder`]. pub fn new() -> Self { - Self { account_type: None, storage_mode: None } + Self { account_type: None } } /// Sets the [`AccountType`] of the generated [`AccountId`] to the provided value. @@ -178,35 +128,22 @@ impl AccountIdBuilder { self } - /// Sets the [`AccountStorageMode`] of the generated [`AccountId`] to the provided value. - pub fn storage_mode(mut self, storage_mode: AccountStorageMode) -> Self { - self.storage_mode = Some(storage_mode); - self - } - /// Builds an [`AccountId`] using the provided [`rand::Rng`]. /// - /// If no [`AccountType`] or [`AccountStorageMode`] were previously set, random ones are - /// generated. + /// If no [`AccountType`] was previously set, a random one is generated. pub fn build_with_rng(self, rng: &mut R) -> AccountId { let account_type = match self.account_type { Some(account_type) => account_type, None => rng.random(), }; - let storage_mode = match self.storage_mode { - Some(storage_mode) => storage_mode, - None => rng.random(), - }; - - AccountId::dummy(rng.random(), AccountIdVersion::Version0, account_type, storage_mode) + AccountId::dummy(rng.random(), AccountIdVersion::Version1, account_type) } /// Builds an [`AccountId`] using the provided seed as input for an RNG implemented in /// [`rand_xoshiro`]. /// - /// If no [`AccountType`] or [`AccountStorageMode`] were previously set, random ones are - /// generated. + /// If no [`AccountType`] was previously set, a random one is generated. pub fn build_with_seed(self, rng_seed: [u8; 32]) -> AccountId { // Match the implementation of rand::rngs::SmallRng and use different RNGs depending on the // platform. diff --git a/crates/miden-protocol/src/testing/add_component.rs b/crates/miden-protocol/src/testing/add_component.rs index 3ac1b16603..1cffe9182c 100644 --- a/crates/miden-protocol/src/testing/add_component.rs +++ b/crates/miden-protocol/src/testing/add_component.rs @@ -1,7 +1,7 @@ use alloc::sync::Arc; +use crate::account::AccountComponent; use crate::account::component::AccountComponentMetadata; -use crate::account::{AccountComponent, AccountType}; use crate::assembly::{Assembler, Library}; use crate::utils::sync::LazyLock; @@ -29,7 +29,7 @@ pub struct AddComponent; impl From for AccountComponent { fn from(_: AddComponent) -> Self { - let metadata = AccountComponentMetadata::new("miden::testing::add", AccountType::all()) + let metadata = AccountComponentMetadata::new("miden::testing::add") .with_description("Add component for testing"); AccountComponent::new(ADD_LIBRARY.clone(), vec![], metadata) diff --git a/crates/miden-protocol/src/testing/asset.rs b/crates/miden-protocol/src/testing/asset.rs index a89be85545..1a48b103e6 100644 --- a/crates/miden-protocol/src/testing/asset.rs +++ b/crates/miden-protocol/src/testing/asset.rs @@ -11,9 +11,8 @@ impl NonFungibleAsset { let non_fungible_asset_details = NonFungibleAssetDetails::new( AccountId::try_from(ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET).unwrap(), asset_data.to_vec(), - ) - .unwrap(); - let non_fungible_asset = NonFungibleAsset::new(&non_fungible_asset_details).unwrap(); + ); + let non_fungible_asset = NonFungibleAsset::new(&non_fungible_asset_details); Asset::NonFungible(non_fungible_asset) } diff --git a/crates/miden-protocol/src/testing/block.rs b/crates/miden-protocol/src/testing/block.rs index cb4fbc446f..1159686a7a 100644 --- a/crates/miden-protocol/src/testing/block.rs +++ b/crates/miden-protocol/src/testing/block.rs @@ -32,8 +32,7 @@ impl BlockHeader { let acct_db = AccountTree::new(smt).expect("failed to create account tree"); let account_root = acct_db.root(); let fee_parameters = - FeeParameters::new(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap(), 500) - .expect("native asset ID should be a fungible faucet ID"); + FeeParameters::new(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap(), 500); let validator_key = random_secret_key(); #[cfg(not(target_family = "wasm"))] diff --git a/crates/miden-protocol/src/testing/block_note_tree.rs b/crates/miden-protocol/src/testing/block_note_tree.rs index 3304527f9a..cc3805948d 100644 --- a/crates/miden-protocol/src/testing/block_note_tree.rs +++ b/crates/miden-protocol/src/testing/block_note_tree.rs @@ -19,7 +19,7 @@ impl BlockNoteTree { // SAFETY: This is only called from test code. Reconsider if this changes. let block_note_index = BlockNoteIndex::new(batch_idx, *note_idx_in_batch) .expect("output note batch indices should fit into a block"); - (block_note_index, note.id(), note.metadata()) + (block_note_index, note.into()) }) }); diff --git a/crates/miden-protocol/src/testing/component_metadata.rs b/crates/miden-protocol/src/testing/component_metadata.rs index 2586d5e1dd..be7c607a2f 100644 --- a/crates/miden-protocol/src/testing/component_metadata.rs +++ b/crates/miden-protocol/src/testing/component_metadata.rs @@ -1,10 +1,8 @@ -use crate::account::AccountType; use crate::account::component::AccountComponentMetadata; impl AccountComponentMetadata { - /// Creates a mock [`AccountComponentMetadata`] with the given name that supports all account - /// types. + /// Creates a mock [`AccountComponentMetadata`] with the given name. pub fn mock(name: &str) -> Self { - AccountComponentMetadata::new(name, AccountType::all()) + AccountComponentMetadata::new(name) } } diff --git a/crates/miden-protocol/src/testing/mod.rs b/crates/miden-protocol/src/testing/mod.rs index 80dda62a68..8ea171fad9 100644 --- a/crates/miden-protocol/src/testing/mod.rs +++ b/crates/miden-protocol/src/testing/mod.rs @@ -9,6 +9,7 @@ pub mod component_metadata; pub mod constants; pub mod noop_auth_component; pub mod note; +pub mod note_script_root; pub mod partial_blockchain; pub mod random_secret_key; pub mod slot_name; diff --git a/crates/miden-protocol/src/testing/noop_auth_component.rs b/crates/miden-protocol/src/testing/noop_auth_component.rs index 38677c003c..069ff94076 100644 --- a/crates/miden-protocol/src/testing/noop_auth_component.rs +++ b/crates/miden-protocol/src/testing/noop_auth_component.rs @@ -1,7 +1,7 @@ use alloc::sync::Arc; +use crate::account::AccountComponent; use crate::account::component::AccountComponentMetadata; -use crate::account::{AccountComponent, AccountType}; use crate::assembly::{Assembler, Library}; use crate::utils::sync::LazyLock; @@ -30,9 +30,8 @@ pub struct NoopAuthComponent; impl From for AccountComponent { fn from(_: NoopAuthComponent) -> Self { - let metadata = - AccountComponentMetadata::new("miden::testing::noop_auth", AccountType::all()) - .with_description("No-op auth component for testing"); + let metadata = AccountComponentMetadata::new("miden::testing::noop_auth") + .with_description("No-op auth component for testing"); AccountComponent::new(NOOP_AUTH_LIBRARY.clone(), vec![], metadata) .expect("component should be valid") diff --git a/crates/miden-protocol/src/testing/note.rs b/crates/miden-protocol/src/testing/note.rs index 4d9ac5fba2..6dbceba7c2 100644 --- a/crates/miden-protocol/src/testing/note.rs +++ b/crates/miden-protocol/src/testing/note.rs @@ -6,12 +6,12 @@ use crate::asset::FungibleAsset; use crate::note::{ Note, NoteAssets, - NoteMetadata, NoteRecipient, NoteScript, NoteStorage, NoteTag, NoteType, + PartialNoteMetadata, }; use crate::testing::account_id::ACCOUNT_ID_SENDER; @@ -28,7 +28,7 @@ impl Note { let note_script = NoteScript::mock(); let assets = NoteAssets::new(vec![FungibleAsset::mock(200)]).expect("note assets should be valid"); - let metadata = NoteMetadata::new(sender_id, NoteType::Private) + let metadata = PartialNoteMetadata::new(sender_id, NoteType::Private) .with_tag(NoteTag::with_account_target(sender_id)); let inputs = NoteStorage::new(Vec::new()).unwrap(); let recipient = NoteRecipient::new(serial_num, note_script, inputs); diff --git a/crates/miden-protocol/src/testing/note_script_root.rs b/crates/miden-protocol/src/testing/note_script_root.rs new file mode 100644 index 0000000000..d778903ae2 --- /dev/null +++ b/crates/miden-protocol/src/testing/note_script_root.rs @@ -0,0 +1,9 @@ +use crate::Word; +use crate::note::NoteScriptRoot; + +impl NoteScriptRoot { + /// Creates a [`NoteScriptRoot`] from an array of u32s for testing purposes. + pub fn from_array(array: [u32; 4]) -> Self { + Self::from_raw(Word::from(array)) + } +} diff --git a/crates/miden-protocol/src/testing/random_secret_key.rs b/crates/miden-protocol/src/testing/random_secret_key.rs index ab5fea909a..c984ce94e7 100644 --- a/crates/miden-protocol/src/testing/random_secret_key.rs +++ b/crates/miden-protocol/src/testing/random_secret_key.rs @@ -1,14 +1,14 @@ // NO STD ECDSA SECRET KEY // ================================================================================================ -use crate::crypto::dsa::ecdsa_k256_keccak::SecretKey; +use crate::crypto::dsa::ecdsa_k256_keccak::SigningKey; // NO STD SECRET KEY // ================================================================================================ -pub fn random_secret_key() -> SecretKey { +pub fn random_secret_key() -> SigningKey { use rand::SeedableRng; use rand_chacha::ChaCha20Rng; let mut rng = ChaCha20Rng::from_os_rng(); - SecretKey::with_rng(&mut rng) + SigningKey::with_rng(&mut rng) } diff --git a/crates/miden-protocol/src/testing/storage.rs b/crates/miden-protocol/src/testing/storage.rs index 3e11934c98..22acb8ff0e 100644 --- a/crates/miden-protocol/src/testing/storage.rs +++ b/crates/miden-protocol/src/testing/storage.rs @@ -93,18 +93,46 @@ pub static MOCK_MAP_SLOT: LazyLock = LazyLock::new(|| { StorageSlotName::new("miden::test::map").expect("storage slot name should be valid") }); -pub const STORAGE_VALUE_0: Word = - Word::new([Felt::new(1), Felt::new(2), Felt::new(3), Felt::new(4)]); -pub const STORAGE_VALUE_1: Word = - Word::new([Felt::new(5), Felt::new(6), Felt::new(7), Felt::new(8)]); +pub const STORAGE_VALUE_0: Word = Word::new([ + Felt::ONE, + Felt::new_unchecked(2), + Felt::new_unchecked(3), + Felt::new_unchecked(4), +]); +pub const STORAGE_VALUE_1: Word = Word::new([ + Felt::new_unchecked(5), + Felt::new_unchecked(6), + Felt::new_unchecked(7), + Felt::new_unchecked(8), +]); pub const STORAGE_LEAVES_2: [(Word, Word); 2] = [ ( - Word::new([Felt::new(101), Felt::new(102), Felt::new(103), Felt::new(104)]), - Word::new([Felt::new(1_u64), Felt::new(2_u64), Felt::new(3_u64), Felt::new(4_u64)]), + Word::new([ + Felt::new_unchecked(101), + Felt::new_unchecked(102), + Felt::new_unchecked(103), + Felt::new_unchecked(104), + ]), + Word::new([ + Felt::new_unchecked(1), + Felt::new_unchecked(2), + Felt::new_unchecked(3), + Felt::new_unchecked(4), + ]), ), ( - Word::new([Felt::new(105), Felt::new(106), Felt::new(107), Felt::new(108)]), - Word::new([Felt::new(5_u64), Felt::new(6_u64), Felt::new(7_u64), Felt::new(8_u64)]), + Word::new([ + Felt::new_unchecked(105), + Felt::new_unchecked(106), + Felt::new_unchecked(107), + Felt::new_unchecked(108), + ]), + Word::new([ + Felt::new_unchecked(5), + Felt::new_unchecked(6), + Felt::new_unchecked(7), + Felt::new_unchecked(8), + ]), ), ]; diff --git a/crates/miden-protocol/src/testing/tx.rs b/crates/miden-protocol/src/testing/tx.rs index 978170f7ba..1179d68ede 100644 --- a/crates/miden-protocol/src/testing/tx.rs +++ b/crates/miden-protocol/src/testing/tx.rs @@ -1,12 +1,19 @@ +use crate::asset::AssetAmount; +use crate::note::NoteId; use crate::transaction::ExecutedTransaction; impl ExecutedTransaction { /// A Rust implementation of the compute_fee epilogue procedure. - pub fn compute_fee(&self) -> u64 { + pub fn compute_fee(&self) -> AssetAmount { // Round up the number of cycles to the next power of two and take log2 of it. let verification_cycles = self.measurements().trace_length().ilog2(); let fee_amount = self.block_header().fee_parameters().verification_base_fee() * verification_cycles; - fee_amount as u64 + AssetAmount::from(fee_amount) + } + + /// Returns `true` if the transaction consumes the note with the given ID. + pub fn consumes_note(&self, note_id: &NoteId) -> bool { + self.input_notes().iter().any(|n| n.id() == *note_id) } } diff --git a/crates/miden-protocol/src/transaction/inputs/account.rs b/crates/miden-protocol/src/transaction/inputs/account.rs index 9f06662d8a..e7e43446a3 100644 --- a/crates/miden-protocol/src/transaction/inputs/account.rs +++ b/crates/miden-protocol/src/transaction/inputs/account.rs @@ -119,7 +119,7 @@ mod tests { let code = AccountCode::mock(); let vault = AssetVault::new(&[]).unwrap(); let storage = AccountStorage::new(vec![]).unwrap(); - let account = Account::new_existing(id, vault, storage, code, Felt::new(10)); + let account = Account::new_existing(id, vault, storage, code, Felt::new_unchecked(10)); let commitment = account.to_commitment(); diff --git a/crates/miden-protocol/src/transaction/inputs/mod.rs b/crates/miden-protocol/src/transaction/inputs/mod.rs index f38e780000..1c3500ec83 100644 --- a/crates/miden-protocol/src/transaction/inputs/mod.rs +++ b/crates/miden-protocol/src/transaction/inputs/mod.rs @@ -543,10 +543,10 @@ fn validate_is_in_block( block_header: &BlockHeader, ) -> Result<(), TransactionInputError> { let note_index = proof.location().block_note_tree_index().into(); - let note_commitment = note.commitment(); + let note_id = note.id().as_word(); proof .note_path() - .verify(note_index, note_commitment, &block_header.note_root()) + .verify(note_index, note_id, &block_header.note_root()) .map_err(|_| { TransactionInputError::InputNoteNotInBlock(note.id(), proof.location().block_num()) }) diff --git a/crates/miden-protocol/src/transaction/inputs/notes.rs b/crates/miden-protocol/src/transaction/inputs/notes.rs index b48fe8741b..1c1bbafe74 100644 --- a/crates/miden-protocol/src/transaction/inputs/notes.rs +++ b/crates/miden-protocol/src/transaction/inputs/notes.rs @@ -21,10 +21,10 @@ use crate::{Felt, Hasher, MAX_INPUT_NOTES_PER_TX, Word}; /// The commitment is composed of: /// /// - nullifier, which prevents double spend and provides unlinkability. -/// - an optional note commitment, which allows for delayed note authentication. +/// - an optional note ID, which allows for delayed note authentication. pub trait ToInputNoteCommitments { fn nullifier(&self) -> Nullifier; - fn note_commitment(&self) -> Option; + fn note_id(&self) -> Option; } // INPUT NOTES @@ -221,11 +221,11 @@ fn build_input_note_commitment(notes: &[T]) -> Word { let mut elements: Vec = Vec::with_capacity(notes.len() * 2); for commitment_data in notes { let nullifier = commitment_data.nullifier(); - let empty_word_or_note_commitment = - &commitment_data.note_commitment().map_or(Word::empty(), |note_id| note_id); + let empty_word_or_note_id = + &commitment_data.note_id().map_or(Word::empty(), |note_id| note_id.as_word()); elements.extend_from_slice(nullifier.as_elements()); - elements.extend_from_slice(empty_word_or_note_commitment.as_elements()); + elements.extend_from_slice(empty_word_or_note_id.as_elements()); } Hasher::hash_elements(&elements) } @@ -310,10 +310,10 @@ impl ToInputNoteCommitments for InputNote { self.note().nullifier() } - fn note_commitment(&self) -> Option { + fn note_id(&self) -> Option { match self { InputNote::Authenticated { .. } => None, - InputNote::Unauthenticated { note } => Some(note.commitment()), + InputNote::Unauthenticated { note } => Some(note.id()), } } } @@ -323,8 +323,8 @@ impl ToInputNoteCommitments for &InputNote { (*self).nullifier() } - fn note_commitment(&self) -> Option { - (*self).note_commitment() + fn note_id(&self) -> Option { + (*self).note_id() } } diff --git a/crates/miden-protocol/src/transaction/inputs/tests.rs b/crates/miden-protocol/src/transaction/inputs/tests.rs index cc2fcee7a2..fa6b20e9f1 100644 --- a/crates/miden-protocol/src/transaction/inputs/tests.rs +++ b/crates/miden-protocol/src/transaction/inputs/tests.rs @@ -40,7 +40,7 @@ fn test_read_foreign_account_inputs_missing_data() { let partial_vault = PartialVault::new(Word::default()); let partial_account = PartialAccount::new( native_account_id, - Felt::new(10), + Felt::new_unchecked(10), code, partial_storage, partial_vault, @@ -83,7 +83,7 @@ fn test_read_foreign_account_inputs_with_storage_data() { let partial_vault = PartialVault::new(Word::default()); let partial_account = PartialAccount::new( native_account_id, - Felt::new(10), + Felt::new_unchecked(10), code.clone(), partial_storage, partial_vault, @@ -94,9 +94,9 @@ fn test_read_foreign_account_inputs_with_storage_data() { // Create foreign account header and storage data. let foreign_header = AccountHeader::new( foreign_account_id, - Felt::new(5), + Felt::new_unchecked(5), Word::default(), - Word::new([Felt::new(1), Felt::new(2), Felt::new(3), Felt::new(4)]), + Word::new([Felt::ONE, Felt::from(2_u32), Felt::from(3_u32), Felt::from(4_u32)]), code.commitment(), ); @@ -106,12 +106,12 @@ fn test_read_foreign_account_inputs_with_storage_data() { let slot1 = StorageSlotHeader::new( slot_name1, StorageSlotType::Value, - Word::new([Felt::new(10), Felt::new(20), Felt::new(30), Felt::new(40)]), + Word::new([Felt::from(10_u32), Felt::from(20_u32), Felt::from(30_u32), Felt::from(40_u32)]), ); let slot2 = StorageSlotHeader::new( slot_name2, StorageSlotType::Map, - Word::new([Felt::new(50), Felt::new(60), Felt::new(70), Felt::new(80)]), + Word::new([Felt::from(50_u32), Felt::from(60_u32), Felt::from(70_u32), Felt::from(80_u32)]), ); let mut slots = vec![slot1, slot2]; @@ -147,7 +147,7 @@ fn test_read_foreign_account_inputs_with_storage_data() { // Should succeed and create partial account with proper storage. let account_inputs = tx_inputs.read_foreign_account_inputs(foreign_account_id).unwrap(); assert_eq!(account_inputs.id(), foreign_account_id); - assert_eq!(account_inputs.account().nonce(), Felt::new(5)); + assert_eq!(account_inputs.account().nonce(), Felt::new_unchecked(5)); // Verify storage was properly reconstructed. let storage = account_inputs.account().storage(); @@ -187,7 +187,7 @@ fn test_read_foreign_account_inputs_with_proper_witness() { let partial_vault = PartialVault::new(Word::default()); let native_account = PartialAccount::new( native_account_id, - Felt::new(10), + Felt::new_unchecked(10), code.clone(), partial_storage, partial_vault, @@ -198,9 +198,9 @@ fn test_read_foreign_account_inputs_with_proper_witness() { // Create a foreign account with proper commitment. let foreign_header = AccountHeader::new( foreign_account_id, - Felt::new(5), + Felt::new_unchecked(5), Word::default(), - Word::new([Felt::new(1), Felt::new(2), Felt::new(3), Felt::new(4)]), + Word::new([Felt::ONE, Felt::from(2_u32), Felt::from(3_u32), Felt::from(4_u32)]), code.commitment(), ); @@ -217,7 +217,7 @@ fn test_read_foreign_account_inputs_with_proper_witness() { // Insert foreign account. let _foreign_partial_account = PartialAccount::new( foreign_account_id, - Felt::new(5), + Felt::new_unchecked(5), code.clone(), PartialStorage::new(foreign_storage_header.clone(), []).unwrap(), PartialVault::new(Word::default()), @@ -270,7 +270,7 @@ fn test_read_foreign_account_inputs_with_proper_witness() { // Should succeed and create proper witness. let account_inputs = tx_inputs.read_foreign_account_inputs(foreign_account_id).unwrap(); assert_eq!(account_inputs.id(), foreign_account_id); - assert_eq!(account_inputs.account().nonce(), Felt::new(5)); + assert_eq!(account_inputs.account().nonce(), Felt::new_unchecked(5)); // Verify witness data. let witness = account_inputs.witness(); @@ -333,7 +333,7 @@ fn test_transaction_inputs_serialization_with_foreign_slot_names() { let partial_vault = PartialVault::new(Word::default()); let partial_account = PartialAccount::new( native_account_id, - Felt::new(10), + Felt::new_unchecked(10), code, partial_storage, partial_vault, diff --git a/crates/miden-protocol/src/transaction/kernel/advice_inputs.rs b/crates/miden-protocol/src/transaction/kernel/advice_inputs.rs index 84480e9b5b..cd0bdaeec0 100644 --- a/crates/miden-protocol/src/transaction/kernel/advice_inputs.rs +++ b/crates/miden-protocol/src/transaction/kernel/advice_inputs.rs @@ -6,7 +6,6 @@ use crate::account::{AccountHeader, PartialAccount}; use crate::block::account_tree::{AccountIdKey, AccountWitness}; use crate::crypto::SequentialCommit; use crate::crypto::merkle::InnerNodeInfo; -use crate::note::NoteAttachmentContent; use crate::transaction::{ AccountInputs, InputNote, @@ -144,7 +143,7 @@ impl TransactionAdviceInputs { /// TX_KERNEL_COMMITMENT /// VALIDATOR_KEY_COMMITMENT, /// [block_num, version, timestamp, 0], - /// [0, verification_base_fee, native_asset_id_suffix, native_asset_id_prefix] + /// [0, verification_base_fee, fee_faucet_id_suffix, fee_faucet_id_prefix] /// [0, 0, 0, 0] /// NOTE_ROOT, /// kernel_version @@ -177,8 +176,8 @@ impl TransactionAdviceInputs { self.extend_stack([ ZERO, Felt::from(header.fee_parameters().verification_base_fee()), - header.fee_parameters().native_asset_id().suffix(), - header.fee_parameters().native_asset_id().prefix().as_felt(), + header.fee_parameters().fee_faucet_id().suffix(), + header.fee_parameters().fee_faucet_id().prefix().as_felt(), ]); self.extend_stack([ZERO, ZERO, ZERO, ZERO]); self.extend_stack(header.note_root()); @@ -228,7 +227,9 @@ impl TransactionAdviceInputs { // insert MMR peaks info into the advice map let peaks = mmr.peaks(); - let mut elements = vec![Felt::new(peaks.num_leaves() as u64), ZERO, ZERO, ZERO]; + let num_leaves = Felt::try_from(peaks.num_leaves() as u64) + .expect("number of blocks in chain should not exceed BlockNumber::MAX"); + let mut elements = vec![num_leaves, ZERO, ZERO, ZERO]; elements.extend(peaks.flatten_and_pad_peaks()); self.add_map_entry(peaks.hash_peaks(), elements); } @@ -264,7 +265,7 @@ impl TransactionAdviceInputs { // CODE_COMMITMENT -> [[ACCOUNT_PROCEDURE_DATA]] let code = account.code(); - self.add_map_entry(code.commitment(), code.as_elements()); + self.add_map_entry(code.commitment(), code.to_elements()); // --- account storage ---------------------------------------------------- @@ -311,10 +312,10 @@ impl TransactionAdviceInputs { /// The advice provider is populated with: /// /// - For each note: - /// - The note's details (serial number, script root, and its storage / assets commitment). /// - The note's private arguments. - /// - The note's public metadata (sender account ID, note type, note tag, attachment kind / - /// scheme and the attachment content). + /// - The note's details (serial number, script root, and its storage / assets commitment). + /// - The note's public metadata (sender account ID, note type, note tag, attachment + /// schemes). /// - The note's storage (unpadded). /// - The note's assets (key and value words). /// - For authenticated notes (determined by the `is_authenticated` flag): @@ -339,24 +340,33 @@ impl TransactionAdviceInputs { self.add_map_entry(recipient.storage().commitment(), recipient.storage().to_elements()); // assets commitments self.add_map_entry(assets.commitment(), assets.to_elements()); - // array attachments - if let NoteAttachmentContent::Array(array_attachment) = - note.metadata().attachment().content() - { - self.add_map_entry( - array_attachment.commitment(), - array_attachment.as_slice().to_vec(), - ); + + // ATTACHMENTS_COMMITMENT |-> [[ATTACHMENT_COMMITMENTS]] + self.add_map_entry( + note.attachments().to_commitment(), + note.attachments() + .commitments() + .iter() + .flat_map(Word::as_elements) + .copied() + .collect(), + ); + + // ATTACHMENT_COMMITMENT |-> [ATTACHMENT_ELEMENTS] for each attachment + for attachment in note.attachments().iter() { + let commitment = attachment.content().to_commitment(); + let elements = attachment.content().to_elements(); + self.add_map_entry(commitment, elements); } - // note details / metadata + // note metadata / details + note_data.extend(*note_arg); note_data.extend(recipient.serial_num()); - note_data.extend(*recipient.script().root()); + note_data.extend(Word::from(recipient.script().root())); note_data.extend(*recipient.storage().commitment()); note_data.extend(*assets.commitment()); - note_data.extend(*note_arg); - note_data.extend(note.metadata().to_attachment_word()); - note_data.extend(note.metadata().to_header_word()); + note_data.extend(note.metadata().to_metadata_word()); + note_data.extend(note.attachments().to_commitment()); note_data.push(Felt::from(recipient.storage().num_items())); note_data.push(Felt::from(assets.num_assets() as u32)); note_data.extend(assets.to_elements()); @@ -368,7 +378,7 @@ impl TransactionAdviceInputs { note_data.push(Felt::ONE); // Merkle path - self.extend_merkle_store(proof.authenticated_nodes(note.commitment())); + self.extend_merkle_store(proof.authenticated_nodes(note.id())); let block_num = proof.location().block_num(); let block_header = if block_num == tx_inputs.block_header().block_num() { diff --git a/crates/miden-protocol/src/transaction/kernel/memory.rs b/crates/miden-protocol/src/transaction/kernel/memory.rs index 95373d8788..28f2b948ba 100644 --- a/crates/miden-protocol/src/transaction/kernel/memory.rs +++ b/crates/miden-protocol/src/transaction/kernel/memory.rs @@ -21,9 +21,9 @@ pub type StorageSlot = u8; // | Kernel data | 1_600 | 140 | 34 procedures in total, 4 elements each | // | Accounts data | 8_192 | 524_288 | 64 accounts max, 8192 elements each | // | Account delta | 532_480 | 263 | | -// | Input notes | 4_194_304 | 3_211_264 | nullifiers data segment (2^16 elements) | -// | | | | + 1024 input notes max, 3072 elements each | -// | Output notes | 16_777_216 | 3_145_728 | 1024 output notes max, 3072 elements each | +// | Input notes | 4_194_304 | 1_114_112 | nullifiers data segment (2^16 elements) | +// | | | | + 1024 input notes max, 1024 elements each | +// | Output notes | 16_777_216 | 1_048_576 | 1024 output notes max, 1024 elements each | // | Link Map Memory | 33_554_432 | 33_554_432 | Enough for 2_097_151 key-value pairs | // Relative layout of one account @@ -197,11 +197,11 @@ pub const FEE_PARAMETERS_PTR: MemoryAddress = 832; /// The index of the verification base fee within the block fee parameters. pub const VERIFICATION_BASE_FEE_IDX: DataIndex = 1; -/// The index of the native asset ID suffix within the block fee parameters. -pub const NATIVE_ASSET_ID_SUFFIX_IDX: DataIndex = 2; +/// The index of the fee faucet ID suffix within the block fee parameters. +pub const FEE_FAUCET_ID_SUFFIX_IDX: DataIndex = 2; -/// The index of the native asset ID prefix within the block fee parameters. -pub const NATIVE_ASSET_ID_PREFIX_IDX: DataIndex = 3; +/// The index of the fee faucet ID prefix within the block fee parameters. +pub const FEE_FAUCET_ID_PREFIX_IDX: DataIndex = 3; /// The memory address at which the note root is stored. pub const NOTE_ROOT_PTR: MemoryAddress = 840; @@ -344,7 +344,7 @@ pub const NATIVE_ACCT_STORAGE_SLOTS_SECTION_PTR: MemoryAddress = // ================================================================================================ /// The size of the memory segment allocated to each note. -pub const NOTE_MEM_SIZE: MemoryAddress = 3072; +pub const NOTE_MEM_SIZE: MemoryAddress = 1024; #[allow(clippy::empty_line_after_outer_attr)] #[rustfmt::skip] @@ -358,18 +358,18 @@ pub const NOTE_MEM_SIZE: MemoryAddress = 3072; // │ NUM │ NOTE 0 │ NOTE 1 │ ... │ NOTE n │ PADDING │ NOTE 0 │ NOTE 1 │ ... │ NOTE n │ // │ NOTES │ NULLIFIER │ NULLIFIER │ │ NULLIFIER │ │ DATA │ DATA │ │ DATA │ // ├──────────┼───────────┼───────────┼─────┼────────────────┼─────────┼──────────┼────────┼───────┼────────┤ -// 4_194_304 4_194_308 4_194_312 4_194_304+4(n+1) 4_259_840 +3072 +6144 +3072n +// 4_194_304 4_194_308 4_194_312 4_194_304+4(n+1) 4_259_840 +1024 +2048 +1024n // // Here `n` represents number of input notes. // -// Each nullifier occupies a single word. A data section for each note consists of exactly 3072 +// Each nullifier occupies a single word. A data section for each note consists of exactly 1024 // elements and is laid out like so: // -// ┌──────┬────────┬────────┬─────────┬────────────┬───────────┬──────────┬────────────┬───────┬ -// │ NOTE │ SERIAL │ SCRIPT │ STORAGE │ ASSETS │ RECIPIENT │ METADATA │ ATTACHMENT │ NOTE │ -// │ ID │ NUM │ ROOT │ COMM │ COMMITMENT │ │ HEADER │ │ ARGS │ -// ├──────┼────────┼────────┼─────────┼────────────┼───────────┼──────────┼────────────┼───────┼ -// 0 4 8 12 16 20 24 28 32 +// ┌──────────────┬────────┬────────┬────────────┬────────────┬──────────┬─────────────┬───────────┬───────┬ +// │ NOTE DETAILS │ SERIAL │ SCRIPT │ STORAGE │ ASSETS │ METADATA │ ATTACHMENTS │ RECIPIENT │ NOTE │ +// │ COMMITMENT │ NUM │ ROOT │ COMMITMENT │ COMMITMENT │ │ COMMITMENT │ │ ARGS │ +// ├──────────────┼────────┼────────┼────────────┼────────────┼──────────┼─────────────┼───────────┼───────┼ +// 0 4 8 12 16 20 24 28 32 // // ┬─────────┬────────┬───────┬─────────┬─────┬────────┬─────────┬─────────┐ // │ STORAGE │ NUM │ ASSET │ ASSET │ ... │ ASSET │ ASSET │ PADDING │ @@ -402,14 +402,14 @@ pub const INPUT_NOTE_DATA_SECTION_OFFSET: MemoryAddress = 4_259_840; pub const NUM_INPUT_NOTES_PTR: MemoryAddress = INPUT_NOTE_SECTION_PTR; /// The offsets at which data of an input note is stored relative to the start of its data segment. -pub const INPUT_NOTE_ID_OFFSET: MemoryOffset = 0; +pub const INPUT_NOTE_DETAILS_COMMITMENT_OFFSET: MemoryOffset = 0; pub const INPUT_NOTE_SERIAL_NUM_OFFSET: MemoryOffset = 4; pub const INPUT_NOTE_SCRIPT_ROOT_OFFSET: MemoryOffset = 8; pub const INPUT_NOTE_STORAGE_COMMITMENT_OFFSET: MemoryOffset = 12; pub const INPUT_NOTE_ASSETS_COMMITMENT_OFFSET: MemoryOffset = 16; -pub const INPUT_NOTE_RECIPIENT_OFFSET: MemoryOffset = 20; -pub const INPUT_NOTE_METADATA_HEADER_OFFSET: MemoryOffset = 24; -pub const INPUT_NOTE_ATTACHMENT_OFFSET: MemoryOffset = 28; +pub const INPUT_NOTE_METADATA_OFFSET: MemoryOffset = 20; +pub const INPUT_NOTE_ATTACHMENTS_COMMITMENT_OFFSET: MemoryOffset = 24; +pub const INPUT_NOTE_RECIPIENT_OFFSET: MemoryOffset = 28; pub const INPUT_NOTE_ARGS_OFFSET: MemoryOffset = 32; pub const INPUT_NOTE_NUM_STORAGE_ITEMS_OFFSET: MemoryOffset = 36; pub const INPUT_NOTE_NUM_ASSETS_OFFSET: MemoryOffset = 40; @@ -420,27 +420,33 @@ pub const INPUT_NOTE_ASSETS_OFFSET: MemoryOffset = 44; // OUTPUT NOTES DATA // ------------------------------------------------------------------------------------------------ // Output notes section contains data of all notes produced by a transaction. The section starts at -// memory offset 16_777_216 with each note data laid out one after another in 512 word increments. +// memory offset 16_777_216 with each note data laid out one after another in 1024 elements chunks. // // ┌─────────────┬─────────────┬───────────────┬─────────────┐ // │ NOTE 0 DATA │ NOTE 1 DATA │ ... │ NOTE n DATA │ // └─────────────┴─────────────┴───────────────┴─────────────┘ -// 16_777_216 +3072 +6144 +3072n +// 16_777_216 +1024 +2048 +1024n // // The total number of output notes for a transaction is stored in the bookkeeping section of the // memory. Data section of each note is laid out like so: // -// ┌──────┬──────────┬────────────┬───────────┬────────────┬────────┬───────┬ -// │ NOTE │ METADATA │ METADATA │ RECIPIENT │ ASSETS │ NUM │ DIRTY │ -// │ ID │ HEADER │ ATTACHMENT │ │ COMMITMENT │ ASSETS │ FLAG │ -// ├──────┼──────────┼────────────┼───────────┼────────────┼────────┼───────┼ -// 0 4 8 12 16 20 21 +// ┌──────────────┬──────────┬───────────┬───────────────────────────────────────────┬ +// │ NOTE DETAILS │ METADATA │ RECIPIENT │ [dirty_flag, num_assets, │ +// │ COMMITMENT │ │ │ num_attachments, total_attachment_words] │ +// ├──────────────┼──────────┼───────────┼───────────────────────────────────────────┼ +// 0 4 8 12 +// +// ┬────────────┬────────────┬────────────┬────────────┬────────────┬ +// │ ATTACHMENT │ ATTACHMENT │ ATTACHMENT │ ATTACHMENT │ ASSETS │ +// │ 0 │ 1 │ 2 │ 3 │ COMMITMENT │ +// ┼────────────┼────────────┼────────────┼────────────┼────────────┼ +// 16 20 24 28 32 // // ┬───────┬─────────┬─────┬────────┬─────────┬─────────┐ // │ ASSET │ ASSET │ ... │ ASSET │ ASSET │ PADDING │ // │ KEY 0 │ VALUE 0 │ │ KEY n │ VALUE n │ │ // ┼───────┼─────────┼─────┼────────┼─────────┼─────────┘ -// 24 28 24 + 8n 28 + 8n +// 36 40 36 + 8n 40 + 8n // // The DIRTY_FLAG is the binary flag which specifies whether the assets commitment stored in this // note is outdated. It holds 1 if some changes were made to the note assets since the last @@ -453,14 +459,19 @@ pub const INPUT_NOTE_ASSETS_OFFSET: MemoryOffset = 44; pub const OUTPUT_NOTE_SECTION_OFFSET: MemoryOffset = 16_777_216; /// The offsets at which data of an output note is stored relative to the start of its data segment. -pub const OUTPUT_NOTE_ID_OFFSET: MemoryOffset = 0; -pub const OUTPUT_NOTE_METADATA_HEADER_OFFSET: MemoryOffset = 4; -pub const OUTPUT_NOTE_ATTACHMENT_OFFSET: MemoryOffset = 8; -pub const OUTPUT_NOTE_RECIPIENT_OFFSET: MemoryOffset = 12; -pub const OUTPUT_NOTE_ASSET_COMMITMENT_OFFSET: MemoryOffset = 16; -pub const OUTPUT_NOTE_NUM_ASSETS_OFFSET: MemoryOffset = 20; -pub const OUTPUT_NOTE_DIRTY_FLAG_OFFSET: MemoryOffset = 21; -pub const OUTPUT_NOTE_ASSETS_OFFSET: MemoryOffset = 24; +pub const OUTPUT_NOTE_DETAILS_COMMITMENT_OFFSET: MemoryOffset = 0; +pub const OUTPUT_NOTE_METADATA_OFFSET: MemoryOffset = 4; +pub const OUTPUT_NOTE_RECIPIENT_OFFSET: MemoryOffset = 8; +pub const OUTPUT_NOTE_DIRTY_FLAG_OFFSET: MemoryOffset = 12; +pub const OUTPUT_NOTE_NUM_ASSETS_OFFSET: MemoryOffset = 13; +pub const OUTPUT_NOTE_NUM_ATTACHMENTS_OFFSET: MemoryOffset = 14; +pub const OUTPUT_NOTE_TOTAL_ATTACHMENT_WORDS_OFFSET: MemoryOffset = 15; +pub const OUTPUT_NOTE_ATTACHMENT_0_OFFSET: MemoryOffset = 16; +pub const OUTPUT_NOTE_ATTACHMENT_1_OFFSET: MemoryOffset = 20; +pub const OUTPUT_NOTE_ATTACHMENT_2_OFFSET: MemoryOffset = 24; +pub const OUTPUT_NOTE_ATTACHMENT_3_OFFSET: MemoryOffset = 28; +pub const OUTPUT_NOTE_ASSETS_COMMITMENT_OFFSET: MemoryOffset = 32; +pub const OUTPUT_NOTE_ASSETS_OFFSET: MemoryOffset = 36; // ASSETS // ------------------------------------------------------------------------------------------------ diff --git a/crates/miden-protocol/src/transaction/kernel/mod.rs b/crates/miden-protocol/src/transaction/kernel/mod.rs index 917abe382b..f49e980462 100644 --- a/crates/miden-protocol/src/transaction/kernel/mod.rs +++ b/crates/miden-protocol/src/transaction/kernel/mod.rs @@ -199,7 +199,7 @@ impl TransactionKernel { /// [ /// OUTPUT_NOTES_COMMITMENT, /// ACCOUNT_UPDATE_COMMITMENT, - /// native_asset_id_suffix, native_asset_id_prefix, fee_amount, expiration_block_num + /// fee_faucet_id_suffix, fee_faucet_id_prefix, fee_amount, expiration_block_num /// ] /// ``` /// @@ -224,7 +224,7 @@ impl TransactionKernel { outputs.extend(account_update_commitment); outputs.push(fee.faucet_id().suffix()); outputs.push(fee.faucet_id().prefix().as_felt()); - outputs.push(Felt::try_from(fee.amount()).expect("amount should fit into 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") @@ -268,12 +268,12 @@ impl TransactionKernel { .get_word(TransactionOutputs::ACCOUNT_UPDATE_COMMITMENT_WORD_IDX) .expect("account_update_commitment (second word) missing"); - let native_asset_id_prefix = stack - .get_element(TransactionOutputs::NATIVE_ASSET_ID_PREFIX_ELEMENT_IDX) - .expect("native_asset_id_prefix missing"); - let native_asset_id_suffix = stack - .get_element(TransactionOutputs::NATIVE_ASSET_ID_SUFFIX_ELEMENT_IDX) - .expect("native_asset_id_suffix 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"); @@ -300,10 +300,10 @@ impl TransactionKernel { )); } - let native_asset_id = - AccountId::try_from_elements(native_asset_id_suffix, native_asset_id_prefix) - .expect("native asset ID should be validated by the tx kernel"); - let fee = FungibleAsset::new(native_asset_id, fee_amount.as_canonical_u64()) + 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)) 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 1c3f3a6161..f07e79bd71 100644 --- a/crates/miden-protocol/src/transaction/kernel/tx_event_id.rs +++ b/crates/miden-protocol/src/transaction/kernel/tx_event_id.rs @@ -48,7 +48,7 @@ pub enum TransactionEventId { NoteBeforeAddAsset = NOTE_BEFORE_ADD_ASSET_ID, NoteAfterAddAsset = NOTE_AFTER_ADD_ASSET_ID, - NoteBeforeSetAttachment = NOTE_BEFORE_SET_ATTACHMENT_ID, + NoteBeforeAddAttachment = NOTE_BEFORE_ADD_ATTACHMENT_ID, AuthRequest = AUTH_REQUEST_ID, @@ -113,7 +113,7 @@ impl TransactionEventId { Self::NoteAfterCreated => &NOTE_AFTER_CREATED_NAME, Self::NoteBeforeAddAsset => &NOTE_BEFORE_ADD_ASSET_NAME, Self::NoteAfterAddAsset => &NOTE_AFTER_ADD_ASSET_NAME, - Self::NoteBeforeSetAttachment => &NOTE_BEFORE_SET_ATTACHMENT_NAME, + Self::NoteBeforeAddAttachment => &NOTE_BEFORE_ADD_ATTACHMENT_NAME, Self::AuthRequest => &AUTH_REQUEST_NAME, Self::PrologueStart => &PROLOGUE_START_NAME, Self::PrologueEnd => &PROLOGUE_END_NAME, @@ -194,7 +194,7 @@ impl TryFrom for TransactionEventId { NOTE_BEFORE_ADD_ASSET_ID => Ok(TransactionEventId::NoteBeforeAddAsset), NOTE_AFTER_ADD_ASSET_ID => Ok(TransactionEventId::NoteAfterAddAsset), - NOTE_BEFORE_SET_ATTACHMENT_ID => Ok(TransactionEventId::NoteBeforeSetAttachment), + NOTE_BEFORE_ADD_ATTACHMENT_ID => Ok(TransactionEventId::NoteBeforeAddAttachment), AUTH_REQUEST_ID => Ok(TransactionEventId::AuthRequest), diff --git a/crates/miden-protocol/src/transaction/mod.rs b/crates/miden-protocol/src/transaction/mod.rs index 977155e755..f52042a805 100644 --- a/crates/miden-protocol/src/transaction/mod.rs +++ b/crates/miden-protocol/src/transaction/mod.rs @@ -23,7 +23,7 @@ pub use outputs::{ OutputNote, OutputNoteCollection, OutputNotes, - PrivateNoteHeader, + PrivateOutputNote, PublicOutputNote, RawOutputNote, RawOutputNotes, diff --git a/crates/miden-protocol/src/transaction/outputs/mod.rs b/crates/miden-protocol/src/transaction/outputs/mod.rs index a784237690..de572055e0 100644 --- a/crates/miden-protocol/src/transaction/outputs/mod.rs +++ b/crates/miden-protocol/src/transaction/outputs/mod.rs @@ -17,7 +17,7 @@ pub use notes::{ OutputNote, OutputNoteCollection, OutputNotes, - PrivateNoteHeader, + PrivateOutputNote, PublicOutputNote, RawOutputNote, RawOutputNotes, @@ -56,13 +56,13 @@ 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 faucet that issues the native asset - /// is stored on the output stack. - pub const NATIVE_ASSET_ID_SUFFIX_ELEMENT_IDX: usize = 8; + /// 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 faucet that issues the native asset - /// is stored on the output stack. - pub const NATIVE_ASSET_ID_PREFIX_ELEMENT_IDX: usize = 9; + /// 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; diff --git a/crates/miden-protocol/src/transaction/outputs/notes.rs b/crates/miden-protocol/src/transaction/outputs/notes.rs index 68f9490ea5..dbec4f03c9 100644 --- a/crates/miden-protocol/src/transaction/outputs/notes.rs +++ b/crates/miden-protocol/src/transaction/outputs/notes.rs @@ -8,12 +8,13 @@ use crate::errors::{OutputNoteError, TransactionOutputError}; use crate::note::{ Note, NoteAssets, + NoteAttachments, + NoteDetailsCommitment, NoteHeader, NoteId, NoteMetadata, NoteRecipient, PartialNote, - compute_note_commitment, }; use crate::utils::serde::{ ByteReader, @@ -76,8 +77,8 @@ where /// Returns the commitment to the output notes. /// - /// The commitment is computed as a sequential hash of (note ID, metadata) tuples for the notes - /// created in a transaction. + /// The commitment is computed as a sequential hash of `(note_details_commitment, + /// metadata_commitment)` tuples for the notes created in a transaction. pub fn commitment(&self) -> Word { self.commitment } @@ -111,9 +112,9 @@ where /// Computes a commitment to output notes. /// /// - For an empty list, [`Word::empty`] is returned. - /// - For a non-empty list of notes, this is a sequential hash of (note_id, metadata_commitment) - /// tuples for the notes created in a transaction, where `metadata_commitment` is the return - /// value of [`NoteMetadata::to_commitment`]. + /// - For a non-empty list of notes, this is a sequential hash of `(note_details_commitment, + /// metadata_commitment)` tuples for the notes created in a transaction, where + /// `metadata_commitment` is the return value of [`NoteMetadata::to_commitment`]. pub(crate) fn compute_commitment<'header>( notes: impl ExactSizeIterator, ) -> Word { @@ -123,7 +124,7 @@ where let mut elements: Vec = Vec::with_capacity(notes.len() * 8); for note_header in notes { - elements.extend_from_slice(note_header.id().as_elements()); + elements.extend_from_slice(note_header.details_commitment().as_elements()); elements.extend_from_slice(note_header.metadata().to_commitment().as_elements()); } @@ -205,7 +206,7 @@ impl RawOutputNote { /// Unique note identifier. /// - /// This value is both an unique identifier and a commitment to the note. + /// This value commits to the note details and metadata. pub fn id(&self) -> NoteId { match self { Self::Full(note) => note.id(), @@ -213,6 +214,14 @@ impl RawOutputNote { } } + /// Returns the commitment to the note's details, excluding metadata. + pub fn details_commitment(&self) -> NoteDetailsCommitment { + match self { + Self::Full(note) => note.details_commitment(), + Self::Partial(note) => note.details_commitment(), + } + } + /// Returns the recipient of the processed [`Full`](RawOutputNote::Full) output note, [`None`] /// if the note type is not [`Full`](RawOutputNote::Full). /// @@ -254,15 +263,15 @@ impl RawOutputNote { pub fn into_output_note(self) -> Result { match self { Self::Full(note) if note.metadata().is_private() => { - let note_id = note.id(); - let (_, metadata, _) = note.into_parts(); - let note_header = NoteHeader::new(note_id, metadata); - Ok(OutputNote::Private(PrivateNoteHeader::new(note_header)?)) + let details_commitment = note.details_commitment(); + let (_, metadata, _, attachments) = note.into_parts(); + let note_header = NoteHeader::new(details_commitment, metadata); + Ok(OutputNote::Private(PrivateOutputNote::new(note_header, attachments)?)) }, Self::Full(note) => Ok(OutputNote::Public(PublicOutputNote::new(note)?)), Self::Partial(note) => { - let (_, header) = note.into_parts(); - Ok(OutputNote::Private(PrivateNoteHeader::new(header)?)) + let (_, header, attachments) = note.into_parts(); + Ok(OutputNote::Private(PrivateOutputNote::new(header, attachments)?)) }, } } @@ -275,11 +284,12 @@ impl RawOutputNote { } } - /// Returns a commitment to the note and its metadata. - /// - /// > hash(NOTE_ID || NOTE_METADATA_COMMITMENT) - pub fn commitment(&self) -> Word { - compute_note_commitment(self.id(), self.metadata()) + /// Returns a reference to the note's attachments. + pub fn attachments(&self) -> &NoteAttachments { + match self { + Self::Full(note) => note.attachments(), + Self::Partial(note) => note.attachments(), + } } } @@ -351,8 +361,8 @@ pub type OutputNotes = OutputNoteCollection; pub enum OutputNote { /// A public note with full details, size-validated. Public(PublicOutputNote), - /// A note private header (for private notes). - Private(PrivateNoteHeader), + /// A private note with a header and attachments. + Private(PrivateOutputNote), } impl OutputNote { @@ -361,7 +371,7 @@ impl OutputNote { /// Unique note identifier. /// - /// This value is both an unique identifier and a commitment to the note. + /// This value commits to the note details and metadata. pub fn id(&self) -> NoteId { match self { Self::Public(note) => note.id(), @@ -369,11 +379,11 @@ impl OutputNote { } } - /// Note's metadata. - pub fn metadata(&self) -> &NoteMetadata { + /// Returns the commitment to the note's details, excluding metadata. + pub fn details_commitment(&self) -> NoteDetailsCommitment { match self { - Self::Public(note) => note.metadata(), - Self::Private(header) => header.metadata(), + Self::Public(note) => note.details_commitment(), + Self::Private(header) => header.details_commitment(), } } @@ -387,11 +397,9 @@ impl OutputNote { } } - /// Returns a commitment to the note and its metadata. - /// - /// > hash(NOTE_ID || NOTE_METADATA_COMMITMENT) - pub fn to_commitment(&self) -> Word { - compute_note_commitment(self.id(), self.metadata()) + /// Returns the note's metadata. + pub fn metadata(&self) -> &NoteMetadata { + <&NoteHeader>::from(self).metadata() } /// Returns the recipient of the public note, if this is a public note. @@ -407,17 +415,17 @@ impl OutputNote { // ------------------------------------------------------------------------------------------------ impl<'note> From<&'note OutputNote> for &'note NoteHeader { - fn from(value: &'note OutputNote) -> Self { - match value { - OutputNote::Public(note) => note.header(), - OutputNote::Private(header) => &header.0, + fn from(note: &'note OutputNote) -> Self { + match note { + OutputNote::Public(public_note) => public_note.header(), + OutputNote::Private(private_note) => private_note.header(), } } } impl From<&OutputNote> for NoteId { - fn from(value: &OutputNote) -> Self { - value.id() + fn from(note: &OutputNote) -> Self { + note.id() } } @@ -451,7 +459,7 @@ impl Deserializable for OutputNote { fn read_from(source: &mut R) -> Result { match source.read_u8()? { Self::PUBLIC => Ok(Self::Public(PublicOutputNote::read_from(source)?)), - Self::PRIVATE => Ok(Self::Private(PrivateNoteHeader::read_from(source)?)), + Self::PRIVATE => Ok(Self::Private(PrivateOutputNote::read_from(source)?)), v => Err(DeserializationError::InvalidValue(format!( "invalid proven output note type: {v}" ))), @@ -503,6 +511,11 @@ impl PublicOutputNote { self.0.id() } + /// Returns the commitment to the note's details, excluding metadata. + pub fn details_commitment(&self) -> NoteDetailsCommitment { + self.0.details_commitment() + } + /// Returns the note's metadata. pub fn metadata(&self) -> &NoteMetadata { self.0.metadata() @@ -554,75 +567,76 @@ impl Deserializable for PublicOutputNote { // PRIVATE NOTE HEADER // ================================================================================================ -/// A [NoteHeader] of a private note. +/// A [`NoteHeader`] of a private note, along with its public attachments. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct PrivateNoteHeader(NoteHeader); +pub struct PrivateOutputNote { + header: NoteHeader, + attachments: NoteAttachments, +} -impl PrivateNoteHeader { - /// Creates a new [`PrivateNoteHeader`] from the given note header. +impl PrivateOutputNote { + /// Creates a new [`PrivateOutputNote`] from the given note header and attachments. /// /// # Errors /// Returns an error if: /// - The provided header is for a public note. - pub fn new(header: NoteHeader) -> Result { - if !header.metadata().is_private() { + pub fn new(header: NoteHeader, attachments: NoteAttachments) -> Result { + if header.metadata().is_public() { return Err(OutputNoteError::NoteIsPublic(header.id())); } - Ok(Self(header)) + Ok(Self { header, attachments }) } /// Returns the note's identifier. /// - /// The [NoteId] value is both an unique identifier and a commitment to the note. + /// The [NoteId] commits to both note details and metadata. pub fn id(&self) -> NoteId { - self.0.id() + self.header.id() } /// Returns the note's metadata. pub fn metadata(&self) -> &NoteMetadata { - self.0.metadata() + self.header.metadata() } - /// Consumes self and returns the note header's metadata. - pub fn into_metadata(self) -> NoteMetadata { - self.0.into_metadata() + /// Returns the note's attachments. + pub fn attachments(&self) -> &NoteAttachments { + &self.attachments } - /// Returns a commitment to the note and its metadata. - /// - /// > hash(NOTE_ID || NOTE_METADATA_COMMITMENT) - /// - /// This value is used primarily for authenticating notes consumed when they are consumed - /// in a transaction. - pub fn commitment(&self) -> Word { - self.0.to_commitment() + /// Returns the commitment to the note's details, excluding metadata. + pub fn details_commitment(&self) -> NoteDetailsCommitment { + self.header.details_commitment() } /// Returns a reference to the underlying note header. - pub fn as_header(&self) -> &NoteHeader { - &self.0 + pub fn header(&self) -> &NoteHeader { + &self.header } /// Consumes this wrapper and returns the underlying note header. pub fn into_header(self) -> NoteHeader { - self.0 + self.header } } -impl Serializable for PrivateNoteHeader { +impl Serializable for PrivateOutputNote { fn write_into(&self, target: &mut W) { - self.0.write_into(target); + self.header.write_into(target); + self.attachments.write_into(target); } fn get_size_hint(&self) -> usize { - self.0.get_size_hint() + self.header.get_size_hint() + self.attachments.get_size_hint() } } -impl Deserializable for PrivateNoteHeader { +impl Deserializable for PrivateOutputNote { fn read_from(source: &mut R) -> Result { let header = NoteHeader::read_from(source)?; - Self::new(header).map_err(|err| DeserializationError::InvalidValue(err.to_string())) + let attachments = NoteAttachments::read_from(source)?; + Self::new(header, attachments) + .map_err(|err| DeserializationError::InvalidValue(err.to_string())) } } diff --git a/crates/miden-protocol/src/transaction/outputs/tests.rs b/crates/miden-protocol/src/transaction/outputs/tests.rs index 7e5834f5f6..f2ce0d055f 100644 --- a/crates/miden-protocol/src/transaction/outputs/tests.rs +++ b/crates/miden-protocol/src/transaction/outputs/tests.rs @@ -11,12 +11,12 @@ use crate::errors::{OutputNoteError, TransactionOutputError}; use crate::note::{ Note, NoteAssets, - NoteMetadata, NoteRecipient, NoteScript, NoteStorage, NoteTag, NoteType, + PartialNoteMetadata, }; use crate::testing::account_id::{ ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET, @@ -57,11 +57,11 @@ fn output_note_size_hint_matches_serialized_length() -> anyhow::Result<()> { let assets = NoteAssets::new(vec![asset_1, asset_2])?; // Build metadata similarly to how mock notes are constructed. - let metadata = NoteMetadata::new(sender_id, NoteType::Private) + let metadata = PartialNoteMetadata::new(sender_id, NoteType::Private) .with_tag(NoteTag::with_account_target(sender_id)); // Build storage with at least two values. - let storage = NoteStorage::new(vec![Felt::new(1), Felt::new(2)])?; + let storage = NoteStorage::new(vec![Felt::ONE, Felt::new_unchecked(2)])?; let serial_num = Word::empty(); let script = NoteScript::mock(); @@ -108,7 +108,7 @@ fn oversized_public_note_triggers_size_limit_error() -> anyhow::Result<()> { let asset = FungibleAsset::new(faucet_id, 100)?.into(); let assets = NoteAssets::new(vec![asset])?; - let metadata = NoteMetadata::new(sender_id, NoteType::Public) + let metadata = PartialNoteMetadata::new(sender_id, NoteType::Public) .with_tag(NoteTag::with_account_target(sender_id)); let recipient = NoteRecipient::new(serial_num, script, storage); diff --git a/crates/miden-protocol/src/transaction/partial_blockchain.rs b/crates/miden-protocol/src/transaction/partial_blockchain.rs index bb424c576b..5181d28e4e 100644 --- a/crates/miden-protocol/src/transaction/partial_blockchain.rs +++ b/crates/miden-protocol/src/transaction/partial_blockchain.rs @@ -1,4 +1,5 @@ use alloc::collections::BTreeMap; +use alloc::string::ToString; use alloc::vec::Vec; use core::ops::RangeTo; @@ -182,11 +183,16 @@ impl PartialBlockchain { /// retrieval. /// /// # Panics - /// Panics if the `block_header.block_num` is not equal to the current chain length (i.e., the - /// provided block header is not the next block in the chain). + /// + /// Panics if: + /// - The `block_header.block_num` is not equal to the current chain length (i.e., the provided + /// block header is not the next block in the chain). + /// - The the chain length exceeds [miden_crypto::merkle::mmr::Forest::MAX_LEAVES]. pub fn add_block(&mut self, block_header: &BlockHeader, track: bool) { assert_eq!(block_header.block_num(), self.chain_length()); - self.mmr.add(block_header.commitment(), track); + self.mmr + .add(block_header.commitment(), track) + .expect("partial mmr leaf count exceeds forest leaf bound"); if track { self.blocks.insert(block_header.block_num(), block_header.clone()); } @@ -264,7 +270,8 @@ impl Deserializable for PartialBlockchain { ) -> Result { let mmr = PartialMmr::read_from(source)?; let blocks = BTreeMap::::read_from(source)?; - Ok(Self { mmr, blocks }) + Self::new(mmr, blocks.into_values()) + .map_err(|err| miden_crypto::utils::DeserializationError::InvalidValue(err.to_string())) } } @@ -288,11 +295,11 @@ mod tests { use crate::Word; use crate::alloc::vec::Vec; use crate::block::{BlockHeader, BlockNumber, FeeParameters}; - use crate::crypto::dsa::ecdsa_k256_keccak::SecretKey; + use crate::crypto::dsa::ecdsa_k256_keccak::SigningKey; use crate::crypto::merkle::mmr::{Mmr, PartialMmr}; use crate::errors::PartialBlockchainError; use crate::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET; - use crate::utils::serde::{Deserializable, Serializable}; + use crate::utils::serde::{Deserializable, DeserializationError, Serializable}; #[test] fn test_partial_blockchain_add() { @@ -300,7 +307,8 @@ mod tests { let mut mmr = Mmr::default(); for i in 0..3 { let block_header = int_to_block_header(i); - mmr.add(block_header.commitment()); + mmr.add(block_header.commitment()) + .expect("mmr leaf count exceeds forest leaf bound"); } let partial_mmr: PartialMmr = mmr.peaks().into(); let mut partial_blockchain = PartialBlockchain::new(partial_mmr, Vec::new()).unwrap(); @@ -308,7 +316,8 @@ mod tests { // add a new block to the partial blockchain, this reduces the number of peaks to 1 let block_num = 3; let block_header = int_to_block_header(block_num); - mmr.add(block_header.commitment()); + mmr.add(block_header.commitment()) + .expect("mmr leaf count exceeds forest leaf bound"); partial_blockchain.add_block(&block_header, true); assert_eq!( @@ -319,7 +328,8 @@ mod tests { // add one more block to the partial blockchain, the number of peaks is again 2 let block_num = 4; let block_header = int_to_block_header(block_num); - mmr.add(block_header.commitment()); + mmr.add(block_header.commitment()) + .expect("mmr leaf count exceeds forest leaf bound"); partial_blockchain.add_block(&block_header, true); assert_eq!( @@ -330,7 +340,8 @@ mod tests { // add one more block to the partial blockchain, the number of peaks is still 2 let block_num = 5; let block_header = int_to_block_header(block_num); - mmr.add(block_header.commitment()); + mmr.add(block_header.commitment()) + .expect("mmr leaf count exceeds forest leaf bound"); partial_blockchain.add_block(&block_header, true); assert_eq!( @@ -346,9 +357,9 @@ mod tests { let block_header2 = int_to_block_header(2); let mut mmr = Mmr::default(); - mmr.add(block_header0.commitment()); - mmr.add(block_header1.commitment()); - mmr.add(block_header2.commitment()); + mmr.add(block_header0.commitment()).unwrap(); + mmr.add(block_header1.commitment()).unwrap(); + mmr.add(block_header2.commitment()).unwrap(); let mut partial_mmr = PartialMmr::from_peaks(mmr.peaks()); for i in 0..3 { @@ -378,6 +389,39 @@ mod tests { ) } + #[test] + fn partial_blockchain_deserialization_on_invalid_header_fails() { + let block_header0 = int_to_block_header(0); + let block_header1 = int_to_block_header(1); + let block_header2 = int_to_block_header(2); + + let mut mmr = Mmr::default(); + mmr.add(block_header0.commitment()).unwrap(); + mmr.add(block_header1.commitment()).unwrap(); + mmr.add(block_header2.commitment()).unwrap(); + + let mut partial_mmr = PartialMmr::from_peaks(mmr.peaks()); + for i in 0..3 { + partial_mmr + .track(i, mmr.get(i).unwrap(), mmr.open(i).unwrap().merkle_path()) + .unwrap(); + } + + let fake_block_header2 = BlockHeader::mock(2, None, None, &[], Word::empty()); + + assert_ne!(block_header2.commitment(), fake_block_header2.commitment()); + + let forged_partial_blockchain = PartialBlockchain::new_unchecked( + partial_mmr, + vec![block_header0, block_header1, fake_block_header2], + ) + .unwrap(); + let bytes = forged_partial_blockchain.to_bytes(); + + let err = PartialBlockchain::read_from_bytes(&bytes).unwrap_err(); + assert_matches!(err, DeserializationError::InvalidValue(_)); + } + #[test] fn partial_blockchain_new_on_block_number_exceeding_chain_length_fails() { let block_header0 = int_to_block_header(0); @@ -398,8 +442,8 @@ mod tests { let block_header1 = int_to_block_header(1); let mut mmr = Mmr::default(); - mmr.add(block_header0.commitment()); - mmr.add(block_header1.commitment()); + mmr.add(block_header0.commitment()).unwrap(); + mmr.add(block_header1.commitment()).unwrap(); let mut partial_mmr = PartialMmr::from_peaks(mmr.peaks()); partial_mmr @@ -420,7 +464,7 @@ mod tests { let mut mmr = Mmr::default(); for i in 0..3 { let block_header = int_to_block_header(i); - mmr.add(block_header.commitment()); + mmr.add(block_header.commitment()).unwrap(); } let partial_mmr: PartialMmr = mmr.peaks().into(); let partial_blockchain = PartialBlockchain::new(partial_mmr, Vec::new()).unwrap(); @@ -433,10 +477,9 @@ mod tests { fn int_to_block_header(block_num: impl Into) -> BlockHeader { let fee_parameters = - FeeParameters::new(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap(), 500) - .expect("native asset ID should be a fungible faucet ID"); + FeeParameters::new(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap(), 500); let mut rng = ChaCha20Rng::from_seed([0u8; 32]); - let validator_key = SecretKey::with_rng(&mut rng).public_key(); + let validator_key = SigningKey::with_rng(&mut rng).public_key(); BlockHeader::new( 0, @@ -463,7 +506,7 @@ mod tests { let mut headers = Vec::new(); for i in 0..total_blocks { let h = int_to_block_header(i); - full_mmr.add(h.commitment()); + full_mmr.add(h.commitment()).unwrap(); headers.push(h); } let mut partial_mmr: PartialMmr = full_mmr.peaks().into(); diff --git a/crates/miden-protocol/src/transaction/proven_tx.rs b/crates/miden-protocol/src/transaction/proven_tx.rs index da89162b3e..2af128182c 100644 --- a/crates/miden-protocol/src/transaction/proven_tx.rs +++ b/crates/miden-protocol/src/transaction/proven_tx.rs @@ -8,7 +8,7 @@ use crate::account::delta::AccountUpdateDetails; use crate::asset::FungibleAsset; use crate::block::BlockNumber; use crate::errors::ProvenTransactionError; -use crate::note::NoteHeader; +use crate::note::{NoteHeader, NoteId}; use crate::transaction::{ AccountId, InputNotes, @@ -546,7 +546,7 @@ impl From<&InputNote> for InputNoteCommitment { }, InputNote::Unauthenticated { note } => Self { nullifier: note.nullifier(), - header: Some(note.header().clone()), + header: Some(*note.header()), }, } } @@ -563,8 +563,8 @@ impl ToInputNoteCommitments for InputNoteCommitment { self.nullifier } - fn note_commitment(&self) -> Option { - self.header.as_ref().map(NoteHeader::to_commitment) + fn note_id(&self) -> Option { + self.header.as_ref().map(NoteHeader::id) } } @@ -583,7 +583,7 @@ impl Deserializable for InputNoteCommitment { let nullifier = Nullifier::read_from(source)?; let header = >::read_from(source)?; - Ok(Self { nullifier, header }) + Ok(Self::from_parts_unchecked(nullifier, header)) } } @@ -607,7 +607,6 @@ mod tests { AccountId, AccountIdVersion, AccountStorageDelta, - AccountStorageMode, AccountType, AccountVaultDelta, StorageMapDelta, @@ -648,8 +647,7 @@ mod tests { fn account_update_size_limit_not_exceeded() -> anyhow::Result<()> { // A small account's delta does not exceed the limit. let account = Account::builder([9; 32]) - .account_type(AccountType::RegularAccountUpdatableCode) - .storage_mode(AccountStorageMode::Public) + .account_type(AccountType::Public) .with_auth_component(NoopAuthComponent) .with_component(AddComponent) .build_existing()?; @@ -705,12 +703,8 @@ mod tests { #[test] fn test_proven_tx_serde_roundtrip() -> anyhow::Result<()> { - let account_id = AccountId::dummy( - [1; 15], - AccountIdVersion::Version0, - AccountType::FungibleFaucet, - AccountStorageMode::Private, - ); + let account_id = + AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Private); let initial_account_commitment = [2; 32].try_into().expect("failed to create initial account commitment"); let final_account_commitment = diff --git a/crates/miden-protocol/src/transaction/transaction_id.rs b/crates/miden-protocol/src/transaction/transaction_id.rs index ddf14c7532..4313e6c465 100644 --- a/crates/miden-protocol/src/transaction/transaction_id.rs +++ b/crates/miden-protocol/src/transaction/transaction_id.rs @@ -1,7 +1,7 @@ use alloc::string::String; use core::fmt::{Debug, Display}; -use miden_protocol_macros::WordWrapper; +use miden_crypto_derive::WordWrapper; use super::{Felt, Hasher, ProvenTransaction, WORD_SIZE, Word, ZERO}; use crate::asset::{Asset, FungibleAsset}; diff --git a/crates/miden-protocol/src/transaction/tx_args.rs b/crates/miden-protocol/src/transaction/tx_args.rs index 1e6657bfaf..c18b2eb996 100644 --- a/crates/miden-protocol/src/transaction/tx_args.rs +++ b/crates/miden-protocol/src/transaction/tx_args.rs @@ -4,9 +4,11 @@ use alloc::vec::Vec; use miden_core::mast::MastNodeExt; use miden_crypto::merkle::InnerNodeInfo; +use miden_mast_package::Package; use super::{Felt, Hasher, Word}; use crate::account::auth::{PublicKeyCommitment, Signature}; +use crate::errors::TransactionScriptError; use crate::note::{NoteId, NoteRecipient}; use crate::utils::serde::{ ByteReader, @@ -167,15 +169,16 @@ impl TransactionArgs { let script_encoded: Vec = script.into(); // Build the advice map entries + let script_root: Word = script.root().into(); let sn_hash = Hasher::merge(&[note_recipient.serial_num(), Word::empty()]); - let sn_script_hash = Hasher::merge(&[sn_hash, script.root()]); + let sn_script_hash = Hasher::merge(&[sn_hash, script_root]); let new_elements = vec![ (sn_hash, concat_words(note_recipient.serial_num(), Word::empty())), - (sn_script_hash, concat_words(sn_hash, script.root())), + (sn_script_hash, concat_words(sn_hash, script_root)), (note_recipient.digest(), concat_words(sn_script_hash, storage.commitment())), (storage.commitment(), storage.to_elements()), - (script.root(), script_encoded), + (script_root, script_encoded), ]; self.advice_inputs.extend(AdviceInputs::default().with_map(new_elements)); @@ -308,6 +311,20 @@ impl TransactionScript { Self { mast, entrypoint } } + /// Creates a [TransactionScript] from a [`Package`]. + /// + /// The package must be an executable (i.e., its target type must be + /// [`TargetType::Executable`](miden_mast_package::TargetType::Executable)). + /// + /// # Errors + /// Returns an error if the package cannot be converted to an executable program. + pub fn from_package(package: &Package) -> Result { + let program = + package.try_into_program().map_err(TransactionScriptError::PackageNotProgram)?; + + Ok(TransactionScript::new(program)) + } + // PUBLIC ACCESSORS // -------------------------------------------------------------------------------------------- @@ -395,7 +412,7 @@ mod tests { // Non-empty advice map should add entries let key = Word::from([1u32, 2, 3, 4]); - let value = vec![Felt::new(42), Felt::new(43)]; + let value = vec![Felt::new_unchecked(42), Felt::new_unchecked(43)]; let mut advice_map = AdviceMap::default(); advice_map.insert(key, value.clone()); diff --git a/crates/miden-protocol/src/transaction/tx_header.rs b/crates/miden-protocol/src/transaction/tx_header.rs index 23a2721e88..2a9a0c1a65 100644 --- a/crates/miden-protocol/src/transaction/tx_header.rs +++ b/crates/miden-protocol/src/transaction/tx_header.rs @@ -188,7 +188,7 @@ impl From<&ExecutedTransaction> for TransactionHeader { tx.initial_account().initial_commitment(), tx.final_account().to_commitment(), tx.input_notes().to_commitments(), - tx.output_notes().iter().map(|n| n.header().clone()).collect(), + tx.output_notes().iter().map(|n| *n.header()).collect(), tx.fee(), ) } diff --git a/crates/miden-protocol/src/utils/mod.rs b/crates/miden-protocol/src/utils/mod.rs new file mode 100644 index 0000000000..f5f012c425 --- /dev/null +++ b/crates/miden-protocol/src/utils/mod.rs @@ -0,0 +1,19 @@ +pub use miden_core::utils::*; +pub use miden_crypto::utils::{HexParseError, bytes_to_hex_string, hex_to_bytes}; +pub use miden_utils_sync as sync; + +pub mod serde { + pub use miden_crypto::utils::{ + BudgetedReader, + ByteReader, + ByteWriter, + Deserializable, + DeserializationError, + Serializable, + SliceReader, + }; +} + +pub mod strings; + +pub(crate) use strings::ShortCapitalString; diff --git a/crates/miden-protocol/src/utils/strings.rs b/crates/miden-protocol/src/utils/strings.rs new file mode 100644 index 0000000000..f903d0add7 --- /dev/null +++ b/crates/miden-protocol/src/utils/strings.rs @@ -0,0 +1,228 @@ +use alloc::fmt; +use alloc::string::String; + +use crate::Felt; +use crate::errors::ShortCapitalStringError; + +/// A short string of uppercase ASCII (and optionally underscores) encoded into a [`Felt`] with a +/// configurable alphabet. +/// +/// Use [`Self::from_ascii_uppercase`] or [`Self::from_ascii_uppercase_and_underscore`] to construct +/// a validated value (same rules as [`crate::asset::TokenSymbol`] and +/// [`crate::account::RoleSymbol`]). +/// +/// The text is stored as a [`String`] and can be converted to a [`Felt`] encoding via +/// [`as_element()`](Self::as_element), and decoded back via +/// [`try_from_encoded_felt()`](Self::try_from_encoded_felt). +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct ShortCapitalString(String); + +impl ShortCapitalString { + /// Maximum allowed string length. + pub const MAX_LENGTH: usize = 12; + + /// Constructs a value from up to 12 uppercase ASCII Latin letters (`A`–`Z`). + /// + /// # Errors + /// Returns an error if: + /// - The number of characters is less than 1 or greater than 12. + /// - The string contains a character that is not uppercase ASCII. + pub fn from_ascii_uppercase( + string: impl Into, + ) -> Result { + let string = string.into(); + let char_count = string.chars().count(); + if char_count == 0 || char_count > Self::MAX_LENGTH { + return Err(ShortCapitalStringError::InvalidLength(char_count)); + } + for character in string.chars() { + if !character.is_ascii_uppercase() { + return Err(ShortCapitalStringError::InvalidCharacter); + } + } + Ok(Self(string)) + } + + /// Constructs a value from up to 12 characters from `A`–`Z` and `_`. + /// + /// # Errors + /// Returns an error if: + /// - The number of characters is less than 1 or greater than 12. + /// - The string contains a character outside `A`–`Z` and `_`. + pub fn from_ascii_uppercase_and_underscore( + string: impl Into, + ) -> Result { + let string = string.into(); + let char_count = string.chars().count(); + if char_count == 0 || char_count > Self::MAX_LENGTH { + return Err(ShortCapitalStringError::InvalidLength(char_count)); + } + for character in string.chars() { + if !character.is_ascii_uppercase() && character != '_' { + return Err(ShortCapitalStringError::InvalidCharacter); + } + } + Ok(Self(string)) + } + + /// Returns the [`Felt`] encoding of this string. + /// + /// The alphabet used in the encoding process is provided by the `alphabet` argument. + /// + /// **Contract:** `alphabet` must contain **ASCII characters only**. Then each character + /// occupies one UTF-8 byte, so the radix is [`str::len`] and matches the number of Unicode + /// scalars. + /// + /// The encoding is performed by multiplying the intermediate encoded value by the length of + /// the used alphabet and adding the relative index of each character. At the end of the + /// encoding process, the character length of the initial string is added to the encoded value. + /// + /// # Errors + /// Returns an error if: + /// - The string contains a character that is not part of the provided alphabet. + pub fn as_element(&self, alphabet: &str) -> Result { + debug_assert!( + alphabet.is_ascii(), + "ShortCapitalString::as_element: alphabet must be ASCII-only" + ); + let alphabet_len = alphabet.len() as u64; + let mut encoded_value: u64 = 0; + + for character in self.0.chars() { + let digit = alphabet + .chars() + .position(|c| c == character) + .map(|pos| pos as u64) + .ok_or(ShortCapitalStringError::InvalidCharacter)?; + + encoded_value = encoded_value * alphabet_len + digit; + } + + // Append the original length so decoding is unambiguous. + let char_len = self.0.chars().count() as u64; + encoded_value = encoded_value * alphabet_len + char_len; + Ok(Felt::new_unchecked(encoded_value)) + } + + /// Decodes an encoded [`Felt`] value into a [`ShortCapitalString`]. + /// + /// `encoded_string` is the field element that carries the short-string encoding (as produced by + /// [`as_element`](Self::as_element)). + /// + /// The alphabet used in the decoding process is provided by the `alphabet` argument. The same + /// **ASCII-only** contract as [`as_element`](Self::as_element) applies; radix is [`str::len`]. + /// + /// The decoding is performed by reading the encoded length from the least-significant digit, + /// then repeatedly taking modulus by alphabet length to recover each character index. + /// + /// # Errors + /// Returns an error if: + /// - The encoded value is outside of the provided `min_encoded_value..=max_encoded_value`. + /// - The decoded length is not between 1 and 12. + /// - Decoding leaves non-zero trailing data. + pub fn try_from_encoded_felt( + encoded_string: Felt, + alphabet: &str, + min_encoded_value: u64, + max_encoded_value: u64, + ) -> Result { + let encoded_value = encoded_string.as_canonical_u64(); + if encoded_value < min_encoded_value { + return Err(ShortCapitalStringError::ValueTooSmall(encoded_value)); + } + if encoded_value > max_encoded_value { + return Err(ShortCapitalStringError::ValueTooLarge(encoded_value)); + } + + debug_assert!( + alphabet.is_ascii(), + "ShortCapitalString::try_from_encoded_felt: alphabet must be ASCII-only" + ); + let alphabet_len = alphabet.len() as u64; + let mut remaining_value = encoded_value; + let string_len = (remaining_value % alphabet_len) as usize; + if string_len == 0 || string_len > Self::MAX_LENGTH { + return Err(ShortCapitalStringError::InvalidLength(string_len)); + } + remaining_value /= alphabet_len; + + let mut decoded = String::with_capacity(string_len); + for _ in 0..string_len { + let digit = (remaining_value % alphabet_len) as usize; + let character = + alphabet.chars().nth(digit).ok_or(ShortCapitalStringError::InvalidCharacter)?; + decoded.insert(0, character); + remaining_value /= alphabet_len; + } + + if remaining_value != 0 { + return Err(ShortCapitalStringError::DataNotFullyDecoded); + } + + Ok(Self(decoded)) + } +} + +impl fmt::Display for ShortCapitalString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +#[cfg(test)] +mod tests { + use alloc::string::{String, ToString}; + + use assert_matches::assert_matches; + + use super::{Felt, ShortCapitalString}; + use crate::errors::ShortCapitalStringError; + + #[test] + fn short_capital_string_encode_decode_roundtrip() { + let short_string = ShortCapitalString::from_ascii_uppercase("MIDEN").unwrap(); + let encoded = short_string.as_element("ABCDEFGHIJKLMNOPQRSTUVWXYZ").unwrap(); + let decoded = ShortCapitalString::try_from_encoded_felt( + encoded, + "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + 1, + 2481152873203736562, + ) + .unwrap(); + assert_eq!(decoded.to_string(), "MIDEN"); + + let name = String::from("MIDEN"); + let from_name = ShortCapitalString::from_ascii_uppercase(name).unwrap(); + assert_eq!(from_name.to_string(), "MIDEN"); + } + + #[test] + fn short_capital_string_rejects_invalid_values() { + assert_matches!( + ShortCapitalString::from_ascii_uppercase("").unwrap_err(), + ShortCapitalStringError::InvalidLength(0) + ); + assert_matches!( + ShortCapitalString::from_ascii_uppercase("ABCDEFGHIJKLM").unwrap_err(), + ShortCapitalStringError::InvalidLength(13) + ); + assert_matches!( + ShortCapitalString::from_ascii_uppercase("A_B").unwrap_err(), + ShortCapitalStringError::InvalidCharacter + ); + + assert_matches!( + ShortCapitalString::from_ascii_uppercase_and_underscore("MINTER-ADMIN").unwrap_err(), + ShortCapitalStringError::InvalidCharacter + ); + + let err = ShortCapitalString::try_from_encoded_felt( + Felt::ZERO, + "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + 1, + 2481152873203736562, + ) + .unwrap_err(); + assert_matches!(err, ShortCapitalStringError::ValueTooSmall(0)); + } +} diff --git a/crates/miden-standards/Cargo.toml b/crates/miden-standards/Cargo.toml index d4876b5cd9..5888abf0df 100644 --- a/crates/miden-standards/Cargo.toml +++ b/crates/miden-standards/Cargo.toml @@ -16,34 +16,35 @@ version.workspace = true [features] default = ["std"] -std = ["miden-assembly/std", "miden-core-lib/std", "miden-processor/std", "miden-protocol/std"] +std = ["miden-assembly/std", "miden-core-lib/std", "miden-protocol/std"] testing = ["dep:rand", "miden-assembly/testing", "miden-protocol/testing"] [dependencies] # Miden dependencies -miden-processor = { workspace = true } -miden-protocol = { workspace = true } +miden-protocol = { workspace = true } # External dependencies +bon = { workspace = true } rand = { optional = true, workspace = true } thiserror = { workspace = true } [build-dependencies] fs-err = { workspace = true } miden-assembly = { workspace = true } -miden-core = { workspace = true } miden-core-lib = { workspace = true } miden-protocol = { workspace = true } -regex = { version = "1.11" } -walkdir = { version = "2.5" } +regex = { workspace = true } +walkdir = { workspace = true } [dev-dependencies] -anyhow = "1.0" -assert_matches = { workspace = true } -miden-processor = { features = ["testing"], workspace = true } -miden-protocol = { features = ["testing"], workspace = true } +anyhow = { workspace = true } +assert_matches = { workspace = true } +miden-protocol = { features = ["testing"], workspace = true } # When building as a dev-dependency (e.g., `cargo test --workspace` or `cargo check --all-targets`), # enable the `testing` feature. This is a workaround for Cargo's lack of test-specific features. # See: https://github.com/rust-lang/cargo/issues/2911#issuecomment-1483256987 miden-standards = { features = ["testing"], path = "." } + +[package.metadata.cargo-shear] +ignored = ["miden-core-lib"] diff --git a/crates/miden-standards/asm/account_components/access/authority.masm b/crates/miden-standards/asm/account_components/access/authority.masm new file mode 100644 index 0000000000..bac025aa0c --- /dev/null +++ b/crates/miden-standards/asm/account_components/access/authority.masm @@ -0,0 +1,27 @@ +# Authority Account Component. +# +# Owns the single authority storage slot consulted by procedures that gate +# 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. + +use miden::protocol::active_account +use miden::standards::access::authority::AUTHORITY_SLOT + +#! Returns the authority discriminator stored on the account. +#! +#! Inputs: [pad(16)] +#! Outputs: [authority, pad(15)] +#! +#! Where: +#! - authority is a single Felt: 0 = AuthControlled, 1 = OwnerControlled, 2 = RbacControlled. +#! +#! Invocation: call +pub proc get_authority + push.AUTHORITY_SLOT[0..2] exec.active_account::get_item + # => [authority, role_symbol, 0, 0, pad(16)] + + movdn.4 dropw + # => [authority, pad(15)] +end diff --git a/crates/miden-standards/asm/account_components/access/pausable/manager.masm b/crates/miden-standards/asm/account_components/access/pausable/manager.masm new file mode 100644 index 0000000000..b7e74886f0 --- /dev/null +++ b/crates/miden-standards/asm/account_components/access/pausable/manager.masm @@ -0,0 +1,12 @@ +# PausableManager component shim. +# +# Exposes `pause` and `unpause` as `Invocation: call` admin procedures, gated by the account-wide +# `Authority` component via `exec.authority::assert_authorized`. +# +# Companion components required: +# - `Authority` (installed via `AccessControl::Ownable2Step` / `AccessControl::Rbac` / +# `AccessControl::AuthControlled`). +# - `Pausable` — provides the `is_paused` storage slot. + +pub use ::miden::standards::access::pausable::manager::pause +pub use ::miden::standards::access::pausable::manager::unpause diff --git a/crates/miden-standards/asm/account_components/access/pausable/mod.masm b/crates/miden-standards/asm/account_components/access/pausable/mod.masm new file mode 100644 index 0000000000..6ac2a10597 --- /dev/null +++ b/crates/miden-standards/asm/account_components/access/pausable/mod.masm @@ -0,0 +1,6 @@ +# Pause / unpause administration is delegated to the [`PausableManager`] component. +# Pause checks (mint, burn, transfer, metadata setters) are handled by +# [`TokenPolicyManager`] and the metadata setter procedures themselves, both of which use the +# `assert_not_paused` exec helper from `miden::standards::access::pausable` directly. + +pub use ::miden::standards::access::pausable::is_paused diff --git a/crates/miden-standards/asm/account_components/access/rbac.masm b/crates/miden-standards/asm/account_components/access/rbac.masm new file mode 100644 index 0000000000..e4483d75a1 --- /dev/null +++ b/crates/miden-standards/asm/account_components/access/rbac.masm @@ -0,0 +1,12 @@ +# The MASM code of the RoleBasedAccessControl Account Component. +# +# 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 +pub use ::miden::standards::access::rbac::set_role_admin +pub use ::miden::standards::access::rbac::grant_role +pub use ::miden::standards::access::rbac::revoke_role +pub use ::miden::standards::access::rbac::renounce_role diff --git a/crates/miden-standards/asm/account_components/auth/multisig_psm.masm b/crates/miden-standards/asm/account_components/auth/guarded_multisig.masm similarity index 63% rename from crates/miden-standards/asm/account_components/auth/multisig_psm.masm rename to crates/miden-standards/asm/account_components/auth/guarded_multisig.masm index 591ba376ab..29ffded0e5 100644 --- a/crates/miden-standards/asm/account_components/auth/multisig_psm.masm +++ b/crates/miden-standards/asm/account_components/auth/guarded_multisig.masm @@ -1,9 +1,9 @@ -# The MASM code of the Multi-Signature Authentication Component with Private State Manager. +# The MASM code of the Multi-Signature Authentication component integrated with a state guardian. # -# See the `AuthMultisigPsm` Rust type's documentation for more details. +# See the `AuthGuardedMultisig` Rust type's documentation for more details. use miden::standards::auth::multisig -use miden::standards::auth::psm +use miden::standards::auth::guardian pub use multisig::update_signers_and_threshold pub use multisig::get_threshold_and_num_approvers @@ -11,9 +11,9 @@ pub use multisig::set_procedure_threshold pub use multisig::get_signer_at pub use multisig::is_signer -pub use psm::update_psm_public_key +pub use guardian::update_guardian_public_key -#! Authenticate a transaction with multi-signature support and optional PSM verification. +#! Authenticate a transaction with multi-signature support and optional guardian verification. #! #! Inputs: #! Operand stack: [SALT] @@ -22,14 +22,14 @@ pub use psm::update_psm_public_key #! #! Invocation: call @auth_script -pub proc auth_tx_multisig_psm(salt: word) +pub proc auth_tx_guarded_multisig(salt: word) exec.multisig::auth_tx # => [TX_SUMMARY_COMMITMENT] dupw # => [TX_SUMMARY_COMMITMENT, TX_SUMMARY_COMMITMENT] - exec.psm::verify_signature + exec.guardian::verify_signature # => [TX_SUMMARY_COMMITMENT] exec.multisig::assert_new_tx diff --git a/crates/miden-standards/asm/account_components/auth/multisig_smart.masm b/crates/miden-standards/asm/account_components/auth/multisig_smart.masm new file mode 100644 index 0000000000..af05afa295 --- /dev/null +++ b/crates/miden-standards/asm/account_components/auth/multisig_smart.masm @@ -0,0 +1,29 @@ +# The MASM code of the Multi-Signature Smart Authentication Component. +# +# See the `AuthMultisigSmart` Rust type's documentation for more details. + +use miden::standards::auth::multisig +use miden::standards::auth::multisig_smart + +pub use multisig::get_threshold_and_num_approvers +pub use multisig::get_signer_at +pub use multisig::is_signer +pub use multisig_smart::set_procedure_policy +pub use multisig_smart::update_signers_and_threshold + +#! Authenticate a transaction using multisig smart-policy rules. +#! +#! Inputs: +#! Operand stack: [SALT] +#! Outputs: +#! Operand stack: [] +#! +#! Invocation: call +@auth_script +pub proc auth_tx_multisig_smart(salt: word) + exec.multisig_smart::auth_tx + # => [TX_SUMMARY_COMMITMENT] + + exec.multisig::assert_new_tx + # => [] +end diff --git a/crates/miden-standards/asm/account_components/auth/network_account.masm b/crates/miden-standards/asm/account_components/auth/network_account.masm new file mode 100644 index 0000000000..cd6b716a7f --- /dev/null +++ b/crates/miden-standards/asm/account_components/auth/network_account.masm @@ -0,0 +1,70 @@ +# The MASM code of the AuthNetworkAccount authentication component. +# +# See the `AuthNetworkAccount` Rust type's documentation for more details. + +use miden::protocol::active_account +use miden::protocol::native_account +use miden::core::word +use miden::standards::auth::note_script_allowlist + +# CONSTANTS +# ================================================================================================= + +# The slot holding the map of allowed input-note script roots. Keys are note script roots +# (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") + +# AUTH PROCEDURE +# ================================================================================================= + +#! Authenticates a transaction against an `AuthNetworkAccount` component. +#! +#! Enforces two invariants: +#! 1. No transaction script was executed in this transaction. +#! 2. Every consumed input note must have a script root present in the allowlist stored at +#! `ALLOWED_NOTE_SCRIPTS_SLOT`. +#! +#! If both checks pass, the nonce is incremented when the account state changed or the account is +#! new, matching the behavior of the NoAuth and SingleSig components. +#! +#! Inputs: [pad(16)] +#! Outputs: [pad(16)] +#! +#! Invocation: call +@auth_script +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 + # => [pad(16)] + + # ---- Reject any input note whose script root is not allowlisted ---- + push.ALLOWED_NOTE_SCRIPTS_SLOT[0..2] + # => [slot_id_suffix, slot_id_prefix, pad(16)] + + exec.note_script_allowlist::assert_all_input_notes_allowed + # => [pad(16)] + + # ---- Increment nonce iff the account state changed or the account is new ---- + exec.active_account::get_initial_commitment + # => [INITIAL_COMMITMENT, pad(16)] + + exec.active_account::compute_commitment + # => [CURRENT_COMMITMENT, INITIAL_COMMITMENT, pad(16)] + + exec.word::eq not + # => [has_account_state_changed, pad(16)] + + exec.active_account::get_nonce eq.0 + # => [is_new_account, has_account_state_changed, pad(16)] + + or + # => [should_increment_nonce, pad(16)] + + if.true + exec.native_account::incr_nonce drop + end + # => [pad(16)] +end diff --git a/crates/miden-standards/asm/account_components/auth/singlesig.masm b/crates/miden-standards/asm/account_components/auth/singlesig.masm index ab9b587f48..5065a06b7e 100644 --- a/crates/miden-standards/asm/account_components/auth/singlesig.masm +++ b/crates/miden-standards/asm/account_components/auth/singlesig.masm @@ -41,10 +41,10 @@ pub proc auth_tx(auth_args: word) # Fetch public key from storage. # --------------------------------------------------------------------------------------------- - push.PUBLIC_KEY_SLOT[0..2] exec.active_account::get_item + push.PUBLIC_KEY_SLOT[0..2] exec.active_account::get_initial_item # => [PUB_KEY, pad(16)] - push.SCHEME_ID_SLOT[0..2] exec.active_account::get_item + push.SCHEME_ID_SLOT[0..2] exec.active_account::get_initial_item # => [scheme_id, 0, 0, 0, PUB_KEY, pad(16)] movdn.7 drop drop drop diff --git a/crates/miden-standards/asm/account_components/auth/singlesig_acl.masm b/crates/miden-standards/asm/account_components/auth/singlesig_acl.masm index b3484554a5..dfb662d8a4 100644 --- a/crates/miden-standards/asm/account_components/auth/singlesig_acl.masm +++ b/crates/miden-standards/asm/account_components/auth/singlesig_acl.masm @@ -52,7 +52,7 @@ pub proc auth_tx_acl(auth_args: word) # => [pad(16)] # Get the authentication configuration - push.AUTH_CONFIG_SLOT[0..2] exec.active_account::get_item + push.AUTH_CONFIG_SLOT[0..2] exec.active_account::get_initial_item # => [num_auth_trigger_procs, allow_unauthorized_output_notes, allow_unauthorized_input_notes, 0, pad(16)] movup.3 drop @@ -78,7 +78,7 @@ pub proc auth_tx_acl(auth_args: word) push.0.0.0 dup.4 sub.1 push.AUTH_TRIGGER_PROCS_MAP_SLOT[0..2] # => [trigger_proc_slot_prefix, trigger_proc_slot_suffix, [i-1, 0, 0, 0], require_acl_auth, i, pad(16)] - exec.active_account::get_map_item + exec.active_account::get_initial_map_item # => [AUTH_TRIGGER_PROC_ROOT, require_acl_auth, i, pad(16)] exec.native_account::was_procedure_called @@ -137,11 +137,11 @@ pub proc auth_tx_acl(auth_args: word) # If authentication is required, perform signature verification if.true # Fetch public key from storage. - push.PUBLIC_KEY_SLOT[0..2] exec.active_account::get_item + push.PUBLIC_KEY_SLOT[0..2] exec.active_account::get_initial_item # => [PUB_KEY, pad(16)] # Fetch scheme_id from storage - push.SCHEME_ID_SLOT[0..2] exec.active_account::get_item + push.SCHEME_ID_SLOT[0..2] exec.active_account::get_initial_item # => [[scheme_id, 0, 0, 0], PUB_KEY, pad(16)] movdn.7 drop drop drop diff --git a/crates/miden-standards/asm/account_components/faucets/basic_fungible_faucet.masm b/crates/miden-standards/asm/account_components/faucets/basic_fungible_faucet.masm deleted file mode 100644 index 37895b9ef7..0000000000 --- a/crates/miden-standards/asm/account_components/faucets/basic_fungible_faucet.masm +++ /dev/null @@ -1,6 +0,0 @@ -# The MASM code of the Basic Fungible Faucet Account Component. -# -# See the `BasicFungibleFaucet` Rust type's documentation for more details. - -pub use ::miden::standards::faucets::basic_fungible::mint_and_send -pub use ::miden::standards::faucets::basic_fungible::burn diff --git a/crates/miden-standards/asm/account_components/faucets/fungible_faucet.masm b/crates/miden-standards/asm/account_components/faucets/fungible_faucet.masm new file mode 100644 index 0000000000..0b370ce547 --- /dev/null +++ b/crates/miden-standards/asm/account_components/faucets/fungible_faucet.masm @@ -0,0 +1,28 @@ +# The MASM code of the Basic Fungible Faucet Account Component. +# +# See the `FungibleFaucet` Rust type's documentation for more details. This component +# bundles the fungible-faucet asset minting/burning procedures and the token metadata accessors +# (name, description, logo URI, external link, mutability config) into a single component. + +# Fungible token minting and burning +pub use ::miden::standards::faucets::fungible::mint_and_send +pub use ::miden::standards::faucets::fungible::receive_and_burn + +# Fungible token configuration +pub use ::miden::standards::faucets::fungible::get_decimals +pub use ::miden::standards::faucets::fungible::get_token_symbol +pub use ::miden::standards::faucets::fungible::get_token_supply +pub use ::miden::standards::faucets::fungible::get_max_supply +pub use ::miden::standards::faucets::fungible::set_max_supply +pub use ::miden::standards::faucets::fungible::is_max_supply_mutable +pub use ::miden::standards::faucets::fungible::get_token_config + +# Token metadata: mandatory name + optional description, logo, external link. +pub use ::miden::standards::faucets::get_name +pub use ::miden::standards::faucets::get_mutability_config +pub use ::miden::standards::faucets::is_description_mutable +pub use ::miden::standards::faucets::is_logo_uri_mutable +pub use ::miden::standards::faucets::is_external_link_mutable +pub use ::miden::standards::faucets::set_description +pub use ::miden::standards::faucets::set_logo_uri +pub use ::miden::standards::faucets::set_external_link diff --git a/crates/miden-standards/asm/account_components/faucets/network_fungible_faucet.masm b/crates/miden-standards/asm/account_components/faucets/network_fungible_faucet.masm deleted file mode 100644 index 0aee492d62..0000000000 --- a/crates/miden-standards/asm/account_components/faucets/network_fungible_faucet.masm +++ /dev/null @@ -1,6 +0,0 @@ -# The MASM code of the Network Fungible Faucet Account Component. -# -# See the `NetworkFungibleFaucet` Rust type's documentation for more details. - -pub use ::miden::standards::faucets::network_fungible::mint_and_send -pub use ::miden::standards::faucets::network_fungible::burn diff --git a/crates/miden-standards/asm/account_components/faucets/policies/burn/allow_all.masm b/crates/miden-standards/asm/account_components/faucets/policies/burn/allow_all.masm new file mode 100644 index 0000000000..8915ff99d7 --- /dev/null +++ b/crates/miden-standards/asm/account_components/faucets/policies/burn/allow_all.masm @@ -0,0 +1,6 @@ +# The MASM code of the `allow_all` Burn Policy Account Component (auth-controlled family). +# +# Exposes the `check_policy` procedure so its MAST root can be registered as the active +# (or allowed) policy on a `BurnPolicyManager`. Storage-free. + +pub use ::miden::standards::faucets::policies::burn::allow_all::check_policy diff --git a/crates/miden-standards/asm/account_components/faucets/policies/burn/owner_controlled/owner_only.masm b/crates/miden-standards/asm/account_components/faucets/policies/burn/owner_controlled/owner_only.masm new file mode 100644 index 0000000000..96f67e0f90 --- /dev/null +++ b/crates/miden-standards/asm/account_components/faucets/policies/burn/owner_controlled/owner_only.masm @@ -0,0 +1,6 @@ +# The MASM code of the `owner_only` Burn Policy Account Component (owner-controlled family). +# +# Exposes the `check_policy` procedure so its MAST root can be registered as the active +# (or allowed) policy on a `BurnPolicyManager`. Storage-free. + +pub use ::miden::standards::faucets::policies::burn::owner_controlled::owner_only::check_policy diff --git a/crates/miden-standards/asm/account_components/faucets/policies/mint/allow_all.masm b/crates/miden-standards/asm/account_components/faucets/policies/mint/allow_all.masm new file mode 100644 index 0000000000..cdee675edb --- /dev/null +++ b/crates/miden-standards/asm/account_components/faucets/policies/mint/allow_all.masm @@ -0,0 +1,6 @@ +# The MASM code of the `allow_all` Mint Policy Account Component (auth-controlled family). +# +# Exposes the `check_policy` procedure so its MAST root can be registered as the active +# (or allowed) policy on a `MintPolicyManager`. Storage-free. + +pub use ::miden::standards::faucets::policies::mint::allow_all::check_policy diff --git a/crates/miden-standards/asm/account_components/faucets/policies/mint/owner_controlled/owner_only.masm b/crates/miden-standards/asm/account_components/faucets/policies/mint/owner_controlled/owner_only.masm new file mode 100644 index 0000000000..e09e0ab9ad --- /dev/null +++ b/crates/miden-standards/asm/account_components/faucets/policies/mint/owner_controlled/owner_only.masm @@ -0,0 +1,6 @@ +# The MASM code of the `owner_only` Mint Policy Account Component (owner-controlled family). +# +# Exposes the `check_policy` procedure so its MAST root can be registered as the active +# (or allowed) policy on a `MintPolicyManager`. Storage-free. + +pub use ::miden::standards::faucets::policies::mint::owner_controlled::owner_only::check_policy 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 new file mode 100644 index 0000000000..eba92641b2 --- /dev/null +++ b/crates/miden-standards/asm/account_components/faucets/policies/policy_manager.masm @@ -0,0 +1,27 @@ +# 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. + +pub use ::miden::standards::faucets::policies::policy_manager::set_mint_policy +pub use ::miden::standards::faucets::policies::policy_manager::get_mint_policy +pub use ::miden::standards::faucets::policies::policy_manager::set_burn_policy +pub use ::miden::standards::faucets::policies::policy_manager::get_burn_policy +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 diff --git a/crates/miden-standards/asm/account_components/faucets/policies/transfer/allow_all.masm b/crates/miden-standards/asm/account_components/faucets/policies/transfer/allow_all.masm new file mode 100644 index 0000000000..5b4d3f893b --- /dev/null +++ b/crates/miden-standards/asm/account_components/faucets/policies/transfer/allow_all.masm @@ -0,0 +1,6 @@ +# The MASM code of the `allow_all` Transfer Policy Account Component. +# +# Exposes the `check_policy` procedure so its MAST root can be registered as the active +# (or allowed) transfer policy on a `TokenPolicyManager`. Storage-free. + +pub use ::miden::standards::faucets::policies::transfer::allow_all::check_policy diff --git a/crates/miden-standards/asm/account_components/faucets/policies/transfer/allowlist/owner_controlled.masm b/crates/miden-standards/asm/account_components/faucets/policies/transfer/allowlist/owner_controlled.masm new file mode 100644 index 0000000000..1475b1505d --- /dev/null +++ b/crates/miden-standards/asm/account_components/faucets/policies/transfer/allowlist/owner_controlled.masm @@ -0,0 +1,12 @@ +# Owner-controlled allowlist admin component. +# +# Re-exports `allow_account` and `disallow_account` from +# `miden::standards::faucets::policies::transfer::allowlist::owner_controlled`, which wraps the +# underlying `Invocation: exec` helpers with an Ownable2Step authorization check. +# +# Pair with the [`Ownable2Step`] component (which provides the owner storage slot) and a +# component that installs the `allowed_accounts` storage slot — typically the basic allowlist +# transfer policy component. + +pub use ::miden::standards::faucets::policies::transfer::allowlist::owner_controlled::allow_account +pub use ::miden::standards::faucets::policies::transfer::allowlist::owner_controlled::disallow_account diff --git a/crates/miden-standards/asm/account_components/faucets/policies/transfer/basic_allowlist.masm b/crates/miden-standards/asm/account_components/faucets/policies/transfer/basic_allowlist.masm new file mode 100644 index 0000000000..d0ef3fb20c --- /dev/null +++ b/crates/miden-standards/asm/account_components/faucets/policies/transfer/basic_allowlist.masm @@ -0,0 +1,8 @@ +# The MASM code of the basic allowlist transfer policy account component. +# +# Exposes the `check_policy` procedure so its MAST root can be registered as the active +# (or allowed) transfer policy on a `TokenPolicyManager`. Installs the `allowed_accounts` +# storage map (defined by the sibling `allowlist` primitive); admin updates go through a +# companion auth-gated component (see `allowlist/owner_controlled.masm`). + +pub use ::miden::standards::faucets::policies::transfer::basic_allowlist::check_policy diff --git a/crates/miden-standards/asm/account_components/faucets/policies/transfer/basic_blocklist.masm b/crates/miden-standards/asm/account_components/faucets/policies/transfer/basic_blocklist.masm new file mode 100644 index 0000000000..36c4ddaaaa --- /dev/null +++ b/crates/miden-standards/asm/account_components/faucets/policies/transfer/basic_blocklist.masm @@ -0,0 +1,8 @@ +# The MASM code of the basic blocklist transfer policy account component. +# +# Exposes the `check_policy` procedure so its MAST root can be registered as the active +# (or allowed) transfer policy on a `TokenPolicyManager`. Installs the `blocked_accounts` +# storage map (defined by the sibling `blocklist` primitive); admin updates go through a +# companion auth-gated component (see `blocklist/owner_controlled.masm`). + +pub use ::miden::standards::faucets::policies::transfer::basic_blocklist::check_policy diff --git a/crates/miden-standards/asm/account_components/faucets/policies/transfer/blocklist/owner_controlled.masm b/crates/miden-standards/asm/account_components/faucets/policies/transfer/blocklist/owner_controlled.masm new file mode 100644 index 0000000000..42a4d42893 --- /dev/null +++ b/crates/miden-standards/asm/account_components/faucets/policies/transfer/blocklist/owner_controlled.masm @@ -0,0 +1,12 @@ +# Owner-controlled blocklist admin component. +# +# Re-exports `block_account` and `unblock_account` from +# `miden::standards::faucets::policies::transfer::blocklist::owner_controlled`, which wraps the +# underlying `Invocation: exec` helpers with an Ownable2Step authorization check. +# +# Pair with the [`Ownable2Step`] component (which provides the owner storage slot) and a +# component that installs the `blocked_accounts` storage slot — typically the basic blocklist +# transfer policy component. + +pub use ::miden::standards::faucets::policies::transfer::blocklist::owner_controlled::block_account +pub use ::miden::standards::faucets::policies::transfer::blocklist::owner_controlled::unblock_account diff --git a/crates/miden-standards/asm/account_components/mint_policies/auth_controlled.masm b/crates/miden-standards/asm/account_components/mint_policies/auth_controlled.masm deleted file mode 100644 index 8f817b74ff..0000000000 --- a/crates/miden-standards/asm/account_components/mint_policies/auth_controlled.masm +++ /dev/null @@ -1,7 +0,0 @@ -# The MASM code of the Mint Policy Auth Controlled Account Component. -# -# See the `AuthControlled` Rust type's documentation for more details. - -pub use ::miden::standards::mint_policies::auth_controlled::allow_all -pub use ::miden::standards::mint_policies::policy_manager::set_mint_policy -pub use ::miden::standards::mint_policies::policy_manager::get_mint_policy diff --git a/crates/miden-standards/asm/account_components/mint_policies/owner_controlled.masm b/crates/miden-standards/asm/account_components/mint_policies/owner_controlled.masm deleted file mode 100644 index cc21f8f0de..0000000000 --- a/crates/miden-standards/asm/account_components/mint_policies/owner_controlled.masm +++ /dev/null @@ -1,7 +0,0 @@ -# The MASM code of the Mint Policy Owner Controlled Account Component. -# -# See the `OwnerControlled` Rust type's documentation for more details. - -pub use ::miden::standards::mint_policies::owner_controlled::owner_only -pub use ::miden::standards::mint_policies::policy_manager::set_mint_policy -pub use ::miden::standards::mint_policies::policy_manager::get_mint_policy diff --git a/crates/miden-standards/asm/standards/access/authority.masm b/crates/miden-standards/asm/standards/access/authority.masm new file mode 100644 index 0000000000..a284c5106d --- /dev/null +++ b/crates/miden-standards/asm/standards/access/authority.masm @@ -0,0 +1,78 @@ +# miden::standards::access::authority +# +# Single source of truth for the account-wide authority. Components that gate state-mutating +# procedures (TokenPolicyManager `set_*_policy`, fungible token metadata `set_*` procedures, +# future NFT metadata setters, ...) all consult this slot via `assert_authorized`. + +use miden::protocol::active_account +use miden::standards::access::ownable2step +use miden::standards::access::rbac + +# CONSTANTS +# ================================================================================================= + +pub const AUTHORITY_SLOT = word("miden::standards::access::authority") + +const AUTH_CONTROLLED = 0 +const OWNER_CONTROLLED = 1 +const RBAC_CONTROLLED = 2 + +# ERRORS +# ================================================================================================= + +const ERR_UNSUPPORTED_AUTHORITY = "authority is not supported" + +# PUBLIC PROCEDURES +# ================================================================================================= + +#! Asserts the caller is authorized to invoke an authority-gated procedure. +#! +#! - 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 +#! [`RoleBasedAccessControl`][crate::account::access::RoleBasedAccessControl] component to be +#! installed on the account; otherwise linking the account fails. +#! +#! 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 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] + + dup eq.AUTH_CONTROLLED + if.true + # AuthControlled — auth component already gated the call. + dropw + # => [] + else + dup eq.OWNER_CONTROLLED + if.true + exec.ownable2step::assert_sender_is_owner + # => [authority, role_symbol, 0, 0] + dropw + # => [] + else + eq.RBAC_CONTROLLED + if.true + # => [role_symbol, 0, 0] + exec.rbac::assert_sender_has_role + # => [0, 0] + drop drop + # => [] + else + # Unknown authority — panic. Stack state irrelevant. + push.0 assert.err=ERR_UNSUPPORTED_AUTHORITY + end + end + end +end diff --git a/crates/miden-standards/asm/standards/access/ownable.masm b/crates/miden-standards/asm/standards/access/ownable.masm deleted file mode 100644 index b0591e71a5..0000000000 --- a/crates/miden-standards/asm/standards/access/ownable.masm +++ /dev/null @@ -1,162 +0,0 @@ -# miden::standards::access::ownable -# -# Provides ownership management functionality for account components. -# This template can be imported and used by any component that needs owner controls. - -use miden::protocol::active_account -use miden::protocol::account_id -use miden::protocol::active_note -use miden::protocol::native_account - -# CONSTANTS -# ================================================================================================ - -# The slot in this component's storage layout where the owner config is stored. -const OWNER_CONFIG_SLOT = word("miden::standards::access::ownable::owner_config") - -# ZERO_ADDRESS word (all zeros) used to represent no owner -# Layout: [suffix=0, prefix=0, 0, 0] as stored in account storage -const ZERO_ADDRESS = [0, 0, 0, 0] - -# ERRORS -# ================================================================================================ - -const ERR_SENDER_NOT_OWNER = "note sender is not the owner" - -# INTERNAL PROCEDURES -# ================================================================================================ - -#! Returns the owner AccountId from storage. -#! -#! Inputs: [] -#! Outputs: [owner_suffix, owner_prefix] -#! -#! Where: -#! - owner_{suffix, prefix} are the suffix and prefix felts of the owner AccountId. -proc owner - push.OWNER_CONFIG_SLOT[0..2] exec.active_account::get_item - # => [0, 0, owner_suffix, owner_prefix] - - drop drop - # => [owner_suffix, owner_prefix] -end - -#! Checks if the given account ID is the owner of this component. -#! -#! Inputs: [account_id_suffix, account_id_prefix] -#! Outputs: [is_owner] -#! -#! Where: -#! - account_id_{suffix, prefix} are the suffix and prefix felts of the AccountId to check. -#! - is_owner is 1 if the account is the owner, 0 otherwise. -proc is_owner - exec.owner - # => [owner_suffix, owner_prefix, account_id_suffix, account_id_prefix] - - exec.account_id::is_equal - # => [is_owner] -end - -# PUBLIC INTERFACE -# ================================================================================================ - -#! Checks if the note sender is the owner and panics if not. -#! -#! Inputs: [] -#! Outputs: [] -#! -#! Panics if: -#! - the note sender is not the owner. -pub proc verify_owner - exec.active_note::get_sender - # => [sender_suffix, sender_prefix] - - exec.is_owner - # => [is_owner] - - assert.err=ERR_SENDER_NOT_OWNER - # => [] -end - -#! Returns the owner AccountId. -#! -#! Inputs: [pad(16)] -#! Outputs: [owner_suffix, owner_prefix, pad(14)] -#! -#! Where: -#! - owner_{suffix, prefix} are the suffix and prefix felts of the owner AccountId. -#! -#! Invocation: call -pub proc get_owner - exec.owner - # => [owner_suffix, owner_prefix, pad(14)] -end - -#! Transfers ownership to a new account. -#! -#! Can only be called by the current owner. -#! -#! Inputs: [new_owner_suffix, new_owner_prefix, pad(14)] -#! Outputs: [pad(16)] -#! -#! Where: -#! - new_owner_{suffix, prefix} are the suffix and prefix felts of the new owner AccountId. -#! -#! Panics if: -#! - the note sender is not the owner. -#! -#! Invocation: call -pub proc transfer_ownership - # Check that the caller is the owner - exec.verify_owner - # => [new_owner_suffix, new_owner_prefix, pad(14)] - - push.0.0 - # => [0, 0, new_owner_suffix, new_owner_prefix, pad(14)] - - push.OWNER_CONFIG_SLOT[0..2] - # => [slot_suffix, slot_prefix, 0, 0, new_owner_suffix, new_owner_prefix, pad(14)] - - exec.native_account::set_item - # => [OLD_OWNER_WORD, pad(14)] - - # When the stack has 16 elements, dropw will shift in zeros from the right, - # resulting in [pad(16)]. So dropw is sufficient here. - dropw - # => [pad(16)] -end - -#! Renounces ownership, leaving the component without an owner. -#! -#! Can only be called by the current owner. -#! -#! Inputs: [pad(16)] -#! Outputs: [pad(16)] -#! -#! Panics if: -#! - the note sender is not the owner. -#! -#! Invocation: call -#! -#! Important Note! -#! This feature allows the owner to relinquish administrative privileges, a common pattern -#! after an initial stage with centralized administration is over. Once ownership is renounced, -#! the component becomes permanently ownerless and cannot be managed by any account. -pub proc renounce_ownership - exec.verify_owner - # => [pad(16)] - - # ---- Push ZERO_ADDRESS to storage ---- - push.ZERO_ADDRESS - # => [0, 0, 0, 0, pad(16)] - - push.OWNER_CONFIG_SLOT[0..2] - # => [slot_suffix, slot_prefix, 0, 0, 0, 0, pad(16)] - - exec.native_account::set_item - # => [OLD_OWNER_WORD, pad(16)] - - dropw - # => [pad(16)] -end - diff --git a/crates/miden-standards/asm/standards/access/ownable2step.masm b/crates/miden-standards/asm/standards/access/ownable2step.masm index d4b7bcffbd..01ae9f06d1 100644 --- a/crates/miden-standards/asm/standards/access/ownable2step.masm +++ b/crates/miden-standards/asm/standards/access/ownable2step.masm @@ -38,15 +38,7 @@ const OWNER_CONFIG_SLOT = word("miden::standards::access::ownable2step::owner_co const ERR_SENDER_NOT_OWNER = "note sender is not the owner" const ERR_SENDER_NOT_NOMINATED_OWNER = "note sender is not the nominated owner" const ERR_NO_NOMINATED_OWNER = "no nominated ownership transfer exists" - -# LOCAL MEMORY ADDRESSES -# ================================================================================================ - -# transfer_ownership locals -const NEW_OWNER_SUFFIX_LOC = 0 -const NEW_OWNER_PREFIX_LOC = 1 -const OWNER_SUFFIX_LOC = 2 -const OWNER_PREFIX_LOC = 3 +const ERR_OWNERSHIP_TRANSFER_IN_PROGRESS = "ownership transfer is in progress" # INTERNAL PROCEDURES # ================================================================================================ @@ -146,21 +138,35 @@ proc is_nominated_owner_internal # => [is_nominated_owner] end -#! Checks if the note sender is the owner and panics if not. +#! Returns 1 if the note sender is the current owner, otherwise 0. #! #! Inputs: [] -#! Outputs: [] +#! Outputs: [is_sender_owner] #! -#! Panics if: -#! - the note sender is not the owner. +#! Where: +#! - is_sender_owner is 1 if the note sender is the current owner, otherwise 0. #! #! Invocation: exec -proc assert_sender_is_owner_internal +pub proc is_sender_owner_internal exec.active_note::get_sender # => [sender_suffix, sender_prefix] exec.is_owner_internal - # => [is_owner] + # => [is_sender_owner] +end + +#! Asserts that the note sender is the current owner. +#! +#! Inputs: [] +#! Outputs: [] +#! +#! Panics if: +#! - the note sender is not the owner. +#! +#! Invocation: exec +pub proc assert_sender_is_owner_internal + exec.is_sender_owner_internal + # => [is_sender_owner] assert.err=ERR_SENDER_NOT_OWNER # => [] @@ -218,80 +224,56 @@ pub proc get_nominated_owner # => [nominated_owner_suffix, nominated_owner_prefix, pad(14)] end -#! Initiates a two-step ownership transfer by setting the nominated owner. +#! Initiates or cancels a two-step ownership transfer. #! #! The current owner remains in control until the nominated owner calls `accept_ownership`. #! Can only be called by the current owner. #! -#! If the new owner is the current owner, any nominated transfer is cancelled and the -#! nominated owner field is cleared. +#! Cancellation behaviour: +#! - If `new_owner` is the zero address `(0, 0)`, any pending nomination is cleared. The +#! zero address is treated as a cancel value and is not validated as an +#! account ID. +#! - Otherwise, `new_owner` is validated and stored as the nominated owner. The current +#! owner remains in control until the nominated owner calls `accept_ownership`. #! #! Inputs: [new_owner_suffix, new_owner_prefix, pad(14)] #! Outputs: [pad(16)] #! #! Panics if: #! - the note sender is not the owner. -#! -#! Locals: -#! 0: new_owner_suffix -#! 1: new_owner_prefix -#! 2: owner_suffix -#! 3: owner_prefix +#! - new_owner is non-zero and the account ID is invalid. #! #! Invocation: call -@locals(4) pub proc transfer_ownership exec.assert_sender_is_owner_internal # => [new_owner_suffix, new_owner_prefix, pad(14)] - dup.1 dup.1 exec.account_id::validate - # => [new_owner_suffix, new_owner_prefix, pad(14)] - - loc_store.NEW_OWNER_SUFFIX_LOC - # => [new_owner_prefix, pad(14)] - - loc_store.NEW_OWNER_PREFIX_LOC - # => [pad(14)] - - exec.get_owner_internal - # => [owner_suffix, owner_prefix, pad(14)] - - loc_store.OWNER_SUFFIX_LOC - # => [owner_prefix, pad(13)] - - loc_store.OWNER_PREFIX_LOC - # => [pad(12)] - - # Check if new_owner == owner (cancel case). - loc_load.NEW_OWNER_PREFIX_LOC loc_load.NEW_OWNER_SUFFIX_LOC - # => [new_owner_suffix, new_owner_prefix, pad(12)] - - loc_load.OWNER_PREFIX_LOC loc_load.OWNER_SUFFIX_LOC - # => [owner_suffix, owner_prefix, new_owner_suffix, new_owner_prefix, pad(12)] - - exec.account_id::is_equal - # => [is_self_transfer, pad(12)] + # Detect explicit cancel via the zero address. The zero address is not a valid + # account ID, so we must check for it before validating. + dup.1 eq.0 dup.1 eq.0 and + # => [is_zero_address, new_owner_suffix, new_owner_prefix, pad(14)] if.true - # Cancel ownership transfer and clear nominated owner. - # Stack for save: [owner_suffix, owner_prefix, nominated_suffix=0, nominated_prefix=0] - loc_load.OWNER_PREFIX_LOC loc_load.OWNER_SUFFIX_LOC - # => [owner_suffix, owner_prefix, pad(12)] + # 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(12)] + # => [owner_suffix, owner_prefix, 0, 0, pad(14)] else - # Transfer ownership by setting nominated = new_owner. - # Stack for save: [owner_suffix, owner_prefix, new_owner_suffix, new_owner_prefix] - loc_load.NEW_OWNER_PREFIX_LOC loc_load.NEW_OWNER_SUFFIX_LOC - # => [new_owner_suffix, new_owner_prefix, pad(12)] + # Non-zero new owner: validate and store as the nominated owner. + dup.1 dup.1 exec.account_id::validate + # => [new_owner_suffix, new_owner_prefix, pad(14)] - loc_load.OWNER_PREFIX_LOC loc_load.OWNER_SUFFIX_LOC - # => [owner_suffix, owner_prefix, new_owner_suffix, new_owner_prefix, pad(12)] + exec.get_owner_internal + # => [owner_suffix, owner_prefix, new_owner_suffix, new_owner_prefix, pad(14)] end exec.save_ownership_info - # => [pad(12)] + # => [pad(14)] end #! Accepts a nominated ownership transfer. The nominated owner becomes the new owner @@ -343,7 +325,8 @@ end #! Renounces ownership, leaving the component without an owner. #! -#! Can only be called by the current owner. Clears both the owner and any nominated owner. +#! Can only be called by the current owner. Rejects if a nominated transfer is in progress +#! to prevent accidentally discarding a pending nomination. #! #! Important Note! #! This feature allows the owner to relinquish administrative privileges, a common pattern @@ -355,12 +338,22 @@ end #! #! Panics if: #! - the note sender is not the owner. +#! - an ownership transfer is currently in progress. #! #! Invocation: call pub proc renounce_ownership exec.assert_sender_is_owner_internal # => [pad(16)] + exec.get_nominated_owner_internal + # => [nominated_suffix, nominated_prefix, pad(16)] + + eq.0 swap eq.0 and + # => [no_nominated_transfer, pad(16)] + + assert.err=ERR_OWNERSHIP_TRANSFER_IN_PROGRESS + # => [pad(16)] + push.RENOUNCED_OWNERSHIP_CONFIG # => [0, 0, 0, 0, pad(16)] diff --git a/crates/miden-standards/asm/standards/access/pausable/manager.masm b/crates/miden-standards/asm/standards/access/pausable/manager.masm new file mode 100644 index 0000000000..7a6bb7a466 --- /dev/null +++ b/crates/miden-standards/asm/standards/access/pausable/manager.masm @@ -0,0 +1,52 @@ +# miden::standards::access::pausable::manager +# +# Pause admin manager. Exposes `pause` and `unpause` as `Invocation: call` procedures gated by +# 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). +# +# Companion components required on the account: +# - `Authority` — provides the auth dispatch the body calls into. +# - `Pausable` — provides the `is_paused` storage slot that pause / unpause write to. + +use miden::standards::access::authority +use miden::standards::access::pausable + +# PUBLIC INTERFACE +# ================================================================================================ + +#! Pause the account. +#! +#! Inputs: [pad(16)] +#! Outputs: [pad(16)] +#! +#! Panics if: +#! - the caller is not authorized per the installed `Authority` component. +#! +#! Invocation: call +pub proc pause + exec.authority::assert_authorized + # => [pad(16)] + + exec.pausable::pause + # => [pad(16)] +end + +#! Unpause the account. +#! +#! Inputs: [pad(16)] +#! Outputs: [pad(16)] +#! +#! Panics if: +#! - the caller is not authorized per the installed `Authority` component. +#! +#! Invocation: call +pub proc unpause + exec.authority::assert_authorized + # => [pad(16)] + + exec.pausable::unpause + # => [pad(16)] +end diff --git a/crates/miden-standards/asm/standards/access/pausable/mod.masm b/crates/miden-standards/asm/standards/access/pausable/mod.masm new file mode 100644 index 0000000000..0087e5cc74 --- /dev/null +++ b/crates/miden-standards/asm/standards/access/pausable/mod.masm @@ -0,0 +1,157 @@ +# miden::standards::access::pausable +# +# Pause primitive: storage slot + low-level helpers. The mutation helpers (`pause`, `unpause`) +# perform NO authorization — they are exec building blocks consumed by auth-checking wrappers +# such as [`pausable::manager`]. The read view (`is_paused`) is also exposed call-invokably so +# tx scripts can query the flag through the Pausable component shim. +# +# Consumers (asset callbacks, custom procedures, TokenPolicyManager mint/burn/transfer dispatch) +# compose `assert_not_paused` / `assert_paused` (exec) to gate their own logic. + +use miden::core::word +use miden::protocol::active_account +use miden::protocol::native_account + +# CONSTANTS +# ================================================================================================ + +# The slot where the paused flag is stored as a single word. +# Unpaused: [0, 0, 0, 0]. Paused: [1, 0, 0, 0]. +const IS_PAUSED_SLOT = word("miden::standards::access::pausable::is_paused") + +const PAUSED_WORD = [1, 0, 0, 0] + +const UNPAUSED_WORD = [0, 0, 0, 0] + +# ERRORS +# ================================================================================================ + +const ERR_PAUSABLE_IS_PAUSED = "the contract is paused" + +const ERR_PAUSABLE_EXPECTED_PAUSE = "the contract is not paused" + +# PUBLIC INTERFACE +# ================================================================================================ + +#! Returns whether the account is currently paused. +#! +#! Reads [`IS_PAUSED_SLOT`] on the active account and returns `1` if the stored word is non-zero +#! (paused) or `0` if it is the zero word (unpaused). +#! +#! Inputs: [pad(16)] +#! Outputs: [is_paused, pad(15)] +#! +#! Invocation: call +pub proc is_paused + push.IS_PAUSED_SLOT[0..2] + exec.active_account::get_item + # => [is_paused, 0, 0, 0, pad(16)] + + exec.word::eqz not + # => [is_paused, pad(16)] + + swap drop + # => [is_paused, pad(15)] +end + +#! Sets the paused flag. +#! +#! This procedure does not verify the caller. Compose with access control in a wrapper component +#! (e.g. `pausable::manager` via Authority::assert_authorized) so only privileged accounts can +#! pause. +#! +#! Inputs: [] +#! Outputs: [] +#! +#! Invocation: exec +pub proc pause + push.PAUSED_WORD + # => [1, 0, 0, 0] + + push.IS_PAUSED_SLOT[0..2] + # => [slot_suffix, slot_prefix, 1, 0, 0, 0] + + exec.native_account::set_item + # => [OLD_WORD] + + dropw + # => [] +end + +#! Clears the paused flag. +#! +#! This procedure does not verify the caller. +#! +#! Inputs: [] +#! Outputs: [] +#! +#! Invocation: exec +pub proc unpause + push.UNPAUSED_WORD + # => [0, 0, 0, 0] + + push.IS_PAUSED_SLOT[0..2] + # => [slot_suffix, slot_prefix, 0, 0, 0, 0] + + exec.native_account::set_item + # => [OLD_WORD] + + dropw + # => [] +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 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. +#! +#! Inputs: [] +#! Outputs: [] +#! +#! Panics if: +#! - 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.word::eqz + # => [is_unpaused] + + assert.err=ERR_PAUSABLE_IS_PAUSED + # => [] +end + +#! Requires the contract to be paused (storage word is not the zero word). +#! +#! Reads [`IS_PAUSED_SLOT`] on the active account, then [`word::eqz`] and inverts. If the +#! stored word is zero (unpaused), panics with [`ERR_PAUSABLE_EXPECTED_PAUSE`]. +#! +#! Typical use: guard `unpause` so clearing the flag only happens from a paused state. +#! +#! Inputs: [] +#! Outputs: [] +#! +#! Panics if: +#! - the paused-state word is the zero word. +#! +#! Invocation: exec +pub proc assert_paused + push.IS_PAUSED_SLOT[0..2] + exec.active_account::get_item + # => [is_paused, 0, 0, 0] + + exec.word::eqz not + # => [is_paused] + + assert.err=ERR_PAUSABLE_EXPECTED_PAUSE + # => [] +end diff --git a/crates/miden-standards/asm/standards/access/rbac.masm b/crates/miden-standards/asm/standards/access/rbac.masm new file mode 100644 index 0000000000..d8507b96ad --- /dev/null +++ b/crates/miden-standards/asm/standards/access/rbac.masm @@ -0,0 +1,561 @@ +# miden::standards::access::rbac +# +# Role-based access control for account components. +# +# Provides per-role membership tracking with delegated role admins, on top of the +# Ownable2Step component which supplies the top-level authority. Members are tracked +# with a boolean flag and a per-role member count +# +# Storage layout (2 slots): +# +# Slot 1 (`ROLE_CONFIG_SLOT`): +# Map: [0, 0, 0, role_symbol] -> [member_count, admin_role_symbol, 0, 0] +# +# Slot 2 (`ROLE_MEMBERSHIP_SLOT`): +# Map: [0, role_symbol, account_suffix, account_prefix] -> [is_member, 0, 0, 0] +# +# Authority model: +# - The account's Ownable2Step owner is the top-level RBAC authority. The owner can +# set role admins and grant/revoke any role. +# - Each role can have a delegated admin role (set via `set_role_admin`). Members of +# the admin role can grant/revoke that role without being the owner. +# - A role is considered to "exist" when it has at least one member. Role admin +# relationships set via `set_role_admin` are stored in the config but do not, by +# themselves, make the role exist. + +use miden::protocol::account_id +use miden::protocol::active_account +use miden::protocol::active_note +use miden::protocol::native_account +use miden::standards::access::ownable2step + +# CONSTANTS +# ================================================================================================= + +# Per-role config map slot. +# Map entries: [0, 0, 0, role_symbol] -> [member_count, admin_role_symbol, 0, 0] +const ROLE_CONFIG_SLOT = word("miden::standards::access::rbac::role_config") + +# Per-role membership map slot. +# Map entries: [0, role_symbol, account_suffix, account_prefix] -> [is_member, 0, 0, 0] +const ROLE_MEMBERSHIP_SLOT = word("miden::standards::access::rbac::role_membership") + +# Membership map values: the first felt is the membership flag, remaining felts are 0. +const SET_MEMBERSHIP = [1, 0, 0, 0] +const CLEAR_MEMBERSHIP = [0, 0, 0, 0] + +# ERRORS +# ================================================================================================= + +const ERR_SENDER_LACKS_ROLE = "note sender does not hold the required role" +const ERR_SENDER_NOT_OWNER_OR_ROLE_ADMIN = "note sender is not the owner or a role admin" +const ERR_ACCOUNT_NOT_IN_ROLE = "account does not hold the role" +const ERR_ROLE_SYMBOL_ZERO = "role symbol is zero" +const ERR_MEMBER_COUNT_OVERFLOW = "role member count overflowed u32" + +# PUBLIC INTERFACE +# ================================================================================================= + +#! Checks that the note sender holds the given role. +#! +#! Inputs: [role_symbol, pad(15)] +#! Outputs: [pad(16)] +#! +#! Where: +#! - role_symbol is the encoded role symbol. +#! +#! Panics if: +#! - role_symbol is zero. +#! - the note sender does not hold the given role. +#! +#! Invocation: call +pub proc assert_sender_has_role + exec.assert_role_symbol_non_zero + # => [role_symbol, pad(15)] + + exec.is_sender_in_role + # => [has_role, pad(15)] + + assert.err=ERR_SENDER_LACKS_ROLE + # => [pad(16)] +end + +#! Returns whether an account holds a role. +#! +#! Returns `0` if the role does not exist or the account is not a member. +#! +#! Inputs: [role_symbol, account_suffix, account_prefix, pad(13)] +#! Outputs: [has_role, pad(15)] +#! +#! Where: +#! - role_symbol is the encoded role symbol. +#! - account_{suffix,prefix} are the suffix and prefix felts of the account ID. +#! - has_role is 1 if the account holds the role, otherwise 0. +#! +#! Invocation: call +pub proc has_role + exec.has_role_internal + # => [has_role, pad(15)] +end + +#! Returns the delegated admin role for a role. +#! +#! Returns `0` when the role is owner-managed or when no role config exists for +#! `role_symbol`. +#! +#! Inputs: [role_symbol, pad(15)] +#! Outputs: [admin_role_symbol, pad(15)] +#! +#! Where: +#! - role_symbol is the encoded role symbol. +#! - admin_role_symbol is the encoded admin role symbol, or 0 if the role is managed +#! by the owner. +#! +#! Invocation: call +pub proc get_role_admin + exec.get_role_admin_internal + # => [admin_role_symbol, pad(15)] +end + +#! Returns the number of accounts assigned to a given role. +#! +#! If the role does not exist (i.e. has never had a member, or had its last member +#! revoked), this returns `0` — the underlying role config map returns `Word::ZERO` +#! for missing keys. +#! +#! Inputs: [role_symbol, pad(15)] +#! Outputs: [member_count, pad(15)] +#! +#! Where: +#! - role_symbol is the encoded role symbol. +#! - member_count is the number of accounts currently assigned to the given role, or +#! `0` if no such role exists. +#! +#! Invocation: call +pub proc get_role_member_count + exec.get_role_member_count_internal + # => [member_count, pad(15)] +end + +#! Sets or updates the delegated admin role for a role. +#! +#! The role does not need to exist (have members) when this is called: the admin +#! relationship is recorded in the role's config so it takes effect as soon as the +#! role gains its first member. Calling this on a role that has no members does not +#! make the role exist. +#! +#! Pass `admin_role_symbol = 0` to clear the delegation and revert the role to +#! owner-only management. +#! +#! Inputs: [role_symbol, admin_role_symbol, pad(14)] +#! Outputs: [pad(16)] +#! +#! Where: +#! - role_symbol is the encoded role symbol to configure. +#! - admin_role_symbol is the encoded delegated admin role symbol, or 0 for +#! owner-only management. +#! +#! Panics if: +#! - the note sender is not the current owner. +#! - role_symbol is zero. +#! +#! Invocation: call +pub proc set_role_admin + exec.ownable2step::assert_sender_is_owner_internal + exec.assert_role_symbol_non_zero + # => [role_symbol, new_admin_role_symbol, pad(14)] + + dup exec.get_role_member_count_internal + # => [member_count, role_symbol, new_admin_role_symbol, pad(14)] + + swap + # => [role_symbol, member_count, new_admin_role_symbol, pad(14)] + + exec.set_role_config + # => [pad(16)] +end + +#! Grants a role to an account. +#! +#! If the role does not yet exist, it is created (made existing) by this grant. If the +#! account is already a member of the role, this is a no-op. +#! +#! Inputs: [role_symbol, account_suffix, account_prefix, pad(13)] +#! Outputs: [pad(16)] +#! +#! Where: +#! - role_symbol is the encoded role symbol to grant. +#! - account_{suffix,prefix} are the suffix and prefix felts of the account ID. +#! +#! Panics if: +#! - role_symbol is zero. +#! - the note sender is neither the current owner nor a holder of the role's +#! delegated admin role. +#! - the account ID is invalid. +#! +#! Invocation: call +pub proc grant_role + exec.assert_role_symbol_non_zero + # => [role_symbol, account_suffix, account_prefix, pad(13)] + + dup exec.assert_sender_is_owner_or_role_admin + # => [role_symbol, account_suffix, account_prefix, pad(13)] + + exec.grant_role_internal + # => [pad(16)] +end + +#! Revokes a role from an account. +#! +#! Decrements the role's member count. If the removed account was the last member, +#! the role becomes non-existent (member_count = 0). +#! +#! Inputs: [role_symbol, account_suffix, account_prefix, pad(13)] +#! Outputs: [pad(16)] +#! +#! Where: +#! - role_symbol is the encoded role symbol to revoke. +#! - account_{suffix,prefix} are the suffix and prefix felts of the account ID. +#! +#! Panics if: +#! - role_symbol is zero. +#! - the note sender is neither the current owner nor a holder of the role's +#! delegated admin role. +#! - the account does not hold the role. +#! - the account ID is invalid. +#! +#! Invocation: call +pub proc revoke_role + exec.assert_role_symbol_non_zero + # => [role_symbol, account_suffix, account_prefix, pad(13)] + + dup exec.assert_sender_is_owner_or_role_admin + # => [role_symbol, account_suffix, account_prefix, pad(13)] + + exec.revoke_role_internal + # => [pad(16)] +end + +#! Renounces a role held by the note sender. +#! +#! Inputs: [role_symbol, pad(15)] +#! Outputs: [pad(16)] +#! +#! Where: +#! - role_symbol is the encoded role symbol to renounce. +#! +#! Panics if: +#! - role_symbol is zero. +#! - the note sender does not hold the role. +#! - the note sender account ID fails validation. +#! +#! Invocation: call +pub proc renounce_role + exec.assert_role_symbol_non_zero + # => [role_symbol, pad(15)] + + exec.active_note::get_sender + # => [sender_suffix, sender_prefix, role_symbol, pad(15)] + + movup.2 + # => [role_symbol, sender_suffix, sender_prefix, pad(13)] + + exec.revoke_role_internal + # => [pad(16)] +end + +# HELPER PROCEDURES +# ================================================================================================= + +#! Asserts that a role symbol is non-zero. +#! +#! Inputs: [role_symbol] +#! Outputs: [role_symbol] +#! +#! 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] +end + +#! Returns the config for a role. +#! +#! Inputs: [role_symbol] +#! Outputs: [member_count, admin_role_symbol] +#! +#! Invocation: exec +proc get_role_config + push.0.0.0 + # => [0, 0, 0, role_symbol] + + push.ROLE_CONFIG_SLOT[0..2] + # => [slot_suffix, slot_prefix, 0, 0, 0, role_symbol] + + exec.active_account::get_map_item + # => [member_count, admin_role_symbol, 0, 0] + + movup.2 drop movup.2 drop + # => [member_count, admin_role_symbol] +end + +#! Writes the config word for a role. +#! +#! The trailing zeros of the stored config word are appended internally so callers do +#! not need to push them themselves. +#! +#! Inputs: [role_symbol, member_count, admin_role_symbol] +#! Outputs: [] +#! +#! Invocation: exec +proc set_role_config + push.0.0 + # => [0, 0, role_symbol, member_count, admin_role_symbol] + + movdn.4 movdn.4 + # => [role_symbol, member_count, admin_role_symbol, 0, 0] + + push.0.0.0 + # => [0, 0, 0, role_symbol, member_count, admin_role_symbol, 0, 0] + + push.ROLE_CONFIG_SLOT[0..2] + # => [slot_suffix, slot_prefix, 0, 0, 0, role_symbol, + # member_count, admin_role_symbol, 0, 0] + + exec.native_account::set_map_item + # => [OLD_ROLE_CONFIG] + + dropw + # => [] +end + +#! Returns whether an account holds a role. +#! +#! Inputs: [role_symbol, account_suffix, account_prefix] +#! Outputs: [has_role] +#! +#! Invocation: exec +proc has_role_internal + push.0 + # => [0, role_symbol, account_suffix, account_prefix] + + push.ROLE_MEMBERSHIP_SLOT[0..2] + # => [slot_suffix, slot_prefix, 0, role_symbol, account_suffix, account_prefix] + + exec.active_account::get_map_item + # => [is_member, 0, 0, 0] + + movdn.3 drop drop drop + # => [is_member] +end + +#! Returns the number of accounts assigned to a role. +#! +#! Inputs: [role_symbol] +#! Outputs: [member_count] +#! +#! Invocation: exec +proc get_role_member_count_internal + exec.get_role_config + # => [member_count, admin_role_symbol] + + swap drop + # => [member_count] +end + +#! Returns the delegated admin role for a role (0 if none). +#! +#! Inputs: [role_symbol] +#! Outputs: [admin_role_symbol] +#! +#! Invocation: exec +proc get_role_admin_internal + exec.get_role_config + # => [member_count, admin_role_symbol] + + drop + # => [admin_role_symbol] +end + +#! Returns whether the note sender holds a role. +#! +#! Inputs: [role_symbol] +#! Outputs: [has_role] +#! +#! Invocation: exec +proc is_sender_in_role + exec.active_note::get_sender + # => [sender_suffix, sender_prefix, role_symbol] + + movup.2 + # => [role_symbol, sender_suffix, sender_prefix] + + exec.has_role_internal + # => [has_role] +end + +#! Asserts that the note sender is the owner or a member of the role's delegated +#! admin role. +#! +#! Inputs: [role_symbol] +#! Outputs: [] +#! +#! Panics if: +#! - the note sender is neither the current owner nor a holder of the role's +#! delegated admin role. +#! +#! Invocation: exec +proc assert_sender_is_owner_or_role_admin + exec.ownable2step::is_sender_owner_internal + # => [is_owner, role_symbol] + + if.true + drop + # => [] + else + # [role_symbol] - look up the role's admin role and check sender membership + exec.get_role_admin_internal + # => [admin_role_symbol] + + # Reject if no admin role is configured (sender can't be a holder of role 0). + dup eq.0 + assertz.err=ERR_SENDER_NOT_OWNER_OR_ROLE_ADMIN + # => [admin_role_symbol] + + exec.is_sender_in_role + # => [has_admin_role] + + assert.err=ERR_SENDER_NOT_OWNER_OR_ROLE_ADMIN + # => [] + end +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. +#! +#! Inputs: [role_symbol, account_suffix, account_prefix] +#! Outputs: [] +#! +#! Invocation: exec +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] + + # Write membership[role, account] = [1, 0, 0, 0]. + push.SET_MEMBERSHIP + # => [1, 0, 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.0 + # => [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] + + dropw + # => [role_symbol] + + # Increment the role's member count. + dup exec.get_role_config + # => [member_count, admin_role_symbol, role_symbol] + + add.1 u32assert.err=ERR_MEMBER_COUNT_OVERFLOW + # => [member_count + 1, admin_role_symbol, role_symbol] + + movup.2 + # => [role_symbol, member_count + 1, admin_role_symbol] + + exec.set_role_config + # => [] + end +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. +#! +#! Inputs: [role_symbol, account_suffix, account_prefix] +#! Outputs: [] +#! +#! Panics if: +#! - the account ID is invalid. +#! - the account does not hold the role. +#! +#! Invocation: exec +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] + + # 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.0 + # => [0, role_symbol, account_suffix, account_prefix, 0, 0, 0, 0, role_symbol] + + push.ROLE_MEMBERSHIP_SLOT[0..2] + # => [slot_suffix, slot_prefix, 0, role_symbol, account_suffix, account_prefix, + # 0, 0, 0, 0, role_symbol] + + exec.native_account::set_map_item + # => [OLD_MEMBERSHIP_WORD, role_symbol] + + dropw + # => [role_symbol] + + # Decrement the role's member count. + dup exec.get_role_config + # => [member_count, admin_role_symbol, role_symbol] + + sub.1 + # => [member_count - 1, admin_role_symbol, role_symbol] + + movup.2 + # => [role_symbol, member_count - 1, admin_role_symbol] + + exec.set_role_config + # => [] +end diff --git a/crates/miden-standards/asm/standards/attachments/network_account_target.masm b/crates/miden-standards/asm/standards/attachments/network_account_target.masm index a5ee0bde40..d770056599 100644 --- a/crates/miden-standards/asm/standards/attachments/network_account_target.masm +++ b/crates/miden-standards/asm/standards/attachments/network_account_target.masm @@ -5,38 +5,36 @@ use miden::protocol::account_id use miden::protocol::active_account use miden::protocol::active_note -use miden::protocol::note # CONSTANTS # ================================================================================================ #! The attachment scheme for NetworkAccountTarget attachments. #! This is a valid u32 that can be compared against an extracted attachment scheme. -pub const NETWORK_ACCOUNT_TARGET_ATTACHMENT_SCHEME = 1 +pub const NETWORK_ACCOUNT_TARGET_ATTACHMENT_SCHEME = 2 -#! The attachment kind for NetworkAccountTarget attachments (Word = 1). -#! This is a valid u32 that can be compared against an extracted attachment kind. -pub const NETWORK_ACCOUNT_TARGET_ATTACHMENT_KIND = 1 +#! The number of words in the network account target attachment. +pub const NETWORK_ACCOUNT_TARGET_ATTACHMENT_NUM_WORDS = 1 # ERRORS # ================================================================================================ -const ERR_NOT_NETWORK_ACCOUNT_TARGET = "attachment is not a valid network account target" -#! Returns a boolean indicating whether the attachment scheme and kind match the expected -#! values for a NetworkAccountTarget attachment. +const ERR_NETWORK_ACCOUNT_TARGET_MISSING = "network account target attachment is not present on active note" + +const ERR_NETWORK_ACCOUNT_TARGET_INCORRECT_NUMBER_OF_WORDS = "network account target attachment must consist of exactly one word" + +# PROCEDURES +# ================================================================================================ + +#! Returns a boolean indicating whether the attachment scheme matches the expected +#! scheme for a NetworkAccountTarget attachment. #! -#! Inputs: [attachment_scheme, attachment_kind] +#! Inputs: [attachment_scheme] #! Outputs: [is_network_account_target] #! #! Invocation: exec pub proc is_network_account_target eq.NETWORK_ACCOUNT_TARGET_ATTACHMENT_SCHEME - # => [is_scheme_valid, attachment_kind] - - swap eq.NETWORK_ACCOUNT_TARGET_ATTACHMENT_KIND - # => [is_kind_valid, is_scheme_valid] - - and # => [is_network_account_target] end @@ -45,21 +43,22 @@ end #! The attachment is expected to have the following layout: #! [account_id_suffix, account_id_prefix, exec_hint_tag, 0] #! -#! WARNING: This procedure does not validate the attachment scheme or kind. The caller -#! should validate these using `is_network_account_target` before calling this procedure. +#! WARNING: This procedure does not validate the attachment scheme. The caller +#! should validate it using `is_network_account_target` before calling this procedure. #! #! WARNING: This procedure does not validate that the returned account ID is well-formed. #! The caller should validate the account ID if needed using `account_id::validate`. #! -#! Inputs: [NOTE_ATTACHMENT] +#! Inputs: [NETWORK_ACCOUNT_TARGET_ATTACHMENT] #! Outputs: [account_id_suffix, account_id_prefix] #! #! Where: #! - account_id_{suffix,prefix} are the suffix and prefix felts of an account ID. #! #! Invocation: exec -pub proc get_id - # => [NOTE_ATTACHMENT] = [account_id_suffix, account_id_prefix, exec_hint_tag, 0] +pub proc into_target_id + # => [NETWORK_ACCOUNT_TARGET_ATTACHMENT] + # => [account_id_suffix, account_id_prefix, exec_hint_tag, 0] movup.2 drop movup.2 drop # => [account_id_suffix, account_id_prefix] @@ -69,56 +68,64 @@ end #! [account_id_suffix, account_id_prefix, exec_hint_tag, 0] #! #! Inputs: [account_id_suffix, account_id_prefix, exec_hint_tag] -#! Outputs: [attachment_scheme, attachment_kind, NOTE_ATTACHMENT] +#! Outputs: [attachment_scheme, NOTE_ATTACHMENT] #! #! Where: #! - account_id_{suffix,prefix} are the suffix and prefix felts of an account ID. #! - exec_hint_tag is the encoded execution hint for the note with its tag. -#! - attachment_kind is the attachment kind (Word = 1) for use with `output_note::set_attachment`. -#! - attachment_scheme is the attachment scheme (1) for use with `output_note::set_attachment`. +#! - attachment_scheme is the attachment scheme (1) for use with `output_note::add_word_attachment`. #! #! Invocation: exec pub proc new # => [account_id_suffix, account_id_prefix, exec_hint_tag] push.0 movdn.3 # => [NOTE_ATTACHMENT] = [account_id_suffix, account_id_prefix, exec_hint_tag, 0] - push.NETWORK_ACCOUNT_TARGET_ATTACHMENT_KIND push.NETWORK_ACCOUNT_TARGET_ATTACHMENT_SCHEME - # => [attachment_scheme, attachment_kind, NOTE_ATTACHMENT] + # => [attachment_scheme, NOTE_ATTACHMENT] end #! Returns a boolean indicating whether the active account matches the target account #! encoded in the active note's attachment. #! -#! Inputs: [] +#! Inputs: [] #! Outputs: [is_equal] #! #! Where: #! - is_equal is a boolean indicating whether the active account matches the target account. #! #! Panics if: -#! - the attachment is not a valid network account target. +#! - the attachment is missing from the active note or the number of words is invalid. #! #! Invocation: exec +@locals(4) pub proc active_account_matches_target_account - # ensure note attachment targets the consuming bridge account - exec.active_note::get_metadata - # => [NOTE_ATTACHMENT, METADATA_HEADER] + # find the network account target attachment, if any + # if there are multiple, we consider the first one the canonical one + push.NETWORK_ACCOUNT_TARGET_ATTACHMENT_SCHEME + exec.active_note::find_attachment + # => [is_found, attachment_idx] + + # network account target attachment must be present + assert.err=ERR_NETWORK_ACCOUNT_TARGET_MISSING + # => [attachment_idx] - swapw - # => [METADATA_HEADER, NOTE_ATTACHMENT] + # write the attachment data to local memory + # we allocate one word of local memory matching NETWORK_ACCOUNT_TARGET_ATTACHMENT_NUM_WORDS + locaddr.0 + # => [dest_ptr, attachment_idx] - exec.note::extract_attachment_info_from_metadata - # => [attachment_kind, attachment_scheme, NOTE_ATTACHMENT] + exec.active_note::write_attachment_to_memory + # => [num_words] - swap - # => [attachment_scheme, attachment_kind, NOTE_ATTACHMENT] + eq.NETWORK_ACCOUNT_TARGET_ATTACHMENT_NUM_WORDS + assert.err=ERR_NETWORK_ACCOUNT_TARGET_INCORRECT_NUMBER_OF_WORDS + # => [] - # ensure the attachment is a network account target - exec.is_network_account_target assert.err=ERR_NOT_NETWORK_ACCOUNT_TARGET - # => [NOTE_ATTACHMENT] = [target_id_suffix, target_id_prefix, exec_hint_tag, 0] + padw loc_loadw_le.0 + # => [NETWORK_ACCOUNT_TARGET_ATTACHMENT] + # => [target_id_suffix, target_id_prefix, exec_hint_tag, 0] - exec.get_id + exec.into_target_id # => [target_id_suffix, target_id_prefix] exec.active_account::get_id diff --git a/crates/miden-standards/asm/standards/auth/guardian.masm b/crates/miden-standards/asm/standards/auth/guardian.masm new file mode 100644 index 0000000000..3f4ba0bc41 --- /dev/null +++ b/crates/miden-standards/asm/standards/auth/guardian.masm @@ -0,0 +1,163 @@ +# State Guardian account component. +# This component is composed into account auth flows especially for multisig and adds +# an extra signature check by a dedicated guardian signer. +# +# A state guardian can help coordinate state availability for private accounts. + +use miden::protocol::auth::AUTH_UNAUTHORIZED_EVENT +use miden::protocol::native_account +use miden::standards::auth::tx_policy +use miden::standards::auth::signature + +# IMPORTANT SECURITY NOTES +# -------------------------------------------------------------------------------- +# - By default, exactly one valid guardian signature is required. +# - If `update_guardian_public_key` is the only non-auth account procedure called in the current +# transaction, `verify_signature` skips the guardian signature check so key rotation can proceed +# without the old guardian signer. +# - `update_guardian_public_key` rotates the guardian public key and corresponding +# scheme id using the fixed map key `GUARDIAN_MAP_KEY`. + + +# CONSTANTS +# ================================================================================================= + +# Storage Slots +# +# This authentication component uses named storage slots. +# - GUARDIAN_PUBLIC_KEYS_SLOT (map): +# GUARDIAN_MAP_KEY => GUARDIAN_PUBLIC_KEY +# where: GUARDIAN_MAP_KEY = [0, 0, 0, 0] +# +# - GUARDIAN_SCHEME_ID_SLOT (map): +# GUARDIAN_MAP_KEY => [scheme_id, 0, 0, 0] +# where: GUARDIAN_MAP_KEY = [0, 0, 0, 0] + +# The slot in this component's storage layout where the guardian public key map is stored. +# Map entries: [GUARDIAN_MAP_KEY] => [GUARDIAN_PUBLIC_KEY] +const GUARDIAN_PUBLIC_KEYS_SLOT = word("miden::standards::auth::guardian::pub_key") + +# The slot in this component's storage layout where the scheme id for the corresponding guardian +# public key map is stored. +# Map entries: [GUARDIAN_MAP_KEY] => [scheme_id, 0, 0, 0] +const GUARDIAN_SCHEME_ID_SLOT = word("miden::standards::auth::guardian::scheme") + +# Single-entry storage map key where guardian signer data is stored. +const GUARDIAN_MAP_KEY = [0, 0, 0, 0] + +# ERRORS +# ------------------------------------------------------------------------------------------------- +const ERR_INVALID_GUARDIAN_SIGNATURE = "invalid guardian signature" + +# PUBLIC INTERFACE +# ================================================================================================ + +#! Updates the guardian public key. +#! +#! Inputs: [new_guardian_scheme_id, NEW_GUARDIAN_PUBLIC_KEY] +#! Outputs: [] +#! +#! Notes: +#! - This procedure only updates the guardian public key and corresponding scheme id. +#! - `verify_signature` skips guardian verification only when this is the only non-auth account +#! procedure called in the transaction. +#! +#! Invocation: call +@locals(1) +pub proc update_guardian_public_key(new_guardian_scheme_id: felt, new_guardian_public_key: word) + # Validate supported signature scheme before committing it to storage. + dup exec.signature::assert_supported_scheme + # => [new_guardian_scheme_id, NEW_GUARDIAN_PUBLIC_KEY] + + loc_store.0 + # => [NEW_GUARDIAN_PUBLIC_KEY] + + push.GUARDIAN_MAP_KEY + # => [GUARDIAN_MAP_KEY, NEW_GUARDIAN_PUBLIC_KEY] + + push.GUARDIAN_PUBLIC_KEYS_SLOT[0..2] + # => [guardian_pubkeys_slot_prefix, guardian_pubkeys_slot_suffix, + # GUARDIAN_MAP_KEY, NEW_GUARDIAN_PUBLIC_KEY] + + exec.native_account::set_map_item + # => [OLD_GUARDIAN_PUBLIC_KEY] + + dropw + # => [] + + # Store new scheme id as [scheme_id, 0, 0, 0] in the single-entry map. + loc_load.0 + # => [scheme_id] + + push.0.0.0 movup.3 + # => [NEW_GUARDIAN_SCHEME_ID_WORD] + + push.GUARDIAN_MAP_KEY + # => [GUARDIAN_MAP_KEY, NEW_GUARDIAN_SCHEME_ID_WORD] + + push.GUARDIAN_SCHEME_ID_SLOT[0..2] + # => [guardian_scheme_slot_prefix, guardian_scheme_slot_suffix, + # GUARDIAN_MAP_KEY, NEW_GUARDIAN_SCHEME_ID_WORD] + + exec.native_account::set_map_item + # => [OLD_GUARDIAN_SCHEME_ID_WORD] + + dropw + # => [] +end + +#! Conditionally verifies a guardian signature. +#! +#! Inputs: [MSG] +#! Outputs: [] +#! +#! Panics if: +#! - `update_guardian_public_key` is called together with another non-auth account procedure. +#! - `update_guardian_public_key` was not called and a valid guardian signature is missing or +#! invalid. +#! +#! Invocation: exec +pub proc verify_signature(msg: word) + procref.update_guardian_public_key + # => [UPDATE_GUARDIAN_PUBLIC_KEY_ROOT, MSG] + + exec.native_account::was_procedure_called + # => [was_update_guardian_public_key_called, MSG] + + if.true + exec.tx_policy::assert_only_one_non_auth_procedure_called + # => [MSG] + + exec.tx_policy::assert_no_input_notes + exec.tx_policy::assert_no_output_notes + # => [MSG] + + dropw + # => [] + else + push.1 + # => [1, MSG] + + push.GUARDIAN_PUBLIC_KEYS_SLOT[0..2] + # => [guardian_pubkeys_slot_prefix, guardian_pubkeys_slot_suffix, 1, MSG] + + push.GUARDIAN_SCHEME_ID_SLOT[0..2] + # => [guardian_scheme_slot_prefix, guardian_scheme_slot_suffix, + # guardian_pubkeys_slot_prefix, guardian_pubkeys_slot_suffix, 1, MSG] + + exec.signature::verify_signatures + # => [num_verified_signatures, MSG] + + neq.1 + # => [is_not_exactly_one, MSG] + + if.true + emit.AUTH_UNAUTHORIZED_EVENT + push.0 assert.err=ERR_INVALID_GUARDIAN_SIGNATURE + end + # => [MSG] + + dropw + # => [] + end +end diff --git a/crates/miden-standards/asm/standards/auth/multisig.masm b/crates/miden-standards/asm/standards/auth/multisig.masm index ed20ff2325..26b9f67abf 100644 --- a/crates/miden-standards/asm/standards/auth/multisig.masm +++ b/crates/miden-standards/asm/standards/auth/multisig.masm @@ -6,6 +6,7 @@ use miden::protocol::active_account use miden::protocol::auth::AUTH_UNAUTHORIZED_EVENT use miden::protocol::native_account use miden::standards::auth +use miden::standards::auth::signature use miden::core::word # Local Memory Addresses @@ -40,20 +41,19 @@ const DEFAULT_THRESHOLD_LOC=0 # - PROC_THRESHOLD_ROOTS_SLOT (map): # PROC_ROOT => [proc_threshold, 0, 0, 0] - # The slot in this component's storage layout where the default signature threshold and # number of approvers are stored as: # [default_threshold, num_approvers, 0, 0]. # The threshold is guaranteed to be less than or equal to num_approvers. -const THRESHOLD_CONFIG_SLOT = word("miden::standards::auth::multisig::threshold_config") +pub const THRESHOLD_CONFIG_SLOT = word("miden::standards::auth::multisig::threshold_config") # The slot in this component's storage layout where the public keys map is stored. # Map entries: [key_index, 0, 0, 0] => APPROVER_PUBLIC_KEY -const APPROVER_PUBLIC_KEYS_SLOT = word("miden::standards::auth::multisig::approver_public_keys") +pub const APPROVER_PUBLIC_KEYS_SLOT = word("miden::standards::auth::multisig::approver_public_keys") # The slot in this component's storage layout where the scheme id for the corresponding public keys map is stored. # Map entries: [key_index, 0, 0, 0] => [scheme_id, 0, 0, 0] -const APPROVER_SCHEME_ID_SLOT = word("miden::standards::auth::multisig::approver_schemes") +pub const APPROVER_SCHEME_ID_SLOT = word("miden::standards::auth::multisig::approver_schemes") # The slot in this component's storage layout where executed transactions are stored. # Map entries: transaction_message => [is_executed, 0, 0, 0] @@ -101,7 +101,7 @@ const ERR_PROC_THRESHOLD_EXCEEDS_NUM_APPROVERS = "procedure threshold exceeds ne #! Panics if: #! - init_num_of_approvers is not a u32 value. #! - new_num_of_approvers is not a u32 value. -proc cleanup_pubkey_and_scheme_id_mapping(init_num_of_approvers: u32, new_num_of_approvers: u32) +pub proc cleanup_pubkey_and_scheme_id_mapping(init_num_of_approvers: u32, new_num_of_approvers: u32) dup.1 dup.1 u32assert2.err=ERR_APPROVER_COUNTS_NOT_U32 u32lt @@ -114,7 +114,7 @@ proc cleanup_pubkey_and_scheme_id_mapping(init_num_of_approvers: u32, new_num_of # => [i-1, new_num_of_approvers] # clear scheme id at APPROVER_MAP_KEY(i-1) - dup exec.create_approver_map_key + dup exec.signature::create_approver_map_key # => [APPROVER_MAP_KEY, i-1, new_num_of_approvers] padw swapw @@ -130,7 +130,7 @@ proc cleanup_pubkey_and_scheme_id_mapping(init_num_of_approvers: u32, new_num_of # => [i-1, new_num_of_approvers] # clear public key at APPROVER_MAP_KEY(i-1) - dup exec.create_approver_map_key + dup exec.signature::create_approver_map_key # => [APPROVER_MAP_KEY, i-1, new_num_of_approvers] padw swapw @@ -153,18 +153,8 @@ proc cleanup_pubkey_and_scheme_id_mapping(init_num_of_approvers: u32, new_num_of drop drop end -#! Builds the storage map key for a signer index. -#! -#! Inputs: [key_index] -#! Outputs: [APPROVER_MAP_KEY] -proc create_approver_map_key - push.0.0.0 movup.3 - # => [[key_index, 0, 0, 0]] - # => [APPROVER_MAP_KEY] -end - -#! Asserts that all configured per-procedure threshold overrides are less than or equal to -#! number of approvers +#! Asserts that all configured per-procedure threshold overrides are less than or equal to +#! number of approvers. #! #! Inputs: [num_approvers] #! Outputs: [] @@ -179,31 +169,34 @@ proc assert_proc_thresholds_lte_num_approvers(num_approvers: u32) # => [should_continue, num_procedures, num_approvers] while.true sub.1 dup - # => [proc_index, proc_index, num_approvers] + # => [num_procedures-1, num_procedures-1, num_approvers] exec.active_account::get_procedure_root - # => [PROC_ROOT, proc_index, num_approvers] + # => [PROC_ROOT, num_procedures-1, num_approvers] push.PROC_THRESHOLD_ROOTS_SLOT[0..2] - # => [proc_roots_slot_suffix, proc_roots_slot_prefix, PROC_ROOT, proc_index, num_approvers] + # => [proc_roots_slot_suffix, proc_roots_slot_prefix, PROC_ROOT, num_procedures-1, num_approvers] + # Use the *current* policy state, not the initial one; otherwise a `set_proc_threshold` + # call earlier in the same transaction that raised a threshold above the new num_approvers + # would be missed and the multisig could end up with an unreachable threshold. exec.active_account::get_map_item - # => [[proc_threshold, 0, 0, 0], proc_index, num_approvers] + # => [[proc_threshold, 0, 0, 0], num_procedures-1, num_approvers] movdn.3 drop drop drop - # => [proc_threshold, proc_index, num_approvers] + # => [proc_threshold, num_procedures-1, num_approvers] dup.2 - # => [num_approvers, proc_threshold, proc_index, num_approvers] + # => [num_approvers, proc_threshold, num_procedures-1, num_approvers] u32assert2.err=ERR_PROC_THRESHOLD_NOT_U32 u32gt assertz.err=ERR_PROC_THRESHOLD_EXCEEDS_NUM_APPROVERS - # => [proc_index, num_approvers] + # => [num_procedures-1, num_approvers] dup neq.0 - # => [should_continue, proc_index, num_approvers] + # => [should_continue, num_procedures-1, num_approvers] end - # => [proc_index, num_approvers] + # => [num_procedures-1, num_approvers] drop drop # => [] @@ -289,7 +282,7 @@ pub proc update_signers_and_threshold(multisig_config_hash: word) sub.1 # => [i-1, pad(12)] - dup exec.create_approver_map_key + dup exec.signature::create_approver_map_key # => [APPROVER_MAP_KEY, i-1, pad(12)] padw adv_loadw @@ -312,7 +305,7 @@ pub proc update_signers_and_threshold(multisig_config_hash: word) exec.auth::signature::assert_supported_scheme_word # => [SCHEME_ID_WORD, i-1, pad(12)] - dup.4 exec.create_approver_map_key + dup.4 exec.signature::create_approver_map_key # => [APPROVER_MAP_KEY, SCHEME_ID_WORD, i-1, pad(12)] push.APPROVER_SCHEME_ID_SLOT[0..2] @@ -346,16 +339,32 @@ end # overrides stored in PROC_THRESHOLD_ROOTS_SLOT. Falls back to default_threshold if no # overrides apply. # +# Each called non-auth procedure contributes a threshold to a max accumulator: +# - if the procedure has a stored override, its override value is the contribution. +# - if the procedure has no override, `default_threshold` is the contribution so a procedure +# without a policy cannot be authorized at a lower threshold by being called alongside a +# low-override procedure (e.g. `receive_asset` configured at 1) in the same transaction. +# +# The auth procedure (procedure index 0) is skipped — it is the auth flow itself, not a +# user-callable account procedure, and counting it would make `transaction_threshold` always +# include `default_threshold`. +# +# When no non-auth procedure was called, `transaction_threshold` stays 0 and is reverted to +# `default_threshold` at the end. +# #! Inputs: [default_threshold] #! Outputs: [transaction_threshold] @locals(1) proc compute_transaction_threshold(default_threshold: u32) -> u32 # 1. initialize transaction_threshold = 0 - # 2. iterate through all account procedures + # 2. iterate through all non-auth account procedures # a. check if the procedure was called during the transaction # b. if called, get the override threshold of that procedure from the config map - # c. if proc_threshold > transaction_threshold, set transaction_threshold = proc_threshold - # 3. if transaction_threshold == 0 at the end, revert to using default_threshold + # c. if the procedure has no override (proc_threshold == 0), contribute default_threshold + # instead so the unpolicied call is still constrained + # d. if contribution > transaction_threshold, set transaction_threshold = contribution + # 3. if transaction_threshold == 0 at the end (no non-auth proc was called), revert to + # using default_threshold # store default_threshold for later loc_store.DEFAULT_THRESHOLD_LOC @@ -373,46 +382,60 @@ proc compute_transaction_threshold(default_threshold: u32) -> u32 dup neq.0 # => [should_continue, num_procedures, transaction_threshold] while.true - sub.1 dup - # => [num_procedures-1, num_procedures-1, transaction_threshold] - - # get procedure root of the procedure with index i - exec.active_account::get_procedure_root dupw - # => [PROC_ROOT, PROC_ROOT, num_procedures-1, transaction_threshold] - - # 2a. check if this procedure has been called in the transaction - exec.native_account::was_procedure_called - # => [was_called, PROC_ROOT, num_procedures-1, transaction_threshold] + sub.1 + # => [num_procedures-1, transaction_threshold] - # if it has been called, get the override threshold of that procedure + # Skip the auth procedure at index 0 — it is the auth flow itself, not a user-callable + # account procedure. Counting it would make transaction_threshold always include + # default_threshold and break the override-down semantic for single-proc transactions. + dup neq.0 + # => [is_non_auth, num_procedures-1, transaction_threshold] if.true - # => [PROC_ROOT, num_procedures-1, transaction_threshold] - - push.PROC_THRESHOLD_ROOTS_SLOT[0..2] - # => [proc_roots_slot_suffix, proc_roots_slot_prefix, PROC_ROOT, num_procedures-1, transaction_threshold] - - # 2b. get the override proc_threshold of that procedure - # if the procedure has no override threshold, the returned map item will be [0, 0, 0, 0] - exec.active_account::get_initial_map_item - # => [[proc_threshold, 0, 0, 0], num_procedures-1, transaction_threshold] - - movdn.3 drop drop drop dup dup.3 - # => [transaction_threshold, proc_threshold, proc_threshold, num_procedures-1, transaction_threshold] - - u32assert2.err="transaction threshold or procedure threshold are not u32" - u32gt - # => [is_gt, proc_threshold, num_procedures-1, transaction_threshold] - # 2c. if proc_threshold > transaction_threshold, update transaction_threshold - movup.2 movdn.3 - # => [is_gt, proc_threshold, transaction_threshold, num_procedures-1] - cdrop - # => [updated_transaction_threshold, num_procedures-1] - swap - # => [num_procedures-1, updated_transaction_threshold] - # if it has not been called during this transaction, nothing to do, move to the next procedure - else - dropw - # => [num_procedures-1, transaction_threshold] + # get procedure root of the procedure with index i + dup exec.active_account::get_procedure_root dupw + # => [PROC_ROOT, PROC_ROOT, num_procedures-1, transaction_threshold] + + # 2a. check if this procedure has been called in the transaction + exec.native_account::was_procedure_called + # => [was_called, PROC_ROOT, num_procedures-1, transaction_threshold] + + # if it has been called, compute and apply its threshold contribution + if.true + # => [PROC_ROOT, num_procedures-1, transaction_threshold] + + push.PROC_THRESHOLD_ROOTS_SLOT[0..2] + # => [proc_roots_slot_suffix, proc_roots_slot_prefix, PROC_ROOT, num_procedures-1, transaction_threshold] + + # 2b. get the override proc_threshold of that procedure + # if the procedure has no override threshold, the returned map item will be [0, 0, 0, 0] + exec.active_account::get_initial_map_item + # => [[proc_threshold, 0, 0, 0], num_procedures-1, transaction_threshold] + + movdn.3 drop drop drop + # => [proc_threshold, num_procedures-1, transaction_threshold] + + # 2c. if the procedure has no override (proc_threshold == 0), contribute + # default_threshold instead so the unpolicied call cannot be silently authorized + # at a lower threshold by being called alongside a low-override procedure + # (e.g. `receive_asset` configured at 1) in the same transaction. + dup eq.0 + if.true + drop + loc_load.DEFAULT_THRESHOLD_LOC + end + # => [contribution, num_procedures-1, transaction_threshold] + + # 2d. transaction_threshold = max(transaction_threshold, contribution) + movup.2 u32max + # => [new_transaction_threshold, num_procedures-1] + + swap + # => [num_procedures-1, new_transaction_threshold] + # if it has not been called during this transaction, nothing to do, move to the next procedure + else + dropw + # => [num_procedures-1, transaction_threshold] + end end dup neq.0 @@ -425,7 +448,7 @@ proc compute_transaction_threshold(default_threshold: u32) -> u32 loc_load.DEFAULT_THRESHOLD_LOC # => [default_threshold, transaction_threshold] - # 3. if transaction_threshold == 0 at the end, revert to using default_threshold + # 3. if no non-auth proc was called, transaction_threshold is 0; fall back to default_threshold dup.1 eq.0 # => [is_zero, default_threshold, transaction_threshold] @@ -433,19 +456,34 @@ proc compute_transaction_threshold(default_threshold: u32) -> u32 # => [effective_transaction_threshold] end -#! Returns current num_approvers and the threshold `THRESHOLD_CONFIG_SLOT` +#! Returns the initial threshold and num_approvers from `THRESHOLD_CONFIG_SLOT`. #! #! Inputs: [] -#! Outputs: [threshold, num_approvers] +#! Outputs: [default_threshold, num_approvers] +#! +#! Invocation: exec +pub proc get_initial_threshold_and_num_approvers + push.THRESHOLD_CONFIG_SLOT[0..2] + exec.active_account::get_initial_item + # => [default_threshold, num_approvers, 0, 0] + + movup.2 drop movup.2 drop + # => [default_threshold, num_approvers] +end + +#! Returns the current threshold and num_approvers from `THRESHOLD_CONFIG_SLOT`. +#! +#! Inputs: [] +#! Outputs: [default_threshold, num_approvers] #! #! Invocation: call pub proc get_threshold_and_num_approvers push.THRESHOLD_CONFIG_SLOT[0..2] - exec.active_account::get_initial_item - # => [threshold, num_approvers, 0, 0] + exec.active_account::get_item + # => [default_threshold, num_approvers, 0, 0] movup.2 drop movup.2 drop - # => [threshold, num_approvers] + # => [default_threshold, num_approvers] end #! Sets or clears a per-procedure threshold override. @@ -511,7 +549,7 @@ pub proc get_signer_at dup # => [index, index] - exec.create_approver_map_key + exec.signature::create_approver_map_key # => [APPROVER_MAP_KEY, index] push.APPROVER_PUBLIC_KEYS_SLOT[0..2] @@ -523,7 +561,7 @@ pub proc get_signer_at movup.4 # => [index, PUB_KEY] - exec.create_approver_map_key + exec.signature::create_approver_map_key # => [APPROVER_MAP_KEY, PUB_KEY] push.APPROVER_SCHEME_ID_SLOT[0..2] @@ -555,7 +593,7 @@ pub proc is_signer(pub_key: word) -> felt push.0 loc_store.IS_SIGNER_FOUND_LOC # => [PUB_KEY] - exec.get_threshold_and_num_approvers + exec.get_initial_threshold_and_num_approvers # => [threshold, num_approvers, PUB_KEY] drop @@ -574,7 +612,7 @@ pub proc is_signer(pub_key: word) -> felt dup loc_store.CURRENT_SIGNER_INDEX_LOC # => [i-1, PUB_KEY] - exec.create_approver_map_key + exec.signature::create_approver_map_key # => [APPROVER_MAP_KEY, PUB_KEY] push.APPROVER_PUBLIC_KEYS_SLOT[0..2] @@ -694,7 +732,7 @@ pub proc auth_tx(salt: word) # ------ Verifying approver signatures ------ - exec.get_threshold_and_num_approvers + exec.get_initial_threshold_and_num_approvers # => [default_threshold, num_of_approvers, TX_SUMMARY_COMMITMENT] movdn.5 @@ -727,6 +765,6 @@ pub proc auth_tx(salt: word) end # TX_SUMMARY_COMMITMENT is returned so wrappers can run optional checks - # (e.g. PSM) before replay-protection finalization. + # (e.g. guardian verification) before replay-protection finalization. # => [TX_SUMMARY_COMMITMENT] end diff --git a/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm b/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm new file mode 100644 index 0000000000..33aa44eeb6 --- /dev/null +++ b/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm @@ -0,0 +1,818 @@ +use miden::protocol::active_account +use miden::protocol::native_account +use miden::protocol::auth::AUTH_UNAUTHORIZED_EVENT +use miden::standards::auth +use miden::standards::auth::multisig +use miden::standards::auth::multisig::APPROVER_PUBLIC_KEYS_SLOT +use miden::standards::auth::multisig::APPROVER_SCHEME_ID_SLOT +use miden::standards::auth::multisig::THRESHOLD_CONFIG_SLOT +use miden::standards::auth::signature +use miden::standards::auth::tx_policy + +# STORAGE SLOTS +# ================================================================================================= + +# Map: PROC_ROOT => smart per-procedure policy word. +const PROCEDURE_POLICIES_SLOT = word("miden::standards::auth::multisig_smart::procedure_policies") + +# EXECUTION MODES +# ================================================================================================= + +# Execution mode value passed to procedure-policy helpers when the transaction runs the immediate +# (direct call) path. +const IMMEDIATE_EXECUTION_MODE = 0 + +# Execution mode value when the transaction runs the delayed (timelocked execute) path. +const DELAYED_EXECUTION_MODE = 1 + +# NOTE RESTRICTIONS +# ================================================================================================= + +# Bit-encoded ProcedurePolicyNoteRestriction enum: +# 0b00 = None, 0b01 = NoInputNotes, 0b10 = NoOutputNotes, 0b11 = NoInputOrOutputNotes. +# The bit-encoding lets us OR per-procedure restrictions together to compute their union. +const NOTE_RESTRICTION_INPUT_NOTES_MASK = 1 + +const NOTE_RESTRICTION_OUTPUT_NOTES_MASK = 2 + +# Highest valid note restriction enum value (NoInputOrOutputNotes = 0b11). +const NOTE_RESTRICTION_MAX = 3 + +# ERRORS +# ================================================================================================= + +const ERR_MALFORMED_MULTISIG_CONFIG = "number of approvers must be equal to or greater than threshold" + +const ERR_ZERO_IN_MULTISIG_CONFIG = "number of approvers or threshold must not be zero" + +const ERR_PROC_POLICY_INVALID_MODE = "called procedures do not support the selected execution mode" + +const ERR_DELAYED_THRESHOLD_EXCEEDS_IMMEDIATE = "delayed threshold cannot exceed immediate threshold" + +const ERR_NOTE_RESTRICTIONS_REQUIRE_THRESHOLD = "procedure policy note restrictions require an immediate or delayed threshold" + +const ERR_NUM_APPROVERS_OR_PROC_THRESHOLD_NOT_U32 = "number of approvers and procedure threshold must be u32" + +const ERR_PROC_THRESHOLD_EXCEEDS_NUM_APPROVERS = "procedure threshold exceeds new number of approvers" + +const ERR_INVALID_NOTE_RESTRICTIONS = "procedure policy note restrictions must be between 0 and 3" + +const ERR_INSUFFICIENT_SIGNATURES = "insufficient number of signatures" + +# LOCAL ADDRESSES (set_procedure_policy) +# ================================================================================================= + +const IMMEDIATE_THRESHOLD_LOC = 0 +const DELAYED_THRESHOLD_LOC = 1 +const NOTE_RESTRICTIONS_LOC = 2 + +# LOCAL ADDRESSES (compute_called_proc_policy) +# ================================================================================================= + +const EXECUTION_MODE_LOC = 0 +const DEFAULT_THRESHOLD_LOC = 1 + +#! Gets the procedure policy entry for PROC_ROOT from the account's initial state. +#! +#! Inputs: [PROC_ROOT] +#! Outputs: [immediate_threshold, delayed_threshold, note_restrictions] +#! +#! Where: +#! - PROC_ROOT is the root of the account procedure whose smart policy is being read. +#! - immediate_threshold is the threshold for direct execution, or 0 when disabled. +#! - delayed_threshold is the threshold for delayed execution, or 0 when disabled. +#! - note_restrictions is the note restriction enum value in the 0..=NOTE_RESTRICTION_MAX range. +#! +#! Invocation: exec +pub proc get_procedure_policy + push.PROCEDURE_POLICIES_SLOT[0..2] + exec.active_account::get_initial_map_item + # => [immediate_threshold, delayed_threshold, note_restrictions, 0] + + movup.3 drop + # => [immediate_threshold, delayed_threshold, note_restrictions] +end + +#! Validates that note_restrictions is within the supported range. +#! +#! Inputs: [note_restrictions] +#! Outputs: [] +#! +#! Where: +#! - note_restrictions is the policy enum value to validate. +#! +#! Panics if: +#! - note_restrictions is not a u32 value. +#! - note_restrictions is greater than NOTE_RESTRICTION_MAX. +#! +#! Invocation: exec +proc assert_valid_note_restrictions + u32assert.err=ERR_INVALID_NOTE_RESTRICTIONS + # => [note_restrictions] + + u32lte.NOTE_RESTRICTION_MAX + # => [is_valid_note_restrictions] + + assert.err=ERR_INVALID_NOTE_RESTRICTIONS + # => [] +end + +# HELPER PROCEDURES +# ================================================================================================= + +#! Returns the current number of approvers after any in-transaction signer update has been applied. +#! +#! Inputs: [] +#! Outputs: [num_approvers] +#! +#! Where: +#! - num_approvers is the current number of signers configured in the threshold config. +#! +#! Invocation: exec +proc get_current_num_approvers + push.THRESHOLD_CONFIG_SLOT[0..2] + exec.active_account::get_item + # => [threshold, num_approvers, 0, 0] + + movup.2 drop movup.2 drop + # => [threshold, num_approvers] + + drop + # => [num_approvers] +end + +#! Extracts the `num_approvers` field out of a MULTISIG_CONFIG word. +#! +#! MULTISIG_CONFIG layout: [threshold, num_approvers, 0, 0]. +#! +#! Inputs: [MULTISIG_CONFIG] +#! Outputs: [num_approvers, MULTISIG_CONFIG] +#! +#! Where: +#! - MULTISIG_CONFIG is the multisig configuration word. +#! - num_approvers is the second felt of MULTISIG_CONFIG. +#! +#! Invocation: exec +proc multisig_config_to_num_approvers + dup.1 + # => [num_approvers, MULTISIG_CONFIG] +end + +#! Computes the effective transaction threshold. +#! +#! Used as a fallback layer on top of `compute_called_proc_policy`: when no non-auth procedure was +#! called (policy_threshold is 0), the tx still needs to require at least `default_threshold` +#! signatures. When any non-auth procedure was called, `compute_called_proc_policy` has already +#! folded `default_threshold` into the max for every unpolicied procedure, so `policy_threshold` +#! alone reflects the correct requirement and is returned as-is. +#! +#! Inputs: [default_threshold, policy_threshold] +#! Outputs: [transaction_threshold] +#! +#! Where: +#! - policy_threshold is the max contribution across all called non-auth procedures (each +#! contributes either its policy-selected threshold or `default_threshold` when unpolicied); +#! it is 0 only when no non-auth procedure was called. +#! - default_threshold is the account's configured default multisig threshold. +#! - transaction_threshold is the effective minimum number of signatures required. +#! +#! Invocation: exec +proc compute_tx_threshold(default_threshold: u32, policy_threshold: u32) -> u32 + dup.1 eq.0 + # => [is_policy_zero, default_threshold, policy_threshold] + + cdrop + # => [effective_transaction_threshold] +end + +#! Computes a single procedure's contribution to the threshold max-accumulator and the +#! `requires_delay` flag, given that procedure's policy thresholds and the active execution mode. +#! +#! Inputs: [immediate, delayed, execution_mode, default_threshold] +#! Outputs: [contribution, requires_delay] +#! +#! Where: +#! - immediate is the procedure's immediate-mode policy threshold (0 = not configured). +#! - delayed is the procedure's delayed-mode policy threshold (0 = not configured). +#! - execution_mode is IMMEDIATE_EXECUTION_MODE (0) or DELAYED_EXECUTION_MODE (1). +#! - default_threshold is the account's configured default multisig threshold. +#! - contribution is the policy-selected threshold when the procedure has a policy for the active +#! mode, or default_threshold when it has no policy at all. +#! - requires_delay is execution_mode when the contribution came from policy, or 0 when it came +#! from default_threshold (a default contribution does not flip the delay flag). +#! +#! Panics if: +#! - the procedure's policy exists but is configured for the opposite execution mode. +#! +#! Invocation: exec +proc compute_proc_policy_contribution + # selected = (execution_mode == DELAYED_EXECUTION_MODE) ? delayed : immediate + dup dup.2 + # => [delayed, immediate, immediate, delayed, execution_mode, default_threshold] + + dup.4 + # => [execution_mode, delayed, immediate, immediate, delayed, execution_mode, default_threshold] + + # delayed mode (1) keeps `delayed`; immediate mode (0) keeps `immediate`. + cdrop + # => [selected, immediate, delayed, execution_mode, default_threshold] + + dup eq.0 + # => [is_selected_zero, selected, immediate, delayed, execution_mode, default_threshold] + + if.true + # Selected threshold is zero. Either there is no policy at all (both thresholds are zero) + # or the policy is configured for the opposite execution mode (the other threshold is + # non-zero). The two cases are distinguished by inspecting `immediate OR delayed`. + drop + # => [immediate, delayed, execution_mode, default_threshold] + + dup dup.2 u32or eq.0 + # => [is_no_policy, immediate, delayed, execution_mode, default_threshold] + + if.true + # No policy for this procedure → contribute default_threshold; requires_delay = 0. + drop drop drop + # => [default_threshold] + + push.0 swap + # => [default_threshold, 0] + else + # Mode misuse: policy exists but is configured for the opposite execution mode. + # Panic so the caller learns about the misconfiguration explicitly. + push.0 assert.err=ERR_PROC_POLICY_INVALID_MODE + end + else + # Selected threshold is non-zero → contribute it; requires_delay = execution_mode. + # => [selected, immediate, delayed, execution_mode, default_threshold] + + movdn.2 drop drop + # => [selected, execution_mode, default_threshold] + + movup.2 drop + # => [selected, execution_mode] + end +end + +#! Computes the effective per-procedure policy for all called procedures. +#! +#! Iterates over all account procedures, and for those that were called in this transaction +#! accumulates: +#! - the highest required threshold (max), +#! - the union of their note restrictions (so if one proc forbids input notes and another forbids +#! output notes, the transaction ends up forbidding both), +#! - whether any called procedure requires the delayed execution mode. +#! +#! Inputs: [execution_mode, default_threshold] +#! Outputs: [policy_threshold, policy_requires_delay, note_restrictions] +#! +#! Where: +#! - execution_mode is IMMEDIATE_EXECUTION_MODE (0) or DELAYED_EXECUTION_MODE (1). +#! - default_threshold is the account's configured default multisig threshold; used as the +#! per-procedure contribution for any *called* procedure that has no stored policy. This makes +#! procedures-without-a-policy visible in the max accumulation, which closes a privilege +#! escalation where a tx mixing a low-policy proc (e.g. receive_asset = 1) with an unpolicied +#! high-impact proc (e.g. update_signers) could otherwise be authorized at the lower threshold. +#! - policy_threshold is the max across all called non-auth procedures, where each called +#! procedure contributes either its policy-selected threshold or default_threshold (no policy). +#! - policy_requires_delay is 1 when any called procedure's policy explicitly requires the +#! delayed execution mode (a default contribution does not flip this). +#! - note_restrictions is the combined (union) note restriction enum across all called procedures. +#! +#! The auth procedure (procedure index 0) is excluded — it is the auth flow itself, not a +#! user-callable account procedure, and counting it would make `policy_threshold` always include +#! `default_threshold`, breaking the override-down semantic for single-proc transactions. +#! +#! Panics if: +#! - any called procedure's policy does not support the active execution mode. +#! +#! Example (default_threshold = 3, immediate execution mode): +#! +#! receive_asset → ProcedurePolicy { immediate_threshold = 1 } +#! update_signers_and_threshold → (no policy) +#! +#! A. Only `receive_asset` is called: +#! - receive_asset: policied → contribute 1 +#! - update_signers: not called → no contribution +#! - threshold_acc = max(0, 1) = 1 +#! - Result: 1 signature required (override-down works for single-proc tx). +#! +#! B. Both `receive_asset` and `update_signers_and_threshold` are called: +#! - receive_asset: policied → contribute 1 +#! - update_signers: called, no policy → contribute default_threshold = 3 +#! - threshold_acc = max(0, 1, 3) = 3 +#! - Result: 3 signatures required (privilege escalation prevented). +#! +#! Invocation: exec +#! +#! Locals: +#! - EXECUTION_MODE_LOC: execution_mode +#! - DEFAULT_THRESHOLD_LOC: default_threshold +@locals(2) +proc compute_called_proc_policy(execution_mode: u32, default_threshold: u32) + loc_store.EXECUTION_MODE_LOC + loc_store.DEFAULT_THRESHOLD_LOC + # => [] + + push.0 push.0 push.0 exec.active_account::get_num_procedures + # => [proc_index, threshold_acc=0, requires_delay_acc=0, restrictions_acc=0] + + dup neq.0 + # => [should_continue, proc_index, threshold_acc, requires_delay_acc, restrictions_acc] + while.true + sub.1 + # => [proc_index, threshold_acc, requires_delay_acc, restrictions_acc] + + # Procedure index 0 is the auth procedure and must never contribute to the policy + # threshold, even if it appears as a called procedure during the auth flow itself. + dup neq.0 + # => [is_non_auth, proc_index, threshold_acc, requires_delay_acc, restrictions_acc] + + dup.1 exec.active_account::get_procedure_root + # => [PROC_ROOT, is_non_auth, proc_index, threshold_acc, requires_delay_acc, restrictions_acc] + + exec.native_account::was_procedure_called + # => [was_called, is_non_auth, proc_index, threshold_acc, requires_delay_acc, restrictions_acc] + + and + # => [should_process, proc_index, threshold_acc, requires_delay_acc, restrictions_acc] + + if.true + dup exec.active_account::get_procedure_root + # => [PROC_ROOT, proc_index, threshold_acc, requires_delay_acc, restrictions_acc] + + exec.get_procedure_policy + # => [immediate, delayed, restr_proc, proc_index, threshold_acc, requires_delay_acc, restrictions_acc] + + # Fold per-proc note_restrictions into the accumulator via OR. + # ProcedurePolicyNoteRestriction is bit-encoded (0=None, 1=NoInput, 2=NoOutput, 3=Both), + # so bitwise OR is the union of constraints: e.g. NoInput (0b01) ∪ NoOutput (0b10) = Both (0b11). + movup.6 movup.3 u32or movdn.5 + # => [immediate, delayed, proc_index, threshold_acc, requires_delay_acc, new_restrictions] + + loc_load.EXECUTION_MODE_LOC movdn.2 + loc_load.DEFAULT_THRESHOLD_LOC movdn.3 + # => [immediate, delayed, execution_mode, default_threshold, proc_index, threshold_acc, requires_delay_acc, new_restrictions] + + exec.compute_proc_policy_contribution + # => [contribution, requires_delay, proc_index, threshold_acc, requires_delay_acc, new_restrictions] + + # threshold_acc' = max(threshold_acc, contribution) + movup.3 u32max + # => [new_threshold, requires_delay, proc_index, requires_delay_acc, new_restrictions] + + # requires_delay_acc' = requires_delay_acc OR requires_delay + swap movup.3 or + # => [new_requires_delay, new_threshold, proc_index, new_restrictions] + + swap movup.2 + # => [proc_index, new_threshold, new_requires_delay, new_restrictions] + end + + dup neq.0 + # => [should_continue, proc_index, threshold_acc, requires_delay_acc, restrictions_acc] + end + + drop + # => [threshold_acc, requires_delay_acc, restrictions_acc] +end + +#! Enforces note_restrictions against the current transaction. +#! +#! `note_restrictions` is treated as a bit set (see `NOTE_RESTRICTION_*_MASK`): +#! - bit 0 (mask 1) → forbid input notes +#! - bit 1 (mask 2) → forbid output notes +#! +#! Inputs: [note_restrictions] +#! Outputs: [] +#! +#! Invocation: exec +pub proc enforce_note_restrictions + dup u32and.NOTE_RESTRICTION_INPUT_NOTES_MASK eq.NOTE_RESTRICTION_INPUT_NOTES_MASK + # => [has_input_note_restriction, note_restrictions] + + if.true + exec.tx_policy::assert_no_input_notes + end + # => [note_restrictions] + + u32and.NOTE_RESTRICTION_OUTPUT_NOTES_MASK eq.NOTE_RESTRICTION_OUTPUT_NOTES_MASK + # => [has_output_note_restriction] + + if.true + exec.tx_policy::assert_no_output_notes + end + # => [] +end + +#! Enforces the procedure-policy for the current transaction: +#! - asserts each called procedure supports the active execution mode, +#! - asserts the union of note restrictions against the transaction's input/output notes, +#! - returns the effective threshold required by the called procedure policies. +#! +#! Always uses IMMEDIATE_EXECUTION_MODE; procedures whose policies require the delayed mode +#! panic via [`compute_called_proc_policy`] because this component has no timelock. +#! +#! Inputs: [default_threshold] +#! Outputs: [policy_threshold] +#! +#! Where: +#! - default_threshold is forwarded to [`compute_called_proc_policy`] as the per-procedure +#! contribution for any called procedure without an explicit policy. +#! +#! Invocation: exec +#! +#! NOTE: This procedure is a temporary form. Once the TimelockedAccount feature lands, the +#! hardcoded IMMEDIATE_EXECUTION_MODE push will be replaced with a call to +#! `timelock_controller::execution_mode`, and the procedure will also expose +#! `policy_requires_delay` for downstream enforcement. +proc enforce_procedure_policy(default_threshold: u32) + push.IMMEDIATE_EXECUTION_MODE + # => [execution_mode, default_threshold] + + exec.compute_called_proc_policy + # => [policy_threshold, policy_requires_delay, note_restrictions] + + # policy_requires_delay is always 0 on IMMEDIATE_EXECUTION_MODE; drop it. + swap drop + # => [policy_threshold, note_restrictions] + + swap + # => [note_restrictions, policy_threshold] + + exec.enforce_note_restrictions + # => [policy_threshold] +end + +#! Asserts that all configured smart per-procedure policies are valid for num_approvers. +#! +#! Inputs: [num_approvers] +#! Outputs: [] +#! +#! Where: +#! - num_approvers is the number of approvers that all stored policies must remain reachable with. +#! +#! Panics if: +#! - any stored immediate or delayed threshold is not a u32 value. +#! - any stored immediate or delayed threshold exceeds num_approvers. +#! +#! Invocation: exec +proc assert_proc_policies_lte_num_approvers + exec.active_account::get_num_procedures + # => [num_procedures, num_approvers] + + dup neq.0 + # => [should_continue, num_procedures, num_approvers] + while.true + sub.1 dup + # => [proc_index, proc_index, num_approvers] + + exec.active_account::get_procedure_root + # => [PROC_ROOT, proc_index, num_approvers] + + push.PROCEDURE_POLICIES_SLOT[0..2] + # => [policy_slot_suffix, policy_slot_prefix, PROC_ROOT, proc_index, num_approvers] + + # Use the *current* policy state, not the initial one. A `set_procedure_policy` earlier + # in this transaction that raised a threshold above the new num_approvers would otherwise + # be missed and the multisig could end up with an unreachable threshold. + exec.active_account::get_map_item + # => [immediate_threshold, delayed_threshold, note_restrictions, 0, proc_index, num_approvers] + + # Drop the trailing 0 (depth 3) without disturbing the three policy fields above it. + movup.3 drop + # => [immediate_threshold, delayed_threshold, note_restrictions, proc_index, num_approvers] + + # immediate_threshold <= num_approvers + dup.4 + # => [num_approvers, immediate_threshold, delayed_threshold, note_restrictions, proc_index, num_approvers] + + u32assert2.err=ERR_NUM_APPROVERS_OR_PROC_THRESHOLD_NOT_U32 + u32gt assertz.err=ERR_PROC_THRESHOLD_EXCEEDS_NUM_APPROVERS + # => [delayed_threshold, note_restrictions, proc_index, num_approvers] + + # delayed_threshold <= num_approvers + dup.3 + # => [num_approvers, delayed_threshold, note_restrictions, proc_index, num_approvers] + + u32assert2.err=ERR_NUM_APPROVERS_OR_PROC_THRESHOLD_NOT_U32 + u32gt assertz.err=ERR_PROC_THRESHOLD_EXCEEDS_NUM_APPROVERS + # => [note_restrictions, proc_index, num_approvers] + + drop + # => [proc_index, num_approvers] + + dup neq.0 + # => [should_continue, proc_index, num_approvers] + end + + drop drop + # => [] +end + +# PUBLIC INTERFACE +# ================================================================================================= + +#! Sets or clears a smart per-procedure policy. +#! +#! Inputs: [immediate_threshold, delayed_threshold, note_restrictions, PROC_ROOT] +#! Outputs: [] +#! +#! Where: +#! - immediate_threshold is the threshold for direct execution, or 0 when disabled. +#! - delayed_threshold is the threshold for delayed execution, or 0 when disabled. +#! - note_restrictions is the note restriction enum value in the 0..=NOTE_RESTRICTION_MAX range. +#! - PROC_ROOT is the root of the account procedure whose policy is being updated. +#! +#! Panics if: +#! - immediate_threshold or delayed_threshold is not a u32 value. +#! - note_restrictions is outside the supported range. +#! - either threshold exceeds the current number of approvers. +#! - delayed_threshold exceeds immediate_threshold when immediate_threshold is non-zero. +#! - note_restrictions is non-zero while both thresholds are zero. +#! +#! Invocation: call +@locals(3) +pub proc set_procedure_policy + loc_store.IMMEDIATE_THRESHOLD_LOC + # => [delayed_threshold, note_restrictions, PROC_ROOT] + + loc_store.DELAYED_THRESHOLD_LOC + # => [note_restrictions, PROC_ROOT] + + loc_store.NOTE_RESTRICTIONS_LOC + # => [PROC_ROOT] + + # ----- Validate immediate_threshold <= num_approvers (preserving num_approvers for the + # delayed check that follows). ----- + exec.get_current_num_approvers + # => [num_approvers, PROC_ROOT] + + dup loc_load.IMMEDIATE_THRESHOLD_LOC swap + # => [num_approvers, immediate_threshold, num_approvers, PROC_ROOT] + + u32assert2.err=ERR_NUM_APPROVERS_OR_PROC_THRESHOLD_NOT_U32 + u32gt assertz.err=ERR_PROC_THRESHOLD_EXCEEDS_NUM_APPROVERS + # => [num_approvers, PROC_ROOT] + + # ----- Validate delayed_threshold <= num_approvers (consumes num_approvers). ----- + loc_load.DELAYED_THRESHOLD_LOC swap + # => [num_approvers, delayed_threshold, PROC_ROOT] + + u32assert2.err=ERR_NUM_APPROVERS_OR_PROC_THRESHOLD_NOT_U32 + u32gt assertz.err=ERR_PROC_THRESHOLD_EXCEEDS_NUM_APPROVERS + # => [PROC_ROOT] + + # ----- Validate note_restrictions is in 0..=NOTE_RESTRICTION_MAX. ----- + loc_load.NOTE_RESTRICTIONS_LOC + exec.assert_valid_note_restrictions + # => [PROC_ROOT] + + # ----- Validate (immediate, delayed, note_restrictions) shape. ----- + # `if.true` consumes its boolean condition, so the body branches operate on `[PROC_ROOT]` + # without any leading `drop`. + loc_load.IMMEDIATE_THRESHOLD_LOC eq.0 + # => [is_immediate_threshold_zero, PROC_ROOT] + + if.true + # immediate is zero. If delayed is also zero, note_restrictions must be zero, otherwise + # the policy would forbid notes for a procedure that has no threshold to authorize them. + loc_load.DELAYED_THRESHOLD_LOC eq.0 + # => [is_delayed_threshold_zero, PROC_ROOT] + + if.true + # `eq.0 assert` produces the proper error when note_restrictions is non-zero; + # `assertz` would surface a generic "binary value expected" error for values 2 or 3. + loc_load.NOTE_RESTRICTIONS_LOC eq.0 assert.err=ERR_NOTE_RESTRICTIONS_REQUIRE_THRESHOLD + # => [PROC_ROOT] + end + else + # immediate is non-zero. Validate delayed_threshold <= immediate_threshold. + loc_load.DELAYED_THRESHOLD_LOC loc_load.IMMEDIATE_THRESHOLD_LOC + # => [immediate_threshold, delayed_threshold, PROC_ROOT] + + u32assert2.err=ERR_NUM_APPROVERS_OR_PROC_THRESHOLD_NOT_U32 + u32gt assertz.err=ERR_DELAYED_THRESHOLD_EXCEEDS_IMMEDIATE + # => [PROC_ROOT] + end + + # ----- Write [immediate, delayed, note_restrictions, 0] to PROCEDURE_POLICIES_SLOT[PROC_ROOT]. + push.0 + loc_load.NOTE_RESTRICTIONS_LOC + loc_load.DELAYED_THRESHOLD_LOC + loc_load.IMMEDIATE_THRESHOLD_LOC + # => [immediate_threshold, delayed_threshold, note_restrictions, 0, PROC_ROOT] + + swapw + # => [PROC_ROOT, immediate_threshold, delayed_threshold, note_restrictions, 0] + + push.PROCEDURE_POLICIES_SLOT[0..2] + # => [procedure_policies_slot_suffix, procedure_policies_slot_prefix, PROC_ROOT, POLICY_WORD] + + exec.native_account::set_map_item + # => [OLD_POLICY_WORD] + + dropw + # => [] +end + +#! Updates threshold config, approvers, and approver scheme ids for smart multisig accounts. +#! +#! Same advice map and config layout as [`multisig::update_signers_and_threshold`]. Differs by +#! validating smart procedure policies ([`assert_proc_policies_lte_num_approvers`]) instead +#! of per-procedure threshold overrides. +#! +#! Inputs: +#! Operand stack: [MULTISIG_CONFIG_COMMITMENT, pad(12)] +#! Outputs: +#! Operand stack: [] +#! +#! Panics if: +#! - the new threshold exceeds the new number of approvers. +#! - the new threshold or number of approvers is zero. +#! - any existing smart procedure policy becomes unreachable under the new number of approvers. +#! - any provided scheme identifier word is malformed. +#! +#! Locals: +#! 0: new_num_of_approvers +#! 1: init_num_of_approvers +#! +#! Invocation: call +@locals(2) +pub proc update_signers_and_threshold(multisig_config_commitment: word) + adv.push_mapval + # => [MULTISIG_CONFIG_COMMITMENT, pad(12)] + + adv_loadw + # => [MULTISIG_CONFIG, pad(12)] + + # store new_num_of_approvers for later + exec.multisig_config_to_num_approvers loc_store.0 + # => [MULTISIG_CONFIG, pad(12)] + + dup dup.2 + # => [num_approvers, threshold, MULTISIG_CONFIG, pad(12)] + + u32assert2.err=ERR_MALFORMED_MULTISIG_CONFIG + u32gt assertz.err=ERR_MALFORMED_MULTISIG_CONFIG + # => [MULTISIG_CONFIG, pad(12)] + + dup dup.2 + # => [num_approvers, threshold, MULTISIG_CONFIG, pad(12)] + + eq.0 assertz.err=ERR_ZERO_IN_MULTISIG_CONFIG + eq.0 assertz.err=ERR_ZERO_IN_MULTISIG_CONFIG + # => [MULTISIG_CONFIG, pad(12)] + + loc_load.0 + # => [num_approvers, MULTISIG_CONFIG, pad(12)] + + exec.assert_proc_policies_lte_num_approvers + # => [MULTISIG_CONFIG, pad(12)] + + push.THRESHOLD_CONFIG_SLOT[0..2] + # => [config_slot_suffix, config_slot_prefix, MULTISIG_CONFIG, pad(12)] + + exec.native_account::set_item + # => [OLD_THRESHOLD_CONFIG, pad(12)] + + # Save the old num_of_approvers for the post-loop scheme/pubkey cleanup, then drop the rest + # of OLD_THRESHOLD_CONFIG. + drop loc_store.1 drop drop + # => [pad(12)] + + loc_load.0 + # => [num_approvers, pad(12)] + + dup neq.0 + while.true + sub.1 + # => [i-1, pad(12)] + + dup exec.signature::create_approver_map_key + # => [APPROVER_MAP_KEY, i-1, pad(12)] + + padw adv_loadw + # => [PUB_KEY, APPROVER_MAP_KEY, i-1, pad(12)] + + swapw + # => [APPROVER_MAP_KEY, PUB_KEY, i-1, pad(12)] + + push.APPROVER_PUBLIC_KEYS_SLOT[0..2] + # => [pub_key_slot_suffix, pub_key_slot_prefix, APPROVER_MAP_KEY, PUB_KEY, i-1, pad(12)] + + exec.native_account::set_map_item + # => [OLD_VALUE, i-1, pad(12)] + + adv_loadw + # => [SCHEME_ID_WORD, i-1, pad(12)] + + exec.auth::signature::assert_supported_scheme_word + # => [SCHEME_ID_WORD, i-1, pad(12)] + + dup.4 exec.signature::create_approver_map_key + # => [APPROVER_MAP_KEY, SCHEME_ID_WORD, i-1, pad(12)] + + push.APPROVER_SCHEME_ID_SLOT[0..2] + # => [scheme_id_slot_id_suffix, scheme_id_slot_id_prefix, APPROVER_MAP_KEY, SCHEME_ID_WORD, i-1, pad(12)] + + exec.native_account::set_map_item + # => [OLD_VALUE, i-1, pad(12)] + + dropw + # => [i-1, pad(12)] + + dup neq.0 + # => [is_non_zero, i-1, pad(12)] + end + # => [pad(13)] + + drop + # => [pad(12)] + + loc_load.0 loc_load.1 + # => [init_num_of_approvers, new_num_of_approvers, pad(12)] + + exec.multisig::cleanup_pubkey_and_scheme_id_mapping + # => [pad(12)] +end + +#! Authenticate a transaction using multisig smart-policy rules. +#! +#! Inputs: +#! Operand stack: [SALT] +#! Outputs: +#! Operand stack: [TX_SUMMARY_COMMITMENT] +#! +#! Locals: +#! 0: policy_threshold +#! 1: default_threshold +#! +#! Invocation: call +#! +#! NOTE: This procedure is a temporary form covering signer verification and +#! per-procedure policy enforcement. The following sections will be added: +#! - Spending Limits + Amount-Based Thresholds: a prologue that calls +#! `exec.spending_limits::compute_spending_policy` to derive a spending-derived threshold and +#! `spending_requires_delay` flag, and combines it via `u32max` with `policy_threshold` before +#! the final `compute_tx_threshold` fallback. +#! - TimelockedAccount: after signature verification, assert the execute-path vs. +#! `policy_requires_delay`/`spending_requires_delay` consistency (restoring +#! `ERR_EXECUTE_PATH_MISMATCH`), then call +#! `exec.timelock_controller::finalize_timelock_proposals` to advance any pending +#! propose/cancel/execute state. +@locals(2) +pub proc auth_tx(salt: word) + exec.native_account::incr_nonce drop + # => [SALT] + + # ------ Computing transaction summary ------ + exec.auth::create_tx_summary + # => [ACCOUNT_DELTA_COMMITMENT, INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, SALT] + + adv.insert_hqword + # => [ACCOUNT_DELTA_COMMITMENT, INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, SALT] + + exec.auth::hash_tx_summary + # => [TX_SUMMARY_COMMITMENT] + + # ------ Reading threshold config (default + num_approvers) ------ + exec.multisig::get_initial_threshold_and_num_approvers + # => [default_threshold, num_of_approvers, TX_SUMMARY_COMMITMENT] + + # Save default_threshold for the procedure-policy enforcement and the final tx-threshold + # fallback below. + dup loc_store.1 + # => [default_threshold, num_of_approvers, TX_SUMMARY_COMMITMENT] + + # ------ Enforcing procedure policy (consumes default_threshold) ------ + exec.enforce_procedure_policy + # => [policy_threshold, num_of_approvers, TX_SUMMARY_COMMITMENT] + + loc_store.0 + # => [num_of_approvers, TX_SUMMARY_COMMITMENT] + + # ------ Verifying approver signatures ------ + push.APPROVER_PUBLIC_KEYS_SLOT[0..2] + push.APPROVER_SCHEME_ID_SLOT[0..2] + exec.::miden::standards::auth::signature::verify_signatures + # => [num_verified_signatures, TX_SUMMARY_COMMITMENT] + + # ------ Computing final transaction threshold ------ + # If no non-auth procedure was called, `policy_threshold` is 0 and `compute_tx_threshold` + # falls back to `default_threshold`; otherwise it returns the policy max directly. + loc_load.0 + loc_load.1 + # => [default_threshold, policy_threshold, num_verified_signatures, TX_SUMMARY_COMMITMENT] + + exec.compute_tx_threshold + # => [transaction_threshold, num_verified_signatures, TX_SUMMARY_COMMITMENT] + + u32assert2 u32lt + # => [is_unauthorized, TX_SUMMARY_COMMITMENT] + + if.true + emit.AUTH_UNAUTHORIZED_EVENT + push.0 assert.err=ERR_INSUFFICIENT_SIGNATURES + end +end diff --git a/crates/miden-standards/asm/standards/auth/note_script_allowlist.masm b/crates/miden-standards/asm/standards/auth/note_script_allowlist.masm new file mode 100644 index 0000000000..32371667e1 --- /dev/null +++ b/crates/miden-standards/asm/standards/auth/note_script_allowlist.masm @@ -0,0 +1,98 @@ +# Reusable note-script allowlist primitives. +# +# 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. +# - `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 +# 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::protocol::input_note +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. +#! +#! Map convention: keys are note 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_all_input_notes_allowed + # => [slot_id_suffix, slot_id_prefix] + + exec.tx::get_num_input_notes + # => [num_input_notes, slot_id_suffix, slot_id_prefix] + + dup sub.1 swap + # => [num_input_notes, note_idx, slot_id_suffix, slot_id_prefix] + + neq.0 + # => [should_loop, note_idx, slot_id_suffix, slot_id_prefix] + + while.true + # => [note_idx, slot_id_suffix, slot_id_prefix] + + dup exec.input_note::get_script_root + # => [NOTE_SCRIPT_ROOT, note_idx, slot_id_suffix, slot_id_prefix] + + # Copy the slot id to the top of the stack to call get_map_item. + dup.6 dup.6 + # => [slot_id_suffix, slot_id_prefix, NOTE_SCRIPT_ROOT, note_idx, slot_id_suffix, slot_id_prefix] + + exec.active_account::get_map_item + # => [VALUE, note_idx, slot_id_suffix, slot_id_prefix] + + exec.word::eqz not + # => [is_allowed, note_idx, slot_id_suffix, slot_id_prefix] + + assert.err=ERR_NOTE_SCRIPT_ALLOWLIST_NOTE_NOT_ALLOWED + # => [note_idx, slot_id_suffix, slot_id_prefix] + + dup sub.1 swap + # => [note_idx, note_idx-1, slot_id_suffix, slot_id_prefix] + + neq.0 + # => [should_continue, note_idx-1, slot_id_suffix, slot_id_prefix] + end + # => [stale_idx, slot_id_suffix, slot_id_prefix] + + drop drop drop + # => [] +end diff --git a/crates/miden-standards/asm/standards/auth/psm.masm b/crates/miden-standards/asm/standards/auth/psm.masm deleted file mode 100644 index d778cafb14..0000000000 --- a/crates/miden-standards/asm/standards/auth/psm.masm +++ /dev/null @@ -1,158 +0,0 @@ -# Private State Manager (PSM) account component. -# This component is composed into account auth flows especially for multisig and adds -# an extra signature check by a dedicated private state manager signer. -# -# Private State Manager (PSM) is a cloud backup and synchronization layer for Miden private accounts -# See: https://github.com/OpenZeppelin/private-state-manager - -use miden::protocol::auth::AUTH_UNAUTHORIZED_EVENT -use miden::protocol::native_account -use miden::standards::auth::tx_policy -use miden::standards::auth::signature - -# IMPORTANT SECURITY NOTES -# -------------------------------------------------------------------------------- -# - By default, exactly one valid PSM signature is required. -# - If `update_psm_public_key` is the only non-auth account procedure called in the current -# transaction, `verify_signature` skips the PSM signature check so key rotation can proceed -# without the old PSM signer. -# - `update_psm_public_key` rotates the PSM public key and corresponding scheme id using the fixed -# map key `PSM_MAP_KEY`. - - -# CONSTANTS -# ================================================================================================= - -# Storage Slots -# -# This authentication component uses named storage slots. -# - PSM_PUBLIC_KEYS_SLOT (map): -# PSM_MAP_KEY => PSM_PUBLIC_KEY -# where: PSM_MAP_KEY = [0, 0, 0, 0] -# -# - PSM_SCHEME_ID_SLOT (map): -# PSM_MAP_KEY => [scheme_id, 0, 0, 0] -# where: PSM_MAP_KEY = [0, 0, 0, 0] - -# The slot in this component's storage layout where the PSM public key map is stored. -# Map entries: [PSM_MAP_KEY] => [PSM_PUBLIC_KEY] -const PSM_PUBLIC_KEYS_SLOT = word("miden::standards::auth::psm::pub_key") - -# The slot in this component's storage layout where the scheme id for the corresponding PSM public key map is stored. -# Map entries: [PSM_MAP_KEY] => [scheme_id, 0, 0, 0] -const PSM_SCHEME_ID_SLOT = word("miden::standards::auth::psm::scheme") - -# Single-entry storage map key where private state manager signer data is stored. -const PSM_MAP_KEY = [0, 0, 0, 0] - -# ERRORS -# ------------------------------------------------------------------------------------------------- -const ERR_INVALID_PSM_SIGNATURE = "invalid private state manager signature" - -# PUBLIC INTERFACE -# ================================================================================================ - -#! Updates the private state manager public key. -#! -#! Inputs: [new_psm_scheme_id, NEW_PSM_PUBLIC_KEY] -#! Outputs: [] -#! -#! Notes: -#! - This procedure only updates the PSM public key and corresponding scheme id. -#! - `verify_signature` skips PSM verification only when this is the only non-auth account -#! procedure called in the transaction. -#! -#! Invocation: call -@locals(1) -pub proc update_psm_public_key(new_psm_scheme_id: felt, new_psm_public_key: word) - # Validate supported signature scheme before committing it to storage. - dup exec.signature::assert_supported_scheme - # => [new_psm_scheme_id, NEW_PSM_PUBLIC_KEY] - - loc_store.0 - # => [NEW_PSM_PUBLIC_KEY] - - push.PSM_MAP_KEY - # => [PSM_MAP_KEY, NEW_PSM_PUBLIC_KEY] - - push.PSM_PUBLIC_KEYS_SLOT[0..2] - # => [psm_pubkeys_slot_prefix, psm_pubkeys_slot_suffix, PSM_MAP_KEY, NEW_PSM_PUBLIC_KEY] - - exec.native_account::set_map_item - # => [OLD_PSM_PUBLIC_KEY] - - dropw - # => [] - - # Store new scheme id as [scheme_id, 0, 0, 0] in the single-entry map. - loc_load.0 - # => [scheme_id] - - push.0.0.0 movup.3 - # => [NEW_PSM_SCHEME_ID_WORD] - - push.PSM_MAP_KEY - # => [PSM_MAP_KEY, NEW_PSM_SCHEME_ID_WORD] - - push.PSM_SCHEME_ID_SLOT[0..2] - # => [psm_scheme_slot_prefix, psm_scheme_slot_suffix, PSM_MAP_KEY, NEW_PSM_SCHEME_ID_WORD] - - exec.native_account::set_map_item - # => [OLD_PSM_SCHEME_ID_WORD] - - dropw - # => [] -end - -#! Conditionally verifies a private state manager signature. -#! -#! Inputs: [MSG] -#! Outputs: [] -#! -#! Panics if: -#! - `update_psm_public_key` is called together with another non-auth account procedure. -#! - `update_psm_public_key` was not called and a valid PSM signature is missing or invalid. -#! -#! Invocation: exec -pub proc verify_signature(msg: word) - procref.update_psm_public_key - # => [UPDATE_PSM_PUBLIC_KEY_ROOT, MSG] - - exec.native_account::was_procedure_called - # => [was_update_psm_public_key_called, MSG] - - if.true - exec.tx_policy::assert_only_one_non_auth_procedure_called - # => [MSG] - - exec.tx_policy::assert_no_input_or_output_notes - # => [MSG] - - dropw - # => [] - else - push.1 - # => [1, MSG] - - push.PSM_PUBLIC_KEYS_SLOT[0..2] - # => [psm_pubkeys_slot_prefix, psm_pubkeys_slot_suffix, 1, MSG] - - push.PSM_SCHEME_ID_SLOT[0..2] - # => [psm_scheme_slot_prefix, psm_scheme_slot_suffix, psm_pubkeys_slot_prefix, psm_pubkeys_slot_suffix, 1, MSG] - - exec.signature::verify_signatures - # => [num_verified_signatures, MSG] - - neq.1 - # => [is_not_exactly_one, MSG] - - if.true - emit.AUTH_UNAUTHORIZED_EVENT - push.0 assert.err=ERR_INVALID_PSM_SIGNATURE - end - # => [MSG] - - dropw - # => [] - end -end diff --git a/crates/miden-standards/asm/standards/auth/signature.masm b/crates/miden-standards/asm/standards/auth/signature.masm index 49cec90b17..50b24da609 100644 --- a/crates/miden-standards/asm/standards/auth/signature.masm +++ b/crates/miden-standards/asm/standards/auth/signature.masm @@ -242,7 +242,7 @@ pub proc verify_signatures adv.has_mapkey # => [SIG_KEY, MSG, i-1] - adv_push.1 + adv_push # => [has_signature, SIG_KEY, MSG, i-1] # if SIG_KEY => SIGNATURE exists in AdviceMap check the signature @@ -320,8 +320,7 @@ end #! #! Inputs: [key_index] #! Outputs: [APPROVER_MAP_KEY] -proc create_approver_map_key +pub proc create_approver_map_key push.0.0.0 movup.3 - # => [[key_index, 0, 0, 0]] # => [APPROVER_MAP_KEY] end diff --git a/crates/miden-standards/asm/standards/auth/tx_policy.masm b/crates/miden-standards/asm/standards/auth/tx_policy.masm index 76da300070..4201e44a86 100644 --- a/crates/miden-standards/asm/standards/auth/tx_policy.masm +++ b/crates/miden-standards/asm/standards/auth/tx_policy.masm @@ -3,7 +3,8 @@ use miden::protocol::native_account use miden::protocol::tx const ERR_AUTH_PROCEDURE_MUST_BE_CALLED_ALONE = "procedure must be called alone" -const ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_OR_OUTPUT_NOTES = "transaction must not include input or output notes" +const ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_NOTES = "transaction must not include input notes" +const ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_OUTPUT_NOTES = "transaction must not include output notes" #! Asserts that exactly one non-auth account procedure was called in the current transaction. #! @@ -59,22 +60,31 @@ pub proc assert_only_one_non_auth_procedure_called # => [] end -#! Asserts that the current transaction does not consume input notes or create output notes. +#! Asserts that the current transaction does not consume input notes. #! #! Inputs: [] #! Outputs: [] #! #! Invocation: exec -pub proc assert_no_input_or_output_notes +pub proc assert_no_input_notes exec.tx::get_num_input_notes # => [num_input_notes] - assertz.err=ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_OR_OUTPUT_NOTES + assertz.err=ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_NOTES # => [] +end +#! Asserts that the current transaction does not create output notes. +#! +#! Inputs: [] +#! Outputs: [] +#! +#! Invocation: exec +pub proc assert_no_output_notes exec.tx::get_num_output_notes # => [num_output_notes] - assertz.err=ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_OR_OUTPUT_NOTES + assertz.err=ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_OUTPUT_NOTES # => [] end + diff --git a/crates/miden-standards/asm/standards/faucets/basic_fungible.masm b/crates/miden-standards/asm/standards/faucets/basic_fungible.masm deleted file mode 100644 index 2cff662f6f..0000000000 --- a/crates/miden-standards/asm/standards/faucets/basic_fungible.masm +++ /dev/null @@ -1,60 +0,0 @@ -# BASIC FUNGIBLE FAUCET CONTRACT -# -# See the `BasicFungibleFaucet` documentation for details. -# -# Note: This component requires `MintPolicyManager` component to also be present in the account. -# ================================================================================================= - -use miden::standards::faucets -use miden::standards::mint_policies::policy_manager - -# PROCEDURES -# ================================================================================================= - -#! Mints fungible assets to the provided recipient by creating a note. -#! -#! Inputs: [amount, tag, note_type, RECIPIENT, pad(9)] -#! Outputs: [note_idx, pad(15)] -#! -#! Where: -#! - amount is the amount to be minted and sent. -#! - tag is the tag to be included in the note. -#! - note_type is the type of the note that holds the asset. -#! - RECIPIENT is the recipient of the asset, i.e., -#! hash(hash(hash(serial_num, [0; 4]), script_root), storage_commitment). -#! - note_idx is the index of the created note. -#! -#! Panics if: -#! - active mint policy validation fails. -#! - any of the validations in faucets::mint_and_send fail. -#! -#! Invocation: call -pub proc mint_and_send - # TODO: Remove once AccountComponentInterface is refactored - # Keep this procedure digest distinct from network_fungible::mint_and_send. - push.0 drop - - exec.policy_manager::execute_mint_policy - # => [new_amount, new_tag, new_note_type, NEW_RECIPIENT, pad(9)] - - exec.faucets::mint_and_send - # => [note_idx, pad(15)] -end - -#! Burns the fungible asset from the active note. -#! -#! This procedure retrieves the asset from the active note and burns it. The note must contain -#! exactly one asset, which must be a fungible asset issued by this faucet. -#! -#! This is a re-export of basic_fungible::burn. -#! -#! Inputs: [pad(16)] -#! Outputs: [pad(16)] -#! -#! Panics if: -#! - the procedure is not called from a note context (active_note::get_assets will fail). -#! - the note does not contain exactly one asset. -#! - the transaction is executed against an account which is not a fungible asset faucet. -#! - the transaction is executed against a faucet which is not the origin of the specified asset. -#! - the amount about to be burned is greater than the outstanding supply of the asset. -pub use faucets::burn diff --git a/crates/miden-standards/asm/standards/faucets/fungible.masm b/crates/miden-standards/asm/standards/faucets/fungible.masm new file mode 100644 index 0000000000..f147ef5fff --- /dev/null +++ b/crates/miden-standards/asm/standards/faucets/fungible.masm @@ -0,0 +1,446 @@ +# miden::standards::faucets::fungible +# +# Fungible faucet logic: token config slot (supply, max_supply, decimals, symbol) and its +# accessors, owner-gated max_supply setter, and the `mint_and_send` / `receive_and_burn` +# procedures wrapping the active mint/burn policies from the `TokenPolicyManager`. +# +# Shared token metadata (name, description, logo_uri, external_link) and the associated +# mutability config word live in the parent `miden::standards::faucets` namespace. + +use miden::protocol::active_account +use miden::protocol::active_note +use miden::protocol::faucet +use miden::protocol::native_account +use miden::protocol::output_note +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::access::pausable + +# ================================================================================================= +# CONSTANTS — slot names +# ================================================================================================= + +pub const TOKEN_CONFIG_SLOT = word("miden::standards::faucets::fungible::token_config") + +# ================================================================================================= +# CONSTANTS — memory & errors +# ================================================================================================= + +# The local memory address at which the token config slot content is stored. +const TOKEN_CONFIG_SLOT_LOCAL = 0 +# The local memory address at which the MINT note's ASSET_KEY is held across the mint policy +# and supply checks in `mint_and_send`. Its faucet ID is checked against the active account +# to bind the mint to the faucet the MINT note was created for. +const MINT_ASSET_KEY_LOCAL = 4 +const ASSET_PTR = 0 + +const ERR_MAX_SUPPLY_NOT_MUTABLE = "max supply is not mutable" +const ERR_NEW_MAX_SUPPLY_BELOW_TOKEN_SUPPLY = "new max supply is less than current token supply" + +const ERR_FUNGIBLE_ASSET_TOKEN_SUPPLY_EXCEEDS_MAX_SUPPLY = "token supply exceeds max supply" +const ERR_FUNGIBLE_ASSET_MAX_SUPPLY_EXCEEDS_FUNGIBLE_ASSET_MAX_AMOUNT = "max supply exceeds maximum representable fungible asset amount" +const ERR_FUNGIBLE_ASSET_DISTRIBUTE_AMOUNT_EXCEEDS_MAX_SUPPLY = "token_supply plus the amount passed to distribute would exceed the maximum supply" +const ERR_FUNGIBLE_MINT_NOTE_ASSET_NOT_FROM_THIS_FAUCET = "the asset stored in the MINT note does not belong to this faucet" +const ERR_FAUCET_BURN_AMOUNT_EXCEEDS_TOKEN_SUPPLY = "asset amount to burn exceeds the existing token supply" +const ERR_FUNGIBLE_BURN_WRONG_NUMBER_OF_ASSETS = "burn requires exactly 1 note asset" + +# ================================================================================================= +# PRIVATE HELPERS — token config slot access +# ================================================================================================= + +#! Loads the token config word from storage. +#! +#! Inputs: [] +#! Outputs: [token_supply, max_supply, decimals, token_symbol] +#! +#! Where: +#! - token_supply, max_supply, decimals, token_symbol are the fields of the token config word +#! (word[0] on top, little-endian). +#! +#! Invocation: exec +proc get_token_config_internal + push.TOKEN_CONFIG_SLOT[0..2] + exec.active_account::get_item +end + +#! Extracts the is_max_supply_mutable flag from the mutability config word. +#! +#! Inputs: [] +#! Outputs: [is_max_supply_mutable] +#! +#! Invocation: exec +proc is_max_supply_mutable_internal + push.MUTABILITY_CONFIG_SLOT[0..2] + exec.active_account::get_item + # => [is_description_mutable, is_logo_uri_mutable, is_external_link_mutable, is_max_supply_mutable] + drop drop drop + # => [is_max_supply_mutable] +end + +# ================================================================================================= +# PUBLIC API — TOKEN CONFIG +# ================================================================================================= + +#! Returns the token config (token_supply, max_supply, decimals, token_symbol). +#! +#! Inputs: [pad(16)] +#! Outputs: [token_supply, max_supply, decimals, token_symbol, pad(12)] +#! +#! Invocation: call +pub proc get_token_config + exec.get_token_config_internal + swapw dropw + # => [token_supply, max_supply, decimals, token_symbol, pad(12)] +end + +#! Returns the maximum supply. +#! +#! Inputs: [pad(16)] +#! Outputs: [max_supply, pad(15)] +#! +#! Invocation: call +pub proc get_max_supply + exec.get_token_config_internal + # => [token_supply, max_supply, decimals, token_symbol, pad(16)] + swap movdn.4 dropw + # => [max_supply, pad(15)] +end + +#! Returns decimals (single felt; e.g. 8). +#! +#! Inputs: [pad(16)] +#! Outputs: [decimals, pad(15)] +#! +#! Invocation: call +pub proc get_decimals + exec.get_token_config_internal + # => [token_supply, max_supply, decimals, token_symbol, pad(16)] + movup.2 movdn.4 dropw + # => [decimals, pad(15)] +end + +#! Returns token_symbol (single felt). +#! +#! Inputs: [pad(16)] +#! Outputs: [token_symbol, pad(15)] +#! +#! Invocation: call +pub proc get_token_symbol + exec.get_token_config_internal + # => [token_supply, max_supply, decimals, token_symbol, pad(16)] + movup.3 movdn.4 dropw + # => [token_symbol, pad(15)] +end + +#! Returns token_supply (single felt). +#! +#! Inputs: [pad(16)] +#! Outputs: [token_supply, pad(15)] +#! +#! Invocation: call +pub proc get_token_supply + exec.get_token_config_internal + # => [token_supply, max_supply, decimals, token_symbol, pad(16)] + movdn.4 dropw + # => [token_supply, pad(15)] +end + +#! Returns 1 if max supply is mutable, and 0 otherwise. +#! +#! Inputs: [pad(16)] +#! Outputs: [is_max_supply_mutable, pad(15)] +#! +#! Invocation: call +pub proc is_max_supply_mutable + exec.is_max_supply_mutable_internal + # => [is_max_supply_mutable, pad(16)] + swap drop + # => [is_max_supply_mutable, pad(15)] +end + +# ================================================================================================= +# SET MAX SUPPLY +# ================================================================================================= + +#! Updates the max supply if the max supply mutability flag is 1 +#! and the caller satisfies the account-wide authority configuration. +#! +#! Inputs: [new_max_supply, pad(15)] +#! Outputs: [pad(16)] +#! +#! Panics if: +#! - the max supply mutability flag is not 1. +#! - the caller is not authorized per the installed [`Authority`]. +#! - the new max supply is less than the current token supply. +#! +#! Invocation: call +pub proc set_max_supply + # 1. Check max supply mutability + exec.is_max_supply_mutable_internal + # => [is_max_supply_mutable, new_max_supply, pad(15)] + assert.err=ERR_MAX_SUPPLY_NOT_MUTABLE + + # 2. Verify the caller satisfies the account-wide authority configuration + exec.authority::assert_authorized + # => [new_max_supply, pad(15)] + + exec.pausable::assert_not_paused + # => [new_max_supply, pad(15)] + + # 3. 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 + 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 + 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 + push.TOKEN_CONFIG_SLOT[0..2] + exec.native_account::set_item + dropw + # => [pad(16)] +end + +# ================================================================================================= +# MINT AND SEND +# ================================================================================================= + +#! Mints the asset stored in the MINT note to the provided recipient by creating a note. +#! +#! The active mint policy runs on the asset amount, the token supply is updated, and an +#! output note carrying the asset is created for the recipient. The asset minted is derived +#! for the active faucet; the `ASSET_KEY` stored in the MINT note must belong to that faucet, +#! so a MINT note created for one faucet cannot be minted by another faucet. +#! +#! Inputs: [ASSET_KEY, ASSET_VALUE, tag, note_type, RECIPIENT, pad(2)] +#! Outputs: [note_idx, pad(15)] +#! +#! Where: +#! - ASSET_KEY is the vault key of the asset to mint. Its faucet ID must equal the active +#! account. +#! - ASSET_VALUE is the value word of the asset to mint. For fungible assets it is +#! `[amount, 0, 0, 0]`; the amount is what the supply checks operate on. +#! - tag is the tag to be included in the output note. +#! - note_type is the type of the output note that holds the asset. +#! - RECIPIENT is the recipient of the asset, i.e., +#! hash(hash(hash(serial_num, [0; 4]), script_root), storage_commitment). +#! - note_idx is the index of the created note. +#! +#! Panics if: +#! - active mint policy validation fails. +#! - the transaction is being executed against an account that is not a fungible asset faucet. +#! - the token supply exceeds the maximum supply. +#! - the maximum supply exceeds the maximum representable fungible asset amount. +#! - the token supply after minting is greater than the maximum allowed supply. +#! - the asset stored in the MINT note does not belong to this faucet. +#! +#! Invocation: call +@locals(8) +pub proc mint_and_send + # save the MINT note's ASSET_KEY in a local so it survives the mint policy and supply + # checks; it is compared against the asset derived for the active faucet before minting + loc_storew_le.MINT_ASSET_KEY_LOCAL dropw + # => [ASSET_VALUE, tag, note_type, RECIPIENT, pad(6)] + + # ASSET_VALUE for a fungible asset is `[amount, 0, 0, 0]`. Keep amount, drop the rest. + movdn.3 drop drop drop + # => [amount, tag, note_type, RECIPIENT, pad(9)] + + exec.policy_manager::execute_mint_policy + # => [new_amount, new_tag, new_note_type, NEW_RECIPIENT, pad(9)] + + # Get the configured max supply and the token supply (= current supply). + # --------------------------------------------------------------------------------------------- + + push.TOKEN_CONFIG_SLOT[0..2] exec.active_account::get_item + # => [token_supply, max_supply, decimals, token_symbol, amount, tag, note_type, 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] + + # Assert that minting does not violate any supply constraints. + # + # To make sure we cannot mint more than intended, we need to check: + # 1) (max_supply - token_supply) <= max_supply, i.e. the subtraction does not wrap around + # 2) amount + token_supply does not exceed max_supply + # 3) amount + token_supply is less than FUNGIBLE_ASSET_MAX_AMOUNT + # + # This is done with the following concrete assertions: + # - assert token_supply <= max_supply which ensures 1) + # - assert max_supply <= FUNGIBLE_ASSET_MAX_AMOUNT to help ensure 3) + # - assert amount <= max_mint_amount to ensure 2) as well as 3) + # - this ensures 3) because token_supply + max_mint_amount at most ends up being equal to + # max_supply and we already asserted that max_supply does not exceed + # FUNGIBLE_ASSET_MAX_AMOUNT + # --------------------------------------------------------------------------------------------- + + dup.1 dup.1 + # => [max_supply, token_supply, max_supply, token_supply, amount, tag, note_type, 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] + + # 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] + + dup.2 swap dup.2 + # => [token_supply, max_supply, amount, token_supply, amount, tag, note_type, 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] + + # assert amount <= max_mint_amount + lte assert.err=ERR_FUNGIBLE_ASSET_DISTRIBUTE_AMOUNT_EXCEEDS_MAX_SUPPLY + # => [token_supply, amount, tag, note_type, RECIPIENT] + + # Compute the new token_supply and update in storage. + # --------------------------------------------------------------------------------------------- + + dup.1 add + # => [new_token_supply, amount, tag, note_type, RECIPIENT] + + padw loc_loadw_le.TOKEN_CONFIG_SLOT_LOCAL + # => [[token_supply, max_supply, decimals, token_symbol], new_token_supply, amount, tag, note_type, RECIPIENT] + + drop movup.3 + # => [[new_token_supply, max_supply, decimals, token_symbol], amount, tag, note_type, 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] + + # Create a new note. + # --------------------------------------------------------------------------------------------- + + movdn.6 exec.output_note::create + # => [note_idx, amount] + + # Mint the asset. + # --------------------------------------------------------------------------------------------- + + dup movup.2 + # => [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 + # => [ASSET_KEY, ASSET_VALUE, note_idx, note_idx] + + # bind the mint to the faucet the MINT note was created for: the asset derived for the + # active faucet must equal the asset stored in the MINT note. The key encodes the faucet + # ID and the faucet's asset-callbacks bit, so a MINT note created for a different faucet + # is rejected. + padw loc_loadw_le.MINT_ASSET_KEY_LOCAL dupw.1 + # => [ASSET_KEY, NOTE_ASSET_KEY, ASSET_KEY, ASSET_VALUE, note_idx, note_idx] + + assert_eqw.err=ERR_FUNGIBLE_MINT_NOTE_ASSET_NOT_FROM_THIS_FAUCET + # => [ASSET_KEY, ASSET_VALUE, note_idx, note_idx] + + dupw.1 dupw.1 + # => [ASSET_KEY, ASSET_VALUE, ASSET_KEY, ASSET_VALUE, note_idx, note_idx] + + exec.faucet::mint + # => [ASSET_KEY, ASSET_VALUE, note_idx, note_idx] + + # Add the asset to the note. + # --------------------------------------------------------------------------------------------- + + exec.output_note::add_asset + # => [note_idx] +end + +# ================================================================================================= +# RECEIVE AND BURN +# ================================================================================================= + +#! Receives a fungible asset from the active note and burns it. +#! +#! Burning the asset removes it from circulation and reduces the token_supply by the asset's amount. +#! +#! This procedure retrieves the asset from the active note, executes the active burn policy +#! against it, and burns it. The note must contain exactly one asset, which must be a fungible +#! asset issued by this faucet. +#! +#! Inputs: [pad(16)] +#! Outputs: [pad(16)] +#! +#! Panics if: +#! - active burn policy validation fails. +#! - the procedure is not called from a note context (active_note::get_assets will fail). +#! - the note does not contain exactly one asset. +#! - the transaction is executed against an account which is not a fungible asset faucet. +#! - the transaction is executed against a faucet which is not the origin of the specified asset. +#! - the amount about to be burned is greater than the token_supply of the faucet. +#! +#! Invocation: call +pub proc receive_and_burn + # Get the asset from the note. + # --------------------------------------------------------------------------------------------- + + # this will fail if not called from a note context. + push.ASSET_PTR exec.active_note::get_assets + # => [num_assets, pad(16)] + + # Verify we have exactly one asset + assert.err=ERR_FUNGIBLE_BURN_WRONG_NUMBER_OF_ASSETS + # => [pad(16)] + + push.ASSET_PTR exec.asset::load + # => [ASSET_KEY, ASSET_VALUE, pad(16)] + + dupw.1 dupw.1 + # => [ASSET_KEY, ASSET_VALUE, ASSET_KEY, ASSET_VALUE, pad(16)] + + exec.policy_manager::execute_burn_policy + # => [ASSET_KEY, ASSET_VALUE, pad(16)] + + # Burn the asset from the transaction vault + # --------------------------------------------------------------------------------------------- + + exec.asset::fungible_to_amount movdn.8 + # => [ASSET_KEY, ASSET_VALUE, amount, pad(16)] + + # burn the asset + # this ensures we only burn assets that were issued by this faucet (which implies they are + # fungible) + exec.faucet::burn + # => [amount, pad(16)] + + # Subtract burnt amount from current token_supply in storage. + # --------------------------------------------------------------------------------------------- + + push.TOKEN_CONFIG_SLOT[0..2] exec.active_account::get_item + # => [token_supply, max_supply, decimals, token_symbol, amount, pad(16)] + + dup.4 dup.1 + # => [token_supply, amount, token_supply, max_supply, decimals, token_symbol, amount, pad(16)] + + # assert that amount <= token_supply + lte assert.err=ERR_FAUCET_BURN_AMOUNT_EXCEEDS_TOKEN_SUPPLY + # => [token_supply, max_supply, decimals, token_symbol, amount, pad(16)] + + movup.4 + # => [amount, token_supply, max_supply, decimals, token_symbol, pad(16)] + + # compute new_token_supply = token_supply - amount + sub + # => [new_token_supply, max_supply, decimals, token_symbol, pad(16)] + + # update the token config slot with the new supply + push.TOKEN_CONFIG_SLOT[0..2] exec.native_account::set_item dropw + # => [pad(16)] +end diff --git a/crates/miden-standards/asm/standards/faucets/mod.masm b/crates/miden-standards/asm/standards/faucets/mod.masm index 57eaf2b416..ddf14fdf38 100644 --- a/crates/miden-standards/asm/standards/faucets/mod.masm +++ b/crates/miden-standards/asm/standards/faucets/mod.masm @@ -1,227 +1,463 @@ +# miden::standards::faucets +# +# Shared token metadata storage layout and accessors for faucet accounts. Holds the token name +# and the optional fields (description, logo_uri, external_link) together with their mutability +# flags. Designed to be shared across fungible and non-fungible faucet implementations. +# +# Fungible-specific procedures (mint/burn, token_config accessors, max_supply mutators) live in +# the `miden::standards::faucets::fungible` namespace. + +use miden::core::mem use miden::protocol::active_account -use miden::protocol::active_note -use miden::protocol::faucet use miden::protocol::native_account -use miden::protocol::output_note -use miden::protocol::asset -use miden::protocol::asset::FUNGIBLE_ASSET_MAX_AMOUNT +use miden::standards::access::authority +use miden::standards::access::pausable -# CONSTANTS +# ================================================================================================= +# CONSTANTS — slot names # ================================================================================================= -const ASSET_PTR=0 -const PRIVATE_NOTE=2 +pub const TOKEN_NAME_0_SLOT = word("miden::standards::faucets::token_name_0") +pub const TOKEN_NAME_1_SLOT = word("miden::standards::faucets::token_name_1") +pub const MUTABILITY_CONFIG_SLOT = word("miden::standards::faucets::mutability_config") + +const TOKEN_DESCRIPTION_0_SLOT = word("miden::standards::faucets::token_description_0") +const TOKEN_DESCRIPTION_1_SLOT = word("miden::standards::faucets::token_description_1") +const TOKEN_DESCRIPTION_2_SLOT = word("miden::standards::faucets::token_description_2") +const TOKEN_DESCRIPTION_3_SLOT = word("miden::standards::faucets::token_description_3") +const TOKEN_DESCRIPTION_4_SLOT = word("miden::standards::faucets::token_description_4") +const TOKEN_DESCRIPTION_5_SLOT = word("miden::standards::faucets::token_description_5") +const TOKEN_DESCRIPTION_6_SLOT = word("miden::standards::faucets::token_description_6") + +const LOGO_URI_0_SLOT = word("miden::standards::faucets::logo_uri_0") +const LOGO_URI_1_SLOT = word("miden::standards::faucets::logo_uri_1") +const LOGO_URI_2_SLOT = word("miden::standards::faucets::logo_uri_2") +const LOGO_URI_3_SLOT = word("miden::standards::faucets::logo_uri_3") +const LOGO_URI_4_SLOT = word("miden::standards::faucets::logo_uri_4") +const LOGO_URI_5_SLOT = word("miden::standards::faucets::logo_uri_5") +const LOGO_URI_6_SLOT = word("miden::standards::faucets::logo_uri_6") + +const EXTERNAL_LINK_0_SLOT = word("miden::standards::faucets::external_link_0") +const EXTERNAL_LINK_1_SLOT = word("miden::standards::faucets::external_link_1") +const EXTERNAL_LINK_2_SLOT = word("miden::standards::faucets::external_link_2") +const EXTERNAL_LINK_3_SLOT = word("miden::standards::faucets::external_link_3") +const EXTERNAL_LINK_4_SLOT = word("miden::standards::faucets::external_link_4") +const EXTERNAL_LINK_5_SLOT = word("miden::standards::faucets::external_link_5") +const EXTERNAL_LINK_6_SLOT = word("miden::standards::faucets::external_link_6") + +const ERR_DESCRIPTION_NOT_MUTABLE = "description is not mutable" +const ERR_LOGO_URI_NOT_MUTABLE = "logo URI is not mutable" +const ERR_EXTERNAL_LINK_NOT_MUTABLE = "external link is not mutable" -# ERRORS # ================================================================================================= +# PRIVATE HELPERS +# ================================================================================================= + +#! Loads name chunk 0 (1 Word). +#! +#! Inputs: [] +#! Outputs: [NAME_CHUNK_0] +#! +#! Invocation: exec +proc get_name_chunk_0 + push.TOKEN_NAME_0_SLOT[0..2] + exec.active_account::get_item +end + +#! Loads name chunk 1 (1 Word). +#! +#! Inputs: [] +#! Outputs: [NAME_CHUNK_1] +#! +#! Invocation: exec +proc get_name_chunk_1 + push.TOKEN_NAME_1_SLOT[0..2] + exec.active_account::get_item +end + +#! Loads the mutability config word. +#! +#! Inputs: [] +#! Outputs: [is_description_mutable, is_logo_uri_mutable, is_external_link_mutable, is_max_supply_mutable] +#! +#! Where: +#! - is_description_mutable, is_logo_uri_mutable, is_external_link_mutable, is_max_supply_mutable are boolean flags +#! (word[0] on top after get_item, little-endian). +#! +#! Invocation: exec +pub proc get_mutability_config_word + push.MUTABILITY_CONFIG_SLOT[0..2] + exec.active_account::get_item +end -const ERR_FUNGIBLE_ASSET_TOKEN_SUPPLY_EXCEEDS_MAX_SUPPLY="token supply exceeds max supply" +#! Pipes 28 felts (7 words) from the advice stack into write_ptr and asserts the hash +#! matches COMMITMENT. adv.push_mapval must be called before this procedure to load +#! the data onto the advice stack. +#! +#! Inputs: [write_ptr, COMMITMENT] +#! Outputs: [] +#! +#! Invocation: exec +proc pipe_to_memory + push.7 + exec.mem::pipe_preimage_to_memory + # => [write_ptr'] + drop +end -const ERR_FUNGIBLE_ASSET_MAX_SUPPLY_EXCEEDS_FUNGIBLE_ASSET_MAX_AMOUNT="max supply exceeds maximum representable fungible asset amount" +# ================================================================================================= +# NAME (2 words) +# ================================================================================================= -const ERR_FUNGIBLE_ASSET_DISTRIBUTE_AMOUNT_EXCEEDS_MAX_SUPPLY="token_supply plus the amount passed to distribute would exceed the maximum supply" +#! Returns the token name. +#! +#! Inputs: [pad(16)] +#! Outputs: [NAME_CHUNK_0, NAME_CHUNK_1, pad(8)] +#! +#! Invocation: call +pub proc get_name + exec.get_name_chunk_1 + exec.get_name_chunk_0 + swapdw dropw dropw + # => [NAME_CHUNK_0, NAME_CHUNK_1, pad(8)] +end -const ERR_FAUCET_BURN_AMOUNT_EXCEEDS_TOKEN_SUPPLY="asset amount to burn exceeds the existing token supply" +# ================================================================================================= +# MUTABILITY CONFIG — [is_description_mutable, is_logo_uri_mutable, is_external_link_mutable, is_max_supply_mutable] +# ================================================================================================= -const ERR_BASIC_FUNGIBLE_BURN_WRONG_NUMBER_OF_ASSETS="burn requires exactly 1 note asset" +#! Returns the mutability config word. +#! +#! Inputs: [pad(16)] +#! Outputs: [is_description_mutable, is_logo_uri_mutable, is_external_link_mutable, is_max_supply_mutable, pad(12)] +#! +#! Invocation: call +pub proc get_mutability_config + exec.get_mutability_config_word + # => [is_description_mutable, is_logo_uri_mutable, is_external_link_mutable, is_max_supply_mutable, pad(16)] + swapw dropw + # => [is_description_mutable, is_logo_uri_mutable, is_external_link_mutable, is_max_supply_mutable, pad(12)] +end -# CONSTANTS +# ================================================================================================= +# MUTABILITY CHECKS — read mutability_config # ================================================================================================= -# The local memory address at which the metadata slot content is stored. -const METADATA_SLOT_LOCAL=0 +# Private helpers — load the mutability config word and extract a single flag. +# Used by both the public is_* procedures and the set_* procedures. -# The standard slot where fungible faucet metadata like token symbol or decimals are stored. -# Layout: [token_supply, max_supply, decimals, token_symbol] -const METADATA_SLOT=word("miden::standards::fungible_faucets::metadata") +#! Extracts the is_description_mutable flag from the mutability config word. +#! +#! Inputs: [] +#! Outputs: [is_description_mutable] +#! +#! Invocation: exec +proc is_description_mutable_internal + exec.get_mutability_config_word + # => [is_description_mutable, is_logo_uri_mutable, is_external_link_mutable, is_max_supply_mutable] + movdn.3 drop drop drop + # => [is_description_mutable] +end -#! Mints fungible assets to the provided recipient by creating a note. +#! Extracts the is_logo_uri_mutable flag from the mutability config word. #! -#! Inputs: [amount, tag, note_type, RECIPIENT] -#! Outputs: [note_idx] +#! Inputs: [] +#! Outputs: [is_logo_uri_mutable] #! -#! Where: -#! - amount is the amount to be minted and sent. -#! - tag is the tag to be included in the note. -#! - note_type is the type of the note that holds the asset. -#! - RECIPIENT is the recipient of the asset, i.e., -#! hash(hash(hash(serial_num, [0; 4]), script_root), storage_commitment). -#! - note_idx is the index of the created note. +#! Invocation: exec +proc is_logo_uri_mutable_internal + exec.get_mutability_config_word + # => [is_description_mutable, is_logo_uri_mutable, is_external_link_mutable, is_max_supply_mutable] + drop movdn.2 drop drop + # => [is_logo_uri_mutable] +end + +#! Extracts the is_external_link_mutable flag from the mutability config word. #! -#! Panics if: -#! - the transaction is being executed against an account that is not a fungible asset faucet. -#! - the token supply exceeds the maximum supply. -#! - the maximum supply exceeds the maximum representable fungible asset amount. -#! - the token supply after minting is greater than the maximum allowed supply. +#! Inputs: [] +#! Outputs: [is_external_link_mutable] #! #! Invocation: exec -@locals(4) -pub proc mint_and_send - # Get the configured max supply and the token supply (= current supply). - # --------------------------------------------------------------------------------------------- - - push.METADATA_SLOT[0..2] exec.active_account::get_item - # => [token_supply, max_supply, decimals, token_symbol, amount, tag, note_type, RECIPIENT] - - # store a copy of the current slot content for the token_supply update later - loc_storew_le.METADATA_SLOT_LOCAL - swap movup.2 drop movup.2 drop - # => [max_supply, token_supply, amount, tag, note_type, RECIPIENT] - - # Assert that minting does not violate any supply constraints. - # - # To make sure we cannot mint more than intended, we need to check: - # 1) (max_supply - token_supply) <= max_supply, i.e. the subtraction does not wrap around - # 2) amount + token_supply does not exceed max_supply - # 3) amount + token_supply is less than FUNGIBLE_ASSET_MAX_AMOUNT - # - # This is done with the following concrete assertions: - # - assert token_supply <= max_supply which ensures 1) - # - assert max_supply <= FUNGIBLE_ASSET_MAX_AMOUNT to help ensure 3) - # - assert amount <= max_mint_amount to ensure 2) as well as 3) - # - this ensures 3) because token_supply + max_mint_amount at most ends up being equal to - # max_supply and we already asserted that max_supply does not exceed - # FUNGIBLE_ASSET_MAX_AMOUNT - # --------------------------------------------------------------------------------------------- - - dup.1 dup.1 - # => [max_supply, token_supply, max_supply, token_supply, amount, tag, note_type, 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] - - # 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] - - dup.2 swap dup.2 - # => [token_supply, max_supply, amount, token_supply, amount, tag, note_type, 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] - - # assert amount <= max_mint_amount - lte assert.err=ERR_FUNGIBLE_ASSET_DISTRIBUTE_AMOUNT_EXCEEDS_MAX_SUPPLY - # => [token_supply, amount, tag, note_type, RECIPIENT] - - # Compute the new token_supply and update in storage. - # --------------------------------------------------------------------------------------------- - - dup.1 add - # => [new_token_supply, amount, tag, note_type, RECIPIENT] - - padw loc_loadw_le.METADATA_SLOT_LOCAL - # => [[token_supply, max_supply, decimals, token_symbol], new_token_supply, amount, tag, note_type, RECIPIENT] - - drop movup.3 - # => [[new_token_supply, max_supply, decimals, token_symbol], amount, tag, note_type, RECIPIENT] - - # update the metadata slot with the new supply - push.METADATA_SLOT[0..2] exec.native_account::set_item dropw - # => [amount, tag, note_type, RECIPIENT] - - # Create a new note. - # --------------------------------------------------------------------------------------------- - - movdn.6 exec.output_note::create - # => [note_idx, amount] - - dup movup.2 - # => [amount, note_idx, note_idx] - - # Mint the asset. - # --------------------------------------------------------------------------------------------- - - # creating the asset - exec.faucet::create_fungible_asset - # => [ASSET_KEY, ASSET_VALUE, note_idx, note_idx] - - dupw.1 dupw.1 - # => [ASSET_KEY, ASSET_VALUE, ASSET_KEY, ASSET_VALUE, note_idx, note_idx] - - # mint the asset; this is needed to satisfy asset preservation logic. - # this ensures that the asset's faucet ID matches the native account's ID. - # this is ensured because create_fungible_asset creates the asset with the native account's ID - exec.faucet::mint - dropw - # => [ASSET_KEY, ASSET_VALUE, note_idx, note_idx] - - # Add the asset to the note. - # --------------------------------------------------------------------------------------------- - - exec.output_note::add_asset - # => [note_idx] +proc is_external_link_mutable_internal + exec.get_mutability_config_word + # => [is_description_mutable, is_logo_uri_mutable, is_external_link_mutable, is_max_supply_mutable] + drop drop swap drop + # => [is_external_link_mutable] +end + +#! Returns 1 if description is mutable, and 0 otherwise. +#! +#! Inputs: [pad(16)] +#! Outputs: [is_description_mutable, pad(15)] +#! +#! Invocation: call +pub proc is_description_mutable + exec.is_description_mutable_internal + # => [is_description_mutable, pad(16)] + swap drop + # => [is_description_mutable, pad(15)] end -#! Burns the fungible asset from the active note. +#! Returns 1 if logo URI is mutable, and 0 otherwise. #! -#! Burning the asset removes it from circulation and reduces the token_supply by the asset's amount. +#! Inputs: [pad(16)] +#! Outputs: [is_logo_uri_mutable, pad(15)] #! -#! This procedure retrieves the asset from the active note and burns it. The note must contain -#! exactly one asset, which must be a fungible asset issued by this faucet. +#! Invocation: call +pub proc is_logo_uri_mutable + exec.is_logo_uri_mutable_internal + # => [is_logo_uri_mutable, pad(16)] + swap drop + # => [is_logo_uri_mutable, pad(15)] +end + +#! Returns 1 if external link is mutable, and 0 otherwise. #! #! Inputs: [pad(16)] -#! Outputs: [pad(16)] +#! Outputs: [is_external_link_mutable, pad(15)] +#! +#! Invocation: call +pub proc is_external_link_mutable + exec.is_external_link_mutable_internal + # => [is_external_link_mutable, pad(16)] + swap drop + # => [is_external_link_mutable, pad(15)] +end + +# ================================================================================================= +# SET DESCRIPTION (gated by Authority when is_description_mutable == 1) +# ================================================================================================= + +#! 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 actual 7 Words in the advice map under that hash. The hash is verified against +#! the preimage during loading. +#! +#! Inputs: +#! Operand stack: [DESCRIPTION_HASH, pad(12)] +#! Advice map: { +#! DESCRIPTION_HASH: [description_elements], +#! } +#! Outputs: +#! Operand stack: [pad(16)] +#! +#! Where: +#! - description_elements are 28 felts (7 Words) encoding the description. #! #! Panics if: -#! - the procedure is not called from a note context (active_note::get_assets will fail). -#! - the note does not contain exactly one asset. -#! - the transaction is executed against an account which is not a fungible asset faucet. -#! - the transaction is executed against a faucet which is not the origin of the specified asset. -#! - the amount about to be burned is greater than the token_supply of the faucet. +#! - the description mutability flag is not 1. +#! - the caller is not authorized per the installed [`Authority`]. +#! - the preimage does not match DESCRIPTION_HASH. #! #! Invocation: call -pub proc burn - # Get the asset from the note. - # --------------------------------------------------------------------------------------------- +@locals(28) +pub proc set_description + # Check mutability; verify owner. + # => [DESCRIPTION_HASH, pad(12)] + + exec.is_description_mutable_internal + # => [is_description_mutable, DESCRIPTION_HASH, pad(12)] + assert.err=ERR_DESCRIPTION_NOT_MUTABLE + # => [DESCRIPTION_HASH, pad(12)] + + exec.authority::assert_authorized + # => [DESCRIPTION_HASH, pad(12)] + + exec.pausable::assert_not_paused + # => [DESCRIPTION_HASH, pad(12)] + + # Pipe 7 words from advice map into local words 0–6, validating the hash. + adv.push_mapval + locaddr.0 + # => [locaddr.0, DESCRIPTION_HASH, pad(16)] + exec.pipe_to_memory + # => [pad(16)] + + loc_loadw_le.0 + push.TOKEN_DESCRIPTION_0_SLOT[0..2] + exec.native_account::set_item dropw + + loc_loadw_le.4 + push.TOKEN_DESCRIPTION_1_SLOT[0..2] + exec.native_account::set_item dropw + + loc_loadw_le.8 + push.TOKEN_DESCRIPTION_2_SLOT[0..2] + exec.native_account::set_item dropw - # this will fail if not called from a note context. - push.ASSET_PTR exec.active_note::get_assets - # => [num_assets, dest_ptr, pad(16)] + loc_loadw_le.12 + push.TOKEN_DESCRIPTION_3_SLOT[0..2] + exec.native_account::set_item dropw - # Verify we have exactly one asset - assert.err=ERR_BASIC_FUNGIBLE_BURN_WRONG_NUMBER_OF_ASSETS - # => [dest_ptr, pad(16)] + loc_loadw_le.16 + push.TOKEN_DESCRIPTION_4_SLOT[0..2] + exec.native_account::set_item dropw - exec.asset::load - # => [ASSET_KEY, ASSET_VALUE, pad(16)] + loc_loadw_le.20 + push.TOKEN_DESCRIPTION_5_SLOT[0..2] + exec.native_account::set_item dropw - # Burn the asset from the transaction vault - # --------------------------------------------------------------------------------------------- + loc_loadw_le.24 + push.TOKEN_DESCRIPTION_6_SLOT[0..2] + exec.native_account::set_item dropw +end + +# ================================================================================================= +# SET LOGO URI (gated by Authority when is_logo_uri_mutable == 1) +# ================================================================================================= + +#! 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 actual 7 Words in the advice map under that hash. The hash is verified against +#! the preimage during loading. +#! +#! Inputs: +#! Operand stack: [LOGO_URI_HASH, pad(12)] +#! Advice map: { +#! LOGO_URI_HASH: [logo_uri_elements], +#! } +#! Outputs: +#! Operand stack: [pad(16)] +#! +#! Where: +#! - logo_uri_elements are 28 felts (7 Words) encoding the logo URI. +#! +#! Panics if: +#! - the logo URI mutability flag is not 1. +#! - the caller is not authorized per the installed [`Authority`]. +#! - the preimage does not match LOGO_URI_HASH. +#! +#! Invocation: call +@locals(28) +pub proc set_logo_uri + # Check mutability; verify owner. + # => [LOGO_URI_HASH, pad(12)] + + exec.is_logo_uri_mutable_internal + # => [is_logo_uri_mutable, LOGO_URI_HASH, pad(12)] + assert.err=ERR_LOGO_URI_NOT_MUTABLE + # => [LOGO_URI_HASH, pad(12)] + + exec.authority::assert_authorized + # => [LOGO_URI_HASH, pad(12)] + + exec.pausable::assert_not_paused + # => [LOGO_URI_HASH, pad(12)] + + adv.push_mapval + locaddr.0 + # => [locaddr.0, LOGO_URI_HASH, pad(16)] + exec.pipe_to_memory + # => [pad(16)] - exec.asset::fungible_to_amount movdn.8 - # => [ASSET_KEY, ASSET_VALUE, amount, pad(16)] + loc_loadw_le.0 + push.LOGO_URI_0_SLOT[0..2] + exec.native_account::set_item dropw - # burn the asset - # this ensures we only burn assets that were issued by this faucet (which implies they are - # fungible) - exec.faucet::burn - # => [amount, pad(16)] + loc_loadw_le.4 + push.LOGO_URI_1_SLOT[0..2] + exec.native_account::set_item dropw - # Subtract burnt amount from current token_supply in storage. - # --------------------------------------------------------------------------------------------- + loc_loadw_le.8 + push.LOGO_URI_2_SLOT[0..2] + exec.native_account::set_item dropw - push.METADATA_SLOT[0..2] exec.active_account::get_item - # => [token_supply, max_supply, decimals, token_symbol, amount, pad(16)] + loc_loadw_le.12 + push.LOGO_URI_3_SLOT[0..2] + exec.native_account::set_item dropw - dup.4 dup.1 - # => [token_supply, amount, token_supply, max_supply, decimals, token_symbol, amount, pad(16)] + loc_loadw_le.16 + push.LOGO_URI_4_SLOT[0..2] + exec.native_account::set_item dropw - # assert that amount <= token_supply - lte assert.err=ERR_FAUCET_BURN_AMOUNT_EXCEEDS_TOKEN_SUPPLY - # => [token_supply, max_supply, decimals, token_symbol, amount, pad(16)] + loc_loadw_le.20 + push.LOGO_URI_5_SLOT[0..2] + exec.native_account::set_item dropw - movup.4 - # => [amount, token_supply, max_supply, decimals, token_symbol, pad(16)] + loc_loadw_le.24 + push.LOGO_URI_6_SLOT[0..2] + exec.native_account::set_item dropw +end - # compute new_token_supply = token_supply - amount - sub - # => [new_token_supply, max_supply, decimals, token_symbol, pad(16)] +# ================================================================================================= +# SET EXTERNAL LINK (gated by Authority when is_external_link_mutable == 1) +# ================================================================================================= - # update the metadata slot with the new supply - push.METADATA_SLOT[0..2] exec.native_account::set_item dropw +#! 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 actual 7 Words in the advice map under that hash. The hash is verified against +#! the preimage during loading. +#! +#! Inputs: +#! Operand stack: [EXTERNAL_LINK_HASH, pad(12)] +#! Advice map: { +#! EXTERNAL_LINK_HASH: [external_link_elements], +#! } +#! Outputs: +#! Operand stack: [pad(16)] +#! +#! Where: +#! - external_link_elements are 28 felts (7 Words) encoding the external link. +#! +#! Panics if: +#! - the external link mutability flag is not 1. +#! - the caller is not authorized per the installed [`Authority`]. +#! - the preimage does not match EXTERNAL_LINK_HASH. +#! +#! Invocation: call +@locals(28) +pub proc set_external_link + # Check mutability; verify owner. + # => [EXTERNAL_LINK_HASH, pad(12)] + + exec.is_external_link_mutable_internal + # => [is_external_link_mutable, EXTERNAL_LINK_HASH, pad(12)] + assert.err=ERR_EXTERNAL_LINK_NOT_MUTABLE + # => [EXTERNAL_LINK_HASH, pad(12)] + + exec.authority::assert_authorized + # => [EXTERNAL_LINK_HASH, pad(12)] + + exec.pausable::assert_not_paused + # => [EXTERNAL_LINK_HASH, pad(12)] + + adv.push_mapval + locaddr.0 + # => [locaddr.0, EXTERNAL_LINK_HASH, pad(16)] + exec.pipe_to_memory # => [pad(16)] + + loc_loadw_le.0 + push.EXTERNAL_LINK_0_SLOT[0..2] + exec.native_account::set_item dropw + + loc_loadw_le.4 + push.EXTERNAL_LINK_1_SLOT[0..2] + exec.native_account::set_item dropw + + loc_loadw_le.8 + push.EXTERNAL_LINK_2_SLOT[0..2] + exec.native_account::set_item dropw + + loc_loadw_le.12 + push.EXTERNAL_LINK_3_SLOT[0..2] + exec.native_account::set_item dropw + + loc_loadw_le.16 + push.EXTERNAL_LINK_4_SLOT[0..2] + exec.native_account::set_item dropw + + loc_loadw_le.20 + push.EXTERNAL_LINK_5_SLOT[0..2] + exec.native_account::set_item dropw + + loc_loadw_le.24 + push.EXTERNAL_LINK_6_SLOT[0..2] + exec.native_account::set_item dropw end diff --git a/crates/miden-standards/asm/standards/faucets/network_fungible.masm b/crates/miden-standards/asm/standards/faucets/network_fungible.masm deleted file mode 100644 index 9f3c58887d..0000000000 --- a/crates/miden-standards/asm/standards/faucets/network_fungible.masm +++ /dev/null @@ -1,60 +0,0 @@ -# NETWORK FUNGIBLE FAUCET CONTRACT -# -# Note: This component requires `MintPolicyManager` component to also be present in the account. -# ================================================================================================= - -use miden::standards::faucets -use miden::standards::mint_policies::policy_manager - -# PUBLIC INTERFACE -# ================================================================================================ - -# ASSET MINTING -# ------------------------------------------------------------------------------------------------ - -#! Mints fungible assets to the provided recipient by creating a note. -#! -#! This procedure first executes the active mint policy configured via -#! `active_policy_proc_root`, and then mints the asset and creates an output note -#! with that asset for the recipient. -#! -#! Inputs: [amount, tag, note_type, RECIPIENT, pad(9)] -#! Outputs: [note_idx, pad(15)] -#! -#! Where: -#! - amount is the amount to be minted and sent. -#! - tag is the tag to be included in the note. -#! - note_type is the type of the note that holds the asset. -#! - RECIPIENT is the recipient of the asset. -#! - note_idx is the index of the created note. -#! -#! Panics if: -#! - active mint policy validation fails. -#! - any of the validations in faucets::mint_and_send fail. -#! -#! Invocation: call -pub proc mint_and_send - exec.policy_manager::execute_mint_policy - # => [new_amount, new_tag, new_note_type, NEW_RECIPIENT, pad(9)] - - exec.faucets::mint_and_send - # => [note_idx, pad(15)] -end - -#! Burns the fungible asset from the active note. -#! -#! This procedure retrieves the asset from the active note and burns it. The note must contain -#! exactly one asset, which must be a fungible asset issued by this faucet. -#! -#! Inputs: [pad(16)] -#! Outputs: [pad(16)] -#! -#! Panics if: -#! - the procedure is not called from a note context (active_note::get_assets will fail). -#! - the note does not contain exactly one asset. -#! - the transaction is executed against an account which is not a fungible asset faucet. -#! - the transaction is executed against a faucet which is not the origin of the specified asset. -#! - the amount about to be burned is greater than the outstanding supply of the asset. -#! -#! Invocation: call -pub use faucets::burn diff --git a/crates/miden-standards/asm/standards/faucets/policies/burn/allow_all.masm b/crates/miden-standards/asm/standards/faucets/policies/burn/allow_all.masm new file mode 100644 index 0000000000..6724e9be06 --- /dev/null +++ b/crates/miden-standards/asm/standards/faucets/policies/burn/allow_all.masm @@ -0,0 +1,14 @@ +# Generic burn policy procedures shared by policy manager flows. + +# POLICY PROCEDURES +# ================================================================================================ + +#! Burn policy that accepts every burn request. +#! +#! Inputs: [ASSET_KEY, ASSET_VALUE] +#! Outputs: [] +#! Invocation: dynexec +pub proc check_policy + dropw dropw + # => [] +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 new file mode 100644 index 0000000000..06c2e78549 --- /dev/null +++ b/crates/miden-standards/asm/standards/faucets/policies/burn/owner_controlled/owner_only.masm @@ -0,0 +1,21 @@ +use miden::standards::access::ownable2step + +# POLICY PROCEDURES +# ================================================================================================ + +#! Owner-only burn predicate. +#! +#! Inputs: [ASSET_KEY, ASSET_VALUE] +#! Outputs: [] +#! +#! Panics if: +#! - note sender is not owner. +#! +#! Invocation: dynexec +pub proc check_policy + exec.ownable2step::assert_sender_is_owner + # => [ASSET_KEY, ASSET_VALUE] + + dropw dropw + # => [] +end diff --git a/crates/miden-standards/asm/standards/faucets/policies/mint/allow_all.masm b/crates/miden-standards/asm/standards/faucets/policies/mint/allow_all.masm new file mode 100644 index 0000000000..339810ff75 --- /dev/null +++ b/crates/miden-standards/asm/standards/faucets/policies/mint/allow_all.masm @@ -0,0 +1,13 @@ +# Generic mint policy procedures shared by policy manager flows. + +# POLICY PROCEDURES +# ================================================================================================ + +#! Mint policy that accepts every mint request. +#! +#! Inputs: [amount, tag, note_type, RECIPIENT] +#! Outputs: [amount, tag, note_type, RECIPIENT] +#! Invocation: dynexec +pub proc check_policy + push.0 drop +end diff --git a/crates/miden-standards/asm/standards/mint_policies/owner_controlled.masm b/crates/miden-standards/asm/standards/faucets/policies/mint/owner_controlled/owner_only.masm similarity index 63% rename from crates/miden-standards/asm/standards/mint_policies/owner_controlled.masm rename to crates/miden-standards/asm/standards/faucets/policies/mint/owner_controlled/owner_only.masm index 9b93582d8d..904aa1b741 100644 --- a/crates/miden-standards/asm/standards/mint_policies/owner_controlled.masm +++ b/crates/miden-standards/asm/standards/faucets/policies/mint/owner_controlled/owner_only.masm @@ -5,14 +5,14 @@ use miden::standards::access::ownable2step #! Owner-only mint predicate. #! -#! Inputs: [amount, tag, note_type, RECIPIENT, pad(9)] -#! Outputs: [amount, tag, note_type, RECIPIENT, pad(9)] +#! Inputs: [amount, tag, note_type, RECIPIENT] +#! Outputs: [amount, tag, note_type, RECIPIENT] #! #! Panics if: #! - note sender is not owner. #! #! Invocation: dynexec -pub proc owner_only +pub proc check_policy exec.ownable2step::assert_sender_is_owner - # => [amount, tag, note_type, RECIPIENT, pad(9)] + # => [amount, tag, note_type, RECIPIENT] end diff --git a/crates/miden-standards/asm/standards/faucets/policies/policy_manager.masm b/crates/miden-standards/asm/standards/faucets/policies/policy_manager.masm new file mode 100644 index 0000000000..d71e3257cd --- /dev/null +++ b/crates/miden-standards/asm/standards/faucets/policies/policy_manager.masm @@ -0,0 +1,493 @@ +use miden::core::word +use miden::protocol::active_account +use miden::protocol::native_account +use miden::standards::access::authority +use miden::standards::access::pausable + +# DEPENDENCY NOTE +# This manager owns mint, burn, send, and receive policy state in a single component. The +# `set_*_policy` procedures consult the account-wide [`Authority`] component (installed +# 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 +# ================================================================================================ + +# Active mint policy root slot. Layout: [PROC_ROOT] +const ACTIVE_MINT_POLICY_PROC_ROOT_SLOT=word("miden::standards::faucets::policies::policy_manager::active_mint_policy_proc_root") + +# 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") + +# 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") + +# 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") + +# Allowlist map slot for burn policy roots. Map entries: [PROC_ROOT] -> [1, 0, 0, 0] +const ALLOWED_BURN_POLICY_PROC_ROOTS_SLOT=word("miden::standards::faucets::policies::policy_manager::allowed_burn_policy_proc_roots") + +# Allowlist map slot for send policy roots. Map entries: [PROC_ROOT] -> [1, 0, 0, 0] +const ALLOWED_SEND_POLICY_PROC_ROOTS_SLOT=word("miden::standards::faucets::policies::policy_manager::allowed_send_policy_proc_roots") + +# Allowlist map slot for receive policy roots. Map entries: [PROC_ROOT] -> [1, 0, 0, 0] +const ALLOWED_RECEIVE_POLICY_PROC_ROOTS_SLOT=word("miden::standards::faucets::policies::policy_manager::allowed_receive_policy_proc_roots") + +# Local memory pointer used to pass a policy root to `dynexec`. +const POLICY_PROC_ROOT_PTR=0 + +# ERRORS +# ================================================================================================ + +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_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_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" +const ERR_SEND_POLICY_ROOT_NOT_ALLOWED="send policy root is not allowed" + +const ERR_RECEIVE_POLICY_ROOT_IS_ZERO="receive policy root is zero" +const ERR_RECEIVE_POLICY_ROOT_NOT_IN_ACCOUNT="receive policy root is not a procedure of this account" +const ERR_RECEIVE_POLICY_ROOT_NOT_ALLOWED="receive policy root is not allowed" + +# MINT INTERNAL PROCEDURES +# ================================================================================================ + +#! Reads active mint policy root from storage. +#! +#! Inputs: [] +#! Outputs: [MINT_POLICY_ROOT] +#! +#! Invocation: exec +proc get_mint_policy_root + push.ACTIVE_MINT_POLICY_PROC_ROOT_SLOT[0..2] exec.active_account::get_item + # => [MINT_POLICY_ROOT] +end + +#! Validates a mint policy root at config time. +#! +#! Inputs: [MINT_POLICY_ROOT] +#! Outputs: [MINT_POLICY_ROOT] +#! +#! Panics if: +#! - policy root is zero. +#! - policy root is not present in this account's procedures. +#! +#! Invocation: exec +proc assert_existing_mint_policy_root + exec.word::testz + assertz.err=ERR_MINT_POLICY_ROOT_IS_ZERO + # => [MINT_POLICY_ROOT] + + dupw exec.active_account::has_procedure + assert.err=ERR_MINT_POLICY_ROOT_NOT_IN_ACCOUNT + # => [MINT_POLICY_ROOT] +end + +#! Validates that the mint policy root is one of the allowed mint policy roots. +#! +#! Inputs: [MINT_POLICY_ROOT] +#! Outputs: [MINT_POLICY_ROOT] +#! +#! Panics if: +#! - policy root is not in the allowed mint policy roots map. +#! +#! Invocation: exec +proc assert_allowed_mint_policy_root + dupw + push.ALLOWED_MINT_POLICY_PROC_ROOTS_SLOT[0..2] + exec.active_account::get_map_item + # => [ALLOWED_FLAG, MINT_POLICY_ROOT] + + exec.word::eqz + assertz.err=ERR_MINT_POLICY_ROOT_NOT_ALLOWED + # => [MINT_POLICY_ROOT] +end + +# BURN INTERNAL PROCEDURES +# ================================================================================================ + +#! Reads active burn policy root from storage. +#! +#! Inputs: [] +#! Outputs: [BURN_POLICY_ROOT] +#! +#! Invocation: exec +proc get_burn_policy_root + push.ACTIVE_BURN_POLICY_PROC_ROOT_SLOT[0..2] exec.active_account::get_item + # => [BURN_POLICY_ROOT] +end + +#! Validates a burn policy root at config time. +#! +#! Inputs: [BURN_POLICY_ROOT] +#! Outputs: [BURN_POLICY_ROOT] +#! +#! Panics if: +#! - policy root is zero. +#! - policy root is not present in this account's procedures. +#! +#! Invocation: exec +proc assert_existing_burn_policy_root + exec.word::testz + assertz.err=ERR_BURN_POLICY_ROOT_IS_ZERO + # => [BURN_POLICY_ROOT] + + dupw exec.active_account::has_procedure + assert.err=ERR_BURN_POLICY_ROOT_NOT_IN_ACCOUNT + # => [BURN_POLICY_ROOT] +end + +#! Validates that the burn policy root is one of the allowed burn policy roots. +#! +#! Inputs: [BURN_POLICY_ROOT] +#! Outputs: [BURN_POLICY_ROOT] +#! +#! Panics if: +#! - policy root is not in the allowed burn policy roots map. +#! +#! Invocation: exec +proc assert_allowed_burn_policy_root + dupw + push.ALLOWED_BURN_POLICY_PROC_ROOTS_SLOT[0..2] + exec.active_account::get_map_item + # => [ALLOWED_FLAG, BURN_POLICY_ROOT] + + exec.word::eqz + assertz.err=ERR_BURN_POLICY_ROOT_NOT_ALLOWED + # => [BURN_POLICY_ROOT] +end + +# SEND INTERNAL PROCEDURES +# ================================================================================================ + +#! Validates a send policy root at config time. +#! +#! Inputs: [SEND_POLICY_ROOT] +#! Outputs: [SEND_POLICY_ROOT] +#! +#! Panics if: +#! - policy root is zero. +#! - policy root is not present in this account's procedures. +#! +#! Invocation: exec +proc assert_existing_send_policy_root + exec.word::testz + assertz.err=ERR_SEND_POLICY_ROOT_IS_ZERO + # => [SEND_POLICY_ROOT] + + dupw exec.active_account::has_procedure + assert.err=ERR_SEND_POLICY_ROOT_NOT_IN_ACCOUNT + # => [SEND_POLICY_ROOT] +end + +#! Validates that the send policy root is one of the allowed send policy roots. +#! +#! Inputs: [SEND_POLICY_ROOT] +#! Outputs: [SEND_POLICY_ROOT] +#! +#! Panics if: +#! - policy root is not in the allowed send policy roots map. +#! +#! Invocation: exec +proc assert_allowed_send_policy_root + dupw + push.ALLOWED_SEND_POLICY_PROC_ROOTS_SLOT[0..2] + exec.active_account::get_map_item + # => [ALLOWED_FLAG, SEND_POLICY_ROOT] + + exec.word::eqz + assertz.err=ERR_SEND_POLICY_ROOT_NOT_ALLOWED + # => [SEND_POLICY_ROOT] +end + +# RECEIVE INTERNAL PROCEDURES +# ================================================================================================ + +#! Validates a receive policy root at config time. +#! +#! Inputs: [RECEIVE_POLICY_ROOT] +#! Outputs: [RECEIVE_POLICY_ROOT] +#! +#! Panics if: +#! - policy root is zero. +#! - policy root is not present in this account's procedures. +#! +#! Invocation: exec +proc assert_existing_receive_policy_root + exec.word::testz + assertz.err=ERR_RECEIVE_POLICY_ROOT_IS_ZERO + # => [RECEIVE_POLICY_ROOT] + + dupw exec.active_account::has_procedure + assert.err=ERR_RECEIVE_POLICY_ROOT_NOT_IN_ACCOUNT + # => [RECEIVE_POLICY_ROOT] +end + +#! Validates that the receive policy root is one of the allowed receive policy roots. +#! +#! Inputs: [RECEIVE_POLICY_ROOT] +#! Outputs: [RECEIVE_POLICY_ROOT] +#! +#! Panics if: +#! - policy root is not in the allowed receive policy roots map. +#! +#! Invocation: exec +proc assert_allowed_receive_policy_root + dupw + push.ALLOWED_RECEIVE_POLICY_PROC_ROOTS_SLOT[0..2] + exec.active_account::get_map_item + # => [ALLOWED_FLAG, RECEIVE_POLICY_ROOT] + + exec.word::eqz + assertz.err=ERR_RECEIVE_POLICY_ROOT_NOT_ALLOWED + # => [RECEIVE_POLICY_ROOT] +end + +# MINT PUBLIC INTERFACE +# ================================================================================================ + +#! Executes active mint policy by dynamic execution. +#! +#! Inputs: [amount, tag, note_type, RECIPIENT] +#! 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). +#! - 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 + exec.pausable::assert_not_paused + # => [amount, tag, note_type, RECIPIENT] + + exec.get_mint_policy_root + # => [MINT_POLICY_ROOT, amount, tag, note_type, RECIPIENT] + + loc_storew_le.POLICY_PROC_ROOT_PTR dropw + # => [amount, tag, note_type, RECIPIENT] + + locaddr.POLICY_PROC_ROOT_PTR + # => [policy_root_ptr, amount, tag, note_type, RECIPIENT] + + dynexec + # => [amount, tag, note_type, RECIPIENT] +end + +#! Returns active mint policy root. +#! +#! Inputs: [pad(16)] +#! Outputs: [MINT_POLICY_ROOT, pad(12)] +#! +#! Invocation: call +pub proc get_mint_policy + exec.get_mint_policy_root + # => [MINT_POLICY_ROOT, pad(12)] +end + +#! Sets active mint policy root. +#! +#! Inputs: [NEW_POLICY_ROOT, pad(12)] +#! Outputs: [pad(16)] +#! +#! Panics if: +#! - the caller is not authorized per the installed [`Authority`]. +#! - NEW_POLICY_ROOT is zero. +#! - NEW_POLICY_ROOT is not a procedure of this account. +#! - NEW_POLICY_ROOT is not in the allowed roots map. +#! +#! Invocation: call +pub proc set_mint_policy + exec.authority::assert_authorized + # => [NEW_POLICY_ROOT, pad(12)] + + exec.assert_existing_mint_policy_root + # => [NEW_POLICY_ROOT, pad(12)] + + exec.assert_allowed_mint_policy_root + # => [NEW_POLICY_ROOT, pad(12)] + + push.ACTIVE_MINT_POLICY_PROC_ROOT_SLOT[0..2] exec.native_account::set_item dropw + # => [pad(16)] +end + +# BURN PUBLIC INTERFACE +# ================================================================================================ + +#! Executes active burn policy for the provided asset by dynamic execution. +#! +#! Inputs: [ASSET_KEY, ASSET_VALUE] +#! Outputs: [] +#! +#! Panics if: +#! - the account is paused. +#! - 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 + exec.pausable::assert_not_paused + # => [ASSET_KEY, ASSET_VALUE] + + exec.get_burn_policy_root + # => [BURN_POLICY_ROOT, ASSET_KEY, ASSET_VALUE] + + loc_storew_le.POLICY_PROC_ROOT_PTR dropw + # => [ASSET_KEY, ASSET_VALUE] + + locaddr.POLICY_PROC_ROOT_PTR + # => [policy_root_ptr, ASSET_KEY, ASSET_VALUE] + + dynexec + # => [] +end + +#! Returns active burn policy root. +#! +#! Inputs: [pad(16)] +#! Outputs: [BURN_POLICY_ROOT, pad(12)] +#! +#! Invocation: call +pub proc get_burn_policy + exec.get_burn_policy_root + # => [BURN_POLICY_ROOT, pad(12)] +end + +#! Sets active burn policy root. +#! +#! Inputs: [NEW_POLICY_ROOT, pad(12)] +#! Outputs: [pad(16)] +#! +#! Panics if: +#! - the caller is not authorized per the installed [`Authority`]. +#! - NEW_POLICY_ROOT is zero. +#! - NEW_POLICY_ROOT is not a procedure of this account. +#! - NEW_POLICY_ROOT is not in the allowed roots map. +#! +#! Invocation: call +pub proc set_burn_policy + exec.authority::assert_authorized + # => [NEW_POLICY_ROOT, pad(12)] + + exec.assert_existing_burn_policy_root + # => [NEW_POLICY_ROOT, pad(12)] + + exec.assert_allowed_burn_policy_root + # => [NEW_POLICY_ROOT, pad(12)] + + push.ACTIVE_BURN_POLICY_PROC_ROOT_SLOT[0..2] exec.native_account::set_item dropw + # => [pad(16)] +end + +# SEND PUBLIC INTERFACE +# ================================================================================================ + +#! Returns active send policy root (reads the protocol-reserved callback slot). +#! +#! 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 + # => [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. +#! +#! Inputs: [NEW_POLICY_ROOT, pad(12)] +#! Outputs: [pad(16)] +#! +#! Panics if: +#! - the caller is not authorized per the installed [`Authority`]. +#! - NEW_POLICY_ROOT is zero. +#! - NEW_POLICY_ROOT is not a procedure of this account. +#! - NEW_POLICY_ROOT is not in the allowed roots map. +#! +#! Invocation: call +pub proc set_send_policy + exec.authority::assert_authorized + # => [NEW_POLICY_ROOT, pad(12)] + + exec.assert_existing_send_policy_root + # => [NEW_POLICY_ROOT, pad(12)] + + 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 + # => [pad(16)] +end + +# RECEIVE PUBLIC INTERFACE +# ================================================================================================ + +#! Returns active receive policy root (reads the protocol-reserved callback slot). +#! +#! 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 + # => [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. +#! +#! Inputs: [NEW_POLICY_ROOT, pad(12)] +#! Outputs: [pad(16)] +#! +#! Panics if: +#! - the caller is not authorized per the installed [`Authority`]. +#! - NEW_POLICY_ROOT is zero. +#! - NEW_POLICY_ROOT is not a procedure of this account. +#! - NEW_POLICY_ROOT is not in the allowed roots map. +#! +#! Invocation: call +pub proc set_receive_policy + exec.authority::assert_authorized + # => [NEW_POLICY_ROOT, pad(12)] + + exec.assert_existing_receive_policy_root + # => [NEW_POLICY_ROOT, pad(12)] + + 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 + # => [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 new file mode 100644 index 0000000000..53c7dac64e --- /dev/null +++ b/crates/miden-standards/asm/standards/faucets/policies/transfer/allow_all.masm @@ -0,0 +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. +#! +#! Invoked as the active send or receive policy via `TokenPolicyManager`. +#! +#! Calls `exec.pausable::assert_not_paused` before allowing the transfer. The `Pausable` component +#! is REQUIRED on accounts using TokenPolicyManager. +#! +#! Inputs: [ASSET_KEY, ASSET_VALUE] +#! Outputs: [ASSET_VALUE] +#! +#! Invocation: call +pub proc check_policy + exec.pausable::assert_not_paused + dropw +end diff --git a/crates/miden-standards/asm/standards/faucets/policies/transfer/allowlist/mod.masm b/crates/miden-standards/asm/standards/faucets/policies/transfer/allowlist/mod.masm new file mode 100644 index 0000000000..5e5e453ee9 --- /dev/null +++ b/crates/miden-standards/asm/standards/faucets/policies/transfer/allowlist/mod.masm @@ -0,0 +1,152 @@ +# miden::standards::faucets::policies::transfer::allowlist +# +# Per-faucet allowlist primitive: storage slot plus low-level `Invocation: exec` helpers. +# This is the core component — it does NOT implement the transfer policy itself; that lives +# in a sibling policy file (e.g. `basic_allowlist.masm`) that consumes these helpers. +# +# - Storage: `allowed_accounts` map keyed by account ID; entry word `[1, 0, 0, 0]` means allowed. +# The default (unset) entry is the zero word, which means NOT allowed — this is the opposite +# default from the blocklist primitive. +# - Helpers (`allow_account` / `disallow_account` / `is_allowed` / `assert_allowed`) perform +# NO authorization — they are low-level building blocks meant to be called by +# `Invocation: call` wrappers in auth-checking account components (see `owner_controlled.masm`). +# Tx scripts cannot invoke them directly — they go through the wrapper component installed on +# the faucet. + +use miden::core::word +use miden::protocol::active_account +use miden::protocol::native_account + +# CONSTANTS +# ================================================================================================ + +# Slot holding the per-faucet map of allowed accounts. +# Map entries: [0, 0, account_id_suffix, account_id_prefix] => [is_allowed, 0, 0, 0] +const ALLOWED_ACCOUNTS_SLOT = word("miden::standards::faucets::policies::transfer::allowlist::allowed_accounts") + +const ALLOWED_WORD = [1, 0, 0, 0] + +const DISALLOWED_WORD = [0, 0, 0, 0] + +# ERRORS +# ================================================================================================ + +const ERR_ACCOUNT_IS_NOT_ALLOWED = "account is not in the allowlist" + +# PUBLIC HELPERS +# ================================================================================================ + +#! Returns whether the given account is currently allowed on this faucet. +#! +#! Reads [`ALLOWED_ACCOUNTS_SLOT`] on the active account at the key derived from the account ID. +#! Returns `1` if the stored word is non-zero (allowed) or `0` if it is the zero word +#! (not allowed, including the default for never-set entries). +#! +#! Inputs: [account_id_suffix, account_id_prefix] +#! Outputs: [is_allowed] +#! +#! Invocation: exec +pub proc is_allowed + exec.build_allowed_accounts_map_key + # => [KEY] + + push.ALLOWED_ACCOUNTS_SLOT[0..2] + # => [slot_suffix, slot_prefix, KEY] + + exec.active_account::get_map_item + # => [VALUE] + + exec.word::eqz not + # => [is_allowed] +end + +#! Adds the given account to the allowlist. If the account is already allowed, this is a noop. +#! +#! This procedure performs NO authorization. Callers are responsible to add this. +#! +#! Inputs: [account_id_suffix, account_id_prefix] +#! Outputs: [] +#! +#! Invocation: exec +pub proc allow_account + exec.build_allowed_accounts_map_key + # => [KEY] + + push.ALLOWED_WORD swapw + # => [KEY, ALLOWED_WORD] + + push.ALLOWED_ACCOUNTS_SLOT[0..2] + # => [slot_suffix, slot_prefix, KEY, ALLOWED_WORD] + + exec.native_account::set_map_item + # => [OLD_VALUE] + + dropw + # => [] +end + +#! Removes the given account from the allowlist. If the account is not currently allowed, this +#! is a noop. See [`allow_account`] for the same authorization caveats. +#! +#! Inputs: [account_id_suffix, account_id_prefix] +#! Outputs: [] +#! +#! Invocation: exec +pub proc disallow_account + exec.build_allowed_accounts_map_key + # => [KEY] + + push.DISALLOWED_WORD swapw + # => [KEY, DISALLOWED_WORD] + + push.ALLOWED_ACCOUNTS_SLOT[0..2] + # => [slot_suffix, slot_prefix, KEY, DISALLOWED_WORD] + + exec.native_account::set_map_item + # => [OLD_VALUE] + + dropw + # => [] +end + +#! Requires the given account to be allowed. +#! +#! Delegates to [`is_allowed`]. If the account is not allowed, panics with +#! [`ERR_ACCOUNT_IS_NOT_ALLOWED`]. +#! +#! Use from other modules or transaction scripts to guard logic that must not run for +#! non-allowed accounts. In asset callback foreign context, the active account is the issuing +#! faucet. +#! +#! Inputs: [account_id_suffix, account_id_prefix] +#! Outputs: [] +#! +#! Panics if: +#! - the stored entry for this account is the zero word. +#! +#! Invocation: exec +pub proc assert_allowed + exec.is_allowed + # => [is_allowed] + + assert.err=ERR_ACCOUNT_IS_NOT_ALLOWED + # => [] +end + +# HELPER PROCEDURES +# ================================================================================================ + +#! Builds the allowed-accounts map key for the given account ID. +#! +#! Inputs: [account_id_suffix, account_id_prefix] +#! Outputs: [KEY] +#! +#! Where: +#! - KEY is [0, 0, account_id_suffix, account_id_prefix]. +#! +#! Invocation: exec +proc build_allowed_accounts_map_key + push.0.0 + # => [0, 0, account_id_suffix, account_id_prefix] + # => [KEY] +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 new file mode 100644 index 0000000000..d300c62b3b --- /dev/null +++ b/crates/miden-standards/asm/standards/faucets/policies/transfer/allowlist/owner_controlled.masm @@ -0,0 +1,50 @@ +# miden::standards::faucets::policies::transfer::allowlist::owner_controlled +# +# Owner-controlled allowlist admin: wraps the `Invocation: exec` helpers from +# `miden::standards::faucets::policies::transfer::allowlist` with an Ownable2Step authorization +# check. +# +# Companion components required on the faucet: +# - `Ownable2Step` — provides the owner storage slot the auth check reads. +# - `BasicAllowlist` (or any other component that installs the `allowed_accounts` storage slot) +# — provides the storage that `allow_account` / `disallow_account` write to. + +use miden::standards::access::ownable2step +use miden::standards::faucets::policies::transfer::allowlist + +# PUBLIC INTERFACE +# ================================================================================================ + +#! Allow an account, gated by the Ownable2Step owner. +#! +#! Inputs: [account_id_suffix, account_id_prefix, pad(14)] +#! Outputs: [pad(16)] +#! +#! Panics if: +#! - the sender is not the account owner. +#! +#! Invocation: call +pub proc allow_account + exec.ownable2step::assert_sender_is_owner + # => [account_id_suffix, account_id_prefix, pad(14)] + + exec.allowlist::allow_account + # => [pad(14)] +end + +#! Disallow an account, gated by the Ownable2Step owner. +#! +#! Inputs: [account_id_suffix, account_id_prefix, pad(14)] +#! Outputs: [pad(16)] +#! +#! Panics if: +#! - the sender is not the account owner. +#! +#! Invocation: call +pub proc disallow_account + exec.ownable2step::assert_sender_is_owner + # => [account_id_suffix, account_id_prefix, pad(14)] + + exec.allowlist::disallow_account + # => [pad(14)] +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 new file mode 100644 index 0000000000..c671b16949 --- /dev/null +++ b/crates/miden-standards/asm/standards/faucets/policies/transfer/basic_allowlist.masm @@ -0,0 +1,46 @@ +# miden::standards::faucets::policies::transfer::basic_allowlist +# +# Basic allowlist transfer policy: rejects transfers whose native account is not allowed on the +# issuing faucet. Delegates the per-account state to the sibling `allowlist` primitive +# (storage + helpers). + +use miden::protocol::native_account +use miden::standards::faucets::policies::transfer::allowlist +use miden::standards::access::pausable + +# POLICY PROCEDURES +# ================================================================================================ + +#! 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)] +#! Outputs: [ASSET_VALUE, pad(12)] +#! +#! 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)] + + exec.allowlist::assert_allowed + # => [ASSET_KEY, ASSET_VALUE, pad(8)] + + dropw + # => [ASSET_VALUE, pad(12)] +end 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 new file mode 100644 index 0000000000..51e2cc9f9e --- /dev/null +++ b/crates/miden-standards/asm/standards/faucets/policies/transfer/basic_blocklist.masm @@ -0,0 +1,45 @@ +# miden::standards::faucets::policies::transfer::basic_blocklist +# +# Basic blocklist transfer policy: rejects transfers whose native account is blocked on the +# issuing faucet. Delegates the per-account state to the sibling `blocklist` primitive +# (storage + helpers). + +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)] +#! Outputs: [ASSET_VALUE, pad(12)] +#! +#! 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)] + + exec.blocklist::assert_not_blocked + # => [ASSET_KEY, ASSET_VALUE, pad(8)] + + dropw + # => [ASSET_VALUE, pad(12)] +end diff --git a/crates/miden-standards/asm/standards/faucets/policies/transfer/blocklist/mod.masm b/crates/miden-standards/asm/standards/faucets/policies/transfer/blocklist/mod.masm new file mode 100644 index 0000000000..4bbd71b7af --- /dev/null +++ b/crates/miden-standards/asm/standards/faucets/policies/transfer/blocklist/mod.masm @@ -0,0 +1,154 @@ +# miden::standards::faucets::policies::transfer::blocklist +# +# Per-faucet blocklist primitive: storage slot plus low-level `Invocation: exec` helpers. +# This is the core component — it does NOT implement the transfer policy itself; that lives +# in a sibling policy file (e.g. `basic_blocklist.masm`) that consumes these helpers. +# +# - Storage: `blocked_accounts` map keyed by account ID; entry word `[1, 0, 0, 0]` means blocked. +# - Helpers (`block_account` / `unblock_account` / `is_blocked` / `assert_not_blocked`) perform +# NO authorization — they are low-level building blocks meant to be called by +# `Invocation: call` wrappers in auth-checking account components (see `owner_controlled.masm`). +# Tx scripts cannot invoke them directly — they go through the wrapper component installed on +# the faucet. + +use miden::core::word +use miden::protocol::active_account +use miden::protocol::native_account + +# CONSTANTS +# ================================================================================================ + +# Slot holding the per-faucet map of blocked accounts. +# Map entries: [0, 0, account_id_suffix, account_id_prefix] => [is_blocked, 0, 0, 0] +const BLOCKED_ACCOUNTS_SLOT = word("miden::standards::faucets::policies::transfer::blocklist::blocked_accounts") + +const BLOCKED_WORD = [1, 0, 0, 0] + +const UNBLOCKED_WORD = [0, 0, 0, 0] + +# ERRORS +# ================================================================================================ + +const ERR_ACCOUNT_IS_BLOCKED = "account is blocked" + +# PUBLIC HELPERS +# ================================================================================================ + +#! Returns whether the given account is currently blocked on this faucet. +#! +#! Reads [`BLOCKED_ACCOUNTS_SLOT`] on the active account at the key derived from the account ID. +#! Returns `1` if the stored word is non-zero (blocked) or `0` if it is the zero word +#! (not blocked, including the default for never-set entries). +#! +#! Inputs: [account_id_suffix, account_id_prefix] +#! Outputs: [is_blocked] +#! +#! Invocation: exec +pub proc is_blocked + exec.build_blocked_accounts_map_key + # => [KEY] + + push.BLOCKED_ACCOUNTS_SLOT[0..2] + # => [slot_suffix, slot_prefix, KEY] + + exec.active_account::get_map_item + # => [VALUE] + + exec.word::eqz not + # => [is_blocked] +end + +#! Adds the given account to the blocklist. If the account is already blocked, this is a noop. +#! +#! This procedure performs NO authorization. Wrappers running in `Invocation: call` mode add +#! the auth check (e.g. `ownable2step::assert_sender_is_owner`) before delegating here via +#! `exec.blocklist::block_account`. +#! +#! Inputs: [account_id_suffix, account_id_prefix] +#! Outputs: [] +#! +#! Invocation: exec +pub proc block_account + exec.build_blocked_accounts_map_key + # => [KEY] + + push.BLOCKED_WORD swapw + # => [KEY, BLOCKED_WORD] + + push.BLOCKED_ACCOUNTS_SLOT[0..2] + # => [slot_suffix, slot_prefix, KEY, BLOCKED_WORD] + + exec.native_account::set_map_item + # => [OLD_VALUE] + + dropw + # => [] +end + +#! Removes the given account from the blocklist. If the account is not currently blocked, this +#! is a noop. See [`block_account`] for the same authorization caveats. +#! +#! Inputs: [account_id_suffix, account_id_prefix] +#! Outputs: [] +#! +#! Invocation: exec +pub proc unblock_account + exec.build_blocked_accounts_map_key + # => [KEY] + + push.UNBLOCKED_WORD swapw + # => [KEY, UNBLOCKED_WORD] + + push.BLOCKED_ACCOUNTS_SLOT[0..2] + # => [slot_suffix, slot_prefix, KEY, UNBLOCKED_WORD] + + exec.native_account::set_map_item + # => [OLD_VALUE] + + dropw + # => [] +end + +#! Requires the given account to not be blocked. +#! +#! Delegates to [`is_blocked`] and inverts the result. If the account is blocked, panics +#! with [`ERR_ACCOUNT_IS_BLOCKED`]. +#! +#! Use from other modules or transaction scripts to guard logic that must not run for blocked +#! accounts. In asset callback foreign context, the active account is the issuing faucet. +#! +#! Inputs: [account_id_suffix, account_id_prefix] +#! Outputs: [] +#! +#! Panics if: +#! - the stored entry for this account is not the zero word. +#! +#! Invocation: exec +pub proc assert_not_blocked + exec.is_blocked + # => [is_blocked] + + not + # => [is_not_blocked] + + assert.err=ERR_ACCOUNT_IS_BLOCKED + # => [] +end + +# HELPER PROCEDURES +# ================================================================================================ + +#! Builds the blocked-accounts map key for the given account ID. +#! +#! Inputs: [account_id_suffix, account_id_prefix] +#! Outputs: [KEY] +#! +#! Where: +#! - KEY is [0, 0, account_id_suffix, account_id_prefix]. +#! +#! Invocation: exec +proc build_blocked_accounts_map_key + push.0.0 + # => [0, 0, account_id_suffix, account_id_prefix] + # => [KEY] +end 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 new file mode 100644 index 0000000000..dc56911f5a --- /dev/null +++ b/crates/miden-standards/asm/standards/faucets/policies/transfer/blocklist/owner_controlled.masm @@ -0,0 +1,50 @@ +# miden::standards::faucets::policies::transfer::blocklist::owner_controlled +# +# Owner-controlled blocklist admin: wraps the `Invocation: exec` helpers from +# `miden::standards::faucets::policies::transfer::blocklist` with an Ownable2Step authorization +# check. +# +# Companion components required on the faucet: +# - `Ownable2Step` — provides the owner storage slot the auth check reads. +# - `BasicBlocklist` (or any other component that installs the `blocked_accounts` storage slot) +# — provides the storage that `block_account` / `unblock_account` write to. + +use miden::standards::access::ownable2step +use miden::standards::faucets::policies::transfer::blocklist + +# PUBLIC INTERFACE +# ================================================================================================ + +#! Block an account, gated by the Ownable2Step owner. +#! +#! Inputs: [account_id_suffix, account_id_prefix, pad(14)] +#! Outputs: [pad(16)] +#! +#! Panics if: +#! - the sender is not the account owner. +#! +#! Invocation: call +pub proc block_account + exec.ownable2step::assert_sender_is_owner + # => [account_id_suffix, account_id_prefix, pad(14)] + + exec.blocklist::block_account + # => [pad(14)] +end + +#! Unblock an account, gated by the Ownable2Step owner. +#! +#! Inputs: [account_id_suffix, account_id_prefix, pad(14)] +#! Outputs: [pad(16)] +#! +#! Panics if: +#! - the sender is not the account owner. +#! +#! Invocation: call +pub proc unblock_account + exec.ownable2step::assert_sender_is_owner + # => [account_id_suffix, account_id_prefix, pad(14)] + + exec.blocklist::unblock_account + # => [pad(14)] +end diff --git a/crates/miden-standards/asm/standards/mint_policies/auth_controlled.masm b/crates/miden-standards/asm/standards/mint_policies/auth_controlled.masm deleted file mode 100644 index e75250cb72..0000000000 --- a/crates/miden-standards/asm/standards/mint_policies/auth_controlled.masm +++ /dev/null @@ -1,12 +0,0 @@ -# POLICY PROCEDURES -# ================================================================================================ - -#! Dummy mint predicate to allow all mints. -#! -#! Inputs: [amount, tag, note_type, RECIPIENT, pad(9)] -#! Outputs: [amount, tag, note_type, RECIPIENT, pad(9)] -#! Invocation: dynexec -pub proc allow_all - # Dummy predicate, no checks yet. - push.0 drop -end diff --git a/crates/miden-standards/asm/standards/mint_policies/policy_manager.masm b/crates/miden-standards/asm/standards/mint_policies/policy_manager.masm deleted file mode 100644 index 2d8842e80b..0000000000 --- a/crates/miden-standards/asm/standards/mint_policies/policy_manager.masm +++ /dev/null @@ -1,207 +0,0 @@ -use miden::core::word -use miden::protocol::active_account -use miden::protocol::native_account -use miden::standards::access::ownable2step - -# DEPENDENCY NOTE -# This manager supports two policy-authority modes: -# - 0: auth_controlled: no Ownable2Step dependency. -# - 1: owner_controlled: requires Ownable2Step component -# (`ownable2step::assert_sender_is_owner`) for `set_mint_policy`. - -# CONSTANTS -# ================================================================================================ - -# Active policy root slot. -# Layout: [PROC_ROOT] -const ACTIVE_POLICY_PROC_ROOT_SLOT=word("miden::standards::mint_policy_manager::active_policy_proc_root") - -# Allowlist map slot for policy roots. -# Map entries: [PROC_ROOT] -> [1, 0, 0, 0] -# This slot ensures the policy manager runs only allowed mint-policy roots, not arbitrary procedures. -# A root that is not in this allowlist is always rejected, even if it exists in account code. -# This prevents arbitrary procedure root execution through policy selection. -# Component constructors initialize this map with known allowed mint-policy roots by default. -# `set_mint_policy` and `execute_mint_policy` checks this. -const ALLOWED_POLICY_PROC_ROOTS_SLOT=word("miden::standards::mint_policy_manager::allowed_policy_proc_roots") - -# Policy authority slot. -# Layout: [policy_authority, 0, 0, 0] -# - POLICY_AUTHORITY = 0: policy authority rely on `auth_controlled`. -# - POLICY_AUTHORITY = 1: `set_mint_policy` requires Ownable2Step owner check. -const POLICY_AUTHORITY_SLOT=word("miden::standards::mint_policy_manager::policy_authority") -const POLICY_AUTHORITY_OWNER_CONTROLLED=1 - -# Local memory pointer used to pass a policy root to `dynexec`. -const MINT_POLICY_PROC_ROOT_PTR=0 - -# ERRORS -# ================================================================================================ - -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" - -# INTERNAL PROCEDURES -# ================================================================================================ - -#! Reads active mint policy root from storage. -#! -#! Inputs: [] -#! Outputs: [MINT_POLICY_ROOT] -#! -#! Invocation: exec -proc get_mint_policy_root - push.ACTIVE_POLICY_PROC_ROOT_SLOT[0..2] exec.active_account::get_item - # => [MINT_POLICY_ROOT] -end - -#! Validates policy root before use. -#! -#! Inputs: [MINT_POLICY_ROOT] -#! Outputs: [MINT_POLICY_ROOT] -#! -#! Panics if: -#! - policy root is zero. -#! - policy root is not present in this account's procedures. -#! -#! Invocation: exec -proc assert_existing_policy_root - exec.word::testz - assertz.err=ERR_MINT_POLICY_ROOT_IS_ZERO - # => [MINT_POLICY_ROOT] - - dupw exec.active_account::has_procedure - assert.err=ERR_MINT_POLICY_ROOT_NOT_IN_ACCOUNT - # => [MINT_POLICY_ROOT] -end - -#! Validates that the policy root is one of the allowed policy roots configured for this account. -#! -#! Inputs: [MINT_POLICY_ROOT] -#! Outputs: [MINT_POLICY_ROOT] -#! -#! Panics if: -#! - policy root is not in the allowed policy roots map. -#! -#! Invocation: exec -proc assert_allowed_policy_root - dupw - push.ALLOWED_POLICY_PROC_ROOTS_SLOT[0..2] - exec.active_account::get_map_item - # => [ALLOWED_FLAG, MINT_POLICY_ROOT] - - exec.word::eqz - assertz.err=ERR_MINT_POLICY_ROOT_NOT_ALLOWED - # => [MINT_POLICY_ROOT] -end - -#! Reads policy authority mode. -#! - 0 = `auth_controlled` -#! - 1 = `owner_controlled` -#! -#! Inputs: [] -#! Outputs: [policy_authority] -#! -#! Invocation: exec -proc get_policy_authority - push.POLICY_AUTHORITY_SLOT[0..2] exec.active_account::get_item - # => [policy_authority, 0, 0, 0] - - movdn.3 - # => [0, 0, 0, policy_authority] - - drop drop drop - # => [policy_authority] -end - -#! Authorizes policy update based on policy authority mode. -#! -#! Inputs: [NEW_POLICY_ROOT, pad(12)] -#! Outputs: [NEW_POLICY_ROOT, pad(12)] -#! -#! Panics if: -#! - POLICY_AUTHORITY = 1 and the sender is not owner. -#! -#! Invocation: exec -proc assert_can_set_mint_policy - exec.get_policy_authority - # => [policy_authority, NEW_POLICY_ROOT, pad(12)] - - eq.POLICY_AUTHORITY_OWNER_CONTROLLED - if.true - exec.ownable2step::assert_sender_is_owner - # => [NEW_POLICY_ROOT, pad(12)] - end -end - -# PUBLIC INTERFACE -# ================================================================================================ - -#! Executes active mint policy by dynamic execution. -#! -#! Inputs: [amount, tag, note_type, RECIPIENT, pad(9)] -#! Outputs: [amount, tag, note_type, RECIPIENT, pad(9)] -#! -#! Panics if: -#! - mint policy root is invalid. -#! - active policy predicate fails. -#! -#! Invocation: exec -@locals(4) -pub proc execute_mint_policy - exec.get_mint_policy_root - # => [MINT_POLICY_ROOT, amount, tag, note_type, RECIPIENT, pad(9)] - - exec.assert_existing_policy_root - # => [MINT_POLICY_ROOT, amount, tag, note_type, RECIPIENT, pad(9)] - - exec.assert_allowed_policy_root - # => [MINT_POLICY_ROOT, amount, tag, note_type, RECIPIENT, pad(9)] - - loc_storew_le.MINT_POLICY_PROC_ROOT_PTR dropw - # => [amount, tag, note_type, RECIPIENT, pad(9)] - - locaddr.MINT_POLICY_PROC_ROOT_PTR - # => [policy_root_ptr, amount, tag, note_type, RECIPIENT, pad(9)] - - dynexec - # => [amount, tag, note_type, RECIPIENT, pad(9)] -end - -#! Returns active mint policy root. -#! -#! Inputs: [pad(16)] -#! Outputs: [MINT_POLICY_ROOT, pad(12)] -#! -#! Invocation: call -pub proc get_mint_policy - exec.get_mint_policy_root - # => [MINT_POLICY_ROOT, pad(12)] -end - -#! Sets active mint policy root. -#! -#! Inputs: [NEW_POLICY_ROOT, pad(12)] -#! Outputs: [pad(16)] -#! -#! Panics if: -#! - POLICY_AUTHORITY = 1 and the sender is not owner. -#! - NEW_POLICY_ROOT is zero. -#! - NEW_POLICY_ROOT is not a procedure of this account. -#! - NEW_POLICY_ROOT is not in the allowed roots map. -#! -#! Invocation: call -pub proc set_mint_policy - exec.assert_can_set_mint_policy - # => [NEW_POLICY_ROOT, pad(12)] - - exec.assert_existing_policy_root - # => [NEW_POLICY_ROOT, pad(12)] - - exec.assert_allowed_policy_root - # => [NEW_POLICY_ROOT, pad(12)] - - push.ACTIVE_POLICY_PROC_ROOT_SLOT[0..2] exec.native_account::set_item dropw - # => [pad(16)] -end diff --git a/crates/miden-standards/asm/standards/notes/burn.masm b/crates/miden-standards/asm/standards/notes/burn.masm index 71f485784c..4f1dc38400 100644 --- a/crates/miden-standards/asm/standards/notes/burn.masm +++ b/crates/miden-standards/asm/standards/notes/burn.masm @@ -1,29 +1,30 @@ -use miden::standards::faucets +use miden::standards::faucets::fungible->faucet -#! BURN script: burns the asset from the note by calling the faucet's burn procedure. -#! This note can be executed against any faucet account that exposes the faucets::burn procedure -#! (e.g., basic fungible faucet or network fungible faucet). +#! BURN script: burns the asset from the note by calling the faucet's `receive_and_burn` +#! procedure. This note is intended to be executed against a fungible faucet account. #! -#! The burn procedure in the faucet already handles all necessary validations including: +#! The receive_and_burn procedure in the faucet already handles all necessary validations +#! including: #! - Checking that the note contains exactly one asset #! - Verifying the asset is a fungible asset issued by this faucet #! - Ensuring the amount to burn doesn't exceed the outstanding supply #! #! Requires that the account exposes: -#! - burn procedure (from the faucets interface). +#! - `miden::standards::faucets::fungible::receive_and_burn` procedure. #! #! Inputs: [ARGS, pad(12)] #! Outputs: [pad(16)] #! #! Panics if: -#! - account does not expose burn procedure. -#! - any of the validations in the burn procedure fail. +#! - account does not expose receive_and_burn procedure. +#! - any of the validations in the receive_and_burn procedure fail. @note_script pub proc main dropw # => [pad(16)] - # Call the faucet's burn procedure which handles all validations - call.faucets::burn + # Call the fungible faucet receive_and_burn wrapper which enforces burn policy and then burns + # the asset. + call.faucet::receive_and_burn # => [pad(16)] end diff --git a/crates/miden-standards/asm/standards/notes/mint.masm b/crates/miden-standards/asm/standards/notes/mint.masm index ca287daf48..0745fcc794 100644 --- a/crates/miden-standards/asm/standards/notes/mint.masm +++ b/crates/miden-standards/asm/standards/notes/mint.masm @@ -1,141 +1,151 @@ use miden::protocol::active_note use miden::protocol::note -use miden::protocol::output_note -use miden::standards::faucets::network_fungible->network_faucet +use miden::standards::faucets::fungible->faucet # CONSTANTS # ================================================================================================= -const MINT_NOTE_NUM_STORAGE_ITEMS_PRIVATE=12 -const MINT_NOTE_MIN_NUM_STORAGE_ITEMS_PUBLIC=16 +const MINT_NOTE_NUM_STORAGE_ITEMS_PRIVATE=13 +const MINT_NOTE_MIN_NUM_STORAGE_ITEMS_PUBLIC=20 -const OUTPUT_NOTE_TYPE_PUBLIC=1 -const OUTPUT_NOTE_TYPE_PRIVATE=2 +use miden::protocol::note::NOTE_TYPE_PUBLIC +use miden::protocol::note::NOTE_TYPE_PRIVATE -# Memory Addresses of MINT note storage -# The attachment is at the same memory address for both private and public storage. -const ATTACHMENT_KIND_ADDRESS=2 -const ATTACHMENT_SCHEME_ADDRESS=3 -const ATTACHMENT_ADDRESS=4 -const OUTPUT_PUBLIC_NOTE_STORAGE_ADDR=16 +# Memory addresses of MINT note storage (private mode, 13 items total) +const STORAGE_PTR = 0 +const PRIVATE_RECIPIENT_PTR = STORAGE_PTR +const PRIVATE_ASSET_KEY_PTR = STORAGE_PTR + 4 +const PRIVATE_ASSET_VALUE_PTR = STORAGE_PTR + 8 +const PRIVATE_TAG_PTR = STORAGE_PTR + 12 + +# Memory addresses of MINT note storage (public mode, 20+ items total) +const PUBLIC_SCRIPT_ROOT_PTR = STORAGE_PTR +const PUBLIC_SERIAL_NUM_PTR = STORAGE_PTR + 4 +const PUBLIC_ASSET_KEY_PTR = STORAGE_PTR + 8 +const PUBLIC_ASSET_VALUE_PTR = STORAGE_PTR + 12 +const PUBLIC_TAG_PTR = STORAGE_PTR + 16 +const PUBLIC_OUTPUT_NOTE_STORAGE_PTR = STORAGE_PTR + 20 # ERRORS # ================================================================================================= -const ERR_MINT_UNEXPECTED_NUMBER_OF_STORAGE_ITEMS="MINT script expects exactly 12 storage items for private or 16+ storage items for public output notes" +const ERR_MINT_UNEXPECTED_NUMBER_OF_STORAGE_ITEMS="MINT script expects exactly 13 storage items for private or 20+ storage items for public output notes" -#! Network Faucet MINT script: mints assets by calling the network faucet's mint_and_send -#! function. +#! Network Faucet MINT script: mints the asset stored in the note by calling the network +#! faucet's mint_and_send function. +#! #! This note is intended to be executed against a network fungible faucet account. #! #! Requires that the account exposes: -#! - miden::standards::faucets::network_fungible::mint_and_send procedure. +#! - miden::standards::faucets::fungible::mint_and_send procedure. #! #! Inputs: [ARGS, pad(12)] #! Outputs: [pad(16)] #! #! Note storage supports two modes. Depending on the number of note storage items, -#! a private or public note is created on consumption of the MINT note: +#! a private or public output note is created on consumption of the MINT note: #! -#! Private mode (12 storage items) - creates a private note: -#! - tag: Note tag for the output note -#! - amount: The amount to mint -#! - attachment_scheme: The user-defined type of the attachment. -#! - attachment_kind: The attachment kind of the attachment. -#! - ATTACHMENT: The attachment to be set. -#! - RECIPIENT: The recipient digest (4 elements) +#! Private mode (13 storage items) - creates a private note: +#! - RECIPIENT: The recipient digest (4 elements, word 0) +#! - ASSET_KEY: The vault key of the asset to mint (4 elements, word 1) +#! - ASSET_VALUE: The value word of the asset to mint (4 elements, word 2) +#! - tag: Note tag for the output note (1 element) #! -#! Public mode (16+ storage items) - creates a public note with variable-length storage: -#! - tag: Note tag for the output note -#! - amount: The amount to mint -#! - attachment_scheme: The user-defined type of the attachment. -#! - attachment_kind: The attachment kind of the attachment. -#! - ATTACHMENT: The attachment to be set. -#! - SCRIPT_ROOT: Script root of the output note (4 elements) -#! - SERIAL_NUM: Serial number of the output note (4 elements) -#! - [STORAGE]: Variable-length storage for the output note (Vec) -#! The number of output note storage items = num_mint_note_storage_items - 16 +#! Public mode (20+ storage items) - creates a public note with variable-length storage: +#! - SCRIPT_ROOT: Script root of the output note (4 elements, word 0) +#! - SERIAL_NUM: Serial number of the output note (4 elements, word 1) +#! - ASSET_KEY: The vault key of the asset to mint (4 elements, word 2) +#! - ASSET_VALUE: The value word of the asset to mint (4 elements, word 3) +#! - tag: Note tag for the output note (1 element) +#! - padding: 3 elements so the variable storage starts at a word boundary +#! - [STORAGE]: Variable-length storage for the output note (Vec) +#! The number of output note storage items = num_mint_note_storage_items - 20 #! #! Panics if: #! - account does not expose mint_and_send procedure. -#! - the number of storage items is not exactly 12 for private or less than 16 for public output notes. +#! - the number of storage items is not exactly 13 for private or less than 20 for public output +#! notes. +#! - the asset stored in the note does not belong to the consuming faucet. @note_script pub proc main dropw # => [pad(16)] - # Load note storage into memory starting at address 0 - push.0 exec.active_note::get_storage - # => [num_storage_items, storage_ptr, pad(16)] + # Load note storage into memory starting at STORAGE_PTR + push.STORAGE_PTR exec.active_note::get_storage + # => [num_storage_items, pad(16)] dup - # => [num_storage_items, num_storage_items, storage_ptr, pad(16)] + # => [num_storage_items, num_storage_items, pad(16)] u32assert2.err=ERR_MINT_UNEXPECTED_NUMBER_OF_STORAGE_ITEMS u32gte.MINT_NOTE_MIN_NUM_STORAGE_ITEMS_PUBLIC - # => [is_public_output_note, num_storage_items, storage_ptr, pad(16)] + # => [is_public_output_note, num_storage_items, pad(16)] if.true # public output note creation - # => [num_storage_items, storage_ptr, pad(16)] + # => [num_storage_items, pad(16)] - movdn.9 drop + movdn.8 # => [EMPTY_WORD, EMPTY_WORD, num_storage_items, pad(8)] - mem_loadw_le.8 + mem_loadw_le.PUBLIC_SCRIPT_ROOT_PTR # => [SCRIPT_ROOT, EMPTY_WORD, num_storage_items, pad(8)] - swapw mem_loadw_le.12 + swapw mem_loadw_le.PUBLIC_SERIAL_NUM_PTR # => [SERIAL_NUM, SCRIPT_ROOT, num_storage_items, pad(8)] # compute variable length note storage for the output note movup.8 sub.MINT_NOTE_MIN_NUM_STORAGE_ITEMS_PUBLIC # => [num_output_note_storage, SERIAL_NUM, SCRIPT_ROOT, pad(8)] - push.OUTPUT_PUBLIC_NOTE_STORAGE_ADDR + push.PUBLIC_OUTPUT_NOTE_STORAGE_PTR # => [storage_ptr, num_output_note_storage, SERIAL_NUM, SCRIPT_ROOT, pad(8)] - exec.note::build_recipient + exec.note::compute_and_store_recipient # => [RECIPIENT, pad(12)] - # push note_type, and load tag and amount - push.OUTPUT_NOTE_TYPE_PUBLIC - mem_load.0 mem_load.1 - # => [amount, tag, note_type, RECIPIENT, pad(12)] + # push note_type and tag, then ASSET_VALUE and ASSET_KEY on top + push.NOTE_TYPE_PUBLIC + # => [note_type, RECIPIENT, pad(12)] + + mem_load.PUBLIC_TAG_PTR + # => [tag, note_type, RECIPIENT, pad(12)] + + padw mem_loadw_le.PUBLIC_ASSET_VALUE_PTR + # => [ASSET_VALUE, tag, note_type, RECIPIENT, pad(12)] + + padw mem_loadw_le.PUBLIC_ASSET_KEY_PTR + # => [ASSET_KEY, ASSET_VALUE, tag, note_type, RECIPIENT, pad(12)] else # private output note creation - eq.MINT_NOTE_NUM_STORAGE_ITEMS_PRIVATE assert.err=ERR_MINT_UNEXPECTED_NUMBER_OF_STORAGE_ITEMS drop - # => [storage_ptr, pad(16)] - - drop + eq.MINT_NOTE_NUM_STORAGE_ITEMS_PRIVATE assert.err=ERR_MINT_UNEXPECTED_NUMBER_OF_STORAGE_ITEMS # => [pad(16)] - mem_loadw_le.8 + mem_loadw_le.PRIVATE_RECIPIENT_PTR # => [RECIPIENT, pad(12)] - # push note_type, and load tag and amount - push.OUTPUT_NOTE_TYPE_PRIVATE - mem_load.0 mem_load.1 - # => [amount, tag, note_type, RECIPIENT, pad(12)] - end - # => [amount, tag, note_type, RECIPIENT, pad(12)] + # push note_type and tag, then ASSET_VALUE and ASSET_KEY on top + push.NOTE_TYPE_PRIVATE + # => [note_type, RECIPIENT, pad(12)] - # mint_and_send expects 9 pad elements, returns 15 and 12 are provided here. - # so the total number of pads after calling is 12 + (15-9) = 18 - call.network_faucet::mint_and_send - # => [note_idx, pad(18))] + mem_load.PRIVATE_TAG_PTR + # => [tag, note_type, RECIPIENT, pad(12)] - padw mem_loadw_le.ATTACHMENT_ADDRESS - # => [ATTACHMENT, note_idx, pad(18))] + padw mem_loadw_le.PRIVATE_ASSET_VALUE_PTR + # => [ASSET_VALUE, tag, note_type, RECIPIENT, pad(12)] - mem_load.ATTACHMENT_KIND_ADDRESS - mem_load.ATTACHMENT_SCHEME_ADDRESS - movup.6 - # => [note_idx, attachment_scheme, attachment_kind, ATTACHMENT, pad(18))] + padw mem_loadw_le.PRIVATE_ASSET_KEY_PTR + # => [ASSET_KEY, ASSET_VALUE, tag, note_type, RECIPIENT, pad(12)] + end + # => [ASSET_KEY, ASSET_VALUE, tag, note_type, RECIPIENT, pad(12)] - exec.output_note::set_attachment - # => [pad(18))] + # mint_and_send consumes [ASSET_KEY, ASSET_VALUE, tag, note_type, RECIPIENT, pad(2)] from the + # operand stack and returns [note_idx, pad(15)]. Conceptually we have 26 elements going into + # the call and 26 coming back out; drop 10 to return to pad(16) as required by @note_script. + call.faucet::mint_and_send + # => [note_idx, pad(25)] - drop drop + dropw dropw drop drop # => [pad(16)] end diff --git a/crates/miden-standards/asm/standards/notes/p2id.masm b/crates/miden-standards/asm/standards/notes/p2id.masm index 99abab6204..9be2fa9d81 100644 --- a/crates/miden-standards/asm/standards/notes/p2id.masm +++ b/crates/miden-standards/asm/standards/notes/p2id.masm @@ -44,14 +44,13 @@ const TARGET_ACCOUNT_ID_PREFIX_PTR = STORAGE_PTR + 1 pub proc main # store the note storage to memory starting at address 0 push.STORAGE_PTR exec.active_note::get_storage - # => [num_storage_items, storage_ptr] + # => [num_storage_items] # make sure the number of storage items is 2 eq.2 assert.err=ERR_P2ID_UNEXPECTED_NUMBER_OF_STORAGE_ITEMS - # => [storage_ptr] + # => [] # read the target account ID from the note storage - drop mem_load.TARGET_ACCOUNT_ID_PREFIX_PTR mem_load.TARGET_ACCOUNT_ID_SUFFIX_PTR # => [target_account_id_suffix, target_account_id_prefix] @@ -106,7 +105,7 @@ pub proc new push.2 locaddr.STORAGE_PTR # => [storage_ptr, num_storage_items=2, SERIAL_NUM, SCRIPT_ROOT, tag, note_type] - exec.note::build_recipient + exec.note::compute_and_store_recipient # => [RECIPIENT, tag, note_type] movup.5 movup.5 diff --git a/crates/miden-standards/asm/standards/notes/p2ide.masm b/crates/miden-standards/asm/standards/notes/p2ide.masm index f476232e06..f9404571ce 100644 --- a/crates/miden-standards/asm/standards/notes/p2ide.masm +++ b/crates/miden-standards/asm/standards/notes/p2ide.masm @@ -104,14 +104,14 @@ end pub proc main # store the note storage to memory starting at address 0 push.0 exec.active_note::get_storage - # => [num_storage_items, storage_ptr] + # => [num_storage_items] # make sure the number of storage items is 4 eq.4 assert.err=ERR_P2IDE_UNEXPECTED_NUMBER_OF_STORAGE_ITEMS - # => [storage_ptr] + # => [] # read the target account ID, reclaim block height, and timelock_block_height from the note storage - mem_loadw_le + push.0 mem_loadw_le # => [target_account_id_suffix, target_account_id_prefix, reclaim_block_height, timelock_block_height] movup.3 diff --git a/crates/miden-standards/asm/standards/notes/pswap.masm b/crates/miden-standards/asm/standards/notes/pswap.masm new file mode 100644 index 0000000000..00837f651c --- /dev/null +++ b/crates/miden-standards/asm/standards/notes/pswap.masm @@ -0,0 +1,807 @@ +use miden::core::math::u64 +use miden::core::math::u128 +use miden::protocol::account_id +use miden::protocol::active_account +use miden::protocol::active_note +use miden::protocol::asset +use miden::protocol::note +use miden::protocol::output_note +use miden::protocol::asset::FUNGIBLE_ASSET_MAX_AMOUNT +use miden::standards::note_tag +use miden::standards::notes::p2id +use miden::standards::wallets::basic->wallet + +# CONSTANTS +# ================================================================================================= + +const NUM_STORAGE_ITEMS=7 +const MAX_U32=0x0000000100000000 + +# Attachment scheme used by both PSWAP output notes (payback P2ID + remainder PSWAP). +# Carries the word [amount, order_id, depth, 0]. Mirrors +# StandardNoteAttachment::PswapAttachment in the Rust standards crate. +const PSWAP_ATTACHMENT_SCHEME = 3 + +# Note storage layout (7 felts, loaded at STORAGE_PTR by get_storage): +# - requested_enable_callbacks [0] : 1 felt +# - requested_faucet_suffix [1] : 1 felt +# - requested_faucet_prefix [2] : 1 felt +# - requested_amount [3] : 1 felt +# - payback_note_type [4] : 1 felt +# - creator_id_prefix [5] : 1 felt +# - creator_id_suffix [6] : 1 felt +# +# Where: +# - requested_enable_callbacks: Callback-enabled flag for the requested fungible asset +# (FungibleAsset::callbacks() as u8) +# - requested_faucet_suffix: Suffix of the requested asset's faucet AccountId (Felt) +# - requested_faucet_prefix: Prefix of the requested asset's faucet AccountId +# (AccountIdPrefix as Felt) +# - requested_amount: Amount of the requested fungible asset (Felt) +# - payback_note_type: The NoteType used for the P2ID payback note +# - creator_id_prefix: The prefix of the creator's AccountId (AccountIdPrefix as Felt) +# - creator_id_suffix: The suffix of the creator's AccountId (Felt) +# +# The payback note tag is derived at runtime from the creator's account ID +# (via note_tag::create_account_target) rather than stored. +# +# The remainder PSWAP note's own tag is not stored — it is lifted from the +# active note's metadata at remainder-creation time (same asset pair => same +# tag), saving one storage slot. +const STORAGE_PTR = 0 +const REQUESTED_ENABLE_CALLBACKS_ITEM = STORAGE_PTR +const REQUESTED_FAUCET_SUFFIX_ITEM = STORAGE_PTR + 1 +const REQUESTED_FAUCET_PREFIX_ITEM = STORAGE_PTR + 2 +const REQUESTED_AMOUNT_ITEM = STORAGE_PTR + 3 +const PAYBACK_NOTE_TYPE_ITEM = STORAGE_PTR + 4 +const PSWAP_CREATOR_PREFIX_ITEM = STORAGE_PTR + 5 +const PSWAP_CREATOR_SUFFIX_ITEM = STORAGE_PTR + 6 + +# Local memory offsets +# ================================================================================================= + +# calculate_output_amount locals +const CALC_FILL_AMOUNT = 0 +const CALC_REQUESTED = 1 + +# create_p2id_note locals +const P2ID_NOTE_IDX = 0 +const P2ID_REQUESTED_ENABLE_CB = 1 +const P2ID_REQUESTED_FAUCET_SUFFIX = 2 +const P2ID_REQUESTED_FAUCET_PREFIX = 3 +const P2ID_AMT_ACCOUNT_FILL = 4 +const P2ID_AMT_NOTE_FILL = 5 + +# create_remainder_note locals +const REMAINDER_NOTE_IDX = 0 +const REMAINDER_OFFERED_FAUCET_PREFIX = 1 +const REMAINDER_OFFERED_FAUCET_SUFFIX = 2 +const REMAINDER_OFFERED_ENABLE_CB = 3 +const REMAINDER_AMT_PAYOUT = 4 +const REMAINDER_AMT_OFFERED = 5 + +# execute_pswap locals +const EXEC_AMT_OFFERED = 0 +const EXEC_AMT_REQUESTED = 1 +const EXEC_AMT_PAYOUT_TOTAL = 2 +const EXEC_AMT_PAYOUT_ACCOUNT_FILL = 3 +const EXEC_AMT_PAYOUT_NOTE_FILL = 4 +const EXEC_OFFERED_ENABLE_CB = 5 +const EXEC_OFFERED_FAUCET_SUFFIX = 6 +const EXEC_OFFERED_FAUCET_PREFIX = 7 +const EXEC_AMT_REQUESTED_ACCOUNT_FILL = 8 +const EXEC_AMT_REQUESTED_NOTE_FILL = 9 + +# Local memory for the parent's PswapAttachment word [amount, order_id, depth, 0]: +# write the word at PARENT_ATTACHMENT_PTR, read back only the depth at DEPTH_OFFSET. +const PARENT_ATTACHMENT_PTR = 0 +const PARENT_ATTACHMENT_DEPTH_OFFSET = 2 + +# ERRORS +# ================================================================================================= + +const ERR_PSWAP_WRONG_NUMBER_OF_STORAGE_ITEMS="PSWAP script expects exactly 7 note storage items" +const ERR_PSWAP_WRONG_NUMBER_OF_ASSETS="PSWAP script requires exactly one note asset" +const ERR_PSWAP_FILL_EXCEEDS_REQUESTED="PSWAP fill amount exceeds requested amount" +const ERR_PSWAP_FILL_SUM_OVERFLOW="PSWAP account_fill + note_fill overflows u64" +const ERR_PSWAP_NOT_VALID_ASSET_AMOUNT="PSWAP computed amount exceeds max fungible asset amount" +const ERR_PSWAP_PAYOUT_OVERFLOW="PSWAP payout quotient does not fit in u64" + +# U64 VALIDATION +# ================================================================================================= + +#! Asserts that the u64 value represented by [lo, hi] on the stack is a valid fungible asset +#! amount, i.e. <= FUNGIBLE_ASSET_MAX_AMOUNT (2^63 - 2^31). +#! +#! Inputs: [lo, hi] +#! Outputs: [lo, hi] +#! +#! Panics if the value exceeds FUNGIBLE_ASSET_MAX_AMOUNT. +proc assert_valid_asset_amount + dup.1 dup.1 + # => [lo, hi, lo, hi] + + push.FUNGIBLE_ASSET_MAX_AMOUNT u32split + # => [max_lo, max_hi, lo, hi, lo, hi] + + exec.u64::lte + # => [is_lte, lo, hi] + + assert.err=ERR_PSWAP_NOT_VALID_ASSET_AMOUNT + # => [lo, hi] +end + +# PRICE CALCULATION +# ================================================================================================= + +#! Computes the proportional amount of offered tokens for a given fill amount. +#! +#! Formula: `payout = floor((offered * fill_amount) / requested)`. +#! +#! The intermediate product `offered * fill_amount` is computed as a full +#! u128 via `u64::widening_mul`, then divided by `requested` (extended to +#! u128) via `u128::div`. This gives exact integer precision with one floor +#! division at the end, each input is safe +#! up to `FungibleAsset::MAX ≈ 2^63`, and the resulting quotient +#! `payout ≤ offered` always fits back in u64 (asserted via the upper-half +#! limb check below). +#! +#! Inputs: [offered, requested, fill_amount] (offered on top) +#! Outputs: [payout_amount] +#! +@locals(2) +# CALC_FILL_AMOUNT: fill amount +# CALC_REQUESTED: requested amount +proc calculate_output_amount + movup.2 loc_store.CALC_FILL_AMOUNT + # => [offered, requested] + + swap loc_store.CALC_REQUESTED + # => [offered] + + u32split + # => [off_lo, off_hi] + + loc_load.CALC_FILL_AMOUNT u32split + # => [fill_lo, fill_hi, off_lo, off_hi] + + exec.u64::widening_mul + # => [p0, p1, p2, p3] + + loc_load.CALC_REQUESTED u32split + # => [req_lo, req_hi, p0, p1, p2, p3] + + push.0 movdn.2 push.0 movdn.3 + # => [req_lo, req_hi, 0, 0, p0, p1, p2, p3] + + exec.u128::div + # => [q0, q1, q2, q3] (quotient, little-endian limbs) + + # assert whether the output fits in u64 + movup.3 eq.0 assert.err=ERR_PSWAP_PAYOUT_OVERFLOW + # => [q0, q1, q2] + + movup.2 eq.0 assert.err=ERR_PSWAP_PAYOUT_OVERFLOW + # => [q0, q1] + + exec.assert_valid_asset_amount + # => [q0, q1] + + # Reconstruct the u64 quotient as a single felt: `q0 + q1 * 2^32`. + swap mul.MAX_U32 add + # => [payout_amount] +end + +# P2ID NOTE CREATION PROCEDURE +# ================================================================================================= + +#! Creates a P2ID output note for the swap creator. +#! +#! Derives a unique serial number by incrementing the least significant element +#! of the provided serial, creates the output note using p2id::new, sets the +#! attachment, and adds the requested assets (from the consumer's account vault +#! and/or from another note in the same transaction). +#! +#! Inputs: [creator_suffix, creator_prefix, note_type, SERIAL_NUM, +#! enable_callbacks, faucet_suffix, faucet_prefix, amt_account_fill, amt_note_fill] +#! Outputs: [] +#! +@locals(6) +# P2ID_NOTE_IDX : note_idx +# P2ID_REQUESTED_ENABLE_CB : enable_callbacks +# P2ID_REQUESTED_FAUCET_SUFFIX : faucet_suffix +# P2ID_REQUESTED_FAUCET_PREFIX : faucet_prefix +# P2ID_AMT_ACCOUNT_FILL : amt_account_fill +# P2ID_AMT_NOTE_FILL : amt_note_fill +proc create_p2id_note + # Derive P2ID serial: increment least significant element + movup.3 add.1 movdn.3 + # => [creator_suffix, creator_prefix, note_type, P2ID_SERIAL_NUM, + # enable_callbacks, faucet_suffix, faucet_prefix, amt_account_fill, amt_note_fill] + + # Derive the payback tag from the creator's account ID prefix + dup.1 + # => [creator_prefix, creator_suffix, creator_prefix, note_type, P2ID_SERIAL_NUM, ...] + exec.note_tag::create_account_target + # => [tag, creator_suffix, creator_prefix, note_type, P2ID_SERIAL_NUM, ...] + movdn.2 + # => [creator_suffix, creator_prefix, tag, note_type, P2ID_SERIAL_NUM, ...] + + exec.p2id::new + # => [note_idx, enable_callbacks, faucet_suffix, faucet_prefix, amt_account_fill, amt_note_fill] + + loc_store.P2ID_NOTE_IDX + loc_store.P2ID_REQUESTED_ENABLE_CB + loc_store.P2ID_REQUESTED_FAUCET_SUFFIX + loc_store.P2ID_REQUESTED_FAUCET_PREFIX + loc_store.P2ID_AMT_ACCOUNT_FILL + loc_store.P2ID_AMT_NOTE_FILL + # => [] + + # The add cannot overflow: `execute_pswap` asserts + # `amt_account_fill + amt_note_fill <= requested_amount` before calling + # this procedure, and `requested_amount` itself fits in a felt. + loc_load.P2ID_NOTE_IDX + # => [note_idx] + + push.0 + # => [0, note_idx] + + exec.get_current_depth + # => [depth, 0, note_idx] + + exec.get_order_id + # => [order_id, depth, 0, note_idx] + + loc_load.P2ID_AMT_ACCOUNT_FILL loc_load.P2ID_AMT_NOTE_FILL add + # => [total_fill, order_id, depth, 0, note_idx] + + push.PSWAP_ATTACHMENT_SCHEME + # => [PSWAP_ATTACHMENT_SCHEME, total_fill, order_id, depth, 0, note_idx] + + exec.output_note::add_word_attachment + # => [] + + # Move account_fill_amount from consumer's vault to P2ID note (if > 0) + loc_load.P2ID_AMT_ACCOUNT_FILL neq.0 + # => [has_account_fill] + + if.true + # Build 16-element call frame: [ASSET_KEY, ASSET_VALUE, note_idx, pad(7)] + padw push.0.0.0 loc_load.P2ID_NOTE_IDX + # => [note_idx, pad(7)] + + loc_load.P2ID_AMT_ACCOUNT_FILL + loc_load.P2ID_REQUESTED_FAUCET_PREFIX + loc_load.P2ID_REQUESTED_FAUCET_SUFFIX + loc_load.P2ID_REQUESTED_ENABLE_CB + # => [enable_cb, faucet_suffix, faucet_prefix, amt_account_fill, note_idx, pad(7)] + + exec.asset::create_fungible_asset + # => [ASSET_KEY, ASSET_VALUE, note_idx, pad(7)] + + call.wallet::move_asset_to_note + # => [pad(16)] + + dropw dropw dropw dropw + # => [] + end + # => [] + + # Add note_fill_amount directly to P2ID note (no vault debit, if > 0) + loc_load.P2ID_AMT_NOTE_FILL neq.0 + # => [has_note_fill] + + if.true + loc_load.P2ID_NOTE_IDX + # => [note_idx] + + loc_load.P2ID_AMT_NOTE_FILL + loc_load.P2ID_REQUESTED_FAUCET_PREFIX + loc_load.P2ID_REQUESTED_FAUCET_SUFFIX + loc_load.P2ID_REQUESTED_ENABLE_CB + # => [enable_cb, faucet_suffix, faucet_prefix, amt_note_fill, note_idx] + + exec.asset::create_fungible_asset + # => [ASSET_KEY, ASSET_VALUE, note_idx] + + exec.output_note::add_asset + # => [] + end + # => [] +end + +# REMAINDER NOTE CREATION PROCEDURE +# ================================================================================================= + +#! Creates a PSWAP remainder output note for partial fills. +#! +#! Updates the requested amount in note storage, builds a new remainder recipient +#! (using the active note's script root and a serial derived by incrementing the +#! most significant element of the active note's serial number), creates the output +#! note, sets the attachment, and adds the remaining offered asset. +#! +#! Inputs: [offered_faucet_suffix, offered_faucet_prefix, offered_enable_cb, +#! amt_payout, amt_offered, +#! remaining_requested, note_type, tag] +#! Outputs: [] +#! +@locals(6) +# REMAINDER_NOTE_IDX : note_idx +# REMAINDER_OFFERED_FAUCET_SUFFIX : offered_faucet_suffix +# REMAINDER_OFFERED_FAUCET_PREFIX : offered_faucet_prefix +# REMAINDER_OFFERED_ENABLE_CB : offered_enable_cb +# REMAINDER_AMT_PAYOUT : amt_payout +# REMAINDER_AMT_OFFERED : amt_offered +proc create_remainder_note + # Store offered asset info to locals (top element stored first) + loc_store.REMAINDER_OFFERED_FAUCET_SUFFIX + loc_store.REMAINDER_OFFERED_FAUCET_PREFIX + loc_store.REMAINDER_OFFERED_ENABLE_CB + loc_store.REMAINDER_AMT_PAYOUT + loc_store.REMAINDER_AMT_OFFERED + # => [remaining_requested, note_type, tag] + + # Update note storage with new requested amount (needed by compute_and_store_recipient) + mem_store.REQUESTED_AMOUNT_ITEM + # => [note_type, tag] + + # Build PSWAP remainder recipient using the same script as the active note. + exec.active_note::get_script_root + # => [SCRIPT_ROOT, note_type, tag] + + # Derive remainder serial: increment most significant element + exec.active_note::get_serial_number + # => [s0, s1, s2, s3, SCRIPT_ROOT, note_type, tag] + + movup.3 add.1 movdn.3 + # => [s0, s1, s2, s3+1, SCRIPT_ROOT, note_type, tag] + + # Build recipient from all note storage items (now with updated requested amount) + push.NUM_STORAGE_ITEMS push.STORAGE_PTR + # => [storage_ptr, num_storage_items, SERIAL_NUM', SCRIPT_ROOT, note_type, tag] + + exec.note::compute_and_store_recipient + # => [RECIPIENT, note_type, tag] + + movup.4 movup.5 + # => [tag, note_type, RECIPIENT] + + exec.output_note::create + # => [note_idx] + + loc_store.REMAINDER_NOTE_IDX + # => [] + + loc_load.REMAINDER_NOTE_IDX + # => [note_idx] + + push.0 + # => [0, note_idx] + + exec.get_current_depth + # => [depth, 0, note_idx] + + exec.get_order_id + # => [order_id, depth, 0, note_idx] + + loc_load.REMAINDER_AMT_PAYOUT + # => [amt_payout, order_id, depth, 0, note_idx] + + push.PSWAP_ATTACHMENT_SCHEME + # => [PSWAP_ATTACHMENT_SCHEME, amt_payout, order_id, depth, 0, note_idx] + + exec.output_note::add_word_attachment + # => [] + + # Add remaining offered asset: remainder_amount = amt_offered - amt_payout. + # Sub cannot underflow: amt_payout <= amt_offered by construction in + # `calculate_output_amount`. + loc_load.REMAINDER_NOTE_IDX + # => [note_idx] + + loc_load.REMAINDER_AMT_OFFERED loc_load.REMAINDER_AMT_PAYOUT sub + # => [remainder_amount, note_idx] + + loc_load.REMAINDER_OFFERED_FAUCET_PREFIX + loc_load.REMAINDER_OFFERED_FAUCET_SUFFIX + loc_load.REMAINDER_OFFERED_ENABLE_CB + # => [offered_enable_cb, offered_faucet_suffix, offered_faucet_prefix, remainder_amount, note_idx] + + exec.asset::create_fungible_asset + # => [ASSET_KEY, ASSET_VALUE, note_idx] + + exec.output_note::add_asset + # => [] +end + +#! Checks if the currently consuming account is the creator of the note. +#! +#! Inputs: [creator_id_suffix, creator_id_prefix] +#! Outputs: [is_creator] +#! +proc is_consumer_creator + exec.active_account::get_id + # => [acct_id_suffix, acct_id_prefix, creator_id_suffix, creator_id_prefix] + + exec.account_id::is_equal + # => [is_creator] +end + +#! Loads the offered asset from the active note and validates there is exactly one asset. +#! +#! Inputs: [] +#! Outputs: [ASSET_KEY, ASSET_VALUE] +#! +@locals(8) +proc load_offered_asset + locaddr.0 exec.active_note::get_assets + # => [num_assets] + + push.1 eq assert.err=ERR_PSWAP_WRONG_NUMBER_OF_ASSETS + # => [] + + locaddr.0 + # => [dest_ptr] + + exec.asset::load + # => [ASSET_KEY, ASSET_VALUE] +end + +#! Reclaims all assets from the note back to the creator's vault. +#! +#! Called when the consumer IS the creator (cancel/reclaim path). +#! +#! Inputs: [] +#! Outputs: [] +#! +proc handle_reclaim + exec.load_offered_asset + # => [ASSET_KEY, ASSET_VALUE] + + # Build 16-element call frame: [ASSET_KEY, ASSET_VALUE, pad(8)] + padw padw swapdw + # => [ASSET_KEY, ASSET_VALUE, pad(8)] + + call.wallet::receive_asset + # => [pad(16)] + + dropw dropw dropw dropw + # => [] +end + +#! Returns the PSWAP order_id, taken from the active note's serial[1]. +#! +#! serial[1] is stable across every payback and remainder in the PSWAP note chain: +#! the payback bumps serial[0] and the remainder bumps serial[3], so the creator's +#! serial[1] carries through every note in the chain unchanged. +#! +#! Inputs: [] +#! Outputs: [order_id] +proc get_order_id + exec.active_note::get_serial_number + # => [s0, s1, s2, s3] + + drop + # => [s1, s2, s3] + + movdn.2 + # => [s2, s3, s1] + + drop drop + # => [s1] +end + +#! Returns current_depth = parent_depth + 1, where parent_depth is the depth carried in the +#! consumed PSWAP note's PswapAttachment word at offset PARENT_ATTACHMENT_DEPTH_OFFSET if such an +#! attachment exists, or 0 if not (i.e., the parent is the original PSWAP, with scheme none() +#! or NetworkAccountTarget). +#! +#! Inputs: [] +#! Outputs: [current_depth] +@locals(4) +proc get_current_depth + push.PSWAP_ATTACHMENT_SCHEME + # => [PSWAP_ATTACHMENT_SCHEME] + + exec.active_note::find_attachment + # => [is_found, attachment_idx] + + if.true + locaddr.PARENT_ATTACHMENT_PTR + # => [dest_ptr, attachment_idx] + + exec.active_note::write_attachment_to_memory + # => [num_words] + + drop + # => [] + + loc_load.PARENT_ATTACHMENT_DEPTH_OFFSET + # => [parent_depth] + else + drop push.0 + # => [0] + end + # => [parent_depth] + + add.1 + # => [current_depth] +end + +#! Executes the partially-fillable swap. +#! +#! Sends offered tokens to consumer, requested tokens to creator via P2ID, +#! and creates a remainder note if partially filled. +#! +#! The total fill (account fill + note fill) must not exceed the requested amount: +#! overfilling is disallowed because the consumer would lose the excess tokens with +#! no change returned, and it is almost always unintentional. +#! +#! Account fill and note fill payouts are computed separately (rather than summing +#! first) because the account fill portion must be credited to the consumer's vault +#! on its own, while the combined total is needed to size the remainder note. +#! +#! Inputs: [account_fill_amount, note_fill_amount] +#! Outputs: [] +#! +@locals(10) +# EXEC_AMT_OFFERED : amt_offered +# EXEC_AMT_REQUESTED : amt_requested +# EXEC_AMT_PAYOUT_TOTAL : amt_payout (total) +# EXEC_AMT_PAYOUT_ACCOUNT_FILL : amt_payout_account_fill +# EXEC_AMT_PAYOUT_NOTE_FILL : amt_payout_note_fill +# EXEC_OFFERED_ENABLE_CB : offered_enable_cb +# EXEC_OFFERED_FAUCET_SUFFIX : offered_faucet_suffix +# EXEC_OFFERED_FAUCET_PREFIX : offered_faucet_prefix +# EXEC_AMT_REQUESTED_ACCOUNT_FILL : amt_requested_account_fill +# EXEC_AMT_REQUESTED_NOTE_FILL : amt_requested_note_fill +proc execute_pswap + loc_store.EXEC_AMT_REQUESTED_ACCOUNT_FILL + loc_store.EXEC_AMT_REQUESTED_NOTE_FILL + # => [] + + exec.load_offered_asset + # => [ASSET_KEY, ASSET_VALUE] + + # Extract offered amount + exec.asset::fungible_to_amount + # => [amount, ASSET_KEY, ASSET_VALUE] + + loc_store.EXEC_AMT_OFFERED + # => [ASSET_KEY, ASSET_VALUE] + + # Extract and store offered faucet info from ASSET_KEY + exec.asset::key_to_callbacks_enabled loc_store.EXEC_OFFERED_ENABLE_CB + # => [ASSET_KEY, ASSET_VALUE] + + exec.asset::key_into_faucet_id + # => [faucet_id_suffix, faucet_id_prefix, ASSET_VALUE] + + loc_store.EXEC_OFFERED_FAUCET_SUFFIX loc_store.EXEC_OFFERED_FAUCET_PREFIX + # => [ASSET_VALUE] + + dropw + # => [] + + # Read requested amount directly from note storage + mem_load.REQUESTED_AMOUNT_ITEM + loc_store.EXEC_AMT_REQUESTED + # => [] + + # Assert (account_fill + note_fill) <= requested. + # account_fill and note_fill are user-provided, so use overflow-checked add. + loc_load.EXEC_AMT_REQUESTED_ACCOUNT_FILL u32split + loc_load.EXEC_AMT_REQUESTED_NOTE_FILL u32split + # => [nf_lo, nf_hi, af_lo, af_hi] + + exec.u64::overflowing_add + # => [overflow, sum_lo, sum_hi] + + eq.0 assert.err=ERR_PSWAP_FILL_SUM_OVERFLOW + # => [sum_lo, sum_hi] + + exec.assert_valid_asset_amount + # => [sum_lo, sum_hi] + + swap mul.MAX_U32 add + # => [fill_amount] + + loc_load.EXEC_AMT_REQUESTED + # => [requested, fill_amount] + + lte assert.err=ERR_PSWAP_FILL_EXCEEDS_REQUESTED + # => [] + + # Payout for account_fill_amount + loc_load.EXEC_AMT_REQUESTED_ACCOUNT_FILL + loc_load.EXEC_AMT_REQUESTED + loc_load.EXEC_AMT_OFFERED + # => [offered, requested, account_fill_amount] + + exec.calculate_output_amount + # => [account_fill_payout] + + loc_store.EXEC_AMT_PAYOUT_ACCOUNT_FILL + # => [] + + # Payout for note_fill_amount + loc_load.EXEC_AMT_REQUESTED_NOTE_FILL + loc_load.EXEC_AMT_REQUESTED + loc_load.EXEC_AMT_OFFERED + # => [offered, requested, note_fill_amount] + + exec.calculate_output_amount + # => [note_fill_payout] + + loc_store.EXEC_AMT_PAYOUT_NOTE_FILL + # => [] + + # total_payout = account_fill_payout + note_fill_payout + loc_load.EXEC_AMT_PAYOUT_ACCOUNT_FILL loc_load.EXEC_AMT_PAYOUT_NOTE_FILL add + # => [total_payout] + + loc_store.EXEC_AMT_PAYOUT_TOTAL + # => [] + + # Create P2ID note carrying the requested asset to the creator. The note's + # assets are derived from the requested fill amounts (`account_fill + + # note_fill`), not from `total_payout` (which is the offered asset owed to + # the consumer). Fills are guaranteed > 0 by the `both_zero` guard in `main`. + loc_load.EXEC_AMT_REQUESTED_NOTE_FILL + loc_load.EXEC_AMT_REQUESTED_ACCOUNT_FILL + mem_load.REQUESTED_FAUCET_PREFIX_ITEM + mem_load.REQUESTED_FAUCET_SUFFIX_ITEM + mem_load.REQUESTED_ENABLE_CALLBACKS_ITEM + exec.active_note::get_serial_number + mem_load.PAYBACK_NOTE_TYPE_ITEM + mem_load.PSWAP_CREATOR_PREFIX_ITEM + mem_load.PSWAP_CREATOR_SUFFIX_ITEM + # => [creator_suffix, creator_prefix, note_type, SERIAL_NUM, + # enable_callbacks, faucet_suffix, faucet_prefix, amt_account_fill, amt_note_fill] + + exec.create_p2id_note + # => [] + + # Consumer receives only account_fill_payout into vault (not the note_fill portion, if > 0) + loc_load.EXEC_AMT_PAYOUT_ACCOUNT_FILL neq.0 + # => [has_account_fill_payout] + + if.true + padw padw + # => [pad(8)] + + loc_load.EXEC_AMT_PAYOUT_ACCOUNT_FILL + loc_load.EXEC_OFFERED_FAUCET_PREFIX + loc_load.EXEC_OFFERED_FAUCET_SUFFIX + loc_load.EXEC_OFFERED_ENABLE_CB + # => [enable_cb, faucet_suffix, faucet_prefix, amt_payout_account_fill, pad(8)] + + exec.asset::create_fungible_asset + # => [ASSET_KEY, ASSET_VALUE, pad(8)] + + call.wallet::receive_asset + # => [pad(16)] + + dropw dropw dropw dropw + # => [] + end + # => [] + + # Check if partial fill. + loc_load.EXEC_AMT_REQUESTED_ACCOUNT_FILL loc_load.EXEC_AMT_REQUESTED_NOTE_FILL add + loc_load.EXEC_AMT_REQUESTED + # => [total_requested, total_in] + + dup.1 dup.1 neq + # => [is_partial, total_requested, total_in] + + if.true + # remaining_requested = total_requested - total_in + swap sub + # => [remaining_requested] + + exec.active_note::get_metadata + # => [METADATA, remaining_requested] + + dupw + # => [METADATA, METADATA, remaining_requested] + + exec.note::metadata_into_tag + # => [tag, METADATA, remaining_requested] + + movdn.4 + # => [METADATA, tag, remaining_requested] + + exec.note::metadata_into_note_type + # => [note_type, tag, remaining_requested] + + movup.2 + # => [remaining_requested, note_type, tag] + + loc_load.EXEC_AMT_OFFERED + loc_load.EXEC_AMT_PAYOUT_TOTAL + loc_load.EXEC_OFFERED_ENABLE_CB + loc_load.EXEC_OFFERED_FAUCET_PREFIX + loc_load.EXEC_OFFERED_FAUCET_SUFFIX + # => [offered_faucet_suffix, offered_faucet_prefix, offered_enable_cb, + # amt_payout, amt_offered, + # remaining_requested, note_type, tag] + + exec.create_remainder_note + # => [] + else + drop drop + # => [] + end + # => [] + +end + +#! Partially-fillable swap note script: exchanges a portion of the offered asset for a +#! proportional amount of the requested asset, producing a P2ID payback note for the creator +#! and, on partial fills, a remainder PSWAP note carrying the unfilled balance. +#! +#! The consumer specifies two fill amounts via `NOTE_ARGS`: +#! - `account_fill_amount`: portion of the requested asset debited from the consumer's vault. +#! - `note_fill_amount`: portion of the requested asset sourced from another note in the +#! same transaction (cross-swap / net-zero flow, no vault debit). +#! +#! At least one of the two must be non-zero. If both are zero — which is the default in +#! network transactions where the executor does not provide note_args — the script falls back +#! to a full fill (`account_fill_amount = requested`). If the consuming account is the note's +#! creator, the script reclaims the offered asset back to the creator's vault instead. +#! +#! Requires that the account exposes: +#! - `miden::standards::wallets::basic::receive_asset` procedure. +#! - `miden::standards::wallets::basic::move_asset_to_note` procedure. +#! +#! Inputs: [NOTE_ARGS] +#! Outputs: [] +#! +#! Where NOTE_ARGS = [account_fill_amount, note_fill_amount, 0, 0] (top of stack first). +#! +#! See the `# Note storage layout` block near the top of this file for the full +#! storage layout consumed by this script. +#! +#! Panics if: +#! - the number of note storage items is not `NUM_STORAGE_ITEMS`. +#! - the note does not carry exactly one offered asset. +#! - the total fill amount (account fill + note fill) exceeds the requested amount. +#! - the account does not expose `receive_asset` / `move_asset_to_note`. +@note_script +pub proc main + movdn.3 movdn.3 drop drop + # => [account_fill_amount, note_fill_amount] + + # Load all note storage items to memory starting at address 0 + push.STORAGE_PTR exec.active_note::get_storage + # => [num_storage_items, account_fill_amount, note_fill_amount] + + eq.NUM_STORAGE_ITEMS assert.err=ERR_PSWAP_WRONG_NUMBER_OF_STORAGE_ITEMS + # => [account_fill_amount, note_fill_amount] + + dup.1 eq.0 dup.1 eq.0 and + # => [both_zero, account_fill_amount, note_fill_amount] + if.true + drop mem_load.REQUESTED_AMOUNT_ITEM + # => [account_fill_amount, note_fill_amount] + end + # => [account_fill_amount, note_fill_amount] + + mem_load.PSWAP_CREATOR_PREFIX_ITEM mem_load.PSWAP_CREATOR_SUFFIX_ITEM + # => [creator_id_suffix, creator_id_prefix, account_fill_amount, note_fill_amount] + + exec.is_consumer_creator + # => [is_creator, account_fill_amount, note_fill_amount] + + if.true + drop drop + exec.handle_reclaim + # => [] + else + exec.execute_pswap + # => [] + end + # => [] +end diff --git a/crates/miden-standards/asm/standards/notes/swap.masm b/crates/miden-standards/asm/standards/notes/swap.masm index cedb7a6236..cfd11c1138 100644 --- a/crates/miden-standards/asm/standards/notes/swap.masm +++ b/crates/miden-standards/asm/standards/notes/swap.masm @@ -6,21 +6,18 @@ use miden::standards::wallets::basic->wallet # CONSTANTS # ================================================================================================= -const SWAP_NOTE_NUM_STORAGE_ITEMS=20 +const SWAP_NOTE_NUM_STORAGE_ITEMS=14 -const PAYBACK_NOTE_TYPE_PTR=0 -const PAYBACK_NOTE_TAG_PTR=1 -const ATTACHMENT_KIND_PTR=2 -const ATTACHMENT_SCHEME_PTR=3 -const ATTACHMENT_PTR=4 -const REQUESTED_ASSET_PTR=8 -const PAYBACK_RECIPIENT_PTR=16 -const ASSET_PTR=20 +const REQUESTED_ASSET_PTR=0 +const PAYBACK_RECIPIENT_PTR=8 +const PAYBACK_NOTE_TYPE_PTR=12 +const PAYBACK_NOTE_TAG_PTR=13 +const ASSET_PTR=16 # ERRORS # ================================================================================================= -const ERR_SWAP_UNEXPECTED_NUMBER_OF_STORAGE_ITEMS="SWAP script expects exactly 16 note storage items" +const ERR_SWAP_UNEXPECTED_NUMBER_OF_STORAGE_ITEMS="SWAP script expects exactly 14 note storage items" const ERR_SWAP_WRONG_NUMBER_OF_ASSETS="SWAP script requires exactly 1 note asset" @@ -35,14 +32,11 @@ const ERR_SWAP_WRONG_NUMBER_OF_ASSETS="SWAP script requires exactly 1 note asset #! Outputs: [] #! #! Note storage is assumed to be as follows: -#! - payback_note_type -#! - payback_note_tag -#! - attachment_kind -#! - attachment_scheme -#! - ATTACHMENT #! - REQUESTED_ASSET_KEY #! - REQUESTED_ASSET_VALUE #! - PAYBACK_RECIPIENT +#! - payback_note_type +#! - payback_note_tag #! #! Panics if: #! - account does not expose miden::standards::wallets::basic::receive_asset procedure. @@ -50,8 +44,6 @@ const ERR_SWAP_WRONG_NUMBER_OF_ASSETS="SWAP script requires exactly 1 note asset #! - account vault does not contain the requested asset. #! - adding a fungible asset would result in amount overflow, i.e., the total amount would be #! greater than 2^63. -#! - the attachment kind or scheme does not fit into a u32. -#! - the attachment kind is an unknown variant. @note_script pub proc main # dropping note args @@ -62,11 +54,10 @@ pub proc main # store note storage into memory starting at address 0 push.0 exec.active_note::get_storage - # => [num_storage_items, storage_ptr] + # => [num_storage_items] # check number of storage items eq.SWAP_NOTE_NUM_STORAGE_ITEMS assert.err=ERR_SWAP_UNEXPECTED_NUMBER_OF_STORAGE_ITEMS - drop # => [] padw mem_loadw_le.PAYBACK_RECIPIENT_PTR @@ -81,40 +72,31 @@ pub proc main exec.output_note::create # => [note_idx] - padw push.0.0.0 dup.7 - # => [note_idx, pad(7), note_idx] + padw push.0.0.0 movup.7 + # => [note_idx, pad(7)] push.REQUESTED_ASSET_PTR exec.asset::load - # => [REQUESTED_ASSET_KEY, REQUESTED_ASSET_VALUE, note_idx, pad(7), note_idx] + # => [REQUESTED_ASSET_KEY, REQUESTED_ASSET_VALUE, note_idx, pad(7)] # move asset to the note call.wallet::move_asset_to_note - # => [pad(16), note_idx] - - dropw - mem_loadw_le.ATTACHMENT_PTR - # => [ATTACHMENT, pad(8), note_idx] - - mem_load.ATTACHMENT_KIND_PTR - mem_load.ATTACHMENT_SCHEME_PTR - movup.14 - # => [note_idx, attachment_scheme, attachment_kind, ATTACHMENT, pad(8)] + # => [pad(16)] - exec.output_note::set_attachment + dropw dropw # => [pad(8)] # --- move assets from the SWAP note into the account ------------------------- # store the number of note assets to memory starting at address ASSET_PTR push.ASSET_PTR exec.active_note::get_assets - # => [num_assets, asset_ptr, pad(8)] + # => [num_assets, pad(8)] # make sure the number of assets is 1 assert.err=ERR_SWAP_WRONG_NUMBER_OF_ASSETS - # => [asset_ptr, pad(8)] + # => [pad(8)] # load asset - exec.asset::load + push.ASSET_PTR exec.asset::load # => [ASSET_KEY, ASSET_VALUE, pad(8)] # add the asset to the account diff --git a/crates/miden-standards/asm/standards/wallets/basic.masm b/crates/miden-standards/asm/standards/wallets/basic.masm index 57f72ec600..aeeb366c87 100644 --- a/crates/miden-standards/asm/standards/wallets/basic.masm +++ b/crates/miden-standards/asm/standards/wallets/basic.masm @@ -6,7 +6,6 @@ use miden::protocol::active_note # CONSTANTS # ================================================================================================= -const PUBLIC_NOTE=1 #! Adds the provided asset to the active account. #! @@ -70,16 +69,16 @@ end #! #! Inputs: [] #! Outputs: [] -@locals(2048) +@locals(512) pub proc add_assets_to_account # write assets to local memory starting at offset 0 # we have allocated ASSET_SIZE * MAX_ASSETS_PER_NOTE number of locals so all assets should fit # since the asset memory will be overwritten, we don't have to initialize the locals to zero locaddr.0 exec.active_note::get_assets - # => [num_of_assets, ptr = 0] + # => [num_of_assets] # compute the pointer at which we should stop iterating - mul.ASSET_SIZE dup.1 add + mul.ASSET_SIZE locaddr.0 dup movdn.2 add # => [end_ptr, ptr] # pad the stack and move the pointer to the top diff --git a/crates/miden-standards/build.rs b/crates/miden-standards/build.rs index aacb343c89..b5ce96b2db 100644 --- a/crates/miden-standards/build.rs +++ b/crates/miden-standards/build.rs @@ -32,15 +32,12 @@ fn main() -> Result<()> { // re-build when the MASM code changes println!("cargo::rerun-if-changed={ASM_DIR}/"); - // Copies the MASM code to the build directory let crate_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); let build_dir = env::var("OUT_DIR").unwrap(); - let src = Path::new(&crate_dir).join(ASM_DIR); - let dst = Path::new(&build_dir).to_path_buf(); - shared::copy_directory(src, &dst, ASM_DIR)?; - // set source directory to {OUT_DIR}/asm - let source_dir = dst.join(ASM_DIR); + // Read MASM sources directly from the crate's asm/ directory. + // No copy to OUT_DIR is needed because this crate doesn't mutate the source tree. + let source_dir = Path::new(&crate_dir).join(ASM_DIR); // set target directory to {OUT_DIR}/assets let target_dir = Path::new(&build_dir).join(ASSETS_DIR); @@ -110,8 +107,8 @@ fn compile_account_components( .expect("reading the component's MASM source code should succeed"); // Build full library path from directory structure: - // e.g. faucets/basic_fungible_faucet.masm -> - // miden::standards::components::faucets::basic_fungible_faucet + // e.g. faucets/fungible_faucet.masm -> + // miden::standards::components::faucets::fungible_faucet let relative_path = masm_file_path .strip_prefix(source_dir) .expect("masm file should be inside source dir"); @@ -201,58 +198,10 @@ mod shared { use fs_err as fs; use miden_assembly::Report; - use miden_assembly::diagnostics::{IntoDiagnostic, Result, WrapErr}; + use miden_assembly::diagnostics::{IntoDiagnostic, Result}; use regex::Regex; use walkdir::WalkDir; - /// Recursively copies `src` into `dst`. - /// - /// This function will overwrite the existing files if re-executed. - pub fn copy_directory, R: AsRef>( - src: T, - dst: R, - asm_dir: &str, - ) -> Result<()> { - let mut prefix = src.as_ref().canonicalize().unwrap(); - // keep all the files inside the `asm` folder - prefix.pop(); - - let target_dir = dst.as_ref().join(asm_dir); - if target_dir.exists() { - // Clear existing asm files that were copied earlier which may no longer exist. - fs::remove_dir_all(&target_dir) - .into_diagnostic() - .wrap_err("failed to remove ASM directory")?; - } - - // Recreate the directory structure. - fs::create_dir_all(&target_dir) - .into_diagnostic() - .wrap_err("failed to create ASM directory")?; - - let dst = dst.as_ref(); - let mut todo = vec![src.as_ref().to_path_buf()]; - - while let Some(goal) = todo.pop() { - for entry in fs::read_dir(goal).unwrap() { - let path = entry.unwrap().path(); - if path.is_dir() { - let src_dir = path.canonicalize().unwrap(); - let dst_dir = dst.join(src_dir.strip_prefix(&prefix).unwrap()); - if !dst_dir.exists() { - fs::create_dir_all(&dst_dir).unwrap(); - } - todo.push(src_dir); - } else { - let dst_file = dst.join(path.strip_prefix(&prefix).unwrap()); - fs::copy(&path, dst_file).unwrap(); - } - } - } - - Ok(()) - } - /// Returns a vector with paths to all MASM files in the specified directory and its /// subdirectories. /// diff --git a/crates/miden-standards/src/account/access/authority.rs b/crates/miden-standards/src/account/access/authority.rs new file mode 100644 index 0000000000..a58c9d874b --- /dev/null +++ b/crates/miden-standards/src/account/access/authority.rs @@ -0,0 +1,191 @@ +use miden_protocol::account::component::{ + AccountComponentCode, + AccountComponentMetadata, + FeltSchema, + StorageSchema, + StorageSlotSchema, +}; +use miden_protocol::account::{ + AccountComponent, + AccountStorage, + RoleSymbol, + StorageSlot, + StorageSlotName, +}; +use miden_protocol::errors::{AccountError, RoleSymbolError}; +use miden_protocol::utils::sync::LazyLock; +use miden_protocol::{Felt, Word}; +use thiserror::Error; + +use crate::account::account_component_code; + +// CONSTANTS +// ================================================================================================ + +account_component_code!(AUTHORITY_CODE, "access/authority.masl"); + +static AUTHORITY_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::access::authority") + .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`]. +const OWNER_CONTROLLED: u8 = 1; +/// Authority value written to the storage slot for [`Authority::RbacControlled`]. +const RBAC_CONTROLLED: u8 = 2; + +// AUTHORITY +// ================================================================================================ + +/// Identifies which authority is allowed to invoke an authority-gated procedure on an account. +/// +/// 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. +/// +/// # Safety invariant for [`Authority::AuthControlled`] +/// +/// Because `assert_authorized` is a no-op under `AuthControlled`, the account's auth component +/// 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. +#[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`. + AuthControlled = AUTH_CONTROLLED, + /// Authority is the [`Ownable2Step`][crate::account::access::Ownable2Step] owner; the call + /// must be sent by the registered 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. + /// + /// Requires the [`RoleBasedAccessControl`][crate::account::access::RoleBasedAccessControl] + /// 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, +} + +impl Authority { + /// The name of the component. + pub const NAME: &'static str = "miden::standards::components::access::authority"; + + /// Returns the [`AccountComponentCode`] of this component. + pub fn code() -> &'static AccountComponentCode { + &AUTHORITY_CODE + } + + // PUBLIC ACCESSORS + // -------------------------------------------------------------------------------------------- + + /// Returns the [`StorageSlotName`] holding the authority configuration. + pub fn authority_slot() -> &'static StorageSlotName { + &AUTHORITY_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) + } + + /// Returns the [`AccountComponentMetadata`] for this component. + pub fn component_metadata() -> AccountComponentMetadata { + let storage_schema = StorageSchema::new(vec![( + AUTHORITY_SLOT_NAME.clone(), + StorageSlotSchema::value( + "Authority configuration", + [ + FeltSchema::u8("authority"), + FeltSchema::felt("role_symbol"), + FeltSchema::new_void(), + FeltSchema::new_void(), + ], + ), + )]) + .expect("storage schema should be valid"); + + AccountComponentMetadata::new(Self::NAME) + .with_description( + "Account-wide authority shared by procedures that gate state-mutating \ + operations behind auth-only, owner-based, or RBAC role-based checks", + ) + .with_storage_schema(storage_schema) + } +} + +// TRAIT IMPLEMENTATIONS +// ================================================================================================ + +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]) + }, + } + } +} + +impl TryFrom for Authority { + type Error = AuthorityError; + + 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())), + } + } +} + +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(), + ) + .expect("authority component should satisfy the requirements of a valid account component") + } +} + +// AUTHORITY ERROR +// ================================================================================================ + +/// Errors raised when reading or parsing an [`Authority`] from storage. +#[derive(Debug, Error)] +pub enum AuthorityError { + #[error("invalid authority value: {0}")] + InvalidAuthority(u64), + #[error("invalid role symbol in authority slot")] + InvalidRoleSymbol(#[source] RoleSymbolError), + #[error("failed to read authority slot from storage")] + MissingStorageSlot(#[source] AccountError), +} diff --git a/crates/miden-standards/src/account/access/mod.rs b/crates/miden-standards/src/account/access/mod.rs index f7c58c875b..599cd9fbd2 100644 --- a/crates/miden-standards/src/account/access/mod.rs +++ b/crates/miden-standards/src/account/access/mod.rs @@ -1,20 +1,98 @@ -use miden_protocol::account::{AccountComponent, AccountId}; +use alloc::vec; +use miden_protocol::account::{AccountComponent, AccountId, RoleSymbol}; + +pub mod authority; pub mod ownable2step; +pub mod pausable; +pub mod rbac; /// Access control configuration for account components. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// +/// 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. +/// +/// - [`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). +/// +/// Pass to +/// [`AccountBuilder::with_components`][miden_protocol::account::AccountBuilder::with_components] +/// to install the access control components on the account: +/// +/// ```no_run +/// 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 }); +/// ``` +/// +/// For accounts that don't use the [`AccessControl`] convenience but want to install the +/// [`Authority`] component directly, the [`Authority`] enum can be passed via +/// [`AccountBuilder::with_component`][miden_protocol::account::AccountBuilder::with_component]. +#[derive(Debug, Clone, PartialEq, Eq)] pub enum AccessControl { - /// Uses two-step ownership transfer with the provided initial owner. + /// 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. Ownable2Step { owner: AccountId }, + /// 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. + Rbac { + owner: AccountId, + authority_role: Option, + }, } -impl From for AccountComponent { - fn from(access_control: AccessControl) -> Self { - match access_control { - AccessControl::Ownable2Step { owner } => Ownable2Step::new(owner).into(), +impl IntoIterator for AccessControl { + type Item = AccountComponent; + type IntoIter = alloc::vec::IntoIter; + + /// Yields the [`AccountComponent`]s implementing this access control configuration, in the + /// order they must be installed on the account. The matching [`Authority`] component is + /// 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![ + Ownable2Step::new(owner).into(), + RoleBasedAccessControl::empty().into(), + Authority::RbacControlled { role }.into(), + ] + .into_iter(), } } } +pub use authority::{Authority, AuthorityError}; pub use ownable2step::{Ownable2Step, Ownable2StepError}; +pub use pausable::{Pausable, PausableManager, PausableStorage}; +pub use rbac::RoleBasedAccessControl; diff --git a/crates/miden-standards/src/account/access/ownable2step.rs b/crates/miden-standards/src/account/access/ownable2step.rs index c5356394ab..1e6c1f2f28 100644 --- a/crates/miden-standards/src/account/access/ownable2step.rs +++ b/crates/miden-standards/src/account/access/ownable2step.rs @@ -1,4 +1,5 @@ use miden_protocol::account::component::{ + AccountComponentCode, AccountComponentMetadata, FeltSchema, StorageSchema, @@ -6,9 +7,9 @@ use miden_protocol::account::component::{ }; use miden_protocol::account::{ AccountComponent, + AccountComponentName, AccountId, AccountStorage, - AccountType, StorageSlot, StorageSlotName, }; @@ -16,7 +17,9 @@ use miden_protocol::errors::AccountIdError; use miden_protocol::utils::sync::LazyLock; use miden_protocol::{Felt, Word}; -use crate::account::components::ownable2step_library; +use crate::account::account_component_code; + +account_component_code!(OWNABLE2STEP_CODE, "access/ownable2step.masl"); static OWNER_CONFIG_SLOT_NAME: LazyLock = LazyLock::new(|| { StorageSlotName::new("miden::standards::access::ownable2step::owner_config") @@ -45,7 +48,17 @@ pub struct Ownable2Step { impl Ownable2Step { /// The name of the component. - pub const NAME: &'static str = "miden::standards::components::access::ownable2step"; + pub const NAME: &'static str = "miden::standards::access::ownable2step"; + + /// 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 { + &OWNABLE2STEP_CODE + } // CONSTRUCTORS // -------------------------------------------------------------------------------------------- @@ -140,7 +153,7 @@ impl Ownable2Step { let storage_schema = StorageSchema::new([Self::slot_schema()]).expect("storage schema should be valid"); - AccountComponentMetadata::new(Self::NAME, AccountType::all()) + AccountComponentMetadata::new(Self::NAME) .with_description("Two-step ownership management component") .with_storage_schema(storage_schema) } @@ -151,7 +164,7 @@ impl From for AccountComponent { let storage_slot = ownership.to_storage_slot(); let metadata = Ownable2Step::component_metadata(); - AccountComponent::new(ownable2step_library(), vec![storage_slot], metadata).expect( + AccountComponent::new(Ownable2Step::code().clone(), vec![storage_slot], metadata).expect( "Ownable2Step component should satisfy the requirements of a valid account component", ) } diff --git a/crates/miden-standards/src/account/access/pausable/manager.rs b/crates/miden-standards/src/account/access/pausable/manager.rs new file mode 100644 index 0000000000..ee3ee06c2c --- /dev/null +++ b/crates/miden-standards/src/account/access/pausable/manager.rs @@ -0,0 +1,82 @@ +use miden_protocol::account::component::{AccountComponentCode, AccountComponentMetadata}; +use miden_protocol::account::{AccountComponent, AccountProcedureRoot}; + +use crate::account::account_component_code; +use crate::procedure_root; + +// PAUSABLE MANAGER COMPONENT +// ================================================================================================ + +account_component_code!(PAUSABLE_MANAGER_CODE, "access/pausable/manager.masl"); + +procedure_root!( + PAUSABLE_MANAGER_PAUSE, + PausableManager::NAME, + PausableManager::PAUSE_PROC_NAME, + PausableManager::code() +); + +procedure_root!( + PAUSABLE_MANAGER_UNPAUSE, + PausableManager::NAME, + PausableManager::UNPAUSE_PROC_NAME, + PausableManager::code() +); + +/// Account component exposing `pause` and `unpause` admin procedures, gated by the account-wide +/// [`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::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). +/// +/// Companion components required: +/// - [`crate::account::access::Authority`] — installed automatically by the +/// [`crate::account::access::AccessControl`] enum. +/// - [`super::Pausable`] — provides the `is_paused` storage slot that pause / unpause write to. +#[derive(Debug, Clone, Copy, Default)] +pub struct PausableManager; + +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"; + + /// Returns the [`AccountComponentCode`] of this component. + pub fn code() -> &'static AccountComponentCode { + &PAUSABLE_MANAGER_CODE + } + + /// Returns the procedure root of the `pause` procedure exposed by this component. + pub fn pause_root() -> AccountProcedureRoot { + *PAUSABLE_MANAGER_PAUSE + } + + /// Returns the procedure root of the `unpause` procedure exposed by this component. + pub fn unpause_root() -> AccountProcedureRoot { + *PAUSABLE_MANAGER_UNPAUSE + } +} + +impl From for AccountComponent { + fn from(_: PausableManager) -> Self { + let metadata = AccountComponentMetadata::new(PausableManager::NAME).with_description( + "PausableManager: pause / unpause admin procedures gated by the account-wide \ + Authority component. Requires the Pausable companion component for storage and the \ + Authority component for auth dispatch.", + ); + + AccountComponent::new(PausableManager::code().clone(), vec![], metadata).expect( + "pausable manager component should satisfy the requirements of a valid account component", + ) + } +} diff --git a/crates/miden-standards/src/account/access/pausable/mod.rs b/crates/miden-standards/src/account/access/pausable/mod.rs new file mode 100644 index 0000000000..fd4198dbbb --- /dev/null +++ b/crates/miden-standards/src/account/access/pausable/mod.rs @@ -0,0 +1,180 @@ +use miden_protocol::account::component::{ + AccountComponentCode, + AccountComponentMetadata, + StorageSchema, + StorageSlotSchema, +}; +use miden_protocol::account::{ + AccountComponent, + AccountProcedureRoot, + StorageSlot, + StorageSlotName, +}; +use miden_protocol::utils::sync::LazyLock; +use miden_protocol::{Felt, Word}; + +use crate::account::account_component_code; +use crate::procedure_root; + +mod manager; +pub use manager::PausableManager; + +// IS_PAUSED STORAGE +// ================================================================================================ + +account_component_code!(PAUSABLE_CODE, "access/pausable/mod.masl"); + +static IS_PAUSED_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::access::pausable::is_paused") + .expect("storage slot name should be valid") +}); + +procedure_root!( + PAUSABLE_IS_PAUSED_ROOT, + Pausable::NAME, + Pausable::IS_PAUSED_PROC_NAME, + Pausable::code() +); + +/// Storage helper backing the pause flag for a single account. +/// +/// `PausableStorage` exposes the slot name and schema for the `is_paused` flag word plus the +/// captured pause state. It is **not** an installable account component on its own — the +/// [`Pausable`] component installs the storage slot, and any consumer (TokenPolicyManager +/// dispatch, asset callbacks, metadata setters) reads via `exec.pausable::assert_not_paused` / +/// `assert_paused` exec helpers from the standards library at +/// `miden::standards::access::pausable`. +/// +/// ## Storage +/// +/// - [`Self::is_paused_slot()`]: single word; the zero word means unpaused, `[1, 0, 0, 0]` means +/// paused. Any non-zero word is interpreted as paused by the MASM helpers. +#[derive(Debug, Clone, Copy, Default)] +pub struct PausableStorage { + state: bool, +} + +impl PausableStorage { + /// Creates a [`PausableStorage`] with the given pause state. + pub const fn new(state: bool) -> Self { + Self { state } + } + + /// Creates a [`PausableStorage`] in the paused state. + pub const fn paused() -> Self { + Self::new(true) + } + + /// Creates a [`PausableStorage`] in the unpaused state. + pub const fn unpaused() -> Self { + Self::new(false) + } + + /// Returns the pause state captured in this storage. + pub fn state(&self) -> bool { + self.state + } + + /// Storage slot name for the pause flag word. + pub fn is_paused_slot() -> &'static StorageSlotName { + &IS_PAUSED_SLOT_NAME + } + + /// Schema entry for the pause flag slot (documentation / tooling). + pub fn is_paused_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::is_paused_slot().clone(), + StorageSlotSchema::value( + "Pause flag word; zero is unpaused, canonical paused encoding is [1,0,0,0]", + [Felt::ZERO; 4], + ), + ) + } + + /// Returns the pause-flag [`Word`] for the captured state. + pub fn to_word(&self) -> Word { + if self.state { + Word::from([1u32, 0, 0, 0]) + } else { + Word::default() + } + } + + /// Consumes the storage and returns the [`StorageSlot`] it contributes to an account + /// component. The slot is initialized with the captured pause state. + pub fn into_slot(self) -> StorageSlot { + StorageSlot::with_value(Self::is_paused_slot().clone(), self.to_word()) + } +} + +// PAUSABLE COMPONENT +// ================================================================================================ + +/// Account component that installs the [`PausableStorage`] slot and exposes `is_paused` +/// view procedure. +/// +/// Pair with [`PausableManager`] to expose `pause` / `unpause` admin procedures gated by the +/// account-wide [`crate::account::access::Authority`] component. +#[derive(Debug, Clone, Copy, Default)] +pub struct Pausable(PausableStorage); + +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"; + + /// Creates a [`Pausable`] component with the given pause state. + pub const fn new(state: bool) -> Self { + Self(PausableStorage::new(state)) + } + + /// Creates a [`Pausable`] component that starts in the paused state. + pub const fn paused() -> Self { + Self::new(true) + } + + /// Creates a [`Pausable`] component that starts in the unpaused state. + pub const fn unpaused() -> Self { + Self::new(false) + } + + /// Returns the pause state captured in this component. + pub fn state(&self) -> bool { + self.0.state() + } + + /// Returns the underlying [`PausableStorage`] helper. + pub fn storage(&self) -> &PausableStorage { + &self.0 + } + + /// Returns the [`AccountComponentCode`] of this component. + pub fn code() -> &'static AccountComponentCode { + &PAUSABLE_CODE + } + + /// Returns the procedure root of the `is_paused` call procedure exposed by this component. + pub fn is_paused_root() -> AccountProcedureRoot { + *PAUSABLE_IS_PAUSED_ROOT + } +} + +impl From for AccountComponent { + fn from(pausable: Pausable) -> Self { + let storage_schema = StorageSchema::new([PausableStorage::is_paused_slot_schema()]) + .expect("storage schema should be valid"); + + let metadata = AccountComponentMetadata::new(Pausable::NAME) + .with_description( + "Pausable: installs the `is_paused` storage slot and exposes \ + `is_paused` view.", + ) + .with_storage_schema(storage_schema); + + AccountComponent::new(Pausable::code().clone(), vec![pausable.0.into_slot()], metadata) + .expect( + "pausable component should satisfy the requirements of a valid account component", + ) + } +} diff --git a/crates/miden-standards/src/account/access/rbac.rs b/crates/miden-standards/src/account/access/rbac.rs new file mode 100644 index 0000000000..7a53bd9fa1 --- /dev/null +++ b/crates/miden-standards/src/account/access/rbac.rs @@ -0,0 +1,197 @@ +use alloc::vec; + +use miden_protocol::account::component::{ + AccountComponentCode, + AccountComponentMetadata, + SchemaType, + StorageSchema, + StorageSlotSchema, +}; +use miden_protocol::account::{ + AccountComponent, + AccountComponentName, + StorageMap, + StorageSlot, + StorageSlotName, +}; +use miden_protocol::utils::sync::LazyLock; + +use crate::account::account_component_code; + +account_component_code!(RBAC_CODE, "access/rbac.masl"); + +static ROLE_CONFIG_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::access::rbac::role_config") + .expect("storage slot name should be valid") +}); +static ROLE_MEMBERSHIP_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::access::rbac::role_membership") + .expect("storage slot name should be valid") +}); + +/// Role-based access control (RBAC) for account components. +/// +/// RBAC provides fine-grained access control on top of [`Ownable2Step`]. Instead of having +/// one account holding every privilege, privileges are split into named roles (for example +/// `MINTER`, `BURNER`, `PAUSER`), and each procedure is guarded against the caller's role +/// membership. It allows role assignment with domain isolation to minimize the scope of +/// damage from a compromised role. +/// +/// ## Relation to [`Ownable2Step`] +/// +/// RBAC is a superset of [`Ownable2Step`] and depends on it: the top-level authority is +/// the [`Ownable2Step`] owner of the account. Build the pair via +/// [`AccessControl::Rbac`][crate::account::access::AccessControl::Rbac] passed to +/// [`AccountBuilder::with_components`][miden_protocol::account::AccountBuilder::with_components]. +/// This avoids duplicated state, duplicated 2-step transfer logic, and duplicated notes +/// for owner transfers. If you only need single-account control, use [`Ownable2Step`] +/// alone. +/// +/// [`Ownable2Step`]: crate::account::access::Ownable2Step +/// +/// ## Owner management +/// +/// The owner can grant and revoke any role, configure the delegated admin of any role via +/// `set_role_admin`, and transfer or renounce its own position. Owner transfer and +/// renouncement go through [`Ownable2Step`] (`transfer_ownership`, `accept_ownership`, +/// `renounce_ownership`). +/// +/// ## Role hierarchy +/// +/// Every role may optionally have a delegated admin role. Accounts holding a role's admin +/// role are authorized to grant and revoke that role without going through the owner. +/// For example, accounts holding `MINTER_ADMIN` can manage the `MINTER` role but have no +/// authority over `BURNER` or `PAUSER`. This lets responsibilities be distributed so that +/// compromise of one domain does not spill into the others. +/// +/// Combined with owner renouncement, this supports a fully decentralized configuration: +/// once every role has its own admin role populated, the owner can renounce and the +/// system continues to operate with each role managed only by its designated admin role. +/// +/// The delegated admin of a role can itself be any role, including one that it admins. +/// Circular relationships are possible but should be designed with care, since each role +/// can then revoke the other. +/// +/// ## Role semantics +/// +/// A role is considered to exist when it has at least one member. Granting the first +/// member creates the role; revoking the last member removes it. As a consequence, +/// `set_role_admin(A, B)` stores the admin relationship in storage but does not make role +/// `A` exist until a member is granted. Once the last member of `A` is revoked, +/// `get_role_member_count(A)` returns `0`, though the admin configuration is retained and +/// will apply the next time a member is granted. +/// +/// ## Membership lookup +/// +/// `has_role` procedure is the primary guard used by procedures that assert the caller's +/// role membership. `get_role_member_count` returns the number of accounts holding a role. +/// +/// ## Role symbol format +/// +/// A [`RoleSymbol`] encodes up to 12 uppercase ASCII characters with underscores into a +/// single field element using the same packing as the token symbol type. Examples: +/// `MINTER`, `MINTER_ADMIN`, `PAUSER`. The zero field element is reserved and cannot be +/// used as a role symbol; attempting to do so panics with `ERR_ROLE_SYMBOL_ZERO`. +/// +/// ## Usage +/// +/// Guarding a procedure in MASM so that only members of `MINTER` can call it: +/// +/// ```text +/// pub proc mint +/// push.MINTER_ROLE_SYMBOL +/// exec.::miden::standards::access::rbac::assert_sender_has_role +/// # add mint logic +/// end +/// ``` +/// +/// [`RoleSymbol`]: miden_protocol::account::RoleSymbol +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RoleBasedAccessControl; + +impl RoleBasedAccessControl { + pub const NAME: &'static str = "miden::standards::components::access::rbac"; + + /// 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 { + &RBAC_CODE + } + + /// Returns an empty RBAC component. Roles are populated at runtime via the + /// `grant_role`, `set_role_admin`, etc. procedures exposed by the component. + pub fn empty() -> Self { + Self + } + + /// Returns the storage slot name for the per-role config map. + pub fn role_config_slot() -> &'static StorageSlotName { + &ROLE_CONFIG_SLOT_NAME + } + + /// Returns the storage slot name for the per-role membership map. + pub fn role_membership_slot() -> &'static StorageSlotName { + &ROLE_MEMBERSHIP_SLOT_NAME + } + + /// Returns the schema entry for the per-role config map. + pub fn role_config_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::role_config_slot().clone(), + StorageSlotSchema::map( + "Per-role RBAC configuration (member count and delegated admin role)", + SchemaType::role_symbol(), + SchemaType::native_word(), + ), + ) + } + + /// Returns the schema entry for the per-role membership map. + pub fn role_membership_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::role_membership_slot().clone(), + StorageSlotSchema::map( + "Role membership flag indexed by role symbol and account ID", + SchemaType::native_word(), + SchemaType::native_word(), + ), + ) + } + + /// Returns the [`AccountComponentMetadata`] describing this component. + pub fn component_metadata() -> AccountComponentMetadata { + let storage_schema = StorageSchema::new(vec![ + Self::role_config_slot_schema(), + Self::role_membership_slot_schema(), + ]) + .expect("storage schema should be valid"); + + AccountComponentMetadata::new(Self::NAME) + .with_description("Role-based access control component") + .with_storage_schema(storage_schema) + } +} + +impl From for AccountComponent { + fn from(_rbac: RoleBasedAccessControl) -> Self { + let role_config_slot = StorageSlot::with_map( + RoleBasedAccessControl::role_config_slot().clone(), + StorageMap::with_entries(vec![]).expect("empty role config map should be valid"), + ); + let role_membership_slot = StorageSlot::with_map( + RoleBasedAccessControl::role_membership_slot().clone(), + StorageMap::with_entries(vec![]).expect("empty role membership map should be valid"), + ); + + AccountComponent::new( + RoleBasedAccessControl::code().clone(), + vec![role_config_slot, role_membership_slot], + RoleBasedAccessControl::component_metadata(), + ) + .expect("RBAC component should satisfy the requirements of a valid account component") + } +} diff --git a/crates/miden-standards/src/account/auth/multisig_psm.rs b/crates/miden-standards/src/account/auth/guarded_multisig.rs similarity index 57% rename from crates/miden-standards/src/account/auth/multisig_psm.rs rename to crates/miden-standards/src/account/auth/guarded_multisig.rs index 1e9ecc34b2..b509a4cf33 100644 --- a/crates/miden-standards/src/account/auth/multisig_psm.rs +++ b/crates/miden-standards/src/account/auth/guarded_multisig.rs @@ -3,6 +3,7 @@ use alloc::vec::Vec; use miden_protocol::Word; use miden_protocol::account::auth::{AuthScheme, PublicKeyCommitment}; use miden_protocol::account::component::{ + AccountComponentCode, AccountComponentMetadata, SchemaType, StorageSchema, @@ -10,7 +11,8 @@ use miden_protocol::account::component::{ }; use miden_protocol::account::{ AccountComponent, - AccountType, + AccountComponentName, + AccountProcedureRoot, StorageMap, StorageMapKey, StorageSlot, @@ -20,39 +22,41 @@ use miden_protocol::errors::AccountError; use miden_protocol::utils::sync::LazyLock; use super::multisig::{AuthMultisig, AuthMultisigConfig}; -use crate::account::components::multisig_psm_library; +use crate::account::account_component_code; + +account_component_code!(GUARDED_MULTISIG_CODE, "auth/guarded_multisig.masl"); // CONSTANTS // ================================================================================================ -static PSM_PUBKEY_SLOT_NAME: LazyLock = LazyLock::new(|| { - StorageSlotName::new("miden::standards::auth::psm::pub_key") +static GUARDIAN_PUBKEY_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::auth::guardian::pub_key") .expect("storage slot name should be valid") }); -static PSM_SCHEME_ID_SLOT_NAME: LazyLock = LazyLock::new(|| { - StorageSlotName::new("miden::standards::auth::psm::scheme") +static GUARDIAN_SCHEME_ID_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::auth::guardian::scheme") .expect("storage slot name should be valid") }); // MULTISIG AUTHENTICATION COMPONENT // ================================================================================================ -/// Configuration for [`AuthMultisigPsm`] component. +/// Configuration for [`AuthGuardedMultisig`] component. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct AuthMultisigPsmConfig { +pub struct AuthGuardedMultisigConfig { multisig: AuthMultisigConfig, - psm_config: PsmConfig, + guardian_config: GuardianConfig, } -/// Public configuration for the private state manager signer. +/// Public configuration for the guardian signer. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct PsmConfig { +pub struct GuardianConfig { pub_key: PublicKeyCommitment, auth_scheme: AuthScheme, } -impl PsmConfig { +impl GuardianConfig { pub fn new(pub_key: PublicKeyCommitment, auth_scheme: AuthScheme) -> Self { Self { pub_key, auth_scheme } } @@ -66,18 +70,18 @@ impl PsmConfig { } fn public_key_slot() -> &'static StorageSlotName { - &PSM_PUBKEY_SLOT_NAME + &GUARDIAN_PUBKEY_SLOT_NAME } fn scheme_id_slot() -> &'static StorageSlotName { - &PSM_SCHEME_ID_SLOT_NAME + &GUARDIAN_SCHEME_ID_SLOT_NAME } fn public_key_slot_schema() -> (StorageSlotName, StorageSlotSchema) { ( Self::public_key_slot().clone(), StorageSlotSchema::map( - "Private state manager public keys", + "Guardian public keys", SchemaType::u32(), SchemaType::pub_key(), ), @@ -88,7 +92,7 @@ impl PsmConfig { ( Self::scheme_id_slot().clone(), StorageSlotSchema::map( - "Private state manager scheme IDs", + "Guardian scheme IDs", SchemaType::u32(), SchemaType::auth_scheme(), ), @@ -98,22 +102,22 @@ impl PsmConfig { fn into_component_parts(self) -> (Vec, Vec<(StorageSlotName, StorageSlotSchema)>) { let mut storage_slots = Vec::with_capacity(2); - // Private state manager public key slot (map: [0, 0, 0, 0] -> pubkey) - let psm_public_key_entries = + // Guardian public key slot (map: [0, 0, 0, 0] -> pubkey) + let guardian_public_key_entries = [(StorageMapKey::from_raw(Word::from([0u32, 0, 0, 0])), Word::from(self.pub_key))]; storage_slots.push(StorageSlot::with_map( Self::public_key_slot().clone(), - StorageMap::with_entries(psm_public_key_entries).unwrap(), + StorageMap::with_entries(guardian_public_key_entries).unwrap(), )); - // Private state manager scheme IDs slot (map: [0, 0, 0, 0] -> [scheme_id, 0, 0, 0]) - let psm_scheme_id_entries = [( + // Guardian scheme IDs slot (map: [0, 0, 0, 0] -> [scheme_id, 0, 0, 0]) + let guardian_scheme_id_entries = [( StorageMapKey::from_raw(Word::from([0u32, 0, 0, 0])), Word::from([self.auth_scheme as u32, 0, 0, 0]), )]; storage_slots.push(StorageSlot::with_map( Self::scheme_id_slot().clone(), - StorageMap::with_entries(psm_scheme_id_entries).unwrap(), + StorageMap::with_entries(guardian_scheme_id_entries).unwrap(), )); let slot_metadata = vec![Self::public_key_slot_schema(), Self::auth_scheme_slot_schema()]; @@ -122,35 +126,35 @@ impl PsmConfig { } } -impl AuthMultisigPsmConfig { - /// Creates a new configuration with the given approvers, default threshold and PSM signer. +impl AuthGuardedMultisigConfig { + /// Creates a new configuration with the given approvers, default threshold and guardian signer. /// /// The `default_threshold` must be at least 1 and at most the number of approvers. - /// The private state manager public key must be different from all approver public keys. + /// The guardian public key must be different from all approver public keys. pub fn new( approvers: Vec<(PublicKeyCommitment, AuthScheme)>, default_threshold: u32, - psm_config: PsmConfig, + guardian_config: GuardianConfig, ) -> Result { let multisig = AuthMultisigConfig::new(approvers, default_threshold)?; if multisig .approvers() .iter() - .any(|(approver, _)| *approver == psm_config.pub_key()) + .any(|(approver, _)| *approver == guardian_config.pub_key()) { return Err(AccountError::other( - "private state manager public key must be different from approvers", + "guardian public key must be different from approvers", )); } - Ok(Self { multisig, psm_config }) + Ok(Self { multisig, guardian_config }) } /// Attaches a per-procedure threshold map. Each procedure threshold must be at least 1 and /// at most the number of approvers. pub fn with_proc_thresholds( mut self, - proc_thresholds: Vec<(Word, u32)>, + proc_thresholds: Vec<(AccountProcedureRoot, u32)>, ) -> Result { self.multisig = self.multisig.with_proc_thresholds(proc_thresholds)?; Ok(self) @@ -164,45 +168,52 @@ impl AuthMultisigPsmConfig { self.multisig.default_threshold() } - pub fn proc_thresholds(&self) -> &[(Word, u32)] { + pub fn proc_thresholds(&self) -> &[(AccountProcedureRoot, u32)] { self.multisig.proc_thresholds() } - pub fn psm_config(&self) -> PsmConfig { - self.psm_config + pub fn guardian_config(&self) -> GuardianConfig { + self.guardian_config } - fn into_parts(self) -> (AuthMultisigConfig, PsmConfig) { - (self.multisig, self.psm_config) + fn into_parts(self) -> (AuthMultisigConfig, GuardianConfig) { + (self.multisig, self.guardian_config) } } -/// An [`AccountComponent`] implementing a multisig authentication with a private state manager. +/// An [`AccountComponent`] implementing multisig authentication integrated with a state guardian. /// /// It enforces a threshold of approver signatures for every transaction, with optional -/// per-procedure threshold overrides. With Private State Manager (PSM) is configured, -/// multisig authorization is combined with PSM authorization, so operations require both -/// multisig approval and a valid PSM signature. This substantially mitigates low-threshold -/// state-withholding scenarios since the PSM is expected to forward state updates to other -/// approvers. -/// -/// This component supports all account types. +/// per-procedure threshold overrides. When a guardian is configured, multisig authorization is +/// combined with guardian authorization, so operations require both multisig approval and a valid +/// guardian signature. This substantially mitigates low-threshold state-withholding scenarios +/// since the guardian is expected to forward state updates to other approvers. #[derive(Debug)] -pub struct AuthMultisigPsm { +pub struct AuthGuardedMultisig { multisig: AuthMultisig, - psm_config: PsmConfig, + guardian_config: GuardianConfig, } -impl AuthMultisigPsm { +impl AuthGuardedMultisig { /// The name of the component. - pub const NAME: &'static str = "miden::standards::components::auth::multisig_psm"; + pub const NAME: &'static str = "miden::standards::components::auth::guarded_multisig"; + + /// Returns the canonical [`AccountComponentName`] of this component. + pub const fn name() -> AccountComponentName { + AccountComponentName::from_static_str(Self::NAME) + } - /// Creates a new [`AuthMultisigPsm`] component from the provided configuration. - pub fn new(config: AuthMultisigPsmConfig) -> Result { - let (multisig_config, psm_config) = config.into_parts(); + /// Returns the [`AccountComponentCode`] of this component. + pub fn code() -> &'static AccountComponentCode { + &GUARDED_MULTISIG_CODE + } + + /// Creates a new [`AuthGuardedMultisig`] component from the provided configuration. + pub fn new(config: AuthGuardedMultisigConfig) -> Result { + let (multisig_config, guardian_config) = config.into_parts(); Ok(Self { multisig: AuthMultisig::new(multisig_config)?, - psm_config, + guardian_config, }) } @@ -231,14 +242,14 @@ impl AuthMultisigPsm { AuthMultisig::procedure_thresholds_slot() } - /// Returns the [`StorageSlotName`] where the private state manager public key is stored. - pub fn psm_public_key_slot() -> &'static StorageSlotName { - PsmConfig::public_key_slot() + /// Returns the [`StorageSlotName`] where the guardian public key is stored. + pub fn guardian_public_key_slot() -> &'static StorageSlotName { + GuardianConfig::public_key_slot() } - /// Returns the [`StorageSlotName`] where the private state manager scheme IDs are stored. - pub fn psm_scheme_id_slot() -> &'static StorageSlotName { - PsmConfig::scheme_id_slot() + /// Returns the [`StorageSlotName`] where the guardian scheme IDs are stored. + pub fn guardian_scheme_id_slot() -> &'static StorageSlotName { + GuardianConfig::scheme_id_slot() } /// Returns the storage slot schema for the threshold configuration slot. @@ -266,14 +277,14 @@ impl AuthMultisigPsm { AuthMultisig::procedure_thresholds_slot_schema() } - /// Returns the storage slot schema for the private state manager public key slot. - pub fn psm_public_key_slot_schema() -> (StorageSlotName, StorageSlotSchema) { - PsmConfig::public_key_slot_schema() + /// Returns the storage slot schema for the guardian public key slot. + pub fn guardian_public_key_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + GuardianConfig::public_key_slot_schema() } - /// Returns the storage slot schema for the private state manager scheme IDs slot. - pub fn psm_auth_scheme_slot_schema() -> (StorageSlotName, StorageSlotSchema) { - PsmConfig::auth_scheme_slot_schema() + /// Returns the storage slot schema for the guardian scheme IDs slot. + pub fn guardian_auth_scheme_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + GuardianConfig::auth_scheme_slot_schema() } /// Returns the [`AccountComponentMetadata`] for this component. @@ -284,49 +295,47 @@ impl AuthMultisigPsm { Self::approver_auth_scheme_slot_schema(), Self::executed_transactions_slot_schema(), Self::procedure_thresholds_slot_schema(), - Self::psm_public_key_slot_schema(), - Self::psm_auth_scheme_slot_schema(), + Self::guardian_public_key_slot_schema(), + Self::guardian_auth_scheme_slot_schema(), ]) .expect("storage schema should be valid"); - AccountComponentMetadata::new(Self::NAME, AccountType::all()) + AccountComponentMetadata::new(Self::NAME) .with_description( - "Multisig authentication component with private state manager \ - using hybrid signature schemes", + "Guarded multisig authentication component integrated \ + with a state guardian using hybrid signature schemes", ) .with_storage_schema(storage_schema) } } -impl From for AccountComponent { - fn from(multisig: AuthMultisigPsm) -> Self { - let AuthMultisigPsm { multisig, psm_config } = multisig; +impl From for AccountComponent { + fn from(multisig: AuthGuardedMultisig) -> Self { + let AuthGuardedMultisig { multisig, guardian_config } = multisig; let multisig_component = AccountComponent::from(multisig); - let (psm_slots, psm_slot_metadata) = psm_config.into_component_parts(); + let (guardian_slots, guardian_slot_metadata) = guardian_config.into_component_parts(); let mut storage_slots = multisig_component.storage_slots().to_vec(); - storage_slots.extend(psm_slots); + storage_slots.extend(guardian_slots); let mut slot_schemas: Vec<(StorageSlotName, StorageSlotSchema)> = multisig_component .storage_schema() .iter() .map(|(slot_name, slot_schema)| (slot_name.clone(), slot_schema.clone())) .collect(); - slot_schemas.extend(psm_slot_metadata); + slot_schemas.extend(guardian_slot_metadata); let storage_schema = StorageSchema::new(slot_schemas).expect("storage schema should be valid"); - let metadata = AccountComponentMetadata::new( - AuthMultisigPsm::NAME, - multisig_component.supported_types().clone(), - ) - .with_description(multisig_component.metadata().description()) - .with_version(multisig_component.metadata().version().clone()) - .with_storage_schema(storage_schema); + let metadata = AccountComponentMetadata::new(AuthGuardedMultisig::NAME) + .with_description(multisig_component.metadata().description()) + .with_version(multisig_component.metadata().version().clone()) + .with_storage_schema(storage_schema); - AccountComponent::new(multisig_psm_library(), storage_slots, metadata).expect( - "Multisig auth component should satisfy the requirements of a valid account component", + AccountComponent::new(AuthGuardedMultisig::code().clone(), storage_slots, metadata).expect( + "Guarded multisig auth component should satisfy the requirements of a valid \ + account component", ) } } @@ -345,14 +354,14 @@ mod tests { use super::*; use crate::account::wallets::BasicWallet; - /// Test multisig component setup with various configurations + /// Test guarded multisig component setup with various configurations. #[test] - fn test_multisig_component_setup() { + fn test_guarded_multisig_component_setup() { // Create test secret keys let sec_key_1 = AuthSecretKey::new_falcon512_poseidon2(); let sec_key_2 = AuthSecretKey::new_falcon512_poseidon2(); let sec_key_3 = AuthSecretKey::new_falcon512_poseidon2(); - let psm_key = AuthSecretKey::new_ecdsa_k256_keccak(); + let guardian_key = AuthSecretKey::new_ecdsa_k256_keccak(); // Create approvers list for multisig config let approvers = vec![ @@ -363,18 +372,21 @@ mod tests { let threshold = 2u32; - // Create multisig component - let multisig_component = AuthMultisigPsm::new( - AuthMultisigPsmConfig::new( + // Create guarded multisig component. + let multisig_component = AuthGuardedMultisig::new( + AuthGuardedMultisigConfig::new( approvers.clone(), threshold, - PsmConfig::new(psm_key.public_key().to_commitment(), psm_key.auth_scheme()), + GuardianConfig::new( + guardian_key.public_key().to_commitment(), + guardian_key.auth_scheme(), + ), ) .expect("invalid multisig config"), ) - .expect("multisig component creation failed"); + .expect("guarded multisig component creation failed"); - // Build account with multisig component + // Build account with guarded multisig component. let account = AccountBuilder::new([0; 32]) .with_auth_component(multisig_component) .with_component(BasicWallet) @@ -384,7 +396,7 @@ mod tests { // Verify config slot: [threshold, num_approvers, 0, 0] let config_slot = account .storage() - .get_item(AuthMultisigPsm::threshold_config_slot()) + .get_item(AuthGuardedMultisig::threshold_config_slot()) .expect("config storage slot access failed"); assert_eq!(config_slot, Word::from([threshold, approvers.len() as u32, 0, 0])); @@ -393,7 +405,7 @@ mod tests { let stored_pub_key = account .storage() .get_map_item( - AuthMultisigPsm::approver_public_keys_slot(), + AuthGuardedMultisig::approver_public_keys_slot(), Word::from([i as u32, 0, 0, 0]), ) .expect("approver public key storage map access failed"); @@ -405,44 +417,53 @@ mod tests { let stored_scheme_id = account .storage() .get_map_item( - AuthMultisigPsm::approver_scheme_ids_slot(), + AuthGuardedMultisig::approver_scheme_ids_slot(), Word::from([i as u32, 0, 0, 0]), ) .expect("approver scheme ID storage map access failed"); assert_eq!(stored_scheme_id, Word::from([*expected_auth_scheme as u32, 0, 0, 0])); } - // Verify private state manager signer is configured. - let psm_public_key = account + // Verify guardian signer is configured. + let guardian_public_key = account .storage() - .get_map_item(AuthMultisigPsm::psm_public_key_slot(), Word::from([0u32, 0, 0, 0])) - .expect("private state manager public key storage map access failed"); - assert_eq!(psm_public_key, Word::from(psm_key.public_key().to_commitment())); + .get_map_item( + AuthGuardedMultisig::guardian_public_key_slot(), + Word::from([0u32, 0, 0, 0]), + ) + .expect("guardian public key storage map access failed"); + assert_eq!(guardian_public_key, Word::from(guardian_key.public_key().to_commitment())); - let psm_scheme_id = account + let guardian_scheme_id = account .storage() - .get_map_item(AuthMultisigPsm::psm_scheme_id_slot(), Word::from([0u32, 0, 0, 0])) - .expect("private state manager scheme ID storage map access failed"); - assert_eq!(psm_scheme_id, Word::from([psm_key.auth_scheme() as u32, 0, 0, 0])); + .get_map_item( + AuthGuardedMultisig::guardian_scheme_id_slot(), + Word::from([0u32, 0, 0, 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])); } - /// Test multisig component with minimum threshold (1 of 1) + /// Test guarded multisig component with minimum threshold (1 of 1). #[test] - fn test_multisig_component_minimum_threshold() { + fn test_guarded_multisig_component_minimum_threshold() { let pub_key = AuthSecretKey::new_ecdsa_k256_keccak().public_key().to_commitment(); - let psm_key = AuthSecretKey::new_falcon512_poseidon2(); + let guardian_key = AuthSecretKey::new_falcon512_poseidon2(); let approvers = vec![(pub_key, AuthScheme::EcdsaK256Keccak)]; let threshold = 1u32; - let multisig_component = AuthMultisigPsm::new( - AuthMultisigPsmConfig::new( + let multisig_component = AuthGuardedMultisig::new( + AuthGuardedMultisigConfig::new( approvers.clone(), threshold, - PsmConfig::new(psm_key.public_key().to_commitment(), psm_key.auth_scheme()), + GuardianConfig::new( + guardian_key.public_key().to_commitment(), + guardian_key.auth_scheme(), + ), ) .expect("invalid multisig config"), ) - .expect("multisig component creation failed"); + .expect("guarded multisig component creation failed"); let account = AccountBuilder::new([0; 32]) .with_auth_component(multisig_component) @@ -453,44 +474,53 @@ mod tests { // Verify storage layout let config_slot = account .storage() - .get_item(AuthMultisigPsm::threshold_config_slot()) + .get_item(AuthGuardedMultisig::threshold_config_slot()) .expect("config storage slot access failed"); assert_eq!(config_slot, Word::from([threshold, approvers.len() as u32, 0, 0])); let stored_pub_key = account .storage() - .get_map_item(AuthMultisigPsm::approver_public_keys_slot(), Word::from([0u32, 0, 0, 0])) + .get_map_item( + AuthGuardedMultisig::approver_public_keys_slot(), + Word::from([0u32, 0, 0, 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(AuthMultisigPsm::approver_scheme_ids_slot(), Word::from([0u32, 0, 0, 0])) + .get_map_item( + AuthGuardedMultisig::approver_scheme_ids_slot(), + Word::from([0u32, 0, 0, 0]), + ) .expect("approver scheme IDs storage map access failed"); assert_eq!(stored_scheme_id, Word::from([AuthScheme::EcdsaK256Keccak as u32, 0, 0, 0])); } - /// Test multisig component setup with a private state manager. + /// Test guarded multisig component setup with a guardian. #[test] - fn test_multisig_component_with_psm() { + fn test_guarded_multisig_component_with_guardian() { let sec_key_1 = AuthSecretKey::new_falcon512_poseidon2(); let sec_key_2 = AuthSecretKey::new_falcon512_poseidon2(); - let psm_key = AuthSecretKey::new_ecdsa_k256_keccak(); + let guardian_key = AuthSecretKey::new_ecdsa_k256_keccak(); let approvers = vec![ (sec_key_1.public_key().to_commitment(), sec_key_1.auth_scheme()), (sec_key_2.public_key().to_commitment(), sec_key_2.auth_scheme()), ]; - let multisig_component = AuthMultisigPsm::new( - AuthMultisigPsmConfig::new( + let multisig_component = AuthGuardedMultisig::new( + AuthGuardedMultisigConfig::new( approvers, 2, - PsmConfig::new(psm_key.public_key().to_commitment(), psm_key.auth_scheme()), + GuardianConfig::new( + guardian_key.public_key().to_commitment(), + guardian_key.auth_scheme(), + ), ) .expect("invalid multisig config"), ) - .expect("multisig component creation failed"); + .expect("guarded multisig component creation failed"); let account = AccountBuilder::new([0; 32]) .with_auth_component(multisig_component) @@ -498,31 +528,40 @@ mod tests { .build() .expect("account building failed"); - let psm_public_key = account + let guardian_public_key = account .storage() - .get_map_item(AuthMultisigPsm::psm_public_key_slot(), Word::from([0u32, 0, 0, 0])) - .expect("private state manager public key storage map access failed"); - assert_eq!(psm_public_key, Word::from(psm_key.public_key().to_commitment())); + .get_map_item( + AuthGuardedMultisig::guardian_public_key_slot(), + Word::from([0u32, 0, 0, 0]), + ) + .expect("guardian public key storage map access failed"); + assert_eq!(guardian_public_key, Word::from(guardian_key.public_key().to_commitment())); - let psm_scheme_id = account + let guardian_scheme_id = account .storage() - .get_map_item(AuthMultisigPsm::psm_scheme_id_slot(), Word::from([0u32, 0, 0, 0])) - .expect("private state manager scheme ID storage map access failed"); - assert_eq!(psm_scheme_id, Word::from([psm_key.auth_scheme() as u32, 0, 0, 0])); + .get_map_item( + AuthGuardedMultisig::guardian_scheme_id_slot(), + Word::from([0u32, 0, 0, 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])); } - /// Test multisig component error cases + /// Test guarded multisig component error cases. #[test] - fn test_multisig_component_error_cases() { + fn test_guarded_multisig_component_error_cases() { let pub_key = AuthSecretKey::new_ecdsa_k256_keccak().public_key().to_commitment(); - let psm_key = AuthSecretKey::new_falcon512_poseidon2(); + let guardian_key = AuthSecretKey::new_falcon512_poseidon2(); let approvers = vec![(pub_key, AuthScheme::EcdsaK256Keccak)]; // Test threshold > number of approvers (should fail) - let result = AuthMultisigPsmConfig::new( + let result = AuthGuardedMultisigConfig::new( approvers, 2, - PsmConfig::new(psm_key.public_key().to_commitment(), psm_key.auth_scheme()), + GuardianConfig::new( + guardian_key.public_key().to_commitment(), + guardian_key.auth_scheme(), + ), ); assert!( @@ -533,13 +572,13 @@ mod tests { ); } - /// Test multisig component with duplicate approvers (should fail) + /// Test guarded multisig component with duplicate approvers (should fail). #[test] - fn test_multisig_component_duplicate_approvers() { + fn test_guarded_multisig_component_duplicate_approvers() { // Create secret keys for approvers let sec_key_1 = AuthSecretKey::new_ecdsa_k256_keccak(); let sec_key_2 = AuthSecretKey::new_ecdsa_k256_keccak(); - let psm_key = AuthSecretKey::new_falcon512_poseidon2(); + let guardian_key = AuthSecretKey::new_falcon512_poseidon2(); // Create approvers list with duplicate public keys let approvers = vec![ @@ -548,10 +587,13 @@ mod tests { (sec_key_2.public_key().to_commitment(), sec_key_2.auth_scheme()), ]; - let result = AuthMultisigPsmConfig::new( + let result = AuthGuardedMultisigConfig::new( approvers, 2, - PsmConfig::new(psm_key.public_key().to_commitment(), psm_key.auth_scheme()), + GuardianConfig::new( + guardian_key.public_key().to_commitment(), + guardian_key.auth_scheme(), + ), ); assert!( result @@ -561,9 +603,9 @@ mod tests { ); } - /// Test multisig component rejects a private state manager key which is already an approver. + /// Test guarded multisig component rejects a guardian key which is already an approver. #[test] - fn test_multisig_component_psm_not_approver() { + fn test_guarded_multisig_component_guardian_not_approver() { let sec_key_1 = AuthSecretKey::new_ecdsa_k256_keccak(); let sec_key_2 = AuthSecretKey::new_ecdsa_k256_keccak(); @@ -572,17 +614,17 @@ mod tests { (sec_key_2.public_key().to_commitment(), sec_key_2.auth_scheme()), ]; - let result = AuthMultisigPsmConfig::new( + let result = AuthGuardedMultisigConfig::new( approvers, 2, - PsmConfig::new(sec_key_1.public_key().to_commitment(), sec_key_1.auth_scheme()), + GuardianConfig::new(sec_key_1.public_key().to_commitment(), sec_key_1.auth_scheme()), ); assert!( result .unwrap_err() .to_string() - .contains("private state manager public key must be different from approvers") + .contains("guardian public key must be different from approvers") ); } } diff --git a/crates/miden-standards/src/account/auth/mod.rs b/crates/miden-standards/src/account/auth/mod.rs index e999fab153..e6caf01607 100644 --- a/crates/miden-standards/src/account/auth/mod.rs +++ b/crates/miden-standards/src/account/auth/mod.rs @@ -10,5 +10,16 @@ pub use singlesig_acl::{AuthSingleSigAcl, AuthSingleSigAclConfig}; mod multisig; pub use multisig::{AuthMultisig, AuthMultisigConfig}; -mod multisig_psm; -pub use multisig_psm::{AuthMultisigPsm, AuthMultisigPsmConfig, PsmConfig}; +pub mod multisig_smart; +pub use multisig_smart::{AuthMultisigSmart, AuthMultisigSmartConfig}; + +mod guarded_multisig; +pub use guarded_multisig::{AuthGuardedMultisig, AuthGuardedMultisigConfig, GuardianConfig}; + +mod network_account; +pub use network_account::{ + AuthNetworkAccount, + NetworkAccount, + NetworkAccountNoteAllowlist, + NetworkAccountNoteAllowlistError, +}; diff --git a/crates/miden-standards/src/account/auth/multisig.rs b/crates/miden-standards/src/account/auth/multisig.rs index 196bb3de0c..fadbbbf407 100644 --- a/crates/miden-standards/src/account/auth/multisig.rs +++ b/crates/miden-standards/src/account/auth/multisig.rs @@ -4,6 +4,7 @@ use alloc::vec::Vec; use miden_protocol::Word; use miden_protocol::account::auth::{AuthScheme, PublicKeyCommitment}; use miden_protocol::account::component::{ + AccountComponentCode, AccountComponentMetadata, FeltSchema, SchemaType, @@ -12,7 +13,8 @@ use miden_protocol::account::component::{ }; use miden_protocol::account::{ AccountComponent, - AccountType, + AccountComponentName, + AccountProcedureRoot, StorageMap, StorageMapKey, StorageSlot, @@ -21,30 +23,33 @@ use miden_protocol::account::{ use miden_protocol::errors::AccountError; use miden_protocol::utils::sync::LazyLock; -use crate::account::components::multisig_library; +use crate::account::account_component_code; + +account_component_code!(MULTISIG_CODE, "auth/multisig.masl"); // CONSTANTS // ================================================================================================ -static THRESHOLD_CONFIG_SLOT_NAME: LazyLock = LazyLock::new(|| { +pub(super) static THRESHOLD_CONFIG_SLOT_NAME: LazyLock = LazyLock::new(|| { StorageSlotName::new("miden::standards::auth::multisig::threshold_config") .expect("storage slot name should be valid") }); -static APPROVER_PUBKEYS_SLOT_NAME: LazyLock = LazyLock::new(|| { +pub(super) static APPROVER_PUBKEYS_SLOT_NAME: LazyLock = LazyLock::new(|| { StorageSlotName::new("miden::standards::auth::multisig::approver_public_keys") .expect("storage slot name should be valid") }); -static APPROVER_SCHEME_ID_SLOT_NAME: LazyLock = LazyLock::new(|| { +pub(super) static APPROVER_SCHEME_ID_SLOT_NAME: LazyLock = LazyLock::new(|| { StorageSlotName::new("miden::standards::auth::multisig::approver_schemes") .expect("storage slot name should be valid") }); -static EXECUTED_TRANSACTIONS_SLOT_NAME: LazyLock = LazyLock::new(|| { - StorageSlotName::new("miden::standards::auth::multisig::executed_transactions") - .expect("storage slot name should be valid") -}); +pub(super) static EXECUTED_TRANSACTIONS_SLOT_NAME: LazyLock = + LazyLock::new(|| { + StorageSlotName::new("miden::standards::auth::multisig::executed_transactions") + .expect("storage slot name should be valid") + }); static PROCEDURE_THRESHOLDS_SLOT_NAME: LazyLock = LazyLock::new(|| { StorageSlotName::new("miden::standards::auth::multisig::procedure_thresholds") @@ -59,7 +64,7 @@ static PROCEDURE_THRESHOLDS_SLOT_NAME: LazyLock = LazyLock::new pub struct AuthMultisigConfig { approvers: Vec<(PublicKeyCommitment, AuthScheme)>, default_threshold: u32, - proc_thresholds: Vec<(Word, u32)>, + proc_thresholds: Vec<(AccountProcedureRoot, u32)>, } impl AuthMultisigConfig { @@ -97,7 +102,7 @@ impl AuthMultisigConfig { /// at most the number of approvers. pub fn with_proc_thresholds( mut self, - proc_thresholds: Vec<(Word, u32)>, + proc_thresholds: Vec<(AccountProcedureRoot, u32)>, ) -> Result { for (_, threshold) in &proc_thresholds { if *threshold == 0 { @@ -121,7 +126,7 @@ impl AuthMultisigConfig { self.default_threshold } - pub fn proc_thresholds(&self) -> &[(Word, u32)] { + pub fn proc_thresholds(&self) -> &[(AccountProcedureRoot, u32)] { &self.proc_thresholds } } @@ -130,11 +135,9 @@ impl AuthMultisigConfig { /// /// It enforces a threshold of approver signatures for every transaction, with optional /// per-procedure threshold overrides. Non-uniform thresholds (especially a threshold of one) -/// should be used with caution for private multisig accounts, without Private State Manager (PSM), -/// a single approver may advance state and withhold updates from other approvers, effectively -/// locking them out. -/// -/// This component supports all account types. +/// should be used with caution for private multisig accounts; without a guardian, a single +/// approver may advance state and withhold updates from other approvers, effectively locking +/// them out. #[derive(Debug)] pub struct AuthMultisig { config: AuthMultisigConfig, @@ -144,6 +147,16 @@ impl AuthMultisig { /// The name of the component. pub const NAME: &'static str = "miden::standards::components::auth::multisig"; + /// 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 { + &MULTISIG_CODE + } + /// Creates a new [`AuthMultisig`] component from the provided configuration. pub fn new(config: AuthMultisigConfig) -> Result { Ok(Self { config }) @@ -249,7 +262,7 @@ impl AuthMultisig { ]) .expect("storage schema should be valid"); - AccountComponentMetadata::new(Self::NAME, AccountType::all()) + AccountComponentMetadata::new(Self::NAME) .with_description("Multisig authentication component using hybrid signature schemes") .with_storage_schema(storage_schema) } @@ -299,7 +312,7 @@ impl From for AccountComponent { // Procedure thresholds slot (map: PROC_ROOT -> threshold) let proc_threshold_roots = StorageMap::with_entries( multisig.config.proc_thresholds().iter().map(|(proc_root, threshold)| { - (StorageMapKey::from_raw(*proc_root), Word::from([*threshold, 0, 0, 0])) + (StorageMapKey::from_raw(proc_root.as_word()), Word::from([*threshold, 0, 0, 0])) }), ) .unwrap(); @@ -310,7 +323,7 @@ impl From for AccountComponent { let metadata = AuthMultisig::component_metadata(); - AccountComponent::new(multisig_library(), storage_slots, metadata).expect( + AccountComponent::new(AuthMultisig::code().clone(), storage_slots, metadata).expect( "Multisig auth component should satisfy the requirements of a valid account component", ) } diff --git a/crates/miden-standards/src/account/auth/multisig_smart/component.rs b/crates/miden-standards/src/account/auth/multisig_smart/component.rs new file mode 100644 index 0000000000..1e1b9e8a79 --- /dev/null +++ b/crates/miden-standards/src/account/auth/multisig_smart/component.rs @@ -0,0 +1,408 @@ +use alloc::vec::Vec; + +use miden_protocol::Word; +use miden_protocol::account::auth::{AuthScheme, PublicKeyCommitment}; +use miden_protocol::account::component::{ + AccountComponentCode, + AccountComponentMetadata, + SchemaType, + StorageSchema, + StorageSlotSchema, +}; +use miden_protocol::account::{ + AccountComponent, + StorageMap, + StorageMapKey, + StorageSlot, + StorageSlotName, +}; +use miden_protocol::errors::AccountError; +use miden_protocol::utils::sync::LazyLock; + +// Slots and schemas reused from `AuthMultisig` to keep the storage layout in sync. The statics +// are exposed as `pub(super)` in the sibling `multisig` module; we reference them directly so +// the sharing is visible at the use site rather than hidden behind delegating methods. +use super::super::multisig::{ + APPROVER_PUBKEYS_SLOT_NAME, + APPROVER_SCHEME_ID_SLOT_NAME, + EXECUTED_TRANSACTIONS_SLOT_NAME, + THRESHOLD_CONFIG_SLOT_NAME, +}; +use super::ProcedurePolicy; +use crate::account::account_component_code; +use crate::account::auth::AuthMultisig; + +account_component_code!(MULTISIG_SMART_CODE, "auth/multisig_smart.masl"); + +// CONSTANTS +// ================================================================================================ + +// Only the smart-specific procedure_policies slot needs its own constant here. The other four +// slots (threshold config, approver public keys, approver scheme ids, executed transactions) are +// reused from `AuthMultisig` via the imports above. +static PROCEDURE_POLICIES_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::auth::multisig_smart::procedure_policies") + .expect("storage slot name should be valid") +}); + +// MULTISIG SMART AUTHENTICATION COMPONENT +// ================================================================================================ + +/// Configuration for [`AuthMultisigSmart`] component. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthMultisigSmartConfig { + approvers: Vec<(PublicKeyCommitment, AuthScheme)>, + default_threshold: u32, + procedure_policies: Vec<(Word, ProcedurePolicy)>, +} + +impl AuthMultisigSmartConfig { + /// Creates a new configuration with the given approvers and a default threshold. + /// + /// The `default_threshold` must be at least 1 and at most the number of approvers. + pub fn new( + approvers: Vec<(PublicKeyCommitment, AuthScheme)>, + default_threshold: u32, + ) -> Result { + if default_threshold == 0 { + return Err(AccountError::other("threshold must be at least 1")); + } + if default_threshold > approvers.len() as u32 { + return Err(AccountError::other( + "threshold cannot be greater than number of approvers", + )); + } + + let unique_approvers: alloc::collections::BTreeSet<_> = + approvers.iter().map(|(pk, _)| pk).collect(); + if unique_approvers.len() != approvers.len() { + return Err(AccountError::other("duplicate approver public keys are not allowed")); + } + + Ok(Self { + approvers, + default_threshold, + procedure_policies: vec![], + }) + } + + /// Attaches a per-procedure smart policy map. + pub fn with_proc_policies( + mut self, + proc_policies: Vec<(Word, ProcedurePolicy)>, + ) -> Result { + validate_proc_policies(self.approvers.len() as u32, &proc_policies)?; + self.procedure_policies = proc_policies; + Ok(self) + } + + pub fn approvers(&self) -> &[(PublicKeyCommitment, AuthScheme)] { + &self.approvers + } + + pub fn default_threshold(&self) -> u32 { + self.default_threshold + } + + pub fn procedure_policies(&self) -> &[(Word, ProcedurePolicy)] { + &self.procedure_policies + } +} + +fn validate_proc_policies( + num_approvers: u32, + proc_policies: &[(Word, ProcedurePolicy)], +) -> Result<(), AccountError> { + // Reject duplicate procedure roots. Catching it here turns the failure into a regular + // `AccountError` returned from `with_proc_policies` / `AuthMultisigSmart::new`. + let mut policy_roots = alloc::collections::BTreeSet::new(); + for (proc_root, _) in proc_policies { + if !policy_roots.insert(*proc_root) { + return Err(AccountError::other( + "duplicate procedure roots are not allowed in the procedure policy map", + )); + } + } + + for (_, policy) in proc_policies { + if let Some(immediate_threshold) = policy.immediate_threshold() + && immediate_threshold > num_approvers + { + return Err(AccountError::other( + "procedure policy immediate threshold cannot exceed number of approvers", + )); + } + if let Some(delay_threshold) = policy.delay_threshold() + && delay_threshold > num_approvers + { + return Err(AccountError::other( + "procedure policy delay threshold cannot exceed number of approvers", + )); + } + } + + Ok(()) +} + +/// An [`AccountComponent`] implementing a multisig auth component with smart-policy slots. +#[derive(Debug)] +pub struct AuthMultisigSmart { + config: AuthMultisigSmartConfig, +} + +impl AuthMultisigSmart { + /// The name of the component. + pub const NAME: &'static str = "miden::standards::components::auth::multisig_smart"; + + /// Returns the [`AccountComponentCode`] of this component. + pub fn code() -> &'static AccountComponentCode { + &MULTISIG_SMART_CODE + } + + /// Creates a new [`AuthMultisigSmart`] component from the provided configuration. + pub fn new(config: AuthMultisigSmartConfig) -> Result { + validate_proc_policies(config.approvers().len() as u32, config.procedure_policies())?; + Ok(Self { config }) + } + + pub fn threshold_config_slot() -> &'static StorageSlotName { + &THRESHOLD_CONFIG_SLOT_NAME + } + + pub fn approver_public_keys_slot() -> &'static StorageSlotName { + &APPROVER_PUBKEYS_SLOT_NAME + } + + pub fn approver_scheme_ids_slot() -> &'static StorageSlotName { + &APPROVER_SCHEME_ID_SLOT_NAME + } + + pub fn executed_transactions_slot() -> &'static StorageSlotName { + &EXECUTED_TRANSACTIONS_SLOT_NAME + } + + pub fn procedure_policies_slot() -> &'static StorageSlotName { + &PROCEDURE_POLICIES_SLOT_NAME + } + + pub fn threshold_config_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + AuthMultisig::threshold_config_slot_schema() + } + + pub fn approver_public_keys_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + AuthMultisig::approver_public_keys_slot_schema() + } + + pub fn approver_auth_scheme_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + AuthMultisig::approver_auth_scheme_slot_schema() + } + + pub fn executed_transactions_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + AuthMultisig::executed_transactions_slot_schema() + } + + pub fn procedure_policies_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::procedure_policies_slot().clone(), + StorageSlotSchema::map( + "Procedure policies", + SchemaType::native_word(), + SchemaType::native_word(), + ), + ) + } +} + +impl From for AccountComponent { + fn from(multisig: AuthMultisigSmart) -> Self { + let mut storage_slots = Vec::with_capacity(5); + + // Threshold config slot (value: [threshold, num_approvers, 0, 0]) + let num_approvers = multisig.config.approvers().len() as u32; + storage_slots.push(StorageSlot::with_value( + AuthMultisigSmart::threshold_config_slot().clone(), + Word::from([multisig.config.default_threshold(), num_approvers, 0, 0]), + )); + + // Approver public keys slot (map) + let map_entries = + multisig.config.approvers().iter().enumerate().map(|(i, (pub_key, _))| { + (StorageMapKey::from_index(i as u32), Word::from(*pub_key)) + }); + storage_slots.push(StorageSlot::with_map( + AuthMultisigSmart::approver_public_keys_slot().clone(), + StorageMap::with_entries(map_entries).unwrap(), + )); + + // Approver scheme IDs slot + let scheme_id_entries = + multisig.config.approvers().iter().enumerate().map(|(i, (_, auth_scheme))| { + (StorageMapKey::from_index(i as u32), Word::from([*auth_scheme as u32, 0, 0, 0])) + }); + storage_slots.push(StorageSlot::with_map( + AuthMultisigSmart::approver_scheme_ids_slot().clone(), + StorageMap::with_entries(scheme_id_entries).unwrap(), + )); + + // Executed transactions slot (map) + storage_slots.push(StorageSlot::with_map( + AuthMultisigSmart::executed_transactions_slot().clone(), + StorageMap::default(), + )); + + // Procedure policies slot (map) + let procedure_policies = + StorageMap::with_entries(multisig.config.procedure_policies().iter().map( + |(proc_root, policy)| (StorageMapKey::from_raw(*proc_root), policy.to_word()), + )) + .unwrap(); + storage_slots.push(StorageSlot::with_map( + AuthMultisigSmart::procedure_policies_slot().clone(), + procedure_policies, + )); + + let storage_schema = StorageSchema::new(vec![ + AuthMultisigSmart::threshold_config_slot_schema(), + AuthMultisigSmart::approver_public_keys_slot_schema(), + AuthMultisigSmart::approver_auth_scheme_slot_schema(), + AuthMultisigSmart::executed_transactions_slot_schema(), + AuthMultisigSmart::procedure_policies_slot_schema(), + ]) + .expect("storage schema should be valid"); + + let metadata = AccountComponentMetadata::new(AuthMultisigSmart::NAME) + .with_description("Multisig smart authentication component") + .with_storage_schema(storage_schema); + + AccountComponent::new(AuthMultisigSmart::code().clone(), storage_slots, metadata).expect( + "multisig smart component should satisfy the requirements of a valid account component", + ) + } +} + +#[cfg(test)] +mod tests { + use alloc::string::ToString; + + use miden_protocol::account::AccountBuilder; + use miden_protocol::account::auth::AuthSecretKey; + + use super::*; + use crate::account::wallets::BasicWallet; + + #[test] + fn test_multisig_smart_component_setup() { + let sec_key_1 = AuthSecretKey::new_ecdsa_k256_keccak(); + let sec_key_2 = AuthSecretKey::new_ecdsa_k256_keccak(); + let approvers = vec![ + (sec_key_1.public_key().to_commitment(), sec_key_1.auth_scheme()), + (sec_key_2.public_key().to_commitment(), sec_key_2.auth_scheme()), + ]; + let num_approvers = approvers.len() as u32; + let default_threshold = 2u32; + let receive_asset_immediate_threshold = 1u32; + + let config = AuthMultisigSmartConfig::new(approvers.clone(), default_threshold) + .expect("invalid multisig smart config") + .with_proc_policies(vec![( + BasicWallet::receive_asset_root().as_word(), + ProcedurePolicy::with_immediate_threshold(receive_asset_immediate_threshold) + .expect("procedure policy should be valid"), + )]) + .expect("procedure policy config should be valid"); + + let component = + AuthMultisigSmart::new(config).expect("multisig smart component creation failed"); + + let account = AccountBuilder::new([0; 32]) + .with_auth_component(component) + .with_component(BasicWallet) + .build() + .expect("account building failed"); + + let threshold_config = account + .storage() + .get_item(AuthMultisigSmart::threshold_config_slot()) + .expect("threshold config should be present"); + assert_eq!(threshold_config, Word::from([default_threshold, num_approvers, 0, 0])); + + let receive_asset_policy = account + .storage() + .get_map_item( + AuthMultisigSmart::procedure_policies_slot(), + BasicWallet::receive_asset_root().as_word(), + ) + .expect("receive_asset policy should be present"); + assert_eq!( + receive_asset_policy, + Word::from([receive_asset_immediate_threshold, 0u32, 0u32, 0u32]) + ); + } + + #[test] + fn test_multisig_smart_component_error_cases() { + let sec_key = AuthSecretKey::new_ecdsa_k256_keccak(); + let approvers = vec![(sec_key.public_key().to_commitment(), sec_key.auth_scheme())]; + + let result = AuthMultisigSmartConfig::new(approvers.clone(), 0); + assert!(result.unwrap_err().to_string().contains("threshold must be at least 1")); + + let result = AuthMultisigSmartConfig::new(approvers, 2); + assert!( + result + .unwrap_err() + .to_string() + .contains("threshold cannot be greater than number of approvers") + ); + } + + #[test] + fn test_multisig_smart_component_rejects_duplicate_procedure_roots() { + let sec_key_1 = AuthSecretKey::new_ecdsa_k256_keccak(); + let sec_key_2 = AuthSecretKey::new_ecdsa_k256_keccak(); + let approvers = vec![ + (sec_key_1.public_key().to_commitment(), sec_key_1.auth_scheme()), + (sec_key_2.public_key().to_commitment(), sec_key_2.auth_scheme()), + ]; + + let receive_asset_root = BasicWallet::receive_asset_root().as_word(); + let policy_one = + ProcedurePolicy::with_immediate_threshold(1).expect("procedure policy should be valid"); + let policy_two = + ProcedurePolicy::with_immediate_threshold(2).expect("procedure policy should be valid"); + + let result = AuthMultisigSmartConfig::new(approvers, 2) + .expect("base config should be valid") + .with_proc_policies(vec![ + (receive_asset_root, policy_one), + (receive_asset_root, policy_two), + ]); + + assert!( + result + .unwrap_err() + .to_string() + .contains("duplicate procedure roots are not allowed in the procedure policy map") + ); + } + + #[test] + fn test_multisig_smart_component_duplicate_approvers() { + let sec_key_1 = AuthSecretKey::new_ecdsa_k256_keccak(); + let sec_key_2 = AuthSecretKey::new_ecdsa_k256_keccak(); + + let approvers = vec![ + (sec_key_1.public_key().to_commitment(), sec_key_1.auth_scheme()), + (sec_key_1.public_key().to_commitment(), sec_key_1.auth_scheme()), + (sec_key_2.public_key().to_commitment(), sec_key_2.auth_scheme()), + ]; + + let result = AuthMultisigSmartConfig::new(approvers, 2); + assert!( + result + .unwrap_err() + .to_string() + .contains("duplicate approver public keys are not allowed") + ); + } +} diff --git a/crates/miden-standards/src/account/auth/multisig_smart/mod.rs b/crates/miden-standards/src/account/auth/multisig_smart/mod.rs new file mode 100644 index 0000000000..8a3b7e705b --- /dev/null +++ b/crates/miden-standards/src/account/auth/multisig_smart/mod.rs @@ -0,0 +1,9 @@ +mod component; +mod procedure_policies; + +pub use component::{AuthMultisigSmart, AuthMultisigSmartConfig}; +pub use procedure_policies::{ + ProcedurePolicy, + ProcedurePolicyExecutionMode, + ProcedurePolicyNoteRestriction, +}; diff --git a/crates/miden-standards/src/account/auth/multisig_smart/procedure_policies.rs b/crates/miden-standards/src/account/auth/multisig_smart/procedure_policies.rs new file mode 100644 index 0000000000..c83245cc0a --- /dev/null +++ b/crates/miden-standards/src/account/auth/multisig_smart/procedure_policies.rs @@ -0,0 +1,246 @@ +use miden_protocol::Word; +use miden_protocol::errors::AccountError; + +/// Defines which execution modes a procedure policy supports and the corresponding threshold +/// values for each mode. +/// +/// A procedure can require the immediate threshold, the delayed threshold, or support both. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProcedurePolicyExecutionMode { + ImmediateOnly { + immediate_threshold: u32, + }, + DelayOnly { + delay_threshold: u32, + }, + ImmediateOrDelay { + immediate_threshold: u32, + delay_threshold: u32, + }, +} + +/// Note Restrictions on whether transactions that call a procedure may consume input notes +/// or create output notes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[repr(u8)] +pub enum ProcedurePolicyNoteRestriction { + #[default] + None = 0, + NoInputNotes = 1, + NoOutputNotes = 2, + NoInputOrOutputNotes = 3, +} + +/// Defines a per-procedure multisig policy. +/// +/// A procedure policy can override the default multisig requirements for a specific procedure. +/// It specifies: +/// - an execution mode, which determines whether the procedure can be executed immediately, after a +/// delay, or both +/// - note restrictions, which limit whether a transaction invoking the procedure may consume input +/// notes or create output notes +/// +/// Execution modes: +/// - Immediate execution: the action is authorized and executed within the current transaction. +/// - Delayed execution: the action is proposed first, and can only be executed after a required +/// time delay has elapsed. +/// +/// Thresholds: +/// - Immediate threshold: the number of signatures required to authorize immediate execution. +/// - Delayed threshold: the number of signatures required to authorize a delayed action. +/// +/// The thresholds for immediate and delayed execution may differ. +/// +/// The policy is encoded into the procedure-policy storage word as: +/// `[immediate_threshold, delayed_threshold, note_restrictions, 0]`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProcedurePolicy { + execution_mode: ProcedurePolicyExecutionMode, + note_restrictions: ProcedurePolicyNoteRestriction, +} + +impl ProcedurePolicy { + /// Creates an explicit procedure policy from an execution mode and note restriction pair. + /// + /// Common multisig cases should generally prefer the `with_*_threshold...` helpers and + /// configure note restrictions afterwards via [`ProcedurePolicy::with_note_restriction`]. + pub fn new( + execution_mode: ProcedurePolicyExecutionMode, + note_restrictions: ProcedurePolicyNoteRestriction, + ) -> Result { + Self::validate_execution_mode(execution_mode)?; + Ok(Self { execution_mode, note_restrictions }) + } + + pub fn with_immediate_threshold(immediate_threshold: u32) -> Result { + Self::new( + ProcedurePolicyExecutionMode::ImmediateOnly { immediate_threshold }, + ProcedurePolicyNoteRestriction::None, + ) + } + + pub fn with_delay_threshold(delay_threshold: u32) -> Result { + Self::new( + ProcedurePolicyExecutionMode::DelayOnly { delay_threshold }, + ProcedurePolicyNoteRestriction::None, + ) + } + + pub fn with_immediate_and_delay_thresholds( + immediate_threshold: u32, + delay_threshold: u32, + ) -> Result { + Self::new( + ProcedurePolicyExecutionMode::ImmediateOrDelay { immediate_threshold, delay_threshold }, + ProcedurePolicyNoteRestriction::None, + ) + } + + pub const fn with_note_restriction( + mut self, + note_restrictions: ProcedurePolicyNoteRestriction, + ) -> Self { + self.note_restrictions = note_restrictions; + self + } + + pub const fn execution_mode(&self) -> ProcedurePolicyExecutionMode { + self.execution_mode + } + + pub const fn note_restrictions(&self) -> ProcedurePolicyNoteRestriction { + self.note_restrictions + } + + pub const fn immediate_threshold(&self) -> Option { + match self.execution_mode { + ProcedurePolicyExecutionMode::ImmediateOnly { immediate_threshold } => { + Some(immediate_threshold) + }, + ProcedurePolicyExecutionMode::DelayOnly { .. } => None, + ProcedurePolicyExecutionMode::ImmediateOrDelay { immediate_threshold, .. } => { + Some(immediate_threshold) + }, + } + } + + pub const fn delay_threshold(&self) -> Option { + match self.execution_mode { + ProcedurePolicyExecutionMode::ImmediateOnly { .. } => None, + ProcedurePolicyExecutionMode::DelayOnly { delay_threshold } => Some(delay_threshold), + ProcedurePolicyExecutionMode::ImmediateOrDelay { delay_threshold, .. } => { + Some(delay_threshold) + }, + } + } + + fn validate_execution_mode( + execution_mode: ProcedurePolicyExecutionMode, + ) -> Result<(), AccountError> { + match execution_mode { + ProcedurePolicyExecutionMode::ImmediateOnly { immediate_threshold } => { + if immediate_threshold == 0 { + return Err(AccountError::other( + "procedure policy immediate threshold must be at least 1", + )); + } + }, + ProcedurePolicyExecutionMode::DelayOnly { delay_threshold } => { + if delay_threshold == 0 { + return Err(AccountError::other( + "procedure policy delay threshold must be at least 1", + )); + } + }, + ProcedurePolicyExecutionMode::ImmediateOrDelay { + immediate_threshold, + delay_threshold, + } => { + if immediate_threshold == 0 || delay_threshold == 0 { + return Err(AccountError::other( + "immediate and delayed thresholds must both be at least 1", + )); + } + // Delayed execution is the lower-quorum option while immediate execution is + // higher-quorum path. If the delay threshold were greater than the + // immediate threshold, the "fast" path would be easier to satisfy + // than the delayed path, which contradicts that model. + if delay_threshold > immediate_threshold { + return Err(AccountError::other( + "delay threshold cannot exceed immediate threshold", + )); + } + }, + } + + Ok(()) + } + + pub fn to_word(self) -> Word { + let immediate_threshold = self.immediate_threshold().unwrap_or(0); + let delay_threshold = self.delay_threshold().unwrap_or(0); + + Word::from([immediate_threshold, delay_threshold, self.note_restrictions as u32, 0]) + } +} + +#[cfg(test)] +mod tests { + use alloc::string::ToString; + + use super::{ProcedurePolicy, ProcedurePolicyNoteRestriction}; + + #[test] + fn procedure_policy_word_encoding_matches_storage_layout() { + let policy = ProcedurePolicy::with_immediate_and_delay_thresholds(4, 3) + .unwrap() + .with_note_restriction(ProcedurePolicyNoteRestriction::NoInputOrOutputNotes); + + assert_eq!(policy.to_word(), [4u32, 3, 3, 0].into()); + } + + #[test] + fn procedure_policy_construction_rejects_invalid_combinations() { + assert!( + ProcedurePolicy::with_immediate_threshold(0) + .unwrap_err() + .to_string() + .contains("procedure policy immediate threshold must be at least 1") + ); + + assert!( + ProcedurePolicy::with_immediate_and_delay_thresholds(1, 0) + .unwrap_err() + .to_string() + .contains("immediate and delayed thresholds must both be at least 1") + ); + + assert!( + ProcedurePolicy::with_immediate_and_delay_thresholds(1, 2) + .unwrap_err() + .to_string() + .contains("delay threshold cannot exceed immediate threshold") + ); + } + + #[test] + fn procedure_policy_thresholds_are_exposed_with_getters() { + let procedure_policy = ProcedurePolicy::with_delay_threshold(2).unwrap(); + + assert_eq!(procedure_policy.immediate_threshold(), None); + assert_eq!(procedure_policy.delay_threshold(), Some(2)); + } + + #[test] + fn procedure_policy_note_restrictions_are_exposed_with_getters() { + let procedure_policy = ProcedurePolicy::with_immediate_threshold(2) + .unwrap() + .with_note_restriction(ProcedurePolicyNoteRestriction::NoInputNotes); + + assert_eq!(ProcedurePolicyNoteRestriction::default(), ProcedurePolicyNoteRestriction::None); + assert_eq!( + procedure_policy.note_restrictions(), + ProcedurePolicyNoteRestriction::NoInputNotes + ); + } +} 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 new file mode 100644 index 0000000000..324c4efbbf --- /dev/null +++ b/crates/miden-standards/src/account/auth/network_account/auth_network_account.rs @@ -0,0 +1,154 @@ +use alloc::collections::BTreeSet; +use alloc::vec; + +use miden_protocol::account::component::{ + AccountComponentCode, + AccountComponentMetadata, + StorageSchema, + StorageSlotSchema, +}; +use miden_protocol::account::{AccountComponent, AccountComponentName, StorageSlotName}; +use miden_protocol::note::NoteScriptRoot; + +use super::{NetworkAccountNoteAllowlist, NetworkAccountNoteAllowlistError}; +use crate::account::account_component_code; + +account_component_code!(NETWORK_ACCOUNT_AUTH_CODE, "auth/network_account.masl"); + +// AUTH NETWORK ACCOUNT +// ================================================================================================ + +/// 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. +/// +/// 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. +/// +/// 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 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. +pub struct AuthNetworkAccount { + allowlist: NetworkAccountNoteAllowlist, +} + +impl AuthNetworkAccount { + /// The name of the component. + pub const NAME: &'static str = "miden::standards::auth::network_account"; + + /// 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 { + &NETWORK_ACCOUNT_AUTH_CODE + } + + /// Creates a new [`AuthNetworkAccount`] component with the provided list of allowed + /// input-note script roots. + /// + /// # Errors + /// + /// Returns an error if `allowed_script_roots` is empty since the account could not consume any + /// notes. + pub fn with_allowlist( + allowed_script_roots: BTreeSet, + ) -> Result { + Ok(Self { + allowlist: NetworkAccountNoteAllowlist::new(allowed_script_roots)?, + }) + } + + /// 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. + pub fn allowed_note_scripts_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + NetworkAccountNoteAllowlist::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"); + + AccountComponentMetadata::new(Self::NAME) + .with_description( + "Authentication component that restricts input notes to a fixed allowlist of \ + note script roots and forbids tx scripts", + ) + .with_storage_schema(storage_schema) + } +} + +impl From for AccountComponent { + fn from(component: AuthNetworkAccount) -> Self { + let storage_slots = vec![component.allowlist.into_storage_slot()]; + let metadata = AuthNetworkAccount::component_metadata(); + + AccountComponent::new(AuthNetworkAccount::code().clone(), storage_slots, metadata).expect( + "AuthNetworkAccount component should satisfy the requirements of a valid \ + account component", + ) + } +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use miden_protocol::account::{AccountBuilder, StorageSlotContent}; + + use super::*; + use crate::account::wallets::BasicWallet; + + #[test] + fn auth_network_account_component_builds() { + let root_a = NoteScriptRoot::from_array([1, 2, 3, 4]); + let root_b = NoteScriptRoot::from_array([5, 6, 7, 8]); + + let _account = AccountBuilder::new([0; 32]) + .with_auth_component( + AuthNetworkAccount::with_allowlist(BTreeSet::from_iter([root_a, root_b])) + .expect("non-empty allowlist should construct"), + ) + .with_component(BasicWallet) + .build() + .expect("account building with AuthNetworkAccount failed"); + } + + #[test] + fn auth_network_account_with_empty_allowlist_is_rejected() { + let result = AuthNetworkAccount::with_allowlist(BTreeSet::new()); + assert!(matches!(result, Err(NetworkAccountNoteAllowlistError::EmptyAllowlist))); + } + + #[test] + 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])) + .expect("non-empty allowlist should construct") + .into(); + + let storage_slots = component.storage_slots(); + assert_eq!(storage_slots.len(), 1); + assert_eq!(storage_slots[0].name(), NetworkAccountNoteAllowlist::slot_name()); + + let StorageSlotContent::Map(_) = storage_slots[0].content() else { + panic!("allowlist slot must be a map"); + }; + } +} diff --git a/crates/miden-standards/src/account/auth/network_account/mod.rs b/crates/miden-standards/src/account/auth/network_account/mod.rs new file mode 100644 index 0000000000..c3c773ca2a --- /dev/null +++ b/crates/miden-standards/src/account/auth/network_account/mod.rs @@ -0,0 +1,9 @@ +mod auth_network_account; +pub use auth_network_account::AuthNetworkAccount; + +#[allow(clippy::module_inception)] +mod network_account; +pub use network_account::NetworkAccount; + +mod note_allowlist; +pub use note_allowlist::{NetworkAccountNoteAllowlist, NetworkAccountNoteAllowlistError}; 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 new file mode 100644 index 0000000000..dff81eae76 --- /dev/null +++ b/crates/miden-standards/src/account/auth/network_account/network_account.rs @@ -0,0 +1,148 @@ +use miden_protocol::account::{Account, AccountId, AccountStorage}; + +use crate::account::auth::network_account::{ + NetworkAccountNoteAllowlist, + NetworkAccountNoteAllowlistError, +}; + +// NETWORK ACCOUNT +// ================================================================================================ + +/// A wrapper around an [`Account`] that is guaranteed to be a network account. +/// +/// # Specification +/// +/// An [`Account`] is a network account if and only if all of the following hold: +/// +/// - It MUST be public, i.e. [`Account::is_public`] returns `true`. The network needs to read +/// account storage to identify the account and route notes to it, so private accounts cannot be +/// network accounts. +/// - Its storage MUST contain a valid [`NetworkAccountNoteAllowlist`] slot. Concretely: +/// - the storage slot named [`NetworkAccountNoteAllowlist::slot_name`] MUST be present, +/// - the slot MUST be a [`StorageMap`](miden_protocol::account::StorageMap) (not a value slot), +/// - the map MUST be non-empty (the allowlist contains at least one allowed +/// [`NoteScriptRoot`](miden_protocol::note::NoteScriptRoot)). +/// +/// The allowlist slot is the shared abstraction across every network-account component, so +/// off-chain services can identify a network account by inspecting its storage for this slot +/// without needing to know which specific component the account uses. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NetworkAccount { + account: Account, + allowlist: NetworkAccountNoteAllowlist, +} + +impl NetworkAccount { + /// Attempts to construct a [`NetworkAccount`] from `account`. + /// + /// Returns an error if: + /// - the account is not [`public`](Account::is_public), or + /// - the account's storage does not contain a valid [`NetworkAccountNoteAllowlist`] slot (see + /// [`NetworkAccountNoteAllowlist::try_from`] for the exact storage-level checks). + pub fn new(account: Account) -> Result { + if !account.is_public() { + return Err(NetworkAccountNoteAllowlistError::AccountNotPublic(account.id())); + } + + let allowlist = NetworkAccountNoteAllowlist::try_from(account.storage())?; + + Ok(Self { account, allowlist }) + } + + /// Consumes `self` and returns the underlying [`Account`]. + pub fn into_account(self) -> Account { + self.account + } + + /// Returns a reference to the underlying [`Account`]. + pub fn as_account(&self) -> &Account { + &self.account + } + + /// Returns the [`AccountId`] of the underlying account. + pub fn id(&self) -> AccountId { + self.account.id() + } + + /// Returns a reference to the [`AccountStorage`] of the underlying account. + pub fn storage(&self) -> &AccountStorage { + self.account.storage() + } + + /// Returns the [`NetworkAccountNoteAllowlist`] decoded from the underlying account's storage. + pub fn allowed_notes(&self) -> &NetworkAccountNoteAllowlist { + &self.allowlist + } +} + +impl TryFrom for NetworkAccount { + type Error = NetworkAccountNoteAllowlistError; + + fn try_from(account: Account) -> Result { + Self::new(account) + } +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use alloc::collections::BTreeSet; + + use miden_protocol::account::{AccountBuilder, AccountType}; + use miden_protocol::note::NoteScriptRoot; + + use super::*; + use crate::account::auth::network_account::AuthNetworkAccount; + use crate::account::wallets::BasicWallet; + + fn build_account(account_type: AccountType, roots: BTreeSet) -> Account { + AccountBuilder::new([0; 32]) + .account_type(account_type) + .with_auth_component( + AuthNetworkAccount::with_allowlist(roots).expect("non-empty allowlist"), + ) + .with_component(BasicWallet) + .build() + .expect("account building should succeed") + } + + #[test] + fn public_account_with_allowlist_is_a_network_account() { + let root = NoteScriptRoot::from_array([1, 2, 3, 4]); + let roots = BTreeSet::from_iter([root]); + let account = build_account(AccountType::Public, roots.clone()); + + let network_account = NetworkAccount::new(account).expect("should be a network account"); + let actual: BTreeSet = + network_account.allowed_notes().allowed_script_roots().iter().copied().collect(); + assert_eq!(actual, roots); + } + + #[test] + fn private_account_is_rejected_even_with_allowlist() { + let root = NoteScriptRoot::from_array([1, 2, 3, 4]); + let account = build_account(AccountType::Private, BTreeSet::from_iter([root])); + + let id = account.id(); + let err = NetworkAccount::new(account).expect_err("private account must be rejected"); + assert!(matches!( + err, + NetworkAccountNoteAllowlistError::AccountNotPublic(account_id) if account_id == id + )); + } + + #[test] + fn public_account_without_allowlist_is_not_a_network_account() { + let account = AccountBuilder::new([0; 32]) + .account_type(AccountType::Public) + .with_auth_component(crate::account::auth::NoAuth) + .with_component(BasicWallet) + .build() + .expect("account building should succeed"); + + let err = NetworkAccount::new(account).expect_err("missing allowlist must be rejected"); + assert!(matches!(err, NetworkAccountNoteAllowlistError::SlotNotFound)); + } +} 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 new file mode 100644 index 0000000000..68bb7519a2 --- /dev/null +++ b/crates/miden-standards/src/account/auth/network_account/note_allowlist.rs @@ -0,0 +1,237 @@ +use alloc::collections::BTreeSet; + +use miden_protocol::account::component::{SchemaType, StorageSlotSchema}; +use miden_protocol::account::{ + AccountId, + AccountStorage, + StorageMap, + StorageMapKey, + StorageSlot, + StorageSlotContent, + StorageSlotName, +}; +use miden_protocol::note::NoteScriptRoot; +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_note_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 NOTE ALLOWLIST +// ================================================================================================ + +/// A standardized storage slot holding the allowlist of input-note script roots that a network +/// account is willing to consume. +/// +/// The presence of this slot is what defines an account as a "network account": it is the +/// abstraction shared by every network-account component, so off-chain services (like the network +/// transaction builder) can identify a network account and filter notes by inspecting account +/// storage for this slot, independent of which component the account uses. +/// +/// The slot is a [`StorageMap`] keyed by note script root; any non-empty value marks a root as +/// allowed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NetworkAccountNoteAllowlist { + allowed_script_roots: BTreeSet, +} + +impl NetworkAccountNoteAllowlist { + /// Creates a new allowlist from the provided list of allowed input-note script roots. + /// + /// # Errors + /// + /// Returns an error if `allowed_script_roots` is empty since the account could not consume any + /// notes. + pub fn new( + allowed_script_roots: BTreeSet, + ) -> Result { + if allowed_script_roots.is_empty() { + return Err(NetworkAccountNoteAllowlistError::EmptyAllowlist); + } + + Ok(Self { allowed_script_roots }) + } + + /// Returns the [`StorageSlotName`] of the standardized allowlist slot. + pub fn slot_name() -> &'static StorageSlotName { + &SLOT_NAME + } + + /// Returns the allowed input-note script roots in this allowlist. + pub fn allowed_script_roots(&self) -> &BTreeSet { + &self.allowed_script_roots + } + + /// Returns the allowed input-note script roots in this allowlist. + 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 input note 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 NetworkAccountNoteAllowlist { + type Error = NetworkAccountNoteAllowlistError; + + /// Reconstructs a [`NetworkAccountNoteAllowlist`] 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(NetworkAccountNoteAllowlistError::SlotNotFound)?; + + let StorageSlotContent::Map(map) = slot.content() else { + return Err(NetworkAccountNoteAllowlistError::UnexpectedSlotType); + }; + + let allowed_script_roots = map + .entries() + .map(|(key, _value)| NoteScriptRoot::from_raw(key.as_word())) + .collect(); + + Self::new(allowed_script_roots) + } +} + +// NETWORK ACCOUNT NOTE ALLOWLIST ERROR +// ================================================================================================ + +/// Errors that can occur when constructing a [`NetworkAccountNoteAllowlist`] or reconstructing one +/// from storage. +#[derive(Debug, thiserror::Error)] +pub enum NetworkAccountNoteAllowlistError { + #[error( + "network account allowlist must contain at least one allowed note script root: an empty \ + allowlist would prevent the account from consuming any notes" + )] + EmptyAllowlist, + #[error( + "network account allowlist storage slot {} not found in account storage", + NetworkAccountNoteAllowlist::slot_name() + )] + SlotNotFound, + #[error( + "network account allowlist storage slot {} must be a map", + NetworkAccountNoteAllowlist::slot_name() + )] + UnexpectedSlotType, + #[error("network account must have public account type, but account {0} does not")] + AccountNotPublic(AccountId), +} + +// 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 = NoteScriptRoot::from_array([1, 2, 3, 4]); + let root_b = NoteScriptRoot::from_array([5, 6, 7, 8]); + + let slot = NetworkAccountNoteAllowlist::new(BTreeSet::from_iter([root_a, root_b])) + .expect("non-empty allowlist should construct") + .into_storage_slot(); + + assert_eq!(slot.name(), NetworkAccountNoteAllowlist::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_rejected() { + let result = NetworkAccountNoteAllowlist::new(BTreeSet::new()); + assert!(matches!(result, Err(NetworkAccountNoteAllowlistError::EmptyAllowlist))); + } + + #[test] + fn allowlist_round_trips_through_account_storage() { + use alloc::collections::BTreeSet; + + let root_a = NoteScriptRoot::from_array([1, 2, 3, 4]); + let root_b = NoteScriptRoot::from_array([5, 6, 7, 8]); + let root_c = NoteScriptRoot::from_array([9, 10, 11, 12]); + let original_roots = BTreeSet::from_iter([root_a, root_b, root_c]); + + let account = AccountBuilder::new([0; 32]) + .with_auth_component( + AuthNetworkAccount::with_allowlist(original_roots.clone()) + .expect("non-empty allowlist should construct"), + ) + .with_component(BasicWallet) + .build() + .expect("account building with AuthNetworkAccount failed"); + + let allowlist = NetworkAccountNoteAllowlist::try_from(account.storage()) + .expect("allowlist should be reconstructable from account storage"); + + // The map's ordering is determined by the StorageMapKey, so compare as sets. + let expected: BTreeSet = original_roots.into_iter().collect(); + let actual: BTreeSet = + allowlist.allowed_script_roots().iter().copied().collect(); + + assert_eq!(actual, expected); + } +} diff --git a/crates/miden-standards/src/account/auth/no_auth.rs b/crates/miden-standards/src/account/auth/no_auth.rs index 6fa7ab6911..d3bc0568af 100644 --- a/crates/miden-standards/src/account/auth/no_auth.rs +++ b/crates/miden-standards/src/account/auth/no_auth.rs @@ -1,7 +1,9 @@ -use miden_protocol::account::component::AccountComponentMetadata; -use miden_protocol::account::{AccountComponent, AccountType}; +use miden_protocol::account::component::{AccountComponentCode, AccountComponentMetadata}; +use miden_protocol::account::{AccountComponent, AccountComponentName}; -use crate::account::components::no_auth_library; +use crate::account::account_component_code; + +account_component_code!(NO_AUTH_CODE, "auth/no_auth.masl"); /// An [`AccountComponent`] implementing a no-authentication scheme. /// @@ -15,14 +17,22 @@ use crate::account::components::no_auth_library; /// - Checks if the account state has changed by comparing initial and final commitments /// - Only increments the nonce if the account state has actually changed /// - Provides no cryptographic authentication -/// -/// This component supports all account types. pub struct NoAuth; impl NoAuth { /// The name of the component. pub const NAME: &'static str = "miden::standards::components::auth::no_auth"; + /// 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 { + &NO_AUTH_CODE + } + /// Creates a new [`NoAuth`] component. pub fn new() -> Self { Self @@ -30,8 +40,7 @@ impl NoAuth { /// Returns the [`AccountComponentMetadata`] for this component. pub fn component_metadata() -> AccountComponentMetadata { - AccountComponentMetadata::new(Self::NAME, AccountType::all()) - .with_description("No authentication component") + AccountComponentMetadata::new(Self::NAME).with_description("No authentication component") } } @@ -45,7 +54,7 @@ impl From for AccountComponent { fn from(_: NoAuth) -> Self { let metadata = NoAuth::component_metadata(); - AccountComponent::new(no_auth_library(), vec![], metadata) + AccountComponent::new(NoAuth::code().clone(), vec![], metadata) .expect("NoAuth component should satisfy the requirements of a valid account component") } } diff --git a/crates/miden-standards/src/account/auth/singlesig.rs b/crates/miden-standards/src/account/auth/singlesig.rs index ee1e8401ef..32324dd5f0 100644 --- a/crates/miden-standards/src/account/auth/singlesig.rs +++ b/crates/miden-standards/src/account/auth/singlesig.rs @@ -1,15 +1,24 @@ use miden_protocol::Word; -use miden_protocol::account::auth::{AuthScheme, PublicKeyCommitment}; +use miden_protocol::account::auth::{AuthScheme, PublicKey, PublicKeyCommitment}; use miden_protocol::account::component::{ + AccountComponentCode, AccountComponentMetadata, SchemaType, StorageSchema, StorageSlotSchema, }; -use miden_protocol::account::{AccountComponent, AccountType, StorageSlot, StorageSlotName}; +use miden_protocol::account::{ + AccountComponent, + AccountComponentName, + StorageSlot, + StorageSlotName, +}; +use miden_protocol::crypto::dsa::{ecdsa_k256_keccak, falcon512_poseidon2}; use miden_protocol::utils::sync::LazyLock; -use crate::account::components::singlesig_library; +use crate::account::account_component_code; + +account_component_code!(SINGLESIG_CODE, "auth/singlesig.masl"); // CONSTANTS // ================================================================================================ @@ -35,8 +44,6 @@ static SCHEME_ID_SLOT_NAME: LazyLock = LazyLock::new(|| { /// assembler (which also implies availability of `miden::protocol`). This is the case when using /// [`CodeBuilder`][builder]. /// -/// This component supports all account types. -/// /// [builder]: crate::code_builder::CodeBuilder pub struct AuthSingleSig { pub_key: PublicKeyCommitment, @@ -47,11 +54,51 @@ impl AuthSingleSig { /// The name of the component. pub const NAME: &'static str = "miden::standards::components::auth::singlesig"; + /// 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 { + &SINGLESIG_CODE + } + /// Creates a new [`AuthSingleSig`] component with the given `public_key`. pub fn new(pub_key: PublicKeyCommitment, auth_scheme: AuthScheme) -> Self { Self { pub_key, auth_scheme } } + /// Creates a new [`AuthSingleSig`] component using the Falcon512Poseidon2 signature scheme. + /// + /// The public key commitment is derived from the provided Falcon512 public key. + pub fn falcon512_poseidon2(pub_key: falcon512_poseidon2::PublicKey) -> Self { + Self { + pub_key: pub_key.into(), + auth_scheme: AuthScheme::Falcon512Poseidon2, + } + } + + /// Creates a new [`AuthSingleSig`] component using the EcdsaK256Keccak signature scheme. + /// + /// The public key commitment is derived from the provided ECDSA K256 public key. + pub fn ecdsa_k256_keccak(pub_key: ecdsa_k256_keccak::PublicKey) -> Self { + Self { + pub_key: pub_key.into(), + auth_scheme: AuthScheme::EcdsaK256Keccak, + } + } + + /// Creates a new [`AuthSingleSig`] component from a [`PublicKey`]. + /// + /// The authentication scheme and public key commitment are derived from the provided key. + pub fn from_public_key(pub_key: PublicKey) -> Self { + Self { + auth_scheme: pub_key.auth_scheme(), + pub_key: pub_key.to_commitment(), + } + } + /// Returns the [`StorageSlotName`] where the public key is stored. pub fn public_key_slot() -> &'static StorageSlotName { &PUBKEY_SLOT_NAME @@ -85,7 +132,7 @@ impl AuthSingleSig { ]) .expect("storage schema should be valid"); - AccountComponentMetadata::new(Self::NAME, AccountType::all()) + AccountComponentMetadata::new(Self::NAME) .with_description( "Authentication component using ECDSA K256 Keccak or Falcon512 Poseidon2 signature scheme", ) @@ -108,7 +155,7 @@ impl From for AccountComponent { ), ]; - AccountComponent::new(singlesig_library(), storage_slots, metadata).expect( + AccountComponent::new(AuthSingleSig::code().clone(), storage_slots, metadata).expect( "singlesig component should satisfy the requirements of a valid account component", ) } diff --git a/crates/miden-standards/src/account/auth/singlesig_acl.rs b/crates/miden-standards/src/account/auth/singlesig_acl.rs index 70ff9a1b1d..884b66ee73 100644 --- a/crates/miden-standards/src/account/auth/singlesig_acl.rs +++ b/crates/miden-standards/src/account/auth/singlesig_acl.rs @@ -2,6 +2,7 @@ use alloc::vec::Vec; use miden_protocol::account::auth::{AuthScheme, PublicKeyCommitment}; use miden_protocol::account::component::{ + AccountComponentCode, AccountComponentMetadata, FeltSchema, SchemaType, @@ -11,7 +12,8 @@ use miden_protocol::account::component::{ use miden_protocol::account::{ AccountCode, AccountComponent, - AccountType, + AccountComponentName, + AccountProcedureRoot, StorageMap, StorageMapKey, StorageSlot, @@ -21,7 +23,9 @@ use miden_protocol::errors::AccountError; use miden_protocol::utils::sync::LazyLock; use miden_protocol::{Felt, Word}; -use crate::account::components::singlesig_acl_library; +use crate::account::account_component_code; + +account_component_code!(SINGLESIG_ACL_CODE, "auth/singlesig_acl.masl"); // CONSTANTS // ================================================================================================ @@ -50,7 +54,7 @@ static TRIGGER_PROCEDURE_ROOT_SLOT_NAME: LazyLock = LazyLock::n #[derive(Debug, Clone, PartialEq, Eq)] pub struct AuthSingleSigAclConfig { /// List of procedure roots that require authentication when called. - pub auth_trigger_procedures: Vec, + pub auth_trigger_procedures: Vec, /// When `false`, creating output notes (sending notes to other accounts) requires /// authentication. When `true`, output notes can be created without authentication. pub allow_unauthorized_output_notes: bool, @@ -71,7 +75,7 @@ impl AuthSingleSigAclConfig { } /// Sets the list of procedure roots that require authentication when called. - pub fn with_auth_trigger_procedures(mut self, procedures: Vec) -> Self { + pub fn with_auth_trigger_procedures(mut self, procedures: Vec) -> Self { self.auth_trigger_procedures = procedures; self } @@ -147,8 +151,6 @@ impl Default for AuthSingleSigAclConfig { /// state or kernel APIs may not be detected as "called" even if they were executed during /// the transaction. This is an important limitation to consider when designing trigger /// procedures for authentication. -/// -/// This component supports all account types. pub struct AuthSingleSigAcl { pub_key: PublicKeyCommitment, auth_scheme: AuthScheme, @@ -158,6 +160,17 @@ pub struct AuthSingleSigAcl { impl AuthSingleSigAcl { /// The name of the component. pub const NAME: &'static str = "miden::standards::components::auth::singlesig_acl"; + + /// 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 { + &SINGLESIG_ACL_CODE + } + /// Creates a new [`AuthSingleSigAcl`] component with the given `public_key` and /// configuration. /// @@ -213,9 +226,9 @@ impl AuthSingleSigAcl { StorageSlotSchema::value( "ACL configuration", [ - FeltSchema::u32("num_trigger_procs").with_default(Felt::new(0)), - FeltSchema::bool("allow_unauthorized_output_notes").with_default(Felt::new(0)), - FeltSchema::bool("allow_unauthorized_input_notes").with_default(Felt::new(0)), + FeltSchema::u32("num_trigger_procs").with_default(Felt::ZERO), + FeltSchema::bool("allow_unauthorized_output_notes").with_default(Felt::ZERO), + FeltSchema::bool("allow_unauthorized_input_notes").with_default(Felt::ZERO), FeltSchema::new_void(), ], ), @@ -252,7 +265,7 @@ impl AuthSingleSigAcl { ]) .expect("storage schema should be valid"); - AccountComponentMetadata::new(Self::NAME, AccountType::all()) + AccountComponentMetadata::new(Self::NAME) .with_description( "Authentication component with procedure-based ACL using ECDSA K256 Keccak or Falcon512 Poseidon2 signature scheme", ) @@ -296,7 +309,7 @@ impl From for AccountComponent { .auth_trigger_procedures .iter() .enumerate() - .map(|(i, proc_root)| (StorageMapKey::from_index(i as u32), *proc_root)); + .map(|(i, proc_root)| (StorageMapKey::from_index(i as u32), proc_root.as_word())); // Safe to unwrap because we know that the map keys are unique. storage_slots.push(StorageSlot::with_map( @@ -306,7 +319,7 @@ impl From for AccountComponent { let metadata = AuthSingleSigAcl::component_metadata(); - AccountComponent::new(singlesig_acl_library(), storage_slots, metadata).expect( + AccountComponent::new(AuthSingleSigAcl::code().clone(), storage_slots, metadata).expect( "singlesig ACL component should satisfy the requirements of a valid account component", ) } @@ -337,10 +350,10 @@ mod tests { } /// Helper function to get the basic wallet procedures for testing - fn get_basic_wallet_procedures() -> Vec { + fn get_basic_wallet_procedures() -> Vec { // Get the two trigger procedures from BasicWallet: `receive_asset`, `move_asset_to_note`. - let procedures: Vec = - StandardAccountComponent::BasicWallet.procedure_digests().collect(); + let procedures: Vec = + StandardAccountComponent::BasicWallet.procedure_roots().collect(); assert_eq!(procedures.len(), 2); procedures @@ -356,7 +369,7 @@ mod tests { .with_allow_unauthorized_output_notes(config.allow_unauthorized_output_notes) .with_allow_unauthorized_input_notes(config.allow_unauthorized_input_notes); - let auth_trigger_procedures = if config.with_procedures { + let auth_trigger_procedures: Vec = if config.with_procedures { let procedures = get_basic_wallet_procedures(); acl_config = acl_config.with_auth_trigger_procedures(procedures.clone()); procedures @@ -398,7 +411,7 @@ mod tests { Word::from([i as u32, 0, 0, 0]), ) .expect("storage map access failed"); - assert_eq!(proc_root, *expected_proc_root); + assert_eq!(proc_root, expected_proc_root.as_word()); } } else { // When no procedures, the map should return empty for key [0,0,0,0] diff --git a/crates/miden-standards/src/account/components/mod.rs b/crates/miden-standards/src/account/components/mod.rs index a14d3ce523..807d3339b8 100644 --- a/crates/miden-standards/src/account/components/mod.rs +++ b/crates/miden-standards/src/account/components/mod.rs @@ -1,179 +1,21 @@ use alloc::collections::BTreeSet; use alloc::vec::Vec; -use miden_processor::mast::MastNodeExt; -use miden_protocol::Word; use miden_protocol::account::AccountProcedureRoot; -use miden_protocol::assembly::{Library, LibraryExport}; -use miden_protocol::utils::serde::Deserializable; -use miden_protocol::utils::sync::LazyLock; +use crate::account::access::{Authority, Ownable2Step, RoleBasedAccessControl}; +use crate::account::auth::{ + AuthGuardedMultisig, + AuthMultisig, + AuthMultisigSmart, + AuthNetworkAccount, + AuthSingleSig, + AuthSingleSigAcl, + NoAuth, +}; +use crate::account::faucets::FungibleFaucet; use crate::account::interface::AccountComponentInterface; - -// WALLET LIBRARIES -// ================================================================================================ - -// Initialize the Basic Wallet library only once. -static BASIC_WALLET_LIBRARY: LazyLock = LazyLock::new(|| { - let bytes = include_bytes!(concat!( - env!("OUT_DIR"), - "/assets/account_components/wallets/basic_wallet.masl" - )); - Library::read_from_bytes(bytes).expect("Shipped Basic Wallet library is well-formed") -}); - -// ACCESS LIBRARIES -// ================================================================================================ - -// Initialize the Ownable2Step library only once. -static OWNABLE2STEP_LIBRARY: LazyLock = LazyLock::new(|| { - let bytes = include_bytes!(concat!( - env!("OUT_DIR"), - "/assets/account_components/access/ownable2step.masl" - )); - Library::read_from_bytes(bytes).expect("Shipped Ownable2Step library is well-formed") -}); - -// AUTH LIBRARIES -// ================================================================================================ - -/// Initialize the ECDSA K256 Keccak library only once. -static SINGLESIG_LIBRARY: LazyLock = LazyLock::new(|| { - let bytes = - include_bytes!(concat!(env!("OUT_DIR"), "/assets/account_components/auth/singlesig.masl")); - Library::read_from_bytes(bytes).expect("Shipped Singlesig library is well-formed") -}); - -// Initialize the ECDSA K256 Keccak ACL library only once. -static SINGLESIG_ACL_LIBRARY: LazyLock = LazyLock::new(|| { - let bytes = include_bytes!(concat!( - env!("OUT_DIR"), - "/assets/account_components/auth/singlesig_acl.masl" - )); - Library::read_from_bytes(bytes).expect("Shipped Singlesig ACL library is well-formed") -}); - -/// Initialize the Multisig library only once. -static MULTISIG_LIBRARY: LazyLock = LazyLock::new(|| { - let bytes = - include_bytes!(concat!(env!("OUT_DIR"), "/assets/account_components/auth/multisig.masl")); - Library::read_from_bytes(bytes).expect("Shipped Multisig library is well-formed") -}); - -/// Initialize the Multisig PSM library only once. -static MULTISIG_PSM_LIBRARY: LazyLock = LazyLock::new(|| { - let bytes = include_bytes!(concat!( - env!("OUT_DIR"), - "/assets/account_components/auth/multisig_psm.masl" - )); - Library::read_from_bytes(bytes).expect("Shipped Multisig PSM library is well-formed") -}); - -// Initialize the NoAuth library only once. -static NO_AUTH_LIBRARY: LazyLock = LazyLock::new(|| { - let bytes = - include_bytes!(concat!(env!("OUT_DIR"), "/assets/account_components/auth/no_auth.masl")); - Library::read_from_bytes(bytes).expect("Shipped NoAuth library is well-formed") -}); - -// FAUCET LIBRARIES -// ================================================================================================ - -// Initialize the Basic Fungible Faucet library only once. -static BASIC_FUNGIBLE_FAUCET_LIBRARY: LazyLock = LazyLock::new(|| { - let bytes = include_bytes!(concat!( - env!("OUT_DIR"), - "/assets/account_components/faucets/basic_fungible_faucet.masl" - )); - Library::read_from_bytes(bytes).expect("Shipped Basic Fungible Faucet library is well-formed") -}); - -// Initialize the Network Fungible Faucet library only once. -static NETWORK_FUNGIBLE_FAUCET_LIBRARY: LazyLock = LazyLock::new(|| { - let bytes = include_bytes!(concat!( - env!("OUT_DIR"), - "/assets/account_components/faucets/network_fungible_faucet.masl" - )); - Library::read_from_bytes(bytes).expect("Shipped Network Fungible Faucet library is well-formed") -}); - -// Initialize the Mint Policy Owner Controlled library only once. -static MINT_POLICY_OWNER_CONTROLLED_LIBRARY: LazyLock = LazyLock::new(|| { - let bytes = include_bytes!(concat!( - env!("OUT_DIR"), - "/assets/account_components/mint_policies/owner_controlled.masl" - )); - Library::read_from_bytes(bytes) - .expect("Shipped Mint Policy Owner Controlled library is well-formed") -}); - -// Initialize the Mint Policy Auth Controlled library only once. -static MINT_POLICY_AUTH_CONTROLLED_LIBRARY: LazyLock = LazyLock::new(|| { - let bytes = include_bytes!(concat!( - env!("OUT_DIR"), - "/assets/account_components/mint_policies/auth_controlled.masl" - )); - Library::read_from_bytes(bytes) - .expect("Shipped Mint Policy Auth Controlled library is well-formed") -}); - -// METADATA LIBRARIES -// ================================================================================================ - -/// Returns the Basic Wallet Library. -pub fn basic_wallet_library() -> Library { - BASIC_WALLET_LIBRARY.clone() -} - -/// Returns the Ownable2Step Library. -pub fn ownable2step_library() -> Library { - OWNABLE2STEP_LIBRARY.clone() -} - -/// Returns the Basic Fungible Faucet Library. -pub fn basic_fungible_faucet_library() -> Library { - BASIC_FUNGIBLE_FAUCET_LIBRARY.clone() -} - -/// Returns the Network Fungible Faucet Library. -pub fn network_fungible_faucet_library() -> Library { - NETWORK_FUNGIBLE_FAUCET_LIBRARY.clone() -} - -/// Returns the Mint Policy Owner Controlled Library. -pub fn owner_controlled_library() -> Library { - MINT_POLICY_OWNER_CONTROLLED_LIBRARY.clone() -} - -/// Returns the Mint Policy Auth Controlled Library. -pub fn auth_controlled_library() -> Library { - MINT_POLICY_AUTH_CONTROLLED_LIBRARY.clone() -} - -/// Returns the Singlesig Library. -pub fn singlesig_library() -> Library { - SINGLESIG_LIBRARY.clone() -} - -/// Returns the Singlesig ACL Library. -pub fn singlesig_acl_library() -> Library { - SINGLESIG_ACL_LIBRARY.clone() -} - -/// Returns the Multisig Library. -pub fn multisig_library() -> Library { - MULTISIG_LIBRARY.clone() -} - -/// Returns the Multisig PSM Library. -pub fn multisig_psm_library() -> Library { - MULTISIG_PSM_LIBRARY.clone() -} - -/// Returns the NoAuth Library. -pub fn no_auth_library() -> Library { - NO_AUTH_LIBRARY.clone() -} +use crate::account::wallets::BasicWallet; // STANDARD ACCOUNT COMPONENTS // ================================================================================================ @@ -182,39 +24,39 @@ pub fn no_auth_library() -> Library { /// crate. pub enum StandardAccountComponent { BasicWallet, - BasicFungibleFaucet, - NetworkFungibleFaucet, + FungibleFaucet, + Authority, + Ownable2Step, + RoleBasedAccessControl, AuthSingleSig, AuthSingleSigAcl, AuthMultisig, - AuthMultisigPsm, + AuthMultisigSmart, + AuthGuardedMultisig, AuthNoAuth, + AuthNetworkAccount, } impl StandardAccountComponent { - /// Returns the iterator over digests of all procedures exported from the component. - pub fn procedure_digests(&self) -> impl Iterator { - let library = match self { - Self::BasicWallet => BASIC_WALLET_LIBRARY.as_ref(), - Self::BasicFungibleFaucet => BASIC_FUNGIBLE_FAUCET_LIBRARY.as_ref(), - Self::NetworkFungibleFaucet => NETWORK_FUNGIBLE_FAUCET_LIBRARY.as_ref(), - Self::AuthSingleSig => SINGLESIG_LIBRARY.as_ref(), - Self::AuthSingleSigAcl => SINGLESIG_ACL_LIBRARY.as_ref(), - Self::AuthMultisig => MULTISIG_LIBRARY.as_ref(), - Self::AuthMultisigPsm => MULTISIG_PSM_LIBRARY.as_ref(), - Self::AuthNoAuth => NO_AUTH_LIBRARY.as_ref(), + /// Returns the iterator over the [`AccountProcedureRoot`]s of all procedures exported from + /// the component. + pub fn procedure_roots(&self) -> impl Iterator { + let code = match self { + Self::BasicWallet => BasicWallet::code(), + Self::FungibleFaucet => FungibleFaucet::code(), + Self::Authority => Authority::code(), + Self::Ownable2Step => Ownable2Step::code(), + Self::RoleBasedAccessControl => RoleBasedAccessControl::code(), + Self::AuthSingleSig => AuthSingleSig::code(), + Self::AuthSingleSigAcl => AuthSingleSigAcl::code(), + Self::AuthMultisig => AuthMultisig::code(), + Self::AuthMultisigSmart => AuthMultisigSmart::code(), + Self::AuthGuardedMultisig => AuthGuardedMultisig::code(), + Self::AuthNoAuth => NoAuth::code(), + Self::AuthNetworkAccount => AuthNetworkAccount::code(), }; - library - .exports() - .filter(|export| matches!(export, LibraryExport::Procedure(_))) - .map(|proc_export| { - library - .mast_forest() - .get_node_by_id(proc_export.unwrap_procedure().node) - .expect("export node not in the forest") - .digest() - }) + code.procedure_roots() } /// Checks whether procedures from the current component are present in the procedures map @@ -226,12 +68,10 @@ impl StandardAccountComponent { component_interface_vec: &mut Vec, ) { // Determine if this component should be extracted based on procedure matching - if self.procedure_digests().all(|proc_digest| { - procedures_set.contains(&AccountProcedureRoot::from_raw(proc_digest)) - }) { + if self.procedure_roots().all(|proc_root| procedures_set.contains(&proc_root)) { // Remove the procedure root of any matching procedure. - self.procedure_digests().for_each(|component_procedure| { - procedures_set.remove(&AccountProcedureRoot::from_raw(component_procedure)); + self.procedure_roots().for_each(|component_procedure| { + procedures_set.remove(&component_procedure); }); // Create the appropriate component interface @@ -239,11 +79,17 @@ impl StandardAccountComponent { Self::BasicWallet => { component_interface_vec.push(AccountComponentInterface::BasicWallet) }, - Self::BasicFungibleFaucet => { - component_interface_vec.push(AccountComponentInterface::BasicFungibleFaucet) + Self::FungibleFaucet => { + component_interface_vec.push(AccountComponentInterface::FungibleFaucet) + }, + Self::Authority => { + component_interface_vec.push(AccountComponentInterface::Authority) }, - Self::NetworkFungibleFaucet => { - component_interface_vec.push(AccountComponentInterface::NetworkFungibleFaucet) + Self::Ownable2Step => { + component_interface_vec.push(AccountComponentInterface::Ownable2Step) + }, + Self::RoleBasedAccessControl => { + component_interface_vec.push(AccountComponentInterface::RoleBasedAccessControl) }, Self::AuthSingleSig => { component_interface_vec.push(AccountComponentInterface::AuthSingleSig) @@ -254,12 +100,18 @@ impl StandardAccountComponent { Self::AuthMultisig => { component_interface_vec.push(AccountComponentInterface::AuthMultisig) }, - Self::AuthMultisigPsm => { - component_interface_vec.push(AccountComponentInterface::AuthMultisigPsm) + Self::AuthMultisigSmart => { + component_interface_vec.push(AccountComponentInterface::AuthMultisigSmart) + }, + Self::AuthGuardedMultisig => { + component_interface_vec.push(AccountComponentInterface::AuthGuardedMultisig) }, Self::AuthNoAuth => { component_interface_vec.push(AccountComponentInterface::AuthNoAuth) }, + Self::AuthNetworkAccount => { + component_interface_vec.push(AccountComponentInterface::AuthNetworkAccount) + }, } } } @@ -271,12 +123,16 @@ impl StandardAccountComponent { component_interface_vec: &mut Vec, ) { Self::BasicWallet.extract_component(procedures_set, component_interface_vec); - Self::BasicFungibleFaucet.extract_component(procedures_set, component_interface_vec); - Self::NetworkFungibleFaucet.extract_component(procedures_set, component_interface_vec); + Self::FungibleFaucet.extract_component(procedures_set, component_interface_vec); + Self::Authority.extract_component(procedures_set, component_interface_vec); + Self::RoleBasedAccessControl.extract_component(procedures_set, component_interface_vec); + Self::Ownable2Step.extract_component(procedures_set, component_interface_vec); Self::AuthSingleSig.extract_component(procedures_set, component_interface_vec); Self::AuthSingleSigAcl.extract_component(procedures_set, component_interface_vec); - Self::AuthMultisigPsm.extract_component(procedures_set, component_interface_vec); + Self::AuthGuardedMultisig.extract_component(procedures_set, component_interface_vec); Self::AuthMultisig.extract_component(procedures_set, component_interface_vec); + Self::AuthMultisigSmart.extract_component(procedures_set, component_interface_vec); Self::AuthNoAuth.extract_component(procedures_set, component_interface_vec); + Self::AuthNetworkAccount.extract_component(procedures_set, component_interface_vec); } } diff --git a/crates/miden-standards/src/account/faucets/basic_fungible.rs b/crates/miden-standards/src/account/faucets/basic_fungible.rs deleted file mode 100644 index 71b94ce2b6..0000000000 --- a/crates/miden-standards/src/account/faucets/basic_fungible.rs +++ /dev/null @@ -1,482 +0,0 @@ -use miden_protocol::account::component::{ - AccountComponentMetadata, - FeltSchema, - SchemaType, - StorageSchema, - StorageSlotSchema, -}; -use miden_protocol::account::{ - Account, - AccountBuilder, - AccountComponent, - AccountStorage, - AccountStorageMode, - AccountType, - StorageSlotName, -}; -use miden_protocol::asset::TokenSymbol; -use miden_protocol::{Felt, Word}; - -use super::{FungibleFaucetError, TokenMetadata}; -use crate::account::AuthMethod; -use crate::account::auth::{AuthSingleSigAcl, AuthSingleSigAclConfig}; -use crate::account::components::basic_fungible_faucet_library; -use crate::account::mint_policies::AuthControlled; - -/// The schema type for token symbols. -const TOKEN_SYMBOL_TYPE: &str = "miden::standards::fungible_faucets::metadata::token_symbol"; -use crate::account::interface::{AccountComponentInterface, AccountInterface, AccountInterfaceExt}; -use crate::procedure_digest; - -// BASIC FUNGIBLE FAUCET ACCOUNT COMPONENT -// ================================================================================================ - -// Initialize the digest of the `mint_and_send` procedure of the Basic Fungible Faucet only once. -procedure_digest!( - BASIC_FUNGIBLE_FAUCET_MINT_AND_SEND, - BasicFungibleFaucet::NAME, - BasicFungibleFaucet::MINT_PROC_NAME, - basic_fungible_faucet_library -); - -// Initialize the digest of the `burn` procedure of the Basic Fungible Faucet only once. -procedure_digest!( - BASIC_FUNGIBLE_FAUCET_BURN, - BasicFungibleFaucet::NAME, - BasicFungibleFaucet::BURN_PROC_NAME, - basic_fungible_faucet_library -); - -/// An [`AccountComponent`] implementing a basic fungible faucet. -/// -/// It reexports the procedures from `miden::standards::faucets::basic_fungible`. When linking -/// against this component, the `miden` library (i.e. -/// [`ProtocolLib`](miden_protocol::ProtocolLib)) must be available to the assembler which is the -/// case when using [`CodeBuilder`][builder]. The procedures of this component are: -/// - `mint_and_send`, which mints an assets and create a note for the provided recipient. -/// - `burn`, which burns the provided asset. -/// -/// The `mint_and_send` procedure can be called from a transaction script and requires -/// authentication via the authentication component. The `burn` procedure can only be called from a -/// note script and requires the calling note to contain the asset to be burned. -/// This component must be combined with an authentication component. -/// -/// This component supports accounts of type [`AccountType::FungibleFaucet`]. -/// -/// ## Storage Layout -/// -/// - [`Self::metadata_slot`]: Stores [`TokenMetadata`]. -/// -/// [builder]: crate::code_builder::CodeBuilder -pub struct BasicFungibleFaucet { - metadata: TokenMetadata, -} - -impl BasicFungibleFaucet { - // CONSTANTS - // -------------------------------------------------------------------------------------------- - - /// The name of the component. - pub const NAME: &'static str = "miden::standards::components::faucets::basic_fungible_faucet"; - - /// The maximum number of decimals supported by the component. - pub const MAX_DECIMALS: u8 = TokenMetadata::MAX_DECIMALS; - - const MINT_PROC_NAME: &str = "mint_and_send"; - const BURN_PROC_NAME: &str = "burn"; - - // CONSTRUCTORS - // -------------------------------------------------------------------------------------------- - - /// Creates a new [`BasicFungibleFaucet`] component from the given pieces of metadata and with - /// an initial token supply of zero. - /// - /// # Errors - /// - /// Returns an error if: - /// - the decimals parameter exceeds maximum value of [`Self::MAX_DECIMALS`]. - /// - the max supply parameter exceeds maximum possible amount for a fungible asset - /// ([`miden_protocol::asset::FungibleAsset::MAX_AMOUNT`]) - pub fn new( - symbol: TokenSymbol, - decimals: u8, - max_supply: Felt, - ) -> Result { - let metadata = TokenMetadata::new(symbol, decimals, max_supply)?; - Ok(Self { metadata }) - } - - /// Creates a new [`BasicFungibleFaucet`] component from the given [`TokenMetadata`]. - /// - /// This is a convenience constructor that allows creating a faucet from pre-validated - /// metadata. - pub fn from_metadata(metadata: TokenMetadata) -> Self { - Self { metadata } - } - - /// Attempts to create a new [`BasicFungibleFaucet`] component from the associated account - /// interface and storage. - /// - /// # Errors - /// - /// Returns an error if: - /// - the provided [`AccountInterface`] does not contain a - /// [`AccountComponentInterface::BasicFungibleFaucet`] component. - /// - the decimals parameter exceeds maximum value of [`Self::MAX_DECIMALS`]. - /// - the max supply value exceeds maximum possible amount for a fungible asset of - /// [`miden_protocol::asset::FungibleAsset::MAX_AMOUNT`]. - /// - the token supply exceeds the max supply. - /// - the token symbol encoded value exceeds the maximum value of - /// [`TokenSymbol::MAX_ENCODED_VALUE`]. - fn try_from_interface( - interface: AccountInterface, - storage: &AccountStorage, - ) -> Result { - // Check that the procedures of the basic fungible faucet exist in the account. - if !interface.components().contains(&AccountComponentInterface::BasicFungibleFaucet) { - return Err(FungibleFaucetError::MissingBasicFungibleFaucetInterface); - } - - let metadata = TokenMetadata::try_from(storage)?; - Ok(Self { metadata }) - } - - // PUBLIC ACCESSORS - // -------------------------------------------------------------------------------------------- - - /// Returns the [`StorageSlotName`] where the [`BasicFungibleFaucet`]'s metadata is stored. - pub fn metadata_slot() -> &'static StorageSlotName { - TokenMetadata::metadata_slot() - } - - /// Returns the storage slot schema for the metadata slot. - pub fn metadata_slot_schema() -> (StorageSlotName, StorageSlotSchema) { - let token_symbol_type = SchemaType::new(TOKEN_SYMBOL_TYPE).expect("valid type"); - ( - Self::metadata_slot().clone(), - StorageSlotSchema::value( - "Token metadata", - [ - FeltSchema::felt("token_supply").with_default(Felt::new(0)), - FeltSchema::felt("max_supply"), - FeltSchema::u8("decimals"), - FeltSchema::new_typed(token_symbol_type, "symbol"), - ], - ), - ) - } - - /// Returns the token metadata. - pub fn metadata(&self) -> &TokenMetadata { - &self.metadata - } - - /// Returns the symbol of the faucet. - pub fn symbol(&self) -> &TokenSymbol { - self.metadata.symbol() - } - - /// Returns the decimals of the faucet. - pub fn decimals(&self) -> u8 { - self.metadata.decimals() - } - - /// Returns the max supply (in base units) of the faucet. - /// - /// This is the highest amount of tokens that can be minted from this faucet. - pub fn max_supply(&self) -> Felt { - self.metadata.max_supply() - } - - /// Returns the token supply (in base units) of the faucet. - /// - /// This is the amount of tokens that were minted from the faucet so far. Its value can never - /// exceed [`Self::max_supply`]. - pub fn token_supply(&self) -> Felt { - self.metadata.token_supply() - } - - /// Returns the digest of the `mint_and_send` account procedure. - pub fn mint_and_send_digest() -> Word { - *BASIC_FUNGIBLE_FAUCET_MINT_AND_SEND - } - - /// Returns the digest of the `burn` account procedure. - pub fn burn_digest() -> Word { - *BASIC_FUNGIBLE_FAUCET_BURN - } - - /// Returns the [`AccountComponentMetadata`] for this component. - pub fn component_metadata() -> AccountComponentMetadata { - let storage_schema = StorageSchema::new([Self::metadata_slot_schema()]) - .expect("storage schema should be valid"); - - AccountComponentMetadata::new(Self::NAME, [AccountType::FungibleFaucet]) - .with_description("Basic fungible faucet component for minting and burning tokens") - .with_storage_schema(storage_schema) - } - - // MUTATORS - // -------------------------------------------------------------------------------------------- - - /// Sets the token_supply (in base units) of the basic fungible faucet. - /// - /// # Errors - /// - /// Returns an error if: - /// - the token supply exceeds the max supply. - pub fn with_token_supply(mut self, token_supply: Felt) -> Result { - self.metadata = self.metadata.with_token_supply(token_supply)?; - Ok(self) - } -} - -impl From for AccountComponent { - fn from(faucet: BasicFungibleFaucet) -> Self { - let storage_slot = faucet.metadata.into(); - let metadata = BasicFungibleFaucet::component_metadata(); - - AccountComponent::new(basic_fungible_faucet_library(), vec![storage_slot], metadata) - .expect("basic fungible faucet component should satisfy the requirements of a valid account component") - } -} - -impl TryFrom for BasicFungibleFaucet { - type Error = FungibleFaucetError; - - fn try_from(account: Account) -> Result { - let account_interface = AccountInterface::from_account(&account); - - BasicFungibleFaucet::try_from_interface(account_interface, account.storage()) - } -} - -impl TryFrom<&Account> for BasicFungibleFaucet { - type Error = FungibleFaucetError; - - fn try_from(account: &Account) -> Result { - let account_interface = AccountInterface::from_account(account); - - BasicFungibleFaucet::try_from_interface(account_interface, account.storage()) - } -} - -/// Creates a new faucet account with basic fungible faucet interface, -/// account storage type, specified authentication scheme, and provided meta data (token symbol, -/// decimals, max supply). -/// -/// The basic faucet interface exposes two procedures: -/// - `mint_and_send`, which mints an assets and create a note for the provided recipient. -/// - `burn`, which burns the provided asset. -/// -/// The `mint_and_send` procedure can be called from a transaction script and requires -/// authentication via the specified authentication scheme. The `burn` procedure can only be called -/// from a note script and requires the calling note to contain the asset to be burned. -/// -/// The storage layout of the faucet account is defined by the combination of the following -/// components (see their docs for details): -/// - [`BasicFungibleFaucet`] -/// - [`AuthSingleSigAcl`] -/// - [`AuthControlled`] -pub fn create_basic_fungible_faucet( - init_seed: [u8; 32], - symbol: TokenSymbol, - decimals: u8, - max_supply: Felt, - account_storage_mode: AccountStorageMode, - auth_method: AuthMethod, -) -> Result { - let mint_proc_root = BasicFungibleFaucet::mint_and_send_digest(); - - let auth_component: AccountComponent = match auth_method { - AuthMethod::SingleSig { approver: (pub_key, auth_scheme) } => AuthSingleSigAcl::new( - pub_key, - auth_scheme, - AuthSingleSigAclConfig::new() - .with_auth_trigger_procedures(vec![mint_proc_root]) - .with_allow_unauthorized_input_notes(true), - ) - .map_err(FungibleFaucetError::AccountError)? - .into(), - AuthMethod::NoAuth => { - return Err(FungibleFaucetError::UnsupportedAuthMethod( - "basic fungible faucets cannot be created with NoAuth authentication method".into(), - )); - }, - AuthMethod::Unknown => { - return Err(FungibleFaucetError::UnsupportedAuthMethod( - "basic fungible faucets cannot be created with Unknown authentication method" - .into(), - )); - }, - AuthMethod::Multisig { .. } => { - return Err(FungibleFaucetError::UnsupportedAuthMethod( - "basic fungible faucets do not support Multisig authentication".into(), - )); - }, - }; - - let account = AccountBuilder::new(init_seed) - .account_type(AccountType::FungibleFaucet) - .storage_mode(account_storage_mode) - .with_auth_component(auth_component) - .with_component(BasicFungibleFaucet::new(symbol, decimals, max_supply)?) - .with_component(AuthControlled::allow_all()) - .build() - .map_err(FungibleFaucetError::AccountError)?; - - Ok(account) -} - -// TESTS -// ================================================================================================ - -#[cfg(test)] -mod tests { - use assert_matches::assert_matches; - use miden_protocol::Word; - use miden_protocol::account::auth::{AuthScheme, PublicKeyCommitment}; - - use super::{ - AccountBuilder, - AccountStorageMode, - AccountType, - AuthMethod, - BasicFungibleFaucet, - Felt, - FungibleFaucetError, - TokenSymbol, - create_basic_fungible_faucet, - }; - use crate::account::auth::{AuthSingleSig, AuthSingleSigAcl}; - use crate::account::wallets::BasicWallet; - - #[test] - fn faucet_contract_creation() { - let pub_key_word = Word::new([Felt::ONE; 4]); - let auth_method: AuthMethod = 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, - ]; - - let max_supply = Felt::new(123); - let token_symbol_string = "POL"; - let token_symbol = TokenSymbol::try_from(token_symbol_string).unwrap(); - let decimals = 2u8; - let storage_mode = AccountStorageMode::Private; - - let token_symbol_felt = token_symbol.as_element(); - let faucet_account = create_basic_fungible_faucet( - init_seed, - token_symbol.clone(), - decimals, - max_supply, - storage_mode, - auth_method, - ) - .unwrap(); - - // The falcon 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 1 trigger procedure (mint_and_send), allow_unauthorized_output_notes=false, and - // allow_unauthorized_input_notes=true, this should be [1, 0, 1, 0]. - assert_eq!( - faucet_account.storage().get_item(AuthSingleSigAcl::config_slot()).unwrap(), - [Felt::ONE, Felt::ZERO, Felt::ONE, Felt::ZERO].into() - ); - - // The procedure root map should contain the mint_and_send procedure root. - let mint_root = BasicFungibleFaucet::mint_and_send_digest(); - assert_eq!( - faucet_account - .storage() - .get_map_item( - AuthSingleSigAcl::trigger_procedure_roots_slot(), - [Felt::ZERO, Felt::ZERO, Felt::ZERO, Felt::ZERO].into() - ) - .unwrap(), - mint_root - ); - - // Check that faucet metadata was initialized to the given values. - // Storage layout: [token_supply, max_supply, decimals, symbol] - assert_eq!( - faucet_account.storage().get_item(BasicFungibleFaucet::metadata_slot()).unwrap(), - [Felt::ZERO, Felt::new(123), Felt::new(2), token_symbol_felt].into() - ); - - assert!(faucet_account.is_faucet()); - - assert_eq!(faucet_account.account_type(), AccountType::FungibleFaucet); - - // Verify the faucet can be extracted and has correct metadata - let faucet_component = BasicFungibleFaucet::try_from(faucet_account.clone()).unwrap(); - assert_eq!(faucet_component.symbol(), &token_symbol); - assert_eq!(faucet_component.decimals(), decimals); - assert_eq!(faucet_component.max_supply(), max_supply); - assert_eq!(faucet_component.token_supply(), Felt::ZERO); - } - - #[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_account = AccountBuilder::new(mock_seed) - .account_type(AccountType::FungibleFaucet) - .with_component( - BasicFungibleFaucet::new(token_symbol.clone(), 10, Felt::new(100)) - .expect("failed to create a fungible faucet component"), - ) - .with_auth_component(AuthSingleSig::new( - mock_public_key, - AuthScheme::Falcon512Poseidon2, - )) - .build_existing() - .expect("failed to create wallet account"); - - let basic_ff = BasicFungibleFaucet::try_from(faucet_account) - .expect("basic fungible faucet creation failed"); - assert_eq!(basic_ff.symbol(), &token_symbol); - assert_eq!(basic_ff.decimals(), 10); - assert_eq!(basic_ff.max_supply(), Felt::new(100)); - assert_eq!(basic_ff.token_supply(), Felt::ZERO); - - // invalid account: basic fungible faucet component is missing - let invalid_faucet_account = AccountBuilder::new(mock_seed) - .account_type(AccountType::FungibleFaucet) - .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"); - - let err = BasicFungibleFaucet::try_from(invalid_faucet_account) - .err() - .expect("basic fungible faucet creation should fail"); - assert_matches!(err, FungibleFaucetError::MissingBasicFungibleFaucetInterface); - } - - /// Check that the obtaining of the basic fungible faucet procedure digests does not panic. - #[test] - fn get_faucet_procedures() { - let _mint_and_send_digest = BasicFungibleFaucet::mint_and_send_digest(); - let _burn_digest = BasicFungibleFaucet::burn_digest(); - } -} diff --git a/crates/miden-standards/src/account/faucets/fungible/mod.rs b/crates/miden-standards/src/account/faucets/fungible/mod.rs new file mode 100644 index 0000000000..9dd256bc2a --- /dev/null +++ b/crates/miden-standards/src/account/faucets/fungible/mod.rs @@ -0,0 +1,707 @@ +use alloc::vec::Vec; + +use miden_protocol::account::component::{ + AccountComponentCode, + AccountComponentMetadata, + FeltSchema, + SchemaType, + StorageSchema, + StorageSlotSchema, +}; +use miden_protocol::account::{ + Account, + AccountBuilder, + AccountComponent, + AccountComponentName, + AccountProcedureRoot, + AccountStorage, + AccountType, + StorageSlot, + StorageSlotName, +}; +use miden_protocol::asset::{AssetAmount, TokenSymbol}; +use miden_protocol::utils::sync::LazyLock; +use miden_protocol::{Felt, Word}; + +use super::{ + Description, + ExternalLink, + FungibleFaucetError, + LogoURI, + TokenMetadata, + TokenMetadataError, + TokenName, +}; +use crate::account::access::{AccessControl, 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::policies::TokenPolicyManager; +use crate::{AuthMethod, procedure_root}; + +#[cfg(test)] +mod tests; + +// CONSTANTS +// ================================================================================================ + +/// Storage slot holding the token config word `[token_supply, max_supply, decimals, +/// token_symbol]` for a [`FungibleFaucet`]. +pub(crate) static TOKEN_CONFIG_SLOT: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::faucets::fungible::token_config") + .expect("storage slot name should be valid") +}); + +/// Schema type string for the token symbol field in the token config slot. +const TOKEN_SYMBOL_TYPE: &str = "miden::standards::faucets::fungible::token_symbol"; + +// FUNGIBLE FAUCET ACCOUNT COMPONENT +// ================================================================================================ + +account_component_code!(FUNGIBLE_FAUCET_CODE, "faucets/fungible_faucet.masl"); + +// Initialize the procedure root of the `mint_and_send` procedure of the Fungible Faucet only once. +procedure_root!( + FUNGIBLE_FAUCET_MINT_AND_SEND, + FungibleFaucet::NAME, + FungibleFaucet::MINT_PROC_NAME, + FungibleFaucet::code() +); + +// Initialize the procedure root of the `receive_and_burn` procedure of the Fungible Faucet only +// once. +procedure_root!( + FUNGIBLE_FAUCET_RECEIVE_AND_BURN, + FungibleFaucet::NAME, + FungibleFaucet::RECEIVE_AND_BURN_PROC_NAME, + FungibleFaucet::code() +); + +procedure_root!( + FUNGIBLE_FAUCET_SET_MAX_SUPPLY, + FungibleFaucet::NAME, + FungibleFaucet::SET_MAX_SUPPLY_PROC_NAME, + FungibleFaucet::code() +); + +procedure_root!( + FUNGIBLE_FAUCET_SET_DESCRIPTION, + FungibleFaucet::NAME, + FungibleFaucet::SET_DESCRIPTION_PROC_NAME, + FungibleFaucet::code() +); + +procedure_root!( + FUNGIBLE_FAUCET_SET_LOGO_URI, + FungibleFaucet::NAME, + FungibleFaucet::SET_LOGO_URI_PROC_NAME, + FungibleFaucet::code() +); + +procedure_root!( + FUNGIBLE_FAUCET_SET_EXTERNAL_LINK, + FungibleFaucet::NAME, + FungibleFaucet::SET_EXTERNAL_LINK_PROC_NAME, + FungibleFaucet::code() +); + +/// An [`AccountComponent`] implementing a fungible faucet. +/// +/// This component bundles the asset minting/burning procedures and the token metadata +/// (name, description, logo URI, external link) together. Whether the faucet behaves like a +/// "basic" public faucet or a network-style faucet is a function of the surrounding account +/// configuration (account type, auth component, access control component, and policy manager +/// configuration), not of the faucet component itself. +/// +/// It re-exports the procedures from `miden::standards::faucets::fungible`. When linking +/// against this component, the `miden` library (i.e. +/// [`ProtocolLib`](miden_protocol::ProtocolLib)) must be available to the assembler — which is the +/// case when using [`CodeBuilder`][builder]. The procedures of this component are: +/// - `mint_and_send`, which mints an asset and creates a note for the provided recipient. +/// - `receive_and_burn`, which receives the fungible asset from the active note and burns it. +/// - The token metadata accessors and owner-gated setters (see the embedded [`TokenMetadata`]). +/// +/// The `mint_and_send` procedure is gated by the active mint policy from the associated +/// [`TokenPolicyManager`]. `receive_and_burn` can only be called from a note script and is gated +/// by the active burn policy. +/// +/// [builder]: crate::code_builder::CodeBuilder +/// [`TokenPolicyManager`]: crate::account::policies::TokenPolicyManager +#[derive(Debug, Clone)] +pub struct FungibleFaucet { + token_supply: AssetAmount, + max_supply: AssetAmount, + decimals: u8, + symbol: TokenSymbol, + /// Embeds name, optional fields, and mutability flags. + metadata: TokenMetadata, +} + +#[bon::bon] +impl FungibleFaucet { + /// Returns a builder for [`FungibleFaucet`]. + /// + /// Required setters: [`name`], [`symbol`], [`decimals`], [`max_supply`]. + /// Optional fields default to `None` (string fields) or `false` (mutability flags); the initial + /// token supply defaults to zero. + /// + /// # Example + /// + /// ``` + /// # use miden_protocol::asset::{AssetAmount, TokenSymbol}; + /// # use miden_standards::account::faucets::FungibleFaucet; + /// # use miden_standards::account::faucets::{Description, LogoURI, TokenName}; + /// # fn main() -> Result<(), Box> { + /// let faucet = FungibleFaucet::builder() + /// .name(TokenName::new("My Token")?) + /// .symbol(TokenSymbol::new("MTK")?) + /// .decimals(8) + /// .max_supply(AssetAmount::from(1_000_000u32)) + /// .token_supply(AssetAmount::from(100u32)) + /// .description(Description::new("A test token")?) + /// .logo_uri(LogoURI::new("https://example.com/logo.png")?) + /// .build()?; + /// # Ok(()) + /// # } + /// ``` + /// + /// [`name`]: FungibleFaucetBuilder::name + /// [`symbol`]: FungibleFaucetBuilder::symbol + /// [`decimals`]: FungibleFaucetBuilder::decimals + /// [`max_supply`]: FungibleFaucetBuilder::max_supply + #[builder] + pub fn new( + name: TokenName, + symbol: TokenSymbol, + decimals: u8, + max_supply: AssetAmount, + #[builder(default)] token_supply: AssetAmount, + description: Option, + logo_uri: Option, + external_link: Option, + #[builder(default)] is_description_mutable: bool, + #[builder(default)] is_logo_uri_mutable: bool, + #[builder(default)] is_external_link_mutable: bool, + #[builder(default)] is_max_supply_mutable: bool, + ) -> Result { + let mut metadata = TokenMetadata::new(name); + if let Some(desc) = description { + metadata = metadata.with_description(desc, is_description_mutable); + } else { + metadata = metadata.with_description_mutable(is_description_mutable); + } + if let Some(uri) = logo_uri { + metadata = metadata.with_logo_uri(uri, is_logo_uri_mutable); + } else { + metadata = metadata.with_logo_uri_mutable(is_logo_uri_mutable); + } + if let Some(link) = external_link { + metadata = metadata.with_external_link(link, is_external_link_mutable); + } else { + metadata = metadata.with_external_link_mutable(is_external_link_mutable); + } + metadata = metadata.with_max_supply_mutable(is_max_supply_mutable); + + Self::new_validated(symbol, decimals, max_supply, token_supply, metadata) + } +} + +impl FungibleFaucet { + // CONSTANTS + // -------------------------------------------------------------------------------------------- + + /// The name of the component. + pub const NAME: &'static str = "miden::standards::components::faucets::fungible_faucet"; + + /// Returns the canonical [`AccountComponentName`] of this component. + pub const fn name() -> AccountComponentName { + AccountComponentName::from_static_str(Self::NAME) + } + + /// The maximum number of decimals supported. + pub const MAX_DECIMALS: u8 = 12; + + const MINT_PROC_NAME: &'static str = "mint_and_send"; + const RECEIVE_AND_BURN_PROC_NAME: &'static str = "receive_and_burn"; + const SET_MAX_SUPPLY_PROC_NAME: &'static str = "set_max_supply"; + const SET_DESCRIPTION_PROC_NAME: &'static str = "set_description"; + const SET_LOGO_URI_PROC_NAME: &'static str = "set_logo_uri"; + const SET_EXTERNAL_LINK_PROC_NAME: &'static str = "set_external_link"; + + // CONSTRUCTORS + // -------------------------------------------------------------------------------------------- + + /// Validates all fields and constructs a [`FungibleFaucet`]. + /// + /// This is the single point where `Self { ... }` is constructed. All other constructors + /// delegate here. + pub(crate) fn new_validated( + symbol: TokenSymbol, + decimals: u8, + max_supply: AssetAmount, + token_supply: AssetAmount, + metadata: TokenMetadata, + ) -> Result { + if decimals > Self::MAX_DECIMALS { + return Err(FungibleFaucetError::TooManyDecimals { + actual: decimals as u64, + max: Self::MAX_DECIMALS, + }); + } + + if token_supply > max_supply { + return Err(FungibleFaucetError::TokenSupplyExceedsMaxSupply { + token_supply: token_supply.as_u64(), + max_supply: max_supply.as_u64(), + }); + } + + Ok(Self { + token_supply, + max_supply, + decimals, + symbol, + metadata, + }) + } + + // PUBLIC ACCESSORS + // -------------------------------------------------------------------------------------------- + + /// Returns the [`AccountComponentCode`] of this component. + pub fn code() -> &'static AccountComponentCode { + &FUNGIBLE_FAUCET_CODE + } + + /// Returns the procedure root of the `mint_and_send` account procedure. + pub fn mint_and_send_root() -> AccountProcedureRoot { + *FUNGIBLE_FAUCET_MINT_AND_SEND + } + + /// Returns the procedure root of the `receive_and_burn` account procedure. + pub fn receive_and_burn_root() -> AccountProcedureRoot { + *FUNGIBLE_FAUCET_RECEIVE_AND_BURN + } + + /// Returns the procedure root of the `set_max_supply` account procedure. + pub fn set_max_supply_root() -> AccountProcedureRoot { + *FUNGIBLE_FAUCET_SET_MAX_SUPPLY + } + + /// Returns the procedure root of the `set_description` account procedure. + pub fn set_description_root() -> AccountProcedureRoot { + *FUNGIBLE_FAUCET_SET_DESCRIPTION + } + + /// Returns the procedure root of the `set_logo_uri` account procedure. + pub fn set_logo_uri_root() -> AccountProcedureRoot { + *FUNGIBLE_FAUCET_SET_LOGO_URI + } + + /// Returns the procedure root of the `set_external_link` account procedure. + pub fn set_external_link_root() -> AccountProcedureRoot { + *FUNGIBLE_FAUCET_SET_EXTERNAL_LINK + } + + /// Returns the [`StorageSlotName`] holding the token config word + /// `[token_supply, max_supply, decimals, token_symbol]`. + pub fn token_config_slot() -> &'static StorageSlotName { + &TOKEN_CONFIG_SLOT + } + + /// Returns the current token supply (amount issued). + pub fn token_supply(&self) -> AssetAmount { + self.token_supply + } + + /// Returns the maximum token supply. + pub fn max_supply(&self) -> AssetAmount { + self.max_supply + } + + /// Returns the number of decimals. + pub fn decimals(&self) -> u8 { + self.decimals + } + + /// Returns the token symbol. + pub fn symbol(&self) -> &TokenSymbol { + &self.symbol + } + + /// Returns the token name. + pub fn token_name(&self) -> &TokenName { + self.metadata.name() + } + + /// Returns the optional description. + pub fn description(&self) -> Option<&Description> { + self.metadata.description() + } + + /// Returns the optional logo URI. + pub fn logo_uri(&self) -> Option<&LogoURI> { + self.metadata.logo_uri() + } + + /// Returns the optional external link. + pub fn external_link(&self) -> Option<&ExternalLink> { + self.metadata.external_link() + } + + /// Returns the storage slot schema for the token config slot. + pub fn token_config_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + let token_symbol_type = SchemaType::new(TOKEN_SYMBOL_TYPE).expect("valid type"); + ( + Self::token_config_slot().clone(), + StorageSlotSchema::value( + "Token config", + [ + FeltSchema::felt("token_supply").with_default(Felt::ZERO), + FeltSchema::felt("max_supply"), + FeltSchema::u8("decimals"), + FeltSchema::new_typed(token_symbol_type, "symbol"), + ], + ), + ) + } + + /// Returns the [`AccountComponentMetadata`] for this component. + pub fn component_metadata() -> AccountComponentMetadata { + let mut schema_entries = vec![Self::token_config_slot_schema()]; + schema_entries.extend(TokenMetadata::storage_schema()); + + let storage_schema = + StorageSchema::new(schema_entries).expect("storage schema should be valid"); + + AccountComponentMetadata::new(Self::NAME) + .with_description( + "Fungible faucet component bundling minting, burning, and token metadata", + ) + .with_storage_schema(storage_schema) + } + + /// 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. + 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 + } + + /// Returns the single storage slot for the token config word. + pub fn token_config_slot_value(&self) -> StorageSlot { + let word = Word::new([ + self.token_supply.into(), + self.max_supply.into(), + Felt::from(self.decimals), + self.symbol.clone().into(), + ]); + StorageSlot::with_value(Self::token_config_slot().clone(), word) + } + + // MUTATORS + // -------------------------------------------------------------------------------------------- + + /// Sets the token_supply (in base units). + /// + /// # Errors + /// + /// Returns an error if: + /// - the token supply exceeds the max supply. + pub fn with_token_supply( + mut self, + token_supply: AssetAmount, + ) -> Result { + if token_supply > self.max_supply { + return Err(FungibleFaucetError::TokenSupplyExceedsMaxSupply { + token_supply: token_supply.as_u64(), + max_supply: self.max_supply.as_u64(), + }); + } + + self.token_supply = token_supply; + + Ok(self) + } + + /// Sets whether the description can be updated by the owner. + pub fn with_description_mutable(mut self, mutable: bool) -> Self { + self.metadata = self.metadata.with_description_mutable(mutable); + self + } + + /// Sets whether the logo URI can be updated by the owner. + pub fn with_logo_uri_mutable(mut self, mutable: bool) -> Self { + self.metadata = self.metadata.with_logo_uri_mutable(mutable); + self + } + + /// Sets whether the external link can be updated by the owner. + pub fn with_external_link_mutable(mut self, mutable: bool) -> Self { + self.metadata = self.metadata.with_external_link_mutable(mutable); + self + } + + /// Sets whether the max supply can be updated by the owner. + pub fn with_max_supply_mutable(mut self, mutable: bool) -> Self { + self.metadata = self.metadata.with_max_supply_mutable(mutable); + self + } + + // INTERFACE EXTRACTION + // -------------------------------------------------------------------------------------------- + + /// Checks that the account contains the fungible faucet interface. + fn try_from_interface( + interface: AccountInterface, + storage: &AccountStorage, + ) -> Result { + if !interface.components().contains(&AccountComponentInterface::FungibleFaucet) { + return Err(FungibleFaucetError::MissingFungibleFaucetInterface); + } + + FungibleFaucet::try_from(storage) + } + + /// Reconstructs from the token config word and the embedded [`TokenMetadata`] read from + /// storage. + pub(crate) fn from_token_config_word_and_token_metadata( + word: Word, + metadata: TokenMetadata, + ) -> Result { + let [token_supply, max_supply, decimals_felt, token_symbol] = *word; + let symbol = + TokenSymbol::try_from(token_symbol).map_err(TokenMetadataError::InvalidTokenSymbol)?; + let decimals: u8 = decimals_felt.as_canonical_u64().try_into().map_err(|_| { + FungibleFaucetError::TooManyDecimals { + actual: decimals_felt.as_canonical_u64(), + max: Self::MAX_DECIMALS, + } + })?; + let max_supply = AssetAmount::try_from(max_supply).map_err(|_| { + FungibleFaucetError::MaxSupplyTooLarge { + actual: max_supply.as_canonical_u64(), + max: AssetAmount::MAX.as_u64(), + } + })?; + let token_supply = AssetAmount::try_from(token_supply).map_err(|_| { + FungibleFaucetError::MaxSupplyTooLarge { + actual: token_supply.as_canonical_u64(), + max: AssetAmount::MAX.as_u64(), + } + })?; + + Self::new_validated(symbol, decimals, max_supply, token_supply, metadata) + } +} + +// TRAIT IMPLEMENTATIONS +// ================================================================================================ + +impl From for AccountComponent { + fn from(faucet: FungibleFaucet) -> Self { + let component_metadata = FungibleFaucet::component_metadata(); + let storage_slots = faucet.into_storage_slots(); + + AccountComponent::new(FungibleFaucet::code().clone(), storage_slots, component_metadata) + .expect("fungible faucet component should satisfy the requirements of a valid account component") + } +} + +impl TryFrom<&AccountStorage> for FungibleFaucet { + type Error = FungibleFaucetError; + + /// Reconstructs [`FungibleFaucet`] by reading all relevant storage slots: the token + /// config word, name, mutability config, description, logo URI, and external link. + fn try_from(storage: &AccountStorage) -> Result { + let token_config_word = storage.get_item(Self::token_config_slot()).map_err(|err| { + TokenMetadataError::StorageLookupFailed { + slot_name: Self::token_config_slot().clone(), + source: err, + } + })?; + + let token_metadata = TokenMetadata::try_from_storage(storage)?; + + Self::from_token_config_word_and_token_metadata(token_config_word, token_metadata) + } +} + +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()) + } +} + +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()) + } +} + +// 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 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`). +/// +/// Only specific `(access_control, auth_method)` combinations are supported; everything else +/// is rejected at the factory level. The valid combinations are: +/// +/// - [`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( + 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 account = AccountBuilder::new(init_seed) + .account_type(account_type) + .with_auth_component(auth_component) + .with_component(faucet) + .with_components(access_control) + .with_components(token_policy_manager) + .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(), + )), + } +} diff --git a/crates/miden-standards/src/account/faucets/fungible/tests.rs b/crates/miden-standards/src/account/faucets/fungible/tests.rs new file mode 100644 index 0000000000..96c3174aea --- /dev/null +++ b/crates/miden-standards/src/account/faucets/fungible/tests.rs @@ -0,0 +1,288 @@ +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::asset::{AssetAmount, TokenSymbol}; +use miden_protocol::{Felt, Word}; + +use super::{FungibleFaucet, create_fungible_faucet}; +use crate::AuthMethod; +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::wallets::BasicWallet; + +/// 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() +} + +/// Builds a sample `FungibleFaucet` shared by construction tests. +fn sample_faucet() -> FungibleFaucet { + FungibleFaucet::builder() + .name(TokenName::new("polygon").unwrap()) + .symbol(TokenSymbol::try_from("POL").unwrap()) + .decimals(2) + .max_supply(AssetAmount::from(123u32)) + .description(Description::new("A polygon token").unwrap()) + .build() + .unwrap() +} + +/// Reads every trigger-procedure-root map entry from `0..num` and returns the set. +fn read_trigger_procedure_roots( + account: &miden_protocol::account::Account, + num: u32, +) -> BTreeSet { + (0..num) + .map(|i| { + account + .storage() + .get_map_item( + AuthSingleSigAcl::trigger_procedure_roots_slot(), + [Felt::from(i), Felt::ZERO, Felt::ZERO, Felt::ZERO].into(), + ) + .unwrap() + }) + .collect() +} + +#[test] +fn faucet_contract_creation() { + 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, + ]; + + let token_symbol_string = "POL"; + let token_symbol = TokenSymbol::try_from(token_symbol_string).unwrap(); + let token_name_string = "polygon"; + let description_string = "A polygon token"; + + let faucet = sample_faucet(); + let faucet_account = create_fungible_faucet( + init_seed, + faucet, + AccountType::Private, + auth_method, + AccessControl::AuthControlled, + allow_all_policy_manager(), + ) + .unwrap(); + + // The falcon 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]. + 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(), + 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(), + ] + .into_iter() + .map(|root| root.as_word()) + .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] + 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(); + assert_eq!(decoded_name.as_str(), token_name_string); + let expected_desc_words = Description::new(description_string).unwrap().to_words(); + for (i, expected) in expected_desc_words.iter().enumerate() { + let chunk = faucet_account.storage().get_item(TokenMetadata::description_slot(i)).unwrap(); + assert_eq!(chunk, *expected); + } + + // Verify the faucet component can be extracted + let _faucet_component = FungibleFaucet::try_from(faucet_account.clone()).unwrap(); +} + +#[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() { + 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 _account = create_fungible_faucet( + [7u8; 32], + sample_faucet(), + AccountType::Public, + AuthMethod::NoAuth, + AccessControl::Ownable2Step { owner }, + allow_all_policy_manager(), + ) + .expect("Ownable2Step+NoAuth 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()) + .symbol(token_symbol) + .decimals(10) + .max_supply(AssetAmount::from(100u32)) + .build() + .expect("failed to create faucet"); + + let faucet_account = AccountBuilder::new(mock_seed) + .with_component(faucet) + .with_auth_component(AuthSingleSig::new(mock_public_key, AuthScheme::Falcon512Poseidon2)) + .build_existing() + .expect("failed to create wallet account"); + + let _fungible_faucet = + FungibleFaucet::try_from(faucet_account).expect("fungible faucet creation failed"); + + // 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"); + + let err = FungibleFaucet::try_from(invalid_faucet_account) + .expect_err("fungible faucet creation should fail"); + assert_matches!(err, FungibleFaucetError::MissingFungibleFaucetInterface); +} + +/// Check that the obtaining of the fungible faucet procedure roots does not panic. +#[test] +fn get_faucet_procedures() { + let _mint_and_send_root = FungibleFaucet::mint_and_send_root(); + let _receive_and_burn_root = FungibleFaucet::receive_and_burn_root(); + let _set_max_supply_root = FungibleFaucet::set_max_supply_root(); + let _set_description_root = FungibleFaucet::set_description_root(); + let _set_logo_uri_root = FungibleFaucet::set_logo_uri_root(); + let _set_external_link_root = FungibleFaucet::set_external_link_root(); + let _set_mint_policy_root = TokenPolicyManager::set_mint_policy_root(); + let _set_burn_policy_root = TokenPolicyManager::set_burn_policy_root(); + let _set_send_policy_root = TokenPolicyManager::set_send_policy_root(); + let _set_receive_policy_root = TokenPolicyManager::set_receive_policy_root(); +} diff --git a/crates/miden-standards/src/account/faucets/mod.rs b/crates/miden-standards/src/account/faucets/mod.rs index df1f3adc16..4e8294ded5 100644 --- a/crates/miden-standards/src/account/faucets/mod.rs +++ b/crates/miden-standards/src/account/faucets/mod.rs @@ -5,14 +5,41 @@ use miden_protocol::errors::{AccountError, TokenSymbolError}; use thiserror::Error; use crate::account::access::Ownable2StepError; +use crate::utils::FixedWidthStringError; -mod basic_fungible; -mod network_fungible; +mod fungible; mod token_metadata; -pub use basic_fungible::{BasicFungibleFaucet, create_basic_fungible_faucet}; -pub use network_fungible::{NetworkFungibleFaucet, create_network_fungible_faucet}; -pub use token_metadata::TokenMetadata; +pub use fungible::{FungibleFaucet, FungibleFaucetBuilder, create_fungible_faucet}; +pub use token_metadata::{Description, ExternalLink, LogoURI, TokenMetadata, TokenName}; + +// TOKEN METADATA ERROR +// ================================================================================================ + +/// Errors raised when parsing token metadata from storage. +#[derive(Debug, Error)] +pub enum TokenMetadataError { + #[error("failed to retrieve storage slot with name {slot_name}")] + StorageLookupFailed { + slot_name: StorageSlotName, + source: AccountError, + }, + #[error("invalid string data in field '{field}'")] + InvalidStringField { + field: &'static str, + #[source] + source: FixedWidthStringError, + }, + #[error("mutability flag at index {index} has invalid value {value}: must be 0 or 1")] + InvalidMutabilityFlag { index: usize, value: u64 }, + #[error("storage slot name mismatch: expected {expected}, got {actual}")] + SlotNameMismatch { + expected: StorageSlotName, + actual: StorageSlotName, + }, + #[error("invalid token symbol")] + InvalidTokenSymbol(#[source] TokenSymbolError), +} // FUNGIBLE FAUCET ERROR // ================================================================================================ @@ -29,31 +56,19 @@ pub enum FungibleFaucetError { #[error( "account interface does not have the procedures of the basic fungible faucet component" )] - MissingBasicFungibleFaucetInterface, - #[error( - "account interface does not have the procedures of the network fungible faucet component" - )] - MissingNetworkFungibleFaucetInterface, - #[error("failed to retrieve storage slot with name {slot_name}")] - StorageLookupFailed { - slot_name: StorageSlotName, - source: AccountError, - }, - #[error("invalid token symbol")] - InvalidTokenSymbol(#[source] TokenSymbolError), - #[error("storage slot name mismatch: expected {expected}, got {actual}")] - SlotNameMismatch { - expected: StorageSlotName, - actual: StorageSlotName, - }, + MissingFungibleFaucetInterface, #[error("unsupported authentication method: {0}")] UnsupportedAuthMethod(String), - #[error("unsupported access control method: {0}")] - UnsupportedAccessControl(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")] NotAFungibleFaucetAccount, #[error("failed to read ownership data from storage")] OwnershipError(#[source] Ownable2StepError), + #[error(transparent)] + TokenMetadata(#[from] TokenMetadataError), } diff --git a/crates/miden-standards/src/account/faucets/network_fungible.rs b/crates/miden-standards/src/account/faucets/network_fungible.rs deleted file mode 100644 index bb7ba4e6be..0000000000 --- a/crates/miden-standards/src/account/faucets/network_fungible.rs +++ /dev/null @@ -1,370 +0,0 @@ -use miden_protocol::account::component::{ - AccountComponentMetadata, - FeltSchema, - SchemaType, - StorageSchema, - StorageSlotSchema, -}; -use miden_protocol::account::{ - Account, - AccountBuilder, - AccountComponent, - AccountStorage, - AccountStorageMode, - AccountType, - StorageSlotName, -}; -use miden_protocol::asset::TokenSymbol; -use miden_protocol::{Felt, Word}; - -use super::{FungibleFaucetError, TokenMetadata}; -use crate::account::access::AccessControl; -use crate::account::auth::NoAuth; -use crate::account::components::network_fungible_faucet_library; -use crate::account::interface::{AccountComponentInterface, AccountInterface, AccountInterfaceExt}; -use crate::account::mint_policies::OwnerControlled; -use crate::procedure_digest; - -/// The schema type for token symbols. -const TOKEN_SYMBOL_TYPE: &str = "miden::standards::fungible_faucets::metadata::token_symbol"; - -// NETWORK FUNGIBLE FAUCET ACCOUNT COMPONENT -// ================================================================================================ - -// Initialize the digest of the `mint_and_send` procedure of the Network Fungible Faucet only once. -procedure_digest!( - NETWORK_FUNGIBLE_FAUCET_MINT_AND_SEND, - NetworkFungibleFaucet::NAME, - NetworkFungibleFaucet::MINT_PROC_NAME, - network_fungible_faucet_library -); - -// Initialize the digest of the `burn` procedure of the Network Fungible Faucet only once. -procedure_digest!( - NETWORK_FUNGIBLE_FAUCET_BURN, - NetworkFungibleFaucet::NAME, - NetworkFungibleFaucet::BURN_PROC_NAME, - network_fungible_faucet_library -); - -/// An [`AccountComponent`] implementing a network fungible faucet. -/// -/// It reexports the procedures from `miden::standards::faucets::network_fungible`. When linking -/// against this component, the `miden` library (i.e. -/// [`ProtocolLib`](miden_protocol::ProtocolLib)) must be available to the assembler which is the -/// case when using [`CodeBuilder`][builder]. The procedures of this component are: -/// - `mint_and_send`, which mints an assets and create a note for the provided recipient. -/// - `burn`, which burns the provided asset. -/// -/// Both `mint_and_send` and `burn` can only be called from note scripts. `mint_and_send` requires -/// authentication while `burn` does not require authentication and can be called by anyone. -/// Thus, this component must be combined with a component providing authentication. -/// -/// This component relies on [`crate::account::access::Ownable2Step`] for ownership checks in -/// `mint_and_send`. When building an account with this component, -/// [`crate::account::access::Ownable2Step`] must also be included. -/// -/// ## Storage Layout -/// -/// - [`Self::metadata_slot`]: Fungible faucet metadata. -/// -/// [builder]: crate::code_builder::CodeBuilder -pub struct NetworkFungibleFaucet { - metadata: TokenMetadata, -} - -impl NetworkFungibleFaucet { - // CONSTANTS - // -------------------------------------------------------------------------------------------- - - /// The name of the component. - pub const NAME: &'static str = "miden::standards::components::faucets::network_fungible_faucet"; - - /// The maximum number of decimals supported by the component. - pub const MAX_DECIMALS: u8 = TokenMetadata::MAX_DECIMALS; - - const MINT_PROC_NAME: &str = "mint_and_send"; - const BURN_PROC_NAME: &str = "burn"; - - // CONSTRUCTORS - // -------------------------------------------------------------------------------------------- - - /// Creates a new [`NetworkFungibleFaucet`] component from the given pieces of metadata. - /// - /// # Errors: - /// Returns an error if: - /// - the decimals parameter exceeds maximum value of [`Self::MAX_DECIMALS`]. - /// - the max supply parameter exceeds maximum possible amount for a fungible asset - /// ([`miden_protocol::asset::FungibleAsset::MAX_AMOUNT`]) - pub fn new( - symbol: TokenSymbol, - decimals: u8, - max_supply: Felt, - ) -> Result { - let metadata = TokenMetadata::new(symbol, decimals, max_supply)?; - Ok(Self { metadata }) - } - - /// Creates a new [`NetworkFungibleFaucet`] component from the given [`TokenMetadata`]. - /// - /// This is a convenience constructor that allows creating a faucet from pre-validated - /// metadata. - pub fn from_metadata(metadata: TokenMetadata) -> Self { - Self { metadata } - } - - /// Attempts to create a new [`NetworkFungibleFaucet`] component from the associated account - /// interface and storage. - /// - /// # Errors: - /// Returns an error if: - /// - the provided [`AccountInterface`] does not contain a - /// [`AccountComponentInterface::NetworkFungibleFaucet`] component. - /// - the decimals parameter exceeds maximum value of [`Self::MAX_DECIMALS`]. - /// - the max supply value exceeds maximum possible amount for a fungible asset of - /// [`miden_protocol::asset::FungibleAsset::MAX_AMOUNT`]. - /// - the token supply exceeds the max supply. - /// - the token symbol encoded value exceeds the maximum value of - /// [`TokenSymbol::MAX_ENCODED_VALUE`]. - fn try_from_interface( - interface: AccountInterface, - storage: &AccountStorage, - ) -> Result { - // Check that the procedures of the network fungible faucet exist in the account. - if !interface - .components() - .contains(&AccountComponentInterface::NetworkFungibleFaucet) - { - return Err(FungibleFaucetError::MissingNetworkFungibleFaucetInterface); - } - - // Read token metadata from storage - let metadata = TokenMetadata::try_from(storage)?; - - Ok(Self { metadata }) - } - - // PUBLIC ACCESSORS - // -------------------------------------------------------------------------------------------- - - /// Returns the [`StorageSlotName`] where the [`NetworkFungibleFaucet`]'s metadata is stored. - pub fn metadata_slot() -> &'static StorageSlotName { - TokenMetadata::metadata_slot() - } - - /// Returns the storage slot schema for the metadata slot. - pub fn metadata_slot_schema() -> (StorageSlotName, StorageSlotSchema) { - let token_symbol_type = SchemaType::new(TOKEN_SYMBOL_TYPE).expect("valid type"); - ( - Self::metadata_slot().clone(), - StorageSlotSchema::value( - "Token metadata", - [ - FeltSchema::felt("token_supply").with_default(Felt::new(0)), - FeltSchema::felt("max_supply"), - FeltSchema::u8("decimals"), - FeltSchema::new_typed(token_symbol_type, "symbol"), - ], - ), - ) - } - - /// Returns the token metadata. - pub fn metadata(&self) -> &TokenMetadata { - &self.metadata - } - - /// Returns the symbol of the faucet. - pub fn symbol(&self) -> &TokenSymbol { - self.metadata.symbol() - } - - /// Returns the decimals of the faucet. - pub fn decimals(&self) -> u8 { - self.metadata.decimals() - } - - /// Returns the max supply (in base units) of the faucet. - /// - /// This is the highest amount of tokens that can be minted from this faucet. - pub fn max_supply(&self) -> Felt { - self.metadata.max_supply() - } - - /// Returns the token supply (in base units) of the faucet. - /// - /// This is the amount of tokens that were minted from the faucet so far. Its value can never - /// exceed [`Self::max_supply`]. - pub fn token_supply(&self) -> Felt { - self.metadata.token_supply() - } - - /// Returns the digest of the `mint_and_send` account procedure. - pub fn mint_and_send_digest() -> Word { - *NETWORK_FUNGIBLE_FAUCET_MINT_AND_SEND - } - - /// Returns the digest of the `burn` account procedure. - pub fn burn_digest() -> Word { - *NETWORK_FUNGIBLE_FAUCET_BURN - } - - // MUTATORS - // -------------------------------------------------------------------------------------------- - - /// Sets the token_supply (in base units) of the network fungible faucet. - /// - /// # Errors - /// - /// Returns an error if: - /// - the token supply exceeds the max supply. - pub fn with_token_supply(mut self, token_supply: Felt) -> Result { - self.metadata = self.metadata.with_token_supply(token_supply)?; - Ok(self) - } - - /// Returns the [`AccountComponentMetadata`] for this component. - pub fn component_metadata() -> AccountComponentMetadata { - let storage_schema = StorageSchema::new([Self::metadata_slot_schema()]) - .expect("storage schema should be valid"); - - AccountComponentMetadata::new(Self::NAME, [AccountType::FungibleFaucet]) - .with_description("Network fungible faucet component for minting and burning tokens") - .with_storage_schema(storage_schema) - } -} - -impl From for AccountComponent { - fn from(network_faucet: NetworkFungibleFaucet) -> Self { - let metadata_slot = network_faucet.metadata.into(); - let metadata = NetworkFungibleFaucet::component_metadata(); - - AccountComponent::new( - network_fungible_faucet_library(), - vec![metadata_slot], - metadata, - ) - .expect("network fungible faucet component should satisfy the requirements of a valid account component") - } -} - -impl TryFrom for NetworkFungibleFaucet { - type Error = FungibleFaucetError; - - fn try_from(account: Account) -> Result { - let account_interface = AccountInterface::from_account(&account); - - NetworkFungibleFaucet::try_from_interface(account_interface, account.storage()) - } -} - -impl TryFrom<&Account> for NetworkFungibleFaucet { - type Error = FungibleFaucetError; - - fn try_from(account: &Account) -> Result { - let account_interface = AccountInterface::from_account(account); - - NetworkFungibleFaucet::try_from_interface(account_interface, account.storage()) - } -} - -/// Creates a new faucet account with network fungible faucet interface and provided metadata -/// (token symbol, decimals, max supply) and access control. -/// -/// The network faucet interface exposes two procedures: -/// - `mint_and_send`, which mints an assets and create a note for the provided recipient. -/// - `burn`, which burns the provided asset. -/// -/// Both `mint_and_send` and `burn` can only be called from note scripts. `mint_and_send` requires -/// authentication using the NoAuth scheme. `burn` does not require authentication and can be -/// called by anyone. -/// -/// Network fungible faucets always use: -/// - [`AccountStorageMode::Network`] for storage -/// - [`NoAuth`] for authentication -/// -/// The storage layout of the faucet account is documented on the [`NetworkFungibleFaucet`] and -/// [`OwnerControlled`] and [`crate::account::access::Ownable2Step`] component types and -/// contains no additional storage slots for its auth ([`NoAuth`]). -pub fn create_network_fungible_faucet( - init_seed: [u8; 32], - symbol: TokenSymbol, - decimals: u8, - max_supply: Felt, - access_control: AccessControl, -) -> Result { - // Validate that access_control is Ownable2Step, as this faucet depends on it. - // When new variants are added to AccessControl, update this match to either support - // them or return Err(FungibleFaucetError::UnsupportedAccessControl). - match access_control { - AccessControl::Ownable2Step { .. } => {}, - #[allow(unreachable_patterns)] - _ => { - return Err(FungibleFaucetError::UnsupportedAccessControl( - "network fungible faucets require Ownable2Step access control".into(), - )); - }, - } - - let auth_component: AccountComponent = NoAuth::new().into(); - - let account = AccountBuilder::new(init_seed) - .account_type(AccountType::FungibleFaucet) - .storage_mode(AccountStorageMode::Network) - .with_auth_component(auth_component) - .with_component(NetworkFungibleFaucet::new(symbol, decimals, max_supply)?) - .with_component(access_control) - .with_component(OwnerControlled::owner_only()) - .build() - .map_err(FungibleFaucetError::AccountError)?; - - Ok(account) -} - -// TESTS -// ================================================================================================ - -#[cfg(test)] -mod tests { - use miden_protocol::account::{AccountId, AccountIdVersion, AccountStorageMode, AccountType}; - - use super::*; - use crate::account::access::Ownable2Step; - - #[test] - fn test_create_network_fungible_faucet() { - let init_seed = [7u8; 32]; - let symbol = TokenSymbol::new("NET").expect("token symbol should be valid"); - let decimals = 8u8; - let max_supply = Felt::new(1_000); - - let owner = AccountId::dummy( - [1u8; 15], - AccountIdVersion::Version0, - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Private, - ); - - let account = create_network_fungible_faucet( - init_seed, - symbol.clone(), - decimals, - max_supply, - AccessControl::Ownable2Step { owner }, - ) - .expect("network faucet creation should succeed"); - - let expected_owner_word = Ownable2Step::new(owner).to_word(); - assert_eq!( - account.storage().get_item(Ownable2Step::slot_name()).unwrap(), - expected_owner_word - ); - - let faucet = NetworkFungibleFaucet::try_from(&account) - .expect("network fungible faucet should be extractable from account"); - assert_eq!(faucet.symbol(), &symbol); - assert_eq!(faucet.decimals(), decimals); - assert_eq!(faucet.max_supply(), max_supply); - assert_eq!(faucet.token_supply(), Felt::ZERO); - } -} diff --git a/crates/miden-standards/src/account/faucets/token_metadata.rs b/crates/miden-standards/src/account/faucets/token_metadata.rs index bdca915fa5..36096ca15a 100644 --- a/crates/miden-standards/src/account/faucets/token_metadata.rs +++ b/crates/miden-standards/src/account/faucets/token_metadata.rs @@ -1,348 +1,633 @@ +//! Generic token metadata helper. +//! +//! [`TokenMetadata`] is a builder-pattern struct used to manage the token name and optional +//! fields (description, logo_uri, external_link) along with their mutability flags. It is meant +//! to be embedded inside a token-bearing component such as +//! [`FungibleFaucet`][crate::account::faucets::FungibleFaucet], not used as a +//! standalone account component. +//! +//! Owner-gated mutators (`set_description`, `set_logo_uri`, `set_external_link`, +//! `set_max_supply`) are exposed through the embedding component's MASM library and rely on +//! the `Ownable2Step` access component for ownership checks. +//! +//! ## Storage layout (per-component, see embedding component for absolute slot order) +//! +//! | Slot name | Contents | +//! |-----------|----------| +//! | `faucets::token_name_0` | first 4 felts of name | +//! | `faucets::token_name_1` | last 4 felts of name | +//! | `faucets::mutability_config` | `[is_desc_mutable, is_logo_mutable, is_extlink_mutable, is_max_supply_mutable]` | +//! | `faucets::token_description_0..=6` | description (7 Words, max 195 bytes) | +//! | `faucets::logo_uri_0..=6` | logo URI (7 Words, max 195 bytes) | +//! | `faucets::external_link_0..=6` | external link (7 Words, max 195 bytes) | +//! +//! Layout sync: the same layout is defined in MASM at `asm/standards/faucets/mod.masm`. +//! Any change to slot names must be applied in both Rust and MASM. +//! +//! ## String encoding (UTF-8) +//! +//! All string fields use **7-bytes-per-felt, length-prefixed** encoding. The N felts are +//! serialized into a flat buffer of N × 7 bytes; byte 0 is the string length, followed by +//! UTF-8 content, zero-padded. Each 7-byte chunk is stored as a LE u64 with the high byte +//! always zero, so it always fits in a Goldilocks field element. +//! +//! The name slots hold 2 Words (8 felts, capacity 55 bytes, capped at 32). + +use alloc::vec::Vec; + +use miden_protocol::account::component::{FeltSchema, StorageSlotSchema}; use miden_protocol::account::{AccountStorage, StorageSlot, StorageSlotName}; -use miden_protocol::asset::{FungibleAsset, TokenSymbol}; use miden_protocol::utils::sync::LazyLock; use miden_protocol::{Felt, Word}; -use super::FungibleFaucetError; +use crate::account::faucets::TokenMetadataError; +use crate::utils::{FixedWidthString, FixedWidthStringError}; -// CONSTANTS +// SLOT NAMES — canonical layout (sync with asm/standards/faucets/fungible.masm) // ================================================================================================ -static METADATA_SLOT_NAME: LazyLock = LazyLock::new(|| { - StorageSlotName::new("miden::standards::fungible_faucets::metadata") +/// Token name (2 Words = 8 felts), split across 2 slots. +static NAME_SLOTS: LazyLock<[StorageSlotName; 2]> = LazyLock::new(|| { + [ + StorageSlotName::new("miden::standards::faucets::token_name_0").expect("valid slot name"), + StorageSlotName::new("miden::standards::faucets::token_name_1").expect("valid slot name"), + ] +}); + +/// Mutability config slot: `[is_desc_mutable, is_logo_mutable, is_extlink_mutable, +/// is_max_supply_mutable]`. +static MUTABILITY_CONFIG_SLOT: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::faucets::mutability_config") .expect("storage slot name should be valid") }); -// TOKEN METADATA -// ================================================================================================ +/// Description (7 Words), split across 7 slots. +static DESCRIPTION_SLOTS: LazyLock<[StorageSlotName; 7]> = LazyLock::new(|| { + [ + StorageSlotName::new("miden::standards::faucets::token_description_0") + .expect("valid slot name"), + StorageSlotName::new("miden::standards::faucets::token_description_1") + .expect("valid slot name"), + StorageSlotName::new("miden::standards::faucets::token_description_2") + .expect("valid slot name"), + StorageSlotName::new("miden::standards::faucets::token_description_3") + .expect("valid slot name"), + StorageSlotName::new("miden::standards::faucets::token_description_4") + .expect("valid slot name"), + StorageSlotName::new("miden::standards::faucets::token_description_5") + .expect("valid slot name"), + StorageSlotName::new("miden::standards::faucets::token_description_6") + .expect("valid slot name"), + ] +}); -/// Token metadata for fungible faucet accounts. -/// -/// This struct encapsulates the metadata associated with a fungible token faucet: -/// - `token_supply`: The current amount of tokens issued by the faucet. -/// - `max_supply`: The maximum amount of tokens that can be issued. -/// - `decimals`: The number of decimal places for token amounts. -/// - `symbol`: The token symbol. -/// -/// The metadata is stored in a single storage slot as: -/// `[token_supply, max_supply, decimals, symbol]` -#[derive(Debug, Clone)] -pub struct TokenMetadata { - token_supply: Felt, - max_supply: Felt, - decimals: u8, - symbol: TokenSymbol, -} +/// Logo URI (7 Words), split across 7 slots. +static LOGO_URI_SLOTS: LazyLock<[StorageSlotName; 7]> = LazyLock::new(|| { + [ + StorageSlotName::new("miden::standards::faucets::logo_uri_0").expect("valid slot name"), + StorageSlotName::new("miden::standards::faucets::logo_uri_1").expect("valid slot name"), + StorageSlotName::new("miden::standards::faucets::logo_uri_2").expect("valid slot name"), + StorageSlotName::new("miden::standards::faucets::logo_uri_3").expect("valid slot name"), + StorageSlotName::new("miden::standards::faucets::logo_uri_4").expect("valid slot name"), + StorageSlotName::new("miden::standards::faucets::logo_uri_5").expect("valid slot name"), + StorageSlotName::new("miden::standards::faucets::logo_uri_6").expect("valid slot name"), + ] +}); -impl TokenMetadata { - // CONSTANTS - // -------------------------------------------------------------------------------------------- +/// External link (7 Words), split across 7 slots. +static EXTERNAL_LINK_SLOTS: LazyLock<[StorageSlotName; 7]> = LazyLock::new(|| { + [ + StorageSlotName::new("miden::standards::faucets::external_link_0") + .expect("valid slot name"), + StorageSlotName::new("miden::standards::faucets::external_link_1") + .expect("valid slot name"), + StorageSlotName::new("miden::standards::faucets::external_link_2") + .expect("valid slot name"), + StorageSlotName::new("miden::standards::faucets::external_link_3") + .expect("valid slot name"), + StorageSlotName::new("miden::standards::faucets::external_link_4") + .expect("valid slot name"), + StorageSlotName::new("miden::standards::faucets::external_link_5") + .expect("valid slot name"), + StorageSlotName::new("miden::standards::faucets::external_link_6") + .expect("valid slot name"), + ] +}); - /// The maximum number of decimals supported. - pub const MAX_DECIMALS: u8 = 12; +/// Returns the [`StorageSlotName`] for the mutability config Word. +pub(crate) fn mutability_config_slot() -> &'static StorageSlotName { + &MUTABILITY_CONFIG_SLOT +} - // CONSTRUCTORS - // -------------------------------------------------------------------------------------------- +/// Maximum length of a name in bytes when using the UTF-8 encoding (capped at 32). +pub(crate) const NAME_UTF8_MAX_BYTES: usize = 32; - /// Creates a new [`TokenMetadata`] with the specified metadata and zero token supply. - /// - /// # Errors - /// Returns an error if: - /// - The decimals parameter exceeds [`Self::MAX_DECIMALS`]. - /// - The max supply parameter exceeds [`FungibleAsset::MAX_AMOUNT`]. - pub fn new( - symbol: TokenSymbol, - decimals: u8, - max_supply: Felt, - ) -> Result { - Self::with_supply(symbol, decimals, max_supply, Felt::ZERO) - } - - /// Creates a new [`TokenMetadata`] with the specified metadata and token supply. - /// - /// # Errors - /// Returns an error if: - /// - The decimals parameter exceeds [`Self::MAX_DECIMALS`]. - /// - The max supply parameter exceeds [`FungibleAsset::MAX_AMOUNT`]. - /// - The token supply exceeds the max supply. - pub fn with_supply( - symbol: TokenSymbol, - decimals: u8, - max_supply: Felt, - token_supply: Felt, - ) -> Result { - if decimals > Self::MAX_DECIMALS { - return Err(FungibleFaucetError::TooManyDecimals { - actual: decimals as u64, - max: Self::MAX_DECIMALS, - }); - } +// TOKEN NAME +// ================================================================================================ - if max_supply.as_canonical_u64() > FungibleAsset::MAX_AMOUNT { - return Err(FungibleFaucetError::MaxSupplyTooLarge { - actual: max_supply.as_canonical_u64(), - max: FungibleAsset::MAX_AMOUNT, - }); +/// Token display name (max 32 bytes UTF-8), stored in 2 Words. +/// +/// The maximum is intentionally capped at 32 bytes even though the 2-Word encoding could +/// hold up to 55 bytes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TokenName(FixedWidthString<2>); + +impl TokenName { + /// Maximum byte length for a token name (capped at 32, below the 55-byte capacity). + pub const MAX_BYTES: usize = NAME_UTF8_MAX_BYTES; + + /// Creates a token name from a UTF-8 string (at most 32 bytes). + pub fn new(s: &str) -> Result { + if s.len() > Self::MAX_BYTES { + return Err(FixedWidthStringError::TooLong { max: Self::MAX_BYTES, actual: s.len() }); } + Ok(Self(FixedWidthString::new(s).expect("length already validated above"))) + } + + /// Returns the name as a string slice. + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + /// Encodes the name into 2 Words for storage. + pub fn to_words(&self) -> Vec { + self.0.to_words() + } - if token_supply.as_canonical_u64() > max_supply.as_canonical_u64() { - return Err(FungibleFaucetError::TokenSupplyExceedsMaxSupply { - token_supply: token_supply.as_canonical_u64(), - max_supply: max_supply.as_canonical_u64(), + /// Decodes a token name from a 2-Word slice. + pub fn try_from_words(words: &[Word]) -> Result { + let inner = FixedWidthString::<2>::try_from_words(words)?; + if inner.as_str().len() > Self::MAX_BYTES { + return Err(FixedWidthStringError::TooLong { + max: Self::MAX_BYTES, + actual: inner.as_str().len(), }); } + Ok(Self(inner)) + } +} + +// FIELD TYPES +// ================================================================================================ + +/// Token description (max 195 bytes UTF-8), stored in 7 Words. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Description(FixedWidthString<7>); + +impl Description { + /// Maximum byte length for a description (7 Words × 4 felts × 7 bytes − 1 length byte). + pub const MAX_BYTES: usize = FixedWidthString::<7>::CAPACITY; - Ok(Self { - token_supply, - max_supply, - decimals, - symbol, - }) + /// Creates a description from a UTF-8 string. + pub fn new(s: &str) -> Result { + FixedWidthString::<7>::new(s).map(Self) } - // PUBLIC ACCESSORS - // -------------------------------------------------------------------------------------------- + /// Returns the description as a string slice. + pub fn as_str(&self) -> &str { + self.0.as_str() + } - /// Returns the [`StorageSlotName`] where the token metadata is stored. - pub fn metadata_slot() -> &'static StorageSlotName { - &METADATA_SLOT_NAME + /// Encodes the description into 7 Words for storage. + pub fn to_words(&self) -> Vec { + self.0.to_words() } - /// Returns the current token supply (amount issued). - pub fn token_supply(&self) -> Felt { - self.token_supply + /// Decodes a description from a 7-Word slice. + pub fn try_from_words(words: &[Word]) -> Result { + FixedWidthString::<7>::try_from_words(words).map(Self) } +} + +/// Token logo URI (max 195 bytes UTF-8), stored in 7 Words. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LogoURI(FixedWidthString<7>); - /// Returns the maximum token supply. - pub fn max_supply(&self) -> Felt { - self.max_supply +impl LogoURI { + /// Maximum byte length for a logo URI (7 Words × 4 felts × 7 bytes − 1 length byte). + pub const MAX_BYTES: usize = FixedWidthString::<7>::CAPACITY; + + /// Creates a logo URI from a UTF-8 string. + pub fn new(s: &str) -> Result { + FixedWidthString::<7>::new(s).map(Self) } - /// Returns the number of decimals. - pub fn decimals(&self) -> u8 { - self.decimals + /// Returns the logo URI as a string slice. + pub fn as_str(&self) -> &str { + self.0.as_str() } - /// Returns the token symbol. - pub fn symbol(&self) -> &TokenSymbol { - &self.symbol + /// Encodes the logo URI into 7 Words for storage. + pub fn to_words(&self) -> Vec { + self.0.to_words() } - // MUTATORS - // -------------------------------------------------------------------------------------------- + /// Decodes a logo URI from a 7-Word slice. + pub fn try_from_words(words: &[Word]) -> Result { + FixedWidthString::<7>::try_from_words(words).map(Self) + } +} - /// Sets the token_supply (in base units). - /// - /// # Errors - /// - /// Returns an error if: - /// - the token supply exceeds the max supply. - pub fn with_token_supply(mut self, token_supply: Felt) -> Result { - if token_supply.as_canonical_u64() > self.max_supply.as_canonical_u64() { - return Err(FungibleFaucetError::TokenSupplyExceedsMaxSupply { - token_supply: token_supply.as_canonical_u64(), - max_supply: self.max_supply.as_canonical_u64(), - }); - } +/// Token external link (max 195 bytes UTF-8), stored in 7 Words. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExternalLink(FixedWidthString<7>); - self.token_supply = token_supply; +impl ExternalLink { + /// Maximum byte length for an external link (7 Words × 4 felts × 7 bytes − 1 length byte). + pub const MAX_BYTES: usize = FixedWidthString::<7>::CAPACITY; - Ok(self) + /// Creates an external link from a UTF-8 string. + pub fn new(s: &str) -> Result { + FixedWidthString::<7>::new(s).map(Self) + } + + /// Returns the external link as a string slice. + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + /// Encodes the external link into 7 Words for storage. + pub fn to_words(&self) -> Vec { + self.0.to_words() + } + + /// Decodes an external link from a 7-Word slice. + pub fn try_from_words(words: &[Word]) -> Result { + FixedWidthString::<7>::try_from_words(words).map(Self) } } -// TRAIT IMPLEMENTATIONS +// TOKEN METADATA // ================================================================================================ -impl TryFrom for TokenMetadata { - type Error = FungibleFaucetError; +/// A helper that stores name, mutability config, and optional fields in fixed value slots. +/// +/// Designed to be embedded in +/// [`FungibleFaucet`][crate::account::faucets::FungibleFaucet] (or other token-bearing +/// account components) to avoid duplication. Slot names are referenced via +/// [`TokenMetadata::name_chunk_0_slot`] and friends. +#[derive(Debug, Clone)] +pub struct TokenMetadata { + name: TokenName, + description: Option, + logo_uri: Option, + external_link: Option, + is_description_mutable: bool, + is_logo_uri_mutable: bool, + is_external_link_mutable: bool, + is_max_supply_mutable: bool, +} - /// Parses token metadata from a Word. - /// - /// The Word is expected to be in the format: `[token_supply, max_supply, decimals, symbol]` - fn try_from(word: Word) -> Result { - let [token_supply, max_supply, decimals, token_symbol] = *word; +impl TokenMetadata { + /// Creates a new token metadata with the given name (all optional fields absent, all flags + /// false). + pub fn new(name: TokenName) -> Self { + Self { + name, + description: None, + logo_uri: None, + external_link: None, + is_description_mutable: false, + is_logo_uri_mutable: false, + is_external_link_mutable: false, + is_max_supply_mutable: false, + } + } - let symbol = - TokenSymbol::try_from(token_symbol).map_err(FungibleFaucetError::InvalidTokenSymbol)?; + // BUILDERS + // -------------------------------------------------------------------------------------------- - let decimals = decimals.as_canonical_u64().try_into().map_err(|_| { - FungibleFaucetError::TooManyDecimals { - actual: decimals.as_canonical_u64(), - max: Self::MAX_DECIMALS, - } - })?; + /// Sets the description and its mutability flag together. + pub fn with_description(mut self, description: Description, mutable: bool) -> Self { + self.description = Some(description); + self.is_description_mutable = mutable; + self + } - Self::with_supply(symbol, decimals, max_supply, token_supply) + /// Sets whether the description can be updated by the owner. + pub fn with_description_mutable(mut self, mutable: bool) -> Self { + self.is_description_mutable = mutable; + self } -} -impl From for Word { - fn from(metadata: TokenMetadata) -> Self { - // Storage layout: [token_supply, max_supply, decimals, symbol] - Word::new([ - metadata.token_supply, - metadata.max_supply, - Felt::from(metadata.decimals), - metadata.symbol.as_element(), - ]) + /// Sets the logo URI and its mutability flag together. + pub fn with_logo_uri(mut self, logo_uri: LogoURI, mutable: bool) -> Self { + self.logo_uri = Some(logo_uri); + self.is_logo_uri_mutable = mutable; + self } -} -impl From for StorageSlot { - fn from(metadata: TokenMetadata) -> Self { - StorageSlot::with_value(TokenMetadata::metadata_slot().clone(), metadata.into()) + /// Sets whether the logo URI can be updated by the owner. + pub fn with_logo_uri_mutable(mut self, mutable: bool) -> Self { + self.is_logo_uri_mutable = mutable; + self } -} -impl TryFrom<&StorageSlot> for TokenMetadata { - type Error = FungibleFaucetError; + /// Sets the external link and its mutability flag together. + pub fn with_external_link(mut self, external_link: ExternalLink, mutable: bool) -> Self { + self.external_link = Some(external_link); + self.is_external_link_mutable = mutable; + self + } - /// Tries to create [`TokenMetadata`] from a storage slot. - /// - /// # Errors - /// Returns an error if: - /// - The slot name does not match the expected metadata slot name. - /// - The slot value cannot be parsed as valid token metadata. - fn try_from(slot: &StorageSlot) -> Result { - if slot.name() != Self::metadata_slot() { - return Err(FungibleFaucetError::SlotNameMismatch { - expected: Self::metadata_slot().clone(), - actual: slot.name().clone(), - }); - } - TokenMetadata::try_from(slot.value()) + /// Sets whether the external link can be updated by the owner. + pub fn with_external_link_mutable(mut self, mutable: bool) -> Self { + self.is_external_link_mutable = mutable; + self } -} -impl TryFrom<&AccountStorage> for TokenMetadata { - type Error = FungibleFaucetError; + /// Sets whether the max supply can be updated by the owner. + pub fn with_max_supply_mutable(mut self, mutable: bool) -> Self { + self.is_max_supply_mutable = mutable; + self + } - /// Tries to create [`TokenMetadata`] from account storage. - fn try_from(storage: &AccountStorage) -> Result { - let metadata_word = storage.get_item(TokenMetadata::metadata_slot()).map_err(|err| { - FungibleFaucetError::StorageLookupFailed { - slot_name: TokenMetadata::metadata_slot().clone(), - source: err, - } - })?; + // ACCESSORS + // -------------------------------------------------------------------------------------------- - TokenMetadata::try_from(metadata_word) + /// Returns the token name. + pub fn name(&self) -> &TokenName { + &self.name } -} -// TESTS -// ================================================================================================ + /// Returns the description if set. + pub fn description(&self) -> Option<&Description> { + self.description.as_ref() + } -#[cfg(test)] -mod tests { - use miden_protocol::asset::TokenSymbol; - use miden_protocol::{Felt, Word}; + /// Returns the logo URI if set. + pub fn logo_uri(&self) -> Option<&LogoURI> { + self.logo_uri.as_ref() + } - use super::*; + /// Returns the external link if set. + pub fn external_link(&self) -> Option<&ExternalLink> { + self.external_link.as_ref() + } - #[test] - fn token_metadata_new() { - let symbol = TokenSymbol::new("TEST").unwrap(); - let decimals = 8u8; - let max_supply = Felt::new(1_000_000); + /// Returns whether the max supply is configured as mutable. + pub fn is_max_supply_mutable(&self) -> bool { + self.is_max_supply_mutable + } - let metadata = TokenMetadata::new(symbol.clone(), decimals, max_supply).unwrap(); + // STATIC SLOT NAME ACCESSORS + // -------------------------------------------------------------------------------------------- - assert_eq!(metadata.symbol(), &symbol); - assert_eq!(metadata.decimals(), decimals); - assert_eq!(metadata.max_supply(), max_supply); - assert_eq!(metadata.token_supply(), Felt::ZERO); + /// Returns the [`StorageSlotName`] for name chunk 0. + pub fn name_chunk_0_slot() -> &'static StorageSlotName { + &NAME_SLOTS[0] } - #[test] - fn token_metadata_with_supply() { - let symbol = TokenSymbol::new("TEST").unwrap(); - let decimals = 8u8; - let max_supply = Felt::new(1_000_000); - let token_supply = Felt::new(500_000); + /// Returns the [`StorageSlotName`] for name chunk 1. + pub fn name_chunk_1_slot() -> &'static StorageSlotName { + &NAME_SLOTS[1] + } - let metadata = - TokenMetadata::with_supply(symbol.clone(), decimals, max_supply, token_supply).unwrap(); + /// Returns the [`StorageSlotName`] for the mutability config Word. + pub fn mutability_config_slot() -> &'static StorageSlotName { + mutability_config_slot() + } - assert_eq!(metadata.symbol(), &symbol); - assert_eq!(metadata.decimals(), decimals); - assert_eq!(metadata.max_supply(), max_supply); - assert_eq!(metadata.token_supply(), token_supply); + /// Returns the [`StorageSlotName`] for a description chunk by index (0..=6). + pub fn description_slot(index: usize) -> &'static StorageSlotName { + &DESCRIPTION_SLOTS[index] } - #[test] - fn token_metadata_too_many_decimals() { - let symbol = TokenSymbol::new("TEST").unwrap(); - let decimals = 13u8; // exceeds MAX_DECIMALS - let max_supply = Felt::new(1_000_000); + /// Returns the [`StorageSlotName`] for a logo URI chunk by index (0..=6). + pub fn logo_uri_slot(index: usize) -> &'static StorageSlotName { + &LOGO_URI_SLOTS[index] + } - let result = TokenMetadata::new(symbol, decimals, max_supply); - assert!(matches!(result, Err(FungibleFaucetError::TooManyDecimals { .. }))); + /// Returns the [`StorageSlotName`] for an external link chunk by index (0..=6). + pub fn external_link_slot(index: usize) -> &'static StorageSlotName { + &EXTERNAL_LINK_SLOTS[index] } - #[test] - fn token_metadata_max_supply_too_large() { - use miden_protocol::asset::FungibleAsset; + /// Returns the storage slot schema entries describing the token metadata layout + /// (name chunks, mutability config, description, logo URI, external link). + /// + /// Embedding components should call this and extend their own schema with the result. + pub fn storage_schema() -> Vec<(StorageSlotName, StorageSlotSchema)> { + let mut entries: Vec<(StorageSlotName, StorageSlotSchema)> = Vec::new(); + + for (i, slot) in NAME_SLOTS.iter().enumerate() { + entries.push(( + slot.clone(), + StorageSlotSchema::value( + alloc::format!("Name chunk {i}"), + core::array::from_fn(|j| FeltSchema::felt(alloc::format!("data_{j}"))), + ), + )); + } - let symbol = TokenSymbol::new("TEST").unwrap(); - let decimals = 8u8; - // FungibleAsset::MAX_AMOUNT is 2^63 - 1, so we use MAX_AMOUNT + 1 to exceed it - let max_supply = Felt::new(FungibleAsset::MAX_AMOUNT + 1); + entries.push(( + MUTABILITY_CONFIG_SLOT.clone(), + StorageSlotSchema::value( + "Mutability config", + [ + FeltSchema::bool("is_description_mutable"), + FeltSchema::bool("is_logo_uri_mutable"), + FeltSchema::bool("is_external_link_mutable"), + FeltSchema::bool("is_max_supply_mutable"), + ], + ), + )); + + for (label, slots) in [ + ("Description", DESCRIPTION_SLOTS.as_slice()), + ("Logo URI", LOGO_URI_SLOTS.as_slice()), + ("External link", EXTERNAL_LINK_SLOTS.as_slice()), + ] { + for (i, slot) in slots.iter().enumerate() { + entries.push(( + slot.clone(), + StorageSlotSchema::value( + alloc::format!("{label} chunk {i}"), + core::array::from_fn(|j| FeltSchema::felt(alloc::format!("data_{j}"))), + ), + )); + } + } - let result = TokenMetadata::new(symbol, decimals, max_supply); - assert!(matches!(result, Err(FungibleFaucetError::MaxSupplyTooLarge { .. }))); + entries } - #[test] - fn token_metadata_to_word() { - let symbol = TokenSymbol::new("POL").unwrap(); - let symbol_felt = symbol.as_element(); - let decimals = 2u8; - let max_supply = Felt::new(123); + // STORAGE + // -------------------------------------------------------------------------------------------- - let metadata = TokenMetadata::new(symbol, decimals, max_supply).unwrap(); - let word: Word = metadata.into(); + /// Converts a single [`Felt`] at the given `index` in the mutability config word to a `bool`. + /// + /// Returns `Err` if the value is neither `0` nor `1`. + fn felt_to_bool(felt: Felt, index: usize) -> Result { + match felt.as_canonical_u64() { + 0 => Ok(false), + 1 => Ok(true), + value => Err(TokenMetadataError::InvalidMutabilityFlag { index, value }), + } + } + + /// Decodes the mutability config [`Word`] into its four boolean flags. + /// + /// The word layout is `[is_desc_mutable, is_logo_mutable, is_extlink_mutable, + /// is_max_supply_mutable]`. Each element must be exactly `0` or `1`. + /// + /// # Errors + /// + /// Returns [`TokenMetadataError::InvalidMutabilityFlag`] if any element is not `0` or `1`. + fn mutability_flags_from_word( + word: Word, + ) -> Result<(bool, bool, bool, bool), TokenMetadataError> { + Ok(( + Self::felt_to_bool(word[0], 0)?, + Self::felt_to_bool(word[1], 1)?, + Self::felt_to_bool(word[2], 2)?, + Self::felt_to_bool(word[3], 3)?, + )) + } - // Storage layout: [token_supply, max_supply, decimals, symbol] - assert_eq!(word[0], Felt::ZERO); // token_supply - assert_eq!(word[1], max_supply); - assert_eq!(word[2], Felt::from(decimals)); - assert_eq!(word[3], symbol_felt); + /// Returns the mutability config word for this metadata. + fn mutability_config_word(&self) -> Word { + Word::from([ + Felt::from(self.is_description_mutable as u32), + Felt::from(self.is_logo_uri_mutable as u32), + Felt::from(self.is_external_link_mutable as u32), + Felt::from(self.is_max_supply_mutable as u32), + ]) } - #[test] - fn token_metadata_from_storage_slot() { - let symbol = TokenSymbol::new("POL").unwrap(); - let decimals = 2u8; - let max_supply = Felt::new(123); + /// Constructs a [`TokenMetadata`] by reading all relevant name, optional-field, and + /// mutability config slots from account storage. + /// + /// # Errors + /// + /// Returns [`TokenMetadataError`] if any storage lookup fails, a mutability flag is invalid, + /// or a string field cannot be decoded. + pub fn try_from_storage(storage: &AccountStorage) -> Result { + let chunk_0 = storage.get_item(TokenMetadata::name_chunk_0_slot()).map_err(|err| { + TokenMetadataError::StorageLookupFailed { + slot_name: TokenMetadata::name_chunk_0_slot().clone(), + source: err, + } + })?; + let chunk_1 = storage.get_item(TokenMetadata::name_chunk_1_slot()).map_err(|err| { + TokenMetadataError::StorageLookupFailed { + slot_name: TokenMetadata::name_chunk_1_slot().clone(), + source: err, + } + })?; + let name_words: [Word; 2] = [chunk_0, chunk_1]; + let name = TokenName::try_from_words(&name_words) + .map_err(|err| TokenMetadataError::InvalidStringField { field: "name", source: err })?; + + let read_slots = |slots: &[StorageSlotName; 7]| -> Result<[Word; 7], TokenMetadataError> { + let mut field = [Word::default(); 7]; + for (i, slot) in slots.iter().enumerate() { + field[i] = storage.get_item(slot).map_err(|err| { + TokenMetadataError::StorageLookupFailed { slot_name: slot.clone(), source: err } + })?; + } + Ok(field) + }; - let original = TokenMetadata::new(symbol.clone(), decimals, max_supply).unwrap(); - let slot: StorageSlot = original.into(); + let description_words = read_slots(&DESCRIPTION_SLOTS)?; + let description = Description::try_from_words(&description_words).map_err(|err| { + TokenMetadataError::InvalidStringField { field: "description", source: err } + })?; + let description = if description.as_str().is_empty() { + None + } else { + Some(description) + }; + + let logo_words = read_slots(&LOGO_URI_SLOTS)?; + let logo_uri = LogoURI::try_from_words(&logo_words).map_err(|err| { + TokenMetadataError::InvalidStringField { field: "logo_uri", source: err } + })?; + let logo_uri = if logo_uri.as_str().is_empty() { + None + } else { + Some(logo_uri) + }; + + let link_words = read_slots(&EXTERNAL_LINK_SLOTS)?; + let external_link = ExternalLink::try_from_words(&link_words).map_err(|err| { + TokenMetadataError::InvalidStringField { field: "external_link", source: err } + })?; + let external_link = if external_link.as_str().is_empty() { + None + } else { + Some(external_link) + }; + + let mutability_word = storage.get_item(mutability_config_slot()).map_err(|err| { + TokenMetadataError::StorageLookupFailed { + slot_name: mutability_config_slot().clone(), + source: err, + } + })?; + let (is_desc_mutable, is_logo_mutable, is_extlink_mutable, is_max_supply_mutable) = + TokenMetadata::mutability_flags_from_word(mutability_word)?; - let restored = TokenMetadata::try_from(&slot).unwrap(); + let mut meta = TokenMetadata::new(name); + if let Some(d) = description { + meta = meta.with_description(d, is_desc_mutable); + } + meta = meta.with_description_mutable(is_desc_mutable); + if let Some(l) = logo_uri { + meta = meta.with_logo_uri(l, is_logo_mutable); + } + meta = meta.with_logo_uri_mutable(is_logo_mutable); + if let Some(e) = external_link { + meta = meta.with_external_link(e, is_extlink_mutable); + } + meta = meta.with_external_link_mutable(is_extlink_mutable); + meta = meta.with_max_supply_mutable(is_max_supply_mutable); - assert_eq!(restored.symbol(), &symbol); - assert_eq!(restored.decimals(), decimals); - assert_eq!(restored.max_supply(), max_supply); - assert_eq!(restored.token_supply(), Felt::ZERO); + Ok(meta) } - #[test] - fn token_metadata_roundtrip_with_supply() { - let symbol = TokenSymbol::new("POL").unwrap(); - let decimals = 2u8; - let max_supply = Felt::new(1000); - let token_supply = Felt::new(500); + /// Consumes `self` and returns the storage slots for this metadata (name, mutability config, + /// and all fields). Absent optional fields are encoded as empty strings (all-zero words). + pub fn into_storage_slots(self) -> Vec { + let mut slots: Vec = Vec::new(); + + let name_words = self.name.to_words(); + slots.push(StorageSlot::with_value( + TokenMetadata::name_chunk_0_slot().clone(), + name_words[0], + )); + slots.push(StorageSlot::with_value( + TokenMetadata::name_chunk_1_slot().clone(), + name_words[1], + )); + + slots.push(StorageSlot::with_value( + mutability_config_slot().clone(), + self.mutability_config_word(), + )); + + let description = self + .description + .unwrap_or_else(|| Description::new("").expect("empty description should be valid")); + for (i, word) in description.to_words().iter().enumerate() { + slots.push(StorageSlot::with_value(TokenMetadata::description_slot(i).clone(), *word)); + } + + let logo_uri = self + .logo_uri + .unwrap_or_else(|| LogoURI::new("").expect("empty logo URI should be valid")); + for (i, word) in logo_uri.to_words().iter().enumerate() { + slots.push(StorageSlot::with_value(TokenMetadata::logo_uri_slot(i).clone(), *word)); + } - let original = - TokenMetadata::with_supply(symbol.clone(), decimals, max_supply, token_supply).unwrap(); - let word: Word = original.into(); - let restored = TokenMetadata::try_from(word).unwrap(); + let external_link = self + .external_link + .unwrap_or_else(|| ExternalLink::new("").expect("empty external link should be valid")); + for (i, word) in external_link.to_words().iter().enumerate() { + slots + .push(StorageSlot::with_value(TokenMetadata::external_link_slot(i).clone(), *word)); + } - assert_eq!(restored.symbol(), &symbol); - assert_eq!(restored.decimals(), decimals); - assert_eq!(restored.max_supply(), max_supply); - assert_eq!(restored.token_supply(), token_supply); + slots } } diff --git a/crates/miden-standards/src/account/interface/component.rs b/crates/miden-standards/src/account/interface/component.rs index 6527767f3f..a386fa0c0c 100644 --- a/crates/miden-standards/src/account/interface/component.rs +++ b/crates/miden-standards/src/account/interface/component.rs @@ -7,7 +7,14 @@ use miden_protocol::note::PartialNote; use miden_protocol::{Felt, Word}; use crate::AuthMethod; -use crate::account::auth::{AuthMultisig, AuthMultisigPsm, AuthSingleSig, AuthSingleSigAcl}; +use crate::account::auth::{ + AuthGuardedMultisig, + AuthMultisig, + AuthMultisigSmart, + AuthSingleSig, + AuthSingleSigAcl, + NetworkAccountNoteAllowlist, +}; use crate::account::interface::AccountInterfaceError; // ACCOUNT COMPONENT INTERFACE @@ -19,11 +26,18 @@ pub enum AccountComponentInterface { /// Exposes procedures from the [`BasicWallet`][crate::account::wallets::BasicWallet] module. BasicWallet, /// Exposes procedures from the - /// [`BasicFungibleFaucet`][crate::account::faucets::BasicFungibleFaucet] module. - BasicFungibleFaucet, + /// [`FungibleFaucet`][crate::account::faucets::FungibleFaucet] module. + FungibleFaucet, + /// Exposes procedures from the + /// [`Authority`][crate::account::access::Authority] access component. + Authority, /// Exposes procedures from the - /// [`NetworkFungibleFaucet`][crate::account::faucets::NetworkFungibleFaucet] module. - NetworkFungibleFaucet, + /// [`Ownable2Step`][crate::account::access::Ownable2Step] access component. + Ownable2Step, + /// Exposes procedures from the + /// [`RoleBasedAccessControl`][crate::account::access::RoleBasedAccessControl] access + /// component. + RoleBasedAccessControl, /// Exposes procedures from the /// [`AuthSingleSig`][crate::account::auth::AuthSingleSig] module. AuthSingleSig, @@ -34,13 +48,23 @@ pub enum AccountComponentInterface { /// [`AuthMultisig`][crate::account::auth::AuthMultisig] module. AuthMultisig, /// Exposes procedures from the - /// [`AuthMultisigPsm`][crate::account::auth::AuthMultisigPsm] module. - AuthMultisigPsm, + /// [`AuthMultisigSmart`][crate::account::auth::AuthMultisigSmart] module. + AuthMultisigSmart, + /// Exposes procedures from the + /// [`AuthGuardedMultisig`][crate::account::auth::AuthGuardedMultisig] module. + AuthGuardedMultisig, /// Exposes procedures from the [`NoAuth`][crate::account::auth::NoAuth] module. /// /// This authentication scheme provides no cryptographic authentication and only increments /// the nonce if the account state has actually changed during transaction execution. AuthNoAuth, + /// Exposes procedures from the + /// [`AuthNetworkAccount`][crate::account::auth::AuthNetworkAccount] module. + /// + /// This authentication scheme is intended for network-owned accounts. It rejects transactions + /// that executed a tx script or consumed input notes outside of a fixed allowlist of note + /// script roots. + AuthNetworkAccount, /// A non-standard, custom interface which exposes the contained procedures. /// /// Custom interface holds all procedures which are not part of some standard interface which is @@ -57,15 +81,19 @@ impl AccountComponentInterface { pub fn name(&self) -> String { match self { AccountComponentInterface::BasicWallet => "Basic Wallet".to_string(), - AccountComponentInterface::BasicFungibleFaucet => "Basic Fungible Faucet".to_string(), - AccountComponentInterface::NetworkFungibleFaucet => { - "Network Fungible Faucet".to_string() + AccountComponentInterface::FungibleFaucet => "Fungible Faucet".to_string(), + AccountComponentInterface::Authority => "Authority".to_string(), + AccountComponentInterface::Ownable2Step => "Ownable2Step".to_string(), + AccountComponentInterface::RoleBasedAccessControl => { + "Role Based Access Control".to_string() }, AccountComponentInterface::AuthSingleSig => "SingleSig".to_string(), AccountComponentInterface::AuthSingleSigAcl => "SingleSig ACL".to_string(), AccountComponentInterface::AuthMultisig => "Multisig".to_string(), - AccountComponentInterface::AuthMultisigPsm => "Multisig PSM".to_string(), + AccountComponentInterface::AuthMultisigSmart => "Multisig Smart".to_string(), + AccountComponentInterface::AuthGuardedMultisig => "Guarded Multisig".to_string(), AccountComponentInterface::AuthNoAuth => "No Auth".to_string(), + AccountComponentInterface::AuthNetworkAccount => "Network Account Auth".to_string(), AccountComponentInterface::Custom(proc_root_vec) => { let result = proc_root_vec .iter() @@ -86,8 +114,10 @@ impl AccountComponentInterface { AccountComponentInterface::AuthSingleSig | AccountComponentInterface::AuthSingleSigAcl | AccountComponentInterface::AuthMultisig - | AccountComponentInterface::AuthMultisigPsm + | AccountComponentInterface::AuthMultisigSmart + | AccountComponentInterface::AuthGuardedMultisig | AccountComponentInterface::AuthNoAuth + | AccountComponentInterface::AuthNetworkAccount ) } @@ -112,15 +142,26 @@ impl AccountComponentInterface { AuthMultisig::approver_scheme_ids_slot(), )] }, - AccountComponentInterface::AuthMultisigPsm => { + AccountComponentInterface::AuthGuardedMultisig => { vec![extract_multisig_auth_method( storage, - AuthMultisigPsm::threshold_config_slot(), - AuthMultisigPsm::approver_public_keys_slot(), - AuthMultisigPsm::approver_scheme_ids_slot(), + 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 } } @@ -148,13 +189,14 @@ impl AccountComponentInterface { /// dropw dropw dropw drop /// ``` /// - /// Example script for the [`AccountComponentInterface::BasicFungibleFaucet`] with one note: + /// Example script for the [`AccountComponentInterface::FungibleFaucet`] with one note: /// /// ```masm /// push.{note information} /// - /// push.{asset amount} - /// call.::miden::standards::faucets::basic_fungible::mint_and_send dropw dropw drop + /// push.{ASSET_VALUE} push.{ASSET_KEY} + /// call.::miden::standards::faucets::fungible::mint_and_send + /// swapdw dropw dropw swapdw dropw dropw /// ``` /// /// # Errors: @@ -190,7 +232,7 @@ impl AccountComponentInterface { )); match self { - AccountComponentInterface::BasicFungibleFaucet => { + AccountComponentInterface::FungibleFaucet => { if partial_note.assets().num_assets() != 1 { return Err(AccountInterfaceError::FaucetNoteWithoutAsset); } @@ -207,13 +249,18 @@ impl AccountComponentInterface { body.push_str(&format!( " - push.{amount} - call.::miden::standards::faucets::basic_fungible::mint_and_send - # => [note_idx, pad(25)] - swapdw dropw dropw swap drop - # => [note_idx, pad(16)]\n + 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 ", - amount = asset.unwrap_fungible().amount() + ASSET_KEY = asset.to_key_word(), + ASSET_VALUE = asset.to_value_word(), )); }, AccountComponentInterface::BasicWallet => { @@ -253,21 +300,29 @@ impl AccountComponentInterface { }, } - body.push_str(&format!( - " - push.{ATTACHMENT} - push.{attachment_kind} + 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} - movup.6 - # => [note_idx, attachment_scheme, attachment_kind, ATTACHMENT, pad(16)] - exec.::miden::protocol::output_note::set_attachment + # => [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)] ", - ATTACHMENT = partial_note.metadata().to_attachment_word(), - attachment_scheme = - partial_note.metadata().attachment().attachment_scheme().as_u32(), - attachment_kind = partial_note.metadata().attachment().attachment_kind().as_u8(), - )); + ); } Ok(body) @@ -352,3 +407,13 @@ fn extract_multisig_auth_method( 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 f23b1414a7..0d3c04b734 100644 --- a/crates/miden-standards/src/account/interface/extension.rs +++ b/crates/miden-standards/src/account/interface/extension.rs @@ -1,31 +1,11 @@ use alloc::collections::BTreeSet; -use alloc::sync::Arc; use alloc::vec::Vec; -use miden_processor::mast::MastNodeExt; -use miden_protocol::Word; use miden_protocol::account::{Account, AccountCode, AccountId, AccountProcedureRoot}; -use miden_protocol::assembly::mast::{MastForest, MastNode, MastNodeId}; -use miden_protocol::note::{Note, NoteScript}; use crate::AuthMethod; -use crate::account::components::{ - StandardAccountComponent, - basic_fungible_faucet_library, - basic_wallet_library, - multisig_library, - multisig_psm_library, - network_fungible_faucet_library, - no_auth_library, - singlesig_acl_library, - singlesig_library, -}; -use crate::account::interface::{ - AccountComponentInterface, - AccountInterface, - NoteAccountCompatibility, -}; -use crate::note::StandardNote; +use crate::account::components::StandardAccountComponent; +use crate::account::interface::{AccountComponentInterface, AccountInterface}; // ACCOUNT INTERFACE EXTENSION TRAIT // ================================================================================================ @@ -38,13 +18,6 @@ pub trait AccountInterfaceExt { /// Creates a new [`AccountInterface`] instance from the provided [`Account`]. fn from_account(account: &Account) -> Self; - - /// Returns [NoteAccountCompatibility::Maybe] if the provided note is compatible with the - /// current [AccountInterface], and [NoteAccountCompatibility::No] otherwise. - fn is_compatible_with(&self, note: &Note) -> NoteAccountCompatibility; - - /// Returns the set of digests of all procedures from all account component interfaces. - fn get_procedure_digests(&self) -> BTreeSet; } impl AccountInterfaceExt for AccountInterface { @@ -69,67 +42,6 @@ impl AccountInterfaceExt for AccountInterface { Self::new(account.id(), auth, components) } - - /// Returns [NoteAccountCompatibility::Maybe] if the provided note is compatible with the - /// current [AccountInterface], and [NoteAccountCompatibility::No] otherwise. - fn is_compatible_with(&self, note: &Note) -> NoteAccountCompatibility { - if let Some(standard_note) = StandardNote::from_script_root(note.script().root()) { - if standard_note.is_compatible_with(self) { - NoteAccountCompatibility::Maybe - } else { - NoteAccountCompatibility::No - } - } else { - verify_note_script_compatibility(note.script(), self.get_procedure_digests()) - } - } - - fn get_procedure_digests(&self) -> BTreeSet { - let mut component_proc_digests = BTreeSet::new(); - for component in self.components.iter() { - match component { - AccountComponentInterface::BasicWallet => { - component_proc_digests - .extend(basic_wallet_library().mast_forest().procedure_digests()); - }, - AccountComponentInterface::BasicFungibleFaucet => { - component_proc_digests - .extend(basic_fungible_faucet_library().mast_forest().procedure_digests()); - }, - AccountComponentInterface::NetworkFungibleFaucet => { - component_proc_digests.extend( - network_fungible_faucet_library().mast_forest().procedure_digests(), - ); - }, - AccountComponentInterface::AuthSingleSig => { - component_proc_digests - .extend(singlesig_library().mast_forest().procedure_digests()); - }, - AccountComponentInterface::AuthSingleSigAcl => { - component_proc_digests - .extend(singlesig_acl_library().mast_forest().procedure_digests()); - }, - AccountComponentInterface::AuthMultisig => { - component_proc_digests - .extend(multisig_library().mast_forest().procedure_digests()); - }, - AccountComponentInterface::AuthMultisigPsm => { - component_proc_digests - .extend(multisig_psm_library().mast_forest().procedure_digests()); - }, - AccountComponentInterface::AuthNoAuth => { - component_proc_digests - .extend(no_auth_library().mast_forest().procedure_digests()); - }, - AccountComponentInterface::Custom(custom_procs) => { - component_proc_digests - .extend(custom_procs.iter().map(|info| *info.mast_root())); - }, - } - } - - component_proc_digests - } } /// An extension for [`AccountComponentInterface`] that allows instantiation from a set of procedure @@ -166,84 +78,3 @@ impl AccountComponentInterfaceExt for AccountComponentInterface { component_interface_vec } } - -// HELPER FUNCTIONS -// ------------------------------------------------------------------------------------------------ - -/// Verifies that the provided note script is compatible with the target account interfaces. -/// -/// This is achieved by checking that at least one execution branch in the note script is compatible -/// with the account procedures vector. -/// -/// This check relies on the fact that account procedures are the only procedures that are `call`ed -/// from note scripts, while kernel procedures are `sycall`ed. -fn verify_note_script_compatibility( - note_script: &NoteScript, - account_procedures: BTreeSet, -) -> NoteAccountCompatibility { - // collect call branches of the note script - let branches = collect_call_branches(note_script); - - // if none of the branches are compatible with the target account, return a `CheckResult::No` - if !branches.iter().any(|call_targets| call_targets.is_subset(&account_procedures)) { - return NoteAccountCompatibility::No; - } - - NoteAccountCompatibility::Maybe -} - -/// Collect call branches by recursively traversing through program execution branches and -/// accumulating call targets. -fn collect_call_branches(note_script: &NoteScript) -> Vec> { - let mut branches = vec![BTreeSet::new()]; - - let entry_node = note_script.entrypoint(); - recursively_collect_call_branches(entry_node, &mut branches, ¬e_script.mast()); - branches -} - -/// Generates a list of calls invoked in each execution branch of the provided code block. -fn recursively_collect_call_branches( - mast_node_id: MastNodeId, - branches: &mut Vec>, - note_script_forest: &Arc, -) { - let mast_node = ¬e_script_forest[mast_node_id]; - - match mast_node { - MastNode::Block(_) => {}, - MastNode::Join(join_node) => { - recursively_collect_call_branches(join_node.first(), branches, note_script_forest); - recursively_collect_call_branches(join_node.second(), branches, note_script_forest); - }, - MastNode::Split(split_node) => { - let current_branch = branches.last().expect("at least one execution branch").clone(); - recursively_collect_call_branches(split_node.on_false(), branches, note_script_forest); - - // If the previous branch had additional calls we need to create a new branch - if branches.last().expect("at least one execution branch").len() > current_branch.len() - { - branches.push(current_branch); - } - - recursively_collect_call_branches(split_node.on_true(), branches, note_script_forest); - }, - MastNode::Loop(loop_node) => { - recursively_collect_call_branches(loop_node.body(), branches, note_script_forest); - }, - MastNode::Call(call_node) => { - if call_node.is_syscall() { - return; - } - - let callee_digest = note_script_forest[call_node.callee()].digest(); - - branches - .last_mut() - .expect("at least one execution branch") - .insert(callee_digest); - }, - MastNode::Dyn(_) => {}, - MastNode::External(_) => {}, - } -} diff --git a/crates/miden-standards/src/account/interface/mod.rs b/crates/miden-standards/src/account/interface/mod.rs index a5409f34a3..50a27e2b0c 100644 --- a/crates/miden-standards/src/account/interface/mod.rs +++ b/crates/miden-standards/src/account/interface/mod.rs @@ -1,8 +1,8 @@ use alloc::string::String; use alloc::vec::Vec; -use miden_protocol::account::{AccountId, AccountType}; -use miden_protocol::note::{NoteAttachmentContent, PartialNote}; +use miden_protocol::account::AccountId; +use miden_protocol::note::PartialNote; use miden_protocol::transaction::TransactionScript; use thiserror::Error; @@ -23,9 +23,6 @@ pub use extension::{AccountComponentInterfaceExt, AccountInterfaceExt}; // ================================================================================================ /// An [`AccountInterface`] describes the exported, callable procedures of an account. -/// -/// A note script's compatibility with this interface can be inspected to check whether the note may -/// result in a successful execution against this account. pub struct AccountInterface { account_id: AccountId, auth: Vec, @@ -48,6 +45,13 @@ impl AccountInterface { 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) + } + // PUBLIC ACCESSORS // -------------------------------------------------------------------------------------------- @@ -56,29 +60,6 @@ impl AccountInterface { &self.account_id } - /// Returns the type of the reference account. - pub fn account_type(&self) -> AccountType { - self.account_id.account_type() - } - - /// Returns true if the reference account can issue assets. - pub fn is_faucet(&self) -> bool { - self.account_id.is_faucet() - } - - /// Returns true if the reference account is a regular. - pub fn is_regular_account(&self) -> bool { - self.account_id.is_regular_account() - } - - /// Returns `true` if the full state of the account is public on chain, i.e. if the modes are - /// [`AccountStorageMode::Public`](miden_protocol::account::AccountStorageMode::Public) or - /// [`AccountStorageMode::Network`](miden_protocol::account::AccountStorageMode::Network), - /// `false` otherwise. - pub fn has_public_state(&self) -> bool { - self.account_id.has_public_state() - } - /// Returns `true` if the reference account is a private account, `false` otherwise. pub fn is_private(&self) -> bool { self.account_id.is_private() @@ -89,11 +70,6 @@ impl AccountInterface { self.account_id.is_public() } - /// Returns true if the reference account is a network account, `false` otherwise. - pub fn is_network(&self) -> bool { - self.account_id.is_network() - } - /// Returns a reference to the vector of used authentication methods. pub fn auth(&self) -> &Vec { &self.auth @@ -118,10 +94,10 @@ impl AccountInterface { /// considered expired and cannot be included into the chain. /// /// Currently only [`AccountComponentInterface::BasicWallet`] and - /// [`AccountComponentInterface::BasicFungibleFaucet`] interfaces are supported for the + /// [`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::BasicFungibleFaucet`] interface. + /// will be generated for the [`AccountComponentInterface::FungibleFaucet`] interface. /// /// # Example /// @@ -133,8 +109,9 @@ impl AccountInterface { /// /// push.{note information} /// - /// push.{asset amount} - /// call.::miden::standards::faucets::basic_fungible::mint_and_send dropw dropw drop + /// push.{ASSET_VALUE} push.{ASSET_KEY} + /// call.::miden::standards::faucets::fungible::mint_and_send + /// swapdw dropw dropw swapdw dropw dropw /// end /// ``` /// @@ -147,7 +124,7 @@ impl AccountInterface { /// - a faucet tries to mint an asset with a different faucet ID. /// /// [wallet]: crate::account::interface::AccountComponentInterface::BasicWallet - /// [faucet]: crate::account::interface::AccountComponentInterface::BasicFungibleFaucet + /// [faucet]: crate::account::interface::AccountComponentInterface::FungibleFaucet pub fn build_send_notes_script( &self, output_notes: &[PartialNote], @@ -161,13 +138,13 @@ impl AccountInterface { note_creation_source, ); - // Add attachment array entries to the code builder's advice map. - // For NoteAttachmentContent::Array, the commitment (to_word) is used as key - // and the array elements as value. + // 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 { - if let NoteAttachmentContent::Array(array) = note.metadata().attachment().content() { - code_builder.add_advice_map_entry(array.commitment(), array.as_slice().to_vec()); + for attachment in note.attachments().iter() { + code_builder + .add_advice_map_entry(attachment.to_commitment(), attachment.to_elements()); } } @@ -194,18 +171,16 @@ impl AccountInterface { &self, output_notes: &[PartialNote], ) -> Result { - if let Some(basic_fungible_faucet) = self.components().iter().find(|component_interface| { - matches!(component_interface, AccountComponentInterface::BasicFungibleFaucet) + if let Some(fungible_faucet) = self.components().iter().find(|component_interface| { + matches!(component_interface, AccountComponentInterface::FungibleFaucet) }) { - basic_fungible_faucet.send_note_body(*self.id(), output_notes) - } else if let Some(_network_fungible_faucet) = - self.components().iter().find(|component_interface| { - matches!(component_interface, AccountComponentInterface::NetworkFungibleFaucet) - }) - { - // Network fungible faucet doesn't support send_note_body, because minting - // is done via a MINT note. - Err(AccountInterfaceError::UnsupportedAccountInterface) + // 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 { @@ -225,24 +200,6 @@ impl AccountInterface { } } -// NOTE ACCOUNT COMPATIBILITY -// ================================================================================================ - -/// Describes whether a note is compatible with a specific account. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum NoteAccountCompatibility { - /// A note is incompatible with an account. - /// - /// The account interface does not have procedures for being able to execute at least one of - /// the program execution branches. - No, - /// The account has all necessary procedures of one execution branch of the note script. This - /// means the note may be able to be consumed by the account if that branch is executed. - Maybe, - /// A note could be successfully executed and consumed by the account. - Yes, -} - // ACCOUNT INTERFACE ERROR // ============================================================================================ diff --git a/crates/miden-standards/src/account/interface/test.rs b/crates/miden-standards/src/account/interface/test.rs index c09aadbd2b..0ed4606b1f 100644 --- a/crates/miden-standards/src/account/interface/test.rs +++ b/crates/miden-standards/src/account/interface/test.rs @@ -1,219 +1,20 @@ use assert_matches::assert_matches; +use miden_protocol::Word; +use miden_protocol::account::AccountBuilder; use miden_protocol::account::auth::{self, PublicKeyCommitment}; -use miden_protocol::account::component::AccountComponentMetadata; -use miden_protocol::account::{AccountBuilder, AccountComponent, AccountId, AccountType}; -use miden_protocol::asset::{FungibleAsset, NonFungibleAsset, TokenSymbol}; -use miden_protocol::crypto::rand::{FeltRng, RandomCoin}; +use miden_protocol::asset::NonFungibleAsset; +use miden_protocol::crypto::rand::RandomCoin; use miden_protocol::errors::NoteError; -use miden_protocol::note::{ - Note, - NoteAssets, - NoteAttachment, - NoteMetadata, - NoteRecipient, - NoteStorage, - NoteTag, - NoteType, -}; -use miden_protocol::testing::account_id::{ - ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE, - ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2, -}; -use miden_protocol::{Felt, Word}; +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::faucets::BasicFungibleFaucet; -use crate::account::interface::{ - AccountComponentInterface, - AccountInterface, - AccountInterfaceExt, - NoteAccountCompatibility, -}; +use crate::account::interface::{AccountComponentInterface, AccountInterface, AccountInterfaceExt}; use crate::account::wallets::BasicWallet; -use crate::code_builder::CodeBuilder; -use crate::note::{P2idNote, P2ideNote, P2ideNoteStorage, SwapNote}; +use crate::note::SwapNote; use crate::testing::account_interface::get_public_keys_from_account; -// DEFAULT NOTES -// ================================================================================================ - -#[test] -fn test_basic_wallet_default_notes() { - 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) - .with_assets(vec![FungibleAsset::mock(20)]) - .build_existing() - .expect("failed to create wallet account"); - - let wallet_account_interface = AccountInterface::from_account(&wallet_account); - - let mock_seed = Word::from([Felt::new(4), Felt::new(5), Felt::new(6), Felt::new(7)]).as_bytes(); - let faucet_account = AccountBuilder::new(mock_seed) - .account_type(AccountType::FungibleFaucet) - .with_auth_component(get_mock_falcon_auth_component()) - .with_component( - BasicFungibleFaucet::new( - TokenSymbol::new("POL").expect("invalid token symbol"), - 10, - Felt::new(100), - ) - .expect("failed to create a fungible faucet component"), - ) - .build_existing() - .expect("failed to create wallet account"); - let faucet_account_interface = AccountInterface::from_account(&faucet_account); - - let p2id_note = P2idNote::create( - ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE.try_into().unwrap(), - ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2.try_into().unwrap(), - vec![FungibleAsset::mock(10)], - NoteType::Public, - Default::default(), - &mut RandomCoin::new(Word::from([1, 2, 3, 4u32])), - ) - .unwrap(); - - let sender: AccountId = ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE.try_into().unwrap(); - - let target: AccountId = ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2.try_into().unwrap(); - - let p2ide_note = P2ideNote::create( - sender, - P2ideNoteStorage::new(target, None, None), - vec![FungibleAsset::mock(10)], - NoteType::Public, - Default::default(), - &mut RandomCoin::new(Word::from([1, 2, 3, 4u32])), - ) - .unwrap(); - - let offered_asset = NonFungibleAsset::mock(&[5, 6, 7, 8]); - let requested_asset = NonFungibleAsset::mock(&[1, 2, 3, 4]); - - let (swap_note, _) = SwapNote::create( - ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE.try_into().unwrap(), - offered_asset, - requested_asset, - NoteType::Public, - NoteAttachment::default(), - NoteType::Public, - NoteAttachment::default(), - &mut RandomCoin::new(Word::from([1, 2, 3, 4u32])), - ) - .unwrap(); - - // Basic wallet - assert_eq!( - NoteAccountCompatibility::Maybe, - wallet_account_interface.is_compatible_with(&p2id_note) - ); - assert_eq!( - NoteAccountCompatibility::Maybe, - wallet_account_interface.is_compatible_with(&p2ide_note) - ); - assert_eq!( - NoteAccountCompatibility::Maybe, - wallet_account_interface.is_compatible_with(&swap_note) - ); - - // Basic fungible faucet - assert_eq!( - NoteAccountCompatibility::No, - faucet_account_interface.is_compatible_with(&p2id_note) - ); - assert_eq!( - NoteAccountCompatibility::No, - faucet_account_interface.is_compatible_with(&p2ide_note) - ); - assert_eq!( - NoteAccountCompatibility::No, - faucet_account_interface.is_compatible_with(&swap_note) - ); -} - -/// Checks the compatibility of the basic notes (P2ID, P2IDE and SWAP) against an account with a -/// custom interface containing a procedure from the basic wallet. -/// -/// In that setup check against P2ID and P2IDE notes should result in `Maybe`, and the check against -/// SWAP should result in `No`. -#[test] -fn test_custom_account_default_note() { - let account_custom_code_source = " - use miden::standards::wallets::basic - - pub use basic::receive_asset - "; - - let account_code = CodeBuilder::default() - .compile_component_code("test::account_custom", account_custom_code_source) - .unwrap(); - let metadata = AccountComponentMetadata::new("test::account_custom", AccountType::all()); - let account_component = AccountComponent::new(account_code, vec![], metadata).unwrap(); - - let mock_seed = Word::from([0, 1, 2, 3u32]).as_bytes(); - let target_account = AccountBuilder::new(mock_seed) - .with_auth_component(get_mock_falcon_auth_component()) - .with_component(account_component.clone()) - .build_existing() - .unwrap(); - let target_account_interface = AccountInterface::from_account(&target_account); - - let p2id_note = P2idNote::create( - ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE.try_into().unwrap(), - ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2.try_into().unwrap(), - vec![FungibleAsset::mock(10)], - NoteType::Public, - Default::default(), - &mut RandomCoin::new(Word::from([1, 2, 3, 4u32])), - ) - .unwrap(); - - let sender: AccountId = ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE.try_into().unwrap(); - - let target: AccountId = ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2.try_into().unwrap(); - - let p2ide_note = P2ideNote::create( - sender, - P2ideNoteStorage::new(target, None, None), - vec![FungibleAsset::mock(10)], - NoteType::Public, - Default::default(), - &mut RandomCoin::new(Word::from([1, 2, 3, 4u32])), - ) - .unwrap(); - - let offered_asset = NonFungibleAsset::mock(&[5, 6, 7, 8]); - let requested_asset = NonFungibleAsset::mock(&[1, 2, 3, 4]); - - let (swap_note, _) = SwapNote::create( - ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE.try_into().unwrap(), - offered_asset, - requested_asset, - NoteType::Public, - NoteAttachment::default(), - NoteType::Public, - NoteAttachment::default(), - &mut RandomCoin::new(Word::from([1, 2, 3, 4u32])), - ) - .unwrap(); - - assert_eq!( - NoteAccountCompatibility::Maybe, - target_account_interface.is_compatible_with(&p2id_note) - ); - assert_eq!( - NoteAccountCompatibility::Maybe, - target_account_interface.is_compatible_with(&p2ide_note) - ); - assert_eq!( - NoteAccountCompatibility::No, - target_account_interface.is_compatible_with(&swap_note) - ); -} - /// Checks the function `create_swap_note` should fail if the requested asset is the same as the /// offered asset. #[test] @@ -226,409 +27,14 @@ fn test_required_asset_same_as_offered() { offered_asset, requested_asset, NoteType::Public, - NoteAttachment::default(), + NoteAttachments::default(), NoteType::Public, - NoteAttachment::default(), &mut RandomCoin::new(Word::from([1, 2, 3, 4u32])), ); assert_matches!(result, Err(NoteError::Other { error_msg, .. }) if error_msg == "requested asset same as offered asset".into()); } -// CUSTOM NOTES -// ================================================================================================ - -#[test] -fn test_basic_wallet_custom_notes() { - 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) - .with_assets(vec![FungibleAsset::mock(20)]) - .build_existing() - .expect("failed to create wallet account"); - let wallet_account_interface = AccountInterface::from_account(&wallet_account); - - let sender_account_id = ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2.try_into().unwrap(); - let serial_num = RandomCoin::new(Word::from([1, 2, 3, 4u32])).draw_word(); - let tag = NoteTag::with_account_target(wallet_account.id()); - let metadata = NoteMetadata::new(sender_account_id, NoteType::Public).with_tag(tag); - let vault = NoteAssets::new(vec![FungibleAsset::mock(100)]).unwrap(); - - let compatible_source_code = " - use miden::protocol::tx - use miden::standards::wallets::basic->wallet - use miden::standards::faucets::basic_fungible->fungible_faucet - - @note_script - pub proc main - push.1 - if.true - # supported procs - call.wallet::receive_asset - call.wallet::move_asset_to_note - - # unsupported procs - call.fungible_faucet::mint_and_send - call.fungible_faucet::burn - else - # supported procs - call.wallet::receive_asset - call.wallet::move_asset_to_note - end - end - "; - let note_script = CodeBuilder::default().compile_note_script(compatible_source_code).unwrap(); - let recipient = NoteRecipient::new(serial_num, note_script, NoteStorage::default()); - let compatible_custom_note = Note::new(vault.clone(), metadata.clone(), recipient); - assert_eq!( - NoteAccountCompatibility::Maybe, - wallet_account_interface.is_compatible_with(&compatible_custom_note) - ); - - let incompatible_source_code = " - use miden::standards::wallets::basic->wallet - use miden::standards::faucets::basic_fungible->fungible_faucet - - @note_script - pub proc main - push.1 - if.true - # unsupported procs - call.fungible_faucet::mint_and_send - call.fungible_faucet::burn - else - # unsupported proc - call.fungible_faucet::mint_and_send - - # supported procs - call.wallet::receive_asset - call.wallet::move_asset_to_note - end - end - "; - let note_script = CodeBuilder::default().compile_note_script(incompatible_source_code).unwrap(); - let recipient = NoteRecipient::new(serial_num, note_script, NoteStorage::default()); - let incompatible_custom_note = Note::new(vault, metadata, recipient); - assert_eq!( - NoteAccountCompatibility::No, - wallet_account_interface.is_compatible_with(&incompatible_custom_note) - ); -} - -#[test] -fn test_basic_fungible_faucet_custom_notes() { - let mock_seed = Word::from([Felt::new(4), Felt::new(5), Felt::new(6), Felt::new(7)]).as_bytes(); - let faucet_account = AccountBuilder::new(mock_seed) - .account_type(AccountType::FungibleFaucet) - .with_auth_component(get_mock_falcon_auth_component()) - .with_component( - BasicFungibleFaucet::new( - TokenSymbol::new("POL").expect("invalid token symbol"), - 10, - Felt::new(100), - ) - .expect("failed to create a fungible faucet component"), - ) - .build_existing() - .expect("failed to create wallet account"); - let faucet_account_interface = AccountInterface::from_account(&faucet_account); - - let sender_account_id = ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2.try_into().unwrap(); - let serial_num = RandomCoin::new(Word::from([1, 2, 3, 4u32])).draw_word(); - let tag = NoteTag::with_account_target(faucet_account.id()); - let metadata = NoteMetadata::new(sender_account_id, NoteType::Public).with_tag(tag); - let vault = NoteAssets::new(vec![FungibleAsset::mock(100)]).unwrap(); - - let compatible_source_code = " - use miden::standards::wallets::basic->wallet - use miden::standards::faucets::basic_fungible->fungible_faucet - - @note_script - pub proc main - push.1 - if.true - # supported procs - call.fungible_faucet::mint_and_send - call.fungible_faucet::burn - else - # supported proc - call.fungible_faucet::mint_and_send - - # unsupported procs - call.wallet::receive_asset - call.wallet::move_asset_to_note - end - end - "; - let note_script = CodeBuilder::default().compile_note_script(compatible_source_code).unwrap(); - let recipient = NoteRecipient::new(serial_num, note_script, NoteStorage::default()); - let compatible_custom_note = Note::new(vault.clone(), metadata.clone(), recipient); - assert_eq!( - NoteAccountCompatibility::Maybe, - faucet_account_interface.is_compatible_with(&compatible_custom_note) - ); - - let incompatible_source_code = " - use miden::standards::wallets::basic->wallet - use miden::standards::faucets::basic_fungible->fungible_faucet - - @note_script - pub proc main - push.1 - if.true - # supported procs - call.fungible_faucet::mint_and_send - call.fungible_faucet::burn - - # unsupported proc - call.wallet::receive_asset - else - # supported proc - call.fungible_faucet::burn - - # unsupported procs - call.wallet::move_asset_to_note - end - end - "; - let note_script = CodeBuilder::default().compile_note_script(incompatible_source_code).unwrap(); - let recipient = NoteRecipient::new(serial_num, note_script, NoteStorage::default()); - let incompatible_custom_note = Note::new(vault, metadata, recipient); - assert_eq!( - NoteAccountCompatibility::No, - faucet_account_interface.is_compatible_with(&incompatible_custom_note) - ); -} - -/// Checks the compatibility of the note with custom code against an account with one custom -/// interface. -/// -/// In that setup the note script should have at least one execution branch with procedures from the -/// account interface for being `Maybe` compatible. -#[test] -fn test_custom_account_custom_notes() { - let account_custom_code_source = " - pub proc procedure_1 - push.1.2.3.4 dropw - end - - pub proc procedure_2 - push.5.6.7.8 dropw - end - "; - - let account_code = CodeBuilder::default() - .compile_component_code("test::account::component_1", account_custom_code_source) - .unwrap(); - let metadata = AccountComponentMetadata::new("test::account::component_1", AccountType::all()); - let account_component = AccountComponent::new(account_code, vec![], metadata).unwrap(); - - let mock_seed = Word::from([0, 1, 2, 3u32]).as_bytes(); - let target_account = AccountBuilder::new(mock_seed) - .with_auth_component(get_mock_falcon_auth_component()) - .with_component(account_component.clone()) - .build_existing() - .unwrap(); - let target_account_interface = AccountInterface::from_account(&target_account); - - let mock_seed = Word::from([0, 1, 2, 3u32]).as_bytes(); - let sender_account = AccountBuilder::new(mock_seed) - .with_auth_component(get_mock_falcon_auth_component()) - .with_component(BasicWallet) - .with_assets(vec![FungibleAsset::mock(20)]) - .build_existing() - .expect("failed to create wallet account"); - - let serial_num = RandomCoin::new(Word::from([1, 2, 3, 4u32])).draw_word(); - let tag = NoteTag::with_account_target(target_account.id()); - let metadata = NoteMetadata::new(sender_account.id(), NoteType::Public).with_tag(tag); - let vault = NoteAssets::new(vec![FungibleAsset::mock(100)]).unwrap(); - - let compatible_source_code = " - use miden::standards::wallets::basic->wallet - use test::account::component_1->test_account - - @note_script - pub proc main - push.1 - if.true - # supported proc - call.test_account::procedure_1 - - # unsupported proc - call.wallet::receive_asset - else - # supported procs - call.test_account::procedure_1 - call.test_account::procedure_2 - end - end - "; - let note_script = CodeBuilder::default() - .with_dynamically_linked_library(account_component.component_code()) - .unwrap() - .compile_note_script(compatible_source_code) - .unwrap(); - let recipient = NoteRecipient::new(serial_num, note_script, NoteStorage::default()); - let compatible_custom_note = Note::new(vault.clone(), metadata.clone(), recipient); - assert_eq!( - NoteAccountCompatibility::Maybe, - target_account_interface.is_compatible_with(&compatible_custom_note) - ); - - let incompatible_source_code = " - use miden::standards::wallets::basic->wallet - use test::account::component_1->test_account - - @note_script - pub proc main - push.1 - if.true - call.wallet::receive_asset - call.test_account::procedure_1 - else - call.test_account::procedure_2 - call.wallet::move_asset_to_note - end - end - "; - let note_script = CodeBuilder::default() - .with_dynamically_linked_library(account_component.component_code()) - .unwrap() - .compile_note_script(incompatible_source_code) - .unwrap(); - let recipient = NoteRecipient::new(serial_num, note_script, NoteStorage::default()); - let incompatible_custom_note = Note::new(vault, metadata, recipient); - assert_eq!( - NoteAccountCompatibility::No, - target_account_interface.is_compatible_with(&incompatible_custom_note) - ); -} - -/// Checks the compatibility of the note with custom code against an account with many custom -/// interfaces. -/// -/// In that setup the note script should have at least one execution branch with procedures from the -/// account interface for being `Maybe` compatible. -#[test] -fn test_custom_account_multiple_components_custom_notes() { - let account_custom_code_source = " - pub proc procedure_1 - push.1.2.3.4 dropw - end - - pub proc procedure_2 - push.5.6.7.8 dropw - end - "; - - let custom_code = CodeBuilder::default() - .compile_component_code("test::account::component_1", account_custom_code_source) - .unwrap(); - let metadata = AccountComponentMetadata::new("test::account::component_1", AccountType::all()); - let custom_component = AccountComponent::new(custom_code, vec![], metadata).unwrap(); - - let mock_seed = Word::from([0, 1, 2, 3u32]).as_bytes(); - let target_account = AccountBuilder::new(mock_seed) - .with_auth_component(get_mock_falcon_auth_component()) - .with_component(custom_component.clone()) - .with_component(BasicWallet) - .build_existing() - .unwrap(); - let target_account_interface = AccountInterface::from_account(&target_account); - - let mock_seed = Word::from([0, 1, 2, 3u32]).as_bytes(); - let sender_account = AccountBuilder::new(mock_seed) - .with_auth_component(get_mock_falcon_auth_component()) - .with_component(BasicWallet) - .with_assets(vec![FungibleAsset::mock(20)]) - .build_existing() - .expect("failed to create wallet account"); - - let serial_num = RandomCoin::new(Word::from([1, 2, 3, 4u32])).draw_word(); - let tag = NoteTag::with_account_target(target_account.id()); - let metadata = NoteMetadata::new(sender_account.id(), NoteType::Public).with_tag(tag); - let vault = NoteAssets::new(vec![FungibleAsset::mock(100)]).unwrap(); - - let compatible_source_code = " - use miden::standards::wallets::basic->wallet - use test::account::component_1->test_account - use miden::standards::faucets::basic_fungible->fungible_faucet - - @note_script - pub proc main - push.1 - if.true - # supported procs - call.wallet::receive_asset - call.wallet::move_asset_to_note - call.test_account::procedure_1 - call.test_account::procedure_2 - else - # supported procs - call.wallet::receive_asset - call.wallet::move_asset_to_note - call.test_account::procedure_1 - call.test_account::procedure_2 - - # unsupported proc - call.fungible_faucet::mint_and_send - end - end - "; - let note_script = CodeBuilder::default() - .with_dynamically_linked_library(custom_component.component_code()) - .unwrap() - .compile_note_script(compatible_source_code) - .unwrap(); - let recipient = NoteRecipient::new(serial_num, note_script, NoteStorage::default()); - let compatible_custom_note = Note::new(vault.clone(), metadata.clone(), recipient); - assert_eq!( - NoteAccountCompatibility::Maybe, - target_account_interface.is_compatible_with(&compatible_custom_note) - ); - - let incompatible_source_code = " - use miden::standards::wallets::basic->wallet - use test::account::component_1->test_account - use miden::standards::faucets::basic_fungible->fungible_faucet - - @note_script - pub proc main - push.1 - if.true - # supported procs - call.wallet::receive_asset - call.wallet::move_asset_to_note - call.test_account::procedure_1 - call.test_account::procedure_2 - - # unsupported proc - call.fungible_faucet::mint_and_send - else - # supported procs - call.test_account::procedure_1 - call.test_account::procedure_2 - - # unsupported proc - call.fungible_faucet::burn - end - end - "; - let note_script = CodeBuilder::default() - .with_dynamically_linked_library(custom_component.component_code()) - .unwrap() - .compile_note_script(incompatible_source_code) - .unwrap(); - let recipient = NoteRecipient::new(serial_num, note_script, NoteStorage::default()); - let incompatible_custom_note = Note::new(vault.clone(), metadata, recipient); - assert_eq!( - NoteAccountCompatibility::No, - target_account_interface.is_compatible_with(&incompatible_custom_note) - ); -} - // HELPERS // ================================================================================================ diff --git a/crates/miden-standards/src/account/metadata/schema_commitment.rs b/crates/miden-standards/src/account/metadata/schema_commitment.rs index b044609d22..23b3173049 100644 --- a/crates/miden-standards/src/account/metadata/schema_commitment.rs +++ b/crates/miden-standards/src/account/metadata/schema_commitment.rs @@ -1,3 +1,11 @@ +//! Account storage schema commitment component. +//! +//! [`AccountSchemaCommitment`] computes a commitment over the merged storage schemas of all +//! account components and stores the result in a dedicated slot. The companion +//! [`AccountBuilderSchemaCommitmentExt`] trait adds a convenience method to +//! [`AccountBuilder`](miden_protocol::account::AccountBuilder) for building accounts with an +//! automatically computed schema commitment. + use alloc::collections::BTreeMap; use miden_protocol::Word; @@ -12,7 +20,6 @@ use miden_protocol::account::{ Account, AccountBuilder, AccountComponent, - AccountType, StorageSlot, StorageSlotName, }; @@ -33,6 +40,7 @@ static STORAGE_SCHEMA_LIBRARY: LazyLock = LazyLock::new(|| { Library::read_from_bytes(bytes).expect("Shipped Storage Schema library is well-formed") }); +/// Schema commitment slot name. static SCHEMA_COMMITMENT_SLOT_NAME: LazyLock = LazyLock::new(|| { StorageSlotName::new("miden::standards::metadata::storage_schema::commitment") .expect("storage slot name should be valid") @@ -44,8 +52,8 @@ static SCHEMA_COMMITMENT_SLOT_NAME: LazyLock = LazyLock::new(|| /// An [`AccountComponent`] exposing the account storage schema commitment. /// /// The [`AccountSchemaCommitment`] component can be constructed from a list of [`StorageSchema`], -/// from which a commitment is computed and then inserted into the -/// `miden::standards::metadata::storage_schema::commitment` slot. +/// from which a commitment is computed and then inserted into the slot returned by +/// [`AccountSchemaCommitment::schema_commitment_slot()`]. /// /// It reexports the `get_schema_commitment` procedure from /// `miden::standards::metadata::storage_schema`. @@ -61,7 +69,6 @@ impl AccountSchemaCommitment { /// Name of the component is set to match the path of the corresponding module in the standards /// library. const NAME: &str = "miden::standards::metadata::storage_schema"; - /// Creates a new [`AccountSchemaCommitment`] component from storage schemas. /// /// The input schemas are merged into a single schema before the final commitment is computed. @@ -98,7 +105,7 @@ impl AccountSchemaCommitment { )]) .expect("storage schema should be valid"); - AccountComponentMetadata::new(Self::NAME, AccountType::all()) + AccountComponentMetadata::new(Self::NAME) .with_description("Component exposing the account storage schema commitment") .with_storage_schema(storage_schema) } @@ -151,8 +158,8 @@ impl AccountBuilderSchemaCommitmentExt for AccountBuilder { /// Computes the schema commitment. /// -/// The account schema commitment is computed from the merged schema commitment. If the passed -/// list of schemas is empty, [`Word::empty()`] is returned. +/// The account schema commitment is computed from the merged schema commitment. +/// If the passed list of schemas is empty, [`Word::empty()`] is returned. fn compute_schema_commitment<'a>( schemas: impl IntoIterator, ) -> Result { @@ -205,7 +212,6 @@ mod tests { name = "Component A" description = "Component A schema" version = "0.1.0" - supported-types = [] [[storage.slots]] name = "test::slot_a" @@ -216,7 +222,6 @@ mod tests { name = "Component B" description = "Component B schema" version = "0.1.0" - supported-types = [] [[storage.slots]] name = "test::slot_b" diff --git a/crates/miden-standards/src/account/mint_policies/auth_controlled.rs b/crates/miden-standards/src/account/mint_policies/auth_controlled.rs deleted file mode 100644 index 116810765e..0000000000 --- a/crates/miden-standards/src/account/mint_policies/auth_controlled.rs +++ /dev/null @@ -1,224 +0,0 @@ -use miden_protocol::Word; -use miden_protocol::account::component::{ - AccountComponentMetadata, - FeltSchema, - SchemaType, - StorageSchema, - StorageSlotSchema, -}; -use miden_protocol::account::{ - AccountComponent, - AccountType, - StorageMap, - StorageMapKey, - StorageSlot, - StorageSlotName, -}; -use miden_protocol::utils::sync::LazyLock; - -use super::MintPolicyAuthority; -use crate::account::components::auth_controlled_library; -use crate::procedure_digest; - -// CONSTANTS -// ================================================================================================ - -procedure_digest!( - ALLOW_ALL_POLICY_ROOT, - AuthControlled::NAME, - AuthControlled::ALLOW_ALL_PROC_NAME, - auth_controlled_library -); - -static ACTIVE_MINT_POLICY_PROC_ROOT_SLOT_NAME: LazyLock = LazyLock::new(|| { - StorageSlotName::new("miden::standards::mint_policy_manager::active_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::mint_policy_manager::allowed_policy_proc_roots") - .expect("storage slot name should be valid") -}); -/// An [`AccountComponent`] providing configurable mint-policy management for network faucets. -/// -/// It reexports policy procedures from `miden::standards::mint_policies` and manager procedures -/// from `miden::standards::mint_policies::policy_manager`: -/// - `allow_all` -/// - `set_mint_policy` -/// - `get_mint_policy` -/// -/// ## Storage Layout -/// -/// - [`Self::active_policy_proc_root_slot`]: Procedure root of the active mint policy. -/// - [`Self::allowed_policy_proc_roots_slot`]: Set of allowed mint policy procedure roots. -/// - [`Self::policy_authority_slot`]: Policy authority mode -/// ([`MintPolicyAuthority::AuthControlled`] = tx auth, [`MintPolicyAuthority::OwnerControlled`] = -/// external owner). -#[derive(Debug, Clone, Copy)] -pub struct AuthControlled { - initial_policy_root: Word, -} - -/// Initial policy configuration for the [`AuthControlled`] component. -#[derive(Debug, Clone, Copy, Default)] -pub enum AuthControlledInitConfig { - /// Sets the initial policy to `allow_all`. - #[default] - AllowAll, - /// Sets a custom initial policy root. - CustomInitialRoot(Word), -} - -impl AuthControlled { - /// The name of the component. - pub const NAME: &'static str = "miden::standards::components::mint_policies::auth_controlled"; - - const ALLOW_ALL_PROC_NAME: &str = "allow_all"; - - /// Creates a new [`AuthControlled`] component from the provided configuration. - pub fn new(policy: AuthControlledInitConfig) -> Self { - let initial_policy_root = match policy { - AuthControlledInitConfig::AllowAll => Self::allow_all_policy_root(), - AuthControlledInitConfig::CustomInitialRoot(root) => root, - }; - - Self { initial_policy_root } - } - - /// Creates a new [`AuthControlled`] component with `allow_all` policy as - /// default. - pub fn allow_all() -> Self { - Self::new(AuthControlledInitConfig::AllowAll) - } - - /// Returns the [`StorageSlotName`] where the active mint policy procedure root is stored. - pub fn active_policy_proc_root_slot() -> &'static StorageSlotName { - &ACTIVE_MINT_POLICY_PROC_ROOT_SLOT_NAME - } - - /// Returns the [`StorageSlotName`] where allowed policy roots are stored. - pub fn allowed_policy_proc_roots_slot() -> &'static StorageSlotName { - &ALLOWED_MINT_POLICY_PROC_ROOTS_SLOT_NAME - } - - /// Returns the storage slot schema for the active mint policy root. - pub fn active_policy_proc_root_slot_schema() -> (StorageSlotName, StorageSlotSchema) { - ( - Self::active_policy_proc_root_slot().clone(), - StorageSlotSchema::value( - "The procedure root of the active mint policy in the mint policy auth controlled component", - [ - FeltSchema::felt("proc_root_0"), - FeltSchema::felt("proc_root_1"), - FeltSchema::felt("proc_root_2"), - FeltSchema::felt("proc_root_3"), - ], - ), - ) - } - - /// Returns the storage slot schema for the allowed policy roots map. - pub fn allowed_policy_proc_roots_slot_schema() -> (StorageSlotName, StorageSlotSchema) { - ( - Self::allowed_policy_proc_roots_slot().clone(), - StorageSlotSchema::map( - "The set of allowed mint policy procedure roots in the mint policy auth controlled component", - SchemaType::native_word(), - SchemaType::native_word(), - ), - ) - } - - /// Returns the [`StorageSlotName`] containing policy authority mode. - pub fn policy_authority_slot() -> &'static StorageSlotName { - MintPolicyAuthority::slot() - } - - /// Returns the storage slot schema for policy authority mode. - pub fn policy_authority_slot_schema() -> (StorageSlotName, StorageSlotSchema) { - ( - Self::policy_authority_slot().clone(), - StorageSlotSchema::value( - "Policy authority mode (AuthControlled = tx auth, OwnerControlled = external owner)", - [ - FeltSchema::u8("policy_authority"), - FeltSchema::new_void(), - FeltSchema::new_void(), - FeltSchema::new_void(), - ], - ), - ) - } - - /// Returns the default `allow_all` policy root. - pub fn allow_all_policy_root() -> Word { - *ALLOW_ALL_POLICY_ROOT - } - - /// Returns the policy authority used by this component. - pub fn mint_policy_authority(&self) -> MintPolicyAuthority { - MintPolicyAuthority::AuthControlled - } -} - -impl Default for AuthControlled { - fn default() -> Self { - Self::allow_all() - } -} - -impl From for AccountComponent { - fn from(auth_controlled: AuthControlled) -> Self { - let active_policy_proc_root_slot = StorageSlot::with_value( - AuthControlled::active_policy_proc_root_slot().clone(), - auth_controlled.initial_policy_root, - ); - let allowed_policy_flag = Word::from([1u32, 0, 0, 0]); - let allow_all_policy_root = AuthControlled::allow_all_policy_root(); - - let mut allowed_policy_entries = - vec![(StorageMapKey::from_raw(allow_all_policy_root), allowed_policy_flag)]; - - if auth_controlled.initial_policy_root != allow_all_policy_root { - allowed_policy_entries.push(( - StorageMapKey::from_raw(auth_controlled.initial_policy_root), - allowed_policy_flag, - )); - } - - let allowed_policy_proc_roots = StorageMap::with_entries(allowed_policy_entries) - .expect("allowed mint policy roots should have unique keys"); - - let allowed_policy_proc_roots_slot = StorageSlot::with_map( - AuthControlled::allowed_policy_proc_roots_slot().clone(), - allowed_policy_proc_roots, - ); - let policy_authority_slot = StorageSlot::from(auth_controlled.mint_policy_authority()); - - let storage_schema = StorageSchema::new(vec![ - AuthControlled::active_policy_proc_root_slot_schema(), - AuthControlled::allowed_policy_proc_roots_slot_schema(), - AuthControlled::policy_authority_slot_schema(), - ]) - .expect("storage schema should be valid"); - - let metadata = - AccountComponentMetadata::new(AuthControlled::NAME, [AccountType::FungibleFaucet]) - .with_description( - "Mint policy auth controlled component for network fungible faucets", - ) - .with_storage_schema(storage_schema); - - AccountComponent::new( - auth_controlled_library(), - vec![ - active_policy_proc_root_slot, - allowed_policy_proc_roots_slot, - policy_authority_slot, - ], - metadata, - ) - .expect( - "mint policy auth controlled component should satisfy the requirements of a valid account component", - ) - } -} diff --git a/crates/miden-standards/src/account/mint_policies/mod.rs b/crates/miden-standards/src/account/mint_policies/mod.rs deleted file mode 100644 index 91f990df62..0000000000 --- a/crates/miden-standards/src/account/mint_policies/mod.rs +++ /dev/null @@ -1,46 +0,0 @@ -use miden_protocol::Word; -use miden_protocol::account::{StorageSlot, StorageSlotName}; -use miden_protocol::utils::sync::LazyLock; - -mod auth_controlled; -mod owner_controlled; - -pub use auth_controlled::{AuthControlled, AuthControlledInitConfig}; -pub use owner_controlled::{OwnerControlled, OwnerControlledInitConfig}; - -static POLICY_AUTHORITY_SLOT_NAME: LazyLock = LazyLock::new(|| { - StorageSlotName::new("miden::standards::mint_policy_manager::policy_authority") - .expect("storage slot name should be valid") -}); - -/// Identifies which authority is allowed to manage the active mint policy for a faucet. -/// -/// This value is stored in the policy authority slot so the account can distinguish whether mint -/// policy updates are governed by authentication component logic or by the account owner. -#[repr(u8)] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MintPolicyAuthority { - /// Mint policy changes are authorized by the account's authentication component logic. - AuthControlled = 0, - /// Mint policy changes are authorized by the external account owner. - OwnerControlled = 1, -} - -impl MintPolicyAuthority { - /// Returns the [`StorageSlotName`] containing the mint policy authority mode. - pub fn slot() -> &'static StorageSlotName { - &POLICY_AUTHORITY_SLOT_NAME - } -} - -impl From for Word { - fn from(value: MintPolicyAuthority) -> Self { - Word::from([value as u32, 0, 0, 0]) - } -} - -impl From for StorageSlot { - fn from(value: MintPolicyAuthority) -> Self { - StorageSlot::with_value(MintPolicyAuthority::slot().clone(), value.into()) - } -} diff --git a/crates/miden-standards/src/account/mint_policies/owner_controlled.rs b/crates/miden-standards/src/account/mint_policies/owner_controlled.rs deleted file mode 100644 index 4cc606f841..0000000000 --- a/crates/miden-standards/src/account/mint_policies/owner_controlled.rs +++ /dev/null @@ -1,225 +0,0 @@ -use miden_protocol::Word; -use miden_protocol::account::component::{ - AccountComponentMetadata, - FeltSchema, - SchemaType, - StorageSchema, - StorageSlotSchema, -}; -use miden_protocol::account::{ - AccountComponent, - AccountType, - StorageMap, - StorageMapKey, - StorageSlot, - StorageSlotName, -}; -use miden_protocol::utils::sync::LazyLock; - -use super::MintPolicyAuthority; -use crate::account::components::owner_controlled_library; -use crate::procedure_digest; - -// CONSTANTS -// ================================================================================================ - -procedure_digest!( - OWNER_ONLY_POLICY_ROOT, - OwnerControlled::NAME, - OwnerControlled::OWNER_ONLY_PROC_NAME, - owner_controlled_library -); - -static ACTIVE_MINT_POLICY_PROC_ROOT_SLOT_NAME: LazyLock = LazyLock::new(|| { - StorageSlotName::new("miden::standards::mint_policy_manager::active_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::mint_policy_manager::allowed_policy_proc_roots") - .expect("storage slot name should be valid") -}); -/// An [`AccountComponent`] providing configurable mint-policy management for network faucets. -/// -/// It reexports policy procedures from `miden::standards::mint_policies` and manager procedures -/// from `miden::standards::mint_policies::policy_manager`: -/// - `owner_only` -/// - `set_mint_policy` -/// - `get_mint_policy` -/// -/// ## Storage Layout -/// -/// - [`Self::active_policy_proc_root_slot`]: Procedure root of the active mint policy. -/// - [`Self::allowed_policy_proc_roots_slot`]: Set of allowed mint policy procedure roots. -/// - [`Self::policy_authority_slot`]: Policy authority mode -/// ([`MintPolicyAuthority::AuthControlled`] = tx auth, [`MintPolicyAuthority::OwnerControlled`] = -/// external owner). -#[derive(Debug, Clone, Copy)] -pub struct OwnerControlled { - initial_policy_root: Word, -} - -/// Initial policy configuration for the [`OwnerControlled`] component. -#[derive(Debug, Clone, Copy, Default)] -pub enum OwnerControlledInitConfig { - /// Sets the initial policy to `owner_only`. - #[default] - OwnerOnly, - /// Sets a custom initial policy root. - CustomInitialRoot(Word), -} - -impl OwnerControlled { - /// The name of the component. - pub const NAME: &'static str = "miden::standards::components::mint_policies::owner_controlled"; - - const OWNER_ONLY_PROC_NAME: &str = "owner_only"; - - /// Creates a new [`OwnerControlled`] component from the provided configuration. - pub fn new(policy: OwnerControlledInitConfig) -> Self { - let initial_policy_root = match policy { - OwnerControlledInitConfig::OwnerOnly => Self::owner_only_policy_root(), - OwnerControlledInitConfig::CustomInitialRoot(root) => root, - }; - - Self { initial_policy_root } - } - - /// Creates a new [`OwnerControlled`] component with owner-only policy as default. - pub fn owner_only() -> Self { - Self::new(OwnerControlledInitConfig::OwnerOnly) - } - - /// Returns the [`StorageSlotName`] where the active mint policy procedure root is stored. - pub fn active_policy_proc_root_slot() -> &'static StorageSlotName { - &ACTIVE_MINT_POLICY_PROC_ROOT_SLOT_NAME - } - - /// Returns the [`StorageSlotName`] where allowed policy roots are stored. - pub fn allowed_policy_proc_roots_slot() -> &'static StorageSlotName { - &ALLOWED_MINT_POLICY_PROC_ROOTS_SLOT_NAME - } - - /// Returns the storage slot schema for the active mint policy root. - pub fn active_policy_proc_root_slot_schema() -> (StorageSlotName, StorageSlotSchema) { - ( - Self::active_policy_proc_root_slot().clone(), - StorageSlotSchema::value( - "The procedure root of the active mint policy in the mint policy owner controlled component", - [ - FeltSchema::felt("proc_root_0"), - FeltSchema::felt("proc_root_1"), - FeltSchema::felt("proc_root_2"), - FeltSchema::felt("proc_root_3"), - ], - ), - ) - } - - /// Returns the storage slot schema for the allowed policy roots map. - pub fn allowed_policy_proc_roots_slot_schema() -> (StorageSlotName, StorageSlotSchema) { - ( - Self::allowed_policy_proc_roots_slot().clone(), - StorageSlotSchema::map( - "The set of allowed mint policy procedure roots in the mint policy owner controlled component", - SchemaType::native_word(), - SchemaType::native_word(), - ), - ) - } - - /// Returns the [`StorageSlotName`] containing policy authority mode. - pub fn policy_authority_slot() -> &'static StorageSlotName { - MintPolicyAuthority::slot() - } - - /// Returns the storage slot schema for policy authority mode. - pub fn policy_authority_slot_schema() -> (StorageSlotName, StorageSlotSchema) { - ( - Self::policy_authority_slot().clone(), - StorageSlotSchema::value( - "Policy authority mode (AuthControlled = tx auth, OwnerControlled = external owner)", - [ - FeltSchema::u8("policy_authority"), - FeltSchema::new_void(), - FeltSchema::new_void(), - FeltSchema::new_void(), - ], - ), - ) - } - - /// Returns the default owner-only policy root. - pub fn owner_only_policy_root() -> Word { - *OWNER_ONLY_POLICY_ROOT - } - - /// Returns the policy authority used by this component. - pub fn mint_policy_authority(&self) -> MintPolicyAuthority { - MintPolicyAuthority::OwnerControlled - } - - /// Returns the [`AccountComponentMetadata`] for this component. - pub fn component_metadata() -> AccountComponentMetadata { - let storage_schema = StorageSchema::new(vec![ - OwnerControlled::active_policy_proc_root_slot_schema(), - OwnerControlled::allowed_policy_proc_roots_slot_schema(), - OwnerControlled::policy_authority_slot_schema(), - ]) - .expect("storage schema should be valid"); - - AccountComponentMetadata::new(OwnerControlled::NAME, [AccountType::FungibleFaucet]) - .with_description("Mint policy owner controlled component for network fungible faucets") - .with_storage_schema(storage_schema) - } -} - -impl Default for OwnerControlled { - fn default() -> Self { - Self::owner_only() - } -} - -impl From for AccountComponent { - fn from(owner_controlled: OwnerControlled) -> Self { - let active_policy_proc_root_slot = StorageSlot::with_value( - OwnerControlled::active_policy_proc_root_slot().clone(), - owner_controlled.initial_policy_root, - ); - let allowed_policy_flag = Word::from([1u32, 0, 0, 0]); - let owner_only_policy_root = OwnerControlled::owner_only_policy_root(); - - let mut allowed_policy_entries = - vec![(StorageMapKey::from_raw(owner_only_policy_root), allowed_policy_flag)]; - - if owner_controlled.initial_policy_root != owner_only_policy_root { - allowed_policy_entries.push(( - StorageMapKey::from_raw(owner_controlled.initial_policy_root), - allowed_policy_flag, - )); - } - - let allowed_policy_proc_roots = StorageMap::with_entries(allowed_policy_entries) - .expect("allowed mint policy roots should have unique keys"); - - let allowed_policy_proc_roots_slot = StorageSlot::with_map( - OwnerControlled::allowed_policy_proc_roots_slot().clone(), - allowed_policy_proc_roots, - ); - let policy_authority_slot = StorageSlot::from(owner_controlled.mint_policy_authority()); - - let metadata = OwnerControlled::component_metadata(); - - AccountComponent::new( - owner_controlled_library(), - vec![ - active_policy_proc_root_slot, - allowed_policy_proc_roots_slot, - policy_authority_slot, - ], - metadata, - ) - .expect( - "mint policy owner controlled component should satisfy the requirements of a valid account component", - ) - } -} diff --git a/crates/miden-standards/src/account/mod.rs b/crates/miden-standards/src/account/mod.rs index 9580c185f8..0dcafe14c3 100644 --- a/crates/miden-standards/src/account/mod.rs +++ b/crates/miden-standards/src/account/mod.rs @@ -6,46 +6,87 @@ pub mod components; pub mod faucets; pub mod interface; pub mod metadata; -pub mod mint_policies; +pub mod policies; pub mod wallets; pub use metadata::AccountBuilderSchemaCommitmentExt; -/// Macro to simplify the creation of static procedure digest constants. +/// Macro to simplify the creation of static procedure root constants. /// -/// This macro generates a `LazyLock` static variable that lazily initializes -/// the digest of a procedure from a library. +/// This macro generates a `LazyLock` static variable that lazily initializes +/// the procedure root of a procedure from an [`AccountComponentCode`]. /// /// The full procedure path is constructed by concatenating `$component_name` and `$proc_name` /// with `::` as separator (i.e. `"{component_name}::{proc_name}"`). /// /// Note: This macro references exported types from `miden_protocol`, so your crate must -/// include `miden_protocol` as a dependency. +/// include `miden_protocol` as a dependency. The expanded code uses `::alloc::format!`, so +/// downstream callers must also have `extern crate alloc;` (or otherwise expose `alloc` at the +/// crate root) - this is automatic in `std`-linked binaries. /// /// # Arguments /// * `$name` - The name of the static variable to create /// * `$component_name` - The name of the component (e.g. `BasicWallet::NAME`) /// * `$proc_name` - The short name of the procedure (e.g. `"receive_asset"`) -/// * `$library_fn` - The function that returns the library containing the procedure +/// * `$component_code` - An expression evaluating to `&AccountComponentCode` (or any type coercible +/// via `Deref` such as `&LazyLock`). +/// +/// [`AccountComponentCode`]: miden_protocol::account::AccountComponentCode /// /// # Example /// ```ignore -/// procedure_digest!( +/// procedure_root!( /// BASIC_WALLET_RECEIVE_ASSET, /// BasicWallet::NAME, /// BasicWallet::RECEIVE_ASSET_PROC_NAME, -/// basic_wallet_library +/// BasicWallet::code() /// ); /// ``` #[macro_export] -macro_rules! procedure_digest { - ($name:ident, $component_name:expr, $proc_name:expr, $library_fn:expr) => { - static $name: miden_protocol::utils::sync::LazyLock = - miden_protocol::utils::sync::LazyLock::new(|| { - let full_path = alloc::format!("{}::{}", $component_name, $proc_name); - $library_fn().get_procedure_root_by_path(full_path.as_str()).unwrap_or_else(|| { - panic!("{} should contain '{}' procedure", stringify!($library_fn), full_path) - }) - }); +macro_rules! procedure_root { + ($name:ident, $component_name:expr, $proc_name:expr, $component_code:expr) => { + static $name: miden_protocol::utils::sync::LazyLock< + miden_protocol::account::AccountProcedureRoot, + > = miden_protocol::utils::sync::LazyLock::new(|| { + let full_path = ::alloc::format!("{}::{}", $component_name, $proc_name); + let code: &miden_protocol::account::AccountComponentCode = $component_code; + code.get_procedure_root_by_path(full_path.as_str()) + .unwrap_or_else(|| panic!("component should contain procedure '{}'", full_path)) + }); + }; +} + +/// Macro to declare a static `LazyLock` initialized from a `.masl` asset +/// shipped by the `miden-standards` build script. +/// +/// `$relative_path` is appended to `concat!(env!("OUT_DIR"), "/assets/account_components/")` and +/// the resulting bytes are deserialized via [`Library::read_from_bytes`]. +/// +/// This macro is intended for use **inside the `miden-standards` crate only**: it relies on +/// `env!("OUT_DIR")` resolving against `miden-standards`'s build script, which is where the +/// `account_components` assets are written. +/// +/// [`Library::read_from_bytes`]: miden_protocol::assembly::Library::read_from_bytes +/// +/// # Example +/// ```ignore +/// account_component_code!(BASIC_WALLET_CODE, "wallets/basic_wallet.masl"); +/// ``` +macro_rules! account_component_code { + ($name:ident, $relative_path:expr) => { + static $name: miden_protocol::utils::sync::LazyLock< + miden_protocol::account::component::AccountComponentCode, + > = miden_protocol::utils::sync::LazyLock::new(|| { + let bytes = include_bytes!(concat!( + env!("OUT_DIR"), + "/assets/account_components/", + $relative_path + )); + let library = ::read_from_bytes(bytes) + .expect("Shipped library failed to deserialize: {err}"); + miden_protocol::account::component::AccountComponentCode::from(library) + }); }; } + +pub(crate) use account_component_code; diff --git a/crates/miden-standards/src/account/policies/burn/allow_all.rs b/crates/miden-standards/src/account/policies/burn/allow_all.rs new file mode 100644 index 0000000000..c63aed86d0 --- /dev/null +++ b/crates/miden-standards/src/account/policies/burn/allow_all.rs @@ -0,0 +1,59 @@ +use miden_protocol::account::component::{AccountComponentCode, AccountComponentMetadata}; +use miden_protocol::account::{AccountComponent, AccountComponentName, AccountProcedureRoot}; + +use crate::account::account_component_code; +use crate::procedure_root; + +// ALLOW-ALL BURN POLICY +// ================================================================================================ + +account_component_code!(ALLOW_ALL_BURN_POLICY_CODE, "faucets/policies/burn/allow_all.masl"); + +procedure_root!( + ALLOW_ALL_POLICY_ROOT, + BurnAllowAll::NAME, + BurnAllowAll::PROC_NAME, + BurnAllowAll::code() +); + +/// The storage-free `allow_all` burn policy account component. +/// +/// Pair with a [`crate::account::policies::TokenPolicyManager`] whose allowed burn-policies +/// map includes [`BurnAllowAll::root`]. `allow_all` makes burning permissionless (no additional +/// authorization beyond the manager's authority gate). +#[derive(Debug, Clone, Copy, Default)] +pub struct BurnAllowAll; + +impl BurnAllowAll { + /// The name of the component. + pub const NAME: &'static str = + "miden::standards::components::faucets::policies::burn::allow_all"; + + pub(crate) const PROC_NAME: &str = "check_policy"; + + /// 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 { + &ALLOW_ALL_BURN_POLICY_CODE + } + + /// Returns the procedure root of the `allow_all` burn policy procedure. + pub fn root() -> AccountProcedureRoot { + *ALLOW_ALL_POLICY_ROOT + } +} + +impl From for AccountComponent { + fn from(_: BurnAllowAll) -> Self { + let metadata = AccountComponentMetadata::new(BurnAllowAll::NAME) + .with_description("`allow_all` burn policy for fungible faucets"); + + AccountComponent::new(BurnAllowAll::code().clone(), vec![], metadata).expect( + "`allow_all` 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 new file mode 100644 index 0000000000..effb645b8a --- /dev/null +++ b/crates/miden-standards/src/account/policies/burn/mod.rs @@ -0,0 +1,56 @@ +//! Burn policy components and the burn policy configuration enum used by +//! [`super::TokenPolicyManager`]. + +use alloc::vec::Vec; + +use miden_protocol::Word; +use miden_protocol::account::AccountComponent; + +mod allow_all; +mod owner_only; + +pub use allow_all::BurnAllowAll; +pub use owner_only::BurnOwnerOnly; + +// CONFIG +// ================================================================================================ + +/// Selects which burn policy is registered with a [`super::TokenPolicyManager`]. +/// +/// 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), +} + +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, + } + } + + /// Returns the [`AccountComponent`]s that must accompany this burn policy variant. + /// + /// 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(), + } + } +} diff --git a/crates/miden-standards/src/account/policies/burn/owner_only.rs b/crates/miden-standards/src/account/policies/burn/owner_only.rs new file mode 100644 index 0000000000..e0e78bc5c0 --- /dev/null +++ b/crates/miden-standards/src/account/policies/burn/owner_only.rs @@ -0,0 +1,63 @@ +use miden_protocol::account::component::{AccountComponentCode, AccountComponentMetadata}; +use miden_protocol::account::{AccountComponent, AccountComponentName, AccountProcedureRoot}; + +use crate::account::account_component_code; +use crate::procedure_root; + +// OWNER-ONLY BURN POLICY +// ================================================================================================ + +account_component_code!( + OWNER_ONLY_BURN_POLICY_CODE, + "faucets/policies/burn/owner_controlled/owner_only.masl" +); + +procedure_root!( + OWNER_ONLY_POLICY_ROOT, + BurnOwnerOnly::NAME, + BurnOwnerOnly::PROC_NAME, + BurnOwnerOnly::code() +); + +/// The storage-free `owner_only` burn policy account component (owner-controlled family). +/// +/// 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. +#[derive(Debug, Clone, Copy, Default)] +pub struct BurnOwnerOnly; + +impl BurnOwnerOnly { + /// The name of the component. + pub const NAME: &'static str = + "miden::standards::components::faucets::policies::burn::owner_controlled::owner_only"; + + pub(crate) const PROC_NAME: &str = "check_policy"; + + /// 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 { + &OWNER_ONLY_BURN_POLICY_CODE + } + + /// Returns the procedure root of the `owner_only` burn policy procedure. + pub fn root() -> AccountProcedureRoot { + *OWNER_ONLY_POLICY_ROOT + } +} + +impl From for AccountComponent { + fn from(_: BurnOwnerOnly) -> Self { + let metadata = AccountComponentMetadata::new(BurnOwnerOnly::NAME).with_description( + "`owner_only` burn policy (owner-controlled family) for fungible faucets", + ); + + AccountComponent::new(BurnOwnerOnly::code().clone(), vec![], metadata).expect( + "`owner_only` burn policy component should satisfy the requirements of a valid account component", + ) + } +} diff --git a/crates/miden-standards/src/account/policies/manager.rs b/crates/miden-standards/src/account/policies/manager.rs new file mode 100644 index 0000000000..e96c7583b9 --- /dev/null +++ b/crates/miden-standards/src/account/policies/manager.rs @@ -0,0 +1,735 @@ +//! 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. + +use alloc::collections::{BTreeMap, BTreeSet}; +use alloc::vec::Vec; + +use miden_protocol::Word; +use miden_protocol::account::component::{ + AccountComponentCode, + AccountComponentMetadata, + SchemaType, + StorageSchema, + StorageSlotSchema, +}; +use miden_protocol::account::{ + AccountComponent, + AccountComponentName, + AccountProcedureRoot, + StorageMap, + StorageMapKey, + StorageSlot, + StorageSlotName, +}; +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::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 +// ================================================================================================ + +/// MASL library namespace used for procedure-root lookups. Distinct from +/// [`TokenPolicyManager::NAME`], which mirrors the standards-side MASM module path. +const POLICY_MANAGER_LIBRARY_PATH: &str = + "miden::standards::components::faucets::policies::policy_manager"; + +procedure_root!( + POLICY_MANAGER_SET_MINT_POLICY, + POLICY_MANAGER_LIBRARY_PATH, + TokenPolicyManager::SET_MINT_POLICY_PROC_NAME, + TokenPolicyManager::code() +); + +procedure_root!( + POLICY_MANAGER_SET_BURN_POLICY, + POLICY_MANAGER_LIBRARY_PATH, + TokenPolicyManager::SET_BURN_POLICY_PROC_NAME, + TokenPolicyManager::code() +); + +procedure_root!( + POLICY_MANAGER_SET_SEND_POLICY, + POLICY_MANAGER_LIBRARY_PATH, + TokenPolicyManager::SET_SEND_POLICY_PROC_NAME, + TokenPolicyManager::code() +); + +procedure_root!( + POLICY_MANAGER_SET_RECEIVE_POLICY, + POLICY_MANAGER_LIBRARY_PATH, + TokenPolicyManager::SET_RECEIVE_POLICY_PROC_NAME, + TokenPolicyManager::code() +); + +// STORAGE SLOT NAMES +// ================================================================================================ + +static ACTIVE_MINT_POLICY_PROC_ROOT_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new( + "miden::standards::faucets::policies::policy_manager::active_mint_policy_proc_root", + ) + .expect("storage slot name should be valid") +}); + +static ACTIVE_BURN_POLICY_PROC_ROOT_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new( + "miden::standards::faucets::policies::policy_manager::active_burn_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", + ) + .expect("storage slot name should be valid") +}); + +static ALLOWED_BURN_POLICY_PROC_ROOTS_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new( + "miden::standards::faucets::policies::policy_manager::allowed_burn_policy_proc_roots", + ) + .expect("storage slot name should be valid") +}); + +static ALLOWED_SEND_POLICY_PROC_ROOTS_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new( + "miden::standards::faucets::policies::policy_manager::allowed_send_policy_proc_roots", + ) + .expect("storage slot name should be valid") +}); + +static ALLOWED_RECEIVE_POLICY_PROC_ROOTS_SLOT_NAME: LazyLock = + LazyLock::new(|| { + StorageSlotName::new( + "miden::standards::faucets::policies::policy_manager::allowed_receive_policy_proc_roots", + ) + .expect("storage slot name should be valid") + }); + +// POLICY KIND +// ================================================================================================ + +/// Identifies which faucet operation a policy gates. +/// +/// Used internally by [`PolicyConfig`] to record which `allowed_*_policies` storage maps a +/// policy procedure root should be registered in. The same procedure root may belong to more +/// than one kind (for example a transfer policy used for both send and receive). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum PolicyKind { + Mint, + Burn, + Send, + Receive, +} + +// POLICY CONFIG +// ================================================================================================ + +/// Internal entry stored inside [`TokenPolicyManager::policies`] for every registered policy +/// procedure root. Captures the companion components the policy needs installed on the +/// account and the set of policy kinds the root is registered under (the same root may serve +/// more than one kind, e.g. a transfer policy active for both send and receive). +#[derive(Debug, Clone)] +struct PolicyConfig { + components: Vec, + kinds: BTreeSet, +} + +// TOKEN POLICY MANAGER +// ================================================================================================ + +/// 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. +/// +/// 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. +/// +/// ## 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::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 +/// 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)] +pub struct TokenPolicyManager { + active_mint_policy_root: AccountProcedureRoot, + active_burn_policy_root: AccountProcedureRoot, + active_send_policy_root: AccountProcedureRoot, + active_receive_policy_root: AccountProcedureRoot, + policies: BTreeMap, +} + +impl TokenPolicyManager { + // CONSTANTS + // -------------------------------------------------------------------------------------------- + + /// The name of the component (used in metadata). + pub const NAME: &'static str = "miden::standards::faucets::policies::policy_manager"; + + /// Component description used in [`AccountComponentMetadata`]. + pub const DESCRIPTION: &'static str = "Token policy manager for fungible faucets"; + + const SET_MINT_POLICY_PROC_NAME: &'static str = "set_mint_policy"; + 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"; + + /// 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 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 send policy procedure root, or [`None`] if no active send policy has + /// been registered. + 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. + pub fn active_receive_policy(&self) -> Option { + (!self.active_receive_policy_root.as_word().is_empty()) + .then_some(self.active_receive_policy_root) + } + + /// Returns all allowed mint policy procedure roots (active + reserved). + pub fn allowed_mint_policies(&self) -> Vec { + self.roots_of_kind(PolicyKind::Mint) + } + + /// Returns all allowed burn policy procedure roots (active + reserved). + pub fn allowed_burn_policies(&self) -> Vec { + self.roots_of_kind(PolicyKind::Burn) + } + + /// Returns all allowed send policy procedure roots (active + reserved). + pub fn allowed_send_policies(&self) -> Vec { + self.roots_of_kind(PolicyKind::Send) + } + + /// Returns all allowed receive policy procedure roots (active + reserved). + pub fn allowed_receive_policies(&self) -> Vec { + self.roots_of_kind(PolicyKind::Receive) + } + + fn roots_of_kind(&self, kind: PolicyKind) -> Vec { + self.policies + .iter() + .filter(|(_, cfg)| cfg.kinds.contains(&kind)) + .map(|(root, _)| *root) + .collect() + } + + /// Returns the procedure root of the `set_mint_policy` account procedure. + pub fn set_mint_policy_root() -> AccountProcedureRoot { + *POLICY_MANAGER_SET_MINT_POLICY + } + + /// Returns the procedure root of the `set_burn_policy` account procedure. + pub fn set_burn_policy_root() -> AccountProcedureRoot { + *POLICY_MANAGER_SET_BURN_POLICY + } + + /// Returns the procedure root of the `set_send_policy` account procedure. + pub fn set_send_policy_root() -> AccountProcedureRoot { + *POLICY_MANAGER_SET_SEND_POLICY + } + + /// Returns the procedure root of the `set_receive_policy` account procedure. + pub fn set_receive_policy_root() -> AccountProcedureRoot { + *POLICY_MANAGER_SET_RECEIVE_POLICY + } + + /// Returns the [`StorageSlotName`] where the active mint policy procedure root is stored. + pub fn active_mint_policy_slot() -> &'static StorageSlotName { + &ACTIVE_MINT_POLICY_PROC_ROOT_SLOT_NAME + } + + /// Returns the [`StorageSlotName`] where the active burn policy procedure root is stored. + pub fn active_burn_policy_slot() -> &'static StorageSlotName { + &ACTIVE_BURN_POLICY_PROC_ROOT_SLOT_NAME + } + + /// 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 + } + + /// Returns the [`StorageSlotName`] where allowed burn policy roots are stored. + pub fn allowed_burn_policies_slot() -> &'static StorageSlotName { + &ALLOWED_BURN_POLICY_PROC_ROOTS_SLOT_NAME + } + + /// Returns the [`StorageSlotName`] where allowed send policy roots are stored. + pub fn allowed_send_policies_slot() -> &'static StorageSlotName { + &ALLOWED_SEND_POLICY_PROC_ROOTS_SLOT_NAME + } + + /// Returns the [`StorageSlotName`] where allowed receive policy roots are stored. + pub fn allowed_receive_policies_slot() -> &'static StorageSlotName { + &ALLOWED_RECEIVE_POLICY_PROC_ROOTS_SLOT_NAME + } + + /// Returns the [`AccountComponentCode`] of this component. + pub fn code() -> &'static AccountComponentCode { + &POLICY_MANAGER_CODE + } + + /// Returns the [`AccountComponentMetadata`] for this component. + pub fn component_metadata() -> AccountComponentMetadata { + let storage_schema = StorageSchema::new(vec![ + ( + ACTIVE_MINT_POLICY_PROC_ROOT_SLOT_NAME.clone(), + StorageSlotSchema::value( + "Active mint policy procedure root", + SchemaType::native_word(), + ), + ), + ( + ACTIVE_BURN_POLICY_PROC_ROOT_SLOT_NAME.clone(), + StorageSlotSchema::value( + "Active burn policy procedure root", + SchemaType::native_word(), + ), + ), + ( + ALLOWED_MINT_POLICY_PROC_ROOTS_SLOT_NAME.clone(), + StorageSlotSchema::map( + "Allowed mint policy procedure roots", + SchemaType::native_word(), + SchemaType::native_word(), + ), + ), + ( + ALLOWED_BURN_POLICY_PROC_ROOTS_SLOT_NAME.clone(), + StorageSlotSchema::map( + "Allowed burn policy procedure roots", + SchemaType::native_word(), + SchemaType::native_word(), + ), + ), + ( + ALLOWED_SEND_POLICY_PROC_ROOTS_SLOT_NAME.clone(), + StorageSlotSchema::map( + "Allowed send policy procedure roots", + SchemaType::native_word(), + SchemaType::native_word(), + ), + ), + ( + ALLOWED_RECEIVE_POLICY_PROC_ROOTS_SLOT_NAME.clone(), + StorageSlotSchema::map( + "Allowed receive policy procedure roots", + SchemaType::native_word(), + SchemaType::native_word(), + ), + ), + ]) + .expect("storage schema should be valid"); + + AccountComponentMetadata::new(Self::NAME) + .with_description(Self::DESCRIPTION) + .with_storage_schema(storage_schema) + } + + 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(), + self.active_mint_policy_root.as_word(), + ), + StorageSlot::with_value( + ACTIVE_BURN_POLICY_PROC_ROOT_SLOT_NAME.clone(), + self.active_burn_policy_root.as_word(), + ), + StorageSlot::with_map( + ALLOWED_MINT_POLICY_PROC_ROOTS_SLOT_NAME.clone(), + self.build_allowed_map(PolicyKind::Mint), + ), + StorageSlot::with_map( + ALLOWED_BURN_POLICY_PROC_ROOTS_SLOT_NAME.clone(), + self.build_allowed_map(PolicyKind::Burn), + ), + StorageSlot::with_map( + ALLOWED_SEND_POLICY_PROC_ROOTS_SLOT_NAME.clone(), + self.build_allowed_map(PolicyKind::Send), + ), + StorageSlot::with_map( + ALLOWED_RECEIVE_POLICY_PROC_ROOTS_SLOT_NAME.clone(), + self.build_allowed_map(PolicyKind::Receive), + ), + ]; + + // 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. + 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()) + .into_storage_slots(); + slots.extend(callback_slots); + } + + slots + } + + /// Builds the `allowed_*_policies` storage map for the given kind by filtering the + /// unified `policies` map. Each entry maps the policy procedure root to a non-zero flag, + /// so runtime `set_*_policy` validation can confirm the root is allowed before activating + /// it. + fn build_allowed_map(&self, kind: PolicyKind) -> StorageMap { + let allowed_flag = Word::from([1u32, 0, 0, 0]); + let entries: Vec<_> = self + .policies + .iter() + .filter(|(_, cfg)| cfg.kinds.contains(&kind)) + .map(|(root, _)| (StorageMapKey::new(root.as_word()), allowed_flag)) + .collect(); + StorageMap::with_entries(entries).expect("allowed policy roots should have unique keys") + } + + fn to_manager_component(&self) -> AccountComponent { + let storage_slots = self.manager_storage_slots(); + AccountComponent::new( + Self::code().clone(), + storage_slots, + Self::component_metadata(), + ) + .expect( + "token policy manager component should satisfy the requirements of a valid account component", + ) + } +} + +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(), + } + } +} + +impl IntoIterator for TokenPolicyManager { + type Item = AccountComponent; + type IntoIter = alloc::vec::IntoIter; + + /// Yields the [`AccountComponent`]s implementing this token policy configuration: the + /// 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. + fn into_iter(self) -> Self::IntoIter { + let manager_component = self.to_manager_component(); + let mut components = vec![manager_component]; + for (_, policy) in self.policies { + components.extend(policy.components); + } + components.into_iter() + } +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use miden_protocol::asset::AssetCallbacks; + + use super::*; + use crate::account::policies::transfer::TransferAllowAll; + + /// Returns the manager component's storage slot for the given slot name, or `None` if the + /// component does not register a slot with that name. + fn find_slot<'a>( + component: &'a AccountComponent, + slot_name: &StorageSlotName, + ) -> Option<&'a StorageSlot> { + 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. + #[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_component = manager.to_manager_component(); + + let allow_all_root = TransferAllowAll::root().as_word(); + + let on_account_slot = + find_slot(&manager_component, AssetCallbacks::on_before_asset_added_to_account_slot()) + .expect( + "AllowAll 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( + "AllowAll send policy must register the on_before_asset_added_to_note protocol \ + 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); + } + + /// A manager configured without any send / receive policy 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_component = manager.to_manager_component(); + + assert!( + find_slot(&manager_component, AssetCallbacks::on_before_asset_added_to_account_slot(),) + .is_none(), + "without a receive policy, the manager must leave the on_before_asset_added_to_account \ + slot to a separate component", + ); + assert!( + find_slot(&manager_component, AssetCallbacks::on_before_asset_added_to_note_slot()) + .is_none(), + "without a send policy, the manager must leave the on_before_asset_added_to_note slot \ + to a separate component", + ); + } +} diff --git a/crates/miden-standards/src/account/policies/mint/allow_all.rs b/crates/miden-standards/src/account/policies/mint/allow_all.rs new file mode 100644 index 0000000000..69add8cfa1 --- /dev/null +++ b/crates/miden-standards/src/account/policies/mint/allow_all.rs @@ -0,0 +1,59 @@ +use miden_protocol::account::component::{AccountComponentCode, AccountComponentMetadata}; +use miden_protocol::account::{AccountComponent, AccountComponentName, AccountProcedureRoot}; + +use crate::account::account_component_code; +use crate::procedure_root; + +// ALLOW-ALL MINT POLICY +// ================================================================================================ + +account_component_code!(ALLOW_ALL_MINT_POLICY_CODE, "faucets/policies/mint/allow_all.masl"); + +procedure_root!( + ALLOW_ALL_POLICY_ROOT, + MintAllowAll::NAME, + MintAllowAll::PROC_NAME, + MintAllowAll::code() +); + +/// The storage-free `allow_all` mint policy account component. +/// +/// Pair with a [`crate::account::policies::TokenPolicyManager`] whose allowed mint-policies +/// map includes [`MintAllowAll::root`]. `allow_all` makes minting permissionless (no additional +/// authorization beyond the manager's authority gate). +#[derive(Debug, Clone, Copy, Default)] +pub struct MintAllowAll; + +impl MintAllowAll { + /// The name of the component. + pub const NAME: &'static str = + "miden::standards::components::faucets::policies::mint::allow_all"; + + pub(crate) const PROC_NAME: &str = "check_policy"; + + /// 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 { + &ALLOW_ALL_MINT_POLICY_CODE + } + + /// Returns the procedure root of the `allow_all` mint policy procedure. + pub fn root() -> AccountProcedureRoot { + *ALLOW_ALL_POLICY_ROOT + } +} + +impl From for AccountComponent { + fn from(_: MintAllowAll) -> Self { + let metadata = AccountComponentMetadata::new(MintAllowAll::NAME) + .with_description("`allow_all` mint policy for fungible faucets"); + + AccountComponent::new(MintAllowAll::code().clone(), vec![], metadata).expect( + "`allow_all` mint policy component should satisfy the requirements of a valid account component", + ) + } +} diff --git a/crates/miden-standards/src/account/policies/mint/mod.rs b/crates/miden-standards/src/account/policies/mint/mod.rs new file mode 100644 index 0000000000..56e634b808 --- /dev/null +++ b/crates/miden-standards/src/account/policies/mint/mod.rs @@ -0,0 +1,56 @@ +//! Mint policy components and the mint policy configuration enum used by +//! [`super::TokenPolicyManager`]. + +use alloc::vec::Vec; + +use miden_protocol::Word; +use miden_protocol::account::AccountComponent; + +mod allow_all; +mod owner_only; + +pub use allow_all::MintAllowAll; +pub use owner_only::MintOwnerOnly; + +// CONFIG +// ================================================================================================ + +/// Selects which mint policy is registered with a [`super::TokenPolicyManager`]. +/// +/// 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), +} + +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, + } + } + + /// Returns the [`AccountComponent`]s that must accompany this mint policy variant. + /// + /// 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(), + } + } +} diff --git a/crates/miden-standards/src/account/policies/mint/owner_only.rs b/crates/miden-standards/src/account/policies/mint/owner_only.rs new file mode 100644 index 0000000000..e62adf4f4b --- /dev/null +++ b/crates/miden-standards/src/account/policies/mint/owner_only.rs @@ -0,0 +1,63 @@ +use miden_protocol::account::component::{AccountComponentCode, AccountComponentMetadata}; +use miden_protocol::account::{AccountComponent, AccountComponentName, AccountProcedureRoot}; + +use crate::account::account_component_code; +use crate::procedure_root; + +// OWNER-ONLY MINT POLICY +// ================================================================================================ + +account_component_code!( + OWNER_ONLY_MINT_POLICY_CODE, + "faucets/policies/mint/owner_controlled/owner_only.masl" +); + +procedure_root!( + OWNER_ONLY_POLICY_ROOT, + MintOwnerOnly::NAME, + MintOwnerOnly::PROC_NAME, + MintOwnerOnly::code() +); + +/// The storage-free `owner_only` mint policy account component (owner-controlled family). +/// +/// 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. +#[derive(Debug, Clone, Copy, Default)] +pub struct MintOwnerOnly; + +impl MintOwnerOnly { + /// The name of the component. + pub const NAME: &'static str = + "miden::standards::components::faucets::policies::mint::owner_controlled::owner_only"; + + pub(crate) const PROC_NAME: &str = "check_policy"; + + /// 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 { + &OWNER_ONLY_MINT_POLICY_CODE + } + + /// Returns the procedure root of the `owner_only` mint policy procedure. + pub fn root() -> AccountProcedureRoot { + *OWNER_ONLY_POLICY_ROOT + } +} + +impl From for AccountComponent { + fn from(_: MintOwnerOnly) -> Self { + let metadata = AccountComponentMetadata::new(MintOwnerOnly::NAME).with_description( + "`owner_only` mint policy (owner-controlled family) for fungible faucets", + ); + + AccountComponent::new(MintOwnerOnly::code().clone(), vec![], metadata).expect( + "`owner_only` mint policy component should satisfy the requirements of a valid account component", + ) + } +} diff --git a/crates/miden-standards/src/account/policies/mod.rs b/crates/miden-standards/src/account/policies/mod.rs new file mode 100644 index 0000000000..57c3ae0b4f --- /dev/null +++ b/crates/miden-standards/src/account/policies/mod.rs @@ -0,0 +1,65 @@ +//! Token policy account components. +//! +//! Policies are the procedures that gate minting, burning, and transferring of tokens. The policy +//! state is owned by a single [`TokenPolicyManager`] component, which exposes four kinds of +//! policies: +//! - **mint** — gate mint operations +//! - **burn** — gate burn operations +//! - **send** — fired by the protocol's `on_before_asset_added_to_note` callback when the issuing +//! faucet's asset is added to a note (transfer "from" side) +//! - **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`. +//! +//! Authority for switching policies is provided by the separate +//! [`Authority`][crate::account::access::Authority] component, which must be installed on the +//! account alongside the policy manager. The masm helper `authority::assert_authorized` is +//! `exec`'d from `set_*_policy` to gate runtime policy changes. +//! +//! Storage-free policy components (e.g. [`MintAllowAll`], [`BurnOwnerOnly`], +//! [`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`]. + +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 transfer::{ + AllowlistOwnerControlled, + AllowlistStorage, + BasicAllowlist, + BasicBlocklist, + BlocklistOwnerControlled, + BlocklistStorage, + TransferAllowAll, + TransferPolicy, +}; + +// 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/allow_all.rs b/crates/miden-standards/src/account/policies/transfer/allow_all.rs new file mode 100644 index 0000000000..86239708a7 --- /dev/null +++ b/crates/miden-standards/src/account/policies/transfer/allow_all.rs @@ -0,0 +1,53 @@ +use miden_protocol::account::component::{AccountComponentCode, AccountComponentMetadata}; +use miden_protocol::account::{AccountComponent, AccountProcedureRoot}; + +use crate::account::account_component_code; +use crate::procedure_root; + +// ALLOW-ALL TRANSFER POLICY +// ================================================================================================ + +account_component_code!(ALLOW_ALL_TRANSFER_POLICY_CODE, "faucets/policies/transfer/allow_all.masl"); + +procedure_root!( + ALLOW_ALL_TRANSFER_POLICY_ROOT, + TransferAllowAll::NAME, + TransferAllowAll::PROC_NAME, + TransferAllowAll::code() +); + +/// The storage-free `allow_all` transfer policy account component. +/// +/// Pair with a [`crate::account::policies::TokenPolicyManager`] whose allowed transfer-policies +/// map includes [`TransferAllowAll::root`]. When active, every transfer succeeds. +#[derive(Debug, Clone, Copy, Default)] +pub struct TransferAllowAll; + +impl TransferAllowAll { + /// The name of the component. + pub const NAME: &'static str = + "miden::standards::components::faucets::policies::transfer::allow_all"; + + pub(crate) const PROC_NAME: &str = "check_policy"; + + /// Returns the [`AccountComponentCode`] of this component. + pub fn code() -> &'static AccountComponentCode { + &ALLOW_ALL_TRANSFER_POLICY_CODE + } + + /// Returns the procedure root of the `allow_all` transfer policy procedure. + pub fn root() -> AccountProcedureRoot { + *ALLOW_ALL_TRANSFER_POLICY_ROOT + } +} + +impl From for AccountComponent { + fn from(_: TransferAllowAll) -> Self { + let metadata = AccountComponentMetadata::new(TransferAllowAll::NAME) + .with_description("`allow_all` transfer policy for callback-enabled faucets"); + + AccountComponent::new(TransferAllowAll::code().clone(), vec![], metadata).expect( + "`allow_all` transfer policy component should satisfy the requirements of a valid account component", + ) + } +} diff --git a/crates/miden-standards/src/account/policies/transfer/allowlist/mod.rs b/crates/miden-standards/src/account/policies/transfer/allowlist/mod.rs new file mode 100644 index 0000000000..88cfa5fcb0 --- /dev/null +++ b/crates/miden-standards/src/account/policies/transfer/allowlist/mod.rs @@ -0,0 +1,96 @@ +use alloc::collections::BTreeSet; + +use miden_protocol::Word; +use miden_protocol::account::component::{SchemaType, StorageSlotSchema}; +use miden_protocol::account::{AccountId, StorageMap, StorageMapKey, StorageSlot, StorageSlotName}; +use miden_protocol::block::account_tree::AccountIdKey; +use miden_protocol::utils::sync::LazyLock; + +mod owner_controlled; + +pub use owner_controlled::AllowlistOwnerControlled; + +// ALLOWED ACCOUNTS STORAGE +// ================================================================================================ + +static ALLOWED_ACCOUNTS_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new( + "miden::standards::faucets::policies::transfer::allowlist::allowed_accounts", + ) + .expect("storage slot name should be valid") +}); + +/// Storage backing the per-faucet allowlist. +/// +/// `AllowlistStorage` exposes the slot name and schema for the `allowed_accounts` map plus an +/// optional initial set of allowed accounts. It is **not** an installable account component on +/// its own — the policy component that installs the storage map (typically +/// [`super::BasicAllowlist`]) reads the slot name and the initial entries from here. +/// +/// ## Storage +/// +/// - [`Self::allowed_accounts_slot()`]: storage map keyed by account ID (word layout `[0, 0, +/// account_id_suffix, account_id_prefix]`). An account is considered allowed when its entry is +/// the word `[1, 0, 0, 0]`; the zero word (including the default for unset entries) means not +/// allowed. This is the opposite of the blocklist default — a faucet with an empty allowlist +/// rejects every transfer. +#[derive(Debug, Clone, Default)] +pub struct AllowlistStorage { + allowed_accounts: BTreeSet, +} + +impl AllowlistStorage { + /// Creates an [`AllowlistStorage`] with no initially allowed accounts. + pub fn new() -> Self { + Self::default() + } + + /// Creates an [`AllowlistStorage`] with the given allowed accounts. + /// + /// Duplicate account IDs are deduplicated by the underlying set. + pub fn with_allowed_accounts(allowed_accounts: impl IntoIterator) -> Self { + Self { + allowed_accounts: allowed_accounts.into_iter().collect(), + } + } + + /// Returns the initial allowed accounts captured in this storage. + pub fn allowed_accounts(&self) -> &BTreeSet { + &self.allowed_accounts + } + + /// Storage slot name for the allowed-accounts map. + pub fn allowed_accounts_slot() -> &'static StorageSlotName { + &ALLOWED_ACCOUNTS_SLOT_NAME + } + + /// Schema entry for the allowed-accounts map slot (documentation / tooling). + pub fn allowed_accounts_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::allowed_accounts_slot().clone(), + StorageSlotSchema::map( + "Per-account allowed flag; zero word is not allowed, [1,0,0,0] is allowed", + SchemaType::native_word(), + SchemaType::bool(), + ), + ) + } + + /// Builds the initial `allowed_accounts` [`StorageMap`] from the captured set, marking each + /// account ID's map entry with the `[1, 0, 0, 0]` allowed-flag word. + pub fn build_storage_map(&self) -> StorageMap { + let allowed_word = Word::from([1u32, 0, 0, 0]); + StorageMap::with_entries( + self.allowed_accounts + .iter() + .map(|id| (StorageMapKey::new(AccountIdKey::from(*id).as_word()), allowed_word)), + ) + .expect("initial allowed accounts should have unique IDs") + } + + /// Consumes the storage and returns the [`StorageSlot`] it contributes to an account + /// component. The `allowed_accounts` map populated with the initial entries. + pub fn into_slot(self) -> StorageSlot { + StorageSlot::with_map(ALLOWED_ACCOUNTS_SLOT_NAME.clone(), self.build_storage_map()) + } +} diff --git a/crates/miden-standards/src/account/policies/transfer/allowlist/owner_controlled.rs b/crates/miden-standards/src/account/policies/transfer/allowlist/owner_controlled.rs new file mode 100644 index 0000000000..e62aa2dc13 --- /dev/null +++ b/crates/miden-standards/src/account/policies/transfer/allowlist/owner_controlled.rs @@ -0,0 +1,51 @@ +use miden_protocol::account::AccountComponent; +use miden_protocol::account::component::{AccountComponentCode, AccountComponentMetadata}; + +use crate::account::account_component_code; + +account_component_code!( + ALLOWLIST_OWNER_CONTROLLED_CODE, + "faucets/policies/transfer/allowlist/owner_controlled.masl" +); + +/// Account component that exposes `allow_account` and `disallow_account` admin procedures gated +/// by the [`crate::account::access::Ownable2Step`] owner. +/// +/// The wrapper procedures live in `miden::standards::faucets::policies::transfer::allowlist:: +/// owner_controlled` and call `ownable2step::assert_sender_is_owner` before delegating to the +/// standards-library helpers in `miden::standards::faucets::policies::transfer::allowlist`. +/// +/// Companion components required: +/// - [`crate::account::access::Ownable2Step`] — provides the owner storage slot the auth check +/// reads. +/// - A component that installs the `allowed_accounts` storage slot — typically +/// [`crate::account::policies::BasicAllowlist`]. +#[derive(Debug, Clone, Copy, Default)] +pub struct AllowlistOwnerControlled; + +impl AllowlistOwnerControlled { + /// The name of the component. + pub const NAME: &'static str = + "miden::standards::components::faucets::policies::transfer::allowlist::owner_controlled"; + + /// Returns the [`AccountComponentCode`] of this component. + pub fn code() -> &'static AccountComponentCode { + &ALLOWLIST_OWNER_CONTROLLED_CODE + } + + /// Returns the [`AccountComponentMetadata`] for this component. + pub fn component_metadata() -> AccountComponentMetadata { + AccountComponentMetadata::new(Self::NAME).with_description( + "Owner-controlled allowlist admin: wraps `allowlist::allow_account` / \ + `disallow_account` with Ownable2Step authorization.", + ) + } +} + +impl From for AccountComponent { + fn from(_: AllowlistOwnerControlled) -> Self { + let metadata = AllowlistOwnerControlled::component_metadata(); + AccountComponent::new(AllowlistOwnerControlled::code().clone(), vec![], metadata) + .expect("owner-controlled allowlist admin component should be valid") + } +} diff --git a/crates/miden-standards/src/account/policies/transfer/basic_allowlist.rs b/crates/miden-standards/src/account/policies/transfer/basic_allowlist.rs new file mode 100644 index 0000000000..7243f9c8b5 --- /dev/null +++ b/crates/miden-standards/src/account/policies/transfer/basic_allowlist.rs @@ -0,0 +1,97 @@ +use alloc::collections::BTreeSet; + +use miden_protocol::account::component::{ + AccountComponentCode, + AccountComponentMetadata, + StorageSchema, +}; +use miden_protocol::account::{AccountComponent, AccountId, AccountProcedureRoot}; + +use crate::account::account_component_code; +use crate::account::policies::transfer::allowlist::AllowlistStorage; +use crate::procedure_root; + +// BASIC ALLOWLIST TRANSFER POLICY +// ================================================================================================ + +account_component_code!( + BASIC_ALLOWLIST_TRANSFER_POLICY_CODE, + "faucets/policies/transfer/basic_allowlist.masl" +); + +procedure_root!( + BASIC_ALLOWLIST_TRANSFER_POLICY_ROOT, + BasicAllowlist::NAME, + BasicAllowlist::PROC_NAME, + BasicAllowlist::code() +); + +/// The basic allowlist transfer policy account component. +/// +/// Pair with a [`crate::account::policies::TokenPolicyManager`] whose send and receive +/// policy maps include [`BasicAllowlist::root`]. When active, transfers fail if the +/// native account (asset recipient or note creator) is not currently allowed on the +/// issuing faucet. +/// +/// Allow / disallow administration is intentionally not part of this component. The +/// `allow_account` / `disallow_account` procedures live in the standards library and require +/// an auth-wrapped admin component (see [`super::AllowlistOwnerControlled`]) to be safely +/// exposed on a production faucet. +#[derive(Debug, Clone, Default)] +pub struct BasicAllowlist(AllowlistStorage); + +impl BasicAllowlist { + /// The name of the component. + pub const NAME: &'static str = + "miden::standards::components::faucets::policies::transfer::basic_allowlist"; + + pub(crate) const PROC_NAME: &str = "check_policy"; + + /// Creates a basic allowlist with the given initial allowed accounts. + pub fn with_allowed_accounts(allowed_accounts: I) -> Self + where + I: IntoIterator, + { + Self(AllowlistStorage::with_allowed_accounts(allowed_accounts)) + } + + /// Returns the initial allowed accounts captured in this component. + pub fn allowed_accounts(&self) -> &BTreeSet { + self.0.allowed_accounts() + } + + /// Returns the [`AccountComponentCode`] of this component. + pub fn code() -> &'static AccountComponentCode { + &BASIC_ALLOWLIST_TRANSFER_POLICY_CODE + } + + /// Returns the MAST root of the basic allowlist transfer policy procedure. + pub fn root() -> AccountProcedureRoot { + *BASIC_ALLOWLIST_TRANSFER_POLICY_ROOT + } +} + +impl From for BasicAllowlist { + fn from(storage: AllowlistStorage) -> Self { + Self(storage) + } +} + +impl From for AccountComponent { + fn from(allowlist: BasicAllowlist) -> Self { + let storage_schema = StorageSchema::new([AllowlistStorage::allowed_accounts_slot_schema()]) + .expect("storage schema should be valid"); + + let metadata = AccountComponentMetadata::new(BasicAllowlist::NAME) + .with_description( + "Basic allowlist transfer policy: predicate procedure plus the `allowed_accounts` \ + storage map it reads", + ) + .with_storage_schema(storage_schema); + + AccountComponent::new(BasicAllowlist::code().clone(), vec![allowlist.0.into_slot()], metadata) + .expect( + "basic allowlist transfer policy component should satisfy the requirements of a valid account component", + ) + } +} diff --git a/crates/miden-standards/src/account/policies/transfer/basic_blocklist.rs b/crates/miden-standards/src/account/policies/transfer/basic_blocklist.rs new file mode 100644 index 0000000000..79163c9864 --- /dev/null +++ b/crates/miden-standards/src/account/policies/transfer/basic_blocklist.rs @@ -0,0 +1,97 @@ +use alloc::collections::BTreeSet; + +use miden_protocol::account::component::{ + AccountComponentCode, + AccountComponentMetadata, + StorageSchema, +}; +use miden_protocol::account::{AccountComponent, AccountId, AccountProcedureRoot}; + +use crate::account::account_component_code; +use crate::account::policies::transfer::blocklist::BlocklistStorage; +use crate::procedure_root; + +// BASIC BLOCKLIST TRANSFER POLICY +// ================================================================================================ + +account_component_code!( + BASIC_BLOCKLIST_TRANSFER_POLICY_CODE, + "faucets/policies/transfer/basic_blocklist.masl" +); + +procedure_root!( + BASIC_BLOCKLIST_TRANSFER_POLICY_ROOT, + BasicBlocklist::NAME, + BasicBlocklist::PROC_NAME, + BasicBlocklist::code() +); + +/// The basic blocklist transfer policy account component. +/// +/// Installs the per-faucet `blocked_accounts` storage map (defined by [`BlocklistStorage`]) +/// plus the `check_policy` predicate procedure. Pair with a +/// [`crate::account::policies::TokenPolicyManager`] whose send / receive policy maps include +/// [`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 +/// [`Self::with_blocked_accounts`] to seed the storage map at component construction time. +/// +/// Block / unblock administration is intentionally not part of this component. The +/// `block_account` / `unblock_account` procedures live in the standards library and require an +/// auth-wrapped admin component (see [`super::BlocklistOwnerControlled`]) to be safely exposed +/// on a production faucet. +#[derive(Debug, Clone, Default)] +pub struct BasicBlocklist(BTreeSet); + +impl BasicBlocklist { + /// The name of the component. + pub const NAME: &'static str = + "miden::standards::components::faucets::policies::transfer::basic_blocklist"; + + pub(crate) const PROC_NAME: &str = "check_policy"; + + /// Creates a basic blocklist with the given initial blocked accounts. + pub fn with_blocked_accounts(blocked_accounts: I) -> Self + where + I: IntoIterator, + { + Self(blocked_accounts.into_iter().collect()) + } + + /// Returns the initial blocked accounts captured in this component. + pub fn blocked_accounts(&self) -> &BTreeSet { + &self.0 + } + + /// Returns the [`AccountComponentCode`] of this component. + pub fn code() -> &'static AccountComponentCode { + &BASIC_BLOCKLIST_TRANSFER_POLICY_CODE + } + + /// Returns the MAST root of the basic blocklist transfer policy procedure. + pub fn root() -> AccountProcedureRoot { + *BASIC_BLOCKLIST_TRANSFER_POLICY_ROOT + } +} + +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"); + + let metadata = AccountComponentMetadata::new(BasicBlocklist::NAME) + .with_description( + "Basic blocklist transfer policy: predicate procedure plus the `blocked_accounts` \ + storage map it reads", + ) + .with_storage_schema(storage_schema); + + AccountComponent::new(BasicBlocklist::code().clone(), vec![storage.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/blocklist/mod.rs b/crates/miden-standards/src/account/policies/transfer/blocklist/mod.rs new file mode 100644 index 0000000000..b851e2add7 --- /dev/null +++ b/crates/miden-standards/src/account/policies/transfer/blocklist/mod.rs @@ -0,0 +1,101 @@ +use alloc::collections::BTreeSet; + +use miden_protocol::Word; +use miden_protocol::account::component::{SchemaType, StorageSlotSchema}; +use miden_protocol::account::{AccountId, StorageMap, StorageMapKey, StorageSlot, StorageSlotName}; +use miden_protocol::block::account_tree::AccountIdKey; +use miden_protocol::utils::sync::LazyLock; + +mod owner_controlled; + +pub use owner_controlled::BlocklistOwnerControlled; + +// BLOCKED ACCOUNTS STORAGE +// ================================================================================================ + +static BLOCKED_ACCOUNTS_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new( + "miden::standards::faucets::policies::transfer::blocklist::blocked_accounts", + ) + .expect("storage slot name should be valid") +}); + +/// Storage backing the per-faucet blocklist. +/// +/// `BlocklistStorage` exposes the slot name and schema for the `blocked_accounts` map plus an +/// optional initial set of blocked accounts. It is **not** an installable account component on +/// its own — the policy component that installs the storage map (typically +/// [`super::BasicBlocklist`]) reads the slot name and the initial entries from here. +/// +/// The low-level `block_account` / `unblock_account` / `is_blocked` / `assert_not_blocked` +/// procedures live in the standards library at +/// `miden::standards::faucets::policies::transfer::blocklist` as `Invocation: exec` helpers — +/// they perform no authorization and must be wrapped by an auth-checking admin component (see +/// [`BlocklistOwnerControlled`]) before being exposed on a faucet. +/// +/// ## Storage +/// +/// - [`Self::blocked_accounts_slot()`]: storage map keyed by account ID (word layout `[0, 0, +/// account_id_suffix, account_id_prefix]`). An account is considered blocked when its entry is +/// the word `[1, 0, 0, 0]`; the zero word (including the default for unset entries) means not +/// blocked. +#[derive(Debug, Clone, Default)] +pub struct BlocklistStorage { + blocked_accounts: BTreeSet, +} + +impl BlocklistStorage { + /// Creates a [`BlocklistStorage`] with no initially blocked accounts. + pub fn new() -> Self { + Self::default() + } + + /// Creates a [`BlocklistStorage`] with the given blocked accounts. + /// + /// Duplicate account IDs are deduplicated by the underlying set. + pub fn with_blocked_accounts(blocked_accounts: impl IntoIterator) -> Self { + Self { + blocked_accounts: blocked_accounts.into_iter().collect(), + } + } + + /// Returns the initial blocked accounts captured in this storage. + pub fn blocked_accounts(&self) -> &BTreeSet { + &self.blocked_accounts + } + + /// Storage slot name for the blocked-accounts map. + pub fn blocked_accounts_slot() -> &'static StorageSlotName { + &BLOCKED_ACCOUNTS_SLOT_NAME + } + + /// Schema entry for the blocked-accounts map slot (documentation / tooling). + pub fn blocked_accounts_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::blocked_accounts_slot().clone(), + StorageSlotSchema::map( + "Per-account blocked flag; zero word is not blocked, [1,0,0,0] is blocked", + SchemaType::native_word(), + SchemaType::bool(), + ), + ) + } + + /// Builds the initial `blocked_accounts` [`StorageMap`] from the captured set, marking each + /// account ID's map entry with the `[1, 0, 0, 0]` blocked-flag word. + pub fn build_storage_map(&self) -> StorageMap { + let blocked_word = Word::from([1u32, 0, 0, 0]); + StorageMap::with_entries( + self.blocked_accounts + .iter() + .map(|id| (StorageMapKey::new(AccountIdKey::from(*id).as_word()), blocked_word)), + ) + .expect("initial blocked accounts should have unique IDs") + } + + /// Consumes the storage and returns the [`StorageSlot`] it contributes to an account + /// component. The `blocked_accounts` map populated with the initial entries. + pub fn into_slot(self) -> StorageSlot { + StorageSlot::with_map(BLOCKED_ACCOUNTS_SLOT_NAME.clone(), self.build_storage_map()) + } +} diff --git a/crates/miden-standards/src/account/policies/transfer/blocklist/owner_controlled.rs b/crates/miden-standards/src/account/policies/transfer/blocklist/owner_controlled.rs new file mode 100644 index 0000000000..37ad7c1d4c --- /dev/null +++ b/crates/miden-standards/src/account/policies/transfer/blocklist/owner_controlled.rs @@ -0,0 +1,51 @@ +use miden_protocol::account::AccountComponent; +use miden_protocol::account::component::{AccountComponentCode, AccountComponentMetadata}; + +use crate::account::account_component_code; + +account_component_code!( + BLOCKLIST_OWNER_CONTROLLED_CODE, + "faucets/policies/transfer/blocklist/owner_controlled.masl" +); + +/// Account component that exposes `block_account` and `unblock_account` admin procedures gated +/// by the [`crate::account::access::Ownable2Step`] owner. +/// +/// The wrapper procedures live in `miden::standards::faucets::policies::transfer::blocklist:: +/// owner_controlled` and call `ownable2step::assert_sender_is_owner` before delegating to the +/// standards-library helpers in `miden::standards::faucets::policies::transfer::blocklist`. +/// +/// Companion components required: +/// - [`crate::account::access::Ownable2Step`] — provides the owner storage slot the auth check +/// reads. +/// - A component that installs the `blocked_accounts` storage slot — typically +/// [`crate::account::policies::BasicBlocklist`]. +#[derive(Debug, Clone, Copy, Default)] +pub struct BlocklistOwnerControlled; + +impl BlocklistOwnerControlled { + /// The name of the component. + pub const NAME: &'static str = + "miden::standards::components::faucets::policies::transfer::blocklist::owner_controlled"; + + /// Returns the [`AccountComponentCode`] of this component. + pub fn code() -> &'static AccountComponentCode { + &BLOCKLIST_OWNER_CONTROLLED_CODE + } + + /// Returns the [`AccountComponentMetadata`] for this component. + pub fn component_metadata() -> AccountComponentMetadata { + AccountComponentMetadata::new(Self::NAME).with_description( + "Owner-controlled blocklist admin: wraps `blocklist::block_account` / \ + `unblock_account` with Ownable2Step authorization.", + ) + } +} + +impl From for AccountComponent { + fn from(_: BlocklistOwnerControlled) -> Self { + let metadata = BlocklistOwnerControlled::component_metadata(); + AccountComponent::new(BlocklistOwnerControlled::code().clone(), vec![], metadata) + .expect("owner-controlled blocklist admin component should be valid") + } +} diff --git a/crates/miden-standards/src/account/policies/transfer/mod.rs b/crates/miden-standards/src/account/policies/transfer/mod.rs new file mode 100644 index 0000000000..e3cc573388 --- /dev/null +++ b/crates/miden-standards/src/account/policies/transfer/mod.rs @@ -0,0 +1,82 @@ +//! Transfer policy components and the transfer policy enum 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. +//! - 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}; + +mod allow_all; +mod allowlist; +mod basic_allowlist; +mod basic_blocklist; +mod blocklist; + +pub use allow_all::TransferAllowAll; +pub use allowlist::{AllowlistOwnerControlled, AllowlistStorage}; +pub use basic_allowlist::BasicAllowlist; +pub use basic_blocklist::BasicBlocklist; +pub use blocklist::{BlocklistOwnerControlled, BlocklistStorage}; + +// TRANSFER POLICY +// ================================================================================================ + +/// Selects a transfer policy variant for the send or receive kind on a +/// [`super::TokenPolicyManager`]. +/// +/// The same variants apply 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), +} + +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 the [`AccountComponent`]s that must accompany this transfer policy variant. + /// + /// 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(), + } + } +} diff --git a/crates/miden-standards/src/account/wallets/mod.rs b/crates/miden-standards/src/account/wallets/mod.rs index c220a0b3e6..9b9c7a14b7 100644 --- a/crates/miden-standards/src/account/wallets/mod.rs +++ b/crates/miden-standards/src/account/wallets/mod.rs @@ -1,39 +1,42 @@ use alloc::string::String; -use miden_protocol::Word; -use miden_protocol::account::component::AccountComponentMetadata; +use miden_protocol::account::component::{AccountComponentCode, AccountComponentMetadata}; use miden_protocol::account::{ Account, AccountBuilder, AccountComponent, - AccountStorageMode, + AccountComponentName, + AccountProcedureRoot, 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::components::basic_wallet_library; -use crate::procedure_digest; +use crate::procedure_root; // BASIC WALLET // ================================================================================================ -// Initialize the digest of the `receive_asset` procedure of the Basic Wallet only once. -procedure_digest!( +account_component_code!(BASIC_WALLET_CODE, "wallets/basic_wallet.masl"); + +// Initialize the procedure root of the `receive_asset` procedure of the Basic Wallet only once. +procedure_root!( BASIC_WALLET_RECEIVE_ASSET, BasicWallet::NAME, BasicWallet::RECEIVE_ASSET_PROC_NAME, - basic_wallet_library + BasicWallet::code() ); -// Initialize the digest of the `move_asset_to_note` procedure of the Basic Wallet only once. -procedure_digest!( +// Initialize the procedure root of the `move_asset_to_note` procedure of the Basic Wallet only +// once. +procedure_root!( BASIC_WALLET_MOVE_ASSET_TO_NOTE, BasicWallet::NAME, BasicWallet::MOVE_ASSET_TO_NOTE_PROC_NAME, - basic_wallet_library + BasicWallet::code() ); /// An [`AccountComponent`] implementing a basic wallet. @@ -49,8 +52,6 @@ procedure_digest!( /// All methods require authentication. Thus, this component must be combined with a component /// providing authentication. /// -/// This component supports all account types. -/// /// [builder]: crate::code_builder::CodeBuilder pub struct BasicWallet; @@ -64,22 +65,32 @@ impl BasicWallet { const RECEIVE_ASSET_PROC_NAME: &str = "receive_asset"; const MOVE_ASSET_TO_NOTE_PROC_NAME: &str = "move_asset_to_note"; + /// Returns the canonical [`AccountComponentName`] of this component. + pub const fn name() -> AccountComponentName { + AccountComponentName::from_static_str(Self::NAME) + } + // PUBLIC ACCESSORS // -------------------------------------------------------------------------------------------- - /// Returns the digest of the `receive_asset` wallet procedure. - pub fn receive_asset_digest() -> Word { + /// Returns the [`AccountComponentCode`] of this component. + pub fn code() -> &'static AccountComponentCode { + &BASIC_WALLET_CODE + } + + /// Returns the procedure root of the `receive_asset` wallet procedure. + pub fn receive_asset_root() -> AccountProcedureRoot { *BASIC_WALLET_RECEIVE_ASSET } - /// Returns the digest of the `move_asset_to_note` wallet procedure. - pub fn move_asset_to_note_digest() -> Word { + /// Returns the procedure root of the `move_asset_to_note` wallet procedure. + pub fn move_asset_to_note_root() -> AccountProcedureRoot { *BASIC_WALLET_MOVE_ASSET_TO_NOTE } /// Returns the [`AccountComponentMetadata`] for this component. pub fn component_metadata() -> AccountComponentMetadata { - AccountComponentMetadata::new(Self::NAME, AccountType::all()) + AccountComponentMetadata::new(Self::NAME) .with_description("Basic wallet component for receiving and sending assets") } } @@ -88,7 +99,7 @@ impl From for AccountComponent { fn from(_: BasicWallet) -> Self { let metadata = BasicWallet::component_metadata(); - AccountComponent::new(basic_wallet_library(), vec![], metadata).expect( + AccountComponent::new(BasicWallet::code().clone(), vec![], metadata).expect( "basic wallet component should satisfy the requirements of a valid account component", ) } @@ -119,15 +130,8 @@ pub enum BasicWalletError { pub fn create_basic_wallet( init_seed: [u8; 32], auth_method: AuthMethod, - account_type: AccountType, - account_storage_mode: AccountStorageMode, + account_storage_mode: AccountType, ) -> Result { - if matches!(account_type, AccountType::FungibleFaucet | AccountType::NonFungibleFaucet) { - return Err(BasicWalletError::AccountError(AccountError::other( - "basic wallet accounts cannot have a faucet account type", - ))); - } - let auth_component: AccountComponent = match auth_method { AuthMethod::SingleSig { approver: (pub_key, auth_scheme) } => { AuthSingleSig::new(pub_key, auth_scheme).into() @@ -135,7 +139,7 @@ pub fn create_basic_wallet( AuthMethod::Multisig { threshold, approvers } => { let config = AuthMultisigConfig::new(approvers, threshold) .and_then(|cfg| { - cfg.with_proc_thresholds(vec![(BasicWallet::receive_asset_digest(), 1)]) + cfg.with_proc_thresholds(vec![(BasicWallet::receive_asset_root(), 1)]) }) .map_err(BasicWalletError::AccountError)?; AuthMultisig::new(config).map_err(BasicWalletError::AccountError)?.into() @@ -145,6 +149,11 @@ pub fn create_basic_wallet( "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(), @@ -153,8 +162,7 @@ pub fn create_basic_wallet( }; let account = AccountBuilder::new(init_seed) - .account_type(account_type) - .storage_mode(account_storage_mode) + .account_type(account_storage_mode) .with_auth_component(auth_component) .with_component(BasicWallet) .build() @@ -172,7 +180,7 @@ mod tests { use miden_protocol::utils::serde::{Deserializable, Serializable}; use miden_protocol::{ONE, Word}; - use super::{Account, AccountStorageMode, AccountType, AuthMethod, create_basic_wallet}; + use super::{Account, AccountType, AuthMethod, create_basic_wallet}; use crate::account::wallets::BasicWallet; #[test] @@ -182,8 +190,7 @@ mod tests { let wallet = create_basic_wallet( [1; 32], AuthMethod::SingleSig { approver: (pub_key, auth_scheme) }, - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Public, + AccountType::Public, ); wallet.unwrap_or_else(|err| { @@ -198,8 +205,7 @@ mod tests { let wallet = create_basic_wallet( [1; 32], AuthMethod::SingleSig { approver: (pub_key, auth_scheme) }, - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Public, + AccountType::Public, ) .unwrap(); @@ -208,10 +214,10 @@ mod tests { assert_eq!(wallet, deserialized_wallet); } - /// Check that the obtaining of the basic wallet procedure digests does not panic. + /// Check that the obtaining of the basic wallet procedure roots does not panic. #[test] fn get_faucet_procedures() { - let _receive_asset_digest = BasicWallet::receive_asset_digest(); - let _move_asset_to_note_digest = BasicWallet::move_asset_to_note_digest(); + let _receive_asset_root = BasicWallet::receive_asset_root(); + let _move_asset_to_note_root = BasicWallet::move_asset_to_note_root(); } } diff --git a/crates/miden-standards/src/auth_method.rs b/crates/miden-standards/src/auth_method.rs index fc2d1a02de..1cb97350e7 100644 --- a/crates/miden-standards/src/auth_method.rs +++ b/crates/miden-standards/src/auth_method.rs @@ -1,8 +1,11 @@ +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. /// @@ -22,6 +25,14 @@ pub enum AuthMethod { 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, } @@ -37,6 +48,7 @@ impl AuthMethod { 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/code_builder/mod.rs b/crates/miden-standards/src/code_builder/mod.rs index 8d43dfd0e9..9bb240d79f 100644 --- a/crates/miden-standards/src/code_builder/mod.rs +++ b/crates/miden-standards/src/code_builder/mod.rs @@ -728,7 +728,7 @@ mod tests { #[test] fn test_code_builder_with_advice_map_entry() -> anyhow::Result<()> { let key = Word::from([1u32, 2, 3, 4]); - let value = vec![Felt::new(42), Felt::new(43)]; + let value = vec![Felt::new_unchecked(42), Felt::new_unchecked(43)]; let script = CodeBuilder::default() .with_advice_map_entry(key, value.clone()) @@ -748,8 +748,8 @@ mod tests { let key2 = Word::from([2u32, 0, 0, 0]); let mut advice_map = AdviceMap::default(); - advice_map.insert(key1, vec![Felt::new(1)]); - advice_map.insert(key2, vec![Felt::new(2)]); + advice_map.insert(key1, vec![Felt::ONE]); + advice_map.insert(key2, vec![Felt::new_unchecked(2)]); let script = CodeBuilder::default() .with_extended_advice_map(advice_map) @@ -766,7 +766,7 @@ mod tests { #[test] fn test_code_builder_advice_map_in_note_script() -> anyhow::Result<()> { let key = Word::from([5u32, 6, 7, 8]); - let value = vec![Felt::new(100)]; + let value = vec![Felt::new_unchecked(100)]; let script = CodeBuilder::default() .with_advice_map_entry(key, value.clone()) @@ -786,7 +786,7 @@ mod tests { #[test] fn test_code_builder_advice_map_in_component_code() -> anyhow::Result<()> { let key = Word::from([11u32, 22, 33, 44]); - let value = vec![Felt::new(500)]; + let value = vec![Felt::new_unchecked(500)]; let component_code = CodeBuilder::default() .with_advice_map_entry(key, value.clone()) diff --git a/crates/miden-standards/src/errors/mod.rs b/crates/miden-standards/src/errors/mod.rs index f1c21dd45b..8563932cd8 100644 --- a/crates/miden-standards/src/errors/mod.rs +++ b/crates/miden-standards/src/errors/mod.rs @@ -5,4 +5,5 @@ pub mod standards { } mod code_builder_errors; + pub use code_builder_errors::CodeBuilderError; diff --git a/crates/miden-standards/src/note/burn.rs b/crates/miden-standards/src/note/burn.rs index d9b22572a1..f693efdbbe 100644 --- a/crates/miden-standards/src/note/burn.rs +++ b/crates/miden-standards/src/note/burn.rs @@ -1,4 +1,3 @@ -use miden_protocol::Word; use miden_protocol::account::AccountId; use miden_protocol::assembly::Path; use miden_protocol::asset::Asset; @@ -7,13 +6,14 @@ use miden_protocol::errors::NoteError; use miden_protocol::note::{ Note, NoteAssets, - NoteAttachment, - NoteMetadata, + NoteAttachments, NoteRecipient, NoteScript, + NoteScriptRoot, NoteStorage, NoteTag, NoteType, + PartialNoteMetadata, }; use miden_protocol::utils::sync::LazyLock; @@ -55,7 +55,7 @@ impl BurnNote { } /// Returns the BURN note script root. - pub fn script_root() -> Word { + pub fn script_root() -> NoteScriptRoot { BURN_SCRIPT.root() } @@ -64,10 +64,9 @@ impl BurnNote { /// Generates a BURN note - a note that instructs a faucet to burn a fungible asset. /// - /// This script enables the creation of a PUBLIC note that, when consumed by a faucet (either - /// basic or network), will burn the fungible assets contained in the note. Both basic and - /// network fungible faucets export the same `burn` procedure with identical MAST roots, - /// allowing a single BURN note script to work with either faucet type. + /// This script enables the creation of a PUBLIC note that, when consumed by a fungible + /// faucet, will burn the fungible assets contained in the note. The compiled call targets + /// `fungible::receive_and_burn`. /// /// BURN notes are always PUBLIC for network execution. /// @@ -78,7 +77,7 @@ impl BurnNote { /// - `sender`: The account ID of the note creator /// - `faucet_id`: The account ID of the faucet that will burn the assets /// - `fungible_asset`: The fungible asset to be burned - /// - `attachment`: The [`NoteAttachment`] of the BURN note + /// - `attachment`: The [`NoteAttachments`] of the BURN note /// - `rng`: Random number generator for creating the serial number /// /// # Errors @@ -87,7 +86,7 @@ impl BurnNote { sender: AccountId, faucet_id: AccountId, fungible_asset: Asset, - attachment: NoteAttachment, + attachments: NoteAttachments, rng: &mut R, ) -> Result { let note_script = Self::script(); @@ -99,11 +98,10 @@ impl BurnNote { let inputs = NoteStorage::new(vec![])?; let tag = NoteTag::with_account_target(faucet_id); - let metadata = - NoteMetadata::new(sender, note_type).with_tag(tag).with_attachment(attachment); + let metadata = PartialNoteMetadata::new(sender, note_type).with_tag(tag); let assets = NoteAssets::new(vec![fungible_asset])?; // BURN notes contain the asset to burn let recipient = NoteRecipient::new(serial_num, note_script, inputs); - Ok(Note::new(assets, metadata, recipient)) + Ok(Note::with_attachments(assets, metadata, recipient, attachments)) } } diff --git a/crates/miden-standards/src/note/execution_hint.rs b/crates/miden-standards/src/note/execution_hint.rs index 1b0f35d068..0714542738 100644 --- a/crates/miden-standards/src/note/execution_hint.rs +++ b/crates/miden-standards/src/note/execution_hint.rs @@ -19,7 +19,7 @@ use miden_protocol::errors::NoteError; /// [24 zero bits | payload (32 bits) | tag (8 bits)] /// ``` /// -/// This way, hints such as [NoteExecutionHint::Always], are represented by `Felt::new(1)`. +/// This way, hints such as [NoteExecutionHint::Always], are represented by `Felt::ONE`. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum NoteExecutionHint { /// Unspecified note execution hint. Implies it is not known under which conditions the note @@ -168,7 +168,7 @@ impl NoteExecutionHint { impl From for Felt { fn from(value: NoteExecutionHint) -> Self { let int_representation: u64 = value.into(); - Felt::new(int_representation) + Felt::new_unchecked(int_representation) } } diff --git a/crates/miden-standards/src/note/mint.rs b/crates/miden-standards/src/note/mint.rs index f3363eaa76..4dea08eeb5 100644 --- a/crates/miden-standards/src/note/mint.rs +++ b/crates/miden-standards/src/note/mint.rs @@ -2,18 +2,20 @@ use alloc::vec::Vec; use miden_protocol::account::AccountId; use miden_protocol::assembly::Path; +use miden_protocol::asset::{Asset, FungibleAsset}; use miden_protocol::crypto::rand::FeltRng; use miden_protocol::errors::NoteError; use miden_protocol::note::{ Note, NoteAssets, - NoteAttachment, - NoteMetadata, + NoteAttachments, NoteRecipient, NoteScript, + NoteScriptRoot, NoteStorage, NoteTag, NoteType, + PartialNoteMetadata, }; use miden_protocol::utils::sync::LazyLock; use miden_protocol::{Felt, MAX_NOTE_STORAGE_ITEMS, Word}; @@ -45,7 +47,16 @@ impl MintNote { // -------------------------------------------------------------------------------------------- /// Expected number of storage items of the MINT note (private mode). - pub const NUM_STORAGE_ITEMS_PRIVATE: usize = 8; + /// + /// Layout: RECIPIENT(4) + ASSET_KEY(4) + ASSET_VALUE(4) + tag(1). + pub const NUM_STORAGE_ITEMS_PRIVATE: usize = 13; + + /// Minimum number of storage items of the MINT note (public mode). + /// + /// Layout: SCRIPT_ROOT(4) + SERIAL_NUM(4) + ASSET_KEY(4) + ASSET_VALUE(4) + tag(1) + + /// padding(3) + variable output-note storage. The variable portion starts at offset 20 + /// (word-aligned) and may contain zero or more items. + pub const MIN_NUM_STORAGE_ITEMS_PUBLIC: usize = 20; // PUBLIC ACCESSORS // -------------------------------------------------------------------------------------------- @@ -56,43 +67,51 @@ impl MintNote { } /// Returns the MINT note script root. - pub fn script_root() -> Word { + pub fn script_root() -> NoteScriptRoot { MINT_SCRIPT.root() } // BUILDERS // -------------------------------------------------------------------------------------------- - /// Generates a MINT note - a note that instructs a network faucet to mint fungible assets. + /// Generates a MINT note: a note that instructs a network faucet to mint the asset + /// embedded in its storage. /// - /// This script enables the creation of a PUBLIC note that, when consumed by a network faucet, - /// will mint the specified amount of fungible assets and create either a PRIVATE or PUBLIC - /// output note depending on the input configuration. The MINT note uses note-based - /// authentication, checking if the note sender equals the faucet owner to authorize - /// minting. + /// The MINT script reads the asset (`ASSET_KEY` + `ASSET_VALUE`) directly from the note's + /// storage and passes it to the faucet's `mint_and_send` procedure, which rejects an asset + /// that does not belong to the consuming faucet. A MINT note bound to faucet A therefore + /// cannot be redirected to faucet B even when both share an owner. /// /// MINT notes are always PUBLIC (for network execution). Output notes can be either PRIVATE - /// or PUBLIC depending on the MintNoteStorage variant used. + /// or PUBLIC depending on the [`MintNoteStorage`] variant used. /// /// The passed-in `rng` is used to generate a serial number for the note. The note's tag /// is automatically set to the faucet's account ID for proper routing. /// /// # Parameters - /// - `faucet_id`: The account ID of the network faucet that will mint the assets + /// - `faucet_id`: The account ID of the network faucet that will mint the asset. Must equal the + /// faucet ID of the asset embedded in `mint_storage`. /// - `sender`: The account ID of the note creator (must be the faucet owner) /// - `mint_storage`: The storage configuration specifying private or public output mode - /// - `attachment`: The [`NoteAttachment`] of the MINT note + /// - `attachments`: The [`NoteAttachments`] of the MINT note /// - `rng`: Random number generator for creating the serial number /// /// # Errors - /// Returns an error if note creation fails. + /// Returns an error if `faucet_id` does not match the faucet of the embedded asset, or if + /// note creation fails. pub fn create( faucet_id: AccountId, sender: AccountId, mint_storage: MintNoteStorage, - attachment: NoteAttachment, + attachments: NoteAttachments, rng: &mut R, ) -> Result { + if faucet_id != mint_storage.asset().faucet_id() { + return Err(NoteError::other( + "faucet_id must equal the faucet ID of the asset embedded in mint_storage", + )); + } + let note_script = Self::script(); let serial_num = rng.draw_word(); @@ -104,12 +123,11 @@ impl MintNote { let tag = NoteTag::with_account_target(faucet_id); - let metadata = - NoteMetadata::new(sender, note_type).with_tag(tag).with_attachment(attachment); + let metadata = PartialNoteMetadata::new(sender, note_type).with_tag(tag); let assets = NoteAssets::new(vec![])?; // MINT notes have no assets let recipient = NoteRecipient::new(serial_num, note_script, storage); - Ok(Note::new(assets, metadata, recipient)) + Ok(Note::with_attachments(assets, metadata, recipient, attachments)) } } @@ -117,78 +135,53 @@ impl MintNote { // ================================================================================================ /// Represents the different storage formats for MINT notes. -/// - Private: Creates a private output note using a precomputed recipient digest (12 MINT note -/// storage items) +/// +/// - Private: Creates a private output note using a precomputed recipient digest (13 MINT note +/// storage items: RECIPIENT + ASSET_KEY + ASSET_VALUE + tag). /// - Public: Creates a public output note by providing script root, serial number, and -/// variable-length storage (16+ MINT note storage items: 16 fixed + variable number of output -/// note storage items) +/// variable-length storage (20+ MINT note storage items: 20 fixed + variable output note storage +/// items, with the variable section word-aligned at offset 20). +/// +/// The asset (`ASSET_KEY` + `ASSET_VALUE`, 8 felts) is embedded in storage so that the +/// faucet executing the MINT note can be checked against the asset's faucet ID at mint time. #[derive(Debug, Clone, PartialEq, Eq)] pub enum MintNoteStorage { Private { recipient_digest: Word, - amount: Felt, + asset: FungibleAsset, tag: Felt, - attachment: NoteAttachment, }, Public { recipient: NoteRecipient, - amount: Felt, + asset: FungibleAsset, tag: Felt, - attachment: NoteAttachment, }, } impl MintNoteStorage { - pub fn new_private(recipient_digest: Word, amount: Felt, tag: Felt) -> Self { - Self::Private { - recipient_digest, - amount, - tag, - attachment: NoteAttachment::default(), - } + pub fn new_private(recipient_digest: Word, asset: FungibleAsset, tag: Felt) -> Self { + Self::Private { recipient_digest, asset, tag } } pub fn new_public( recipient: NoteRecipient, - amount: Felt, + asset: FungibleAsset, tag: Felt, ) -> Result { - // Calculate total number of storage items that will be created: - // 16 fixed items (tag, amount, attachment_kind, attachment_scheme, ATTACHMENT, - // SCRIPT_ROOT, SERIAL_NUM) + variable recipient number of storage items - const FIXED_PUBLIC_STORAGE_ITEMS: usize = 16; let total_storage_items = - FIXED_PUBLIC_STORAGE_ITEMS + recipient.storage().num_items() as usize; + MintNote::MIN_NUM_STORAGE_ITEMS_PUBLIC + recipient.storage().num_items() as usize; if total_storage_items > MAX_NOTE_STORAGE_ITEMS { return Err(NoteError::TooManyStorageItems(total_storage_items)); } - Ok(Self::Public { - recipient, - amount, - tag, - attachment: NoteAttachment::default(), - }) + Ok(Self::Public { recipient, asset, tag }) } - /// Overwrites the [`NoteAttachment`] of the note storage. - pub fn with_attachment(self, attachment: NoteAttachment) -> Self { + /// Returns the asset that will be minted on consumption. + pub fn asset(&self) -> FungibleAsset { match self { - MintNoteStorage::Private { - recipient_digest, - amount, - tag, - attachment: _, - } => MintNoteStorage::Private { - recipient_digest, - amount, - tag, - attachment, - }, - MintNoteStorage::Public { recipient, amount, tag, attachment: _ } => { - MintNoteStorage::Public { recipient, amount, tag, attachment } - }, + Self::Private { asset, .. } | Self::Public { asset, .. } => *asset, } } } @@ -196,37 +189,22 @@ impl MintNoteStorage { impl From for NoteStorage { fn from(mint_storage: MintNoteStorage) -> Self { match mint_storage { - MintNoteStorage::Private { - recipient_digest, - amount, - tag, - attachment, - } => { - let attachment_scheme = Felt::from(attachment.attachment_scheme().as_u32()); - let attachment_kind = Felt::from(attachment.attachment_kind().as_u8()); - let attachment = attachment.content().to_word(); - - let mut storage_values = Vec::with_capacity(12); - storage_values.extend_from_slice(&[ - tag, - amount, - attachment_kind, - attachment_scheme, - ]); - storage_values.extend_from_slice(attachment.as_elements()); + MintNoteStorage::Private { recipient_digest, asset, tag } => { + let mut storage_values = Vec::with_capacity(MintNote::NUM_STORAGE_ITEMS_PRIVATE); storage_values.extend_from_slice(recipient_digest.as_elements()); + storage_values.extend_from_slice(&Asset::from(asset).as_elements()); + storage_values.push(tag); NoteStorage::new(storage_values) .expect("number of storage items should not exceed max storage items") }, - MintNoteStorage::Public { recipient, amount, tag, attachment } => { - let attachment_scheme = Felt::from(attachment.attachment_scheme().as_u32()); - let attachment_kind = Felt::from(attachment.attachment_kind().as_u8()); - let attachment = attachment.content().to_word(); - - let mut storage_values = vec![tag, amount, attachment_kind, attachment_scheme]; - storage_values.extend_from_slice(attachment.as_elements()); + MintNoteStorage::Public { recipient, asset, tag } => { + let mut storage_values = Vec::new(); storage_values.extend_from_slice(recipient.script().root().as_elements()); storage_values.extend_from_slice(recipient.serial_num().as_elements()); + storage_values.extend_from_slice(&Asset::from(asset).as_elements()); + // tag followed by 3 padding felts so the variable storage that follows starts at + // a word-aligned offset (20). + storage_values.extend_from_slice(&[tag, Felt::ZERO, Felt::ZERO, Felt::ZERO]); storage_values.extend_from_slice(recipient.storage().items()); NoteStorage::new(storage_values) .expect("number of storage items should not exceed max storage items") diff --git a/crates/miden-standards/src/note/mod.rs b/crates/miden-standards/src/note/mod.rs index 7da32ea234..6335fcb407 100644 --- a/crates/miden-standards/src/note/mod.rs +++ b/crates/miden-standards/src/note/mod.rs @@ -2,14 +2,9 @@ use alloc::boxed::Box; use alloc::string::ToString; use core::error::Error; -use miden_protocol::Word; use miden_protocol::account::AccountId; use miden_protocol::block::BlockNumber; -use miden_protocol::note::{Note, NoteScript}; - -use crate::account::faucets::{BasicFungibleFaucet, NetworkFungibleFaucet}; -use crate::account::interface::{AccountComponentInterface, AccountInterface, AccountInterfaceExt}; -use crate::account::wallets::BasicWallet; +use miden_protocol::note::{Note, NoteScript, NoteScriptRoot}; mod burn; pub use burn::BurnNote; @@ -26,6 +21,9 @@ pub use p2id::{P2idNote, P2idNoteStorage}; mod p2ide; pub use p2ide::{P2ideNote, P2ideNoteStorage}; +mod pswap; +pub use pswap::{PswapNote, PswapNoteAttachment, PswapNoteStorage}; + mod swap; pub use swap::{SwapNote, SwapNoteStorage}; @@ -46,6 +44,7 @@ pub enum StandardNote { P2ID, P2IDE, SWAP, + PSWAP, MINT, BURN, } @@ -62,7 +61,7 @@ impl StandardNote { /// Returns a [`StandardNote`] instance based on the provided script root. Returns `None` if /// the provided root does not match any standard note script. - pub fn from_script_root(root: Word) -> Option { + pub fn from_script_root(root: NoteScriptRoot) -> Option { if root == P2idNote::script_root() { return Some(Self::P2ID); } @@ -72,6 +71,9 @@ impl StandardNote { if root == SwapNote::script_root() { return Some(Self::SWAP); } + if root == PswapNote::script_root() { + return Some(Self::PSWAP); + } if root == MintNote::script_root() { return Some(Self::MINT); } @@ -91,6 +93,7 @@ impl StandardNote { Self::P2ID => "P2ID", Self::P2IDE => "P2IDE", Self::SWAP => "SWAP", + Self::PSWAP => "PSWAP", Self::MINT => "MINT", Self::BURN => "BURN", } @@ -102,6 +105,7 @@ impl StandardNote { Self::P2ID => P2idNote::NUM_STORAGE_ITEMS, Self::P2IDE => P2ideNote::NUM_STORAGE_ITEMS, Self::SWAP => SwapNote::NUM_STORAGE_ITEMS, + Self::PSWAP => PswapNote::NUM_STORAGE_ITEMS, Self::MINT => MintNote::NUM_STORAGE_ITEMS_PRIVATE, Self::BURN => BurnNote::NUM_STORAGE_ITEMS, } @@ -113,59 +117,24 @@ impl StandardNote { Self::P2ID => P2idNote::script(), Self::P2IDE => P2ideNote::script(), Self::SWAP => SwapNote::script(), + Self::PSWAP => PswapNote::script(), Self::MINT => MintNote::script(), Self::BURN => BurnNote::script(), } } /// Returns the script root of the current [StandardNote] instance. - pub fn script_root(&self) -> Word { + pub fn script_root(&self) -> NoteScriptRoot { match self { Self::P2ID => P2idNote::script_root(), Self::P2IDE => P2ideNote::script_root(), Self::SWAP => SwapNote::script_root(), + Self::PSWAP => PswapNote::script_root(), Self::MINT => MintNote::script_root(), Self::BURN => BurnNote::script_root(), } } - /// Returns a boolean value indicating whether this [StandardNote] is compatible with the - /// provided [AccountInterface]. - pub fn is_compatible_with(&self, account_interface: &AccountInterface) -> bool { - if account_interface.components().contains(&AccountComponentInterface::BasicWallet) { - return true; - } - - let interface_proc_digests = account_interface.get_procedure_digests(); - match self { - Self::P2ID | &Self::P2IDE => { - // To consume P2ID and P2IDE notes, the `receive_asset` procedure must be present in - // the provided account interface. - interface_proc_digests.contains(&BasicWallet::receive_asset_digest()) - }, - Self::SWAP => { - // To consume SWAP note, the `receive_asset` and `move_asset_to_note` procedures - // must be present in the provided account interface. - interface_proc_digests.contains(&BasicWallet::receive_asset_digest()) - && interface_proc_digests.contains(&BasicWallet::move_asset_to_note_digest()) - }, - Self::MINT => { - // MINT notes work only with network fungible faucets. The network faucet uses - // note-based authentication (checking if the note sender equals the faucet owner) - // to authorize minting, while basic faucets have different mint procedures that - // are not compatible with MINT notes. - interface_proc_digests.contains(&NetworkFungibleFaucet::mint_and_send_digest()) - }, - Self::BURN => { - // BURN notes work with both basic and network fungible faucets because both - // faucet types export the same `burn` procedure with identical MAST roots. - // This allows a single BURN note script to work with either faucet type. - interface_proc_digests.contains(&BasicFungibleFaucet::burn_digest()) - || interface_proc_digests.contains(&NetworkFungibleFaucet::burn_digest()) - }, - } - } - /// Performs the inputs check of the provided standard note against the target account and the /// block number. /// diff --git a/crates/miden-standards/src/note/network_account_target.rs b/crates/miden-standards/src/note/network_account_target.rs index 4471c145f8..2484c8f2c4 100644 --- a/crates/miden-standards/src/note/network_account_target.rs +++ b/crates/miden-standards/src/note/network_account_target.rs @@ -1,13 +1,7 @@ use miden_protocol::Word; use miden_protocol::account::AccountId; use miden_protocol::errors::{AccountIdError, NoteError}; -use miden_protocol::note::{ - NoteAttachment, - NoteAttachmentContent, - NoteAttachmentKind, - NoteAttachmentScheme, - NoteType, -}; +use miden_protocol::note::{NoteAttachment, NoteAttachmentScheme, NoteAttachments, NoteType}; use crate::note::{NoteExecutionHint, StandardNoteAttachment}; @@ -16,7 +10,7 @@ use crate::note::{NoteExecutionHint, StandardNoteAttachment}; /// A [`NoteAttachment`] for notes targeted at network accounts. /// -/// It can be encoded to and from a [`NoteAttachmentContent::Word`] with the following layout: +/// It can be encoded to and from a single-word attachment content with the following layout: /// /// ```text /// - 0th felt: [target_id_suffix (56 bits) | 8 zero bits] @@ -47,14 +41,13 @@ impl NetworkAccountTarget { /// /// Returns an error if: /// - the provided `target_id` does not have - /// [`AccountStorageMode::Network`](miden_protocol::account::AccountStorageMode::Network). + /// [`AccountType::Public`](miden_protocol::account::AccountType::Public). pub fn new( target_id: AccountId, exec_hint: NoteExecutionHint, ) -> Result { - // TODO: Once AccountStorageMode::Network is removed, this should check is_public. - if !target_id.is_network() { - return Err(NetworkAccountTargetError::TargetNotNetwork(target_id)); + if !target_id.is_public() { + return Err(NetworkAccountTargetError::TargetNotPublic(target_id)); } Ok(Self { target_id, exec_hint }) @@ -81,10 +74,23 @@ impl From for NoteAttachment { word[1] = network_attachment.target_id.prefix().as_felt(); word[2] = network_attachment.exec_hint.into(); - NoteAttachment::new_word(NetworkAccountTarget::ATTACHMENT_SCHEME, word) + NoteAttachment::with_word(NetworkAccountTarget::ATTACHMENT_SCHEME, word) } } +impl TryFrom<&NoteAttachments> for NetworkAccountTarget { + type Error = NetworkAccountTargetError; + + fn try_from(attachments: &NoteAttachments) -> Result { + // Find the first matching attachment. In case of multiple network account target + // attachments, we pick the first one as the canonical one. + let attachment = attachments + .find(NetworkAccountTarget::ATTACHMENT_SCHEME) + .ok_or_else(|| NetworkAccountTargetError::MissingAttachmentScheme)?; + + Self::try_from(attachment) + } +} impl TryFrom<&NoteAttachment> for NetworkAccountTarget { type Error = NetworkAccountTargetError; @@ -95,24 +101,25 @@ impl TryFrom<&NoteAttachment> for NetworkAccountTarget { )); } - match attachment.content() { - NoteAttachmentContent::Word(word) => { - let id_suffix = word[0]; - let id_prefix = word[1]; - let exec_hint = word[2]; + let words = attachment.content().as_words(); + if words.len() != 1 { + return Err(NetworkAccountTargetError::AttachmentContentNumWordsMismatch( + attachment.content().num_words(), + )); + } + let word = words[0]; - let target_id = AccountId::try_from_elements(id_suffix, id_prefix) - .map_err(NetworkAccountTargetError::DecodeTargetId)?; + let id_suffix = word[0]; + let id_prefix = word[1]; + let exec_hint = word[2]; - let exec_hint = NoteExecutionHint::try_from(exec_hint.as_canonical_u64()) - .map_err(NetworkAccountTargetError::DecodeExecutionHint)?; + let target_id = AccountId::try_from_elements(id_suffix, id_prefix) + .map_err(NetworkAccountTargetError::DecodeTargetId)?; - NetworkAccountTarget::new(target_id, exec_hint) - }, - _ => Err(NetworkAccountTargetError::AttachmentKindMismatch( - attachment.content().attachment_kind(), - )), - } + let exec_hint = NoteExecutionHint::try_from(exec_hint.as_canonical_u64()) + .map_err(NetworkAccountTargetError::DecodeExecutionHint)?; + + NetworkAccountTarget::new(target_id, exec_hint) } } @@ -121,18 +128,17 @@ impl TryFrom<&NoteAttachment> for NetworkAccountTarget { #[derive(Debug, thiserror::Error)] pub enum NetworkAccountTargetError { - #[error("target account ID must be of type network account")] - TargetNotNetwork(AccountId), + #[error("note attachments do not contain a network account target scheme")] + MissingAttachmentScheme, + #[error("target account ID must have public account type")] + TargetNotPublic(AccountId), #[error( "attachment scheme {0} did not match expected type {expected}", expected = NetworkAccountTarget::ATTACHMENT_SCHEME )] AttachmentSchemeMismatch(NoteAttachmentScheme), - #[error( - "attachment kind {0} did not match expected type {expected}", - expected = NoteAttachmentKind::Word - )] - AttachmentKindMismatch(NoteAttachmentKind), + #[error("network account target expects attachment content with one word, got {0}")] + AttachmentContentNumWordsMismatch(u16), #[error("failed to decode target account ID")] DecodeTargetId(#[source] AccountIdError), #[error("failed to decode execution hint")] @@ -147,7 +153,7 @@ pub enum NetworkAccountTargetError { #[cfg(test)] mod tests { use assert_matches::assert_matches; - use miden_protocol::account::AccountStorageMode; + use miden_protocol::account::AccountType; use miden_protocol::testing::account_id::AccountIdBuilder; use super::*; @@ -155,7 +161,7 @@ mod tests { #[test] fn network_account_target_serde() -> anyhow::Result<()> { let id = AccountIdBuilder::new() - .storage_mode(AccountStorageMode::Network) + .account_type(AccountType::Public) .build_with_rng(&mut rand::rng()); let network_account_target = NetworkAccountTarget::new(id, NoteExecutionHint::Always)?; assert_eq!( @@ -167,15 +173,15 @@ mod tests { } #[test] - fn network_account_target_fails_on_private_network_target_account() -> anyhow::Result<()> { + fn network_account_target_fails_on_private_target_account() -> anyhow::Result<()> { let id = AccountIdBuilder::new() - .storage_mode(AccountStorageMode::Private) + .account_type(AccountType::Private) .build_with_rng(&mut rand::rng()); let err = NetworkAccountTarget::new(id, NoteExecutionHint::Always).unwrap_err(); assert_matches!( err, - NetworkAccountTargetError::TargetNotNetwork(account_id) if account_id == id + NetworkAccountTargetError::TargetNotPublic(account_id) if account_id == id ); Ok(()) diff --git a/crates/miden-standards/src/note/network_note.rs b/crates/miden-standards/src/note/network_note.rs index c0a1c51559..b367b4bca6 100644 --- a/crates/miden-standards/src/note/network_note.rs +++ b/crates/miden-standards/src/note/network_note.rs @@ -1,5 +1,5 @@ use miden_protocol::account::AccountId; -use miden_protocol::note::{Note, NoteAttachment, NoteMetadata, NoteType}; +use miden_protocol::note::{Note, NoteAttachments, NoteMetadata, NoteType}; use crate::note::{NetworkAccountTarget, NetworkAccountTargetError, NoteExecutionHint}; @@ -27,7 +27,8 @@ impl AccountTargetNetworkNote { } // Validate that the attachment is a valid NetworkAccountTarget. - NetworkAccountTarget::try_from(note.metadata().attachment())?; + NetworkAccountTarget::try_from(note.attachments())?; + Ok(Self { note }) } @@ -53,7 +54,7 @@ impl AccountTargetNetworkNote { /// Returns the decoded [`NetworkAccountTarget`] attachment. pub fn target(&self) -> NetworkAccountTarget { - NetworkAccountTarget::try_from(self.note.metadata().attachment()) + NetworkAccountTarget::try_from(self.note.attachments()) .expect("AccountTargetNetworkNote guarantees valid NetworkAccountTarget attachment") } @@ -62,9 +63,9 @@ impl AccountTargetNetworkNote { self.target().execution_hint() } - /// Returns the raw [`NoteAttachment`] from the note metadata. - pub fn attachment(&self) -> &NoteAttachment { - self.metadata().attachment() + /// Returns the raw [`NoteAttachments`] from the note's attachments. + pub fn attachments(&self) -> &NoteAttachments { + self.note.attachments() } /// Returns the [`NoteType`] of the underlying note. @@ -89,7 +90,7 @@ pub trait NetworkNoteExt { impl NetworkNoteExt for Note { fn is_network_note(&self) -> bool { self.metadata().note_type() == NoteType::Public - && NetworkAccountTarget::try_from(self.metadata().attachment()).is_ok() + && NetworkAccountTarget::try_from(self.attachments()).is_ok() } fn into_account_target_network_note( diff --git a/crates/miden-standards/src/note/p2id.rs b/crates/miden-standards/src/note/p2id.rs index 82ea64f41a..41f06c3e33 100644 --- a/crates/miden-standards/src/note/p2id.rs +++ b/crates/miden-standards/src/note/p2id.rs @@ -8,13 +8,14 @@ use miden_protocol::errors::NoteError; use miden_protocol::note::{ Note, NoteAssets, - NoteAttachment, - NoteMetadata, + NoteAttachments, NoteRecipient, NoteScript, + NoteScriptRoot, NoteStorage, NoteTag, NoteType, + PartialNoteMetadata, }; use miden_protocol::utils::sync::LazyLock; use miden_protocol::{Felt, Word}; @@ -56,7 +57,7 @@ impl P2idNote { } /// Returns the P2ID (Pay-to-ID) note script root. - pub fn script_root() -> Word { + pub fn script_root() -> NoteScriptRoot { P2ID_SCRIPT.root() } @@ -78,7 +79,7 @@ impl P2idNote { target: AccountId, assets: Vec, note_type: NoteType, - attachment: NoteAttachment, + attachments: NoteAttachments, rng: &mut R, ) -> Result { let serial_num = rng.draw_word(); @@ -86,11 +87,10 @@ impl P2idNote { let tag = NoteTag::with_account_target(target); - let metadata = - NoteMetadata::new(sender, note_type).with_tag(tag).with_attachment(attachment); + let metadata = PartialNoteMetadata::new(sender, note_type).with_tag(tag); let vault = NoteAssets::new(assets)?; - Ok(Note::new(vault, metadata, recipient)) + Ok(Note::with_attachments(vault, metadata, recipient, attachments)) } } @@ -163,19 +163,14 @@ impl TryFrom<&[Felt]> for P2idNoteStorage { #[cfg(test)] mod tests { use miden_protocol::Felt; - use miden_protocol::account::{AccountId, AccountIdVersion, AccountStorageMode, AccountType}; + use miden_protocol::account::{AccountId, AccountIdVersion, AccountType}; use miden_protocol::errors::NoteError; use super::*; #[test] fn try_from_valid_storage_succeeds() { - let target = AccountId::dummy( - [1u8; 15], - AccountIdVersion::Version0, - AccountType::FungibleFaucet, - AccountStorageMode::Private, - ); + let target = AccountId::dummy([1u8; 15], AccountIdVersion::Version1, AccountType::Private); let storage = vec![target.suffix(), target.prefix().as_felt()]; @@ -203,7 +198,7 @@ mod tests { #[test] fn try_from_invalid_storage_contents_returns_error() { - let storage = vec![Felt::new(999u64), Felt::new(888u64)]; + let storage = vec![Felt::new_unchecked(999_u64), Felt::new_unchecked(888_u64)]; let err = P2idNoteStorage::try_from(storage.as_slice()) .expect_err("should fail due to invalid account id encoding"); diff --git a/crates/miden-standards/src/note/p2ide.rs b/crates/miden-standards/src/note/p2ide.rs index aa1bdafe15..bccfe34ff4 100644 --- a/crates/miden-standards/src/note/p2ide.rs +++ b/crates/miden-standards/src/note/p2ide.rs @@ -9,13 +9,14 @@ use miden_protocol::errors::NoteError; use miden_protocol::note::{ Note, NoteAssets, - NoteAttachment, - NoteMetadata, + NoteAttachments, NoteRecipient, NoteScript, + NoteScriptRoot, NoteStorage, NoteTag, NoteType, + PartialNoteMetadata, }; use miden_protocol::utils::sync::LazyLock; use miden_protocol::{Felt, Word}; @@ -65,7 +66,7 @@ impl P2ideNote { } /// Returns the P2IDE (Pay-to-ID extended) note script root. - pub fn script_root() -> Word { + pub fn script_root() -> NoteScriptRoot { P2IDE_SCRIPT.root() } @@ -85,18 +86,17 @@ impl P2ideNote { storage: P2ideNoteStorage, assets: Vec, note_type: NoteType, - attachment: NoteAttachment, + attachments: NoteAttachments, rng: &mut R, ) -> Result { let serial_num = rng.draw_word(); let recipient = storage.into_recipient(serial_num)?; let tag = NoteTag::with_account_target(storage.target()); - let metadata = - NoteMetadata::new(sender, note_type).with_tag(tag).with_attachment(attachment); + let metadata = PartialNoteMetadata::new(sender, note_type).with_tag(tag); let vault = NoteAssets::new(assets)?; - Ok(Note::new(vault, metadata, recipient)) + Ok(Note::with_attachments(vault, metadata, recipient, attachments)) } } @@ -214,19 +214,14 @@ impl TryFrom<&[Felt]> for P2ideNoteStorage { #[cfg(test)] mod tests { use miden_protocol::Felt; - use miden_protocol::account::{AccountId, AccountIdVersion, AccountStorageMode, AccountType}; + use miden_protocol::account::{AccountId, AccountIdVersion, AccountType}; use miden_protocol::block::BlockNumber; use miden_protocol::errors::NoteError; use super::*; fn dummy_account() -> AccountId { - AccountId::dummy( - [3u8; 15], - AccountIdVersion::Version0, - AccountType::FungibleFaucet, - AccountStorageMode::Private, - ) + AccountId::dummy([3u8; 15], AccountIdVersion::Version1, AccountType::Private) } #[test] @@ -278,7 +273,7 @@ mod tests { #[test] fn try_from_invalid_account_id_fails() { - let storage = vec![Felt::new(999u64), Felt::new(888u64), Felt::ZERO, Felt::ZERO]; + let storage = vec![Felt::from(999_u32), Felt::from(888_u32), Felt::ZERO, Felt::ZERO]; let err = P2ideNoteStorage::try_from(storage.as_slice()) .expect_err("invalid account id encoding must fail"); @@ -291,7 +286,7 @@ mod tests { let target = dummy_account(); // > u32::MAX - let overflow = Felt::new(u64::from(u32::MAX) + 1); + let overflow = Felt::new_unchecked(u64::from(u32::MAX) + 1); let storage = vec![target.suffix(), target.prefix().as_felt(), overflow, Felt::ZERO]; @@ -305,7 +300,7 @@ mod tests { fn try_from_timelock_height_overflow_fails() { let target = dummy_account(); - let overflow = Felt::new(u64::from(u32::MAX) + 10); + let overflow = Felt::new_unchecked(u64::from(u32::MAX) + 10); let storage = vec![target.suffix(), target.prefix().as_felt(), Felt::ZERO, overflow]; diff --git a/crates/miden-standards/src/note/pswap.rs b/crates/miden-standards/src/note/pswap.rs new file mode 100644 index 0000000000..14ea70ea7b --- /dev/null +++ b/crates/miden-standards/src/note/pswap.rs @@ -0,0 +1,1222 @@ +use alloc::vec; + +use miden_protocol::account::AccountId; +use miden_protocol::assembly::Path; +use miden_protocol::asset::{Asset, AssetAmount, AssetCallbackFlag, FungibleAsset}; +use miden_protocol::errors::NoteError; +use miden_protocol::note::{ + Note, + NoteAssets, + NoteAttachment, + NoteAttachmentScheme, + NoteAttachments, + NoteRecipient, + NoteScript, + NoteScriptRoot, + NoteStorage, + NoteTag, + NoteType, + PartialNoteMetadata, +}; +use miden_protocol::utils::sync::LazyLock; +use miden_protocol::{Felt, ONE, Word, ZERO}; + +use crate::StandardsLib; +use crate::note::{P2idNoteStorage, StandardNoteAttachment}; + +// NOTE SCRIPT +// ================================================================================================ + +/// Path to the PSWAP note script procedure in the standards library. +const PSWAP_SCRIPT_PATH: &str = "::miden::standards::notes::pswap::main"; + +// Initialize the PSWAP note script only once +static PSWAP_SCRIPT: LazyLock = LazyLock::new(|| { + let standards_lib = StandardsLib::default(); + let path = Path::new(PSWAP_SCRIPT_PATH); + NoteScript::from_library_reference(standards_lib.as_ref(), path) + .expect("Standards library contains PSWAP note script procedure") +}); + +// PSWAP NOTE STORAGE +// ================================================================================================ + +/// Canonical storage representation for a PSWAP note. +/// +/// Maps to the 7-element [`NoteStorage`] layout consumed by the on-chain MASM script: +/// +/// | Slot | Field | +/// |---------|-------| +/// | `[0]` | Requested asset enable_callbacks flag | +/// | `[1]` | Requested asset faucet ID suffix | +/// | `[2]` | Requested asset faucet ID prefix | +/// | `[3]` | Requested asset amount | +/// | `[4]` | Payback note type (0 = private, 1 = public) | +/// | `[5-6]` | Creator account ID (prefix, suffix) | +/// +/// The payback note tag is derived at runtime from the creator account ID +/// (via `note_tag::create_account_target` in MASM) rather than stored. +/// +/// The PSWAP note's own tag is not stored: it lives in the note's metadata and +/// is lifted from there by the on-chain script when a remainder note is created +/// (the asset pair is unchanged, so the tag carries over unchanged). +#[derive(Debug, Clone, PartialEq, Eq, bon::Builder)] +pub struct PswapNoteStorage { + requested_asset: FungibleAsset, + + creator_account_id: AccountId, + + /// Note type of the payback note produced when the pswap is filled. Defaults to + /// [`NoteType::Private`] because the payback carries the fill asset and is typically + /// consumed directly by the creator — a private note is cheaper in fees and bandwidth + /// and offers the same information (the fill amount is already recorded in the + /// executed transaction's output). + #[builder(default = NoteType::Private)] + payback_note_type: NoteType, +} + +impl PswapNoteStorage { + // CONSTANTS + // -------------------------------------------------------------------------------------------- + + /// Expected number of storage items for the PSWAP note. + pub const NUM_STORAGE_ITEMS: usize = 7; + + /// Consumes the storage and returns a PSWAP [`NoteRecipient`] with the provided serial number. + pub fn into_recipient(self, serial_num: Word) -> NoteRecipient { + NoteRecipient::new(serial_num, PswapNote::script(), NoteStorage::from(self)) + } + + // PUBLIC ACCESSORS + // -------------------------------------------------------------------------------------------- + + /// Returns a reference to the requested [`FungibleAsset`]. + pub fn requested_asset(&self) -> &FungibleAsset { + &self.requested_asset + } + + /// Returns the payback note routing tag, derived from the creator's account ID. + pub fn payback_note_tag(&self) -> NoteTag { + NoteTag::with_account_target(self.creator_account_id) + } + + /// Returns the account ID of the note creator. + pub fn creator_account_id(&self) -> AccountId { + self.creator_account_id + } + + /// Returns the [`NoteType`] used when creating the payback note. + pub fn payback_note_type(&self) -> NoteType { + self.payback_note_type + } + + /// Returns the faucet ID of the requested asset. + pub fn requested_faucet_id(&self) -> AccountId { + self.requested_asset.faucet_id() + } + + /// Returns the requested token amount. + pub fn requested_asset_amount(&self) -> u64 { + self.requested_asset.amount().as_u64() + } +} + +/// Serializes [`PswapNoteStorage`] into a 7-element [`NoteStorage`]. +impl From for NoteStorage { + fn from(storage: PswapNoteStorage) -> Self { + let storage_items = vec![ + // Requested asset (individual felts) [0-3] + Felt::from(storage.requested_asset.callbacks().as_u8()), + storage.requested_asset.faucet_id().suffix(), + storage.requested_asset.faucet_id().prefix().as_felt(), + Felt::from(storage.requested_asset.amount()), + // Payback note type [4] + Felt::from(storage.payback_note_type.as_u8()), + // Creator ID [5-6] + storage.creator_account_id.prefix().as_felt(), + storage.creator_account_id.suffix(), + ]; + NoteStorage::new(storage_items) + .expect("number of storage items should not exceed max storage items") + } +} + +/// Deserializes [`PswapNoteStorage`] from a slice of exactly 7 [`Felt`]s. +impl TryFrom<&[Felt]> for PswapNoteStorage { + type Error = NoteError; + + fn try_from(note_storage: &[Felt]) -> Result { + if note_storage.len() != Self::NUM_STORAGE_ITEMS { + return Err(NoteError::InvalidNoteStorageLength { + expected: Self::NUM_STORAGE_ITEMS, + actual: note_storage.len(), + }); + } + + // Reconstruct requested asset from individual felts: + // [0] = enable_callbacks, [1] = faucet_id_suffix, [2] = faucet_id_prefix, [3] = amount + let callbacks = AssetCallbackFlag::try_from( + u8::try_from(note_storage[0].as_canonical_u64()) + .map_err(|_| NoteError::other("enable_callbacks exceeds u8"))?, + ) + .map_err(|e| NoteError::other_with_source("failed to parse asset callback flag", e))?; + + let faucet_id = AccountId::try_from_elements(note_storage[1], note_storage[2]) + .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); + + // [4] = payback_note_type + let payback_note_type = NoteType::try_from( + u8::try_from(note_storage[4].as_canonical_u64()) + .map_err(|_| NoteError::other("payback_note_type exceeds u8"))?, + ) + .map_err(|e| NoteError::other_with_source("failed to parse payback note type", e))?; + + // [5-6] = creator account ID (prefix, suffix) + let creator_account_id = AccountId::try_from_elements(note_storage[6], note_storage[5]) + .map_err(|e| NoteError::other_with_source("failed to parse creator account ID", e))?; + + Ok(Self { + requested_asset, + creator_account_id, + payback_note_type, + }) + } +} + +// PSWAP NOTE ATTACHMENT +// ================================================================================================ + +/// Typed attachment carried by both PSWAP output notes, encoded as +/// `[amount, order_id, depth, 0]` under [`PswapNote::PSWAP_ATTACHMENT_SCHEME`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PswapNoteAttachment { + amount: AssetAmount, + order_id: Felt, + depth: u32, +} + +impl PswapNoteAttachment { + /// Creates a new [`PswapNoteAttachment`]. + pub fn new(amount: AssetAmount, order_id: Felt, depth: u32) -> Self { + Self { amount, order_id, depth } + } + + pub fn amount(&self) -> AssetAmount { + self.amount + } + + pub fn order_id(&self) -> Felt { + self.order_id + } + + pub fn depth(&self) -> u32 { + self.depth + } +} + +impl From for NoteAttachment { + fn from(attachment: PswapNoteAttachment) -> Self { + let word = Word::from([ + Felt::from(attachment.amount), + attachment.order_id, + Felt::from(attachment.depth), + ZERO, + ]); + NoteAttachment::with_word(PswapNote::PSWAP_ATTACHMENT_SCHEME, word) + } +} + +// PSWAP NOTE +// ================================================================================================ + +/// A partially-fillable swap note for decentralized asset exchange. +/// +/// A PSWAP note allows a creator to offer one fungible asset in exchange for another. +/// Unlike a regular SWAP note, consumers may fill it partially — the unfilled portion +/// is re-created as a remainder note with an updated serial number, while the creator +/// receives the filled portion via a payback note. +/// +/// The note can be consumed both in local transactions (where the consumer provides +/// fill amounts via note_args) and in network transactions (where note_args default to +/// `[0, 0, 0, 0]`, triggering a full fill). To route a PSWAP note to a network account, +/// set the `attachment` to a [`NetworkAccountTarget`](crate::note::NetworkAccountTarget) +/// via the builder. +#[derive(Debug, Clone, bon::Builder)] +#[builder(finish_fn(vis = "", name = build_internal))] +pub struct PswapNote { + sender: AccountId, + storage: PswapNoteStorage, + serial_number: Word, + + #[builder(default = NoteType::Private)] + note_type: NoteType, + + offered_asset: FungibleAsset, + + attachment: Option, +} + +impl PswapNoteBuilder +where + S: pswap_note_builder::IsComplete, +{ + /// Validates and builds the [`PswapNote`]. + /// + /// # Errors + /// + /// Returns an error if the offered and requested assets have the same faucet ID. + pub fn build(self) -> Result { + let note = self.build_internal(); + + if note.offered_asset.faucet_id() == note.storage.requested_faucet_id() { + return Err(NoteError::other( + "offered and requested assets must have different faucets", + )); + } + + Ok(note) + } +} + +impl PswapNote { + // CONSTANTS + // -------------------------------------------------------------------------------------------- + + /// Expected number of storage items for the PSWAP note. + pub const NUM_STORAGE_ITEMS: usize = PswapNoteStorage::NUM_STORAGE_ITEMS; + + /// Attachment scheme stamped on both PSWAP output notes (the payback P2ID and the + /// remainder PSWAP). + pub const PSWAP_ATTACHMENT_SCHEME: NoteAttachmentScheme = + StandardNoteAttachment::PswapAttachment.attachment_scheme(); + + /// Offset of the `depth` field within the [`Self::PSWAP_ATTACHMENT_SCHEME`] word. + const PARENT_ATTACHMENT_DEPTH_OFFSET: usize = 2; + + // PUBLIC ACCESSORS + // -------------------------------------------------------------------------------------------- + + /// Returns the compiled PSWAP note script. + pub fn script() -> NoteScript { + PSWAP_SCRIPT.clone() + } + + /// Returns the root hash of the PSWAP note script. + pub fn script_root() -> NoteScriptRoot { + PSWAP_SCRIPT.root() + } + + /// Builds the `NOTE_ARGS` word that the PSWAP script expects when a + /// consumer wants to fill part of the swap: + /// + /// `[account_fill, note_fill, 0, 0]` + /// + /// - `account_fill` is the portion of the requested asset the consumer pays out of their own + /// vault. + /// - `note_fill` is the portion sourced from another note in the same transaction (cross-swap / + /// net-zero flow). + /// + /// Both values are in the requested asset's base units. In a network + /// transaction the kernel defaults `NOTE_ARGS` to `[0, 0, 0, 0]` and the + /// script falls back to a full fill, so this helper is only needed for + /// local transactions where the consumer is choosing the fill split. + /// + /// # Errors + /// + /// Returns an error if either value exceeds the Goldilocks field size + /// (i.e. cannot be represented as a [`Felt`]). In practice this cannot + /// happen for any amount that fits in a [`FungibleAsset`] — + /// `FungibleAsset::MAX_AMOUNT` is comfortably below `2^63` — but the + /// conversion is surfaced explicitly rather than hidden behind a panic. + pub fn create_args(account_fill: u64, note_fill: u64) -> Result { + let account_fill = Felt::try_from(account_fill) + .map_err(|e| NoteError::other_with_source("account_fill is not a valid felt", e))?; + let note_fill = Felt::try_from(note_fill) + .map_err(|e| NoteError::other_with_source("note_fill is not a valid felt", e))?; + Ok(Word::from([account_fill, note_fill, ZERO, ZERO])) + } + + /// Returns the account ID of the note sender. + pub fn sender(&self) -> AccountId { + self.sender + } + + /// Returns a reference to the PSWAP note storage. + pub fn storage(&self) -> &PswapNoteStorage { + &self.storage + } + + /// Returns the serial number of this note. + pub fn serial_number(&self) -> Word { + self.serial_number + } + + /// Returns the note type (public or private). + pub fn note_type(&self) -> NoteType { + self.note_type + } + + /// Returns a reference to the offered [`FungibleAsset`]. + pub fn offered_asset(&self) -> &FungibleAsset { + &self.offered_asset + } + + /// Returns a reference to the note attachments. + /// + /// For notes targeting a network account, this may contain a + /// [`NetworkAccountTarget`](crate::note::NetworkAccountTarget) with scheme = 2. For a + /// remainder PSWAP this contains the [`Self::PSWAP_ATTACHMENT_SCHEME`] word + /// `[amt_payout, order_id, depth, 0]`. For an original PSWAP (no prior fill), + /// this is typically empty. + pub fn attachments(&self) -> Option<&NoteAttachment> { + self.attachment.as_ref() + } + + /// Returns the order_id of this lineage, equal to `serial_number()[1]`. + pub fn order_id(&self) -> Felt { + self.serial_number[1] + } + + /// Returns the depth carried in this note's [`Self::PSWAP_ATTACHMENT_SCHEME`] attachment, + /// or 0 if the note has no such attachment (i.e., it is the original PSWAP, not a + /// remainder produced by an earlier fill). + /// + /// The next round's `current_depth` is computed as `parent_depth() + 1`, matching the + /// on-chain `get_current_depth` MASM procedure. + pub fn parent_depth(&self) -> u64 { + match self.attachment.as_ref() { + Some(att) if att.attachment_scheme() == Self::PSWAP_ATTACHMENT_SCHEME => { + let attachment_word = att.content().as_words()[0]; + attachment_word[Self::PARENT_ATTACHMENT_DEPTH_OFFSET].as_canonical_u64() + }, + _ => 0, + } + } + + // INSTANCE METHODS + // -------------------------------------------------------------------------------------------- + + /// Executes the swap as a full fill, producing only the payback note (no remainder). + /// + /// Equivalent to calling [`Self::execute`] with `account_fill_asset` set to the full + /// requested amount and `note_fill_asset = None`. It also matches the on-chain + /// behavior when a note is consumed without explicit `note_args` (e.g. in a network + /// transaction, where the kernel defaults `note_args` to `[0, 0, 0, 0]` and the MASM + /// script falls back to a full fill). + pub fn execute_full_fill(&self, consumer_account_id: AccountId) -> Result { + 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()); + + self.create_payback_note(consumer_account_id, fill_asset, total_requested_amount) + } + + /// Executes the swap, producing the output notes for a given fill. + /// + /// `account_fill_asset` is debited from the consumer's vault; `note_fill_asset` arrives + /// from another note in the same transaction (cross-swap). At least one must be + /// provided. + /// + /// Returns `(payback_note, Option)`. The remainder is + /// `None` when the fill equals the total requested amount (full fill). + /// + /// # Errors + /// + /// Returns an error if: + /// - Both assets are `None`. + /// - The fill amount is zero. + /// - The fill amount exceeds the total requested amount. + pub fn execute( + &self, + consumer_account_id: AccountId, + account_fill_asset: Option, + note_fill_asset: Option, + ) -> Result<(Note, Option), NoteError> { + // Combine account fill and note fill into a single payback asset. + let payback_asset = match (account_fill_asset, note_fill_asset) { + (Some(account_fill), Some(note_fill)) => account_fill.add(note_fill).map_err(|e| { + NoteError::other_with_source( + "failed to combine account fill and note fill assets", + e, + ) + })?, + (Some(asset), None) | (None, Some(asset)) => asset, + (None, None) => { + return Err(NoteError::other( + "at least one of account_fill_asset or note_fill_asset must be provided", + )); + }, + }; + let fill_amount = payback_asset.amount().as_u64(); + + let total_offered_amount = self.offered_asset.amount().as_u64(); + let requested_faucet_id = self.storage.requested_faucet_id(); + let total_requested_amount = self.storage.requested_asset_amount(); + + // Validate fill amount + if fill_amount == 0 { + return Err(NoteError::other("Fill amount must be greater than 0")); + } + if fill_amount > total_requested_amount { + return Err(NoteError::other(alloc::format!( + "Fill amount {} exceeds requested amount {}", + fill_amount, + total_requested_amount + ))); + } + + // Calculate payout amounts separately for account fill and note fill, matching the + // MASM which calls calculate_tokens_offered_for_requested twice. This is necessary + // because the account fill portion goes to the consumer's vault while the total + // determines the remainder note's offered amount. + let account_fill_amount = account_fill_asset.as_ref().map_or(0, |a| a.amount().as_u64()); + let note_fill_amount = note_fill_asset.as_ref().map_or(0, |a| a.amount().as_u64()); + let payout_for_account_fill = Self::calculate_output_amount( + total_offered_amount, + total_requested_amount, + account_fill_amount, + )?; + let payout_for_note_fill = Self::calculate_output_amount( + total_offered_amount, + total_requested_amount, + note_fill_amount, + )?; + let offered_amount_for_fill = payout_for_account_fill + payout_for_note_fill; + + let payback_note = + self.create_payback_note(consumer_account_id, payback_asset, fill_amount)?; + + // Create remainder note if partial fill + let remainder = if fill_amount < total_requested_amount { + 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()); + + Some(self.create_remainder_pswap_note( + consumer_account_id, + remaining_offered_asset, + remaining_requested_asset, + offered_amount_for_fill, + )?) + } else { + None + }; + + Ok((payback_note, remainder)) + } + + /// Returns how many offered tokens a consumer receives for `fill_amount` of the + /// requested asset, based on this note's current offered/requested ratio. + /// + /// # Errors + /// + /// Returns an error if the calculated payout is not a valid asset amount. + pub fn calculate_offered_for_requested(&self, fill_amount: u64) -> Result { + let total_requested = self.storage.requested_asset_amount(); + let total_offered = self.offered_asset.amount().as_u64(); + + Self::calculate_output_amount(total_offered, total_requested, fill_amount) + } + + // LINEAGE DISCOVERY + // -------------------------------------------------------------------------------------------- + + /// Reconstructs the depth-`d` payback P2ID [`Note`], so the creator can consume it as an + /// unauthenticated input note. + /// + /// `consumer_account_id` must be the account that consumed the parent PSWAP in round + /// `depth`: the MASM stamps it as the payback's metadata sender, which feeds into + /// [`Note::details_commitment`]. + /// + /// # Errors + /// + /// Returns an error if `attachment.depth() == 0` or if the fill amount is not a valid + /// asset amount. + pub fn payback_note( + &self, + consumer_account_id: AccountId, + attachment: &PswapNoteAttachment, + ) -> Result { + let depth = attachment.depth(); + if depth == 0 { + return Err(NoteError::other("depth must be >= 1")); + } + let parent_depth = Felt::from(depth - 1); + let p2id_serial = Word::from([ + self.serial_number[0] + ONE, + self.serial_number[1], + self.serial_number[2], + self.serial_number[3] + parent_depth, + ]); + + 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 assets = NoteAssets::new(vec![fill_asset.into()])?; + + let metadata = + PartialNoteMetadata::new(consumer_account_id, self.storage.payback_note_type) + .with_tag(self.storage.payback_note_tag()); + + Ok(Note::with_attachments( + assets, + metadata, + recipient, + NoteAttachments::from(NoteAttachment::from(*attachment)), + )) + } + + /// Reconstructs the depth-`d` remainder PSWAP [`Note`] in this lineage. + /// + /// Called on the original PSWAP, this returns the full Note for the remainder produced + /// in round `depth`. The returned Note matches the created note exactly. + /// + /// - `consumer_account_id` — the account that consumed the parent PSWAP in round `depth`, used + /// as the remainder's sender. + /// - `attachment` — the on-chain `[amount, order_id, depth, 0]` attachment for this round, + /// where `amount` is the offered-asset units paid out. + /// - `remaining_offered` / `remaining_requested` — the leftover amounts that survive into this + /// remainder. Both are required because the price formula uses floor division, so one isn't + /// derivable from the other across rounds in general. + /// + /// # Errors + /// + /// Returns an error if `attachment.depth() == 0` or if any amount is not a valid asset + /// amount. + pub fn remainder_note( + &self, + consumer_account_id: AccountId, + attachment: &PswapNoteAttachment, + remaining_offered: AssetAmount, + remaining_requested: AssetAmount, + ) -> Result { + let depth = attachment.depth(); + if depth == 0 { + return Err(NoteError::other("depth must be >= 1")); + } + let remainder_serial = Word::from([ + self.serial_number[0], + self.serial_number[1], + self.serial_number[2], + 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 new_storage = PswapNoteStorage::builder() + .requested_asset(requested_asset) + .creator_account_id(self.storage.creator_account_id) + .payback_note_type(self.storage.payback_note_type) + .build(); + let recipient = new_storage.into_recipient(remainder_serial); + + let assets = NoteAssets::new(vec![offered_asset.into()])?; + + let tag = Self::create_tag(self.note_type, &offered_asset, &requested_asset); + let metadata = PartialNoteMetadata::new(consumer_account_id, self.note_type).with_tag(tag); + + Ok(Note::with_attachments( + assets, + metadata, + recipient, + NoteAttachments::from(NoteAttachment::from(*attachment)), + )) + } + + // ASSOCIATED FUNCTIONS + // -------------------------------------------------------------------------------------------- + + /// Builds the 32-bit [`NoteTag`] for a PSWAP note. + /// + /// ```text + /// [31..30] note_type (2 bits) + /// [29..16] script_root MSBs (14 bits) + /// [15..8] offered faucet ID (8 bits, top byte of prefix) + /// [7..0] requested faucet ID (8 bits, top byte of prefix) + /// ``` + pub fn create_tag( + note_type: NoteType, + offered_asset: &FungibleAsset, + requested_asset: &FungibleAsset, + ) -> NoteTag { + let pswap_root_bytes = Self::script().root().as_bytes(); + + // Construct the pswap use case ID from the 14 most significant bits of the script root. + // This leaves the two most significant bits zero. + let mut pswap_use_case_id = (pswap_root_bytes[0] as u16) << 6; + pswap_use_case_id |= (pswap_root_bytes[1] >> 2) as u16; + + // Get bits 0..8 from the faucet IDs of both assets which will form the tag payload. + let offered_asset_id: u64 = offered_asset.faucet_id().prefix().into(); + let offered_asset_tag = (offered_asset_id >> 56) as u8; + + let requested_asset_id: u64 = requested_asset.faucet_id().prefix().into(); + let requested_asset_tag = (requested_asset_id >> 56) as u8; + + let asset_pair = ((offered_asset_tag as u16) << 8) | (requested_asset_tag as u16); + + let tag = ((note_type as u8 as u32) << 30) + | ((pswap_use_case_id as u32) << 16) + | asset_pair as u32; + + NoteTag::new(tag) + } + + /// Computes `floor((offered_total * fill_amount) / requested_total)` via a + /// u128 intermediate, mirroring `u64::widening_mul` + `u128::div` on the + /// MASM side. + /// + /// # Errors + /// + /// Returns an error if the result does not fit in a valid [`AssetAmount`]. + fn calculate_output_amount( + offered_total: u64, + requested_total: u64, + fill_amount: u64, + ) -> Result { + let product = (offered_total as u128) * (fill_amount as u128); + let quotient = product / (requested_total as u128); + let amount = u64::try_from(quotient) + .map_err(|_| NoteError::other("payout quotient does not fit in u64"))?; + // Validate the result is a valid fungible asset amount. + AssetAmount::new(amount).map_err(|e| { + NoteError::other_with_source("payout amount exceeds max fungible asset amount", e) + })?; + Ok(amount) + } + + /// Builds the [`NoteAttachment`] carried by both PSWAP output notes (payback and + /// remainder). + /// + /// `amount` is the round's transferred amount on the relevant side of the trade — + /// requested-asset units for the payback, offered-asset units for the remainder. + fn pswap_output_attachment( + amount: u64, + order_id: Felt, + depth: u64, + ) -> Result { + let amount = AssetAmount::new(amount) + .map_err(|e| NoteError::other_with_source("amount is not a valid asset amount", e))?; + let depth = u32::try_from(depth) + .map_err(|_| NoteError::other("PSWAP depth does not fit in u32"))?; + Ok(PswapNoteAttachment::new(amount, order_id, depth).into()) + } + + /// Builds a payback note (P2ID) that delivers the filled assets to the swap creator. + /// + /// The note inherits its type (public/private) from this PSWAP note and derives a + /// deterministic serial number by incrementing the least significant element of the + /// serial number (`serial[0] + 1`). + /// + /// The attachment carries `[fill_amount, order_id, current_depth, 0]` under + /// [`Self::PSWAP_ATTACHMENT_SCHEME`]. `current_depth` is `parent_depth + 1` — i.e., + /// the round number that produced this payback (1-indexed). + fn create_payback_note( + &self, + consumer_account_id: AccountId, + payback_asset: FungibleAsset, + fill_amount: u64, + ) -> Result { + let payback_note_tag = self.storage.payback_note_tag(); + // Derive P2ID serial: increment least significant element (matching MASM add.1) + let p2id_serial_num = Word::from([ + self.serial_number[0] + ONE, + self.serial_number[1], + self.serial_number[2], + self.serial_number[3], + ]); + + // P2ID recipient targets the creator + let recipient = + P2idNoteStorage::new(self.storage.creator_account_id).into_recipient(p2id_serial_num); + + let current_depth = self.parent_depth() + 1; + let attachment = + Self::pswap_output_attachment(fill_amount, self.order_id(), current_depth)?; + + let p2id_assets = NoteAssets::new(vec![payback_asset.into()])?; + let p2id_metadata = + PartialNoteMetadata::new(consumer_account_id, self.storage.payback_note_type) + .with_tag(payback_note_tag); + + Ok(Note::with_attachments( + p2id_assets, + p2id_metadata, + recipient, + NoteAttachments::from(attachment), + )) + } + + /// Builds a remainder PSWAP note carrying the unfilled portion of the swap. + /// + /// The remainder inherits the original creator, tags, and note type, with an updated + /// serial number (`serial[3] + 1`). + /// + /// The attachment carries `[offered_amount_for_fill, order_id, current_depth, 0]` under + /// [`Self::PSWAP_ATTACHMENT_SCHEME`]. The remainder must carry this attachment so that + /// when *it* is later consumed as a parent, `get_current_depth` reads the right scheme + /// and increments depth correctly. + fn create_remainder_pswap_note( + &self, + consumer_account_id: AccountId, + remaining_offered_asset: FungibleAsset, + remaining_requested_asset: FungibleAsset, + offered_amount_for_fill: u64, + ) -> Result { + let new_storage = PswapNoteStorage::builder() + .requested_asset(remaining_requested_asset) + .creator_account_id(self.storage.creator_account_id) + .payback_note_type(self.storage.payback_note_type) + .build(); + + // Remainder serial: increment most significant element (matching MASM movup.3 add.1 + // movdn.3) + let remainder_serial_num = Word::from([ + self.serial_number[0], + self.serial_number[1], + self.serial_number[2], + self.serial_number[3] + ONE, + ]); + + let current_depth = self.parent_depth() + 1; + let attachment = + Self::pswap_output_attachment(offered_amount_for_fill, self.order_id(), current_depth)?; + + PswapNote::builder() + .sender(consumer_account_id) + .storage(new_storage) + .serial_number(remainder_serial_num) + .note_type(self.note_type) + .offered_asset(remaining_offered_asset) + .attachment(attachment) + .build() + } +} + +// CONVERSIONS +// ================================================================================================ + +/// Converts a [`PswapNote`] into a protocol [`Note`], computing the final PSWAP tag. +impl From for Note { + fn from(pswap: PswapNote) -> Self { + let tag = PswapNote::create_tag( + pswap.note_type, + &pswap.offered_asset, + pswap.storage.requested_asset(), + ); + + let recipient = pswap.storage.into_recipient(pswap.serial_number); + + let assets = NoteAssets::new(vec![pswap.offered_asset.into()]) + .expect("single fungible asset should be valid"); + + let metadata = PartialNoteMetadata::new(pswap.sender, pswap.note_type).with_tag(tag); + + let attachments = pswap.attachment.map(NoteAttachments::from).unwrap_or_default(); + + Note::with_attachments(assets, metadata, recipient, attachments) + } +} + +/// Parses a protocol [`Note`] back into a [`PswapNote`] by deserializing its storage. +impl TryFrom<&Note> for PswapNote { + type Error = NoteError; + + fn try_from(note: &Note) -> Result { + if note.recipient().script().root() != PswapNote::script_root() { + return Err(NoteError::other("note script root does not match PSWAP script root")); + } + + let storage = PswapNoteStorage::try_from(note.recipient().storage().items())?; + + if note.assets().num_assets() != 1 { + return Err(NoteError::other("PSWAP note must have exactly one asset")); + } + let offered_asset = match note.assets().iter().next().unwrap() { + Asset::Fungible(fa) => *fa, + Asset::NonFungible(_) => { + return Err(NoteError::other("PSWAP note asset must be fungible")); + }, + }; + + let attachment = match note.attachments().num_attachments() { + 0 => None, + 1 => { + Some(note.attachments().get(0).expect("length should have been validated").clone()) + }, + _ => return Err(NoteError::other("pswap note supports only one attachment")), + }; + + PswapNote::builder() + .sender(note.metadata().sender()) + .storage(storage) + .serial_number(note.recipient().serial_num()) + .note_type(note.metadata().note_type()) + .offered_asset(offered_asset) + .maybe_attachment(attachment) + .build() + } +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use miden_protocol::account::{AccountId, AccountIdVersion, AccountType}; + use miden_protocol::asset::FungibleAsset; + use miden_protocol::crypto::rand::{FeltRng, RandomCoin}; + + use super::*; + + // TEST HELPERS + // -------------------------------------------------------------------------------------------- + + fn dummy_faucet_id(byte: u8) -> AccountId { + let mut bytes = [0; 15]; + bytes[0] = byte; + AccountId::dummy(bytes, AccountIdVersion::Version1, AccountType::Public) + } + + fn dummy_creator_id() -> AccountId { + AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Public) + } + + fn dummy_consumer_id() -> AccountId { + AccountId::dummy([2; 15], AccountIdVersion::Version1, AccountType::Public) + } + + fn build_pswap_note( + offered_asset: FungibleAsset, + requested_asset: FungibleAsset, + creator_id: AccountId, + ) -> (PswapNote, Note) { + let mut rng = RandomCoin::new(Word::default()); + let storage = PswapNoteStorage::builder() + .requested_asset(requested_asset) + .creator_account_id(creator_id) + .build(); + let pswap = PswapNote::builder() + .sender(creator_id) + .storage(storage) + .serial_number(rng.draw_word()) + .note_type(NoteType::Public) + .offered_asset(offered_asset) + .build() + .unwrap(); + let note: Note = pswap.clone().into(); + (pswap, note) + } + + // 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 (pswap, note) = build_pswap_note(offered_asset, requested_asset, creator_id); + + assert_eq!(pswap.sender(), creator_id); + assert_eq!(pswap.note_type(), NoteType::Public); + + let script = PswapNote::script(); + assert!(Word::from(script.root()) != Word::default(), "Script root should not be zero"); + assert_eq!(note.metadata().sender(), creator_id); + assert_eq!(note.metadata().note_type(), NoteType::Public); + assert_eq!(note.assets().num_assets(), 1); + assert_eq!(note.recipient().script().root(), script.root()); + assert_eq!( + note.recipient().storage().num_items(), + PswapNoteStorage::NUM_STORAGE_ITEMS as u16, + ); + } + + #[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 (pswap, note) = build_pswap_note(offered_asset, requested_asset, creator_id); + + assert_eq!(pswap.sender(), creator_id); + assert_eq!(pswap.note_type(), NoteType::Public); + assert_eq!(note.metadata().sender(), creator_id); + assert_eq!(note.metadata().note_type(), NoteType::Public); + assert_eq!(note.assets().num_assets(), 1); + assert_eq!( + note.recipient().storage().num_items(), + PswapNoteStorage::NUM_STORAGE_ITEMS as u16, + ); + } + + #[test] + fn pswap_tag() { + let mut offered_faucet_bytes = [0; 15]; + offered_faucet_bytes[0] = 0xcd; + offered_faucet_bytes[1] = 0xb1; + + let mut requested_faucet_bytes = [0; 15]; + requested_faucet_bytes[0] = 0xab; + requested_faucet_bytes[1] = 0xec; + + let offered_asset = FungibleAsset::new( + AccountId::dummy(offered_faucet_bytes, AccountIdVersion::Version1, AccountType::Public), + 100, + ) + .unwrap(); + let requested_asset = FungibleAsset::new( + AccountId::dummy( + requested_faucet_bytes, + AccountIdVersion::Version1, + AccountType::Public, + ), + 200, + ) + .unwrap(); + + let tag = PswapNote::create_tag(NoteType::Public, &offered_asset, &requested_asset); + let tag_u32 = u32::from(tag); + + // Verify note_type bits (top 2 bits should be 10 for Public) + let note_type_bits = tag_u32 >> 30; + assert_eq!(note_type_bits, NoteType::Public as u32); + } + + #[test] + fn calculate_output_amount() { + assert_eq!(PswapNote::calculate_output_amount(100, 100, 50).unwrap(), 50); // Equal ratio + assert_eq!(PswapNote::calculate_output_amount(200, 100, 50).unwrap(), 100); // 2:1 ratio + assert_eq!(PswapNote::calculate_output_amount(100, 200, 50).unwrap(), 25); // 1:2 ratio + + // Non-integer ratio (100/73) + let result = PswapNote::calculate_output_amount(100, 73, 7).unwrap(); + assert!(result > 0, "Should produce non-zero output"); + } + + #[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 storage_items = vec![ + Felt::from(requested_asset.callbacks().as_u8()), + requested_asset.faucet_id().suffix(), + requested_asset.faucet_id().prefix().as_felt(), + Felt::from(requested_asset.amount()), + Felt::from(NoteType::Private.as_u8()), // payback_note_type + creator_id.prefix().as_felt(), + creator_id.suffix(), + ]; + + let parsed = PswapNoteStorage::try_from(storage_items.as_slice()).unwrap(); + assert_eq!(parsed.creator_account_id(), creator_id); + assert_eq!(parsed.requested_asset_amount(), 500); + } + + #[test] + fn pswap_note_storage_roundtrip() { + let creator_id = dummy_creator_id(); + let requested_asset = FungibleAsset::new(dummy_faucet_id(0xaa), 500).unwrap(); + + let storage = PswapNoteStorage::builder() + .requested_asset(requested_asset) + .creator_account_id(creator_id) + .build(); + + let note_storage = NoteStorage::from(storage.clone()); + let parsed = PswapNoteStorage::try_from(note_storage.items()).unwrap(); + + assert_eq!(parsed.creator_account_id(), creator_id); + assert_eq!(parsed.requested_asset_amount(), 500); + } + + /// Consumer supplies both an account fill and a note fill, and the sum is below + /// the requested amount → `execute` must combine them into a single payback note + /// carrying account_fill+note_fill of the requested asset and emit a remainder + /// pswap note for the unfilled portion. + #[test] + fn pswap_execute_combined_account_fill_and_note_fill_partial_fill() { + let creator_id = dummy_creator_id(); + let consumer_id = dummy_consumer_id(); + let offered_faucet = dummy_faucet_id(0xaa); + 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 (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 (payback, remainder) = + pswap.execute(consumer_id, Some(account_fill), Some(note_fill)).unwrap(); + + // Payback note must carry the combined 30 of requested asset. + assert_eq!(payback.assets().num_assets(), 1); + let payback_asset = payback.assets().iter().next().unwrap(); + let Asset::Fungible(fa) = payback_asset else { + panic!("expected fungible payback asset"); + }; + assert_eq!(fa.faucet_id(), requested_faucet); + assert_eq!(fa.amount().as_u64(), 30); + + // Remainder must exist with the unfilled 50 - 30 = 20 of requested, and the + // offered amount reduced proportionally (100 - 30*2 = 40). + let remainder = remainder.expect("partial fill should produce remainder"); + assert_eq!(remainder.storage().requested_asset_amount(), 20); + assert_eq!(remainder.offered_asset().amount().as_u64(), 40); + assert_eq!(remainder.storage().creator_account_id(), creator_id); + } + + /// Consumer supplies both an account fill and a note fill, and the sum exactly + /// matches the requested amount → `execute` must produce a single payback note for + /// the full amount and no remainder. + #[test] + fn pswap_execute_combined_account_fill_and_note_fill_full_fill() { + let creator_id = dummy_creator_id(); + let consumer_id = dummy_consumer_id(); + 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 (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 (payback, remainder) = + pswap.execute(consumer_id, Some(account_fill), Some(note_fill)).unwrap(); + + // Payback note must carry the full 50 of requested asset. + assert_eq!(payback.assets().num_assets(), 1); + let payback_asset = payback.assets().iter().next().unwrap(); + let Asset::Fungible(fa) = payback_asset else { + panic!("expected fungible payback asset"); + }; + assert_eq!(fa.faucet_id(), requested_faucet); + assert_eq!(fa.amount().as_u64(), 50); + + // Full fill → no remainder note. + assert!(remainder.is_none(), "full fill must not produce a remainder"); + } + + /// Regression for the silent `AssetCallbackFlag` drop: when the PSWAP's requested or + /// offered asset carries `Enabled` callbacks, the on-chain MASM preserves that flag + /// on every output note's asset. The Rust-side `execute`, `payback_note`, and + /// `remainder_note` must do the same — otherwise the reconstructed `Note::details_commitment` + /// diverges from the on-chain leaf and the unauthenticated consume path fails. + #[test] + fn pswap_output_assets_preserve_callback_flag() { + let creator_id = dummy_creator_id(); + let consumer_id = dummy_consumer_id(); + 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 (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 (payback, remainder) = pswap.execute(consumer_id, Some(account_fill), None).unwrap(); + + let Asset::Fungible(fa) = payback.assets().iter().next().unwrap() else { + panic!("expected fungible payback asset"); + }; + assert_eq!(fa.callbacks(), AssetCallbackFlag::Enabled); + + let remainder = remainder.expect("partial fill should produce remainder"); + assert_eq!( + remainder.offered_asset().callbacks(), + AssetCallbackFlag::Enabled, + "remainder offered asset must inherit callbacks", + ); + assert_eq!( + remainder.storage().requested_asset().callbacks(), + AssetCallbackFlag::Enabled, + "remainder storage's requested asset must inherit callbacks", + ); + + // --- payback_note() reconstruction --- + let payback_attachment = + PswapNoteAttachment::new(AssetAmount::new(20).unwrap(), pswap.order_id(), 1); + let reconstructed_payback = pswap.payback_note(consumer_id, &payback_attachment).unwrap(); + let Asset::Fungible(fa) = reconstructed_payback.assets().iter().next().unwrap() else { + panic!("expected fungible payback asset"); + }; + assert_eq!( + fa.callbacks(), + AssetCallbackFlag::Enabled, + "payback_note must preserve requested asset's callback flag", + ); + + // --- remainder_note() reconstruction --- + let remainder_attachment = + PswapNoteAttachment::new(AssetAmount::new(40).unwrap(), pswap.order_id(), 1); + let reconstructed_remainder = pswap + .remainder_note( + consumer_id, + &remainder_attachment, + AssetAmount::new(60).unwrap(), + AssetAmount::new(30).unwrap(), + ) + .unwrap(); + let Asset::Fungible(fa) = reconstructed_remainder.assets().iter().next().unwrap() else { + panic!("expected fungible remainder asset"); + }; + assert_eq!( + fa.callbacks(), + AssetCallbackFlag::Enabled, + "remainder_note must preserve offered asset's callback flag", + ); + } +} diff --git a/crates/miden-standards/src/note/standard_note_attachment.rs b/crates/miden-standards/src/note/standard_note_attachment.rs index 17ec1332df..bcdd4b587c 100644 --- a/crates/miden-standards/src/note/standard_note_attachment.rs +++ b/crates/miden-standards/src/note/standard_note_attachment.rs @@ -6,13 +6,18 @@ use miden_protocol::note::NoteAttachmentScheme; pub enum StandardNoteAttachment { /// See [`NetworkAccountTarget`](crate::note::NetworkAccountTarget) for details. NetworkAccountTarget, + /// The attachment scheme used by both PSWAP output notes (payback P2ID + remainder PSWAP). + /// Carries the word `[amount, order_id, depth, 0]`. See + /// [`PswapNote`](crate::note::PswapNote) for details. + PswapAttachment, } impl StandardNoteAttachment { /// Returns the [`NoteAttachmentScheme`] of the standard attachment. pub const fn attachment_scheme(&self) -> NoteAttachmentScheme { match self { - StandardNoteAttachment::NetworkAccountTarget => NoteAttachmentScheme::new(1u32), + StandardNoteAttachment::NetworkAccountTarget => NoteAttachmentScheme::new_const(2u16), + StandardNoteAttachment::PswapAttachment => NoteAttachmentScheme::new_const(3u16), } } } diff --git a/crates/miden-standards/src/note/swap.rs b/crates/miden-standards/src/note/swap.rs index ae91cf445f..17a7347af6 100644 --- a/crates/miden-standards/src/note/swap.rs +++ b/crates/miden-standards/src/note/swap.rs @@ -1,5 +1,6 @@ use alloc::vec::Vec; +use miden_protocol::Word; use miden_protocol::account::AccountId; use miden_protocol::assembly::Path; use miden_protocol::asset::Asset; @@ -8,17 +9,17 @@ use miden_protocol::errors::NoteError; use miden_protocol::note::{ Note, NoteAssets, - NoteAttachment, + NoteAttachments, NoteDetails, - NoteMetadata, NoteRecipient, NoteScript, + NoteScriptRoot, NoteStorage, NoteTag, NoteType, + PartialNoteMetadata, }; use miden_protocol::utils::sync::LazyLock; -use miden_protocol::{Felt, Word}; use crate::StandardsLib; use crate::note::P2idNoteStorage; @@ -59,7 +60,7 @@ impl SwapNote { } /// Returns the SWAP note script root. - pub fn script_root() -> Word { + pub fn script_root() -> NoteScriptRoot { SWAP_SCRIPT.root() } @@ -80,9 +81,8 @@ impl SwapNote { offered_asset: Asset, requested_asset: Asset, swap_note_type: NoteType, - swap_note_attachment: NoteAttachment, + swap_note_attachments: NoteAttachments, payback_note_type: NoteType, - payback_note_attachment: NoteAttachment, rng: &mut R, ) -> Result<(Note, NoteDetails), NoteError> { if requested_asset == offered_asset { @@ -91,13 +91,8 @@ impl SwapNote { let payback_serial_num = rng.draw_word(); - let swap_storage = SwapNoteStorage::new( - sender, - requested_asset, - payback_note_type, - payback_note_attachment, - payback_serial_num, - ); + let swap_storage = + SwapNoteStorage::new(sender, requested_asset, payback_note_type, payback_serial_num); let serial_num = rng.draw_word(); let recipient = swap_storage.into_recipient(serial_num); @@ -106,11 +101,9 @@ impl SwapNote { let tag = Self::build_tag(swap_note_type, &offered_asset, &requested_asset); // build the outgoing note - let metadata = NoteMetadata::new(sender, swap_note_type) - .with_tag(tag) - .with_attachment(swap_note_attachment); + let metadata = PartialNoteMetadata::new(sender, swap_note_type).with_tag(tag); let assets = NoteAssets::new(vec![offered_asset])?; - let note = Note::new(assets, metadata, recipient); + let note = Note::with_attachments(assets, metadata, recipient, swap_note_attachments); // build the payback note details let payback_recipient = P2idNoteStorage::new(sender).into_recipient(payback_serial_num); @@ -126,7 +119,7 @@ impl SwapNote { /// /// ```text /// [ - /// note_type (2 bits) | script_root (14 bits) + /// note_type (1 bit) | script_root (15 bits) /// | offered_asset_faucet_id (8 bits) | requested_asset_faucet_id (8 bits) /// ] /// ``` @@ -138,10 +131,10 @@ impl SwapNote { requested_asset: &Asset, ) -> NoteTag { let swap_root_bytes = Self::script().root().as_bytes(); - // Construct the swap use case ID from the 14 most significant bits of the script root. This - // leaves the two most significant bits zero. - let mut swap_use_case_id = (swap_root_bytes[0] as u16) << 6; - swap_use_case_id |= (swap_root_bytes[1] >> 2) as u16; + // Construct the swap use case ID from the 15 most significant bits of the script root. This + // leaves the most significant bit zero. + let mut swap_use_case_id = (swap_root_bytes[0] as u16) << 7; + swap_use_case_id |= (swap_root_bytes[1] >> 1) as u16; // Get bits 0..8 from the faucet IDs of both assets which will form the tag payload. let offered_asset_id: u64 = offered_asset.faucet_id().prefix().into(); @@ -152,7 +145,7 @@ impl SwapNote { let asset_pair = ((offered_asset_tag as u16) << 8) | (requested_asset_tag as u16); - let tag = ((note_type as u8 as u32) << 30) + let tag = ((note_type as u8 as u32) << 31) | ((swap_use_case_id as u32) << 16) | asset_pair as u32; @@ -172,7 +165,6 @@ impl SwapNote { pub struct SwapNoteStorage { payback_note_type: NoteType, payback_tag: NoteTag, - payback_attachment: NoteAttachment, requested_asset: Asset, payback_recipient_digest: Word, } @@ -182,7 +174,7 @@ impl SwapNoteStorage { // -------------------------------------------------------------------------------------------- /// Expected number of storage items of the SWAP note. - pub const NUM_ITEMS: usize = 20; + pub const NUM_ITEMS: usize = 14; // CONSTRUCTORS // -------------------------------------------------------------------------------------------- @@ -192,7 +184,6 @@ impl SwapNoteStorage { sender: AccountId, requested_asset: Asset, payback_note_type: NoteType, - payback_attachment: NoteAttachment, payback_serial_number: Word, ) -> Self { let payback_recipient = P2idNoteStorage::new(sender).into_recipient(payback_serial_number); @@ -201,7 +192,6 @@ impl SwapNoteStorage { Self::from_parts( payback_note_type, payback_tag, - payback_attachment, requested_asset, payback_recipient.digest(), ) @@ -211,14 +201,12 @@ impl SwapNoteStorage { pub fn from_parts( payback_note_type: NoteType, payback_tag: NoteTag, - payback_attachment: NoteAttachment, requested_asset: Asset, payback_recipient_digest: Word, ) -> Self { Self { payback_note_type, payback_tag, - payback_attachment, requested_asset, payback_recipient_digest, } @@ -234,11 +222,6 @@ impl SwapNoteStorage { self.payback_tag } - /// Returns the payback note attachment. - pub fn payback_attachment(&self) -> &NoteAttachment { - &self.payback_attachment - } - /// Returns the requested asset. pub fn requested_asset(&self) -> Asset { self.requested_asset @@ -260,38 +243,26 @@ impl SwapNoteStorage { impl From for NoteStorage { fn from(storage: SwapNoteStorage) -> Self { - let attachment_scheme = Felt::from(storage.payback_attachment.attachment_scheme().as_u32()); - let attachment_kind = Felt::from(storage.payback_attachment.attachment_kind().as_u8()); - let attachment = storage.payback_attachment.content().to_word(); - let mut storage_values = Vec::with_capacity(SwapNoteStorage::NUM_ITEMS); - storage_values.extend_from_slice(&[ - storage.payback_note_type.into(), - storage.payback_tag.into(), - attachment_scheme, - attachment_kind, - ]); - storage_values.extend_from_slice(attachment.as_elements()); storage_values.extend_from_slice(&storage.requested_asset.as_elements()); storage_values.extend_from_slice(storage.payback_recipient_digest.as_elements()); + storage_values + .extend_from_slice(&[storage.payback_note_type.into(), storage.payback_tag.into()]); NoteStorage::new(storage_values) .expect("number of storage items should not exceed max storage items") } } -// NOTE: TryFrom<&[Felt]> for SwapNoteStorage is not implemented because -// array attachment content cannot be reconstructed from storage alone. See https://github.com/0xMiden/protocol/issues/2555 - // TESTS // ================================================================================================ #[cfg(test)] mod tests { - use miden_protocol::Felt; - use miden_protocol::account::{AccountIdVersion, AccountStorageMode, AccountType}; + + use miden_protocol::account::{AccountIdVersion, AccountType}; use miden_protocol::asset::{FungibleAsset, NonFungibleAsset, NonFungibleAssetDetails}; - use miden_protocol::note::{NoteAttachment, NoteStorage, NoteTag, NoteType}; + use miden_protocol::note::{NoteStorage, NoteTag, NoteType}; use miden_protocol::testing::account_id::{ ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET, @@ -312,31 +283,26 @@ mod tests { } fn non_fungible_asset() -> Asset { - let details = - NonFungibleAssetDetails::new(non_fungible_faucet(), vec![0xaa, 0xbb]).unwrap(); - Asset::NonFungible(NonFungibleAsset::new(&details).unwrap()) + let details = NonFungibleAssetDetails::new(non_fungible_faucet(), vec![0xaa, 0xbb]); + Asset::NonFungible(NonFungibleAsset::new(&details)) } #[test] fn swap_note_storage() { let payback_note_type = NoteType::Private; let payback_tag = NoteTag::new(0x12345678); - let payback_attachment = NoteAttachment::default(); let requested_asset = fungible_asset(); - let payback_recipient_digest = - Word::new([Felt::new(1), Felt::new(2), Felt::new(3), Felt::new(4)]); + let payback_recipient_digest = Word::from([1_u32, 2_u32, 3_u32, 4_u32]); let storage = SwapNoteStorage::from_parts( payback_note_type, payback_tag, - payback_attachment.clone(), requested_asset, payback_recipient_digest, ); assert_eq!(storage.payback_note_type(), payback_note_type); assert_eq!(storage.payback_tag(), payback_tag); - assert_eq!(storage.payback_attachment(), &payback_attachment); assert_eq!(storage.requested_asset(), requested_asset); assert_eq!(storage.payback_recipient_digest(), payback_recipient_digest); @@ -349,15 +315,12 @@ mod tests { fn swap_note_storage_with_non_fungible_asset() { let payback_note_type = NoteType::Public; let payback_tag = NoteTag::new(0xaabbccdd); - let payback_attachment = NoteAttachment::default(); let requested_asset = non_fungible_asset(); - let payback_recipient_digest = - Word::new([Felt::new(10), Felt::new(20), Felt::new(30), Felt::new(40)]); + let payback_recipient_digest = Word::from([10_u32, 20_u32, 30_u32, 40_u32]); let storage = SwapNoteStorage::from_parts( payback_note_type, payback_tag, - payback_attachment, requested_asset, payback_recipient_digest, ); @@ -385,30 +348,23 @@ mod tests { FungibleAsset::new( AccountId::dummy( fungible_faucet_id_bytes, - AccountIdVersion::Version0, - AccountType::FungibleFaucet, - AccountStorageMode::Public, + AccountIdVersion::Version1, + AccountType::Public, ), 2500, ) .unwrap(), ); - let requested_asset = Asset::NonFungible( - NonFungibleAsset::new( - &NonFungibleAssetDetails::new( - AccountId::dummy( - non_fungible_faucet_id_bytes, - AccountIdVersion::Version0, - AccountType::NonFungibleFaucet, - AccountStorageMode::Public, - ), - vec![0xaa, 0xbb, 0xcc, 0xdd], - ) - .unwrap(), - ) - .unwrap(), - ); + let requested_asset = + Asset::NonFungible(NonFungibleAsset::new(&NonFungibleAssetDetails::new( + AccountId::dummy( + non_fungible_faucet_id_bytes, + AccountIdVersion::Version1, + AccountType::Public, + ), + vec![0xaa, 0xbb, 0xcc, 0xdd], + ))); // The fungible ID starts with 0xcdb1. // The non fungible ID starts with 0xabec. @@ -419,18 +375,18 @@ mod tests { let actual_tag = SwapNote::build_tag(note_type, &offered_asset, &requested_asset); assert_eq!(actual_tag.as_u32() as u16, expected_asset_pair, "asset pair should match"); - assert_eq!((actual_tag.as_u32() >> 30) as u8, note_type as u8, "note type should match"); + assert_eq!((actual_tag.as_u32() >> 31) as u8, note_type as u8, "note type should match"); // Check the 8 bits of the first script root byte. assert_eq!( - (actual_tag.as_u32() >> 22) as u8, + (actual_tag.as_u32() >> 23) as u8, SwapNote::script_root().as_bytes()[0], "swap script root byte 0 should match" ); - // Extract the 6 bits of the second script root byte and shift for comparison. + // Extract the 7 bits of the second script root byte and shift for comparison. assert_eq!( - ((actual_tag.as_u32() & 0b00000000_00111111_00000000_00000000) >> 16) as u8, - SwapNote::script_root().as_bytes()[1] >> 2, - "swap script root byte 1 should match with the lower two bits set to zero" + ((actual_tag.as_u32() & 0b00000000_01111111_00000000_00000000) >> 16) as u8, + SwapNote::script_root().as_bytes()[1] >> 1, + "swap script root byte 1 should match with the highest bit set to zero" ); } } diff --git a/crates/miden-standards/src/standards_lib.rs b/crates/miden-standards/src/standards_lib.rs index effda29a16..2ee011323e 100644 --- a/crates/miden-standards/src/standards_lib.rs +++ b/crates/miden-standards/src/standards_lib.rs @@ -59,7 +59,7 @@ mod tests { #[test] fn test_compile() { - let path = Path::new("::miden::standards::faucets::basic_fungible::mint_and_send"); + let path = Path::new("::miden::standards::faucets::fungible::mint_and_send"); let miden = StandardsLib::default(); let exists = miden.0.module_infos().any(|module| { module diff --git a/crates/miden-standards/src/testing/account_component/conditional_auth.rs b/crates/miden-standards/src/testing/account_component/conditional_auth.rs index 64a5f4ce46..fbd360d99e 100644 --- a/crates/miden-standards/src/testing/account_component/conditional_auth.rs +++ b/crates/miden-standards/src/testing/account_component/conditional_auth.rs @@ -1,7 +1,7 @@ use alloc::string::String; use miden_protocol::account::component::AccountComponentMetadata; -use miden_protocol::account::{AccountComponent, AccountComponentCode, AccountType}; +use miden_protocol::account::{AccountComponent, AccountComponentCode}; use miden_protocol::utils::sync::LazyLock; use crate::code_builder::CodeBuilder; @@ -52,9 +52,8 @@ pub struct ConditionalAuthComponent; impl From for AccountComponent { fn from(_: ConditionalAuthComponent) -> Self { - let metadata = - AccountComponentMetadata::new("miden::testing::conditional_auth", AccountType::all()) - .with_description("Testing auth component with conditional behavior"); + let metadata = AccountComponentMetadata::new("miden::testing::conditional_auth") + .with_description("Testing auth component with conditional behavior"); AccountComponent::new(CONDITIONAL_AUTH_LIBRARY.clone(), vec![], metadata) .expect("component should be valid") diff --git a/crates/miden-standards/src/testing/account_component/incr_nonce.rs b/crates/miden-standards/src/testing/account_component/incr_nonce.rs index 95c4158c64..4f1e611e0e 100644 --- a/crates/miden-standards/src/testing/account_component/incr_nonce.rs +++ b/crates/miden-standards/src/testing/account_component/incr_nonce.rs @@ -1,5 +1,5 @@ +use miden_protocol::account::AccountComponent; use miden_protocol::account::component::AccountComponentMetadata; -use miden_protocol::account::{AccountComponent, AccountType}; use miden_protocol::assembly::Library; use miden_protocol::utils::sync::LazyLock; @@ -29,9 +29,8 @@ pub struct IncrNonceAuthComponent; impl From for AccountComponent { fn from(_: IncrNonceAuthComponent) -> Self { - let metadata = - AccountComponentMetadata::new("miden::testing::incr_nonce_auth", AccountType::all()) - .with_description("Testing auth component that always increments nonce"); + let metadata = AccountComponentMetadata::new("miden::testing::incr_nonce_auth") + .with_description("Testing auth component that always increments nonce"); AccountComponent::new(INCR_NONCE_AUTH_LIBRARY.clone(), vec![], metadata) .expect("component should be valid") diff --git a/crates/miden-standards/src/testing/account_component/mock_account_component.rs b/crates/miden-standards/src/testing/account_component/mock_account_component.rs index e3e089e2cb..3bab9b8452 100644 --- a/crates/miden-standards/src/testing/account_component/mock_account_component.rs +++ b/crates/miden-standards/src/testing/account_component/mock_account_component.rs @@ -1,13 +1,7 @@ use alloc::vec::Vec; use miden_protocol::account::component::AccountComponentMetadata; -use miden_protocol::account::{ - AccountCode, - AccountComponent, - AccountStorage, - AccountType, - StorageSlot, -}; +use miden_protocol::account::{AccountCode, AccountComponent, AccountStorage, StorageSlot}; use crate::testing::mock_account_code::MockAccountCodeExt; @@ -20,9 +14,6 @@ use crate::testing::mock_account_code::MockAccountCodeExt; /// arbitrary number of storage slots (within the overall limit) so anything can be set for testing /// purposes. /// -/// This component supports all [`AccountType`](miden_protocol::account::AccountType)s for testing -/// purposes. -/// /// [account_lib]: crate::testing::mock_account_code::MockAccountCodeExt::mock_account_library pub struct MockAccountComponent { storage_slots: Vec, @@ -61,9 +52,8 @@ impl MockAccountComponent { impl From for AccountComponent { fn from(mock_component: MockAccountComponent) -> Self { - let metadata = - AccountComponentMetadata::new("miden::testing::mock_account", AccountType::all()) - .with_description("Mock account component for testing"); + let metadata = AccountComponentMetadata::new("miden::testing::mock_account") + .with_description("Mock account component for testing"); AccountComponent::new( AccountCode::mock_account_library(), diff --git a/crates/miden-standards/src/testing/account_component/mock_faucet_component.rs b/crates/miden-standards/src/testing/account_component/mock_faucet_component.rs index 23cffa2ec3..64fbeef750 100644 --- a/crates/miden-standards/src/testing/account_component/mock_faucet_component.rs +++ b/crates/miden-standards/src/testing/account_component/mock_faucet_component.rs @@ -1,5 +1,5 @@ use miden_protocol::account::component::AccountComponentMetadata; -use miden_protocol::account::{AccountCode, AccountComponent, AccountType}; +use miden_protocol::account::{AccountCode, AccountComponent}; use crate::testing::mock_account_code::MockAccountCodeExt; @@ -11,19 +11,13 @@ use crate::testing::mock_account_code::MockAccountCodeExt; /// It uses the [`MockAccountCodeExt::mock_faucet_library`][faucet_lib] and contains no storage /// slots. /// -/// This component supports the faucet [`AccountType`](miden_protocol::account::AccountType)s for -/// testing purposes. -/// /// [faucet_lib]: crate::testing::mock_account_code::MockAccountCodeExt::mock_faucet_library pub struct MockFaucetComponent; impl From for AccountComponent { fn from(_: MockFaucetComponent) -> Self { - let metadata = AccountComponentMetadata::new( - "miden::testing::mock_faucet", - [AccountType::FungibleFaucet, AccountType::NonFungibleFaucet], - ) - .with_description("Mock faucet component for testing"); + let metadata = AccountComponentMetadata::new("miden::testing::mock_faucet") + .with_description("Mock faucet component for testing"); AccountComponent::new(AccountCode::mock_faucet_library(), vec![], metadata).expect( "mock faucet component should satisfy the requirements of a valid account component", diff --git a/crates/miden-standards/src/testing/mock_account.rs b/crates/miden-standards/src/testing/mock_account.rs index 0a9be4e5ef..4cd8ac9edf 100644 --- a/crates/miden-standards/src/testing/mock_account.rs +++ b/crates/miden-standards/src/testing/mock_account.rs @@ -4,7 +4,6 @@ use miden_protocol::account::{ AccountComponent, AccountId, AccountStorage, - AccountType, }; use miden_protocol::asset::AssetVault; use miden_protocol::testing::noop_auth_component::NoopAuthComponent; @@ -20,7 +19,6 @@ pub trait MockAccountExt { fn mock(account_id: u128, auth: impl Into) -> Account { let account_id = AccountId::try_from(account_id).unwrap(); let account = AccountBuilder::new([1; 32]) - .account_type(account_id.account_type()) .with_auth_component(auth) .with_component(MockAccountComponent::with_slots(AccountStorage::mock_storage_slots())) .with_assets(AssetVault::mock().assets()) @@ -34,10 +32,8 @@ pub trait MockAccountExt { /// Creates a mock account with fungible faucet storage and the given account ID. fn mock_fungible_faucet(account_id: u128) -> Account { let account_id = AccountId::try_from(account_id).unwrap(); - assert_eq!(account_id.account_type(), AccountType::FungibleFaucet); let account = AccountBuilder::new([1; 32]) - .account_type(account_id.account_type()) .with_auth_component(NoopAuthComponent) .with_component(MockFaucetComponent) .build_existing() @@ -50,10 +46,8 @@ pub trait MockAccountExt { /// Creates a mock account with non-fungible faucet storage and the given account ID. fn mock_non_fungible_faucet(account_id: u128) -> Account { let account_id = AccountId::try_from(account_id).unwrap(); - assert_eq!(account_id.account_type(), AccountType::NonFungibleFaucet); let account = AccountBuilder::new([1; 32]) - .account_type(account_id.account_type()) .with_auth_component(NoopAuthComponent) .with_component(MockFaucetComponent) .build_existing() diff --git a/crates/miden-standards/src/testing/mock_account_code.rs b/crates/miden-standards/src/testing/mock_account_code.rs index 48de0e4d32..83a65dc149 100644 --- a/crates/miden-standards/src/testing/mock_account_code.rs +++ b/crates/miden-standards/src/testing/mock_account_code.rs @@ -8,10 +8,10 @@ const MOCK_FAUCET_CODE: &str = " use miden::protocol::faucet #! Inputs: [ASSET_KEY, ASSET_VALUE, pad(8)] - #! Outputs: [NEW_ASSET_VALUE, pad(12)] + #! Outputs: [pad(16)] pub proc mint exec.faucet::mint - # => [NEW_ASSET_VALUE, pad(12)] + # => [pad(16)] end #! Inputs: [ASSET_KEY, ASSET_VALUE, pad(8)] diff --git a/crates/miden-standards/src/testing/mock_util_lib.rs b/crates/miden-standards/src/testing/mock_util_lib.rs index 9d6eebe6cd..7fce87a5c3 100644 --- a/crates/miden-standards/src/testing/mock_util_lib.rs +++ b/crates/miden-standards/src/testing/mock_util_lib.rs @@ -9,13 +9,14 @@ use crate::StandardsLib; const MOCK_UTIL_LIBRARY_CODE: &str = " use miden::protocol::output_note + use miden::protocol::note::NOTE_TYPE_PRIVATE use miden::standards::wallets::basic->wallet #! Inputs: [] #! Outputs: [note_idx] pub proc create_default_note push.1.2.3.4 # = RECIPIENT - push.2 # = NoteType::Private + push.NOTE_TYPE_PRIVATE # = NoteType::Private push.0 # = NoteTag # => [tag, note_type, RECIPIENT] diff --git a/crates/miden-standards/src/testing/note.rs b/crates/miden-standards/src/testing/note.rs index 240fd61be1..f52f33fb66 100644 --- a/crates/miden-standards/src/testing/note.rs +++ b/crates/miden-standards/src/testing/note.rs @@ -11,15 +11,16 @@ use miden_protocol::note::{ Note, NoteAssets, NoteAttachment, - NoteMetadata, + NoteAttachments, NoteRecipient, NoteScript, NoteStorage, NoteTag, NoteType, + PartialNoteMetadata, }; use miden_protocol::testing::note::DEFAULT_NOTE_SCRIPT; -use miden_protocol::vm::Package; +use miden_protocol::vm::{AdviceMap, Package}; use miden_protocol::{Felt, Word}; use rand::Rng; @@ -35,6 +36,7 @@ enum SourceCodeOrigin { source_manager: Arc, }, Package(Arc), + Script(NoteScript), } #[derive(Debug, Clone)] @@ -46,17 +48,18 @@ pub struct NoteBuilder { serial_num: Word, tag: NoteTag, code: String, - attachment: NoteAttachment, + attachments: NoteAttachments, + advice_map: AdviceMap, source_code: SourceCodeOrigin, } impl NoteBuilder { pub fn new(sender: AccountId, mut rng: T) -> Self { let serial_num = Word::from([ - Felt::new(rng.random()), - Felt::new(rng.random()), - Felt::new(rng.random()), - Felt::new(rng.random()), + Felt::new_unchecked(rng.random()), + Felt::new_unchecked(rng.random()), + Felt::new_unchecked(rng.random()), + Felt::new_unchecked(rng.random()), ]); Self { @@ -68,7 +71,8 @@ impl NoteBuilder { // The note tag is not under test, so we choose a value that is always valid. tag: NoteTag::with_account_target(sender), code: DEFAULT_NOTE_SCRIPT.to_string(), - attachment: NoteAttachment::default(), + attachments: NoteAttachments::default(), + advice_map: AdviceMap::default(), source_code: SourceCodeOrigin::Masm { dyn_libraries: Vec::new(), source_manager: Arc::new(DefaultSourceManager::default()), @@ -114,9 +118,18 @@ impl NoteBuilder { self } - /// Overwrites the attachment. + /// Appends an attachment to the existing attachments. pub fn attachment(mut self, attachment: impl Into) -> Self { - self.attachment = attachment.into(); + let mut attachments = core::mem::take(&mut self.attachments).into_vec(); + attachments.push(attachment.into()); + self.attachments = + NoteAttachments::new(attachments).expect("number of attachments exceeds maximum"); + self + } + + /// Sets the advice map entries that will be added to the compiled note script. + pub fn advice_map(mut self, advice_map: AdviceMap) -> Self { + self.advice_map = advice_map; self } @@ -133,6 +146,9 @@ impl NoteBuilder { SourceCodeOrigin::Package(_) => { panic!("dynamic libraries cannot be set on a package") }, + SourceCodeOrigin::Script(_) => { + panic!("dynamic libraries cannot be set on a precompiled script") + }, } self } @@ -145,6 +161,9 @@ impl NoteBuilder { SourceCodeOrigin::Package(_) => { panic!("source manager cannot be set on a package") }, + SourceCodeOrigin::Script(_) => { + panic!("source manager cannot be set on a precompiled script") + }, } self } @@ -155,6 +174,12 @@ impl NoteBuilder { self } + /// Sets the note script to a precompiled [`NoteScript`], skipping compilation in `build`. + pub fn script(mut self, script: NoteScript) -> Self { + self.source_code = SourceCodeOrigin::Script(script); + self + } + pub fn build(self) -> Result { let note_script = match self.source_code { SourceCodeOrigin::Masm { dyn_libraries, source_manager } => { @@ -184,15 +209,16 @@ impl NoteBuilder { .expect("note script should compile") }, SourceCodeOrigin::Package(package) => NoteScript::from_package(&package)?, + SourceCodeOrigin::Script(script) => script, }; + let note_script = note_script.with_advice_map(self.advice_map); + let vault = NoteAssets::new(self.assets)?; - let metadata = NoteMetadata::new(self.sender, self.note_type) - .with_tag(self.tag) - .with_attachment(self.attachment); + let metadata = PartialNoteMetadata::new(self.sender, self.note_type).with_tag(self.tag); let storage = NoteStorage::new(self.storage)?; let recipient = NoteRecipient::new(self.serial_num, note_script, storage); - Ok(Note::new(vault, metadata, recipient)) + Ok(Note::with_attachments(vault, metadata, recipient, self.attachments)) } } diff --git a/crates/miden-standards/src/utils/mod.rs b/crates/miden-standards/src/utils/mod.rs index d245b85214..c4a10cd7f4 100644 --- a/crates/miden-standards/src/utils/mod.rs +++ b/crates/miden-standards/src/utils/mod.rs @@ -1 +1,2 @@ -pub mod string; +mod string; +pub use string::{FixedWidthString, FixedWidthStringError}; diff --git a/crates/miden-standards/src/utils/string.rs b/crates/miden-standards/src/utils/string.rs index 5716961846..7307efb620 100644 --- a/crates/miden-standards/src/utils/string.rs +++ b/crates/miden-standards/src/utils/string.rs @@ -320,4 +320,27 @@ mod tests { let s: FixedWidthString<2> = FixedWidthString::default(); assert_eq!(s.as_str(), ""); } + + #[test] + fn empty_string_encodes_to_7_empty_words() { + // An empty FixedWidthString encodes to all-zero words because the length prefix is 0 + // and the rest of the buffer is zero-padded. This property is relied upon by + // `TokenMetadata::storage_slots` to encode absent optional fields as empty word slices. + let s = FixedWidthString::<7>::new("").unwrap(); + let words = s.to_words(); + assert_eq!(words.len(), 7); + for word in &words { + assert_eq!(*word, Word::default()); + } + } + + #[test] + fn empty_string_encodes_to_9_empty_words() { + let s = FixedWidthString::<9>::new("").unwrap(); + let words = s.to_words(); + assert_eq!(words.len(), 9); + for word in &words { + assert_eq!(*word, Word::default()); + } + } } diff --git a/crates/miden-testing/Cargo.toml b/crates/miden-testing/Cargo.toml index 28b86046ab..bf31b773ba 100644 --- a/crates/miden-testing/Cargo.toml +++ b/crates/miden-testing/Cargo.toml @@ -22,7 +22,6 @@ tx_context_debug = [] [dependencies] # Workspace dependencies -miden-agglayer = { features = ["testing"], workspace = true } miden-block-prover = { features = ["testing"], workspace = true } miden-protocol = { features = ["testing"], workspace = true } miden-standards = { features = ["testing"], workspace = true } @@ -30,7 +29,6 @@ miden-tx = { features = ["testing"], workspace = true } miden-tx-batch-prover = { features = ["testing"], workspace = true } # Miden dependencies -miden-assembly = { workspace = true } miden-core-lib = { workspace = true } miden-crypto = { workspace = true } miden-processor = { workspace = true } @@ -45,11 +43,12 @@ thiserror = { workspace = true } [dev-dependencies] anyhow = { features = ["backtrace", "std"], workspace = true } assert_matches = { workspace = true } -hex = { version = "0.4" } +miden-agglayer = { features = ["testing"], workspace = true } +miden-assembly = { workspace = true } miden-crypto = { workspace = true } miden-protocol = { features = ["std"], workspace = true } primitive-types = { workspace = true } rstest = { workspace = true } serde = { features = ["derive"], workspace = true } -serde_json = { features = ["arbitrary_precision"], version = "1.0" } +serde_json = { features = ["arbitrary_precision", "std"], workspace = true } tokio = { features = ["macros", "rt"], workspace = true } diff --git a/crates/miden-testing/src/asserts.rs b/crates/miden-testing/src/asserts.rs new file mode 100644 index 0000000000..bb2fb0ae7c --- /dev/null +++ b/crates/miden-testing/src/asserts.rs @@ -0,0 +1,112 @@ +//! Assertion macro for note-lifecycle checks in tests. + +use alloc::vec::Vec; + +use miden_protocol::account::AccountId; +use miden_protocol::asset::Asset; +use miden_protocol::note::NoteType; +use miden_protocol::transaction::ExecutedTransaction; + +/// Spec for [`assert_note_created!`]. Fields left as `None` are skipped. +#[doc(hidden)] +#[derive(Default, Debug, Clone)] +pub struct OutputNoteSpec { + pub note_type: Option, + pub sender: Option, + pub assets: Option>, +} + +/// Returns `true` if at least one output note in `tx` matches `spec`. +#[doc(hidden)] +pub fn check_output_note_created(tx: &ExecutedTransaction, spec: &OutputNoteSpec) -> bool { + tx.output_notes().iter().any(|note| { + if let Some(expected) = spec.note_type + && note.metadata().note_type() != expected + { + return false; + } + if let Some(expected) = spec.sender + && note.metadata().sender() != expected + { + return false; + } + if let Some(expected) = spec.assets.as_ref() { + let actual = note.assets(); + if actual.num_assets() != expected.len() { + return false; + } + // Each actual matches at most once (otherwise [A,A] would match [A,B]). + let mut consumed = vec![false; expected.len()]; + let matched = expected.iter().all(|exp| { + let slot = actual.iter().enumerate().find(|(i, a)| !consumed[*i] && *a == exp); + if let Some((i, _)) = slot { + consumed[i] = true; + true + } else { + false + } + }); + if !matched { + return false; + } + } + true + }) +} + +/// Asserts the tx emitted a note matching the spec. Fields are optional; unset ones are skipped. +/// +/// # Example +/// ```ignore +/// use miden_testing::assert_note_created; +/// use miden_protocol::note::NoteType; +/// +/// assert_note_created!( +/// executed_tx, +/// note_type: NoteType::Public, +/// sender: faucet.id(), +/// assets: [FungibleAsset::new(faucet.id(), amount)?.into()], +/// ); +/// ``` +#[macro_export] +macro_rules! assert_note_created { + ($tx:expr $(, $key:ident : $val:expr)* $(,)?) => {{ + #[allow(unused_mut)] + let mut spec = $crate::asserts::OutputNoteSpec::default(); + $( + $crate::__assert_note_created_field!(spec, $key, $val); + )* + let tx: &::miden_protocol::transaction::ExecutedTransaction = &$tx; + assert!( + $crate::asserts::check_output_note_created(tx, &spec), + "no output note matches spec: {:?}\n tx produced {} output note(s)", + spec, + tx.output_notes().num_notes(), + ); + }}; +} + +#[doc(hidden)] +#[macro_export] +macro_rules! __assert_note_created_field { + ($spec:ident,note_type, $val:expr) => { + $spec.note_type = ::core::option::Option::Some($val); + }; + ($spec:ident,sender, $val:expr) => { + $spec.sender = ::core::option::Option::Some($val); + }; + ($spec:ident,assets, $val:expr) => { + $spec.assets = ::core::option::Option::Some( + ::core::iter::IntoIterator::into_iter($val) + .map(::core::convert::Into::into) + .collect::<::alloc::vec::Vec<::miden_protocol::asset::Asset>>(), + ); + }; + ($spec:ident, $key:ident, $val:expr) => { + ::core::compile_error!(concat!( + "unknown field in assert_note_created!: `", + stringify!($key), + "`. Supported fields: note_type, sender, assets", + )); + }; +} diff --git a/crates/miden-testing/src/executor.rs b/crates/miden-testing/src/executor.rs index 6e0486d502..346922abdd 100644 --- a/crates/miden-testing/src/executor.rs +++ b/crates/miden-testing/src/executor.rs @@ -1,7 +1,7 @@ #[cfg(test)] use miden_processor::DefaultHost; use miden_processor::advice::AdviceInputs; -use miden_processor::{ExecutionOutput, FastProcessor, Host, Program, StackInputs}; +use miden_processor::{ExecutionError, ExecutionOutput, FastProcessor, Host, Program, StackInputs}; #[cfg(test)] use miden_protocol::assembly::Assembler; @@ -68,6 +68,8 @@ impl CodeExecutor { let processor = FastProcessor::new(stack_inputs) .with_advice(self.advice_inputs) + .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/proposed_batch.rs b/crates/miden-testing/src/kernel_tests/batch/proposed_batch.rs index 254af358f1..7dedd8ec34 100644 --- a/crates/miden-testing/src/kernel_tests/batch/proposed_batch.rs +++ b/crates/miden-testing/src/kernel_tests/batch/proposed_batch.rs @@ -4,12 +4,19 @@ use std::collections::BTreeMap; use anyhow::Context; use assert_matches::assert_matches; use miden_protocol::Word; -use miden_protocol::account::{Account, AccountId, AccountStorageMode}; +use miden_protocol::account::{Account, AccountId, AccountType}; use miden_protocol::batch::ProposedBatch; use miden_protocol::block::BlockNumber; use miden_protocol::crypto::merkle::MerkleError; use miden_protocol::errors::{BatchAccountUpdateError, ProposedBatchError}; -use miden_protocol::note::{Note, NoteType}; +use miden_protocol::note::{ + Note, + NoteAssets, + NoteAttachments, + NoteTag, + NoteType, + PartialNoteMetadata, +}; use miden_protocol::testing::account_id::AccountIdBuilder; use miden_protocol::transaction::{ InputNote, @@ -18,6 +25,7 @@ use miden_protocol::transaction::{ PartialBlockchain, RawOutputNote, }; +use miden_standards::note::P2idNoteStorage; use miden_standards::testing::account_component::MockAccountComponent; use miden_standards::testing::note::NoteBuilder; use rand::rngs::SmallRng; @@ -62,7 +70,7 @@ fn setup_chain() -> TestSetup { fn generate_account(chain: &mut MockChainBuilder) -> Account { let account_builder = Account::builder(rand::rng().random()) - .storage_mode(AccountStorageMode::Private) + .account_type(AccountType::Private) .with_component(MockAccountComponent::with_empty_slots()); chain .add_account_from_builder(Auth::IncrNonce, account_builder, AccountState::Exists) @@ -96,12 +104,12 @@ fn note_created_and_consumed_in_same_batch() -> anyhow::Result<()> { let note = mock_note(40); let tx1 = MockProvenTxBuilder::with_account(account1.id(), Word::empty(), account1.to_commitment()) - .ref_block_commitment(block1.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()) - .ref_block_commitment(block1.commitment()) + .reference_block(&block1) .unauthenticated_notes(vec![note.clone()]) .build()?; @@ -118,6 +126,107 @@ fn note_created_and_consumed_in_same_batch() -> anyhow::Result<()> { Ok(()) } +/// Notes with the same details but different metadata are not considered the same for batch +/// erasure. +#[test] +fn same_details_different_metadata_not_erased_from_batch() -> anyhow::Result<()> { + let TestSetup { mut chain, account1, account2, .. } = setup_chain(); + let block1 = chain.block_header(1); + let block2 = chain.prove_next_block()?; + + // create two notes with identical details (recipient, assets, attachments) but different + // metadata, so they have distinct note IDs + + let output_note = NoteBuilder::new(mock_account_id(7), SmallRng::from_seed([7; 32])) + .serial_number([1, 2, 3, 4u32].into()) + .tag(100) + .note_type(NoteType::Public) + .build()?; + + let input_note = Note::with_attachments( + output_note.assets().clone(), + output_note.metadata().partial_metadata().with_tag(NoteTag::from(200)), + output_note.recipient().clone(), + output_note.attachments().clone(), + ); + + let output_note_proven = RawOutputNote::Full(output_note.clone()).into_output_note().unwrap(); + + let tx1 = + MockProvenTxBuilder::with_account(account1.id(), Word::empty(), account1.to_commitment()) + .reference_block(&block1) + .output_notes(vec![output_note_proven.clone()]) + .build()?; + let tx2 = + MockProvenTxBuilder::with_account(account2.id(), Word::empty(), account2.to_commitment()) + .reference_block(&block1) + .unauthenticated_notes(vec![input_note.clone()]) + .build()?; + + let batch = ProposedBatch::new( + [tx1, tx2].into_iter().map(Arc::new).collect(), + block2.header().clone(), + chain.latest_partial_blockchain(), + BTreeMap::default(), + )?; + + assert_eq!( + batch.input_notes().clone().into_vec(), + vec![InputNoteCommitment::from(&InputNote::unauthenticated(input_note))], + ); + assert_eq!(batch.output_notes()[0], output_note_proven); + + Ok(()) +} + +/// Two standards P2ID output notes with identical details but different metadata should both appear +/// in the batch. +#[test] +fn two_p2id_inputs_same_details_different_metadata_in_same_batch() -> anyhow::Result<()> { + let TestSetup { mut chain, account1, account2, .. } = setup_chain(); + let block1 = chain.block_header(1); + let block2 = chain.prove_next_block()?; + + let serial_num = Word::from([11, 22, 33, 44u32]); + let recipient = P2idNoteStorage::new(account2.id()).into_recipient(serial_num); + + let note_300 = Note::with_attachments( + NoteAssets::default(), + PartialNoteMetadata::new(account1.id(), NoteType::Public).with_tag(NoteTag::from(300)), + recipient.clone(), + NoteAttachments::default(), + ); + let note_301 = Note::with_attachments( + NoteAssets::default(), + PartialNoteMetadata::new(account1.id(), NoteType::Public).with_tag(NoteTag::from(301)), + recipient, + NoteAttachments::default(), + ); + + // Only metadata should be different. + assert_eq!(note_300.assets(), note_301.assets()); + assert_ne!(note_300.metadata(), note_301.metadata()); + assert_eq!(note_300.recipient(), note_301.recipient()); + assert_eq!(note_300.attachments(), note_301.attachments()); + + let tx = + MockProvenTxBuilder::with_account(account1.id(), Word::empty(), account1.to_commitment()) + .reference_block(&block1) + .authenticated_notes(vec![note_300.clone(), note_301.clone()]) + .build()?; + + let batch = ProposedBatch::new( + vec![Arc::new(tx)], + block2.header().clone(), + chain.latest_partial_blockchain(), + BTreeMap::default(), + )?; + + assert_eq!(batch.input_notes().num_notes(), 2); + + Ok(()) +} + /// Tests that an error is returned if the same unauthenticated input note appears multiple /// times in different transactions. #[test] @@ -128,12 +237,12 @@ fn duplicate_unauthenticated_input_notes() -> anyhow::Result<()> { let note = mock_note(50); let tx1 = MockProvenTxBuilder::with_account(account1.id(), Word::empty(), account1.to_commitment()) - .ref_block_commitment(block1.commitment()) + .reference_block(&block1) .unauthenticated_notes(vec![note.clone()]) .build()?; let tx2 = MockProvenTxBuilder::with_account(account2.id(), Word::empty(), account2.to_commitment()) - .ref_block_commitment(block1.commitment()) + .reference_block(&block1) .unauthenticated_notes(vec![note.clone()]) .build()?; @@ -167,12 +276,12 @@ fn duplicate_authenticated_input_notes() -> anyhow::Result<()> { let tx1 = MockProvenTxBuilder::with_account(account1.id(), Word::empty(), account1.to_commitment()) - .ref_block_commitment(block1.commitment()) + .reference_block(&block1) .authenticated_notes(vec![note1.clone()]) .build()?; let tx2 = MockProvenTxBuilder::with_account(account2.id(), Word::empty(), account2.to_commitment()) - .ref_block_commitment(block1.commitment()) + .reference_block(&block1) .authenticated_notes(vec![note1.clone()]) .build()?; @@ -206,12 +315,12 @@ fn duplicate_mixed_input_notes() -> anyhow::Result<()> { let tx1 = MockProvenTxBuilder::with_account(account1.id(), Word::empty(), account1.to_commitment()) - .ref_block_commitment(block1.commitment()) + .reference_block(&block1) .unauthenticated_notes(vec![note1.clone()]) .build()?; let tx2 = MockProvenTxBuilder::with_account(account2.id(), Word::empty(), account2.to_commitment()) - .ref_block_commitment(block1.commitment()) + .reference_block(&block1) .authenticated_notes(vec![note1.clone()]) .build()?; @@ -245,12 +354,12 @@ fn duplicate_output_notes() -> anyhow::Result<()> { let note0 = mock_output_note(50); let tx1 = MockProvenTxBuilder::with_account(account1.id(), Word::empty(), account1.to_commitment()) - .ref_block_commitment(block1.commitment()) + .reference_block(&block1) .output_notes(vec![note0.clone()]) .build()?; let tx2 = MockProvenTxBuilder::with_account(account2.id(), Word::empty(), account2.to_commitment()) - .ref_block_commitment(block1.commitment()) + .reference_block(&block1) .output_notes(vec![note0.clone()]) .build()?; @@ -306,24 +415,18 @@ async fn unauthenticated_note_converted_to_authenticated() -> anyhow::Result<()> "block 1 should contain note1 and note2" ); assert!( - block1 - .body() - .output_notes() - .any(|(_, note)| note.to_commitment() == note1.commitment()), + block1.body().output_notes().any(|(_, note)| note.id() == note1.id()), "block 1 should contain note1" ); assert!( - block1 - .body() - .output_notes() - .any(|(_, note)| note.to_commitment() == note2.commitment()), + block1.body().output_notes().any(|(_, note)| note.id() == note2.id()), "block 1 should contain note2" ); // Consume the authenticated note as an unauthenticated one in the transaction. let tx1 = MockProvenTxBuilder::with_account(account1.id(), Word::empty(), account1.to_commitment()) - .ref_block_commitment(block2.header().commitment()) + .reference_block(block2.header()) .unauthenticated_notes(vec![note2.clone()]) .build()?; @@ -432,12 +535,12 @@ fn authenticated_note_created_in_same_batch() -> anyhow::Result<()> { let note0 = mock_note(50); let tx1 = MockProvenTxBuilder::with_account(account1.id(), Word::empty(), account1.to_commitment()) - .ref_block_commitment(block1.commitment()) + .reference_block(&block1) .output_notes(vec![RawOutputNote::Full(note0.clone()).into_output_note().unwrap()]) .build()?; let tx2 = MockProvenTxBuilder::with_account(account2.id(), Word::empty(), account2.to_commitment()) - .ref_block_commitment(block1.commitment()) + .reference_block(&block1) .authenticated_notes(vec![note1.clone()]) .build()?; @@ -469,18 +572,18 @@ fn multiple_transactions_against_same_account() -> anyhow::Result<()> { initial_state_commitment, account1.to_commitment(), ) - .ref_block_commitment(block1.commitment()) + .reference_block(&block1) .output_notes(vec![mock_output_note(0)]) .build()?; // Use some random hash as the final state commitment of tx2. - let final_state_commitment = mock_note(10).commitment(); + let final_state_commitment = mock_note(10).id().as_word(); let tx2 = MockProvenTxBuilder::with_account( account1.id(), account1.to_commitment(), final_state_commitment, ) - .ref_block_commitment(block1.commitment()) + .reference_block(&block1) .build()?; // Success: Transactions are correctly ordered. @@ -548,13 +651,13 @@ fn input_and_output_notes_commitment() -> anyhow::Result<()> { let tx1 = MockProvenTxBuilder::with_account(account1.id(), Word::empty(), account1.to_commitment()) - .ref_block_commitment(block1.commitment()) + .reference_block(&block1) .unauthenticated_notes(vec![note1.clone(), note5.clone()]) .output_notes(vec![note0.clone()]) .build()?; let tx2 = MockProvenTxBuilder::with_account(account2.id(), Word::empty(), account2.to_commitment()) - .ref_block_commitment(block1.commitment()) + .reference_block(&block1) .unauthenticated_notes(vec![note4.clone(), note6.clone()]) .output_notes(vec![ RawOutputNote::Full(note1.clone()).into_output_note().unwrap(), @@ -602,14 +705,14 @@ fn batch_expiration() -> anyhow::Result<()> { let tx1 = MockProvenTxBuilder::with_account(account1.id(), Word::empty(), account1.to_commitment()) - .ref_block_commitment(block1.commitment()) + .reference_block(&block1) .expiration_block_num(BlockNumber::from(35)) .build()?; // This transaction has the smallest valid expiration block num that allows it to still be // included in the batch. let tx2 = MockProvenTxBuilder::with_account(account2.id(), Word::empty(), account2.to_commitment()) - .ref_block_commitment(block1.commitment()) + .reference_block(&block1) .expiration_block_num(block1.block_num() + 1) .build()?; @@ -633,7 +736,7 @@ fn duplicate_transaction() -> anyhow::Result<()> { let tx1 = MockProvenTxBuilder::with_account(account1.id(), Word::empty(), account1.to_commitment()) - .ref_block_commitment(block1.commitment()) + .reference_block(&block1) .expiration_block_num(BlockNumber::from(35)) .build()?; @@ -663,13 +766,13 @@ fn circular_note_dependency() -> anyhow::Result<()> { let tx1 = MockProvenTxBuilder::with_account(account1.id(), Word::empty(), account1.to_commitment()) - .ref_block_commitment(block1.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()) - .ref_block_commitment(block1.commitment()) + .reference_block(&block1) .unauthenticated_notes(vec![note_y.clone()]) .output_notes(vec![RawOutputNote::Full(note_x.clone()).into_output_note().unwrap()]) .build()?; @@ -696,12 +799,12 @@ fn expired_transaction() -> anyhow::Result<()> { // This transaction expired at the batch's reference block. let tx1 = MockProvenTxBuilder::with_account(account1.id(), Word::empty(), account1.to_commitment()) - .ref_block_commitment(block1.commitment()) + .reference_block(&block1) .expiration_block_num(block1.block_num()) .build()?; let tx2 = MockProvenTxBuilder::with_account(account2.id(), Word::empty(), account2.to_commitment()) - .ref_block_commitment(block1.commitment()) + .reference_block(&block1) .expiration_block_num(block1.block_num() + 3) .build()?; @@ -744,7 +847,7 @@ fn noop_tx_before_state_updating_tx_against_same_account() -> anyhow::Result<()> account1.to_commitment(), account1.to_commitment(), ) - .ref_block_commitment(block1.commitment()) + .reference_block(&block1) .authenticated_notes(vec![note1]) .output_notes(vec![RawOutputNote::Full(note.clone()).into_output_note().unwrap()]) .build()?; @@ -760,7 +863,7 @@ fn noop_tx_before_state_updating_tx_against_same_account() -> anyhow::Result<()> account1.to_commitment(), random_final_state_commitment, ) - .ref_block_commitment(block1.commitment()) + .reference_block(&block1) .unauthenticated_notes(vec![note.clone()]) .build()?; @@ -778,6 +881,116 @@ fn noop_tx_before_state_updating_tx_against_same_account() -> anyhow::Result<()> Ok(()) } +/// Tests that a transaction with a ref_block_commitment that does not match the commitment of the +/// block at the declared ref_block_num in the partial blockchain is rejected. +/// +/// The test uses two independent MockChain instances so that the same block 1 has different block +/// commitments on each chain. A transaction built against chain_a's block 1 is then included in a +/// batch whose partial blockchain comes from chain_b, which has a different commitment for block 1. +#[test] +fn mismatched_ref_block_commitment_rejected() -> anyhow::Result<()> { + let account_builder = Account::builder([42; 32]) + .account_type(AccountType::Private) + .with_component(MockAccountComponent::with_empty_slots()); + + // Build chain_a with the account. + let mut builder1 = MockChain::builder(); + let account_a = builder1.add_account_from_builder( + Auth::IncrNonce, + account_builder.clone(), + AccountState::Exists, + )?; + let mut chain_a = builder1.build()?; + let chain_a_block1 = chain_a.prove_next_block()?; + + // Build chain_b with the exact same account. + let mut builder2 = MockChain::builder(); + let account_b = builder2.add_account_from_builder( + Auth::IncrNonce, + account_builder, + AccountState::Exists, + )?; + let mut chain_b = builder2.build()?; + let chain_b_block1 = chain_b.prove_next_block()?; + let chain_b_block2 = chain_b.prove_next_block()?; + + // Sanity checks: same account, different block commitments at block 1. + assert_eq!( + account_a.to_commitment(), + account_b.to_commitment(), + "accounts should have the same commitment" + ); + assert_ne!( + chain_a_block1.header().commitment(), + chain_b_block1.header().commitment(), + "block 1 should have different commitments on the two chains" + ); + + // Build a transaction that references chain_a's block 1. This means the transaction was + // executed against a chain state that is incompatible with chain_b. + let tx = + MockProvenTxBuilder::with_account(account_a.id(), Word::empty(), account_a.to_commitment()) + .reference_block(chain_a_block1.header()) + .build()?; + + // 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( + vec![Arc::new(tx.clone())], + chain_b_block2.header().clone(), + chain_b.latest_partial_blockchain(), + BTreeMap::default(), + ) + .unwrap_err(); + + assert_matches!( + result, + ProposedBatchError::TransactionReferenceBlockCommitmentMismatch { + transaction_id, block_num, expected_block_commitment, actual_block_commitment + } => { + assert_eq!(transaction_id, tx.id()); + assert_eq!(block_num, tx.ref_block_num()); + assert_eq!(actual_block_commitment, tx.ref_block_commitment()); + assert_eq!(expected_block_commitment, chain_b_block1.header().commitment()); + } + ); + + // Make sure the same error occurs when the block referenced by the transaction is the same as + // the batch reference block. + let (ref_block, partial_blockchain) = chain_b.selective_partial_blockchain( + chain_b_block1.header().block_num(), + [BlockNumber::GENESIS], + )?; + assert_eq!( + ref_block.block_num(), + tx.ref_block_num(), + "tx and batch ref block num should match" + ); + + let result = ProposedBatch::new( + vec![Arc::new(tx.clone())], + ref_block.clone(), + partial_blockchain, + BTreeMap::default(), + ) + .unwrap_err(); + + assert_matches!( + result, + ProposedBatchError::TransactionReferenceBlockCommitmentMismatch { + transaction_id, block_num, expected_block_commitment, actual_block_commitment + } => { + assert_eq!(transaction_id, tx.id()); + assert_eq!(block_num, tx.ref_block_num()); + assert_eq!(actual_block_commitment, tx.ref_block_commitment()); + assert_eq!(expected_block_commitment, ref_block.commitment()); + } + ); + + Ok(()) +} + /// Tests that a NOOP transaction with state commitments X -> X against account A can appear /// _after_ a state-updating transaction with state commitments X -> Y against account A. #[test] @@ -795,7 +1008,7 @@ fn noop_tx_after_state_updating_tx_against_same_account() -> anyhow::Result<()> account1.to_commitment(), random_final_state_commitment, ) - .ref_block_commitment(block1.commitment()) + .reference_block(&block1) .unauthenticated_notes(vec![note.clone()]) .build()?; @@ -805,7 +1018,7 @@ fn noop_tx_after_state_updating_tx_against_same_account() -> anyhow::Result<()> random_final_state_commitment, random_final_state_commitment, ) - .ref_block_commitment(block1.commitment()) + .reference_block(&block1) .authenticated_notes(vec![note1]) .output_notes(vec![RawOutputNote::Full(note.clone()).into_output_note().unwrap()]) .build()?; 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 abf6b8809e..62429a20d1 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 @@ -5,7 +5,7 @@ use miden_protocol::Word; use miden_protocol::account::AccountId; use miden_protocol::account::delta::AccountUpdateDetails; use miden_protocol::asset::FungibleAsset; -use miden_protocol::block::BlockNumber; +use miden_protocol::block::{BlockHeader, BlockNumber}; use miden_protocol::crypto::merkle::SparseMerklePath; use miden_protocol::note::{Note, NoteInclusionProof, Nullifier}; use miden_protocol::transaction::{ @@ -22,7 +22,7 @@ pub struct MockProvenTxBuilder { account_id: AccountId, initial_account_commitment: Word, final_account_commitment: Word, - ref_block_commitment: Option, + reference_block: Option<(BlockNumber, Word)>, fee: FungibleAsset, expiration_block_num: BlockNumber, output_notes: Option>, @@ -42,7 +42,7 @@ impl MockProvenTxBuilder { account_id, initial_account_commitment, final_account_commitment, - ref_block_commitment: None, + reference_block: None, fee: FungibleAsset::mock(50).unwrap_fungible(), expiration_block_num: BlockNumber::from(u32::MAX), output_notes: None, @@ -96,8 +96,8 @@ impl MockProvenTxBuilder { /// Sets the transaction's block reference. #[must_use] - pub fn ref_block_commitment(mut self, ref_block_commitment: Word) -> Self { - self.ref_block_commitment = Some(ref_block_commitment); + pub fn reference_block(mut self, ref_block: &BlockHeader) -> Self { + self.reference_block = Some((ref_block.block_num(), ref_block.commitment())); self } @@ -124,12 +124,15 @@ impl MockProvenTxBuilder { ) .context("failed to build account update")?; + let (ref_block_num, ref_block_commitment) = + self.reference_block.unwrap_or((BlockNumber::GENESIS, Word::empty())); + ProvenTransaction::new( account_update, input_note_commitments, self.output_notes.unwrap_or_default(), - BlockNumber::from(0), - self.ref_block_commitment.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/block/proposed_block_errors.rs b/crates/miden-testing/src/kernel_tests/block/proposed_block_errors.rs index 4a68ef1f30..4cdb62da64 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 @@ -9,7 +9,7 @@ use miden_protocol::asset::FungibleAsset; use miden_protocol::block::{BlockInputs, BlockNumber, ProposedBlock}; use miden_protocol::crypto::merkle::SparseMerklePath; use miden_protocol::errors::ProposedBlockError; -use miden_protocol::note::{NoteAttachment, NoteInclusionProof, NoteType}; +use miden_protocol::note::{NoteAttachments, NoteInclusionProof, NoteType}; use miden_standards::note::P2idNote; use miden_tx::LocalTransactionProver; @@ -207,7 +207,10 @@ async fn proposed_block_fails_on_partial_blockchain_and_prev_block_inconsistency // Add an invalid value making the chain length equal to block2's number, but resulting in a // different chain commitment. - partial_blockchain.partial_mmr_mut().add(block2.header().nullifier_root(), true); + partial_blockchain + .partial_mmr_mut() + .add(block2.header().nullifier_root(), true) + .expect("partial mmr leaf count exceeds forest leaf bound"); let block_inputs = BlockInputs::new( block2.header().clone(), @@ -353,7 +356,7 @@ async fn proposed_block_fails_on_invalid_proof_or_missing_note_inclusion_referen account1.id(), vec![], NoteType::Private, - NoteAttachment::default(), + NoteAttachments::default(), builder.rng_mut(), )?; let spawn_note = builder.add_spawn_note([&p2id_note])?; 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 46cd8eb3a6..c12958795a 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 @@ -6,7 +6,7 @@ use anyhow::Context; use assert_matches::assert_matches; use miden_protocol::Felt; use miden_protocol::account::delta::AccountUpdateDetails; -use miden_protocol::account::{Account, AccountId, AccountStorageMode}; +use miden_protocol::account::{Account, AccountId, AccountType}; use miden_protocol::asset::FungibleAsset; use miden_protocol::block::{BlockInputs, ProposedBlock}; use miden_protocol::note::{Note, NoteType}; @@ -265,7 +265,7 @@ async fn proposed_block_with_batch_at_expiration_limit() -> anyhow::Result<()> { #[tokio::test] async fn noop_tx_and_state_updating_tx_against_same_account_in_same_block() -> anyhow::Result<()> { let account_builder = Account::builder(rand::rng().random()) - .storage_mode(AccountStorageMode::Public) + .account_type(AccountType::Public) .with_component(MockAccountComponent::with_empty_slots()); let mut builder = MockChain::builder(); @@ -333,9 +333,9 @@ async fn generate_conditional_tx( modify_storage: bool, ) -> ExecutedTransaction { let auth_args = [ - Felt::new(97), - Felt::new(98), - Felt::new(99), + Felt::new_unchecked(97), + Felt::new_unchecked(98), + Felt::new_unchecked(99), // increment nonce if modify_storage is true if modify_storage { Felt::ONE } else { Felt::ZERO }, ]; diff --git a/crates/miden-testing/src/kernel_tests/block/proven_block_success.rs b/crates/miden-testing/src/kernel_tests/block/proven_block_success.rs index 19aaea3df2..865d0a244c 100644 --- a/crates/miden-testing/src/kernel_tests/block/proven_block_success.rs +++ b/crates/miden-testing/src/kernel_tests/block/proven_block_success.rs @@ -8,7 +8,7 @@ use miden_protocol::batch::BatchNoteTree; use miden_protocol::block::account_tree::AccountTree; use miden_protocol::block::{BlockInputs, BlockNoteIndex, BlockNoteTree, ProposedBlock}; use miden_protocol::crypto::merkle::smt::Smt; -use miden_protocol::note::{NoteAttachment, NoteType}; +use miden_protocol::note::{NoteAttachments, NoteType}; use miden_protocol::transaction::InputNoteCommitment; use miden_standards::note::P2idNote; @@ -37,7 +37,7 @@ async fn proven_block_success() -> anyhow::Result<()> { account0.id(), vec![asset], NoteType::Private, - NoteAttachment::default(), + NoteAttachments::default(), builder.rng_mut(), )?; let output_note1 = P2idNote::create( @@ -45,7 +45,7 @@ async fn proven_block_success() -> anyhow::Result<()> { account1.id(), vec![asset], NoteType::Private, - NoteAttachment::default(), + NoteAttachments::default(), builder.rng_mut(), )?; let output_note2 = P2idNote::create( @@ -53,7 +53,7 @@ async fn proven_block_success() -> anyhow::Result<()> { account2.id(), vec![asset], NoteType::Private, - NoteAttachment::default(), + NoteAttachments::default(), builder.rng_mut(), )?; let output_note3 = P2idNote::create( @@ -61,7 +61,7 @@ async fn proven_block_success() -> anyhow::Result<()> { account3.id(), vec![asset], NoteType::Private, - NoteAttachment::default(), + NoteAttachments::default(), builder.rng_mut(), )?; @@ -112,11 +112,7 @@ async fn proven_block_success() -> anyhow::Result<()> { let expected_block_note_tree = BlockNoteTree::with_entries(batch0_iter.chain(batch1_iter).map( |(batch_idx, note_idx_in_batch, note)| { - ( - BlockNoteIndex::new(batch_idx, note_idx_in_batch).unwrap(), - note.id(), - note.metadata(), - ) + (BlockNoteIndex::new(batch_idx, note_idx_in_batch).unwrap(), note.into()) }, )) .unwrap(); @@ -209,7 +205,7 @@ async fn proven_block_success() -> anyhow::Result<()> { Ok(()) } -/// Tests that an unauthenticated note is erased when it is created in the same block. +/// Tests that an unauthenticated note is erased when it is consumed in the same block. /// /// The high level test setup is that there are four transactions split in two batches: /// tx0 (batch0): consume note0 -> create output_note0. @@ -342,10 +338,9 @@ async fn proven_block_erasing_unauthenticated_notes() -> anyhow::Result<()> { let actual_block_note_tree = proven_block.body().compute_block_note_tree(); // Remove the erased note to get the expected batch note tree. - let mut batch_tree = BatchNoteTree::with_contiguous_leaves( - batch0.output_notes().iter().map(|note| (note.id(), note.metadata())), - ) - .unwrap(); + let mut batch_tree = + BatchNoteTree::with_contiguous_leaves(batch0.output_notes().iter().map(Into::into)) + .unwrap(); batch_tree.remove(erased_note_idx as u64).unwrap(); let mut expected_block_note_tree = BlockNoteTree::empty(); 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 5f6ad5c110..503d67755d 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_account.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_account.rs @@ -17,7 +17,6 @@ use miden_protocol::account::{ AccountComponent, AccountId, AccountStorage, - AccountStorageMode, AccountType, StorageMap, StorageMapKey, @@ -31,11 +30,17 @@ use miden_protocol::account::{ use miden_protocol::assembly::diagnostics::NamedSource; use miden_protocol::assembly::diagnostics::reporting::PrintDiagnostic; use miden_protocol::assembly::{DefaultSourceManager, Library}; -use miden_protocol::asset::{Asset, AssetCallbacks, FungibleAsset}; +use miden_protocol::asset::{ + Asset, + AssetAmount, + AssetCallbackFlag, + AssetCallbacks, + AssetVaultKey, + FungibleAsset, +}; use miden_protocol::errors::tx_kernel::{ ERR_ACCOUNT_ID_SUFFIX_LEAST_SIGNIFICANT_BYTE_MUST_BE_ZERO, ERR_ACCOUNT_ID_SUFFIX_MOST_SIGNIFICANT_BIT_MUST_BE_ZERO, - ERR_ACCOUNT_ID_UNKNOWN_STORAGE_MODE, ERR_ACCOUNT_ID_UNKNOWN_VERSION, ERR_ACCOUNT_NONCE_AT_MAX, ERR_ACCOUNT_NONCE_CAN_ONLY_BE_INCREMENTED_ONCE, @@ -54,7 +59,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::faucets::BasicFungibleFaucet; +use miden_standards::account::faucets::{FungibleFaucet, TokenName}; use miden_standards::code_builder::CodeBuilder; use miden_standards::testing::account_component::MockAccountComponent; use miden_standards::testing::mock_account::MockAccountExt; @@ -164,64 +169,6 @@ pub async fn compute_commitment() -> anyhow::Result<()> { // ACCOUNT ID TESTS // ================================================================================================ -#[tokio::test] -async fn test_account_type() -> anyhow::Result<()> { - let procedures = vec![ - ("is_fungible_faucet", AccountType::FungibleFaucet), - ("is_non_fungible_faucet", AccountType::NonFungibleFaucet), - ("is_updatable_account", AccountType::RegularAccountUpdatableCode), - ("is_immutable_account", AccountType::RegularAccountImmutableCode), - ]; - - let test_cases = [ - ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE, - ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE, - ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, - ACCOUNT_ID_PRIVATE_NON_FUNGIBLE_FAUCET, - ]; - - for (procedure, expected_type) in procedures { - let mut has_type = false; - - for account_id in test_cases.iter() { - let account_id = AccountId::try_from(*account_id).unwrap(); - - let code = format!( - " - use $kernel::account_id - - begin - exec.account_id::{procedure} - end - " - ); - - let exec_output = CodeExecutor::with_default_host() - .stack_inputs(StackInputs::new(&[account_id.prefix().as_felt()])?) - .run(&code) - .await?; - - let type_matches = account_id.account_type() == expected_type; - let expected_result = if type_matches { Felt::ONE } else { Felt::ZERO }; - has_type |= type_matches; - - assert_eq!( - exec_output.get_stack_element(0), - expected_result, - "Rust and Masm check on account type diverge. proc: {} account_id: {} account_type: {:?} expected_type: {:?}", - procedure, - account_id, - account_id.account_type(), - expected_type, - ); - } - - assert!(has_type, "missing test for type {expected_type:?}"); - } - - Ok(()) -} - #[tokio::test] async fn test_account_validate_id() -> anyhow::Result<()> { let test_cases = [ @@ -230,8 +177,13 @@ async fn test_account_validate_id() -> anyhow::Result<()> { (ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, None), (ACCOUNT_ID_PRIVATE_NON_FUNGIBLE_FAUCET, None), ( - // Set version to a non-zero value (10). - ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE | (0x0a << 64), + // The zero account ID should be invalid by construction. + 0, + Some(ERR_ACCOUNT_ID_UNKNOWN_VERSION), + ), + ( + // Set version to another unsupported value (10). + (ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE & !(0x0f << 64)) | (0x0a << 64), Some(ERR_ACCOUNT_ID_UNKNOWN_VERSION), ), ( @@ -239,11 +191,6 @@ async fn test_account_validate_id() -> anyhow::Result<()> { ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET | (0x80 << 56), Some(ERR_ACCOUNT_ID_SUFFIX_MOST_SIGNIFICANT_BIT_MUST_BE_ZERO), ), - ( - // Set storage mode to an unknown value (0b11). - ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE | (0b11 << (64 + 6)), - Some(ERR_ACCOUNT_ID_UNKNOWN_STORAGE_MODE), - ), ( // Set lower 8 bits to a non-zero value (1). ACCOUNT_ID_PRIVATE_NON_FUNGIBLE_FAUCET | 1, @@ -311,47 +258,6 @@ async fn test_account_validate_id() -> anyhow::Result<()> { Ok(()) } -#[tokio::test] -async fn test_is_faucet_procedure() -> anyhow::Result<()> { - let test_cases = [ - ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE, - ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE, - ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, - ACCOUNT_ID_PRIVATE_NON_FUNGIBLE_FAUCET, - ]; - - for account_id in test_cases.iter() { - let account_id = AccountId::try_from(*account_id).unwrap(); - - let code = format!( - " - use $kernel::account_id - - begin - push.{prefix} - exec.account_id::is_faucet - # => [is_faucet, account_id_prefix] - - # truncate the stack - swap drop - end - ", - prefix = account_id.prefix().as_felt(), - ); - - let exec_output = CodeExecutor::with_default_host().run(&code).await?; - - let is_faucet = account_id.is_faucet(); - assert_eq!( - exec_output.get_stack_element(0), - Felt::new(is_faucet as u64), - "Rust and MASM is_faucet diverged for account_id {account_id}" - ); - } - - Ok(()) -} - // ACCOUNT CODE TESTS // ================================================================================================ @@ -763,16 +669,13 @@ async fn test_set_map_item() -> anyhow::Result<()> { /// Tests that we can successfully create regular and faucet accounts with empty storage. #[tokio::test] async fn create_account_with_empty_storage_slots() -> anyhow::Result<()> { - for account_type in [AccountType::FungibleFaucet, AccountType::RegularAccountUpdatableCode] { - let account = AccountBuilder::new([5; 32]) - .account_type(account_type) - .with_auth_component(Auth::IncrNonce) - .with_component(MockAccountComponent::with_empty_slots()) - .build() - .context("failed to build account")?; - - TransactionContextBuilder::new(account).build()?.execute().await?; - } + let account = AccountBuilder::new([5; 32]) + .with_auth_component(Auth::IncrNonce) + .with_component(MockAccountComponent::with_empty_slots()) + .build() + .context("failed to build account")?; + + TransactionContextBuilder::new(account).build()?.execute().await?; Ok(()) } @@ -907,7 +810,7 @@ async fn prove_account_creation_with_non_empty_storage() -> anyhow::Result<()> { StorageSlot::with_map(slot_name2.clone(), StorageMap::with_entries(map_entries.clone())?); let account = AccountBuilder::new([6; 32]) - .storage_mode(AccountStorageMode::Public) + .account_type(AccountType::Public) .with_auth_component(Auth::IncrNonce) .with_component(MockAccountComponent::with_slots(vec![ slot0.clone(), @@ -922,7 +825,7 @@ 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::new(1)); + assert_eq!(tx.account_delta().nonce_delta(), Felt::ONE); assert_matches!( tx.account_delta().storage().get(&slot_name0).unwrap(), @@ -945,7 +848,7 @@ async fn prove_account_creation_with_non_empty_storage() -> anyhow::Result<()> { ); assert!(tx.account_delta().vault().is_empty()); - assert_eq!(tx.final_account().nonce(), Felt::new(1)); + assert_eq!(tx.final_account().nonce(), Felt::ONE); let proven_tx = LocalTransactionProver::default().prove(tx.clone()).await?; @@ -1083,30 +986,26 @@ async fn test_get_init_balance_addition() -> anyhow::Result<()> { // case 1: existing asset was added to the account // ------------------------------------------ - let initial_balance = account - .vault() - .get_balance(faucet_existing_asset) - .expect("faucet_id should be a fungible faucet ID"); + let asset_key = AssetVaultKey::new_fungible(faucet_existing_asset, AssetCallbackFlag::Disabled); + let initial_balance = account.vault().get_balance(asset_key)?.as_u64(); let add_existing_source = format!( r#" use miden::protocol::active_account begin - # push faucet ID prefix and suffix - push.{prefix}.{suffix} - # => [faucet_id_suffix, faucet_id_prefix] - # get the current asset balance - dup.1 dup.1 exec.active_account::get_balance - # => [final_balance, faucet_id_suffix, faucet_id_prefix] + push.{ASSET_KEY} + exec.active_account::get_balance + # => [final_balance] # assert final balance is correct push.{final_balance} assert_eq.err="final balance is incorrect" - # => [faucet_id_suffix, faucet_id_prefix] + # => [] # get the initial asset balance + push.{ASSET_KEY} exec.active_account::get_initial_balance # => [init_balance] @@ -1115,10 +1014,9 @@ async fn test_get_init_balance_addition() -> anyhow::Result<()> { assert_eq.err="initial balance is incorrect" end "#, - suffix = faucet_existing_asset.suffix(), - prefix = faucet_existing_asset.prefix().as_felt(), + ASSET_KEY = asset_key.to_word(), final_balance = - initial_balance + fungible_asset_for_note_existing.unwrap_fungible().amount(), + initial_balance + fungible_asset_for_note_existing.unwrap_fungible().amount().as_u64(), ); let tx_script = CodeBuilder::default().compile_tx_script(add_existing_source)?; @@ -1137,30 +1035,26 @@ async fn test_get_init_balance_addition() -> anyhow::Result<()> { // case 2: new asset was added to the account // ------------------------------------------ - let initial_balance = account - .vault() - .get_balance(faucet_new_asset) - .expect("faucet_id should be a fungible faucet ID"); + let asset_key = AssetVaultKey::new_fungible(faucet_new_asset, AssetCallbackFlag::Disabled); + let initial_balance = account.vault().get_balance(asset_key)?.as_u64(); let add_new_source = format!( r#" use miden::protocol::active_account begin - # push faucet ID prefix and suffix - push.{prefix}.{suffix} - # => [faucet_id_suffix, faucet_id_prefix] - # get the current asset balance - dup.1 dup.1 exec.active_account::get_balance - # => [final_balance, faucet_id_suffix, faucet_id_prefix] + push.{ASSET_KEY} + exec.active_account::get_balance + # => [final_balance] # assert final balance is correct push.{final_balance} assert_eq.err="final balance is incorrect" - # => [faucet_id_suffix, faucet_id_prefix] + # => [] # get the initial asset balance + push.{ASSET_KEY} exec.active_account::get_initial_balance # => [init_balance] @@ -1169,9 +1063,9 @@ async fn test_get_init_balance_addition() -> anyhow::Result<()> { assert_eq.err="initial balance is incorrect" end "#, - suffix = faucet_new_asset.suffix(), - prefix = faucet_new_asset.prefix().as_felt(), - final_balance = initial_balance + fungible_asset_for_note_new.unwrap_fungible().amount(), + ASSET_KEY = asset_key.to_word(), + final_balance = + initial_balance + fungible_asset_for_note_new.unwrap_fungible().amount().as_u64(), ); let tx_script = CodeBuilder::default().compile_tx_script(add_new_source)?; @@ -1215,10 +1109,8 @@ async fn test_get_init_balance_subtraction() -> anyhow::Result<()> { let mut mock_chain = builder.build()?; mock_chain.prove_next_block()?; - let initial_balance = account - .vault() - .get_balance(faucet_existing_asset) - .expect("faucet_id should be a fungible faucet ID"); + let asset_key = AssetVaultKey::new_fungible(faucet_existing_asset, AssetCallbackFlag::Disabled); + let initial_balance = account.vault().get_balance(asset_key)?.as_u64(); let expected_output_note = create_public_p2any_note(ACCOUNT_ID_SENDER.try_into()?, [fungible_asset_for_note_existing]); @@ -1239,20 +1131,18 @@ async fn test_get_init_balance_subtraction() -> anyhow::Result<()> { exec.util::move_asset_to_note # => [] - # push faucet ID prefix and suffix - push.{prefix}.{suffix} - # => [faucet_id_suffix, faucet_id_prefix] - # get the current asset balance - dup.1 dup.1 exec.active_account::get_balance - # => [final_balance, faucet_id_suffix, faucet_id_prefix] + push.{ASSET_KEY} + exec.active_account::get_balance + # => [final_balance] # assert final balance is correct push.{final_balance} assert_eq.err="final balance is incorrect" - # => [faucet_id_suffix, faucet_id_prefix] + # => [] # get the initial asset balance + push.{ASSET_KEY} exec.active_account::get_initial_balance # => [init_balance] @@ -1263,10 +1153,9 @@ async fn test_get_init_balance_subtraction() -> anyhow::Result<()> { "#, REMOVED_ASSET_KEY = fungible_asset_for_note_existing.to_key_word(), REMOVED_ASSET_VALUE = fungible_asset_for_note_existing.to_value_word(), - suffix = faucet_existing_asset.suffix(), - prefix = faucet_existing_asset.prefix().as_felt(), + ASSET_KEY = asset_key.to_word(), final_balance = - initial_balance - fungible_asset_for_note_existing.unwrap_fungible().amount(), + initial_balance - fungible_asset_for_note_existing.unwrap_fungible().amount().as_u64(), ); let tx_script = CodeBuilder::with_mock_libraries().compile_tx_script(remove_existing_source)?; @@ -1376,11 +1265,8 @@ async fn test_get_init_asset() -> anyhow::Result<()> { async fn test_authenticate_and_track_procedure() -> anyhow::Result<()> { let mock_component = MockAccountComponent::with_empty_slots(); - let account_code = AccountCode::from_components( - &[Auth::IncrNonce.into(), mock_component.into()], - AccountType::RegularAccountUpdatableCode, - ) - .unwrap(); + let account_code = + AccountCode::from_components(&[Auth::IncrNonce.into(), mock_component.into()]).unwrap(); let tc_0 = *account_code.procedures()[1].mast_root(); let tc_1 = *account_code.procedures()[2].mast_root(); @@ -1579,7 +1465,7 @@ 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::new(1)); + assert_eq!(executed_tx.account_delta().nonce_delta(), 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); @@ -1701,12 +1587,16 @@ async fn test_faucet_has_callbacks( #[case] callback_slots: Vec, #[case] expected_has_callbacks: bool, ) -> anyhow::Result<()> { - let basic_faucet = BasicFungibleFaucet::new("CBK".try_into()?, 8, Felt::new(1_000_000))?; + let faucet = FungibleFaucet::builder() + .name(TokenName::new("").expect("empty string is a valid token name")) + .symbol("CBK".try_into()?) + .decimals(8) + .max_supply(AssetAmount::from(1_000_000u32)) + .build()?; let account = AccountBuilder::new([1u8; 32]) - .storage_mode(AccountStorageMode::Public) - .account_type(AccountType::FungibleFaucet) - .with_component(basic_faucet) + .account_type(AccountType::Public) + .with_component(faucet) .with_component(MockAccountComponent::with_slots(callback_slots)) .with_auth_component(Auth::IncrNonce) .build_existing()?; @@ -1956,7 +1846,7 @@ async fn incrementing_nonce_overflow_fails() -> anyhow::Result<()> { .context("failed to build account")?; // Increment the nonce to the maximum felt value. The nonce is already 1, so we increment by // modulus - 2. - account.increment_nonce(Felt::new(Felt::ORDER_U64 - 2))?; + account.increment_nonce(Felt::new_unchecked(Felt::ORDER_U64 - 2))?; let result = TransactionContextBuilder::new(account).build()?.execute().await; 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 index 8c4dfed2e1..ea737c16a5 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_account_delta.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_account_delta.rs @@ -11,7 +11,6 @@ use miden_protocol::account::{ AccountDelta, AccountId, AccountStorage, - AccountStorageMode, AccountType, StorageMap, StorageMapKey, @@ -101,7 +100,7 @@ async fn delta_nonce() -> anyhow::Result<()> { .await .context("failed to execute transaction")?; - assert_eq!(executed_tx.account_delta().nonce_delta(), Felt::new(1)); + assert_eq!(executed_tx.account_delta().nonce_delta(), Felt::ONE); Ok(()) } @@ -394,35 +393,29 @@ 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::FungibleFaucet) + .account_type(AccountType::Private) .build_with_seed(rand::random()); let faucet1: AccountId = AccountIdBuilder::new() - .account_type(AccountType::FungibleFaucet) - .build_with_seed(rand::random()); - let faucet2: AccountId = AccountIdBuilder::new() - .account_type(AccountType::FungibleFaucet) - .build_with_seed(rand::random()); - let faucet3: AccountId = AccountIdBuilder::new() - .account_type(AccountType::FungibleFaucet) - .build_with_seed(rand::random()); - let faucet4: AccountId = AccountIdBuilder::new() - .account_type(AccountType::FungibleFaucet) + .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)?; + 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)?; + 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)?; + let removed_asset3 = FungibleAsset::new(faucet3, FungibleAsset::MAX_AMOUNT.as_u64())?; let TestSetup { mock_chain, account_id, notes } = setup_test( [], @@ -472,13 +465,17 @@ async fn fungible_asset_delta() -> anyhow::Result<()> { .account_delta() .vault() .added_assets() - .map(|asset| (asset.unwrap_fungible().faucet_id(), asset.unwrap_fungible().amount())) + .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())) + .map(|asset| { + (asset.unwrap_fungible().faucet_id(), asset.unwrap_fungible().amount().as_u64()) + }) .collect::>(); assert_eq!(added_assets.len(), 2); @@ -486,17 +483,20 @@ async fn fungible_asset_delta() -> anyhow::Result<()> { assert_eq!( added_assets.remove(&original_asset2.faucet_id()).unwrap(), - added_asset2.amount() - removed_asset2.amount() + 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!(added_assets.remove(&added_asset4.faucet_id()).unwrap(), added_asset4.amount()); assert_eq!( removed_assets.remove(&original_asset0.faucet_id()).unwrap(), - removed_asset0.amount() - added_asset0.amount() + removed_asset0.amount().as_u64() - added_asset0.amount().as_u64() ); assert_eq!( removed_assets.remove(&original_asset3.faucet_id()).unwrap(), - removed_asset3.amount() + removed_asset3.amount().as_u64() ); Ok(()) @@ -512,35 +512,31 @@ 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() - .account_type(AccountType::NonFungibleFaucet) - .build_with_seed(rng.random()); - let faucet1: AccountId = AccountIdBuilder::new() - .account_type(AccountType::NonFungibleFaucet) - .build_with_seed(rng.random()); + 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::NonFungibleFaucet) + .account_type(AccountType::Public) .build_with_seed(rng.random()); let faucet3: AccountId = AccountIdBuilder::new() - .account_type(AccountType::NonFungibleFaucet) + .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))?; @@ -768,7 +764,7 @@ async fn asset_and_storage_delta() -> anyhow::Result<()> { // nonce delta // -------------------------------------------------------------------------------------------- - assert_eq!(executed_transaction.account_delta().nonce_delta(), Felt::new(1)); + assert_eq!(executed_transaction.account_delta().nonce_delta(), Felt::ONE); // storage delta // -------------------------------------------------------------------------------------------- @@ -851,7 +847,7 @@ async fn proven_tx_storage_maps_matches_executed_tx_for_new_account() -> anyhow: // Build a public account so the proven transaction includes the account update. let account = AccountBuilder::new([1; 32]) - .storage_mode(AccountStorageMode::Public) + .account_type(AccountType::Public) .with_auth_component(Auth::IncrNonce) .with_component(MockAccountComponent::with_slots(vec![ AccountStorage::mock_value_slot0(), @@ -953,8 +949,7 @@ async fn delta_for_new_account_retains_empty_value_storage_slots() -> anyhow::Re let slot_value2 = Word::from([1, 2, 3, 4u32]); let mut account = AccountBuilder::new(rand::random()) - .account_type(AccountType::RegularAccountUpdatableCode) - .storage_mode(AccountStorageMode::Network) + .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), @@ -1006,8 +1001,7 @@ async fn delta_for_new_account_retains_empty_map_storage_slots() -> anyhow::Resu let slot_name0 = StorageSlotName::mock(0); let mut account = AccountBuilder::new(rand::random()) - .account_type(AccountType::RegularAccountUpdatableCode) - .storage_mode(AccountStorageMode::Network) + .account_type(AccountType::Public) .with_component(MockAccountComponent::with_slots(vec![StorageSlot::with_empty_map( slot_name0.clone(), )])) diff --git a/crates/miden-testing/src/kernel_tests/tx/test_account_interface.rs b/crates/miden-testing/src/kernel_tests/tx/test_account_interface.rs index 2d2496a47e..ba09751323 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_account_interface.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_account_interface.rs @@ -12,11 +12,11 @@ use miden_protocol::field::PrimeField64; use miden_protocol::note::{ Note, NoteAssets, - NoteMetadata, NoteRecipient, NoteStorage, NoteTag, NoteType, + PartialNoteMetadata, }; use miden_protocol::testing::account_id::{ ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE, @@ -36,13 +36,7 @@ use miden_standards::note::{ use miden_standards::testing::mock_account::MockAccountExt; use miden_standards::testing::note::NoteBuilder; use miden_tx::auth::UnreachableAuth; -use miden_tx::{ - FailedNote, - NoteConsumptionChecker, - NoteConsumptionInfo, - TransactionExecutor, - TransactionExecutorError, -}; +use miden_tx::{NoteConsumptionChecker, TransactionExecutor, TransactionExecutorError}; use rand::{Rng, SeedableRng}; use rand_chacha::ChaCha20Rng; @@ -90,15 +84,16 @@ async fn check_note_consumability_standard_notes_success() -> anyhow::Result<()> .check_notes_consumability(target_account_id, block_ref, notes.clone(), tx_args) .await?; - assert_matches!(consumption_info, NoteConsumptionInfo { successful, failed, .. } => { - assert_eq!(successful.len(), notes.len()); + let successful = consumption_info.successful(); + let failed = consumption_info.failed(); - // we asserted that `successful` and `notes` vectors have the same length, so it's safe to - // check their equality that way - successful.iter().for_each(|successful_note| assert!(notes.contains(successful_note))); + assert_eq!(successful.len(), notes.len()); - assert!(failed.is_empty()); - }); + // we asserted that `successful` and `notes` vectors have the same length, so it's safe to + // check their equality that way + successful.iter().for_each(|s| assert!(notes.contains(s.note()))); + + assert!(failed.is_empty()); Ok(()) } @@ -137,14 +132,15 @@ async fn check_note_consumability_custom_notes_success( .check_notes_consumability(account_id, block_ref, notes.clone(), tx_args) .await?; - assert_matches!(consumption_info, NoteConsumptionInfo { successful, failed, .. }=> { - if notes.is_empty() { - assert!(successful.is_empty()); - assert!(failed.is_empty()); - } else { - assert_eq!(successful.len(), notes.len()); - } - }); + let successful = consumption_info.successful(); + let failed = consumption_info.failed(); + + if notes.is_empty() { + assert!(successful.is_empty()); + assert!(failed.is_empty()); + } else { + assert_eq!(successful.len(), notes.len()); + } Ok(()) } @@ -216,49 +212,41 @@ async fn check_note_consumability_partial_success() -> anyhow::Result<()> { .check_notes_consumability(account_id, block_ref, notes, tx_args) .await?; + let successful = consumption_info.successful(); + let failed = consumption_info.failed(); + + assert_eq!(failed.len(), 2); + assert_eq!(successful.len(), 3); + + // First failing note. + let first_failed = failed.first().expect("first failed notes should exist"); + assert_matches!( + first_failed.error(), + TransactionExecutorError::TransactionProgramExecutionFailed( + ExecutionError::OperationError { + err: miden_processor::operation::OperationError::DivideByZero, + .. + } + ) + ); + assert_eq!(first_failed.note().id(), failing_note_2.id()); + + // Second failing note. + let second_failed = failed.get(1).expect("second failed note should exist"); assert_matches!( - consumption_info, - NoteConsumptionInfo { - successful, - failed - } => { - assert_eq!(failed.len(), 2); - assert_eq!(successful.len(), 3); - - // First failing note. - assert_matches!( - failed.first().expect("first failed notes should exist"), - FailedNote { - note, - error: TransactionExecutorError::TransactionProgramExecutionFailed( - ExecutionError::OperationError { err: miden_processor::operation::OperationError::DivideByZero, .. }) - } => { - assert_eq!( - note.id(), - failing_note_2.id(), - ); - } - ); - // Second failing note. - assert_matches!( - failed.get(1).expect("second failed note should exist"), - FailedNote { - note, - error: TransactionExecutorError::TransactionProgramExecutionFailed( - ExecutionError::OperationError { err: miden_processor::operation::OperationError::DivideByZero, .. }) - } => { - assert_eq!( - note.id(), - failing_note_1.id(), - ); - } - ); - // Successful notes. - assert_eq!( - [successful[0].id(), successful[1].id(), successful[2].id()], - [successful_note_2.id(), successful_note_1.id(), successful_note_3.id()], - ); + second_failed.error(), + TransactionExecutorError::TransactionProgramExecutionFailed( + ExecutionError::OperationError { + err: miden_processor::operation::OperationError::DivideByZero, + .. } + ) + ); + assert_eq!(second_failed.note().id(), failing_note_1.id()); + // Successful notes. + assert_eq!( + [successful[0].note().id(), successful[1].note().id(), successful[2].note().id()], + [successful_note_2.id(), successful_note_1.id(), successful_note_3.id()], ); Ok(()) } @@ -298,16 +286,11 @@ async fn check_note_consumability_epilogue_failure() -> anyhow::Result<()> { .check_notes_consumability(account_id, block_ref, notes, tx_args) .await?; - assert_matches!( - consumption_info, - NoteConsumptionInfo { - successful, - failed - } => { - assert!(successful.is_empty()); - assert_eq!(failed.len(), 1); - } - ); + let successful = consumption_info.successful(); + let failed = consumption_info.failed(); + + assert!(successful.is_empty()); + assert_eq!(failed.len(), 1); Ok(()) } @@ -380,49 +363,42 @@ async fn check_note_consumability_epilogue_failure_with_new_combination() -> any .check_notes_consumability(account_id, block_ref, notes, tx_args) .await?; + let successful = consumption_info.successful(); + let failed = consumption_info.failed(); + + assert_eq!(failed.len(), 2); + assert_eq!(successful.len(), 3); + + // First failing note should be the note that does not cause epilogue failure. + let first_failed = failed.first().expect("first failed notes should exist"); assert_matches!( - consumption_info, - NoteConsumptionInfo { - successful, - failed - } => { - assert_eq!(failed.len(), 2); - assert_eq!(successful.len(), 3); - - // First failing note should be the note that does not cause epilogue failure. - assert_matches!( - failed.first().expect("first failed notes should exist"), - FailedNote { - note, - error: TransactionExecutorError::TransactionProgramExecutionFailed( - ExecutionError::OperationError { err: miden_processor::operation::OperationError::DivideByZero, .. }) - } => { - assert_eq!( - note.id(), - failing_note_1.id(), - ); - } - ); - // Second failing note should be the note that causes epilogue failure. - assert_matches!( - failed.get(1).expect("second failed note should exist"), - FailedNote { - note, - error: TransactionExecutorError::TransactionProgramExecutionFailed( - ExecutionError::OperationError { err: miden_processor::operation::OperationError::FailedAssertion { .. }, .. }) - } => { - assert_eq!( - note.id(), - fail_epilogue_note.id(), - ); - } - ); - // Successful notes. - assert_eq!( - [successful[0].id(), successful[1].id(), successful[2].id()], - [successful_note_1.id(), successful_note_2.id(), successful_note_3.id()], - ); + first_failed.error(), + TransactionExecutorError::TransactionProgramExecutionFailed( + ExecutionError::OperationError { + err: miden_processor::operation::OperationError::DivideByZero, + .. } + ) + ); + assert_eq!(first_failed.note().id(), failing_note_1.id()); + + // Second failing note should be the note that causes epilogue failure. + let second_failed = failed.get(1).expect("second failed note should exist"); + assert_matches!( + second_failed.error(), + TransactionExecutorError::TransactionProgramExecutionFailed( + ExecutionError::OperationError { + err: miden_processor::operation::OperationError::FailedAssertion { .. }, + .. + } + ) + ); + assert_eq!(second_failed.note().id(), fail_epilogue_note.id()); + + // Successful notes. + assert_eq!( + [successful[0].note().id(), successful[1].note().id(), successful[2].note().id()], + [successful_note_1.id(), successful_note_2.id(), successful_note_3.id()], ); Ok(()) } @@ -810,11 +786,11 @@ fn create_p2ide_note_with_storage( let recipient = NoteRecipient::new( serial_num, note_script, - NoteStorage::new(storage.into_iter().map(Felt::new).collect()).unwrap(), + NoteStorage::new(storage.into_iter().map(Felt::new_unchecked).collect()).unwrap(), ); let tag = NoteTag::with_account_target(sender); - let metadata = NoteMetadata::new(sender, NoteType::Public).with_tag(tag); + let metadata = PartialNoteMetadata::new(sender, NoteType::Public).with_tag(tag); Note::new(NoteAssets::default(), metadata, recipient) } 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 b54fe1c9a9..d8621cc6e0 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 @@ -1,19 +1,21 @@ use alloc::string::String; use anyhow::Context; -use miden_protocol::account::Account; use miden_protocol::account::auth::AuthScheme; +use miden_protocol::account::{Account, AccountId}; use miden_protocol::asset::FungibleAsset; use miden_protocol::crypto::rand::{FeltRng, RandomCoin}; use miden_protocol::errors::tx_kernel::ERR_NOTE_ATTEMPT_TO_ACCESS_NOTE_METADATA_WHILE_NO_NOTE_BEING_PROCESSED; use miden_protocol::note::{ Note, NoteAssets, - NoteMetadata, + NoteAttachment, + NoteAttachmentScheme, NoteRecipient, NoteStorage, NoteTag, NoteType, + PartialNoteMetadata, }; use miden_protocol::testing::account_id::{ ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE, @@ -25,7 +27,10 @@ use miden_protocol::transaction::memory::{ASSET_SIZE, ASSET_VALUE_OFFSET}; use miden_protocol::{EMPTY_WORD, Felt, ONE, WORD_SIZE, Word}; use miden_standards::code_builder::CodeBuilder; use miden_standards::testing::mock_account::MockAccountExt; +use miden_standards::testing::note::NoteBuilder; +use rstest::rstest; +use super::StackInputs; use crate::kernel_tests::tx::ExecutionOutputExt; use crate::utils::create_public_p2any_note; use crate::{ @@ -105,13 +110,9 @@ async fn test_active_note_get_metadata() -> anyhow::Result<()> { # get the metadata of the active note exec.active_note::get_metadata - # => [NOTE_ATTACHMENT, METADATA_HEADER] + # => [METADATA] - push.{NOTE_ATTACHMENT} - assert_eqw.err="note 0 has incorrect note attachment" - # => [METADATA_HEADER] - - push.{METADATA_HEADER} + push.{METADATA} assert_eqw.err="note 0 has incorrect metadata" # => [] @@ -119,9 +120,7 @@ async fn test_active_note_get_metadata() -> anyhow::Result<()> { swapw dropw end "#, - METADATA_HEADER = tx_context.input_notes().get_note(0).note().metadata().to_header_word(), - NOTE_ATTACHMENT = - tx_context.input_notes().get_note(0).note().metadata().to_attachment_word() + METADATA = tx_context.input_notes().get_note(0).note().metadata().to_metadata_word(), ); tx_context.execute_code(&code).await?; @@ -169,6 +168,87 @@ async fn test_active_note_get_sender() -> anyhow::Result<()> { Ok(()) } +#[rstest::rstest] +#[case(NoteType::Public)] +#[case(NoteType::Private)] +#[tokio::test] +async fn test_active_note_get_note_type(#[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 = miden_protocol::crypto::rand::RandomCoin::new(Word::default()); + let input_note = crate::utils::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 code = " + use $kernel::prologue + use $kernel::note->note_internal + use miden::protocol::active_note + use miden::protocol::note + + begin + exec.prologue::prepare_transaction + exec.note_internal::prepare_note + dropw dropw dropw dropw + + exec.active_note::get_metadata + # => [METADATA] + + exec.note::metadata_into_note_type + # => [note_type] + + # truncate the stack + swapw dropw + end + "; + + let exec_output = tx_context.execute_code(code).await?; + + let actual_note_type = NoteType::try_from(exec_output.get_stack_element(0)) + .expect("stack element should be a valid note type"); + assert_eq!(actual_note_type, note_type); + + Ok(()) +} + +#[tokio::test] +async fn test_metadata_into_tag() -> anyhow::Result<()> { + use miden_protocol::note::{NoteAttachments, NoteMetadata}; + + use crate::executor::CodeExecutor; + + let sender_id: AccountId = ACCOUNT_ID_SENDER.try_into()?; + let tag = NoteTag::new(0xabcd_1234); + let partial_metadata = PartialNoteMetadata::new(sender_id, NoteType::Public).with_tag(tag); + let metadata = NoteMetadata::new(partial_metadata, &NoteAttachments::default()); + let metadata_word = metadata.to_metadata_word(); + + let code = " + use miden::protocol::note + + begin + exec.note::metadata_into_tag + end + "; + + let exec_output = CodeExecutor::with_default_host() + .stack_inputs(StackInputs::new(metadata_word.as_slice())?) + .run(code) + .await?; + + assert_eq!(exec_output.get_stack_element(0), Felt::from(tag.as_u32())); + + Ok(()) +} + #[tokio::test] async fn test_active_note_get_assets() -> anyhow::Result<()> { // Creates a mockchain with an account and a note that it can consume @@ -248,8 +328,8 @@ async fn test_active_note_get_assets() -> anyhow::Result<()> { # assert the number of assets is correct eq.{note_0_num_assets} assert.err="unexpected num assets for note 0" - # assert the pointer is returned - dup eq.{DEST_POINTER_NOTE_0} assert.err="unexpected dest ptr for note 0" + # push the dest pointer for asset assertions + push.{DEST_POINTER_NOTE_0} # asset memory assertions {NOTE_0_ASSET_ASSERTIONS} @@ -271,8 +351,8 @@ async fn test_active_note_get_assets() -> anyhow::Result<()> { # assert the number of assets is correct eq.{note_1_num_assets} assert.err="unexpected num assets for note 1" - # assert the pointer is returned - dup eq.{DEST_POINTER_NOTE_1} assert.err="unexpected dest ptr for note 1" + # push the dest pointer for asset assertions + push.{DEST_POINTER_NOTE_1} # asset memory assertions {NOTE_1_ASSET_ASSERTIONS} @@ -378,12 +458,13 @@ async fn test_active_note_get_storage() -> anyhow::Result<()> { # => [] push.{NOTE_0_PTR} exec.active_note::get_storage - # => [num_storage_items, dest_ptr] + # => [num_storage_items] eq.{num_storage_items} assert.err="unexpected num_storage_items" - # => [dest_ptr] + # => [] - dup eq.{NOTE_0_PTR} assert.err="unexpected dest ptr" + # push the dest pointer for storage assertions + push.{NOTE_0_PTR} # => [dest_ptr] # apply note 1 storage assertions @@ -423,7 +504,7 @@ async fn test_active_note_get_exactly_8_inputs() -> anyhow::Result<()> { // prepare note data let serial_num = RandomCoin::new(Word::from([4u32; 4])).draw_word(); let tag = NoteTag::with_account_target(target_id); - let metadata = NoteMetadata::new(sender_id, NoteType::Public).with_tag(tag); + let metadata = PartialNoteMetadata::new(sender_id, NoteType::Public).with_tag(tag); let vault = NoteAssets::new(vec![]).context("failed to create input note assets")?; let note_script = CodeBuilder::default() .compile_note_script(DEFAULT_NOTE_SCRIPT) @@ -436,13 +517,13 @@ async fn test_active_note_get_exactly_8_inputs() -> anyhow::Result<()> { note_script, NoteStorage::new(vec![ ONE, - Felt::new(2), - Felt::new(3), - Felt::new(4), - Felt::new(5), - Felt::new(6), - Felt::new(7), - Felt::new(8), + Felt::new_unchecked(2), + Felt::new_unchecked(3), + Felt::new_unchecked(4), + Felt::new_unchecked(5), + Felt::new_unchecked(6), + Felt::new_unchecked(7), + Felt::new_unchecked(8), ]) .context("failed to create note storage")?, ); @@ -555,6 +636,130 @@ async fn test_active_note_get_script_root() -> anyhow::Result<()> { let exec_output = tx_context.execute_code(code).await?; let script_root = tx_context.input_notes().get_note(0).note().script().root(); - assert_eq!(exec_output.get_stack_word(0), script_root); + assert_eq!(exec_output.get_stack_word(0), script_root.into()); + Ok(()) +} + +/// Tests `{input_note, active_note}::find_attachment` for both the found and not-found cases. +/// +/// Setup: create an input note with two word attachments (schemes 10 and 20), then call +/// `find_attachment` on the active/input note. +/// +/// - `found`: search for scheme 10 → is_found=1, attachment_idx=0. +/// - `not_found`: search for scheme 99 → is_found=0. +#[rstest] +#[case::active_note_scheme_found(None, "active_note", 20, true)] +#[case::active_note_scheme_not_found(None, "active_note", 99, false)] +// uses note index 1 +#[case::input_note_scheme_found(Some(1), "input_note", 20, true)] +// uses note index 1 +#[case::input_note_scheme_not_found(Some(1), "input_note", 99, false)] +#[tokio::test] +async fn test_note_find_attachment( + #[case] note_idx: Option, + #[case] module_under_test: &str, + #[case] search_scheme: u16, + #[case] expected_found: bool, +) -> anyhow::Result<()> { + let word_0 = Word::from([3, 4, 5, 6u32]); + let word_1 = Word::from([7, 8, 9, 10u32]); + let scheme_0 = NoteAttachmentScheme::new(10)?; + let scheme_1 = NoteAttachmentScheme::new(20)?; + + let tx_context = { + let account = + Account::mock(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE, Auth::IncrNonce); + + let mut rng = RandomCoin::new(Word::from([1, 2, 3, 4u32])); + // Add a random first note so we test with note_index != 0. + let input_note0 = NoteBuilder::new(account.id(), &mut rng).build()?; + let input_note1 = NoteBuilder::new(account.id(), &mut rng) + .note_type(NoteType::Public) + .attachment(NoteAttachment::with_word(scheme_0, word_0)) + .attachment(NoteAttachment::with_word(scheme_1, word_1)) + .build()?; + + TransactionContextBuilder::new(account) + .extend_input_notes(vec![input_note0, input_note1]) + .build()? + }; + assert_eq!(tx_context.tx_inputs().input_notes().num_notes(), 2); + + let setup_find_attachment = match note_idx { + Some(idx) => format!("push.{idx}"), + // for active_note module, we don't need to push anything + None => "".into(), + }; + + // Setup stack for write_attachment_to_memory based on whether note_idx is needed. + // active_note needs [dest_ptr, attachment_idx] + // input_note needs [dest_ptr, attachment_idx, note_index] + let setup_write_stack = match note_idx { + Some(idx) => format!("push.{idx} swap push.DEST_PTR"), + None => "push.DEST_PTR".into(), + }; + + let code = format!( + r#" + use $kernel::prologue + use $kernel::note->note_internal + use miden::protocol::active_note + use miden::protocol::input_note + + const DEST_PTR = 0x1000 + + begin + exec.prologue::prepare_transaction + exec.note_internal::increment_active_input_note_ptr drop + # prepare note 1 + exec.note_internal::prepare_note + dropw dropw dropw dropw + + # push note index, if any + {setup_find_attachment} + # search for the target scheme on the active note + push.{search_scheme} + # => [attachment_scheme] + exec.{module_under_test}::find_attachment + # => [is_found, attachment_idx] + + # assert is_found matches expectation + push.{expected_found} + assert_eq.err="is_found mismatch" + # => [attachment_idx] + + push.{expected_found} + if.true + # found path: write attachment to memory using returned index + {setup_write_stack} + exec.{module_under_test}::write_attachment_to_memory + # => [num_words] + + eq.1 assert.err="expected num_words=1" + # => [] + + # read the word from memory and assert it matches + padw push.DEST_PTR mem_loadw_le + # => [ATTACHMENT_WORD] + + push.{EXPECTED_WORD} + assert_eqw.err="attachment data mismatch" + # => [] + else + # not-found path: drop the (undefined) attachment_idx + drop + # => [] + end + + # truncate the stack + swapw dropw + end + "#, + expected_found = expected_found as u8, + EXPECTED_WORD = word_1, + ); + + tx_context.execute_code(&code).await?; + Ok(()) } 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 15e49bf6cd..5cbc4b351d 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_asset.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_asset.rs @@ -1,6 +1,7 @@ use miden_protocol::account::AccountId; use miden_protocol::asset::{ AssetCallbackFlag, + AssetComposition, AssetId, AssetVaultKey, FungibleAsset, @@ -8,14 +9,17 @@ use miden_protocol::asset::{ NonFungibleAssetDetails, }; use miden_protocol::errors::MasmError; +use miden_protocol::errors::protocol::ERR_VAULT_ASSET_METADATA_NON_ZERO_RESERVED_BITS; use miden_protocol::errors::tx_kernel::{ ERR_FUNGIBLE_ASSET_AMOUNT_EXCEEDS_MAX_AMOUNT, - ERR_FUNGIBLE_ASSET_KEY_ACCOUNT_ID_MUST_BE_FUNGIBLE, ERR_FUNGIBLE_ASSET_KEY_ASSET_ID_MUST_BE_ZERO, + ERR_FUNGIBLE_ASSET_KEY_COMPOSITION_MUST_BE_FUNGIBLE, ERR_FUNGIBLE_ASSET_VALUE_MOST_SIGNIFICANT_ELEMENTS_MUST_BE_ZERO, ERR_NON_FUNGIBLE_ASSET_ID_PREFIX_MUST_MATCH_HASH1, ERR_NON_FUNGIBLE_ASSET_ID_SUFFIX_MUST_MATCH_HASH0, - ERR_NON_FUNGIBLE_ASSET_KEY_ACCOUNT_ID_MUST_BE_NON_FUNGIBLE, + ERR_NON_FUNGIBLE_ASSET_KEY_COMPOSITION_MUST_BE_NON_FUNGIBLE, + ERR_VAULT_ASSET_METADATA_NOT_U32, + ERR_VAULT_ASSET_METADATA_UNKNOWN_COMPOSITION, }; use miden_protocol::testing::account_id::{ ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, @@ -72,8 +76,8 @@ 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(), - )?; - let non_fungible_asset = NonFungibleAsset::new(&non_fungible_asset_details)?; + ); + let non_fungible_asset = NonFungibleAsset::new(&non_fungible_asset_details); let code = format!( " @@ -102,26 +106,40 @@ async fn test_create_non_fungible_asset_succeeds() -> anyhow::Result<()> { Ok(()) } +const METADATA_BYTE_NONE: u64 = 0; +const METADATA_BYTE_FUNGIBLE: u64 = AssetComposition::Fungible as u64; + +/// Returns the third element of a synthesised asset key, packing the faucet ID suffix with the +/// given metadata byte (lower 8 bits). +fn key_suffix_with_metadata(account_id: AccountId, metadata_byte: u64) -> Felt { + Felt::try_from(account_id.suffix().as_canonical_u64() | metadata_byte) + .expect("metadata byte only occupies the lower 8 bits") +} + #[rstest::rstest] #[case::account_is_not_non_fungible_faucet( ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE.try_into()?, AssetId::default(), - ERR_NON_FUNGIBLE_ASSET_KEY_ACCOUNT_ID_MUST_BE_NON_FUNGIBLE + METADATA_BYTE_FUNGIBLE, + ERR_NON_FUNGIBLE_ASSET_KEY_COMPOSITION_MUST_BE_NON_FUNGIBLE )] #[case::asset_id_suffix_mismatch( ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET.try_into()?, AssetId::new(Felt::from(0u32), Felt::from(3u32)), + METADATA_BYTE_NONE, ERR_NON_FUNGIBLE_ASSET_ID_SUFFIX_MUST_MATCH_HASH0 )] #[case::asset_id_prefix_mismatch( ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET.try_into()?, AssetId::new(Felt::from(2u32), Felt::from(0u32)), + METADATA_BYTE_NONE, ERR_NON_FUNGIBLE_ASSET_ID_PREFIX_MUST_MATCH_HASH1 )] #[tokio::test] async fn test_validate_non_fungible_asset( #[case] account_id: AccountId, #[case] asset_id: AssetId, + #[case] metadata_byte: u64, #[case] expected_err: MasmError, ) -> anyhow::Result<()> { let code = format!( @@ -147,7 +165,7 @@ async fn test_validate_non_fungible_asset( ", asset_id_suffix = asset_id.suffix(), asset_id_prefix = asset_id.prefix(), - account_id_suffix = account_id.suffix(), + account_id_suffix = key_suffix_with_metadata(account_id, metadata_byte), account_id_prefix = account_id.prefix().as_felt(), ); @@ -163,30 +181,35 @@ async fn test_validate_non_fungible_asset( ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE.try_into()?, AssetId::default(), Word::empty(), - ERR_FUNGIBLE_ASSET_KEY_ACCOUNT_ID_MUST_BE_FUNGIBLE + METADATA_BYTE_NONE, + ERR_FUNGIBLE_ASSET_KEY_COMPOSITION_MUST_BE_FUNGIBLE )] #[case::asset_id_suffix_is_non_zero( ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into()?, AssetId::new(Felt::from(1u32), Felt::from(0u32)), Word::empty(), + METADATA_BYTE_FUNGIBLE, ERR_FUNGIBLE_ASSET_KEY_ASSET_ID_MUST_BE_ZERO )] #[case::asset_id_prefix_is_non_zero( ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into()?, AssetId::new(Felt::from(0u32), Felt::from(1u32)), Word::empty(), + METADATA_BYTE_FUNGIBLE, ERR_FUNGIBLE_ASSET_KEY_ASSET_ID_MUST_BE_ZERO )] #[case::non_amount_value_is_non_zero( ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into()?, AssetId::default(), Word::from([0, 1, 0, 0u32]), + METADATA_BYTE_FUNGIBLE, ERR_FUNGIBLE_ASSET_VALUE_MOST_SIGNIFICANT_ELEMENTS_MUST_BE_ZERO )] #[case::amount_exceeds_max( ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into()?, AssetId::default(), - Word::try_from([FungibleAsset::MAX_AMOUNT + 1, 0, 0, 0])?, + Word::try_from([FungibleAsset::MAX_AMOUNT.as_u64() + 1, 0, 0, 0])?, + METADATA_BYTE_FUNGIBLE, ERR_FUNGIBLE_ASSET_AMOUNT_EXCEEDS_MAX_AMOUNT )] #[tokio::test] @@ -194,6 +217,7 @@ async fn test_validate_fungible_asset( #[case] account_id: AccountId, #[case] asset_id: AssetId, #[case] asset_value: Word, + #[case] metadata_byte: u64, #[case] expected_err: MasmError, ) -> anyhow::Result<()> { let code = format!( @@ -216,7 +240,7 @@ async fn test_validate_fungible_asset( ", asset_id_suffix = asset_id.suffix(), asset_id_prefix = asset_id.prefix(), - account_id_suffix = account_id.suffix(), + account_id_suffix = key_suffix_with_metadata(account_id, metadata_byte), account_id_prefix = account_id.prefix().as_felt(), ASSET_VALUE = asset_value, ); @@ -229,12 +253,60 @@ async fn test_validate_fungible_asset( } #[rstest::rstest] -#[case::without_callbacks(AssetCallbackFlag::Disabled)] -#[case::with_callbacks(AssetCallbackFlag::Enabled)] +// Valid: composition=None, callbacks=disabled. +#[case::valid_none(0, None)] +// Valid: composition=Fungible, callbacks=disabled. +#[case::valid_fungible(METADATA_BYTE_FUNGIBLE, None)] +// Valid: composition=Custom, callbacks=disabled. +#[case::valid_custom(AssetComposition::Custom as u64, None)] +// Valid: composition=None, callbacks=enabled (bit 2 set). +#[case::valid_callbacks_enabled((AssetCallbackFlag::Enabled as u64) << 2, None)] +// Metadata is not a valid u32 (does not fit in 32 bits). +#[case::not_u32(u32::MAX as u64 + 1, Some(ERR_VAULT_ASSET_METADATA_NOT_U32))] +// Metadata is not a valid byte. +#[case::not_u8(u16::MAX as u64, Some(ERR_VAULT_ASSET_METADATA_NON_ZERO_RESERVED_BITS))] +// Reserved bit 3 is set. +#[case::reserved_bits_set(0b1000, Some(ERR_VAULT_ASSET_METADATA_NON_ZERO_RESERVED_BITS))] +// Composition value 3 is the unused bit pattern within the 2-bit field. +#[case::unknown_composition(0b011, Some(ERR_VAULT_ASSET_METADATA_UNKNOWN_COMPOSITION))] #[tokio::test] -async fn test_key_to_asset_metadata(#[case] callbacks: AssetCallbackFlag) -> anyhow::Result<()> { +async fn test_validate_asset_metadata( + #[case] asset_metadata: u64, + #[case] expected_err: Option, +) -> anyhow::Result<()> { + let code = format!( + " + use $kernel::asset + + begin + push.{asset_metadata} + exec.asset::validate_metadata + end + " + ); + + let exec_result = CodeExecutor::with_default_host().run(&code).await; + + match expected_err { + Some(err) => assert_execution_error!(exec_result, err), + None => { + exec_result.expect("validate_metadata should accept valid metadata"); + }, + } + + Ok(()) +} + +#[rstest::rstest] +#[case::fungible_without_callbacks(AssetComposition::Fungible, AssetCallbackFlag::Disabled)] +#[case::non_fungible_with_callbacks(AssetComposition::None, AssetCallbackFlag::Enabled)] +#[tokio::test] +async fn test_key_to_asset_metadata( + #[case] composition: AssetComposition, + #[case] callbacks: AssetCallbackFlag, +) -> anyhow::Result<()> { let faucet_id = AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?; - let vault_key = AssetVaultKey::new(AssetId::default(), faucet_id, callbacks)?; + let vault_key = AssetVaultKey::new(AssetId::default(), faucet_id, composition, callbacks)?; let code = format!( " @@ -245,9 +317,16 @@ async fn test_key_to_asset_metadata(#[case] callbacks: AssetCallbackFlag) -> any exec.asset::key_to_callbacks_enabled # => [callbacks_enabled, ASSET_KEY] + movdn.4 + exec.asset::key_to_composition + # => [asset_composition, ASSET_KEY, callbacks_enabled] + + movdn.4 dropw + # => [asset_composition, callbacks_enabled] + # truncate stack - swapw dropw swap drop - # => [callbacks_enabled] + swapw dropw + # => [asset_composition, callbacks_enabled] end ", ASSET_KEY = vault_key.to_word(), @@ -257,8 +336,13 @@ async fn test_key_to_asset_metadata(#[case] callbacks: AssetCallbackFlag) -> any assert_eq!( exec_output.get_stack_element(0).as_canonical_u64(), + composition.as_u8() as u64, + "MASM asset::key_to_composition returned wrong value for {composition:?}" + ); + assert_eq!( + exec_output.get_stack_element(1).as_canonical_u64(), callbacks.as_u8() as u64, - "MASM key_to_asset_category returned wrong value for {callbacks:?}" + "MASM asset::key_to_callbacks_enabled returned wrong value for {callbacks:?}" ); Ok(()) 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 e4bdfefce9..d0ff5ea885 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 @@ -2,6 +2,7 @@ use assert_matches::assert_matches; use miden_protocol::account::AccountId; use miden_protocol::asset::{ Asset, + AssetCallbackFlag, AssetVaultKey, FungibleAsset, NonFungibleAsset, @@ -35,6 +36,7 @@ async fn get_balance_returns_correct_amount() -> anyhow::Result<()> { let tx_context = TransactionContextBuilder::with_existing_mock_account().build()?; let faucet_id: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap(); + let asset_key = AssetVaultKey::new_fungible(faucet_id, AssetCallbackFlag::Disabled); let code = format!( r#" use $kernel::prologue @@ -43,8 +45,7 @@ async fn get_balance_returns_correct_amount() -> anyhow::Result<()> { begin exec.prologue::prepare_transaction - push.{prefix} - push.{suffix} + push.{ASSET_KEY} exec.active_account::get_balance # => [balance] @@ -52,15 +53,14 @@ async fn get_balance_returns_correct_amount() -> anyhow::Result<()> { swap drop end "#, - prefix = faucet_id.prefix().as_felt(), - suffix = faucet_id.suffix(), + ASSET_KEY = asset_key.to_word(), ); let exec_output = tx_context.execute_code(&code).await?; assert_eq!( exec_output.get_stack_element(0).as_canonical_u64(), - tx_context.account().vault().get_balance(faucet_id).unwrap() + tx_context.account().vault().get_balance(asset_key)?.as_u64() ); Ok(()) @@ -71,7 +71,7 @@ async fn get_balance_returns_correct_amount() -> anyhow::Result<()> { async fn peek_asset_returns_correct_asset() -> anyhow::Result<()> { let tx_context = TransactionContextBuilder::with_existing_mock_account().build()?; let faucet_id: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap(); - let asset_key = AssetVaultKey::new_fungible(faucet_id).unwrap(); + let asset_key = AssetVaultKey::new_fungible(faucet_id, AssetCallbackFlag::Disabled); let code = format!( r#" @@ -120,6 +120,8 @@ 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 code = format!( " use $kernel::prologue @@ -127,12 +129,11 @@ async fn test_get_balance_non_fungible_fails() -> anyhow::Result<()> { begin exec.prologue::prepare_transaction - push.{prefix} push.{suffix} + push.{ASSET_KEY} exec.active_account::get_balance end ", - prefix = faucet_id.prefix().as_felt(), - suffix = faucet_id.suffix(), + ASSET_KEY = non_fungible_asset.to_key_word(), ); let exec_result = tx_context.execute_code(&code).await; @@ -180,7 +181,7 @@ async fn test_add_fungible_asset_success() -> anyhow::Result<()> { let tx_context = TransactionContextBuilder::with_existing_mock_account().build()?; 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 - FUNGIBLE_ASSET_AMOUNT; + let amount = FungibleAsset::MAX_AMOUNT.as_u64() - FUNGIBLE_ASSET_AMOUNT; let add_fungible_asset = FungibleAsset::new(faucet_id, amount)?; let code = format!( @@ -226,7 +227,7 @@ async fn test_add_non_fungible_asset_fail_overflow() -> 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 - FUNGIBLE_ASSET_AMOUNT + 1; + let amount = FungibleAsset::MAX_AMOUNT.as_u64() - FUNGIBLE_ASSET_AMOUNT + 1; let add_fungible_asset = FungibleAsset::new(faucet_id, amount)?; let code = format!( @@ -260,8 +261,8 @@ async fn test_add_non_fungible_asset_success() -> anyhow::Result<()> { 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]).unwrap(), - )?); + &NonFungibleAssetDetails::new(faucet_id, vec![1, 2, 3, 4, 5, 6, 7, 8]), + )); let code = format!( " @@ -303,9 +304,8 @@ async fn test_add_non_fungible_asset_fail_duplicate() -> anyhow::Result<()> { 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()).unwrap(); - let non_fungible_asset = - Asset::NonFungible(NonFungibleAsset::new(&non_fungible_asset_details).unwrap()); + NonFungibleAssetDetails::new(faucet_id, NON_FUNGIBLE_ASSET_DATA.to_vec()); + let non_fungible_asset = Asset::NonFungible(NonFungibleAsset::new(&non_fungible_asset_details)); let code = format!( " @@ -458,8 +458,8 @@ async fn test_remove_inexisting_non_fungible_asset_fails() -> anyhow::Result<()> let mut account_vault = tx_context.account().vault().clone(); let non_fungible_asset_details = - NonFungibleAssetDetails::new(faucet_id, NON_FUNGIBLE_ASSET_DATA.to_vec()).unwrap(); - let nonfungible = NonFungibleAsset::new(&non_fungible_asset_details).unwrap(); + NonFungibleAssetDetails::new(faucet_id, NON_FUNGIBLE_ASSET_DATA.to_vec()); + let nonfungible = NonFungibleAsset::new(&non_fungible_asset_details); let non_existent_non_fungible_asset = Asset::NonFungible(nonfungible); assert_matches!( @@ -502,9 +502,8 @@ async fn test_remove_non_fungible_asset_success() -> anyhow::Result<()> { 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()).unwrap(); - let non_fungible_asset = - Asset::NonFungible(NonFungibleAsset::new(&non_fungible_asset_details).unwrap()); + NonFungibleAssetDetails::new(faucet_id, NON_FUNGIBLE_ASSET_DATA.to_vec()); + let non_fungible_asset = Asset::NonFungible(NonFungibleAsset::new(&non_fungible_asset_details)); let code = format!( " @@ -545,7 +544,7 @@ async fn test_remove_non_fungible_asset_success() -> anyhow::Result<()> { #[tokio::test] async fn test_merge_fungible_asset_success() -> anyhow::Result<()> { let asset0 = FungibleAsset::mock(FUNGIBLE_ASSET_AMOUNT); - let asset1 = FungibleAsset::mock(FungibleAsset::MAX_AMOUNT - FUNGIBLE_ASSET_AMOUNT); + let asset1 = FungibleAsset::mock(FungibleAsset::MAX_AMOUNT.as_u64() - FUNGIBLE_ASSET_AMOUNT); let merged_asset = asset0.unwrap_fungible().add(asset1.unwrap_fungible())?; // Check merging is commutative by checking asset0 + asset1 = asset1 + asset0. @@ -581,7 +580,8 @@ async fn test_merge_fungible_asset_success() -> anyhow::Result<()> { #[tokio::test] async fn test_merge_fungible_asset_fails_when_max_amount_exceeded() -> anyhow::Result<()> { let asset0 = FungibleAsset::mock(FUNGIBLE_ASSET_AMOUNT); - let asset1 = FungibleAsset::mock(FungibleAsset::MAX_AMOUNT + 1 - FUNGIBLE_ASSET_AMOUNT); + let asset1 = + FungibleAsset::mock(FungibleAsset::MAX_AMOUNT.as_u64() + 1 - FUNGIBLE_ASSET_AMOUNT); // Check merging fails for both asset0 + asset1 and asset1 + asset0. for (asset_a, asset_b) in [(asset0, asset1), (asset1, asset0)] { diff --git a/crates/miden-testing/src/kernel_tests/tx/test_auth.rs b/crates/miden-testing/src/kernel_tests/tx/test_auth.rs index e24157d1a5..5093d1f88a 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_auth.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_auth.rs @@ -24,9 +24,9 @@ async fn test_auth_procedure_args() -> anyhow::Result<()> { Account::mock(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE, ConditionalAuthComponent); let auth_args = [ - Felt::new(97), - Felt::new(98), - Felt::new(99), + Felt::new_unchecked(97), + Felt::new_unchecked(98), + Felt::new_unchecked(99), ONE, // incr_nonce = true ]; @@ -50,9 +50,9 @@ async fn test_auth_procedure_args_wrong_inputs() -> anyhow::Result<()> { // The auth script expects [99, 98, 97, nonce_increment_flag] let auth_args = [ ONE, // incr_nonce = true - Felt::new(103), - Felt::new(102), - Felt::new(101), + Felt::new_unchecked(103), + Felt::new_unchecked(102), + Felt::new_unchecked(101), ]; let tx_context = TransactionContextBuilder::new(account).auth_args(auth_args.into()).build()?; 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 55888fc58c..5e0cb032db 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_callbacks.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_callbacks.rs @@ -11,7 +11,7 @@ use miden_protocol::account::{ AccountComponent, AccountComponentCode, AccountId, - AccountStorageMode, + AccountProcedureRoot, AccountType, StorageMap, StorageMapKey, @@ -20,8 +20,10 @@ use miden_protocol::account::{ }; use miden_protocol::asset::{ Asset, + AssetAmount, AssetCallbackFlag, AssetCallbacks, + AssetComposition, FungibleAsset, NonFungibleAsset, NonFungibleAssetDetails, @@ -31,10 +33,16 @@ 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::faucets::BasicFungibleFaucet; -use miden_standards::account::mint_policies::AuthControlled; +use miden_standards::account::access::Authority; +use miden_standards::account::faucets::{FungibleFaucet, TokenName}; +use miden_standards::account::policies::{ + BurnPolicyConfig, + MintPolicyConfig, + PolicyRegistration, + TokenPolicyManager, +}; use miden_standards::code_builder::CodeBuilder; -use miden_standards::procedure_digest; +use miden_standards::procedure_root; use miden_standards::testing::account_component::MockFaucetComponent; use crate::{AccountState, Auth, MockChain, MockChainBuilder, assert_transaction_executor_error}; @@ -133,18 +141,18 @@ static BLOCK_LIST_SLOT_NAME: LazyLock = LazyLock::new(|| { .expect("storage slot name should be valid") }); -procedure_digest!( +procedure_root!( BLOCK_LIST_ON_BEFORE_ASSET_ADDED_TO_ACCOUNT, BlockList::NAME, BlockList::ON_BEFORE_ASSET_ADDED_TO_ACCOUNT_PROC_NAME, - || { BLOCK_LIST_COMPONENT_CODE.as_library() } + &BLOCK_LIST_COMPONENT_CODE ); -procedure_digest!( +procedure_root!( BLOCK_LIST_ON_BEFORE_ASSET_ADDED_TO_NOTE, BlockList::NAME, BlockList::ON_BEFORE_ASSET_ADDED_TO_NOTE_PROC_NAME, - || { BLOCK_LIST_COMPONENT_CODE.as_library() } + &BLOCK_LIST_COMPONENT_CODE ); // BLOCK LIST @@ -171,13 +179,13 @@ impl BlockList { Self { blocked_accounts } } - /// Returns the digest of the `on_before_asset_added_to_account` procedure. - pub fn on_before_asset_added_to_account_digest() -> Word { + /// Returns the procedure root of the `on_before_asset_added_to_account` procedure. + pub fn on_before_asset_added_to_account_root() -> AccountProcedureRoot { *BLOCK_LIST_ON_BEFORE_ASSET_ADDED_TO_ACCOUNT } - /// Returns the digest of the `on_before_asset_added_to_note` procedure. - pub fn on_before_asset_added_to_note_digest() -> Word { + /// Returns the procedure root of the `on_before_asset_added_to_note` procedure. + pub fn on_before_asset_added_to_note_root() -> AccountProcedureRoot { *BLOCK_LIST_ON_BEFORE_ASSET_ADDED_TO_NOTE } } @@ -205,16 +213,15 @@ impl From for AccountComponent { storage_slots.extend( AssetCallbacks::new() .on_before_asset_added_to_account( - BlockList::on_before_asset_added_to_account_digest(), + BlockList::on_before_asset_added_to_account_root().as_word(), + ) + .on_before_asset_added_to_note( + BlockList::on_before_asset_added_to_note_root().as_word(), ) - .on_before_asset_added_to_note(BlockList::on_before_asset_added_to_note_digest()) .into_storage_slots(), ); - let metadata = AccountComponentMetadata::new( - BlockList::NAME, - [AccountType::FungibleFaucet, AccountType::NonFungibleFaucet], - ) - .with_description("block list callback component for testing"); + let metadata = AccountComponentMetadata::new(BlockList::NAME) + .with_description("block list callback component for testing"); AccountComponent::new(BLOCK_LIST_COMPONENT_CODE.clone(), storage_slots, metadata) .expect("block list should satisfy the requirements of a valid account component") @@ -227,13 +234,13 @@ impl From for AccountComponent { /// Tests that consuming a callbacks-enabled asset succeeds even when the issuing faucet does not /// have the callback storage slot or when the callback storage slot contains the empty word. #[rstest::rstest] -#[case::fungible_empty_storage(AccountType::FungibleFaucet, true)] -#[case::fungible_no_storage(AccountType::FungibleFaucet, false)] -#[case::non_fungible_empty_storage(AccountType::NonFungibleFaucet, true)] -#[case::non_fungible_no_storage(AccountType::NonFungibleFaucet, false)] +#[case::fungible_empty_storage(AssetComposition::Fungible, true)] +#[case::fungible_no_storage(AssetComposition::Fungible, false)] +#[case::non_fungible_empty_storage(AssetComposition::None, true)] +#[case::non_fungible_no_storage(AssetComposition::None, false)] #[tokio::test] async fn test_faucet_without_callback_slot_skips_callback( - #[case] account_type: AccountType, + #[case] asset_composition: AssetComposition, #[case] has_empty_callback_proc_root: bool, ) -> anyhow::Result<()> { let mut builder = MockChain::builder(); @@ -242,8 +249,7 @@ async fn test_faucet_without_callback_slot_skips_callback( // Create a faucet WITHOUT any AssetCallbacks component. let mut account_builder = AccountBuilder::new([45u8; 32]) - .storage_mode(AccountStorageMode::Public) - .account_type(account_type) + .account_type(AccountType::Public) .with_component(MockFaucetComponent); // If callback proc roots should be empty, add the empty storage slots. @@ -268,12 +274,12 @@ 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 account_type { - AccountType::FungibleFaucet => Asset::from(FungibleAsset::new(faucet.id(), 100)?), - AccountType::NonFungibleFaucet => Asset::from(NonFungibleAsset::new( - &NonFungibleAssetDetails::new(faucet.id(), vec![1])?, - )?), - _ => unreachable!("test only uses faucet account types"), + 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]))) + }, + _ => unreachable!("test does not use custom composition"), } .with_callbacks(AssetCallbackFlag::Enabled); @@ -379,27 +385,24 @@ async fn test_on_before_asset_added_to_account_callback_receives_correct_inputs( /// Tests that a blocked account cannot receive an asset with callbacks enabled. #[rstest::rstest] #[case::fungible( - AccountType::FungibleFaucet, |faucet_id| { Ok(FungibleAsset::new(faucet_id, 100)?.with_callbacks(AssetCallbackFlag::Enabled).into()) } )] #[case::non_fungible( - AccountType::NonFungibleFaucet, |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]); + Ok(NonFungibleAsset::new(&details).with_callbacks(AssetCallbackFlag::Enabled).into()) } )] #[tokio::test] async fn test_blocked_account_cannot_receive_asset( - #[case] account_type: AccountType, #[case] create_asset: impl FnOnce(AccountId) -> anyhow::Result, ) -> anyhow::Result<()> { let mut builder = MockChain::builder(); let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; - let faucet = add_faucet_with_block_list(&mut builder, account_type, [target_account.id()])?; + let faucet = add_faucet_with_block_list(&mut builder, [target_account.id()])?; let note = builder.add_p2id_note( faucet.id(), @@ -431,27 +434,24 @@ async fn test_blocked_account_cannot_receive_asset( /// Tests that a blocked account cannot add a callbacks-enabled asset to an output note. #[rstest::rstest] #[case::fungible( - AccountType::FungibleFaucet, |faucet_id| { Ok(FungibleAsset::new(faucet_id, 100)?.with_callbacks(AssetCallbackFlag::Enabled).into()) } )] #[case::non_fungible( - AccountType::NonFungibleFaucet, |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]); + Ok(NonFungibleAsset::new(&details).with_callbacks(AssetCallbackFlag::Enabled).into()) } )] #[tokio::test] async fn test_blocked_account_cannot_add_asset_to_note( - #[case] account_type: AccountType, #[case] create_asset: impl FnOnce(AccountId) -> anyhow::Result, ) -> anyhow::Result<()> { let mut builder = MockChain::builder(); let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; - let faucet = add_faucet_with_block_list(&mut builder, account_type, [target_account.id()])?; + let faucet = add_faucet_with_block_list(&mut builder, [target_account.id()])?; let asset = create_asset(faucet.id())?; let mut mock_chain = builder.build()?; @@ -656,9 +656,15 @@ async fn test_faucet_with_callback_calls_itself() -> anyhow::Result<()> { push.{note_type} push.{tag} push.{amount} - # => [amount, tag, note_type, RECIPIENT, pad(9)] + push.{faucet_id_prefix} + push.{faucet_id_suffix} + push.1 + # => [enable_callbacks=1, faucet_id_suffix, faucet_id_prefix, amount, tag, note_type, RECIPIENT, pad(...)] - call.::miden::standards::faucets::basic_fungible::mint_and_send + exec.::miden::protocol::asset::create_fungible_asset + # => [ASSET_KEY, ASSET_VALUE, tag, note_type, RECIPIENT, pad(...)] + + call.::miden::standards::faucets::fungible::mint_and_send # => [note_idx, pad(15)] # truncate the stack @@ -666,6 +672,8 @@ async fn test_faucet_with_callback_calls_itself() -> anyhow::Result<()> { end ", note_type = NoteType::Private as u8, + faucet_id_suffix = faucet.id().suffix(), + faucet_id_prefix = faucet.id().prefix().as_felt(), ); let tx_script = CodeBuilder::default().compile_tx_script(tx_script_code)?; @@ -691,18 +699,12 @@ async fn test_faucet_with_callback_calls_itself() -> anyhow::Result<()> { /// native account is in the block list and panics if so. fn add_faucet_with_block_list( builder: &mut MockChainBuilder, - account_type: AccountType, blocked_accounts: impl IntoIterator, ) -> anyhow::Result { let block_list = BlockList::new(blocked_accounts.into_iter().collect()); - if !account_type.is_faucet() { - anyhow::bail!("account type must be of type faucet") - } - let account_builder = AccountBuilder::new([42u8; 32]) - .storage_mode(AccountStorageMode::Public) - .account_type(account_type) + .account_type(AccountType::Public) .with_component(MockFaucetComponent) .with_component(block_list); @@ -753,20 +755,28 @@ fn add_faucet_with_callbacks( callbacks = callbacks.on_before_asset_added_to_note(proc_root); } - let basic_faucet = BasicFungibleFaucet::new("SYM".try_into()?, 8, Felt::new(1_000_000))?; + let faucet = FungibleFaucet::builder() + .name(TokenName::new("").expect("empty string is a valid token name")) + .symbol("SYM".try_into()?) + .decimals(8) + .max_supply(AssetAmount::from(1_000_000u32)) + .build()?; let callback_storage_slots = callbacks.into_storage_slots(); - let callback_metadata = - AccountComponentMetadata::new(component_name, [AccountType::FungibleFaucet]) - .with_description("callback component for testing"); + let callback_metadata = AccountComponentMetadata::new(component_name) + .with_description("callback component for testing"); let callback_component = AccountComponent::new(callback_code, callback_storage_slots, callback_metadata)?; let account_builder = AccountBuilder::new([42; 32]) - .storage_mode(AccountStorageMode::Public) - .account_type(AccountType::FungibleFaucet) - .with_component(basic_faucet) - .with_component(AuthControlled::allow_all()) + .account_type(AccountType::Public) + .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)?, + ) .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 7755e1f36d..9bd3eee04e 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_epilogue.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_epilogue.rs @@ -21,7 +21,7 @@ use miden_protocol::testing::account_id::{ use miden_protocol::testing::storage::MOCK_VALUE_SLOT0; use miden_protocol::transaction::memory::{ NOTE_MEM_SIZE, - OUTPUT_NOTE_ASSET_COMMITMENT_OFFSET, + OUTPUT_NOTE_ASSETS_COMMITMENT_OFFSET, OUTPUT_NOTE_SECTION_OFFSET, }; use miden_protocol::transaction::{RawOutputNote, RawOutputNotes, TransactionOutputs}; @@ -116,7 +116,7 @@ async fn test_transaction_epilogue() -> anyhow::Result<()> { 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().native_asset_id(), + tx_context.tx_inputs().block_header().fee_parameters().fee_faucet_id(), 0, )?; @@ -129,18 +129,18 @@ async fn test_transaction_epilogue() -> anyhow::Result<()> { account_update_commitment, ); assert_eq!( - exec_output.get_stack_element(TransactionOutputs::NATIVE_ASSET_ID_SUFFIX_ELEMENT_IDX), + 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::NATIVE_ASSET_ID_PREFIX_ELEMENT_IDX), + 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() + fee_asset.amount().as_u64() ); assert_eq!( exec_output @@ -160,7 +160,7 @@ async fn test_transaction_epilogue() -> anyhow::Result<()> { /// Tests that the output note memory section is correctly populated during finalize_transaction. #[tokio::test] -async fn test_compute_output_note_id() -> anyhow::Result<()> { +async fn test_compute_output_note_details_commitment() -> anyhow::Result<()> { let mut rng = RandomCoin::new(Word::from([3, 4, 5, 6u32])); let account = Account::mock(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE, Auth::IncrNonce); let mut assets = account.vault().assets(); @@ -229,15 +229,15 @@ async fn test_compute_output_note_id() -> anyhow::Result<()> { exec_output.get_kernel_mem_word( OUTPUT_NOTE_SECTION_OFFSET + i * NOTE_MEM_SIZE - + OUTPUT_NOTE_ASSET_COMMITMENT_OFFSET + + OUTPUT_NOTE_ASSETS_COMMITMENT_OFFSET ), "ASSET_COMMITMENT didn't match expected value", ); assert_eq!( - note.id().as_word(), + note.details_commitment().as_word(), exec_output.get_kernel_mem_word(OUTPUT_NOTE_SECTION_OFFSET + i * NOTE_MEM_SIZE), - "NOTE_ID didn't match expected value", + "note details commitment didn't match kernel output note offset 0", ); } 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 59796ae2ec..a070d34fc6 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_faucet.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_faucet.rs @@ -1,10 +1,11 @@ use alloc::sync::Arc; use miden_protocol::Felt; -use miden_protocol::account::{Account, AccountBuilder, AccountComponent, AccountId, AccountType}; +use miden_protocol::account::{Account, AccountBuilder, AccountComponent, AccountId}; use miden_protocol::assembly::DefaultSourceManager; use miden_protocol::asset::{ AssetCallbackFlag, + AssetComposition, AssetId, AssetVaultKey, FungibleAsset, @@ -14,8 +15,8 @@ use miden_protocol::errors::tx_kernel::{ ERR_FUNGIBLE_ASSET_AMOUNT_EXCEEDS_MAX_AMOUNT, ERR_FUNGIBLE_ASSET_FAUCET_IS_NOT_ORIGIN, ERR_NON_FUNGIBLE_ASSET_FAUCET_IS_NOT_ORIGIN, + ERR_VAULT_ASSET_METADATA_NON_ZERO_RESERVED_BITS, ERR_VAULT_FUNGIBLE_ASSET_AMOUNT_LESS_THAN_AMOUNT_TO_WITHDRAW, - ERR_VAULT_INVALID_ENABLE_CALLBACKS, ERR_VAULT_NON_FUNGIBLE_ASSET_TO_REMOVE_NOT_FOUND, }; use miden_protocol::testing::account_id::{ @@ -31,6 +32,7 @@ use miden_protocol::testing::constants::{ NON_FUNGIBLE_ASSET_DATA_2, }; use miden_protocol::testing::noop_auth_component::NoopAuthComponent; +use miden_protocol::transaction::memory::INPUT_VAULT_ROOT_PTR; use miden_standards::code_builder::CodeBuilder; use miden_standards::testing::mock_account::MockAccountExt; @@ -60,13 +62,10 @@ async fn test_mint_fungible_asset_succeeds() -> anyhow::Result<()> { push.{FUNGIBLE_ASSET_VALUE} push.{FUNGIBLE_ASSET_KEY} call.mock_faucet::mint - - # assert the correct asset is returned - push.{FUNGIBLE_ASSET_VALUE} - assert_eqw.err="minted asset does not match expected asset" + # => [] # assert the input vault has been updated - exec.memory::get_input_vault_root_ptr + push.{INPUT_VAULT_ROOT_PTR} push.{FUNGIBLE_ASSET_KEY} exec.asset_vault::get_asset # => [ASSET_VALUE] @@ -78,7 +77,7 @@ async fn test_mint_fungible_asset_succeeds() -> anyhow::Result<()> { push.{FUNGIBLE_ASSET_AMOUNT} assert_eq.err="input vault should contain minted asset" # truncate the stack - dropw + dropw dropw end "#, FUNGIBLE_ASSET_KEY = asset.to_key_word(), @@ -160,7 +159,7 @@ async fn mint_fungible_asset_fails_on_invalid_asset_metadata() -> anyhow::Result let asset = FungibleAsset::mock(50); let mut vault_key_word = asset.to_key_word(); - vault_key_word[2] = Felt::try_from(vault_key_word[2].as_canonical_u64() | u8::MAX as u64)?; + vault_key_word[2] = Felt::try_from(vault_key_word[2].as_canonical_u64() | 1 << 7)?; let code = format!( " @@ -183,7 +182,7 @@ async fn mint_fungible_asset_fails_on_invalid_asset_metadata() -> anyhow::Result .build()? .execute_code(&code) .await; - assert_execution_error!(result, ERR_VAULT_INVALID_ENABLE_CALLBACKS); + assert_execution_error!(result, ERR_VAULT_ASSET_METADATA_NON_ZERO_RESERVED_BITS); Ok(()) } @@ -211,7 +210,7 @@ async fn test_mint_fungible_asset_fails_when_amount_exceeds_max_representable_am end ", ASSET_KEY = FungibleAsset::mock(0).to_key_word(), - max_amount_plus_1 = FungibleAsset::MAX_AMOUNT + 1, + max_amount_plus_1 = FungibleAsset::MAX_AMOUNT.as_u64() + 1, ); let tx_script = CodeBuilder::with_mock_libraries().compile_tx_script(code)?; @@ -252,19 +251,17 @@ async fn test_mint_non_fungible_asset_succeeds() -> anyhow::Result<()> { push.{NON_FUNGIBLE_ASSET_VALUE} push.{NON_FUNGIBLE_ASSET_KEY} call.mock_faucet::mint - - # assert the correct asset is returned - push.{NON_FUNGIBLE_ASSET_VALUE} - assert_eqw.err="minted asset does not match expected asset" + # => [] # assert the input vault has been updated. - exec.memory::get_input_vault_root_ptr + push.{INPUT_VAULT_ROOT_PTR} push.{NON_FUNGIBLE_ASSET_KEY} exec.asset_vault::get_asset push.{NON_FUNGIBLE_ASSET_VALUE} assert_eqw.err="vault should contain asset" - dropw + # truncate the stack + dropw dropw end "#, NON_FUNGIBLE_ASSET_KEY = non_fungible_asset.to_key_word(), @@ -344,7 +341,12 @@ async fn test_mint_fungible_asset_with_callbacks_enabled() -> anyhow::Result<()> let asset = FungibleAsset::new(faucet_id, FUNGIBLE_ASSET_AMOUNT)?; // Build a vault key with callbacks enabled. - let vault_key = AssetVaultKey::new(AssetId::default(), faucet_id, AssetCallbackFlag::Enabled)?; + let vault_key = AssetVaultKey::new( + AssetId::default(), + faucet_id, + AssetComposition::Fungible, + AssetCallbackFlag::Enabled, + )?; let code = format!( r#" @@ -401,7 +403,7 @@ async fn test_burn_fungible_asset_succeeds() -> anyhow::Result<()> { call.mock_faucet::burn # assert the input vault has been updated - exec.memory::get_input_vault_root_ptr + push.{INPUT_VAULT_ROOT_PTR} push.{FUNGIBLE_ASSET_KEY} exec.asset_vault::get_asset @@ -545,13 +547,13 @@ async fn test_burn_non_fungible_asset_succeeds() -> anyhow::Result<()> { exec.prologue::prepare_transaction # add non-fungible asset to the vault - exec.memory::get_input_vault_root_ptr + push.{INPUT_VAULT_ROOT_PTR} push.{NON_FUNGIBLE_ASSET_VALUE} push.{NON_FUNGIBLE_ASSET_KEY} exec.asset_vault::add_non_fungible_asset dropw # check that the non-fungible asset is presented in the input vault - exec.memory::get_input_vault_root_ptr + push.{INPUT_VAULT_ROOT_PTR} push.{NON_FUNGIBLE_ASSET_KEY} exec.asset_vault::get_asset push.{NON_FUNGIBLE_ASSET_VALUE} @@ -564,7 +566,7 @@ async fn test_burn_non_fungible_asset_succeeds() -> anyhow::Result<()> { dropw # assert the input vault has been updated and does not have the burnt asset - exec.memory::get_input_vault_root_ptr + push.{INPUT_VAULT_ROOT_PTR} push.{NON_FUNGIBLE_ASSET_KEY} exec.asset_vault::get_asset # the returned word should be empty, indicating the asset is absent @@ -694,13 +696,9 @@ fn setup_non_faucet_account() -> anyhow::Result { "pub use ::miden::protocol::faucet::mint pub use ::miden::protocol::faucet::burn", )?; - let metadata = AccountComponentMetadata::new( - "test::non_faucet_component", - [AccountType::RegularAccountUpdatableCode], - ); + let metadata = AccountComponentMetadata::new("test::non_faucet_component"); let faucet_component = AccountComponent::new(faucet_code, vec![], metadata)?; Ok(AccountBuilder::new([4; 32]) - .account_type(AccountType::RegularAccountUpdatableCode) .with_auth_component(NoopAuthComponent) .with_component(faucet_component) .build_existing()?) diff --git a/crates/miden-testing/src/kernel_tests/tx/test_fee.rs b/crates/miden-testing/src/kernel_tests/tx/test_fee.rs index 2256a43ab9..e07c755902 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_fee.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_fee.rs @@ -4,7 +4,7 @@ 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_NATIVE_ASSET_FAUCET; +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; @@ -36,34 +36,32 @@ async fn create_account_with_fees() -> anyhow::Result<()> { 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.native_asset_id(), note_amount)?.sub(tx.fee())?; + let added_asset = FungibleAsset::new(chain.fee_faucet_id(), note_amount)?.sub(tx.fee())?; - assert_eq!(tx.account_delta().nonce_delta(), Felt::new(1)); + 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::new(1)); + 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 native +/// 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 native_asset_id = AccountId::try_from(ACCOUNT_ID_NATIVE_ASSET_FAUCET)?; + let fee_faucet_id = AccountId::try_from(ACCOUNT_ID_FEE_FAUCET)?; - let mut builder = - MockChain::builder().native_asset_id(native_asset_id).verification_base_fee(50); - let native_asset = FungibleAsset::new(native_asset_id, account_amount)?; - let account = - builder.add_existing_wallet_with_assets(Auth::IncrNonce, [native_asset.into()])?; + 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()?; @@ -125,10 +123,9 @@ async fn create_account_no_storage_no_fees() -> anyhow::Result anyhow::Result { - let native_asset_id = AccountId::try_from(ACCOUNT_ID_NATIVE_ASSET_FAUCET)?; - let native_asset = FungibleAsset::new(native_asset_id, 10_000)?; - let mut builder = - MockChain::builder().native_asset_id(native_asset_id).verification_base_fee(100); + 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, [ @@ -138,7 +135,7 @@ async fn mutate_account_with_storage() -> anyhow::Result { StorageMap::with_entries([(StorageMapKey::from_raw(rand_value()), rand_value())])?, ), ], - [Asset::from(native_asset), NonFungibleAsset::mock(&[1, 2, 3, 4])], + [Asset::from(fee_asset), NonFungibleAsset::mock(&[1, 2, 3, 4])], )?; let p2id_note = builder.add_p2id_note( account.id(), @@ -157,10 +154,9 @@ async fn mutate_account_with_storage() -> anyhow::Result { /// Returns a transaction that consumes two notes and creates two notes. async fn create_output_notes() -> anyhow::Result { - let native_asset_id = AccountId::try_from(ACCOUNT_ID_NATIVE_ASSET_FAUCET)?; - let native_asset = FungibleAsset::new(native_asset_id, 10_000)?; - let mut builder = - MockChain::builder().native_asset_id(native_asset_id).verification_base_fee(20); + 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, [ @@ -170,7 +166,7 @@ async fn create_output_notes() -> anyhow::Result { ), StorageSlot::with_value(StorageSlotName::mock(1), rand_value()), ], - [Asset::from(native_asset), NonFungibleAsset::mock(&[1, 2, 3, 4])], + [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(); 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 9ae7d70fbd..4e425e0306 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_fpi.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_fpi.rs @@ -13,11 +13,18 @@ use miden_protocol::account::{ AccountId, AccountProcedureRoot, AccountStorage, - AccountStorageMode, + AccountType, StorageSlot, }; use miden_protocol::assembly::DefaultSourceManager; -use miden_protocol::asset::{Asset, FungibleAsset, NonFungibleAsset, NonFungibleAssetDetails}; +use miden_protocol::asset::{ + Asset, + AssetCallbackFlag, + AssetVaultKey, + FungibleAsset, + NonFungibleAsset, + NonFungibleAssetDetails, +}; use miden_protocol::errors::tx_kernel::{ ERR_FOREIGN_ACCOUNT_CONTEXT_AGAINST_NATIVE_ACCOUNT, ERR_FOREIGN_ACCOUNT_INVALID_COMMITMENT, @@ -101,7 +108,7 @@ async fn test_fpi_memory_single_account() -> anyhow::Result<()> { let native_account = AccountBuilder::new(ChaCha20Rng::from_os_rng().random()) .with_auth_component(Auth::IncrNonce) .with_component(MockAccountComponent::with_slots(vec![AccountStorage::mock_map_slot()])) - .storage_mode(AccountStorageMode::Public) + .account_type(AccountType::Public) .build_existing()?; let mut mock_chain = @@ -376,7 +383,7 @@ async fn test_fpi_memory_two_accounts() -> anyhow::Result<()> { let native_account = AccountBuilder::new(ChaCha20Rng::from_os_rng().random()) .with_auth_component(Auth::IncrNonce) .with_component(MockAccountComponent::with_empty_slots()) - .storage_mode(AccountStorageMode::Public) + .account_type(AccountType::Public) .build_existing()?; let mut mock_chain = MockChainBuilder::with_accounts([ @@ -603,7 +610,7 @@ async fn test_fpi_execute_foreign_procedure() -> anyhow::Result<()> { let native_account = AccountBuilder::new(ChaCha20Rng::from_os_rng().random()) .with_auth_component(Auth::IncrNonce) .with_component(MockAccountComponent::with_empty_slots()) - .storage_mode(AccountStorageMode::Public) + .account_type(AccountType::Public) .build_existing()?; let mut mock_chain = @@ -741,8 +748,10 @@ async fn foreign_account_can_get_balance_and_presence_of_asset() -> anyhow::Resu // 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])?, - )?); + &NonFungibleAssetDetails::new(non_fungible_faucet_id, vec![1, 2, 3]), + )); + let fungible_asset_key = + AssetVaultKey::new_fungible(fungible_faucet_id, AssetCallbackFlag::Disabled); let foreign_account_code_source = format!( " @@ -750,7 +759,7 @@ async fn foreign_account_can_get_balance_and_presence_of_asset() -> anyhow::Resu pub proc get_asset_balance # get balance of first asset - push.{fungible_faucet_id_prefix} push.{fungible_faucet_id_suffix} + push.{FUNGIBLE_ASSET_KEY} exec.active_account::get_balance # => [balance] @@ -768,8 +777,7 @@ async fn foreign_account_can_get_balance_and_presence_of_asset() -> anyhow::Resu # => [has_asset_balance] end ", - fungible_faucet_id_prefix = fungible_faucet_id.prefix().as_felt(), - fungible_faucet_id_suffix = fungible_faucet_id.suffix(), + FUNGIBLE_ASSET_KEY = fungible_asset_key.to_word(), NON_FUNGIBLE_ASSET_KEY = non_fungible_asset.to_key_word(), ); @@ -790,7 +798,7 @@ async fn foreign_account_can_get_balance_and_presence_of_asset() -> anyhow::Resu let native_account = AccountBuilder::new(ChaCha20Rng::from_os_rng().random()) .with_auth_component(Auth::IncrNonce) .with_component(MockAccountComponent::with_empty_slots()) - .storage_mode(AccountStorageMode::Public) + .account_type(AccountType::Public) .build_existing()?; let mut mock_chain = @@ -856,16 +864,18 @@ async fn foreign_account_can_get_balance_and_presence_of_asset() -> anyhow::Resu 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_key = + AssetVaultKey::new_fungible(fungible_faucet_id, AssetCallbackFlag::Disabled); let foreign_account_code_source = format!( " use miden::protocol::active_account pub proc get_initial_balance - # push the faucet ID on the stack - push.{fungible_faucet_id_prefix} push.{fungible_faucet_id_suffix} + # push the asset vault key on the stack + push.{FUNGIBLE_ASSET_KEY} - # get the initial balance of the asset associated with the provided faucet ID + # get the initial balance of the asset associated with the provided vault key exec.active_account::get_balance # => [initial_balance] @@ -874,8 +884,7 @@ async fn foreign_account_get_initial_balance() -> anyhow::Result<()> { # => [initial_balance] end ", - fungible_faucet_id_prefix = fungible_faucet_id.prefix().as_felt(), - fungible_faucet_id_suffix = fungible_faucet_id.suffix(), + FUNGIBLE_ASSET_KEY = fungible_asset_key.to_word(), ); let source_manager = Arc::new(DefaultSourceManager::default()); @@ -895,7 +904,7 @@ async fn foreign_account_get_initial_balance() -> anyhow::Result<()> { let native_account = AccountBuilder::new(ChaCha20Rng::from_os_rng().random()) .with_auth_component(Auth::IncrNonce) .with_component(MockAccountComponent::with_empty_slots()) - .storage_mode(AccountStorageMode::Public) + .account_type(AccountType::Public) .build_existing()?; let mut mock_chain = @@ -995,7 +1004,7 @@ async fn test_nested_fpi_cyclic_invocation() -> anyhow::Result<()> { padw adv_loadw # push the foreign account ID from the advice stack - adv_push.2 + adv_push adv_push # => [foreign_account_id_suffix, foreign_account_id_prefix, FOREIGN_PROC_ROOT, # slot_id_suffix, slot_id_prefix, pad(8)] @@ -1055,7 +1064,7 @@ async fn test_nested_fpi_cyclic_invocation() -> anyhow::Result<()> { padw adv_loadw # push the ID of the second foreign account from the advice stack - adv_push.2 + adv_push adv_push # => [foreign_account_id_suffix, foreign_account_id_prefix, FOREIGN_PROC_ROOT, storage_item_index, pad(14)] exec.tx::execute_foreign_procedure @@ -1102,7 +1111,7 @@ async fn test_nested_fpi_cyclic_invocation() -> anyhow::Result<()> { let native_account = AccountBuilder::new(ChaCha20Rng::from_os_rng().random()) .with_auth_component(Auth::IncrNonce) .with_component(MockAccountComponent::with_empty_slots()) - .storage_mode(AccountStorageMode::Public) + .account_type(AccountType::Public) .build_existing()?; let mut mock_chain = MockChainBuilder::with_accounts([ @@ -1279,7 +1288,7 @@ async fn test_prove_fpi_two_foreign_accounts_chain() -> anyhow::Result<()> { let native_account = AccountBuilder::new(ChaCha20Rng::from_os_rng().random()) .with_auth_component(Auth::IncrNonce) .with_component(MockAccountComponent::with_empty_slots()) - .storage_mode(AccountStorageMode::Public) + .account_type(AccountType::Public) .build_existing()?; let mut mock_chain = MockChainBuilder::with_accounts([ @@ -1462,7 +1471,7 @@ async fn test_nested_fpi_stack_overflow() -> anyhow::Result<()> { let native_account = AccountBuilder::new(ChaCha20Rng::from_os_rng().random()) .with_auth_component(Auth::IncrNonce) .with_component(MockAccountComponent::with_empty_slots()) - .storage_mode(AccountStorageMode::Public) + .account_type(AccountType::Public) .build_existing() .unwrap(); @@ -1546,7 +1555,7 @@ async fn test_nested_fpi_native_account_invocation() -> anyhow::Result<()> { padw adv_loadw # push the ID of the native account from the advice stack - adv_push.2 + adv_push adv_push # => [native_account_id_suffix, native_account_id_prefix, NATIVE_PROC_ROOT, pad(15)] exec.tx::execute_foreign_procedure @@ -1572,7 +1581,7 @@ async fn test_nested_fpi_native_account_invocation() -> anyhow::Result<()> { let native_account = AccountBuilder::new(ChaCha20Rng::from_os_rng().random()) .with_auth_component(Auth::IncrNonce) .with_component(MockAccountComponent::with_empty_slots()) - .storage_mode(AccountStorageMode::Public) + .account_type(AccountType::Public) .build_existing()?; let mut mock_chain = @@ -1778,7 +1787,7 @@ async fn test_fpi_get_account_id() -> anyhow::Result<()> { let native_account = AccountBuilder::new(ChaCha20Rng::from_os_rng().random()) .with_auth_component(Auth::IncrNonce) .with_component(MockAccountComponent::with_empty_slots()) - .storage_mode(AccountStorageMode::Public) + .account_type(AccountType::Public) .build_existing()?; let mut mock_chain = @@ -1861,7 +1870,7 @@ async fn test_get_initial_item_and_get_initial_map_item_with_foreign_account() - let native_account = AccountBuilder::new(ChaCha20Rng::from_os_rng().random()) .with_auth_component(Auth::IncrNonce) .with_component(MockAccountComponent::with_empty_slots()) - .storage_mode(AccountStorageMode::Public) + .account_type(AccountType::Public) .build_existing()?; let mock_value_slot0 = AccountStorage::mock_value_slot0(); @@ -2038,7 +2047,7 @@ fn foreign_account_data_memory_assertions( for (i, elements) in foreign_account .code() - .as_elements() + .to_elements() .chunks(AccountProcedureRoot::NUM_ELEMENTS) .enumerate() { diff --git a/crates/miden-testing/src/kernel_tests/tx/test_input_note.rs b/crates/miden-testing/src/kernel_tests/tx/test_input_note.rs index 51d746748c..63507ede66 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_input_note.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_input_note.rs @@ -121,20 +121,15 @@ async fn test_get_recipient_and_metadata() -> anyhow::Result<()> { # get the metadata from the requested input note push.0 exec.input_note::get_metadata - # => [NOTE_ATTACHMENT, METADATA_HEADER] + # => [METADATA] - push.{NOTE_ATTACHMENT} - assert_eqw.err="note 0 has incorrect note attachment" - # => [METADATA_HEADER] - - push.{METADATA_HEADER} - assert_eqw.err="note 0 has incorrect metadata header" + push.{METADATA} + assert_eqw.err="note 0 has incorrect metadata" # => [] end "#, RECIPIENT = p2id_note_1_asset.recipient().digest(), - METADATA_HEADER = p2id_note_1_asset.metadata().to_header_word(), - NOTE_ATTACHMENT = p2id_note_1_asset.metadata().to_attachment_word(), + METADATA = p2id_note_1_asset.metadata().to_metadata_word(), ); let tx_script = CodeBuilder::default().compile_tx_script(code)?; @@ -219,12 +214,16 @@ async fn test_get_assets() -> anyhow::Result<()> { # write the assets to the memory exec.input_note::get_assets - # => [num_assets, dest_ptr, note_index] + # => [num_assets] # assert the number of note assets push.{assets_number} assert_eq.err="note {note_index} has incorrect assets number" - # => [dest_ptr, note_index] + # => [] + + # push the dest pointer for asset assertions + push.{dest_ptr} + # => [dest_ptr] "#, note_idx = note_index, dest_ptr = dest_ptr, @@ -237,27 +236,27 @@ async fn test_get_assets() -> anyhow::Result<()> { r#" # load the asset key stored in memory padw dup.4 mem_loadw_le - # => [STORED_ASSET_KEY, dest_ptr, note_index] + # => [STORED_ASSET_KEY, dest_ptr] # assert the asset key matches push.{NOTE_ASSET_KEY} assert_eqw.err="expected asset key at asset index {asset_index} of the note\ {note_index} to be {NOTE_ASSET_KEY}" - # => [dest_ptr, note_index] + # => [dest_ptr] # load the asset value stored in memory padw dup.4 add.{ASSET_VALUE_OFFSET} mem_loadw_le - # => [STORED_ASSET_VALUE, dest_ptr, note_index] + # => [STORED_ASSET_VALUE, dest_ptr] # assert the asset value matches push.{NOTE_ASSET_VALUE} assert_eqw.err="expected asset value at asset index {asset_index} of the note\ {note_index} to be {NOTE_ASSET_VALUE}" - # => [dest_ptr, note_index] + # => [dest_ptr] # move the pointer add.{ASSET_SIZE} - # => [dest_ptr+ASSET_SIZE, note_index] + # => [dest_ptr+ASSET_SIZE] "#, NOTE_ASSET_KEY = asset.to_key_word(), NOTE_ASSET_VALUE = asset.to_value_word(), @@ -266,8 +265,8 @@ async fn test_get_assets() -> anyhow::Result<()> { )); } - // drop the final `dest_ptr` and `note_index` from the stack - check_assets_code.push_str("\ndrop drop"); + // drop the final `dest_ptr` from the stack + check_assets_code.push_str("\ndrop"); check_assets_code } 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 3e55bc1a28..25520f131d 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 @@ -5,7 +5,7 @@ use miden_protocol::account::{AccountId, AccountStorage, StorageMapKey, StorageSlotDelta}; use miden_protocol::asset::{Asset, FungibleAsset}; use miden_protocol::testing::account_id::{ - ACCOUNT_ID_NATIVE_ASSET_FAUCET, + ACCOUNT_ID_FEE_FAUCET, ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2, }; @@ -28,7 +28,7 @@ async fn adding_fungible_assets_with_lazy_loading_succeeds() -> anyhow::Result<( let faucet_id2: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2.try_into().unwrap(); let fungible_asset1 = - FungibleAsset::new(faucet_id1, FungibleAsset::MAX_AMOUNT - FUNGIBLE_ASSET_AMOUNT)?; + FungibleAsset::new(faucet_id1, FungibleAsset::MAX_AMOUNT.as_u64() - FUNGIBLE_ASSET_AMOUNT)?; let fungible_asset2 = FungibleAsset::new(faucet_id2, FUNGIBLE_ASSET_AMOUNT)?; // Build a note that adds the assets to the input vault of the transaction. This is necessary @@ -85,7 +85,7 @@ async fn removing_fungible_assets_with_lazy_loading_succeeds() -> anyhow::Result let faucet_id2: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2.try_into().unwrap(); let fungible_asset1 = - FungibleAsset::new(faucet_id1, FungibleAsset::MAX_AMOUNT - FUNGIBLE_ASSET_AMOUNT)?; + FungibleAsset::new(faucet_id1, FungibleAsset::MAX_AMOUNT.as_u64() - FUNGIBLE_ASSET_AMOUNT)?; let fungible_asset2 = FungibleAsset::new(faucet_id2, FUNGIBLE_ASSET_AMOUNT)?; let code = format!( @@ -161,8 +161,7 @@ async fn removing_fungible_assets_with_lazy_loading_succeeds() -> anyhow::Result /// 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().native_asset_id(ACCOUNT_ID_NATIVE_ASSET_FAUCET.try_into()?); + 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, [ diff --git a/crates/miden-testing/src/kernel_tests/tx/test_note.rs b/crates/miden-testing/src/kernel_tests/tx/test_note.rs index a926869424..9302b00924 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_note.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_note.rs @@ -13,11 +13,15 @@ use miden_protocol::errors::MasmError; use miden_protocol::note::{ Note, NoteAssets, + NoteAttachmentHeader, + NoteAttachmentScheme, + NoteAttachments, NoteMetadata, NoteRecipient, NoteStorage, NoteTag, NoteType, + PartialNoteMetadata, }; use miden_protocol::testing::account_id::{ ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE, @@ -33,6 +37,7 @@ use miden_standards::testing::note::NoteBuilder; use rand::SeedableRng; use rand_chacha::ChaCha20Rng; +use crate::executor::CodeExecutor; use crate::kernel_tests::tx::{ExecutionOutputExt, input_note_data_ptr}; use crate::{ Auth, @@ -168,7 +173,7 @@ fn note_setup_stack_assertions(exec_output: &ExecutionOutput, inputs: &Transacti // assert that the stack contains the note storage at the end of execution assert_eq!( exec_output.get_stack_word(0), - inputs.input_notes().get_note(0).note().script().root() + inputs.input_notes().get_note(0).note().script().root().into() ); assert_eq!(exec_output.get_stack_word(4), Word::empty()); assert_eq!(exec_output.get_stack_word(8), Word::empty()); @@ -184,7 +189,7 @@ fn note_setup_memory_assertions(exec_output: &ExecutionOutput) { } #[tokio::test] -async fn test_build_recipient() -> anyhow::Result<()> { +async fn test_compute_and_store_recipient() -> anyhow::Result<()> { let tx_context = TransactionContextBuilder::with_existing_mock_account().build()?; // Create test script and serial number @@ -210,21 +215,21 @@ async fn test_build_recipient() -> anyhow::Result<()> { push.{script_root} # SCRIPT_ROOT push.{serial_num} # SERIAL_NUM push.4.{base_addr} # num_storage_items, storage_ptr - exec.note::build_recipient + exec.note::compute_and_store_recipient # => [RECIPIENT_4] # Test with 5 values (needs padding to 8) push.{script_root} # SCRIPT_ROOT push.{serial_num} # SERIAL_NUM push.5.{base_addr} # num_storage_items, storage_ptr - exec.note::build_recipient + exec.note::compute_and_store_recipient # => [RECIPIENT_5, RECIPIENT_4] # Test with 8 values (no padding needed - exactly one rate block) push.{script_root} # SCRIPT_ROOT push.{serial_num} # SERIAL_NUM push.8.{base_addr} # num_storage_items, storage_ptr - exec.note::build_recipient + exec.note::compute_and_store_recipient # => [RECIPIENT_8, RECIPIENT_5, RECIPIENT_4] # truncate the stack @@ -363,17 +368,17 @@ async fn test_compute_storage_commitment() -> anyhow::Result<()> { } #[tokio::test] -async fn test_build_metadata_header() -> anyhow::Result<()> { +async fn test_build_metadata() -> anyhow::Result<()> { let tx_context = TransactionContextBuilder::with_existing_mock_account().build().unwrap(); let sender = tx_context.account().id(); let receiver = AccountId::try_from(ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE) .map_err(|e| anyhow::anyhow!("Failed to convert account ID: {}", e))?; - let test_metadata1 = NoteMetadata::new(sender, NoteType::Private) + let test_metadata1 = PartialNoteMetadata::new(sender, NoteType::Private) .with_tag(NoteTag::with_account_target(receiver)); let test_metadata2 = - NoteMetadata::new(sender, NoteType::Public).with_tag(NoteTag::new(u32::MAX)); + PartialNoteMetadata::new(sender, NoteType::Public).with_tag(NoteTag::new(u32::MAX)); for (iteration, test_metadata) in [test_metadata1, test_metadata2].into_iter().enumerate() { let code = format!( @@ -384,7 +389,7 @@ async fn test_build_metadata_header() -> anyhow::Result<()> { begin exec.prologue::prepare_transaction push.{note_type} push.{tag} - exec.output_note::build_metadata_header + exec.output_note::build_metadata # truncate the stack swapw dropw @@ -399,7 +404,7 @@ async fn test_build_metadata_header() -> anyhow::Result<()> { let metadata_word = exec_output.get_stack_word(0); assert_eq!( - test_metadata.to_header_word(), + NoteMetadata::new(test_metadata, &NoteAttachments::default()).to_metadata_word(), metadata_word, "failed in iteration {iteration}" ); @@ -422,13 +427,14 @@ pub async fn test_timelock() -> anyhow::Result<()> { pub proc main # store the note storage to memory starting at address 0 push.0 exec.active_note::get_storage - # => [num_storage_items, storage_ptr] + # => [num_storage_items] # make sure the number of storage items is 1 eq.1 assert.err="note number of storage items is not 1" - # => [storage_ptr] + # => [] # read the timestamp at which the note can be consumed + push.0 mem_load # => [timestamp] @@ -518,7 +524,7 @@ async fn test_public_key_as_note_input() -> anyhow::Result<()> { let serial_num = RandomCoin::new(Word::from([1, 2, 3, 4u32])).draw_word(); let tag = NoteTag::with_account_target(target_account.id()); - let metadata = NoteMetadata::new(sender_account.id(), NoteType::Public).with_tag(tag); + let metadata = PartialNoteMetadata::new(sender_account.id(), NoteType::Public).with_tag(tag); let vault = NoteAssets::new(vec![])?; let note_script = CodeBuilder::default().compile_note_script(DEFAULT_NOTE_SCRIPT)?; let recipient = @@ -533,3 +539,157 @@ async fn test_public_key_as_note_input() -> anyhow::Result<()> { tx_context.execute().await?; Ok(()) } + +#[rstest::rstest] +#[case::all_present( + [ + NoteAttachmentHeader::new(NoteAttachmentScheme::new_const(2)), + NoteAttachmentHeader::new(NoteAttachmentScheme::new_const(10000)), + NoteAttachmentHeader::new(NoteAttachmentScheme::new_const(0xfffe)), + NoteAttachmentHeader::new(NoteAttachmentScheme::new_const(42)), + ] +)] +#[case::first_only( + [ + NoteAttachmentHeader::new(NoteAttachmentScheme::new_const(0x0fff)), + NoteAttachmentHeader::new(NoteAttachmentScheme::new_const(0xfffe)), + NoteAttachmentHeader::absent(), + NoteAttachmentHeader::absent(), + ] +)] +#[case::all_absent( + [ + NoteAttachmentHeader::absent(), + NoteAttachmentHeader::absent(), + NoteAttachmentHeader::absent(), + NoteAttachmentHeader::absent(), + ] +)] +#[tokio::test] +async fn test_metadata_into_attachment_schemes( + #[case] attachment_headers: [NoteAttachmentHeader; 4], +) -> anyhow::Result<()> { + let sender = AccountId::try_from(ACCOUNT_ID_SENDER).unwrap(); + let partial_metadata = PartialNoteMetadata::new(sender, NoteType::Public); + let metadata = NoteMetadata::from_parts(partial_metadata, attachment_headers, Word::default()); + let metadata_word = metadata.to_metadata_word(); + + let code = format!( + " + use miden::protocol::note + + begin + push.{metadata_word} + exec.note::metadata_into_attachment_schemes + # => [scheme0, scheme1, scheme2, scheme3, pad(16)] + + # truncate the stack + swapw dropw + end + ", + ); + + let exec_output = CodeExecutor::with_default_host().run(&code).await?; + + for (i, header) in attachment_headers.iter().enumerate() { + let expected_scheme = header.scheme().as_ref().map_or(0, NoteAttachmentScheme::as_u16); + assert_eq!( + exec_output.get_stack_element(i).as_canonical_u64(), + u64::from(expected_scheme), + "attachment scheme mismatch at index {i}" + ); + } + + Ok(()) +} + +/// Tests the `find_attachment_idx` procedure which searches for a given scheme in the +/// metadata and returns `[is_found, attachment_idx]`. +#[rstest::rstest] +#[case::found_at_index_0( + [ + NoteAttachmentHeader::new(NoteAttachmentScheme::new_const(42)), + NoteAttachmentHeader::new(NoteAttachmentScheme::new_const(20)), + NoteAttachmentHeader::new(NoteAttachmentScheme::new_const(30)), + // The scheme exists again at a higher index, but the first match should be returned. + NoteAttachmentHeader::new(NoteAttachmentScheme::new_const(42)), + ], + 42, + true, + 0, +)] +#[case::found_at_index_2( + [ + NoteAttachmentHeader::new(NoteAttachmentScheme::new_const(10)), + NoteAttachmentHeader::new(NoteAttachmentScheme::new_const(20)), + NoteAttachmentHeader::new(NoteAttachmentScheme::new_const(42)), + NoteAttachmentHeader::absent() + ], + 42, + true, + 2, +)] +#[case::found_at_index_3( + [ + NoteAttachmentHeader::new(NoteAttachmentScheme::new_const(10)), + NoteAttachmentHeader::new(NoteAttachmentScheme::new_const(20)), + NoteAttachmentHeader::new(NoteAttachmentScheme::new_const(30)), + NoteAttachmentHeader::new(NoteAttachmentScheme::new_const(42)), + ], + 42, + true, + 3, +)] +#[case::not_found( + [ + NoteAttachmentHeader::new(NoteAttachmentScheme::new_const(10)), + NoteAttachmentHeader::new(NoteAttachmentScheme::new_const(20)), + NoteAttachmentHeader::absent(), + NoteAttachmentHeader::absent(), + ], + 42, + false, + 0, // attachment_idx is undefined when not found; we don't assert it +)] +#[tokio::test] +async fn test_find_attachment_idx( + #[case] attachment_headers: [NoteAttachmentHeader; 4], + #[case] search_scheme: u16, + #[case] expected_found: bool, + #[case] expected_idx: u8, +) -> anyhow::Result<()> { + let sender = AccountId::try_from(ACCOUNT_ID_SENDER).unwrap(); + let partial_metadata = PartialNoteMetadata::new(sender, NoteType::Public); + let metadata = NoteMetadata::from_parts(partial_metadata, attachment_headers, Word::default()); + let metadata_word = metadata.to_metadata_word(); + + let code = format!( + " + use miden::protocol::note + + begin + push.{metadata_word} + push.{search_scheme} + exec.note::find_attachment_idx + # => [is_found, attachment_idx, pad(16)] + + # truncate the stack + movup.2 drop movup.2 drop + # => [is_found, attachment_idx, pad(14)] + end + ", + ); + + let exec_output = CodeExecutor::with_default_host().run(&code).await?; + + let is_found = exec_output.get_stack_element(0); + assert_eq!(is_found, Felt::from(expected_found as u8), "is_found mismatch"); + + // attachment_idx is undefined when not found so we only assert when found + if expected_found { + let attachment_idx = exec_output.get_stack_element(1); + assert_eq!(attachment_idx, Felt::from(expected_idx), "attachment_idx mismatch"); + } + + 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 fbcc0cd51d..d2997342aa 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 @@ -1,30 +1,42 @@ use alloc::string::String; +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::crypto::rand::RandomCoin; +use miden_protocol::errors::MasmError; use miden_protocol::errors::tx_kernel::{ ERR_NON_FUNGIBLE_ASSET_ALREADY_EXISTS, + ERR_NOTE_NUM_OF_ASSETS_EXCEED_LIMIT, + ERR_OUTPUT_NOTE_ATTACHMENT_SCHEME_CANNOT_BE_ZERO, + ERR_OUTPUT_NOTE_ATTACHMENT_SIZE_CANNOT_BE_ZERO, + ERR_OUTPUT_NOTE_ATTACHMENT_SIZE_MAX_EXCEEDED, + ERR_OUTPUT_NOTE_ATTACHMENT_SIZE_MUST_BE_MULTIPLE_OF_WORD_SIZE, + ERR_OUTPUT_NOTE_INDEX_OUT_OF_BOUNDS, + ERR_OUTPUT_NOTE_TOO_MANY_ATTACHMENTS, + ERR_OUTPUT_NOTE_TOTAL_ATTACHMENT_WORDS_EXCEEDED, ERR_TX_NUMBER_OF_OUTPUT_NOTES_EXCEEDS_LIMIT, }; use miden_protocol::note::{ Note, NoteAttachment, NoteAttachmentScheme, + NoteAttachments, NoteMetadata, NoteRecipient, NoteStorage, NoteTag, NoteType, + PartialNoteMetadata, }; use miden_protocol::testing::account_id::{ - ACCOUNT_ID_NETWORK_NON_FUNGIBLE_FAUCET, ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET, ACCOUNT_ID_PRIVATE_SENDER, ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1, ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2, + ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET, ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE, ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE, ACCOUNT_ID_SENDER, @@ -36,14 +48,14 @@ use miden_protocol::transaction::memory::{ NOTE_MEM_SIZE, NUM_OUTPUT_NOTES_PTR, OUTPUT_NOTE_ASSETS_OFFSET, - OUTPUT_NOTE_ATTACHMENT_OFFSET, - OUTPUT_NOTE_METADATA_HEADER_OFFSET, + OUTPUT_NOTE_ATTACHMENT_0_OFFSET, + OUTPUT_NOTE_METADATA_OFFSET, OUTPUT_NOTE_NUM_ASSETS_OFFSET, OUTPUT_NOTE_RECIPIENT_OFFSET, OUTPUT_NOTE_SECTION_OFFSET, }; use miden_protocol::transaction::{RawOutputNote, RawOutputNotes}; -use miden_protocol::{Felt, Word, ZERO}; +use miden_protocol::{Felt, WORD_SIZE, Word, ZERO}; use miden_standards::code_builder::CodeBuilder; use miden_standards::note::{ AccountTargetNetworkNote, @@ -54,11 +66,18 @@ use miden_standards::note::{ }; use miden_standards::testing::mock_account::MockAccountExt; use miden_standards::testing::note::NoteBuilder; +use rstest::rstest; use super::{TestSetup, setup_test}; use crate::kernel_tests::tx::ExecutionOutputExt; use crate::utils::{create_public_p2any_note, create_spawn_note}; -use crate::{Auth, MockChain, TransactionContextBuilder, assert_execution_error}; +use crate::{ + Auth, + MockChain, + TransactionContextBuilder, + assert_execution_error, + assert_transaction_executor_error, +}; #[tokio::test] async fn test_create_note() -> anyhow::Result<()> { @@ -78,7 +97,7 @@ async fn test_create_note() -> anyhow::Result<()> { exec.prologue::prepare_transaction push.{recipient} - push.{PUBLIC_NOTE} + push.{NOTE_TYPE_PUBLIC} push.{tag} exec.output_note::create @@ -88,7 +107,7 @@ async fn test_create_note() -> anyhow::Result<()> { end ", recipient = recipient, - PUBLIC_NOTE = NoteType::Public as u8, + NOTE_TYPE_PUBLIC = NoteType::Public as u8, tag = tag, ); @@ -106,19 +125,20 @@ async fn test_create_note() -> anyhow::Result<()> { "recipient must be stored at the correct memory location", ); - let metadata = NoteMetadata::new(account_id, NoteType::Public).with_tag(tag); - let expected_metadata_header = metadata.to_header_word(); - let expected_note_attachment = metadata.to_attachment_word(); + let metadata = PartialNoteMetadata::new(account_id, NoteType::Public).with_tag(tag); + let expected_metadata_word = + NoteMetadata::new(metadata, &NoteAttachments::default()).to_metadata_word(); + let expected_note_attachment = NoteAttachments::default().to_commitment(); assert_eq!( - exec_output - .get_kernel_mem_word(OUTPUT_NOTE_SECTION_OFFSET + OUTPUT_NOTE_METADATA_HEADER_OFFSET), - expected_metadata_header, - "metadata header must be stored at the correct memory location", + exec_output.get_kernel_mem_word(OUTPUT_NOTE_SECTION_OFFSET + OUTPUT_NOTE_METADATA_OFFSET), + expected_metadata_word, + "metadata must be stored at the correct memory location", ); assert_eq!( - exec_output.get_kernel_mem_word(OUTPUT_NOTE_SECTION_OFFSET + OUTPUT_NOTE_ATTACHMENT_OFFSET), + exec_output + .get_kernel_mem_word(OUTPUT_NOTE_SECTION_OFFSET + OUTPUT_NOTE_ATTACHMENT_0_OFFSET), expected_note_attachment, "attachment must be stored at the correct memory location", ); @@ -135,7 +155,7 @@ async fn test_create_note() -> anyhow::Result<()> { async fn test_create_note_with_invalid_tag() -> anyhow::Result<()> { let tx_context = TransactionContextBuilder::with_existing_mock_account().build()?; - let invalid_tag = Felt::new((NoteType::Public as u64) << 62); + let invalid_tag = Felt::new_unchecked((NoteType::Public as u64) << 62); let valid_tag: Felt = NoteTag::default().into(); // Test invalid tag @@ -157,7 +177,7 @@ fn note_creation_script(tag: Felt) -> String { exec.prologue::prepare_transaction push.{recipient} - push.{PUBLIC_NOTE} + push.{NOTE_TYPE_PUBLIC} push.{tag} exec.output_note::create @@ -167,7 +187,7 @@ fn note_creation_script(tag: Felt) -> String { end ", recipient = Word::from([0, 1, 2, 3u32]), - PUBLIC_NOTE = NoteType::Public as u8, + NOTE_TYPE_PUBLIC = NoteType::Public as u8, ) } @@ -188,7 +208,7 @@ async fn test_create_note_too_many_notes() -> anyhow::Result<()> { exec.prologue::prepare_transaction push.{recipient} - push.{PUBLIC_NOTE} + push.{NOTE_TYPE_PUBLIC} push.{tag} exec.output_note::create @@ -196,7 +216,7 @@ async fn test_create_note_too_many_notes() -> anyhow::Result<()> { ", tag = NoteTag::new(1234 << 16 | 5678), recipient = Word::from([0, 1, 2, 3u32]), - PUBLIC_NOTE = NoteType::Public as u8, + NOTE_TYPE_PUBLIC = NoteType::Public as u8, ); let exec_output = tx_context.execute_code(&code).await; @@ -228,12 +248,24 @@ async fn test_get_output_notes_commitment() -> anyhow::Result<()> { .tag(NoteTag::with_custom_account_target(account.id(), 2)?.as_u32()) .note_type(NoteType::Public) .add_assets([asset_2]) - .attachment(NoteAttachment::new_array( - NoteAttachmentScheme::new(5), - [42, 43, 44, 45, 46u32].map(Felt::from).to_vec(), + .attachment(NoteAttachment::with_words( + NoteAttachmentScheme::new(5u16)?, + vec![Word::from([42, 43, 44, 45u32]); NoteAttachment::MAX_NUM_WORDS as usize], )?) .build()?; + let attachment = output_note_2.attachments().get(0).unwrap(); + let attachment_words = attachment.content().as_words(); + let store_attachment_words = attachment_words + .iter() + .enumerate() + .map(|(word_idx, word)| { + format!("push.{word} loc_storew_le.{offset} dropw", offset = word_idx * WORD_SIZE) + }) + .collect::>() + .join("\n "); + let num_attachment_words = attachment_words.len(); + let tx_context = TransactionContextBuilder::new(account) .extend_input_notes(vec![input_note_1.clone(), input_note_2.clone()]) .extend_expected_output_notes(vec![ @@ -258,13 +290,27 @@ async fn test_get_output_notes_commitment() -> anyhow::Result<()> { use $kernel::prologue + #! Since we execute in the kernel context, we write to local memory rather than to global + #! kernel memory to avoid accidental overwrites. + #! + #! Inputs: [] + #! Outputs: [attachment_ptr] + @locals({num_attachment_elements}) + proc store_attachment_words + {store_attachment_words} + # => [] + + locaddr.0 + # => [attachment_ptr] + end + begin exec.prologue::prepare_transaction # => [] # create output note 1 push.{recipient_1} - push.{PUBLIC_NOTE} + push.{NOTE_TYPE_PUBLIC} push.{tag_1} exec.output_note::create # => [note_idx] @@ -276,7 +322,7 @@ async fn test_get_output_notes_commitment() -> anyhow::Result<()> { # create output note 2 push.{recipient_2} - push.{PUBLIC_NOTE} + push.{NOTE_TYPE_PUBLIC} push.{tag_2} exec.output_note::create # => [note_idx] @@ -287,11 +333,12 @@ async fn test_get_output_notes_commitment() -> anyhow::Result<()> { exec.output_note::add_asset # => [note_idx] - push.{ATTACHMENT2} + # Store attachment words to memory + exec.store_attachment_words + push.{num_attachment_words} push.{attachment_scheme2} - movup.5 - # => [note_idx, attachment_scheme, ATTACHMENT] - exec.output_note::set_array_attachment + # => [attachment_scheme, num_words, ptr, note_idx] + exec.output_note::add_attachment_from_memory # => [] # compute the output notes commitment @@ -303,7 +350,7 @@ async fn test_get_output_notes_commitment() -> anyhow::Result<()> { # => [OUTPUT_NOTES_COMMITMENT] end ", - PUBLIC_NOTE = NoteType::Public as u8, + NOTE_TYPE_PUBLIC = NoteType::Public as u8, recipient_1 = output_note_1.recipient().digest(), tag_1 = output_note_1.metadata().tag(), ASSET_1_KEY = asset_1.to_key_word(), @@ -312,8 +359,11 @@ async fn test_get_output_notes_commitment() -> anyhow::Result<()> { tag_2 = output_note_2.metadata().tag(), ASSET_2_KEY = asset_2.to_key_word(), ASSET_2_VALUE = asset_2.to_value_word(), - ATTACHMENT2 = output_note_2.metadata().to_attachment_word(), - attachment_scheme2 = output_note_2.metadata().attachment().attachment_scheme().as_u32(), + store_attachment_words = store_attachment_words, + num_attachment_words = num_attachment_words, + attachment_scheme2 = + output_note_2.attachments().get(0).unwrap().attachment_scheme().as_u16(), + num_attachment_elements = output_note_2.attachments().get(0).unwrap().as_elements().len(), ); let exec_output = &tx_context.execute_code(&code).await?; @@ -324,33 +374,50 @@ async fn test_get_output_notes_commitment() -> anyhow::Result<()> { "The test creates two notes", ); assert_eq!( - exec_output - .get_kernel_mem_word(OUTPUT_NOTE_SECTION_OFFSET + OUTPUT_NOTE_METADATA_HEADER_OFFSET), - output_note_1.metadata().to_header_word(), - "Validate the output note 1 metadata header", - ); - assert_eq!( - exec_output.get_kernel_mem_word(OUTPUT_NOTE_SECTION_OFFSET + OUTPUT_NOTE_ATTACHMENT_OFFSET), - output_note_1.metadata().to_attachment_word(), - "Validate the output note 1 attachment", + exec_output.get_kernel_mem_word(OUTPUT_NOTE_SECTION_OFFSET + OUTPUT_NOTE_METADATA_OFFSET), + output_note_1.metadata().to_metadata_word(), + "Validate the output note 1 metadata", ); + for attachment_idx in 0..4u32 { + assert_eq!( + exec_output.get_kernel_mem_word( + OUTPUT_NOTE_SECTION_OFFSET + + OUTPUT_NOTE_ATTACHMENT_0_OFFSET + + attachment_idx * WORD_SIZE as u32 + ), + Word::empty(), + "Validate output note 1 attachment {attachment_idx} is empty", + ); + } assert_eq!( exec_output.get_kernel_mem_word( - OUTPUT_NOTE_SECTION_OFFSET + OUTPUT_NOTE_METADATA_HEADER_OFFSET + NOTE_MEM_SIZE + OUTPUT_NOTE_SECTION_OFFSET + OUTPUT_NOTE_METADATA_OFFSET + NOTE_MEM_SIZE ), - output_note_2.metadata().to_header_word(), - "Validate the output note 2 metadata header", + output_note_2.metadata().to_metadata_word(), + "Validate the output note 2 metadata", ); assert_eq!( exec_output.get_kernel_mem_word( - OUTPUT_NOTE_SECTION_OFFSET + OUTPUT_NOTE_ATTACHMENT_OFFSET + NOTE_MEM_SIZE + OUTPUT_NOTE_SECTION_OFFSET + OUTPUT_NOTE_ATTACHMENT_0_OFFSET + NOTE_MEM_SIZE ), - output_note_2.metadata().to_attachment_word(), + output_note_2.attachments().get(0).unwrap().content().to_commitment(), "Validate the output note 2 attachment", ); + for attachment_idx in 1..4u32 { + assert_eq!( + exec_output.get_kernel_mem_word( + OUTPUT_NOTE_SECTION_OFFSET + + OUTPUT_NOTE_ATTACHMENT_0_OFFSET + + attachment_idx * WORD_SIZE as u32 + ), + Word::empty(), + "Validate output note 2 attachment {attachment_idx} is empty", + ); + } assert_eq!(exec_output.get_stack_word(0), expected_output_notes_commitment); + Ok(()) } @@ -373,7 +440,7 @@ async fn test_create_note_and_add_asset() -> anyhow::Result<()> { exec.prologue::prepare_transaction push.{recipient} - push.{PUBLIC_NOTE} + push.{NOTE_TYPE_PUBLIC} push.{tag} exec.output_note::create @@ -395,7 +462,7 @@ async fn test_create_note_and_add_asset() -> anyhow::Result<()> { end ", recipient = recipient, - PUBLIC_NOTE = NoteType::Public as u8, + NOTE_TYPE_PUBLIC = NoteType::Public as u8, tag = tag, ASSET_KEY = asset.to_key_word(), ASSET_VALUE = asset.to_value_word(), @@ -443,7 +510,7 @@ async fn test_create_note_and_add_multiple_assets() -> anyhow::Result<()> { exec.prologue::prepare_transaction push.{recipient} - push.{PUBLIC_NOTE} + push.{NOTE_TYPE_PUBLIC} push.{tag} exec.output_note::create # => [note_idx] @@ -480,7 +547,7 @@ async fn test_create_note_and_add_multiple_assets() -> anyhow::Result<()> { end ", recipient = recipient, - PUBLIC_NOTE = NoteType::Public as u8, + NOTE_TYPE_PUBLIC = NoteType::Public as u8, tag = tag, ASSET_KEY = asset.to_key_word(), ASSET_VALUE = asset.to_value_word(), @@ -572,7 +639,7 @@ async fn test_create_note_and_add_same_nft_twice() -> anyhow::Result<()> { # => [] push.{recipient} - push.{PUBLIC_NOTE} + push.{NOTE_TYPE_PUBLIC} push.{tag} exec.output_note::create # => [note_idx] @@ -592,7 +659,7 @@ async fn test_create_note_and_add_same_nft_twice() -> anyhow::Result<()> { end ", recipient = recipient, - PUBLIC_NOTE = NoteType::Public as u8, + NOTE_TYPE_PUBLIC = NoteType::Public as u8, tag = tag, ASSET_KEY = non_fungible_asset.to_key_word(), ASSET_VALUE = non_fungible_asset.to_value_word(), @@ -604,6 +671,79 @@ async fn test_create_note_and_add_same_nft_twice() -> anyhow::Result<()> { Ok(()) } +/// Tests adding assets to an output note at and beyond the `MAX_ASSETS_PER_NOTE` limit. +/// +/// - `at_max`: adding exactly `MAX_ASSETS_PER_NOTE` assets succeeds. +/// - `exceeding_max`: adding `MAX_ASSETS_PER_NOTE + 1` assets fails with +/// `ERR_NOTE_NUM_OF_ASSETS_EXCEED_LIMIT`. +#[rstest::rstest] +#[case::at_max(0, false)] +#[case::exceeding_max(1, true)] +#[tokio::test] +async fn test_add_assets_around_max_per_note( + #[case] extra_assets: usize, + #[case] expect_error: bool, +) -> anyhow::Result<()> { + use miden_protocol::MAX_ASSETS_PER_NOTE; + + let tx_context = TransactionContextBuilder::with_existing_mock_account().build()?; + + let recipient = Word::from([0, 1, 2, 3u32]); + let tag = NoteTag::new(999 << 16 | 777); + + // Create the required number of unique non-fungible assets. + let num_assets = MAX_ASSETS_PER_NOTE + extra_assets; + let assets: Vec = (0..num_assets) + .map(|i| NonFungibleAsset::mock(&(i as u32).to_le_bytes())) + .collect(); + + // Build the MASM code: create a note, then add all assets one by one. + let mut add_assets_code = String::new(); + for (i, asset) in assets.iter().enumerate() { + let is_last = i == num_assets - 1; + // For all but the last asset, duplicate note_idx so it remains on the stack. + if !is_last { + add_assets_code.push_str("dup\n"); + } + add_assets_code.push_str(&format!( + "push.{ASSET_VALUE}\npush.{ASSET_KEY}\nexec.output_note::add_asset\n", + ASSET_KEY = asset.to_key_word(), + ASSET_VALUE = asset.to_value_word(), + )); + } + + let code = format!( + " + use $kernel::prologue + use miden::protocol::output_note + + begin + exec.prologue::prepare_transaction + + push.{recipient} + push.{NOTE_TYPE_PUBLIC} + push.{tag} + exec.output_note::create + # => [note_idx] + + {add_assets_code} + end + ", + recipient = recipient, + NOTE_TYPE_PUBLIC = NoteType::Public as u8, + tag = tag, + add_assets_code = add_assets_code, + ); + + if expect_error { + let exec_output = tx_context.execute_code(&code).await; + assert_execution_error!(exec_output, ERR_NOTE_NUM_OF_ASSETS_EXCEED_LIMIT); + } else { + tx_context.execute_code(&code).await?; + } + Ok(()) +} + /// Tests that creating a note with a fungible asset with amount zero works. #[tokio::test] async fn creating_note_with_fungible_asset_amount_zero_works() -> anyhow::Result<()> { @@ -628,7 +768,7 @@ async fn creating_note_with_fungible_asset_amount_zero_works() -> anyhow::Result } #[tokio::test] -async fn test_build_recipient_hash() -> anyhow::Result<()> { +async fn test_compute_recipient() -> anyhow::Result<()> { let tx_context = { let account = Account::mock(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE, Auth::IncrNonce); @@ -647,7 +787,7 @@ async fn test_build_recipient_hash() -> anyhow::Result<()> { let output_serial_no = Word::from([0, 1, 2, 3u32]); let tag = NoteTag::new(42 << 16 | 42); let single_input = 2; - let storage = NoteStorage::new(vec![Felt::new(single_input)]).unwrap(); + let storage = NoteStorage::new(vec![Felt::new_unchecked(single_input)]).unwrap(); let storage_commitment = storage.commitment(); let recipient = NoteRecipient::new(output_serial_no, input_note_1.script().clone(), storage); @@ -669,10 +809,10 @@ async fn test_build_recipient_hash() -> anyhow::Result<()> { push.{output_serial_no} # => [SERIAL_NUM, SCRIPT_ROOT, STORAGE_COMMITMENT] - exec.note::build_recipient_hash + exec.note::compute_recipient # => [RECIPIENT, pad(12)] - push.{PUBLIC_NOTE} + push.{NOTE_TYPE_PUBLIC} push.{tag} # => [tag, note_type, RECIPIENT] @@ -685,7 +825,7 @@ async fn test_build_recipient_hash() -> anyhow::Result<()> { ", script_root = input_note_1.script().clone().root(), output_serial_no = output_serial_no, - PUBLIC_NOTE = NoteType::Public as u8, + NOTE_TYPE_PUBLIC = NoteType::Public as u8, tag = tag, ); @@ -751,7 +891,7 @@ async fn test_get_asset_info() -> anyhow::Result<()> { ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE.try_into()?, vec![fungible_asset_0], NoteType::Public, - NoteAttachment::default(), + NoteAttachments::default(), &mut RandomCoin::new(Word::from([1, 2, 3, 4u32])), )?; @@ -760,7 +900,7 @@ async fn test_get_asset_info() -> anyhow::Result<()> { ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE.try_into()?, vec![fungible_asset_0, fungible_asset_1], NoteType::Public, - NoteAttachment::default(), + NoteAttachments::default(), &mut RandomCoin::new(Word::from([4, 3, 2, 1u32])), )?; @@ -881,7 +1021,7 @@ async fn test_get_recipient_and_metadata() -> anyhow::Result<()> { ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE.try_into()?, vec![FungibleAsset::mock(5)], NoteType::Public, - NoteAttachment::default(), + NoteAttachments::default(), &mut RandomCoin::new(Word::from([1, 2, 3, 4u32])), )?; @@ -908,14 +1048,10 @@ async fn test_get_recipient_and_metadata() -> anyhow::Result<()> { # get the metadata (the only existing note has 0'th index) push.0 exec.output_note::get_metadata - # => [NOTE_ATTACHMENT, METADATA_HEADER] - - push.{NOTE_ATTACHMENT} - assert_eqw.err="requested note has incorrect note attachment" - # => [METADATA_HEADER] + # => [METADATA] - push.{METADATA_HEADER} - assert_eqw.err="requested note has incorrect metadata header" + push.{METADATA} + assert_eqw.err="requested note has incorrect metadata" # => [] # truncate the stack @@ -924,8 +1060,7 @@ async fn test_get_recipient_and_metadata() -> anyhow::Result<()> { "#, output_note = create_output_note(&output_note), RECIPIENT = output_note.recipient().digest(), - METADATA_HEADER = output_note.metadata().to_header_word(), - NOTE_ATTACHMENT = output_note.metadata().to_attachment_word(), + METADATA = output_note.metadata().to_metadata_word(), ); let tx_script = CodeBuilder::default().compile_tx_script(tx_script_src)?; @@ -962,12 +1097,16 @@ async fn test_get_assets() -> anyhow::Result<()> { # write the assets to memory exec.output_note::get_assets - # => [num_assets, dest_ptr, note_index] + # => [num_assets] # assert the number of note assets push.{assets_number} assert_eq.err="expected note {note_index} to have {assets_number} assets" - # => [dest_ptr, note_index] + # => [] + + # push the dest pointer for asset assertions + push.{dest_ptr} + # => [dest_ptr] "#, note_idx = note_index, dest_ptr = dest_ptr, @@ -980,27 +1119,27 @@ async fn test_get_assets() -> anyhow::Result<()> { r#" # load the asset stored in memory padw dup.4 mem_loadw_le - # => [STORED_ASSET_KEY, dest_ptr, note_index] + # => [STORED_ASSET_KEY, dest_ptr] # assert the asset key matches push.{NOTE_ASSET_KEY} assert_eqw.err="expected asset key at asset index {asset_index} of the note\ {note_index} to be {NOTE_ASSET_KEY}" - # => [dest_ptr, note_index] + # => [dest_ptr] # load the asset stored in memory padw dup.4 add.{ASSET_VALUE_OFFSET} mem_loadw_le - # => [STORED_ASSET_VALUE, dest_ptr, note_index] + # => [STORED_ASSET_VALUE, dest_ptr] # assert the asset value matches push.{NOTE_ASSET_VALUE} assert_eqw.err="expected asset value at asset index {asset_index} of the note\ {note_index} to be {NOTE_ASSET_VALUE}" - # => [dest_ptr, note_index] + # => [dest_ptr] # move the pointer add.{ASSET_SIZE} - # => [dest_ptr+ASSET_SIZE, note_index] + # => [dest_ptr+ASSET_SIZE] "#, NOTE_ASSET_KEY = asset.to_key_word(), NOTE_ASSET_VALUE = asset.to_value_word(), @@ -1009,8 +1148,8 @@ async fn test_get_assets() -> anyhow::Result<()> { )); } - // drop the final `dest_ptr` and `note_index` from the stack - check_assets_code.push_str("\ndrop drop"); + // drop the final `dest_ptr` from the stack + check_assets_code.push_str("\ndrop"); check_assets_code } @@ -1059,69 +1198,146 @@ async fn test_get_assets() -> anyhow::Result<()> { Ok(()) } +#[rstest] +#[case::zero_elements(vec![], ERR_OUTPUT_NOTE_ATTACHMENT_SIZE_CANNOT_BE_ZERO)] +#[case::one_element(vec![1], ERR_OUTPUT_NOTE_ATTACHMENT_SIZE_MUST_BE_MULTIPLE_OF_WORD_SIZE)] +#[case::max_elements_exceeded( + vec![2; WORD_SIZE * (NoteAttachment::MAX_NUM_WORDS as usize + 1)], + ERR_OUTPUT_NOTE_ATTACHMENT_SIZE_MAX_EXCEEDED +)] #[tokio::test] -async fn test_set_none_attachment() -> anyhow::Result<()> { - let account = Account::mock(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET, Auth::IncrNonce); - let rng = RandomCoin::new(Word::from([1, 2, 3, 4u32])); - let attachment = NoteAttachment::default(); - let output_note = - RawOutputNote::Full(NoteBuilder::new(account.id(), rng).attachment(attachment).build()?); +async fn test_add_attachment_with_invalid_num_elements_fails( + #[case] elements: Vec, + #[case] expected_error: MasmError, +) -> anyhow::Result<()> { + let elements = elements.into_iter().map(Felt::from).collect(); + let commitment = Word::from([42, 43, 44, 45u32]); + let tx_context = TransactionContextBuilder::with_existing_mock_account() + .extend_advice_map(vec![(commitment, elements)]) + .build()?; - let tx_script = format!( + let code = format!( " use miden::protocol::output_note + use miden::standards::note_tag::DEFAULT_TAG + use $kernel::prologue + use mock::util begin - push.{RECIPIENT} - push.{note_type} - push.{tag} - exec.output_note::create + exec.prologue::prepare_transaction + + exec.util::create_default_note # => [note_idx] - push.{ATTACHMENT} - push.{attachment_kind} - push.{attachment_scheme} - movup.6 - # => [note_idx, attachment_scheme, attachment_kind, ATTACHMENT] - exec.output_note::set_attachment + push.{COMMITMENT} + push.5 + # => [attachment_scheme, ATTACHMENT_COMMITMENT, note_idx] + exec.output_note::add_attachment # => [] - - # truncate the stack - swapdw dropw dropw end ", - RECIPIENT = output_note.recipient().unwrap().digest(), - note_type = output_note.metadata().note_type() as u8, - tag = output_note.metadata().tag().as_u32(), - ATTACHMENT = output_note.metadata().to_attachment_word(), - attachment_kind = output_note.metadata().attachment().content().attachment_kind().as_u8(), - attachment_scheme = output_note.metadata().attachment().attachment_scheme().as_u32(), + COMMITMENT = commitment, ); - let tx_script = CodeBuilder::new().compile_tx_script(tx_script)?; + let exec_output = tx_context.execute_code(&code).await; - let tx = TransactionContextBuilder::new(account) - .extend_expected_output_notes(vec![output_note.clone()]) + assert_execution_error!(exec_output, expected_error); + + Ok(()) +} + +#[tokio::test] +async fn test_add_attachment_with_scheme_zero_fails() -> anyhow::Result<()> { + let tx_context = TransactionContextBuilder::with_existing_mock_account().build()?; + + let code = " + use miden::protocol::output_note + use miden::standards::note_tag::DEFAULT_TAG + use $kernel::prologue + use mock::util + + begin + exec.prologue::prepare_transaction + + exec.util::create_default_note + # => [note_idx] + + push.1.2.3.4 + push.0 + # => [attachment_scheme, ATTACHMENT_COMMITMENT, note_idx] + exec.output_note::add_attachment + # => [] + end + "; + + let exec_output = tx_context.execute_code(code).await; + + assert_execution_error!(exec_output, ERR_OUTPUT_NOTE_ATTACHMENT_SCHEME_CANNOT_BE_ZERO); + + Ok(()) +} + +/// Test that adding a fifth attachment to an output note fails with +/// `ERR_OUTPUT_NOTE_TOO_MANY_ATTACHMENTS`. +#[tokio::test] +async fn test_add_fifth_attachment_fails() -> anyhow::Result<()> { + let tx_script = " + use miden::protocol::output_note + use mock::util + + begin + exec.util::create_default_note + # => [note_idx] + + # add attachment 1 + dup push.1.2.3.4 push.1 + exec.output_note::add_word_attachment + # => [note_idx] + + # add attachment 2 + dup push.5.6.7.8 push.2 + exec.output_note::add_word_attachment + # => [note_idx] + + # add attachment 3 + dup push.9.10.11.12 push.3 + exec.output_note::add_word_attachment + # => [note_idx] + + # add attachment 4 + dup push.13.14.15.16 push.4 + exec.output_note::add_word_attachment + # => [note_idx] + + # add attachment 5 (should fail) + push.17.18.19.20 push.5 + exec.output_note::add_word_attachment + # => [] + end + "; + + let tx_script = CodeBuilder::with_mock_libraries().compile_tx_script(tx_script)?; + + let result = TransactionContextBuilder::with_existing_mock_account() .tx_script(tx_script) .build()? .execute() - .await?; + .await; - let actual_note = tx.output_notes().get_note(0); - assert_eq!(actual_note.header(), output_note.header()); - assert_eq!(actual_note.assets(), output_note.assets()); + assert_transaction_executor_error!(result, ERR_OUTPUT_NOTE_TOO_MANY_ATTACHMENTS); Ok(()) } #[tokio::test] -async fn test_set_word_attachment() -> anyhow::Result<()> { +async fn test_add_word_attachment() -> anyhow::Result<()> { let account = Account::mock(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET, Auth::IncrNonce); let rng = RandomCoin::new(Word::from([1, 2, 3, 4u32])); - let attachment = - NoteAttachment::new_word(NoteAttachmentScheme::new(u32::MAX), Word::from([3, 4, 5, 6u32])); - let output_note = - RawOutputNote::Full(NoteBuilder::new(account.id(), rng).attachment(attachment).build()?); + let attachment_word = Word::from([3, 4, 5, 6u32]); + let attachment = NoteAttachment::with_word(NoteAttachmentScheme::MAX, attachment_word); + let output_note = RawOutputNote::Full( + NoteBuilder::new(account.id(), rng).attachment(attachment.clone()).build()?, + ); let tx_script = format!( " @@ -1136,9 +1352,8 @@ async fn test_set_word_attachment() -> anyhow::Result<()> { push.{ATTACHMENT} push.{attachment_scheme} - movup.5 - # => [note_idx, attachment_scheme, ATTACHMENT] - exec.output_note::set_word_attachment + # => [attachment_scheme, ATTACHMENT, note_idx] + exec.output_note::add_word_attachment # => [] # truncate the stack @@ -1146,10 +1361,10 @@ async fn test_set_word_attachment() -> anyhow::Result<()> { end ", RECIPIENT = output_note.recipient().unwrap().digest(), - note_type = output_note.metadata().note_type() as u8, + note_type = output_note.metadata().note_type().as_u8(), tag = output_note.metadata().tag().as_u32(), - attachment_scheme = output_note.metadata().attachment().attachment_scheme().as_u32(), - ATTACHMENT = output_note.metadata().to_attachment_word(), + attachment_scheme = output_note.attachments().get(0).unwrap().attachment_scheme().as_u16(), + ATTACHMENT = attachment_word, ); let tx_script = CodeBuilder::new().compile_tx_script(tx_script)?; @@ -1162,6 +1377,9 @@ async fn test_set_word_attachment() -> anyhow::Result<()> { .await?; let actual_note = tx.output_notes().get_note(0); + assert_eq!(actual_note.attachments().num_attachments(), 1); + assert_eq!(actual_note.attachments().get(0).unwrap(), &attachment); + assert_eq!(actual_note.header(), output_note.header()); assert_eq!(actual_note.assets(), output_note.assets()); @@ -1169,13 +1387,27 @@ async fn test_set_word_attachment() -> anyhow::Result<()> { } #[tokio::test] -async fn test_set_array_attachment() -> anyhow::Result<()> { +async fn test_add_attachment_from_memory() -> anyhow::Result<()> { let account = Account::mock(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET, Auth::IncrNonce); let rng = RandomCoin::new(Word::from([1, 2, 3, 4u32])); - let elements = [3, 4, 5, 6, 7, 8, 9u32].map(Felt::from).to_vec(); - let attachment = NoteAttachment::new_array(NoteAttachmentScheme::new(42), elements.clone())?; - let output_note = - RawOutputNote::Full(NoteBuilder::new(account.id(), rng).attachment(attachment).build()?); + let words = vec![Word::from([3, 4, 5, 6u32]); NoteAttachment::MAX_NUM_WORDS as usize]; + let attachment = NoteAttachment::with_words(NoteAttachmentScheme::new(42)?, words.clone())?; + let output_note = RawOutputNote::Full( + NoteBuilder::new(account.id(), rng).attachment(attachment.clone()).build()?, + ); + + let attachment_ptr = 1024; + let store_attachment_words = words + .iter() + .enumerate() + .map(|(idx, word)| { + format!( + "push.{word} push.{ptr} mem_storew_le dropw", + ptr = attachment_ptr + idx * WORD_SIZE + ) + }) + .collect::>() + .join("\n"); let tx_script = format!( " @@ -1188,11 +1420,14 @@ async fn test_set_array_attachment() -> anyhow::Result<()> { exec.output_note::create # => [note_idx] - push.{ATTACHMENT} + # Store attachment words to memory + {store_attachment_words} + + push.{attachment_ptr} + push.{num_words} push.{attachment_scheme} - movup.5 - # => [note_idx, attachment_scheme, ATTACHMENT] - exec.output_note::set_array_attachment + # => [attachment_scheme, num_words, ptr, note_idx] + exec.output_note::add_attachment_from_memory # => [] # truncate the stack @@ -1200,10 +1435,10 @@ async fn test_set_array_attachment() -> anyhow::Result<()> { end ", RECIPIENT = output_note.recipient().unwrap().digest(), - note_type = output_note.metadata().note_type() as u8, + note_type = output_note.metadata().note_type().as_u8(), tag = output_note.metadata().tag().as_u32(), - attachment_scheme = output_note.metadata().attachment().attachment_scheme().as_u32(), - ATTACHMENT = output_note.metadata().to_attachment_word(), + attachment_scheme = output_note.attachments().get(0).unwrap().attachment_scheme().as_u16(), + num_words = words.len(), ); let tx_script = CodeBuilder::new().compile_tx_script(tx_script)?; @@ -1211,12 +1446,13 @@ async fn test_set_array_attachment() -> anyhow::Result<()> { let tx = TransactionContextBuilder::new(account) .extend_expected_output_notes(vec![output_note.clone()]) .tx_script(tx_script) - .extend_advice_map(vec![(output_note.metadata().to_attachment_word(), elements)]) .build()? .execute() .await?; let actual_note = tx.output_notes().get_note(0); + assert_eq!(actual_note.attachments().num_attachments(), 1); + assert_eq!(actual_note.attachments().get(0).unwrap(), &attachment); assert_eq!(actual_note.header(), output_note.header()); assert_eq!(actual_note.assets(), output_note.assets()); @@ -1229,7 +1465,7 @@ async fn test_set_network_target_account_attachment() -> anyhow::Result<()> { let account = Account::mock(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET, Auth::IncrNonce); let rng = RandomCoin::new(Word::from([1, 2, 3, 4u32])); let attachment = NetworkAccountTarget::new( - ACCOUNT_ID_NETWORK_NON_FUNGIBLE_FAUCET.try_into()?, + ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET.try_into()?, NoteExecutionHint::on_block_slot(5, 32, 3), )?; let output_note = NoteBuilder::new(account.id(), rng) @@ -1249,7 +1485,8 @@ async fn test_set_network_target_account_attachment() -> anyhow::Result<()> { assert_eq!(actual_note.assets(), output_note.assets()); // Make sure we can deserialize the attachment back into its original type. - let actual_attachment = NetworkAccountTarget::try_from(actual_note.metadata().attachment())?; + let actual_attachment = + NetworkAccountTarget::try_from(actual_note.attachments().get(0).unwrap())?; assert_eq!(actual_attachment, attachment); Ok(()) @@ -1261,7 +1498,7 @@ async fn test_network_note() -> anyhow::Result<()> { let mut rng = RandomCoin::new(Word::from([9, 8, 7, 6u32])); // --- Valid network note --- - let target_id = AccountId::try_from(ACCOUNT_ID_NETWORK_NON_FUNGIBLE_FAUCET)?; + let target_id = AccountId::try_from(ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET)?; let attachment = NetworkAccountTarget::new(target_id, NoteExecutionHint::Always)?; let note = NoteBuilder::new(sender.id(), &mut rng) @@ -1324,6 +1561,475 @@ async fn test_network_note() -> anyhow::Result<()> { Ok(()) } +/// Test that `output_note::write_attachment_commitments_to_memory` returns the correct number of +/// attachments and writes the individual attachment commitments to memory at the destination +/// pointer. +#[tokio::test] +async fn test_write_attachment_commitments_to_memory() -> anyhow::Result<()> { + let account = Account::mock(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET, Auth::IncrNonce); + let rng = RandomCoin::new(Word::from([1, 2, 3, 4u32])); + + let attachment_0 = + NoteAttachment::with_word(NoteAttachmentScheme::new(1)?, Word::from([3, 4, 5, 6u32])); + let attachment_1 = + NoteAttachment::with_word(NoteAttachmentScheme::new(2)?, Word::from([7, 8, 9, 10u32])); + + let output_note = RawOutputNote::Full( + NoteBuilder::new(account.id(), rng) + .attachment(attachment_0.clone()) + .attachment(attachment_1.clone()) + .build()?, + ); + + let commitment_0 = attachment_0.to_commitment(); + let commitment_1 = attachment_1.to_commitment(); + + let tx_script = format!( + " + use miden::protocol::output_note + use miden::core::sys + + const DEST_PTR = 0x1000 + + begin + push.{RECIPIENT} + push.{note_type} + push.{tag} + exec.output_note::create + # => [note_idx] + + # add first word attachment (note_idx = 0) + push.{ATTACHMENT_WORD_0} + push.{attachment_scheme_0} + # => [attachment_scheme, ATTACHMENT, note_idx] + exec.output_note::add_word_attachment + # => [] + + # add second word attachment + push.0 + push.{ATTACHMENT_WORD_1} + push.{attachment_scheme_1} + # => [attachment_scheme, ATTACHMENT, note_idx=0] + exec.output_note::add_word_attachment + # => [] + + # write attachment commitments for note at index 0 to DEST_PTR + push.0 push.DEST_PTR + # => [dest_ptr, note_idx=0] + exec.output_note::write_attachment_commitments_to_memory + # => [num_attachments] + + # assert num_attachments == 2 + eq.2 assert.err=\"expected 2 attachments\" + # => [] + + # read commitment 0 from memory at DEST_PTR and assert + padw push.DEST_PTR mem_loadw_le + # => [COMMITMENT_0] + push.{EXPECTED_COMMITMENT_0} + assert_eqw.err=\"attachment commitment 0 mismatch\" + # => [] + + # read commitment 1 from DEST_PTR + WORD_SIZE + padw push.DEST_PTR add.4 mem_loadw_le + # => [COMMITMENT_1] + push.{EXPECTED_COMMITMENT_1} + assert_eqw.err=\"attachment commitment 1 mismatch\" + # => [] + + # truncate the stack + exec.sys::truncate_stack + end + ", + RECIPIENT = output_note.recipient().unwrap().digest(), + note_type = output_note.metadata().note_type() as u8, + tag = output_note.metadata().tag().as_u32(), + attachment_scheme_0 = attachment_0.attachment_scheme().as_u16(), + ATTACHMENT_WORD_0 = Word::from([3, 4, 5, 6u32]), + attachment_scheme_1 = attachment_1.attachment_scheme().as_u16(), + ATTACHMENT_WORD_1 = Word::from([7, 8, 9, 10u32]), + EXPECTED_COMMITMENT_0 = commitment_0, + EXPECTED_COMMITMENT_1 = commitment_1, + ); + + let tx_script = CodeBuilder::new().compile_tx_script(tx_script)?; + + let tx = TransactionContextBuilder::new(account) + .extend_expected_output_notes(vec![output_note.clone()]) + .tx_script(tx_script) + .build()? + .execute() + .await?; + + let actual_note = tx.output_notes().get_note(0); + assert_eq!(actual_note.header(), output_note.header()); + + Ok(()) +} + +/// Test that `output_note::write_attachment_to_memory` retrieves the correct attachment data from +/// the advice map and writes it to the destination pointer. +#[tokio::test] +async fn test_write_attachment_to_memory() -> anyhow::Result<()> { + let account = Account::mock(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET, Auth::IncrNonce); + let rng = RandomCoin::new(Word::from([1, 2, 3, 4u32])); + + let attachment0_word = Word::from([3, 4, 5, 6u32]); + let attachment1_word0 = Word::from([7, 8, 9, 10u32]); + let attachment1_word1 = Word::from([11, 12, 13, 14u32]); + + let attachment_0 = NoteAttachment::with_word(NoteAttachmentScheme::new(1)?, attachment0_word); + let attachment_1 = NoteAttachment::with_words( + NoteAttachmentScheme::new(2)?, + [attachment1_word0, attachment1_word1].to_vec(), + )?; + + let output_note = RawOutputNote::Full( + NoteBuilder::new(account.id(), rng) + .attachment(attachment_0.clone()) + .attachment(attachment_1.clone()) + .build()?, + ); + + let tx_script = format!( + r#" + use miden::protocol::output_note + use miden::core::sys + + const ATTACHMENT_2_PTR = 1024 + const ATTACHMENT_2_WORD_0_PTR = ATTACHMENT_2_PTR + const ATTACHMENT_2_WORD_1_PTR = ATTACHMENT_2_PTR + 4 + + const ATTACHMENT_DEST_PTR = 2048 + + begin + push.{RECIPIENT} + push.{note_type} + push.{tag} + exec.output_note::create + # => [note_idx] + + # add first word attachment (note_idx = 0) + push.{attachment0_word} + push.{attachment_scheme_0} + # => [attachment_scheme, ATTACHMENT, note_idx] + exec.output_note::add_word_attachment + # => [] + + # write attachment elements to memory + push.{attachment1_word0} mem_storew_le.ATTACHMENT_2_WORD_0_PTR dropw + push.{attachment1_word1} mem_storew_le.ATTACHMENT_2_WORD_1_PTR dropw + # => [] + + # add second attachment + push.0 + push.ATTACHMENT_2_PTR + push.{attachment1_num_words} + push.{attachment_scheme_1} + # => [attachment_scheme, num_words, attachment_ptr, note_idx=0] + exec.output_note::add_attachment_from_memory + # => [] + + # --- validate attachment 0 --- + push.0 push.0 push.ATTACHMENT_DEST_PTR + # => [dest_ptr, attachment_idx=0, note_idx=0] + exec.output_note::write_attachment_to_memory + # => [num_words] + + eq.{attachment0_num_words} + assert.err="expected attachment 0 to have {attachment0_num_words} words" + # => [] + + padw mem_loadw_le.ATTACHMENT_DEST_PTR + push.{attachment0_word} + assert_eqw.err="attachment 0 word mismatch" + + # --- validate attachment 1 --- + push.0 push.1 push.ATTACHMENT_DEST_PTR + # => [dest_ptr, attachment_idx=1, note_idx=0] + exec.output_note::write_attachment_to_memory + # => [num_words] + + eq.{attachment1_num_words} + assert.err="expected attachment 1 to have {attachment1_num_words} words" + # => [] + + # validate first word in attachment_ptr + padw mem_loadw_le.ATTACHMENT_DEST_PTR + # => [ATTACHMENT1_WORD0, attachment_ptr] + push.{attachment1_word0} + assert_eqw.err="attachment 1 word 0 mismatch" + # => [attachment_ptr] + + # validate second word in attachment_ptr (offset by 4) + padw push.ATTACHMENT_DEST_PTR add.4 mem_loadw_le + # => [ATTACHMENT1_WORD1] + push.{attachment1_word1} + assert_eqw.err="attachment 1 word 1 mismatch" + # => [] + + # truncate the stack + exec.sys::truncate_stack + end + "#, + RECIPIENT = output_note.recipient().unwrap().digest(), + note_type = output_note.metadata().note_type() as u8, + tag = output_note.metadata().tag().as_u32(), + attachment_scheme_0 = attachment_0.attachment_scheme().as_u16(), + attachment_scheme_1 = attachment_1.attachment_scheme().as_u16(), + attachment0_num_words = attachment_0.num_words(), + attachment1_num_words = attachment_1.num_words(), + ); + + let tx_script = CodeBuilder::new().compile_tx_script(tx_script)?; + + let tx = TransactionContextBuilder::new(account) + .extend_expected_output_notes(vec![output_note.clone()]) + .tx_script(tx_script) + .build()? + .execute() + .await?; + + let actual_note = tx.output_notes().get_note(0); + assert_eq!(actual_note.header(), output_note.header()); + + Ok(()) +} + +/// Tests `output_note::find_attachment` for both the found and not-found cases. +/// +/// Setup: a SPAWN note creates an output note with two word attachments (schemes 10 and 20). +/// The tx_script then calls `find_attachment` on the created output note. +/// +/// - `found`: search for scheme 10 → is_found=1, attachment_idx=0. +/// - `not_found`: search for scheme 99 → is_found=0. +#[rstest] +#[case::found(20, true, 1)] +#[case::not_found(99, false, 0)] +#[tokio::test] +async fn test_find_attachment( + #[case] search_scheme: u16, + #[case] expected_found: bool, + #[case] expected_idx: u8, +) -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let account = builder.add_existing_wallet(Auth::IncrNonce)?; + + let word_0 = Word::from([3, 4, 5, 6u32]); + let word_1 = Word::from([7, 8, 9, 10u32]); + let scheme_0 = NoteAttachmentScheme::new(10)?; + let scheme_1 = NoteAttachmentScheme::new(20)?; + + let output_note = NoteBuilder::new(account.id(), RandomCoin::new(Word::from([1, 2, 3, 4u32]))) + .note_type(NoteType::Public) + .attachment(NoteAttachment::with_word(scheme_0, word_0)) + .attachment(NoteAttachment::with_word(scheme_1, word_1)) + .build()?; + + let spawn_note = builder.add_spawn_note([&output_note])?; + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + let tx_script = format!( + r#" + use miden::protocol::output_note + use miden::core::sys + + const DEST_PTR = 0x1000 + + begin + # the spawn note creates output note at index 0; + # search for the target scheme on that note + push.0 + push.{search_scheme} + # => [attachment_scheme, note_idx=0] + exec.output_note::find_attachment + # => [is_found, attachment_idx] + + # assert is_found matches expectation + push.{expected_found} assert_eq.err="is_found mismatch" + # => [attachment_idx] + + push.{expected_found} + if.true + # found path: verify attachment_idx matches expectation + push.{expected_idx} assert_eq.err="attachment_idx mismatch" + # => [] + + # write the found attachment to memory and read it back + push.0 push.{expected_idx} push.DEST_PTR + # => [dest_ptr, attachment_idx, note_idx=0] + exec.output_note::write_attachment_to_memory + # => [num_words] + + eq.1 assert.err="expected num_words=1" + # => [] + + # read the word from memory and assert it matches + padw push.DEST_PTR mem_loadw_le + # => [ATTACHMENT_WORD] + + push.{EXPECTED_WORD} + assert_eqw.err="attachment data mismatch" + # => [] + else + # not-found path: drop the (undefined) attachment_idx + drop + # => [] + end + + # truncate the stack + exec.sys::truncate_stack + end + "#, + expected_found = expected_found as u8, + EXPECTED_WORD = word_1, + ); + + let tx_script = CodeBuilder::new().compile_tx_script(tx_script)?; + + let tx = mock_chain + .build_tx_context(account.id(), &[spawn_note.id()], &[])? + .extend_expected_output_notes(vec![RawOutputNote::Full(output_note.clone())]) + .tx_script(tx_script) + .build()? + .execute() + .await?; + + let actual_note = tx.output_notes().get_note(0); + assert_eq!(actual_note.header(), output_note.header()); + + Ok(()) +} + +#[tokio::test] +async fn test_add_attachments_with_too_many_overall_elements_fails() -> anyhow::Result<()> { + let attachment0 = NoteAttachment::with_words( + NoteAttachmentScheme::new_const(3), + vec![Word::from([1, 2, 3, 4u32]); NoteAttachment::MAX_NUM_WORDS as usize], + )?; + let attachment1 = NoteAttachment::with_words( + NoteAttachmentScheme::new_const(6), + vec![Word::from([2, 3, 4, 5u32]); NoteAttachment::MAX_NUM_WORDS as usize], + )?; + + let tx_context = TransactionContextBuilder::with_existing_mock_account() + .extend_advice_map(vec![(attachment0.to_commitment(), attachment0.content().to_elements())]) + .extend_advice_map(vec![(attachment1.to_commitment(), attachment1.content().to_elements())]) + .build()?; + + let code = format!( + " + use miden::protocol::output_note + use miden::standards::note_tag::DEFAULT_TAG + use $kernel::prologue + use mock::util + + begin + exec.prologue::prepare_transaction + + exec.util::create_default_note + # => [note_idx] + + dup push.{ATTACHMENT_0_COMMITMENT} push.{attachment0_scheme} + # => [attachment_scheme, ATTACHMENT_COMMITMENT, note_idx] + + exec.output_note::add_attachment + # => [note_idx] + + dup push.{ATTACHMENT_1_COMMITMENT} push.{attachment1_scheme} + # => [attachment_scheme, ATTACHMENT_COMMITMENT, note_idx] + + exec.output_note::add_attachment + # => [note_idx] + + # add one more word which pushes the overall limit of 512 words over the edge + push.1.2.3.4 push.5 + exec.output_note::add_word_attachment + # => [] + end + ", + attachment0_scheme = attachment0.attachment_scheme().as_u16(), + attachment1_scheme = attachment1.attachment_scheme().as_u16(), + ATTACHMENT_0_COMMITMENT = attachment0.to_commitment(), + ATTACHMENT_1_COMMITMENT = attachment1.to_commitment(), + ); + + let exec_output = tx_context.execute_code(&code).await; + + assert_execution_error!(exec_output, ERR_OUTPUT_NOTE_TOTAL_ATTACHMENT_WORDS_EXCEEDED); + + Ok(()) +} + +/// Test that output_note procedures abort when given an out-of-bounds note index (equal to +/// num_output_notes). +/// +/// Each case creates one note via `mock::util::create_default_note` (index 0), then calls the +/// procedure under test with index 1, which is out of bounds. The bounds assertion fires before +/// any parameter validation, so dummy values are sufficient. +#[rstest] +#[case::add_asset(8, "add_asset")] +#[case::get_assets_info(0, "get_assets_info")] +#[case::get_assets(1, "get_assets")] +#[case::get_recipient(0, "get_recipient")] +#[case::get_metadata(0, "get_metadata")] +#[case::add_attachment(5, "add_attachment")] +#[case::add_word_attachment(5, "add_word_attachment")] +#[case::find_attachment(1, "find_attachment")] +#[case::write_attachment_commitments_to_memory(1, "write_attachment_commitments_to_memory")] +#[case::write_attachment_to_memory(2, "write_attachment_to_memory")] +#[case::get_attachments_commitment(0, "get_attachments_commitment")] +#[tokio::test] +async fn test_output_note_index_out_of_bounds( + #[case] params_above: usize, + #[case] procedure_name: &str, +) -> anyhow::Result<()> { + let tx_context = TransactionContextBuilder::with_existing_mock_account().build()?; + + let push_above = if params_above > 0 { + format!("repeat.{params_above} push.99 end") + } else { + String::new() + }; + + // Create one note (index 0), then try to call the procedure with index 1. + let code = format!( + " + use miden::protocol::output_note + use mock::util + + use $kernel::prologue + + begin + exec.prologue::prepare_transaction + + exec.util::create_default_note + # => [note_idx = 0] + drop + # => [] + + # push the out-of-bounds index (1 == num_output_notes) + push.1 + # => [note_idx = 1] + + # push garbage parameters that should sit above note_idx + {push_above} + # => [params_above(n), note_idx = 1] + + # call the procedure under test with the invalid index + exec.output_note::{procedure_name} + end + ", + ); + + let exec_output = tx_context.execute_code(&code).await; + + assert_execution_error!(exec_output, ERR_OUTPUT_NOTE_INDEX_OUT_OF_BOUNDS); + Ok(()) +} + // HELPER FUNCTIONS // ================================================================================================ 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 3cb661b3a2..667eaea6fd 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_prologue.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_prologue.rs @@ -9,7 +9,6 @@ use miden_protocol::account::{ AccountBuilder, AccountHeader, AccountProcedureRoot, - AccountStorageMode, AccountType, StorageSlot, StorageSlotName, @@ -30,6 +29,8 @@ use miden_protocol::transaction::memory::{ BLOCK_METADATA_PTR, BLOCK_NUMBER_IDX, CHAIN_COMMITMENT_PTR, + FEE_FAUCET_ID_PREFIX_IDX, + FEE_FAUCET_ID_SUFFIX_IDX, FEE_PARAMETERS_PTR, GLOBAL_ACCOUNT_ID_PREFIX_PTR, GLOBAL_ACCOUNT_ID_SUFFIX_PTR, @@ -40,9 +41,9 @@ use miden_protocol::transaction::memory::{ INPUT_NOTE_ARGS_OFFSET, INPUT_NOTE_ASSETS_COMMITMENT_OFFSET, INPUT_NOTE_ASSETS_OFFSET, - INPUT_NOTE_ATTACHMENT_OFFSET, - INPUT_NOTE_ID_OFFSET, - INPUT_NOTE_METADATA_HEADER_OFFSET, + INPUT_NOTE_ATTACHMENTS_COMMITMENT_OFFSET, + INPUT_NOTE_DETAILS_COMMITMENT_OFFSET, + INPUT_NOTE_METADATA_OFFSET, INPUT_NOTE_NULLIFIER_SECTION_PTR, INPUT_NOTE_NUM_ASSETS_OFFSET, INPUT_NOTE_RECIPIENT_OFFSET, @@ -58,8 +59,6 @@ use miden_protocol::transaction::memory::{ NATIVE_ACCT_STORAGE_COMMITMENT_PTR, NATIVE_ACCT_STORAGE_SLOTS_SECTION_PTR, NATIVE_ACCT_VAULT_ROOT_PTR, - NATIVE_ASSET_ID_PREFIX_IDX, - NATIVE_ASSET_ID_SUFFIX_IDX, NATIVE_NUM_ACCT_PROCEDURES_PTR, NATIVE_NUM_ACCT_STORAGE_SLOTS_PTR, NOTE_ROOT_PTR, @@ -281,21 +280,21 @@ fn block_data_memory_assertions(exec_output: &ExecutionOutput, inputs: &Transact ); assert_eq!( - exec_output.get_kernel_mem_word(FEE_PARAMETERS_PTR)[NATIVE_ASSET_ID_SUFFIX_IDX], - inputs.tx_inputs().block_header().fee_parameters().native_asset_id().suffix(), - "The native asset ID suffix should be stored at FEE_PARAMETERS_PTR[NATIVE_ASSET_ID_SUFFIX_IDX]" + exec_output.get_kernel_mem_word(FEE_PARAMETERS_PTR)[FEE_FAUCET_ID_SUFFIX_IDX], + inputs.tx_inputs().block_header().fee_parameters().fee_faucet_id().suffix(), + "The fee faucet ID suffix should be stored at FEE_PARAMETERS_PTR[FEE_FAUCET_ID_SUFFIX_IDX]" ); assert_eq!( - exec_output.get_kernel_mem_word(FEE_PARAMETERS_PTR)[NATIVE_ASSET_ID_PREFIX_IDX], + exec_output.get_kernel_mem_word(FEE_PARAMETERS_PTR)[FEE_FAUCET_ID_PREFIX_IDX], inputs .tx_inputs() .block_header() .fee_parameters() - .native_asset_id() + .fee_faucet_id() .prefix() .as_felt(), - "The native asset ID prefix should be stored at FEE_PARAMETERS_PTR[NATIVE_ASSET_ID_PREFIX_IDX]" + "The fee faucet ID prefix should be stored at FEE_PARAMETERS_PTR[FEE_FAUCET_ID_PREFIX_IDX]" ); assert_eq!( @@ -322,7 +321,7 @@ fn partial_blockchain_memory_assertions( assert_eq!( exec_output.get_kernel_mem_word(PARTIAL_BLOCKCHAIN_NUM_LEAVES_PTR)[0], - Felt::new(partial_blockchain.chain_length().as_u64()), + Felt::from(partial_blockchain.chain_length()), "The number of leaves should be stored at the PARTIAL_BLOCKCHAIN_NUM_LEAVES_PTR" ); @@ -416,7 +415,7 @@ fn account_data_memory_assertions(exec_output: &ExecutionOutput, inputs: &Transa for (i, elements) in inputs .account() .code() - .as_elements() + .to_elements() .chunks(AccountProcedureRoot::NUM_ELEMENTS) .enumerate() { @@ -452,9 +451,9 @@ fn input_notes_memory_assertions( ); assert_eq!( - exec_output.get_note_mem_word(note_idx, INPUT_NOTE_ID_OFFSET), - note.id().as_word(), - "ID hash should be computed and stored at the correct offset" + exec_output.get_note_mem_word(note_idx, INPUT_NOTE_DETAILS_COMMITMENT_OFFSET), + note.details_commitment().as_word(), + "note details commitment should be computed and stored at INPUT_NOTE_DETAILS_COMMITMENT_OFFSET" ); assert_eq!( @@ -465,7 +464,7 @@ fn input_notes_memory_assertions( assert_eq!( exec_output.get_note_mem_word(note_idx, INPUT_NOTE_SCRIPT_ROOT_OFFSET), - note.script().root(), + note.script().root().into(), "note script root should be stored at the correct offset" ); @@ -488,14 +487,14 @@ fn input_notes_memory_assertions( ); assert_eq!( - exec_output.get_note_mem_word(note_idx, INPUT_NOTE_METADATA_HEADER_OFFSET), - note.metadata().to_header_word(), - "note metadata header should be stored at the correct offset" + exec_output.get_note_mem_word(note_idx, INPUT_NOTE_METADATA_OFFSET), + note.metadata().to_metadata_word(), + "note metadata should be stored at the correct offset" ); assert_eq!( - exec_output.get_note_mem_word(note_idx, INPUT_NOTE_ATTACHMENT_OFFSET), - note.metadata().to_attachment_word(), + exec_output.get_note_mem_word(note_idx, INPUT_NOTE_ATTACHMENTS_COMMITMENT_OFFSET), + note.attachments().to_commitment(), "note attachment should be stored at the correct offset" ); @@ -541,7 +540,7 @@ fn input_notes_memory_assertions( #[tokio::test] async fn create_simple_account() -> anyhow::Result<()> { let account = AccountBuilder::new([6; 32]) - .storage_mode(AccountStorageMode::Public) + .account_type(AccountType::Public) .with_auth_component(Auth::IncrNonce) .with_component(MockAccountComponent::with_empty_slots()) .build()?; @@ -552,11 +551,11 @@ async fn create_simple_account() -> anyhow::Result<()> { .await .context("failed to execute account-creating transaction")?; - assert_eq!(tx.account_delta().nonce_delta(), Felt::new(1)); + assert_eq!(tx.account_delta().nonce_delta(), 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_eq!(tx.final_account().nonce(), Felt::new(1)); + 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); @@ -571,33 +570,22 @@ pub async fn create_account_test( TransactionContextBuilder::new(account).build().unwrap().execute().await } -pub async fn create_multiple_accounts_test(storage_mode: AccountStorageMode) -> anyhow::Result<()> { +pub async fn create_multiple_accounts_test(account_type: AccountType) -> anyhow::Result<()> { let mut accounts = Vec::new(); - for account_type in [ - AccountType::RegularAccountImmutableCode, - AccountType::RegularAccountUpdatableCode, - AccountType::FungibleFaucet, - AccountType::NonFungibleFaucet, - ] { - let account = AccountBuilder::new(ChaCha20Rng::from_os_rng().random()) - .account_type(account_type) - .storage_mode(storage_mode) - .with_auth_component(Auth::IncrNonce) - .with_component(MockAccountComponent::with_slots(vec![StorageSlot::with_value( - StorageSlotName::mock(0), - Word::from([255u32; WORD_SIZE]), - )])) - .build() - .with_context(|| { - format!("account build for {account_type} and {storage_mode} failed") - })?; - - accounts.push(account); - } + let account = AccountBuilder::new(ChaCha20Rng::from_os_rng().random()) + .account_type(account_type) + .with_auth_component(Auth::IncrNonce) + .with_component(MockAccountComponent::with_slots(vec![StorageSlot::with_value( + StorageSlotName::mock(0), + Word::from([255u32; WORD_SIZE]), + )])) + .build() + .with_context(|| format!("account build with account type {account_type} failed"))?; + + accounts.push(account); for account in accounts { - let account_type = account.account_type(); create_account_test(account).await.context(format!( "create_multiple_accounts_test test failed for account type {account_type}" ))?; @@ -606,14 +594,12 @@ pub async fn create_multiple_accounts_test(storage_mode: AccountStorageMode) -> Ok(()) } -/// Tests that a valid account of each storage mode can be created successfully. +/// Tests that a valid account of each account type can be created successfully. #[tokio::test] pub async fn create_accounts_with_all_storage_modes() -> anyhow::Result<()> { - create_multiple_accounts_test(AccountStorageMode::Private).await?; - - create_multiple_accounts_test(AccountStorageMode::Public).await?; + create_multiple_accounts_test(AccountType::Private).await?; - create_multiple_accounts_test(AccountStorageMode::Network).await + create_multiple_accounts_test(AccountType::Public).await } /// Tests that supplying an invalid seed causes account creation to fail. @@ -623,7 +609,6 @@ pub async fn create_account_invalid_seed() -> anyhow::Result<()> { mock_chain.prove_next_block()?; let account = AccountBuilder::new(ChaCha20Rng::from_os_rng().random()) - .account_type(AccountType::RegularAccountUpdatableCode) .with_auth_component(Auth::IncrNonce) .with_component(BasicWallet) .build()?; 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 30233755ee..c82587ced1 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_tx.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_tx.rs @@ -11,7 +11,6 @@ use miden_protocol::account::{ AccountCode, AccountComponent, AccountStorage, - AccountStorageMode, AccountType, StorageSlot, StorageSlotName, @@ -24,15 +23,15 @@ use miden_protocol::note::{ Note, NoteAssets, NoteAttachment, - NoteAttachmentContent, NoteAttachmentScheme, - NoteHeader, + NoteAttachments, + NoteDetailsCommitment, NoteId, - NoteMetadata, NoteRecipient, NoteStorage, NoteTag, NoteType, + PartialNoteMetadata, }; use miden_protocol::testing::account_id::{ ACCOUNT_ID_PRIVATE_SENDER, @@ -218,10 +217,10 @@ async fn executed_transaction_output_notes() -> anyhow::Result<()> { let tag3 = NoteTag::default(); let attachment2 = - NoteAttachment::new_word(NoteAttachmentScheme::new(28), Word::from([2, 3, 4, 5u32])); - let attachment3 = NoteAttachment::new_array( - NoteAttachmentScheme::new(29), - [6, 7, 8, 9u32].map(Felt::from).to_vec(), + NoteAttachment::with_word(NoteAttachmentScheme::new(28)?, Word::from([2, 3, 4, 5u32])); + let attachment3 = NoteAttachment::with_words( + NoteAttachmentScheme::new(29)?, + vec![Word::from([6, 7, 8, 9u32]), Word::from([10, 11, 12, 13u32])], )?; let note_type1 = NoteType::Private; @@ -237,23 +236,24 @@ async fn executed_transaction_output_notes() -> anyhow::Result<()> { let serial_num_2 = Word::from([1, 2, 3, 4u32]); let note_script_2 = CodeBuilder::default().compile_note_script(DEFAULT_NOTE_SCRIPT)?; let inputs_2 = NoteStorage::new(vec![ONE])?; - let metadata_2 = NoteMetadata::new(account_id, note_type2) - .with_tag(tag2) - .with_attachment(attachment2.clone()); + let metadata_2 = PartialNoteMetadata::new(account_id, note_type2).with_tag(tag2); let vault_2 = NoteAssets::new(vec![removed_asset_3, removed_asset_4])?; let recipient_2 = NoteRecipient::new(serial_num_2, note_script_2, inputs_2); - let expected_output_note_2 = Note::new(vault_2, metadata_2, recipient_2); + let attachments_2 = NoteAttachments::from(attachment2.clone()); + let expected_output_note_2 = + Note::with_attachments(vault_2, metadata_2, recipient_2, attachments_2); // Create the expected output note for Note 3 which is public - let serial_num_3 = Word::from([Felt::new(5), Felt::new(6), Felt::new(7), Felt::new(8)]); + let serial_num_3 = + Word::from([Felt::from(5_u32), Felt::from(6_u32), Felt::from(7_u32), Felt::from(8_u32)]); let note_script_3 = CodeBuilder::default().compile_note_script(DEFAULT_NOTE_SCRIPT)?; - let inputs_3 = NoteStorage::new(vec![ONE, Felt::new(2)])?; - let metadata_3 = NoteMetadata::new(account_id, note_type3) - .with_tag(tag3) - .with_attachment(attachment3.clone()); + let inputs_3 = NoteStorage::new(vec![ONE, Felt::from(2_u32)])?; + let metadata_3 = PartialNoteMetadata::new(account_id, note_type3).with_tag(tag3); let vault_3 = NoteAssets::new(vec![])?; let recipient_3 = NoteRecipient::new(serial_num_3, note_script_3, inputs_3); - let expected_output_note_3 = Note::new(vault_3, metadata_3, recipient_3); + let attachments_3 = NoteAttachments::from(attachment3.clone()); + let expected_output_note_3 = + Note::with_attachments(vault_3, metadata_3, recipient_3, attachments_3); let tx_script_src = format!( "\ @@ -307,8 +307,8 @@ async fn executed_transaction_output_notes() -> anyhow::Result<()> { push.{ATTACHMENT2} push.{attachment_scheme2} - movup.5 - exec.output_note::set_word_attachment + # => [attachment_scheme, ATTACHMENT, note_idx] + exec.output_note::add_word_attachment # => [] # create a public note without assets @@ -318,10 +318,15 @@ async fn executed_transaction_output_notes() -> anyhow::Result<()> { exec.output_note::create # => [note_idx = 2] - push.{ATTACHMENT3} + # Store attachment3 words to memory at address 1024 + push.{attachment3_word0} mem_storew_le.1024 dropw + push.{attachment3_word1} mem_storew_le.1028 dropw + + push.1024 + push.{num_attachment3_words} push.{attachment_scheme3} - movup.5 - exec.output_note::set_array_attachment + # => [attachment_scheme, num_words, ptr, note_idx] + exec.output_note::add_attachment_from_memory # => [] end ", @@ -338,10 +343,12 @@ async fn executed_transaction_output_notes() -> anyhow::Result<()> { NOTETYPE1 = note_type1 as u8, NOTETYPE2 = note_type2 as u8, NOTETYPE3 = note_type3 as u8, - attachment_scheme2 = attachment2.attachment_scheme().as_u32(), - ATTACHMENT2 = attachment2.content().to_word(), - attachment_scheme3 = attachment3.attachment_scheme().as_u32(), - ATTACHMENT3 = attachment3.content().to_word(), + attachment_scheme2 = attachment2.attachment_scheme().as_u16(), + ATTACHMENT2 = Word::from([2, 3, 4, 5u32]), + attachment_scheme3 = attachment3.attachment_scheme().as_u16(), + attachment3_word0 = attachment3.content().as_words()[0], + attachment3_word1 = attachment3.content().as_words()[1], + num_attachment3_words = attachment3.content().num_words(), ); let tx_script = CodeBuilder::with_mock_libraries().compile_tx_script(tx_script_src)?; @@ -350,13 +357,10 @@ async fn executed_transaction_output_notes() -> anyhow::Result<()> { // -------------------------------------------------------------------------------------------- // execute the transaction and get the witness - let NoteAttachmentContent::Array(array) = attachment3.content() else { - panic!("expected array attachment"); - }; + assert!(attachment3.content().num_words() > 1, "expected multi-word attachment"); let tx_context = TransactionContextBuilder::new(executor_account) .tx_script(tx_script) - .extend_advice_map(vec![(attachment3.content().to_word(), array.as_slice().to_vec())]) .extend_expected_output_notes(vec![ RawOutputNote::Full(expected_output_note_2.clone()), RawOutputNote::Full(expected_output_note_3.clone()), @@ -376,18 +380,17 @@ async fn executed_transaction_output_notes() -> anyhow::Result<()> { let resulting_output_note_1 = executed_transaction.output_notes().get_note(0); let expected_note_assets_1 = NoteAssets::new(vec![combined_asset])?; - let expected_note_id_1 = NoteId::new(recipient_1, expected_note_assets_1.commitment()); + let details_commitment_1 = NoteDetailsCommitment::from_raw_commitments( + recipient_1, + expected_note_assets_1.commitment(), + ); + let expected_note_id_1 = NoteId::new(details_commitment_1, resulting_output_note_1.metadata()); assert_eq!(resulting_output_note_1.id(), expected_note_id_1); // assert that the expected output note 2 is present let resulting_output_note_2 = executed_transaction.output_notes().get_note(1); - let expected_note_id_2 = expected_output_note_2.id(); - let expected_note_metadata_2 = expected_output_note_2.metadata().clone(); - assert_eq!( - *resulting_output_note_2.header(), - NoteHeader::new(expected_note_id_2, expected_note_metadata_2) - ); + assert_eq!(*resulting_output_note_2.header(), *expected_output_note_2.header()); // assert that the expected output note 3 is present and has no assets let resulting_output_note_3 = executed_transaction.output_notes().get_note(2); @@ -459,7 +462,7 @@ async fn user_code_can_abort_transaction_with_summary() -> anyhow::Result<()> { .context("failed to parse auth component")?; let account = AccountBuilder::new([42; 32]) - .storage_mode(AccountStorageMode::Private) + .account_type(AccountType::Private) .with_auth_component(auth_component) .with_component(BasicWallet) .build_existing() @@ -472,7 +475,7 @@ async fn user_code_can_abort_transaction_with_summary() -> anyhow::Result<()> { account.id(), vec![], NoteType::Private, - NoteAttachment::default(), + NoteAttachments::default(), &mut rng, )?; let input_note = create_spawn_note(vec![&output_note])?; @@ -517,7 +520,7 @@ async fn tx_summary_commitment_is_signed_by_falcon_auth() -> anyhow::Result<()> account.id(), vec![], NoteType::Private, - NoteAttachment::default(), + NoteAttachments::default(), &mut rng, )?; let spawn_note = builder.add_spawn_note([&p2id_note])?; @@ -549,6 +552,9 @@ async fn tx_summary_commitment_is_signed_by_falcon_auth() -> anyhow::Result<()> 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"), }; @@ -576,7 +582,7 @@ async fn tx_summary_commitment_is_signed_by_ecdsa_auth() -> anyhow::Result<()> { account.id(), vec![], NoteType::Private, - NoteAttachment::default(), + NoteAttachments::default(), &mut rng, )?; let spawn_note = builder.add_spawn_note([&p2id_note])?; @@ -608,6 +614,9 @@ async fn tx_summary_commitment_is_signed_by_ecdsa_auth() -> anyhow::Result<()> { 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"), }; @@ -668,7 +677,7 @@ async fn execute_tx_view_script() -> anyhow::Result<()> { .execute_tx_view_script(account_id, block_ref, tx_script, advice_inputs) .await?; - assert_eq!(stack_outputs[..3], [Felt::new(7), Felt::new(2), ONE]); + assert_eq!(stack_outputs[..3], [Felt::new_unchecked(7), Felt::new_unchecked(2), ONE]); Ok(()) } @@ -752,6 +761,62 @@ async fn test_tx_script_args() -> anyhow::Result<()> { Ok(()) } +/// Tests that `tx::get_tx_script_root` returns the root of the executed transaction script. +#[tokio::test] +async fn test_get_script_root_with_script() -> anyhow::Result<()> { + let tx_script = CodeBuilder::default().compile_tx_script("begin nop end")?; + let expected_root = tx_script.root(); + + let code = format!( + r#" + use miden::protocol::tx + use $kernel::prologue + + begin + exec.prologue::prepare_transaction + + exec.tx::get_tx_script_root + # => [TX_SCRIPT_ROOT] + + push.{expected_root} assert_eqw.err="tx script root mismatch" + end + "# + ); + + let tx_context = TransactionContextBuilder::with_existing_mock_account() + .tx_script(tx_script) + .build()?; + + tx_context.execute_code(&code).await?; + + Ok(()) +} + +/// Tests that `tx::get_tx_script_root` returns the empty word when no transaction script is +/// executed. +#[tokio::test] +async fn test_get_script_root_without_script() -> anyhow::Result<()> { + let code = r#" + use miden::protocol::tx + use $kernel::prologue + + begin + exec.prologue::prepare_transaction + + exec.tx::get_tx_script_root + # => [TX_SCRIPT_ROOT] + + padw assert_eqw.err="tx script root must be zero when no script is executed" + end + "#; + + let tx_context = TransactionContextBuilder::with_existing_mock_account().build()?; + + tx_context.execute_code(code).await?; + + Ok(()) +} + // Tests that advice map from the account code and transaction script gets correctly passed as // part of the transaction advice inputs #[tokio::test] @@ -776,10 +841,8 @@ async fn inputs_created_correctly() -> anyhow::Result<()> { AccountComponentMetadata::mock("test::adv_map_component"), )?; - let account_code = AccountCode::from_components( - &[IncrNonceAuthComponent.into(), component.clone()], - AccountType::RegularAccountUpdatableCode, - )?; + let account_code = + AccountCode::from_components(&[IncrNonceAuthComponent.into(), component.clone()])?; let script = r#" adv_map A([1,2,3,4]) = [5,6,7,8] @@ -813,7 +876,7 @@ async fn inputs_created_correctly() -> anyhow::Result<()> { AssetVault::mock(), AccountStorage::mock(), account_code, - Felt::new(1u64), + Felt::new_unchecked(1u64), ); let tx_context = crate::TransactionContextBuilder::new(account).tx_script(tx_script).build()?; _ = tx_context.execute().await?; diff --git a/crates/miden-testing/src/lib.rs b/crates/miden-testing/src/lib.rs index 6763012635..c48e162f1e 100644 --- a/crates/miden-testing/src/lib.rs +++ b/crates/miden-testing/src/lib.rs @@ -19,6 +19,8 @@ pub use mock_chain::{ mod tx_context; pub use tx_context::{ExecError, TransactionContext, TransactionContextBuilder}; +pub mod asserts; + pub mod executor; mod mock_host; diff --git a/crates/miden-testing/src/mock_chain/auth.rs b/crates/miden-testing/src/mock_chain/auth.rs index 5b7f06b06a..732188d3ca 100644 --- a/crates/miden-testing/src/mock_chain/auth.rs +++ b/crates/miden-testing/src/mock_chain/auth.rs @@ -1,20 +1,26 @@ // AUTH // ================================================================================================ +use alloc::collections::BTreeSet; use alloc::vec::Vec; use miden_protocol::Word; -use miden_protocol::account::AccountComponent; use miden_protocol::account::auth::{AuthScheme, AuthSecretKey, PublicKeyCommitment}; +use miden_protocol::account::{AccountComponent, AccountProcedureRoot}; +use miden_protocol::note::NoteScriptRoot; use miden_protocol::testing::noop_auth_component::NoopAuthComponent; +use miden_standards::account::auth::multisig_smart::ProcedurePolicy; use miden_standards::account::auth::{ + AuthGuardedMultisig, + AuthGuardedMultisigConfig, AuthMultisig, AuthMultisigConfig, - AuthMultisigPsm, - AuthMultisigPsmConfig, + AuthMultisigSmart, + AuthMultisigSmartConfig, + AuthNetworkAccount, AuthSingleSig, AuthSingleSigAcl, AuthSingleSigAclConfig, - PsmConfig, + GuardianConfig, }; use miden_standards::testing::account_component::{ ConditionalAuthComponent, @@ -35,22 +41,29 @@ pub enum Auth { Multisig { threshold: u32, approvers: Vec<(PublicKeyCommitment, AuthScheme)>, - proc_threshold_map: Vec<(Word, u32)>, + proc_threshold_map: Vec<(AccountProcedureRoot, u32)>, }, - /// Multisig with a private state manager. - MultisigPsm { + /// Guarded multisig. + GuardedMultisig { threshold: u32, approvers: Vec<(PublicKeyCommitment, AuthScheme)>, - psm_config: PsmConfig, - proc_threshold_map: Vec<(Word, u32)>, + guardian_config: GuardianConfig, + proc_threshold_map: Vec<(AccountProcedureRoot, u32)>, + }, + + /// Multisig with smart per-procedure policy configuration. + MultisigSmart { + threshold: u32, + approvers: Vec<(PublicKeyCommitment, AuthScheme)>, + proc_policy_map: Vec<(Word, ProcedurePolicy)>, }, /// Creates a secret key for the account, and creates a [BasicAuthenticator] used to /// authenticate the account with [AuthSingleSigAcl]. Authentication will only be /// triggered if any of the procedures specified in the list are called during execution. Acl { - auth_trigger_procedures: Vec, + auth_trigger_procedures: Vec, allow_unauthorized_output_notes: bool, allow_unauthorized_input_notes: bool, auth_scheme: AuthScheme, @@ -68,6 +81,12 @@ pub enum Auth { /// The auth procedure expects the first three arguments as [99, 98, 97] to succeed. /// In case it succeeds, it conditionally increments the nonce based on the fourth argument. Conditional, + + /// Network-account authentication that restricts the account to consuming only notes whose + /// script roots appear in `allowed_script_roots`. Must be non-empty. + NetworkAccount { + allowed_script_roots: BTreeSet, + }, } impl Auth { @@ -96,17 +115,29 @@ impl Auth { (component, None) }, - Auth::MultisigPsm { + Auth::GuardedMultisig { threshold, approvers, - psm_config, + guardian_config, proc_threshold_map, } => { - let config = AuthMultisigPsmConfig::new(approvers.clone(), *threshold, *psm_config) - .and_then(|cfg| cfg.with_proc_thresholds(proc_threshold_map.clone())) - .expect("invalid multisig psm config"); - let component = AuthMultisigPsm::new(config) - .expect("multisig psm component creation failed") + let config = + AuthGuardedMultisigConfig::new(approvers.clone(), *threshold, *guardian_config) + .and_then(|cfg| cfg.with_proc_thresholds(proc_threshold_map.clone())) + .expect("invalid guarded multisig config"); + let component = AuthGuardedMultisig::new(config) + .expect("guarded multisig component creation failed") + .into(); + + (component, None) + }, + Auth::MultisigSmart { threshold, approvers, proc_policy_map } => { + let config = AuthMultisigSmartConfig::new(approvers.clone(), *threshold) + .and_then(|cfg| cfg.with_proc_policies(proc_policy_map.clone())) + .expect("invalid multisig smart config"); + + let component = AuthMultisigSmart::new(config) + .expect("multisig smart component creation failed") .into(); (component, None) @@ -139,6 +170,12 @@ 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(); + (component, None) + }, } } } diff --git a/crates/miden-testing/src/mock_chain/chain.rs b/crates/miden-testing/src/mock_chain/chain.rs index 08d9d7a7cc..d45fd81d73 100644 --- a/crates/miden-testing/src/mock_chain/chain.rs +++ b/crates/miden-testing/src/mock_chain/chain.rs @@ -19,7 +19,7 @@ use miden_protocol::block::{ ProposedBlock, ProvenBlock, }; -use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SecretKey; +use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; use miden_protocol::note::{Note, NoteHeader, NoteId, NoteInclusionProof, Nullifier}; use miden_protocol::transaction::{ ExecutedTransaction, @@ -64,7 +64,7 @@ use crate::{MockChainBuilder, TransactionContextBuilder}; /// # use anyhow::Result; /// # use miden_protocol::{ /// # account::auth::AuthScheme, -/// # asset::{Asset, FungibleAsset}, +/// # asset::{Asset, AssetCallbackFlag, FungibleAsset}, /// # note::NoteType, /// # }; /// # use miden_testing::{Auth, MockChain}; @@ -119,7 +119,7 @@ use crate::{MockChainBuilder, TransactionContextBuilder}; /// mock_chain /// .committed_account(receiver.id())? /// .vault() -/// .get_balance(fungible_asset.faucet_id())?, +/// .get_balance(fungible_asset.vault_key())?, /// fungible_asset.amount() /// ); /// # Ok(()) @@ -206,7 +206,7 @@ pub struct MockChain { account_authenticators: BTreeMap, /// Validator secret key used for signing blocks. - validator_secret_key: SecretKey, + validator_secret_key: SigningKey, } impl MockChain { @@ -238,7 +238,7 @@ impl MockChain { genesis_block: ProvenBlock, account_tree: AccountTree, account_authenticators: BTreeMap, - secret_key: SecretKey, + secret_key: SigningKey, genesis_notes: Vec, ) -> anyhow::Result { let mut chain = MockChain { @@ -434,12 +434,12 @@ impl MockChain { } /// Returns the [`AccountId`] of the faucet whose assets are accepted for fee payments in the - /// transaction kernel, or in other words, the native asset of the blockchain. + /// transaction kernel, or in other words, the fee faucet of the blockchain. /// /// This value is taken from the genesis block because it is assumed not to change throughout /// the chain's lifecycle. - pub fn native_asset_id(&self) -> AccountId { - self.genesis_block_header().fee_parameters().native_asset_id() + pub fn fee_faucet_id(&self) -> AccountId { + self.genesis_block_header().fee_parameters().fee_faucet_id() } /// Returns a reference to the nullifier tree. @@ -454,6 +454,24 @@ impl MockChain { &self.committed_notes } + /// Returns `true` if a note with the given ID is recorded in committed notes. + pub fn is_note_committed(&self, note_id: &NoteId) -> bool { + self.committed_notes.contains_key(note_id) + } + + /// Returns `true` if the nullifier has been recorded on-chain (note was consumed). + pub fn is_note_consumed(&self, nullifier: &Nullifier) -> bool { + self.nullifier_tree.get_block_num(nullifier).is_some() + } + + /// Returns `true` if the nullifier is not yet on-chain. + /// + /// A nullifier can be unspent without the chain having seen the underlying note. Pair with + /// [`Self::is_note_committed`] when both conditions matter. + pub fn is_note_unspent(&self, nullifier: &Nullifier) -> bool { + !self.is_note_consumed(nullifier) + } + /// Returns an [`InputNote`] for the given note ID. If the note does not exist or is not /// public, `None` is returned. pub fn get_public_note(&self, note_id: &NoteId) -> Option { @@ -954,7 +972,7 @@ impl MockChain { created_note.id(), MockChainNote::Private( created_note.id(), - created_note.metadata().clone(), + *created_note.metadata(), note_inclusion_proof, ), ); @@ -1078,7 +1096,7 @@ impl Deserializable for MockChain { let committed_notes = BTreeMap::::read_from(source)?; let account_authenticators = BTreeMap::::read_from(source)?; - let secret_key = SecretKey::read_from(source)?; + let secret_key = SigningKey::read_from(source)?; Ok(Self { chain, @@ -1203,7 +1221,7 @@ impl From for TxContextInput { #[cfg(test)] mod tests { use miden_protocol::account::auth::AuthScheme; - use miden_protocol::account::{AccountBuilder, AccountStorageMode}; + use miden_protocol::account::{AccountBuilder, AccountType}; use miden_protocol::asset::{Asset, FungibleAsset}; use miden_protocol::note::NoteType; use miden_protocol::testing::account_id::{ @@ -1230,7 +1248,7 @@ mod tests { async fn private_account_state_update() -> anyhow::Result<()> { let faucet_id = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into()?; let account_builder = AccountBuilder::new([4; 32]) - .storage_mode(AccountStorageMode::Private) + .account_type(AccountType::Private) .with_component(BasicWallet); let mut builder = MockChain::builder(); diff --git a/crates/miden-testing/src/mock_chain/chain_builder.rs b/crates/miden-testing/src/mock_chain/chain_builder.rs index 468953f38b..4f5aa42276 100644 --- a/crates/miden-testing/src/mock_chain/chain_builder.rs +++ b/crates/miden-testing/src/mock_chain/chain_builder.rs @@ -21,11 +21,10 @@ use miden_protocol::account::{ AccountComponent, AccountDelta, AccountId, - AccountStorageMode, AccountType, StorageSlot, }; -use miden_protocol::asset::{Asset, FungibleAsset, TokenSymbol}; +use miden_protocol::asset::{Asset, AssetAmount, FungibleAsset, TokenSymbol}; use miden_protocol::block::account_tree::AccountTree; use miden_protocol::block::nullifier_tree::NullifierTree; use miden_protocol::block::{ @@ -42,20 +41,22 @@ use miden_protocol::block::{ }; use miden_protocol::crypto::merkle::smt::Smt; use miden_protocol::errors::NoteError; -use miden_protocol::note::{Note, NoteAttachment, NoteDetails, NoteType}; -use miden_protocol::testing::account_id::ACCOUNT_ID_NATIVE_ASSET_FAUCET; +use miden_protocol::note::{Note, NoteAttachments, NoteDetails, NoteScriptRoot, NoteType}; +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::{Felt, MAX_OUTPUT_NOTES_PER_BATCH, Word}; -use miden_standards::account::access::Ownable2Step; -use miden_standards::account::faucets::{BasicFungibleFaucet, NetworkFungibleFaucet}; -use miden_standards::account::mint_policies::{ - AuthControlled, - OwnerControlled, - OwnerControlledInitConfig, +use miden_protocol::{MAX_OUTPUT_NOTES_PER_BATCH, Word}; +use miden_standards::account::access::AccessControl; +use miden_standards::account::faucets::{FungibleFaucet, TokenName}; +use miden_standards::account::policies::{ + BurnPolicyConfig, + MintPolicyConfig, + PolicyRegistration, + TokenPolicyManager, + TransferPolicy, }; use miden_standards::account::wallets::BasicWallet; -use miden_standards::note::{P2idNote, P2ideNote, P2ideNoteStorage, SwapNote}; +use miden_standards::note::{BurnNote, MintNote, P2idNote, P2ideNote, P2ideNoteStorage, SwapNote}; use miden_standards::testing::account_component::MockAccountComponent; use rand::Rng; @@ -113,7 +114,7 @@ pub struct MockChainBuilder { notes: Vec, rng: RandomCoin, // Fee parameters. - native_asset_id: AccountId, + fee_faucet_id: AccountId, verification_base_fee: u32, } @@ -123,20 +124,19 @@ impl MockChainBuilder { /// Initializes a new mock chain builder with an empty state. /// - /// By default, the `native_asset_id` is set to [`ACCOUNT_ID_NATIVE_ASSET_FAUCET`] and can be - /// overwritten using [`Self::native_asset_id`]. + /// By default, the `fee_faucet_id` is set to [`ACCOUNT_ID_FEE_FAUCET`] and can be + /// overwritten using [`Self::fee_faucet_id`]. /// /// The `verification_base_fee` is initialized to 0 which means no fees are required by default. pub fn new() -> Self { - let native_asset_id = - ACCOUNT_ID_NATIVE_ASSET_FAUCET.try_into().expect("account ID should be valid"); + let fee_faucet_id = ACCOUNT_ID_FEE_FAUCET.try_into().expect("account ID should be valid"); Self { accounts: BTreeMap::new(), account_authenticators: BTreeMap::new(), notes: Vec::new(), rng: RandomCoin::new(Default::default()), - native_asset_id, + fee_faucet_id, verification_base_fee: 0, } } @@ -162,12 +162,12 @@ impl MockChainBuilder { // BUILDER METHODS // ---------------------------------------------------------------------------------------- - /// Sets the native asset ID of the chain. + /// Sets the fee faucet ID of the chain. /// /// This must be a fungible faucet [`AccountId`] and is the asset in which fees will be accepted /// by the transaction kernel. - pub fn native_asset_id(mut self, native_asset_id: AccountId) -> Self { - self.native_asset_id = native_asset_id; + pub fn fee_faucet_id(mut self, fee_faucet_id: AccountId) -> Self { + self.fee_faucet_id = fee_faucet_id; self } @@ -240,8 +240,7 @@ impl MockChainBuilder { let tx_commitment = transactions.commitment(); let tx_kernel_commitment = TransactionKernel.to_commitment(); let timestamp = MockChain::TIMESTAMP_START_SECS; - let fee_parameters = FeeParameters::new(self.native_asset_id, self.verification_base_fee) - .context("failed to construct fee parameters")?; + let fee_parameters = FeeParameters::new(self.fee_faucet_id, self.verification_base_fee); let validator_secret_key = random_secret_key(); let validator_public_key = validator_secret_key.public_key(); @@ -290,7 +289,7 @@ impl MockChainBuilder { /// [`MockChain::build_tx_context`] to automatically add the authenticator. pub fn create_new_wallet(&mut self, auth_method: Auth) -> anyhow::Result { let account_builder = AccountBuilder::new(self.rng.random()) - .storage_mode(AccountStorageMode::Public) + .account_type(AccountType::Public) .with_component(BasicWallet); self.add_account_from_builder(auth_method, account_builder, AccountState::New) @@ -310,44 +309,72 @@ impl MockChainBuilder { assets: impl IntoIterator, ) -> anyhow::Result { let account_builder = Account::builder(self.rng.random()) - .storage_mode(AccountStorageMode::Public) + .account_type(AccountType::Public) .with_component(BasicWallet) .with_assets(assets); self.add_account_from_builder(auth_method, account_builder, AccountState::Exists) } - /// Creates a new public [`BasicFungibleFaucet`] account and registers the authenticator (if + /// 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. - pub fn create_new_faucet( + fn create_new_fungible_faucet( &mut self, auth_method: Auth, - token_symbol: &str, - max_supply: u64, + faucet: FungibleFaucet, + account_type: AccountType, + access_control: AccessControl, + token_policy_manager: TokenPolicyManager, ) -> anyhow::Result { - let token_symbol = TokenSymbol::new(token_symbol) - .with_context(|| format!("invalid token symbol: {token_symbol}"))?; - let max_supply_felt = Felt::try_from(max_supply)?; - let basic_faucet = - BasicFungibleFaucet::new(token_symbol, DEFAULT_FAUCET_DECIMALS, max_supply_felt) - .context("failed to create BasicFungibleFaucet")?; - let account_builder = AccountBuilder::new(self.rng.random()) - .storage_mode(AccountStorageMode::Public) - .account_type(AccountType::FungibleFaucet) - .with_component(basic_faucet) - .with_component(AuthControlled::allow_all()); + .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 [`BasicFungibleFaucet`] account to the initial chain state and - /// registers the authenticator. + /// 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( + &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::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`. /// - /// Basic fungible faucets always use `AccountStorageMode::Public` and require authentication. + /// For full control over the faucet's metadata, decimals, and policies, construct a + /// [`FungibleFaucet`] manually and call `Self::add_existing_fungible_faucet`. pub fn add_existing_basic_faucet( &mut self, auth_method: Auth, @@ -355,62 +382,162 @@ impl MockChainBuilder { max_supply: u64, token_supply: Option, ) -> anyhow::Result { - let max_supply = Felt::try_from(max_supply)?; - let token_supply = Felt::try_from(token_supply.unwrap_or(0))?; - let token_symbol = - TokenSymbol::new(token_symbol).context("failed to create token symbol")?; - - let basic_faucet = - BasicFungibleFaucet::new(token_symbol, DEFAULT_FAUCET_DECIMALS, max_supply) - .and_then(|fungible_faucet| fungible_faucet.with_token_supply(token_supply)) - .context("failed to create basic fungible faucet")?; - - let account_builder = AccountBuilder::new(self.rng.random()) - .storage_mode(AccountStorageMode::Public) - .with_component(basic_faucet) - .with_component(AuthControlled::allow_all()) - .account_type(AccountType::FungibleFaucet); - - self.add_account_from_builder(auth_method, account_builder, AccountState::Exists) + let token_supply = token_supply.unwrap_or(0); + let name = TokenName::new(token_symbol)?; + let symbol = TokenSymbol::new(token_symbol) + .with_context(|| format!("invalid token symbol: {token_symbol}"))?; + let max_supply = AssetAmount::new(max_supply).context("invalid max_supply")?; + let token_supply = AssetAmount::new(token_supply).context("invalid token_supply")?; + let faucet = FungibleFaucet::builder() + .name(name) + .symbol(symbol) + .decimals(DEFAULT_FAUCET_DECIMALS) + .max_supply(max_supply) + .token_supply(token_supply) + .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)?; + + self.add_existing_fungible_faucet( + auth_method, + faucet, + AccountType::Public, + AccessControl::AuthControlled, + token_policy_manager, + ) } - /// Adds an existing [`NetworkFungibleFaucet`] account to the initial chain state. + /// Convenience: builds an owner-controlled (network-style) fungible faucet from a + /// token-symbol shorthand using default decimals, the given `mint_policy`, and `BurnAllowAll`. + /// + /// The faucet is added with [`AccountType::Public`] and [`Auth::IncrNonce`]. + /// + /// `mint_policy` selects the initial active mint policy on the faucet. The installed + /// [`TokenPolicyManager`] is always owner-controlled. /// - /// Network fungible faucets always use `AccountStorageMode::Network` and `Auth::NoAuth`. + /// The [`MintNote`] and [`BurnNote`] script roots are always added to `allowed_script_roots`, + /// so callers only need to provide any additional roots their test scripts require. pub fn add_existing_network_faucet( &mut self, token_symbol: &str, max_supply: u64, owner_account_id: AccountId, token_supply: Option, - mint_policy: OwnerControlledInitConfig, + mint_policy: MintPolicyConfig, + allowed_script_roots: impl IntoIterator, ) -> anyhow::Result { - let max_supply = Felt::try_from(max_supply)?; - let token_supply = Felt::try_from(token_supply.unwrap_or(0))?; - let token_symbol = - TokenSymbol::new(token_symbol).context("failed to create token symbol")?; + let token_supply = token_supply.unwrap_or(0); + let name = TokenName::new(token_symbol)?; + let symbol = TokenSymbol::new(token_symbol) + .with_context(|| format!("invalid token symbol: {token_symbol}"))?; + let max_supply = AssetAmount::new(max_supply).context("invalid max_supply")?; + let token_supply = AssetAmount::new(token_supply).context("invalid token_supply")?; + let faucet = FungibleFaucet::builder() + .name(name) + .symbol(symbol) + .decimals(DEFAULT_FAUCET_DECIMALS) + .max_supply(max_supply) + .token_supply(token_supply) + .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 allowed_script_roots = allowed_script_roots + .into_iter() + .chain([MintNote::script_root(), BurnNote::script_root()]) + .collect(); - let network_faucet = - NetworkFungibleFaucet::new(token_symbol, DEFAULT_FAUCET_DECIMALS, max_supply) - .and_then(|fungible_faucet| fungible_faucet.with_token_supply(token_supply)) - .context("failed to create network fungible faucet")?; + self.add_existing_fungible_faucet( + Auth::NetworkAccount { allowed_script_roots }, + faucet, + AccountType::Public, + AccessControl::Ownable2Step { owner: owner_account_id }, + token_policy_manager, + ) + } - let account_builder = AccountBuilder::new(self.rng.random()) - .storage_mode(AccountStorageMode::Network) - .with_component(network_faucet) - .with_component(Ownable2Step::new(owner_account_id)) - .with_component(OwnerControlled::new(mint_policy)) - .account_type(AccountType::FungibleFaucet); + /// Convenience: adds an existing owner-controlled (network-style) fungible faucet whose token + /// metadata is fully provided by the caller. Uses `OwnerOnly` mint policy and `AllowAll` + /// burn policy by default. + /// + /// The [`MintNote`] and [`BurnNote`] script roots are always added to `allowed_script_roots`, + /// so callers only need to provide any additional roots their test scripts require. + pub fn add_existing_network_faucet_with_metadata( + &mut self, + owner_account_id: AccountId, + 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 + .into_iter() + .chain([MintNote::script_root(), BurnNote::script_root()]) + .collect(); + + self.add_existing_fungible_faucet( + Auth::NetworkAccount { allowed_script_roots }, + faucet, + AccountType::Public, + AccessControl::Ownable2Step { owner: owner_account_id }, + token_policy_manager, + ) + } - // Network faucets always use IncrNonce auth (no authentication) - self.add_account_from_builder(Auth::IncrNonce, account_builder, AccountState::Exists) + /// Convenience: builds a new (uncreated) basic auth-controlled fungible faucet from a + /// token-symbol shorthand using default decimals and `AllowAll` policies. + pub fn create_new_faucet( + &mut self, + auth_method: Auth, + token_symbol: &str, + max_supply: u64, + ) -> anyhow::Result { + let name = TokenName::new(token_symbol)?; + let symbol = TokenSymbol::new(token_symbol) + .with_context(|| format!("invalid token symbol: {token_symbol}"))?; + let max_supply = AssetAmount::new(max_supply).context("invalid max_supply")?; + let faucet = FungibleFaucet::builder() + .name(name) + .symbol(symbol) + .decimals(DEFAULT_FAUCET_DECIMALS) + .max_supply(max_supply) + .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)?; + + self.create_new_fungible_faucet( + auth_method, + faucet, + AccountType::Public, + AccessControl::AuthControlled, + token_policy_manager, + ) } /// Creates a new public account with an [`MockAccountComponent`] and registers the /// authenticator (if any). pub fn create_new_mock_account(&mut self, auth_method: Auth) -> anyhow::Result { let account_builder = Account::builder(self.rng.random()) - .storage_mode(AccountStorageMode::Public) + .account_type(AccountType::Public) .with_component(MockAccountComponent::with_empty_slots()); self.add_account_from_builder(auth_method, account_builder, AccountState::New) @@ -451,7 +578,7 @@ impl MockChainBuilder { assets: impl IntoIterator, ) -> anyhow::Result { let account_builder = Account::builder(self.rng.random()) - .storage_mode(AccountStorageMode::Public) + .account_type(AccountType::Public) .with_component(MockAccountComponent::with_slots(slots.into_iter().collect())) .with_assets(assets); @@ -500,7 +627,7 @@ impl MockChainBuilder { components: impl IntoIterator, ) -> anyhow::Result { let mut account_builder = - Account::builder(rand::rng().random()).storage_mode(AccountStorageMode::Public); + Account::builder(rand::rng().random()).account_type(AccountType::Public); for component in components { account_builder = account_builder.with_component(component); @@ -566,7 +693,7 @@ impl MockChainBuilder { target_account_id, asset.to_vec(), note_type, - NoteAttachment::default(), + NoteAttachments::default(), &mut self.rng, )?; self.add_output_note(RawOutputNote::Full(note.clone())); @@ -595,7 +722,7 @@ impl MockChainBuilder { storage, asset.to_vec(), note_type, - Default::default(), + NoteAttachments::default(), &mut self.rng, )?; @@ -617,9 +744,8 @@ impl MockChainBuilder { offered_asset, requested_asset, NoteType::Public, - NoteAttachment::default(), + NoteAttachments::default(), payback_note_type, - NoteAttachment::default(), &mut self.rng, )?; @@ -651,10 +777,10 @@ impl MockChainBuilder { Ok(note) } - /// Creates a new P2ID note with the provided amount of the native fee asset of the chain. + /// Creates a new P2ID note with the provided amount of the fee asset of the chain. /// - /// The native asset ID of the asset can be set using [`Self::native_asset_id`]. By default it - /// is [`ACCOUNT_ID_NATIVE_ASSET_FAUCET`]. + /// The fee faucet ID of the asset can be set using [`Self::fee_faucet_id`]. By default it + /// is [`ACCOUNT_ID_FEE_FAUCET`]. /// /// In the created [`MockChain`], the note will be immediately spendable by `target_account_id`. pub fn add_p2id_note_with_fee( @@ -662,9 +788,9 @@ impl MockChainBuilder { target_account_id: AccountId, amount: u64, ) -> anyhow::Result { - let fee_asset = self.native_fee_asset(amount)?; + let fee_asset = self.fee_asset(amount)?; let note = self.add_p2id_note( - self.native_asset_id, + self.fee_faucet_id, target_account_id, &[Asset::from(fee_asset)], NoteType::Public, @@ -683,9 +809,9 @@ impl MockChainBuilder { &mut self.rng } - /// Constructs a fungible asset based on the native asset ID and the provided amount. - fn native_fee_asset(&self, amount: u64) -> anyhow::Result { - FungibleAsset::new(self.native_asset_id, amount).context("failed to create fee asset") + /// 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") } } diff --git a/crates/miden-testing/src/mock_host.rs b/crates/miden-testing/src/mock_host.rs index 7bfecaf079..fa81ba4534 100644 --- a/crates/miden-testing/src/mock_host.rs +++ b/crates/miden-testing/src/mock_host.rs @@ -5,7 +5,7 @@ use alloc::vec::Vec; use miden_processor::advice::AdviceMutation; use miden_processor::event::EventError; use miden_processor::mast::MastForest; -use miden_processor::{FutureMaybeSend, Host, ProcessorState}; +use miden_processor::{BaseHost, FutureMaybeSend, Host, ProcessorState}; use miden_protocol::transaction::TransactionEventId; use miden_protocol::vm::{EventId, EventName}; use miden_protocol::{CoreLibrary, Word}; @@ -84,7 +84,7 @@ impl<'store> MockHost<'store> { } } -impl<'store> Host for MockHost<'store> { +impl<'store> BaseHost for MockHost<'store> { fn get_label_and_source_file( &self, location: &miden_protocol::assembly::debuginfo::Location, @@ -95,6 +95,12 @@ impl<'store> Host for MockHost<'store> { self.exec_host.get_label_and_source_file(location) } + fn resolve_event(&self, event_id: EventId) -> Option<&EventName> { + self.exec_host.resolve_event(event_id) + } +} + +impl<'store> Host for MockHost<'store> { fn get_mast_forest(&self, node_digest: &Word) -> impl FutureMaybeSend>> { self.exec_host.get_mast_forest(node_digest) } @@ -114,8 +120,4 @@ impl<'store> Host for MockHost<'store> { } } } - - fn resolve_event(&self, event_id: EventId) -> Option<&EventName> { - self.exec_host.resolve_event(event_id) - } } diff --git a/crates/miden-testing/src/standards/mod.rs b/crates/miden-testing/src/standards/mod.rs index 76cf06a85d..b0f8c808a7 100644 --- a/crates/miden-testing/src/standards/mod.rs +++ b/crates/miden-testing/src/standards/mod.rs @@ -1,2 +1,3 @@ mod network_account_target; mod note_tag; +mod token_metadata; diff --git a/crates/miden-testing/src/standards/network_account_target.rs b/crates/miden-testing/src/standards/network_account_target.rs index 0b7d3d47c2..40e9652421 100644 --- a/crates/miden-testing/src/standards/network_account_target.rs +++ b/crates/miden-testing/src/standards/network_account_target.rs @@ -1,52 +1,44 @@ //! Tests for the `miden::standards::attachments::network_account_target` module. use miden_protocol::Felt; -use miden_protocol::account::AccountStorageMode; -use miden_protocol::note::{NoteAttachment, NoteMetadata, NoteTag, NoteType}; +use miden_protocol::account::AccountType; +use miden_protocol::note::NoteAttachment; use miden_protocol::testing::account_id::AccountIdBuilder; use miden_standards::note::{NetworkAccountTarget, NoteExecutionHint}; use crate::executor::CodeExecutor; #[tokio::test] -async fn network_account_target_get_id() -> anyhow::Result<()> { +async fn network_account_target_into_target_id() -> anyhow::Result<()> { let target_id = AccountIdBuilder::new() - .storage_mode(AccountStorageMode::Network) + .account_type(AccountType::Public) .build_with_rng(&mut rand::rng()); let exec_hint = NoteExecutionHint::Always; let attachment = NoteAttachment::from(NetworkAccountTarget::new(target_id, exec_hint)?); - let metadata = NoteMetadata::new(target_id, NoteType::Public) - .with_tag(NoteTag::with_account_target(target_id)) - .with_attachment(attachment.clone()); - let metadata_header = metadata.to_header_word(); let source = format!( r#" use miden::standards::attachments::network_account_target use miden::protocol::note - const ERR_NOT_NETWORK_ACCOUNT_TARGET = "attachment is not a valid network account target" - begin - push.{attachment_word} - push.{metadata_header} - exec.note::extract_attachment_info_from_metadata - # => [attachment_kind, attachment_scheme, NOTE_ATTACHMENT] - swap - # => [attachment_scheme, attachment_kind, NOTE_ATTACHMENT] + push.{attachment_scheme} + # => [attachment_scheme] exec.network_account_target::is_network_account_target - # => [is_valid, NOTE_ATTACHMENT] - assert.err=ERR_NOT_NETWORK_ACCOUNT_TARGET + # => [is_valid] + assert.err="expected scheme to be a valid network account target" + + push.{attachment_word} # => [NOTE_ATTACHMENT] - exec.network_account_target::get_id + exec.network_account_target::into_target_id # => [account_id_suffix, account_id_prefix] # cleanup stack movup.2 drop movup.2 drop end "#, - metadata_header = metadata_header, - attachment_word = attachment.content().to_word(), + attachment_scheme = attachment.attachment_scheme().as_u16(), + attachment_word = attachment.content().as_words()[0], ); let exec_output = CodeExecutor::with_default_host().run(&source).await?; @@ -60,13 +52,12 @@ async fn network_account_target_get_id() -> anyhow::Result<()> { #[tokio::test] async fn network_account_target_new_attachment() -> anyhow::Result<()> { let target_id = AccountIdBuilder::new() - .storage_mode(AccountStorageMode::Network) + .account_type(AccountType::Public) .build_with_rng(&mut rand::rng()); let exec_hint = NoteExecutionHint::Always; let attachment = NoteAttachment::from(NetworkAccountTarget::new(target_id, exec_hint)?); - let attachment_word = attachment.content().to_word(); - let expected_attachment_kind = Felt::from(attachment.attachment_kind().as_u8()); + let raw_attachment_word = attachment.content().as_words()[0]; let source = format!( r#" @@ -78,7 +69,7 @@ async fn network_account_target_new_attachment() -> anyhow::Result<()> { push.{target_id_suffix} # => [target_id_suffix, target_id_prefix, exec_hint] exec.network_account_target::new - # => [attachment_scheme, attachment_kind, ATTACHMENT, pad(16)] + # => [attachment_scheme, NOTE_ATTACHMENT, pad(16)] # cleanup stack swapdw dropw dropw @@ -91,14 +82,13 @@ async fn network_account_target_new_attachment() -> anyhow::Result<()> { let exec_output = CodeExecutor::with_default_host().run(&source).await?; - assert_eq!(exec_output.stack[0], expected_attachment_kind); assert_eq!( - exec_output.stack[1], - Felt::from(NetworkAccountTarget::ATTACHMENT_SCHEME.as_u32()) + exec_output.stack[0], + Felt::from(NetworkAccountTarget::ATTACHMENT_SCHEME.as_u16()) ); - let word = exec_output.stack.get_word(2).unwrap(); - assert_eq!(word, attachment_word); + let word = exec_output.stack.get_word(1).unwrap(); + assert_eq!(word, raw_attachment_word); Ok(()) } @@ -106,7 +96,7 @@ async fn network_account_target_new_attachment() -> anyhow::Result<()> { #[tokio::test] async fn network_account_target_attachment_round_trip() -> anyhow::Result<()> { let target_id = AccountIdBuilder::new() - .storage_mode(AccountStorageMode::Network) + .account_type(AccountType::Public) .build_with_rng(&mut rand::rng()); let exec_hint = NoteExecutionHint::Always; @@ -122,12 +112,12 @@ async fn network_account_target_attachment_round_trip() -> anyhow::Result<()> { push.{target_id_suffix} # => [target_id_suffix, target_id_prefix, exec_hint] exec.network_account_target::new - # => [attachment_scheme, attachment_kind, ATTACHMENT] + # => [attachment_scheme, NOTE_ATTACHMENT] exec.network_account_target::is_network_account_target - # => [is_valid, ATTACHMENT] + # => [is_valid, NOTE_ATTACHMENT] assert.err=ERR_NOT_NETWORK_ACCOUNT_TARGET - # => [ATTACHMENT] - exec.network_account_target::get_id + # => [NOTE_ATTACHMENT] + exec.network_account_target::into_target_id # => [target_id_suffix, target_id_prefix] # cleanup stack movup.2 drop movup.2 drop diff --git a/crates/miden-testing/src/standards/token_metadata.rs b/crates/miden-testing/src/standards/token_metadata.rs new file mode 100644 index 0000000000..04427c31a6 --- /dev/null +++ b/crates/miden-testing/src/standards/token_metadata.rs @@ -0,0 +1,979 @@ +//! Integration tests for the Token Metadata standard (`FungibleFaucet`). + +extern crate alloc; + +use alloc::sync::Arc; +use alloc::vec::Vec; + +use miden_crypto::hash::poseidon2::Poseidon2; +use miden_processor::crypto::random::RandomCoin; +use miden_protocol::account::{ + Account, + AccountBuilder, + AccountComponent, + AccountId, + AccountIdVersion, + AccountType, + StorageSlotName, +}; +use miden_protocol::assembly::DefaultSourceManager; +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::auth::NoAuth; +use miden_standards::account::faucets::{ + Description, + ExternalLink, + FungibleFaucet, + LogoURI, + TokenMetadata, + TokenName, +}; +use miden_standards::code_builder::CodeBuilder; +use miden_standards::errors::standards::{ + ERR_DESCRIPTION_NOT_MUTABLE, + ERR_EXTERNAL_LINK_NOT_MUTABLE, + ERR_LOGO_URI_NOT_MUTABLE, + ERR_MAX_SUPPLY_NOT_MUTABLE, + ERR_SENDER_NOT_OWNER, +}; +use miden_standards::testing::note::NoteBuilder; + +use crate::{MockChain, TransactionContextBuilder, assert_transaction_executor_error}; + +// SHARED HELPERS +// ================================================================================================ + +/// Builds [`FungibleFaucet`] for tests that use raw word arrays + mutability flags +/// (e.g. from [`description_config`] / [`logo_uri_config`] / [`external_link_config`]). +fn network_faucet_metadata( + token_symbol: &str, + max_supply: u64, + token_supply: Option, + max_supply_mutable: bool, + description: Option<([Word; 7], bool)>, + logo_uri: Option<([Word; 7], bool)>, + external_link: Option<([Word; 7], bool)>, +) -> anyhow::Result { + let token_supply = AssetAmount::new(token_supply.unwrap_or(0))?; + let max_supply = AssetAmount::new(max_supply)?; + let name = TokenName::new(token_symbol)?; + let token_symbol = TokenSymbol::new(token_symbol)?; + + let (description, is_description_mutable) = match description { + Some((words, mutable)) => ( + Some(Description::try_from_words(&words).expect("valid description words")), + mutable, + ), + None => (None, false), + }; + let (logo_uri, is_logo_uri_mutable) = match logo_uri { + Some((words, mutable)) => { + (Some(LogoURI::try_from_words(&words).expect("valid logo_uri words")), mutable) + }, + None => (None, false), + }; + let (external_link, is_external_link_mutable) = match external_link { + Some((words, mutable)) => ( + Some(ExternalLink::try_from_words(&words).expect("valid external_link words")), + mutable, + ), + None => (None, false), + }; + + Ok(FungibleFaucet::builder() + .name(name) + .symbol(token_symbol) + .decimals(10) + .max_supply(max_supply) + .token_supply(token_supply) + .is_max_supply_mutable(max_supply_mutable) + .maybe_description(description) + .is_description_mutable(is_description_mutable) + .maybe_logo_uri(logo_uri) + .is_logo_uri_mutable(is_logo_uri_mutable) + .maybe_external_link(external_link) + .is_external_link_mutable(is_external_link_mutable) + .build()?) +} + +fn initial_field_data() -> [Word; 7] { + [ + Word::from([1u32, 2, 3, 4]), + Word::from([5u32, 6, 7, 8]), + Word::from([9u32, 10, 11, 12]), + Word::from([13u32, 14, 15, 16]), + Word::from([17u32, 18, 19, 20]), + Word::from([21u32, 22, 23, 24]), + Word::from([25u32, 26, 27, 28]), + ] +} + +fn new_field_data() -> [Word; 7] { + [ + Word::from([100u32, 101, 102, 103]), + Word::from([104u32, 105, 106, 107]), + Word::from([108u32, 109, 110, 111]), + Word::from([112u32, 113, 114, 115]), + Word::from([116u32, 117, 118, 119]), + Word::from([120u32, 121, 122, 123]), + Word::from([124u32, 125, 126, 127]), + ] +} + +fn owner_account_id() -> AccountId { + AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Private) +} + +fn non_owner_account_id() -> AccountId { + AccountId::dummy([2; 15], AccountIdVersion::Version1, AccountType::Private) +} + +/// Build a minimal faucet metadata (no optional fields). +fn build_faucet_metadata() -> FungibleFaucet { + FungibleFaucet::builder() + .name(TokenName::new("T").unwrap()) + .symbol("TST".try_into().unwrap()) + .decimals(2) + .max_supply(AssetAmount::from(1_000u32)) + .build() + .unwrap() +} + +/// Build a standard POL faucet metadata (used by scalar getter tests). +/// Uses "Polygon Token" (13 bytes) so both name word chunks are non-zero. +fn build_pol_faucet_metadata() -> FungibleFaucet { + FungibleFaucet::builder() + .name(TokenName::new("Polygon Token").unwrap()) + .symbol(TokenSymbol::new("POL").unwrap()) + .decimals(8) + .max_supply(AssetAmount::from(1_000_000u32)) + .build() + .unwrap() +} + +/// Build a basic faucet account with POL metadata. +fn build_pol_faucet_account() -> Account { + AccountBuilder::new([4u8; 32]) + .account_type(AccountType::Public) + .with_auth_component(NoAuth) + .with_component(build_pol_faucet_metadata()) + .build() + .unwrap() +} + +/// Flatten `[Word; 7]` into `Vec` for advice map values. +fn field_advice_map_value(field: &[Word; 7]) -> Vec { + let mut value = Vec::with_capacity(28); + for word in field.iter() { + value.extend(word.iter()); + } + value +} + +/// Compute the Poseidon2 hash of the field data (used as the advice map key). +fn compute_field_hash(data: &[Word; 7]) -> Word { + let felts = field_advice_map_value(data); + Poseidon2::hash_elements(&felts) +} + +/// Execute a tx script against the given account and assert success. +async fn execute_tx_script( + account: Account, + tx_script_code: impl AsRef, +) -> anyhow::Result<()> { + let source_manager = Arc::new(DefaultSourceManager::default()); + let tx_script = CodeBuilder::with_source_manager(source_manager.clone()) + .compile_tx_script(tx_script_code.as_ref())?; + let tx_context = TransactionContextBuilder::new(account) + .tx_script(tx_script) + .with_source_manager(source_manager) + .build()?; + tx_context.execute().await?; + Ok(()) +} + +// ================================================================================================= +// GETTER TESTS – name +// ================================================================================================= + +#[tokio::test] +async fn get_name_from_masm() -> anyhow::Result<()> { + let token_name = TokenName::new("test name").unwrap(); + let name = token_name.to_words(); + + let faucet = FungibleFaucet::builder() + .name(token_name) + .symbol("TST".try_into().unwrap()) + .decimals(2) + .max_supply(AssetAmount::from(1_000u32)) + .build() + .unwrap(); + + let account = AccountBuilder::new([1u8; 32]) + .with_auth_component(NoAuth) + .with_component(faucet) + .build()?; + + execute_tx_script( + account, + format!( + r#" + begin + call.::miden::standards::faucets::get_name + push.{n0} + assert_eqw.err="name chunk 0 does not match" + push.{n1} + assert_eqw.err="name chunk 1 does not match" + end + "#, + n0 = name[0], + n1 = name[1], + ), + ) + .await +} + +#[tokio::test] +async fn get_name_zeros_returns_empty() -> anyhow::Result<()> { + // Build a faucet with an empty name to verify get_name returns zero words. + let faucet = FungibleFaucet::builder() + .name(TokenName::new("").expect("empty string is a valid token name")) + .symbol("TST".try_into().unwrap()) + .decimals(2) + .max_supply(AssetAmount::from(1_000u32)) + .build() + .unwrap(); + + let account = AccountBuilder::new([1u8; 32]) + .with_auth_component(NoAuth) + .with_component(faucet) + .build()?; + + execute_tx_script( + account, + r#" + begin + call.::miden::standards::faucets::get_name + padw assert_eqw.err="name chunk 0 should be empty" + padw assert_eqw.err="name chunk 1 should be empty" + end + "#, + ) + .await +} + +// ================================================================================================= +// GETTER TESTS – scalar fields +// ================================================================================================= + +#[tokio::test] +async fn faucet_get_decimals() -> anyhow::Result<()> { + let expected = Felt::from(8u8).as_canonical_u64(); + execute_tx_script( + build_pol_faucet_account(), + format!( + r#" + begin + call.::miden::standards::faucets::fungible::get_decimals + push.{expected} assert_eq.err="decimals does not match" + push.0 assert_eq.err="clean stack: pad must be 0" + end + "# + ), + ) + .await +} + +#[tokio::test] +async fn faucet_get_token_symbol() -> anyhow::Result<()> { + let expected = Felt::from(TokenSymbol::new("POL").unwrap()).as_canonical_u64(); + execute_tx_script( + build_pol_faucet_account(), + format!( + r#" + begin + call.::miden::standards::faucets::fungible::get_token_symbol + push.{expected} assert_eq.err="token_symbol does not match" + push.0 assert_eq.err="clean stack: pad must be 0" + end + "# + ), + ) + .await +} + +#[tokio::test] +async fn faucet_get_token_supply() -> anyhow::Result<()> { + execute_tx_script( + build_pol_faucet_account(), + r#" + begin + call.::miden::standards::faucets::fungible::get_token_supply + push.0 assert_eq.err="token_supply does not match" + push.0 assert_eq.err="clean stack: pad must be 0" + end + "#, + ) + .await +} + +#[tokio::test] +async fn faucet_get_max_supply() -> anyhow::Result<()> { + let expected = 1_000_000_u64; + execute_tx_script( + build_pol_faucet_account(), + format!( + r#" + begin + call.::miden::standards::faucets::fungible::get_max_supply + push.{expected} assert_eq.err="max_supply does not match" + push.0 assert_eq.err="clean stack: pad must be 0" + end + "# + ), + ) + .await +} + +#[tokio::test] +async fn faucet_get_token_config() -> anyhow::Result<()> { + let symbol = TokenSymbol::new("POL").unwrap(); + let expected_symbol = Felt::from(symbol).as_canonical_u64(); + let expected_decimals = 8_u64; + let expected_max_supply = 1_000_000_u64; + + execute_tx_script( + build_pol_faucet_account(), + format!( + r#" + begin + call.::miden::standards::faucets::fungible::get_token_config + push.0 assert_eq.err="token_supply does not match" + push.{expected_max_supply} assert_eq.err="max_supply does not match" + push.{expected_decimals} assert_eq.err="decimals does not match" + push.{expected_symbol} assert_eq.err="token_symbol does not match" + end + "# + ), + ) + .await +} + +#[tokio::test] +async fn faucet_get_decimals_symbol_and_max_supply() -> anyhow::Result<()> { + let symbol = TokenSymbol::new("POL").unwrap(); + let expected_decimals = 8_u64; + let expected_symbol = Felt::from(symbol).as_canonical_u64(); + let expected_max_supply = 1_000_000_u64; + + execute_tx_script( + build_pol_faucet_account(), + format!( + r#" + begin + call.::miden::standards::faucets::fungible::get_decimals + push.{expected_decimals} assert_eq.err="decimals does not match" + call.::miden::standards::faucets::fungible::get_token_symbol + push.{expected_symbol} assert_eq.err="token_symbol does not match" + call.::miden::standards::faucets::fungible::get_max_supply + push.{expected_max_supply} assert_eq.err="max_supply does not match" + end + "# + ), + ) + .await +} + +// ================================================================================================= +// GETTER TESTS – mutability config +// ================================================================================================= + +#[tokio::test] +async fn get_mutability_config() -> anyhow::Result<()> { + let faucet = FungibleFaucet::builder() + .name(TokenName::new("T").unwrap()) + .symbol("TST".try_into().unwrap()) + .decimals(2) + .max_supply(AssetAmount::from(1_000u32)) + .description(Description::new("test").unwrap()) + .is_description_mutable(true) + .is_max_supply_mutable(true) + .build() + .unwrap(); + + let account = AccountBuilder::new([1u8; 32]) + .with_auth_component(NoAuth) + .with_component(faucet) + .build()?; + + execute_tx_script( + account, + r#" + begin + call.::miden::standards::faucets::get_mutability_config + push.1 assert_eq.err="desc_mutable should be 1" + push.0 assert_eq.err="logo_mutable should be 0" + push.0 assert_eq.err="extlink_mutable should be 0" + push.1 assert_eq.err="max_supply_mutable should be 1" + end + "#, + ) + .await +} + +/// Tests all `is_*_mutable` procedures with flag=0 and flag=1. +#[rstest::rstest] +#[case("::miden::standards::faucets::fungible::is_max_supply_mutable", + build_faucet_metadata().with_max_supply_mutable(true), 1)] +#[case("::miden::standards::faucets::is_description_mutable", + build_faucet_metadata().with_description_mutable(true), 1)] +#[case("::miden::standards::faucets::is_description_mutable", + build_faucet_metadata().with_description_mutable(false), 0)] +#[case("::miden::standards::faucets::is_logo_uri_mutable", + build_faucet_metadata().with_logo_uri_mutable(true), 1)] +#[case("::miden::standards::faucets::is_logo_uri_mutable", + build_faucet_metadata().with_logo_uri_mutable(false), 0)] +#[case("::miden::standards::faucets::is_external_link_mutable", + build_faucet_metadata().with_external_link_mutable(true), 1)] +#[case("::miden::standards::faucets::is_external_link_mutable", + build_faucet_metadata().with_external_link_mutable(false),0)] +#[tokio::test] +async fn is_field_mutable_checks( + #[case] proc_path: &str, + #[case] faucet: FungibleFaucet, + #[case] expected: u8, +) -> anyhow::Result<()> { + let account = AccountBuilder::new([1u8; 32]) + .with_auth_component(NoAuth) + .with_component(faucet) + .build()?; + + execute_tx_script( + account, + format!( + "begin + call.{proc_path} + push.{expected} + assert_eq.err=\"{proc_path} returned unexpected value\" + end" + ), + ) + .await +} + +// ================================================================================================= +// STORAGE LAYOUT TESTS +// ================================================================================================= + +#[test] +fn faucet_with_metadata_storage_layout() { + let token_name = TokenName::new("test faucet name").unwrap(); + let desc_text = "faucet description text for testing"; + let description = Description::new(desc_text).unwrap(); + + let faucet = FungibleFaucet::builder() + .name(token_name) + .symbol("TST".try_into().unwrap()) + .decimals(8) + .max_supply(AssetAmount::from(1_000_000u32)) + .description(description) + .build() + .unwrap(); + + let account = AccountBuilder::new([1u8; 32]) + .account_type(AccountType::Public) + .with_auth_component(NoAuth) + .with_component(faucet) + .build() + .unwrap(); + + // Verify roundtrip via try_from + let restored = FungibleFaucet::try_from(account.storage()).unwrap(); + assert_eq!(restored.token_supply(), AssetAmount::ZERO); + assert_eq!(restored.max_supply().as_u64(), 1_000_000); + assert_eq!(restored.decimals(), 8); + assert_eq!(restored.description().map(|d| d.as_str()), Some(desc_text)); +} + +// ================================================================================================= +// FAUCET INITIALIZATION – basic + network with max name/description +// ================================================================================================= + +fn verify_faucet_with_max_name_and_description( + seed: [u8; 32], + symbol: &str, + max_supply: u64, + account_type: AccountType, + extra_components: Vec, +) { + let max_name = "a".repeat(TokenName::MAX_BYTES); + let desc_text = "a".repeat(Description::MAX_BYTES); + let description = Description::new(&desc_text).unwrap(); + + let max_supply = AssetAmount::new(max_supply).unwrap(); + let faucet = FungibleFaucet::builder() + .name(TokenName::new(&max_name).unwrap()) + .symbol(symbol.try_into().unwrap()) + .decimals(6) + .max_supply(max_supply) + .description(description) + .build() + .unwrap(); + + let mut builder = AccountBuilder::new(seed) + .account_type(account_type) + .with_auth_component(NoAuth) + .with_component(faucet); + + for comp in extra_components { + builder = builder.with_component(comp); + } + + let account = builder.build().unwrap(); + + // Verify roundtrip via try_from + let restored = FungibleFaucet::try_from(account.storage()).unwrap(); + assert_eq!(restored.token_name().as_str(), max_name); + assert_eq!(restored.description().map(|d| d.as_str()), Some(desc_text.as_str())); + assert_eq!(restored.max_supply(), max_supply); +} + +#[test] +fn basic_faucet_with_max_name_and_full_description() { + verify_faucet_with_max_name_and_description( + [5u8; 32], + "MAX", + 1_000_000, + AccountType::Public, + vec![], + ); +} + +// ================================================================================================= +// MASM NAME READBACK – basic + network faucets +// ================================================================================================= + +// ================================================================================================= +// SETTER TESTS – set_description, set_logo_uri, set_external_link (parameterised) +// ================================================================================================= + +struct FieldSetterFaucetArgs { + description: Option<([Word; 7], bool)>, + logo_uri: Option<([Word; 7], bool)>, + external_link: Option<([Word; 7], bool)>, +} + +fn description_config(data: [Word; 7], mutable: bool) -> FieldSetterFaucetArgs { + FieldSetterFaucetArgs { + description: Some((data, mutable)), + logo_uri: None, + external_link: None, + } +} + +fn logo_uri_config(data: [Word; 7], mutable: bool) -> FieldSetterFaucetArgs { + FieldSetterFaucetArgs { + description: None, + logo_uri: Some((data, mutable)), + external_link: None, + } +} + +fn external_link_config(data: [Word; 7], mutable: bool) -> FieldSetterFaucetArgs { + FieldSetterFaucetArgs { + description: None, + logo_uri: None, + external_link: Some((data, mutable)), + } +} + +async fn test_field_setter_immutable_fails( + proc_name: &str, + immutable_error: MasmError, + args: FieldSetterFaucetArgs, +) -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let owner = owner_account_id(); + + let faucet = network_faucet_metadata( + "FLD", + 1000, + Some(0), + false, + args.description, + args.logo_uri, + args.external_link, + )?; + let faucet_account = builder.add_existing_network_faucet_with_metadata(owner, faucet, [])?; + let mock_chain = builder.build()?; + + let tx_script_code = format!( + r#" + begin + call.::miden::standards::faucets::{proc_name} + end + "# + ); + + let source_manager = Arc::new(DefaultSourceManager::default()); + let tx_script = CodeBuilder::with_source_manager(source_manager.clone()) + .compile_tx_script(&tx_script_code)?; + + let tx_context = mock_chain + .build_tx_context(faucet_account.id(), &[], &[])? + .tx_script(tx_script) + .with_source_manager(source_manager) + .build()?; + + let result = tx_context.execute().await; + assert_transaction_executor_error!(result, immutable_error); + + Ok(()) +} + +async fn test_field_setter_owner_succeeds( + proc_name: &str, + args: FieldSetterFaucetArgs, + slot_fn: fn(usize) -> &'static StorageSlotName, +) -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let owner = owner_account_id(); + let new_data = new_field_data(); + + let faucet = network_faucet_metadata( + "FLD", + 1000, + Some(0), + false, + args.description, + args.logo_uri, + args.external_link, + )?; + + let hash = compute_field_hash(&new_data); + + // Push hash as a word so advice map key matches; dropw after call so stack depth is 16 + // (setter leaves 20). Use `debug.stack` in the script and run with --nocapture to trace. + let note_script_code = format!( + r#" + @note_script + pub proc main + dropw push.{hash} + call.::miden::standards::faucets::{proc_name} + dropw + end +"#, + ); + + let note_script = CodeBuilder::default().compile_note_script(¬e_script_code)?; + let faucet_account = + builder.add_existing_network_faucet_with_metadata(owner, faucet, [note_script.root()])?; + let mock_chain = builder.build()?; + + let source_manager = Arc::new(DefaultSourceManager::default()); + + let mut rng = RandomCoin::new([Felt::from(42u32); 4].into()); + let note = NoteBuilder::new(owner, &mut rng) + .note_type(NoteType::Private) + .tag(NoteTag::default().into()) + .serial_number(Word::from([7, 8, 9, 10u32])) + .script(note_script) + .build()?; + + let tx_context = mock_chain + .build_tx_context(faucet_account.id(), &[], &[note])? + .extend_advice_map([(hash, field_advice_map_value(&new_data))]) + .with_source_manager(source_manager) + .build()?; + + let executed = tx_context.execute().await?; + let mut updated_faucet = faucet_account.clone(); + updated_faucet.apply_delta(executed.account_delta())?; + + for (i, expected) in new_data.iter().enumerate() { + let chunk = updated_faucet.storage().get_item(slot_fn(i))?; + assert_eq!(chunk, *expected, "field chunk {i} should be updated"); + } + + Ok(()) +} + +async fn test_field_setter_non_owner_fails( + proc_name: &str, + args: FieldSetterFaucetArgs, +) -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let owner = owner_account_id(); + let non_owner = non_owner_account_id(); + + let faucet = network_faucet_metadata( + "FLD", + 1000, + Some(0), + false, + args.description, + args.logo_uri, + args.external_link, + )?; + + // Auth check fires before data is touched, so no hash push is needed. + let note_script_code = format!( + r#" + @note_script + pub proc main + call.::miden::standards::faucets::{proc_name} + dropw + end +"#, + proc_name = proc_name, + ); + + let note_script = CodeBuilder::default().compile_note_script(¬e_script_code)?; + let faucet_account = + builder.add_existing_network_faucet_with_metadata(owner, faucet, [note_script.root()])?; + let mock_chain = builder.build()?; + + let source_manager = Arc::new(DefaultSourceManager::default()); + + let mut rng = RandomCoin::new([Felt::from(99u32); 4].into()); + let note = NoteBuilder::new(non_owner, &mut rng) + .note_type(NoteType::Private) + .tag(NoteTag::default().into()) + .serial_number(Word::from([11, 12, 13, 14u32])) + .script(note_script) + .build()?; + + let tx_context = mock_chain + .build_tx_context(faucet_account.id(), &[], &[note])? + .with_source_manager(source_manager) + .build()?; + + let result = tx_context.execute().await; + assert_transaction_executor_error!(result, ERR_SENDER_NOT_OWNER); + + Ok(()) +} + +// --- set_description --- + +#[tokio::test] +async fn set_description_immutable_fails() -> anyhow::Result<()> { + test_field_setter_immutable_fails( + "set_description", + ERR_DESCRIPTION_NOT_MUTABLE, + description_config(initial_field_data(), false), + ) + .await +} + +#[tokio::test] +async fn set_description_mutable_owner_succeeds() -> anyhow::Result<()> { + test_field_setter_owner_succeeds( + "set_description", + description_config(initial_field_data(), true), + TokenMetadata::description_slot, + ) + .await +} + +#[tokio::test] +async fn set_description_mutable_non_owner_fails() -> anyhow::Result<()> { + test_field_setter_non_owner_fails( + "set_description", + description_config(initial_field_data(), true), + ) + .await +} + +// --- set_logo_uri --- + +#[tokio::test] +async fn set_logo_uri_immutable_fails() -> anyhow::Result<()> { + test_field_setter_immutable_fails( + "set_logo_uri", + ERR_LOGO_URI_NOT_MUTABLE, + logo_uri_config(initial_field_data(), false), + ) + .await +} + +#[tokio::test] +async fn set_logo_uri_mutable_owner_succeeds() -> anyhow::Result<()> { + test_field_setter_owner_succeeds( + "set_logo_uri", + logo_uri_config(initial_field_data(), true), + TokenMetadata::logo_uri_slot, + ) + .await +} + +#[tokio::test] +async fn set_logo_uri_mutable_non_owner_fails() -> anyhow::Result<()> { + test_field_setter_non_owner_fails("set_logo_uri", logo_uri_config(initial_field_data(), true)) + .await +} + +// --- set_external_link --- + +#[tokio::test] +async fn set_external_link_immutable_fails() -> anyhow::Result<()> { + test_field_setter_immutable_fails( + "set_external_link", + ERR_EXTERNAL_LINK_NOT_MUTABLE, + external_link_config(initial_field_data(), false), + ) + .await +} + +#[tokio::test] +async fn set_external_link_mutable_owner_succeeds() -> anyhow::Result<()> { + test_field_setter_owner_succeeds( + "set_external_link", + external_link_config(initial_field_data(), true), + TokenMetadata::external_link_slot, + ) + .await +} + +#[tokio::test] +async fn set_external_link_mutable_non_owner_fails() -> anyhow::Result<()> { + test_field_setter_non_owner_fails( + "set_external_link", + external_link_config(initial_field_data(), true), + ) + .await +} + +// ================================================================================================= +// SETTER TESTS – set_max_supply +// ================================================================================================= + +#[tokio::test] +async fn set_max_supply_immutable_fails() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let owner = owner_account_id(); + + let faucet = network_faucet_metadata("MSM", 1000, Some(0), false, None, None, None)?; + let faucet_account = builder.add_existing_network_faucet_with_metadata(owner, faucet, [])?; + let mock_chain = builder.build()?; + + let tx_script_code = r#" + begin + push.2000 + call.::miden::standards::faucets::fungible::set_max_supply + end + "#; + + let source_manager = Arc::new(DefaultSourceManager::default()); + let tx_script = CodeBuilder::with_source_manager(source_manager.clone()) + .compile_tx_script(tx_script_code)?; + + let tx_context = mock_chain + .build_tx_context(faucet_account.id(), &[], &[])? + .tx_script(tx_script) + .with_source_manager(source_manager) + .build()?; + + let result = tx_context.execute().await; + assert_transaction_executor_error!(result, ERR_MAX_SUPPLY_NOT_MUTABLE); + + Ok(()) +} + +#[tokio::test] +async fn set_max_supply_mutable_owner_succeeds() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let owner = owner_account_id(); + let new_max_supply: u64 = 2000; + + let faucet = network_faucet_metadata("MSM", 1000, Some(0), true, None, None, None)?; + + let note_script_code = format!( + r#" + @note_script + pub proc main + push.{new_max_supply} + swap drop + call.::miden::standards::faucets::fungible::set_max_supply + end + "# + ); + + let note_script = CodeBuilder::default().compile_note_script(¬e_script_code)?; + let faucet_account = + builder.add_existing_network_faucet_with_metadata(owner, faucet, [note_script.root()])?; + let mock_chain = builder.build()?; + + let source_manager = Arc::new(DefaultSourceManager::default()); + + let mut rng = RandomCoin::new([Felt::from(42u32); 4].into()); + let note = NoteBuilder::new(owner, &mut rng) + .note_type(NoteType::Private) + .tag(NoteTag::default().into()) + .serial_number(Word::from([20, 21, 22, 23u32])) + .script(note_script) + .build()?; + + let tx_context = mock_chain + .build_tx_context(faucet_account.id(), &[], &[note])? + .with_source_manager(source_manager) + .build()?; + + let executed = tx_context.execute().await?; + let mut updated_faucet = faucet_account.clone(); + updated_faucet.apply_delta(executed.account_delta())?; + + let restored = FungibleFaucet::try_from(updated_faucet.storage())?; + assert_eq!(restored.max_supply().as_u64(), new_max_supply, "max_supply should be updated"); + assert_eq!( + restored.token_supply(), + AssetAmount::ZERO, + "token_supply should remain unchanged" + ); + + Ok(()) +} + +#[tokio::test] +async fn set_max_supply_mutable_non_owner_fails() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let owner = owner_account_id(); + let non_owner = non_owner_account_id(); + + let faucet = network_faucet_metadata("MSM", 1000, Some(0), true, None, None, None)?; + + // Auth check fires before data is touched, so no arguments needed. + let note_script_code = " + @note_script + pub proc main + call.::miden::standards::faucets::fungible::set_max_supply + end + "; + + let note_script = CodeBuilder::default().compile_note_script(note_script_code)?; + let faucet_account = + builder.add_existing_network_faucet_with_metadata(owner, faucet, [note_script.root()])?; + let mock_chain = builder.build()?; + + let source_manager = Arc::new(DefaultSourceManager::default()); + + let mut rng = RandomCoin::new([Felt::from(99u32); 4].into()); + let note = NoteBuilder::new(non_owner, &mut rng) + .note_type(NoteType::Private) + .tag(NoteTag::default().into()) + .serial_number(Word::from([30, 31, 32, 33u32])) + .script(note_script) + .build()?; + + let tx_context = mock_chain + .build_tx_context(faucet_account.id(), &[], &[note])? + .with_source_manager(source_manager) + .build()?; + + let result = tx_context.execute().await; + assert_transaction_executor_error!(result, ERR_SENDER_NOT_OWNER); + + Ok(()) +} diff --git a/crates/miden-testing/src/tx_context/builder.rs b/crates/miden-testing/src/tx_context/builder.rs index b61a5a5e98..c4cbac72e3 100644 --- a/crates/miden-testing/src/tx_context/builder.rs +++ b/crates/miden-testing/src/tx_context/builder.rs @@ -14,7 +14,7 @@ use miden_protocol::account::{Account, AccountHeader, 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}; +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::{ @@ -29,7 +29,7 @@ use miden_tx::TransactionMastStore; use miden_tx::auth::BasicAuthenticator; use super::TransactionContext; -use crate::{MockChain, MockChainNote}; +use crate::MockChain; // TRANSACTION CONTEXT BUILDER // ================================================================================================ @@ -81,7 +81,7 @@ pub struct TransactionContextBuilder { tx_inputs: Option, auth_args: Word, signatures: Vec<(PublicKeyCommitment, Word, Signature)>, - note_scripts: BTreeMap, + note_scripts: BTreeMap, is_lazy_loading_enabled: bool, is_debug_mode_enabled: bool, } @@ -284,17 +284,18 @@ impl TransactionContextBuilder { // to generate valid block header/MMR data let mut builder = MockChain::builder(); - for i in self.input_notes { - builder.add_output_note(RawOutputNote::Full(i)); + + // Get the set of note IDs in the provided order. + let input_note_ids: Vec = self.input_notes.iter().map(Note::id).collect(); + + for input_note in self.input_notes { + builder.add_output_note(RawOutputNote::Full(input_note)); } let mut mock_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")?; - let input_note_ids: Vec = - mock_chain.committed_notes().values().map(MockChainNote::id).collect(); - mock_chain .get_transaction_inputs(&self.account, &input_note_ids, &[]) .context("failed to get transaction inputs from mock chain")? diff --git a/crates/miden-testing/src/tx_context/context.rs b/crates/miden-testing/src/tx_context/context.rs index 9395c00dc0..ea930a7fe0 100644 --- a/crates/miden-testing/src/tx_context/context.rs +++ b/crates/miden-testing/src/tx_context/context.rs @@ -15,10 +15,10 @@ use miden_protocol::account::{ }; use miden_protocol::assembly::debuginfo::{SourceLanguage, Uri}; use miden_protocol::assembly::{Assembler, SourceManager, SourceManagerSync}; -use miden_protocol::asset::{Asset, AssetVaultKey, AssetWitness}; +use miden_protocol::asset::{Asset, AssetCallbackFlag, AssetVaultKey, AssetWitness}; use miden_protocol::block::account_tree::AccountWitness; use miden_protocol::block::{BlockHeader, BlockNumber}; -use miden_protocol::note::{Note, NoteScript}; +use miden_protocol::note::{Note, NoteScript, NoteScriptRoot}; use miden_protocol::transaction::{ AccountInputs, ExecutedTransaction, @@ -61,7 +61,7 @@ pub struct TransactionContext { pub(super) mast_store: TransactionMastStore, pub(super) authenticator: Option, pub(super) source_manager: Arc, - pub(super) note_scripts: BTreeMap, + pub(super) note_scripts: BTreeMap, pub(super) is_lazy_loading_enabled: bool, pub(super) is_debug_mode_enabled: bool, } @@ -90,11 +90,6 @@ impl TransactionContext { .iter() .flat_map(|note| note.note().assets().iter().map(Asset::vault_key)) .collect::>(); - let fee_asset_vault_key = AssetVaultKey::new_fungible( - self.tx_inputs().block_header().fee_parameters().native_asset_id(), - ) - .expect("fee asset should be a fungible asset"); - asset_vault_keys.extend([fee_asset_vault_key]); let (account, block_header, _blockchain) = self .get_transaction_inputs( @@ -106,9 +101,11 @@ impl TransactionContext { // 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().native_asset_id()) - .expect("fee asset should be a fungible asset"); + 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. @@ -387,7 +384,7 @@ impl DataStore for TransactionContext { fn get_note_script( &self, - script_root: Word, + script_root: NoteScriptRoot, ) -> impl FutureMaybeSend, DataStoreError>> { async move { Ok(self.note_scripts.get(&script_root).cloned()) } } @@ -404,7 +401,6 @@ impl MastForestStore for TransactionContext { #[cfg(test)] mod tests { - use miden_protocol::Felt; use miden_standards::code_builder::CodeBuilder; use super::*; @@ -448,8 +444,7 @@ mod tests { assert_eq!(retrieved_script2, note_script2); // Fetching a non-existent one returns None - let non_existent_root = - Word::from([Felt::new(1), Felt::new(2), Felt::new(3), Felt::new(4)]); + let non_existent_root = NoteScriptRoot::from_array([1, 2, 3, 4]); let result = tx_context.get_note_script(non_existent_root).await; assert!(matches!(result, Ok(None))); } diff --git a/crates/miden-testing/src/utils.rs b/crates/miden-testing/src/utils.rs index 81a49d2c74..6b26006715 100644 --- a/crates/miden-testing/src/utils.rs +++ b/crates/miden-testing/src/utils.rs @@ -7,7 +7,8 @@ use miden_protocol::account::AccountId; use miden_protocol::asset::Asset; use miden_protocol::crypto::rand::FeltRng; use miden_protocol::errors::NoteError; -use miden_protocol::note::{Note, NoteAssets, NoteMetadata, NoteTag, NoteType}; +use miden_protocol::note::{Note, NoteAssets, NoteTag, NoteType, PartialNoteMetadata}; +use miden_protocol::vm::AdviceMap; use miden_standards::code_builder::CodeBuilder; use miden_standards::note::P2idNoteStorage; use miden_standards::testing::note::NoteBuilder; @@ -138,10 +139,12 @@ pub fn create_p2any_note( @note_script pub proc main # fetch pointer & number of assets - push.0 exec.active_note::get_assets # [num_assets, dest_ptr] + push.0 exec.active_note::get_assets # [num_assets] # runtime-check we got the expected count - push.{num_assets} assert_eq.err="unexpected number of assets" # [dest_ptr] + push.{num_assets} assert_eq.err="unexpected number of assets" # [] + + push.0 # [dest_ptr] {code_body} dropw dropw dropw dropw @@ -187,21 +190,24 @@ where .metadata() .sender(); - let note_code = note_script_that_creates_notes(sender_id, output_notes)?; + let (note_code, advice_map) = note_script_that_creates_notes(sender_id, output_notes)?; let note = NoteBuilder::new(sender_id, SmallRng::from_os_rng()) .code(note_code) + .advice_map(advice_map) .dynamically_linked_libraries(CodeBuilder::mock_libraries()) .build()?; Ok(note) } -/// Returns the code for a note that creates all notes in `output_notes` +/// Returns the code for a note that creates all notes in `output_notes`, along with an +/// advice map containing the elements for any attachments keyed by their commitment. fn note_script_that_creates_notes<'note>( sender_id: AccountId, output_notes: impl Iterator, -) -> anyhow::Result { +) -> anyhow::Result<(String, AdviceMap)> { + let mut advice_map = AdviceMap::default(); let mut out = String::from("use miden::protocol::output_note\n\n@note_script\npub proc main\n"); for (idx, note) in output_notes.into_iter().enumerate() { @@ -239,20 +245,24 @@ fn note_script_that_creates_notes<'note>( tag = note.metadata().tag(), )); - out.push_str(&format!( - " - push.{ATTACHMENT} - push.{attachment_scheme} - push.{attachment_kind} - dup.6 - # => [note_idx, attachment_kind, attachment_scheme, ATTACHMENT, note_idx] - exec.output_note::set_attachment - # => [note_idx] - ", - ATTACHMENT = note.metadata().to_attachment_word(), - attachment_scheme = note.metadata().attachment().attachment_scheme().as_u32(), - attachment_kind = note.metadata().attachment().content().attachment_kind().as_u8(), - )); + for attachment in note.attachments().iter() { + let attachment_scheme = attachment.attachment_scheme().as_u16(); + let commitment = attachment.content().to_commitment(); + + out.push_str(&format!( + " + dup + push.{commitment} + push.{attachment_scheme} + # => [attachment_scheme, ATTACHMENT_COMMITMENT, note_idx, note_idx] + exec.output_note::add_attachment + # => [note_idx] + ", + )); + + // Add the elements to the advice map keyed by the commitment. + advice_map.insert(commitment, attachment.content().to_elements()); + } for asset in note.assets().iter() { out.push_str(&format!( @@ -271,7 +281,7 @@ fn note_script_that_creates_notes<'note>( out.push_str("repeat.5 dropw end\nend"); - Ok(out) + Ok((out, advice_map)) } /// Generates a P2ID note - Pay-to-ID note with an exact serial number @@ -286,7 +296,7 @@ pub fn create_p2id_note_exact( let tag = NoteTag::with_account_target(target); - let metadata = NoteMetadata::new(sender, note_type).with_tag(tag); + let metadata = PartialNoteMetadata::new(sender, note_type).with_tag(tag); let vault = NoteAssets::new(assets)?; Ok(Note::new(vault, metadata, recipient)) diff --git a/crates/miden-testing/tests/agglayer/asset_conversion.rs b/crates/miden-testing/tests/agglayer/asset_conversion.rs index 8a6a30ac3e..dc288828d9 100644 --- a/crates/miden-testing/tests/agglayer/asset_conversion.rs +++ b/crates/miden-testing/tests/agglayer/asset_conversion.rs @@ -51,7 +51,7 @@ async fn test_scale_up_helper( .to_elements() .into_iter() .rev() - .map(|f| Felt::new((f.as_canonical_u64() as u32).swap_bytes() as u64)) + .map(|f| Felt::new_unchecked((f.as_canonical_u64() as u32).swap_bytes() as u64)) .collect(); assert_eq!(actual_felts, expected_felts); @@ -62,13 +62,12 @@ async fn test_scale_up_helper( #[tokio::test] async fn test_scale_up_basic_examples() -> anyhow::Result<()> { // Test case 1: amount=1, no scaling (scale_exponent=0) - test_scale_up_helper(Felt::new(1), Felt::new(0), EthAmount::from_uint_str("1").unwrap()) - .await?; + test_scale_up_helper(Felt::ONE, Felt::ZERO, EthAmount::from_uint_str("1").unwrap()).await?; // Test case 2: amount=1, scale to 1e18 (scale_exponent=18) test_scale_up_helper( - Felt::new(1), - Felt::new(18), + Felt::ONE, + Felt::new_unchecked(18), EthAmount::from_uint_str("1000000000000000000").unwrap(), ) .await?; @@ -80,16 +79,16 @@ async fn test_scale_up_basic_examples() -> anyhow::Result<()> { async fn test_scale_up_realistic_amounts() -> anyhow::Result<()> { // 100 units base 1e6, scale to 1e18 test_scale_up_helper( - Felt::new(100_000_000), - Felt::new(12), + Felt::new_unchecked(100_000_000), + Felt::new_unchecked(12), EthAmount::from_uint_str("100000000000000000000").unwrap(), ) .await?; // Large amount: 1e18 units scaled by 8 test_scale_up_helper( - Felt::new(1000000000000000000), - Felt::new(8), + Felt::new_unchecked(1000000000000000000), + Felt::new_unchecked(8), EthAmount::from_uint_str("100000000000000000000000000").unwrap(), ) .await?; @@ -199,7 +198,7 @@ async fn test_scale_down_realistic_scenarios_fuzzing() -> anyhow::Result<()> { let min_x = U256::from(10_000_000_000_000u64); // 1e13 let desired_max_x = U256::from_dec_str("1000000000000000000000000").unwrap(); // 1e24 - let max_y = U256::from(FungibleAsset::MAX_AMOUNT); // 2^63 - 2^31 + let max_y = U256::from(FungibleAsset::MAX_AMOUNT.as_u64()); // 2^63 - 2^31 for scale in 0..=MAX_SCALE { let scale_factor = U256::from(10u64).pow(U256::from(scale)); @@ -378,14 +377,14 @@ async fn test_scale_down_high_limb_subtraction() -> anyhow::Result<()> { #[test] fn test_felts_to_u256_bytes_sequential_values() { let limbs = [ - Felt::new(1), - Felt::new(2), - Felt::new(3), - Felt::new(4), - Felt::new(5), - Felt::new(6), - Felt::new(7), - Felt::new(8), + Felt::ONE, + Felt::new_unchecked(2), + Felt::new_unchecked(3), + Felt::new_unchecked(4), + Felt::new_unchecked(5), + Felt::new_unchecked(6), + Felt::new_unchecked(7), + Felt::new_unchecked(8), ]; let result = packed_u32_elements_to_bytes(&limbs); assert_eq!(result.len(), 32); @@ -401,13 +400,13 @@ fn test_felts_to_u256_bytes_sequential_values() { #[test] fn test_felts_to_u256_bytes_edge_cases() { // Test case 1: All zeros (minimum) - let limbs = [Felt::new(0); 8]; + let limbs = [Felt::ZERO; 8]; let result = packed_u32_elements_to_bytes(&limbs); assert_eq!(result.len(), 32); assert!(result.iter().all(|&b| b == 0)); // Test case 2: All max u32 values (maximum) - let limbs = [Felt::new(u32::MAX as u64); 8]; + let limbs = [Felt::new_unchecked(u32::MAX as u64); 8]; let result = packed_u32_elements_to_bytes(&limbs); assert_eq!(result.len(), 32); assert!(result.iter().all(|&b| b == 255)); diff --git a/crates/miden-testing/tests/agglayer/bridge_in.rs b/crates/miden-testing/tests/agglayer/bridge_in.rs index 9e45a965ab..6c749f0fd4 100644 --- a/crates/miden-testing/tests/agglayer/bridge_in.rs +++ b/crates/miden-testing/tests/agglayer/bridge_in.rs @@ -4,36 +4,51 @@ use alloc::slice; use alloc::string::String; use anyhow::Context; -use miden_agglayer::errors::ERR_CLAIM_ALREADY_SPENT; +use miden_agglayer::errors::{ + ERR_CLAIM_ALREADY_SPENT, + ERR_CLAIM_LEAF_DESTINATION_NETWORK_MISMATCH, + ERR_TOKEN_NOT_REGISTERED, +}; use miden_agglayer::{ + AggLayerBridge, + B2AggNote, + ClaimNote, ClaimNoteStorage, ConfigAggBridgeNote, + ConversionMetadata, + EthAddress, EthEmbeddedAccountId, ExitRoot, LeafValue, SmtNode, UpdateGerNote, agglayer_library, - create_claim_note, create_existing_agglayer_faucet, create_existing_bridge_account, }; use miden_protocol::Felt; -use miden_protocol::account::Account; use miden_protocol::account::auth::AuthScheme; -use miden_protocol::asset::{Asset, FungibleAsset}; +use miden_protocol::account::{Account, AccountId, AccountIdVersion, AccountType}; +use miden_protocol::asset::{Asset, AssetAmount, AssetCallbackFlag, FungibleAsset}; use miden_protocol::crypto::SequentialCommit; use miden_protocol::crypto::rand::FeltRng; -use miden_protocol::note::NoteType; -use miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE; +use miden_protocol::note::{NoteAssets, NoteType}; use miden_protocol::transaction::RawOutputNote; +use miden_standards::account::policies::MintPolicyConfig; 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; use miden_standards::note::P2idNote; use miden_standards::testing::account_component::IncrNonceAuthComponent; use miden_standards::testing::mock_account::MockAccountExt; use miden_testing::utils::create_p2id_note_exact; -use miden_testing::{AccountState, Auth, MockChain, TransactionContextBuilder}; +use miden_testing::{ + AccountState, + Auth, + MockChain, + TransactionContextBuilder, + assert_transaction_executor_error, +}; use miden_tx::utils::hex_to_bytes; use rand::Rng; @@ -109,20 +124,14 @@ fn merkle_proof_verification_code( /// TX3: MINT → aggfaucet (mints asset, creates P2ID note) /// TX4: P2ID → destination (simulated case only) /// -/// Parameterized over three claim data sources: -/// - [`ClaimDataSource::RealL1ToMiden`]: uses real [`ProofData`] and [`LeafData`] from -/// `claim_asset_vectors_real_tx.json`, captured from an actual on-chain `claimAsset` transaction. -/// - [`ClaimDataSource::SimulatedL1ToMiden`]: uses locally generated [`ProofData`] and [`LeafData`] -/// from `claim_asset_vectors_local_tx.json`, produced by simulating a `bridgeAsset()` call. -/// - [`ClaimDataSource::SimulatedL2ToMiden`]: uses rollup deposit data from -/// `claim_asset_vectors_rollup_tx.json`, produced by simulating a rollup deposit. -/// -/// Note: Modifying anything in the real test vectors would invalidate the Merkle proof, -/// as the proof was computed for the original leaf data including the original destination. +/// Parameterized over two claim data sources: +/// - [`ClaimDataSource::L1ToMiden`]: uses locally generated [`ProofData`] and [`LeafData`] from +/// `claim_asset_vectors_l1_tx.json`, produced by simulating a `bridgeAsset()` call. +/// - [`ClaimDataSource::L2ToMiden`]: uses rollup deposit data from +/// `claim_asset_vectors_l2_tx.json`, produced by simulating a rollup deposit. #[rstest::rstest] -#[case::real_l1_to_miden(ClaimDataSource::RealL1ToMiden)] -#[case::simulated_l1_to_miden(ClaimDataSource::SimulatedL1ToMiden)] -#[case::simulated_l2_to_miden(ClaimDataSource::SimulatedL2ToMiden)] +#[case::l1_to_miden(ClaimDataSource::L1ToMiden)] +#[case::l2_to_miden(ClaimDataSource::L2ToMiden)] #[tokio::test] async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> anyhow::Result<()> { use miden_agglayer::AggLayerBridge; @@ -157,7 +166,7 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a // -------------------------------------------------------------------------------------------- let token_symbol = "AGG"; let decimals = 8u8; - let max_supply = Felt::new(FungibleAsset::MAX_AMOUNT); + 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; @@ -171,10 +180,6 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a max_supply, Felt::ZERO, bridge_account.id(), - &origin_token_address, - origin_network, - scale, - leaf_data.metadata_hash, ); builder.add_account(agglayer_faucet.clone())?; @@ -185,25 +190,9 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a .expect("destination address is not an embedded Miden AccountId") .into_account_id(); - // For the simulated/rollup case, create the destination account so we can consume the P2ID note - let destination_account = if matches!( - data_source, - ClaimDataSource::SimulatedL1ToMiden | ClaimDataSource::SimulatedL2ToMiden - ) { - let dest = - Account::mock(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE, IncrNonceAuthComponent); - // Ensure the mock account ID matches the destination embedded in the JSON test vector, - // since the claim note targets this account ID. - assert_eq!( - dest.id(), - destination_account_id, - "mock destination account ID must match the destination_account_id from the claim data" - ); - builder.add_account(dest.clone())?; - Some(dest) - } else { - None - }; + let destination_account = + Account::mock(u128::from(destination_account_id), IncrNonceAuthComponent); + builder.add_account(destination_account.clone())?; // CREATE SENDER ACCOUNT (for creating the claim note) // -------------------------------------------------------------------------------------------- @@ -227,13 +216,14 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a .scale_to_token_amount(scale as u32) .expect("amount should scale successfully"); + let metadata_hash = leaf_data.metadata_hash; let claim_inputs = ClaimNoteStorage { proof_data, leaf_data, miden_claim_amount, }; - let claim_note = create_claim_note( + let claim_note = ClaimNote::create( claim_inputs, bridge_account.id(), // Target the bridge, not the faucet sender_account.id(), @@ -246,8 +236,14 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a // CREATE CONFIG_AGG_BRIDGE NOTE (registers faucet + token address in bridge) // -------------------------------------------------------------------------------------------- let config_note = ConfigAggBridgeNote::create( - agglayer_faucet.id(), - &origin_token_address, + ConversionMetadata { + faucet_account_id: agglayer_faucet.id(), + origin_token_address, + scale, + origin_network, + is_native: false, + metadata_hash, + }, bridge_admin.id(), bridge_account.id(), builder.rng_mut(), @@ -355,7 +351,7 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a // Verify minted amount matches expected scaled value assert_eq!( - Felt::new(p2id_asset.amount()), + Felt::from(p2id_asset.amount()), miden_claim_amount, "asset amount does not match" ); @@ -373,6 +369,7 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a let expected_asset: Asset = FungibleAsset::new(agglayer_faucet.id(), miden_claim_amount.as_canonical_u64()) .unwrap() + .with_callbacks(AssetCallbackFlag::Enabled) .into(); let expected_output_p2id_note = create_p2id_note_exact( agglayer_faucet.id(), @@ -385,37 +382,365 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a assert_eq!(RawOutputNote::Full(expected_output_p2id_note.clone()), *output_note); - // TX4: CONSUME THE P2ID NOTE WITH THE DESTINATION ACCOUNT (simulated case only) - // -------------------------------------------------------------------------------------------- - // For the simulated case, we control the destination account and can verify the full - // end-to-end flow including P2ID consumption and balance updates. - if let Some(destination_account) = destination_account { - // Add the faucet transaction to the chain and prove the next block so the P2ID note is - // committed and can be consumed. - mock_chain.add_pending_executed_transaction(&mint_executed)?; - mock_chain.prove_next_block()?; - - // Execute the consume transaction for the destination account - let consume_tx_context = mock_chain - .build_tx_context( - destination_account.id(), - &[], - slice::from_ref(&expected_output_p2id_note), - )? - .build()?; - let consume_executed_transaction = consume_tx_context.execute().await?; - - // Verify the destination account received the minted asset - let mut destination_account = destination_account.clone(); - destination_account.apply_delta(consume_executed_transaction.account_delta())?; - - let balance = destination_account.vault().get_balance(agglayer_faucet.id())?; - assert_eq!( - balance, - miden_claim_amount.as_canonical_u64(), - "destination account balance does not match" - ); - } + // TX4: CONSUME THE P2ID NOTE WITH THE DESTINATION ACCOUNT + // -------------------------------------------------------------------------------------------- + // Add the faucet transaction to the chain and prove the next block so the P2ID note is + // committed and can be consumed. + mock_chain.add_pending_executed_transaction(&mint_executed)?; + mock_chain.prove_next_block()?; + + // Execute the consume transaction for the destination account. Pass the account + // directly since the JSON-encoded destination decodes to a private account ID. The + // issuing AggLayer faucet must be supplied as a foreign account so the kernel can + // dispatch the receive callback when the asset is added to the destination vault. + let agglayer_faucet_inputs = mock_chain.get_foreign_account_inputs(agglayer_faucet.id())?; + let consume_tx_context = mock_chain + .build_tx_context( + destination_account.clone(), + &[], + slice::from_ref(&expected_output_p2id_note), + )? + .foreign_accounts(vec![agglayer_faucet_inputs]) + .build()?; + let consume_executed_transaction = consume_tx_context.execute().await?; + + // Verify the destination account received the minted asset + let mut destination_account = destination_account; + destination_account.apply_delta(consume_executed_transaction.account_delta())?; + + let balance = destination_account.vault().get_balance(expected_asset.vault_key())?; + assert_eq!( + balance.as_u64(), + miden_claim_amount.as_canonical_u64(), + "destination account balance does not match" + ); + Ok(()) +} + +/// Asserts that a MINT note produced by `claim` for faucet A cannot be consumed by a +/// different same-bridge faucet B. +/// +/// Both faucets are registered in the bridge, so the only thing preventing faucet B from +/// consuming faucet A's MINT note is the faucet bind itself. The MINT note embeds the full +/// `ASSET` (`ASSET_KEY` + `ASSET_VALUE`) in its storage; `fungible::mint_and_send` derives the +/// asset for the consuming faucet and rejects it with +/// `ERR_FUNGIBLE_MINT_NOTE_ASSET_NOT_FROM_THIS_FAUCET` when its key does not match the stored +/// `ASSET_KEY`. Before this fix the MINT note carried only the amount, so faucet B would mint its +/// own token and the cross-faucet consumption would succeed. +#[tokio::test] +async fn test_mint_cannot_be_consumed_by_unrelated_faucet() -> anyhow::Result<()> { + let data_source = ClaimDataSource::L1ToMiden; + let mut builder = MockChain::builder(); + + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_manager = 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()); + builder.add_account(bridge_account.clone())?; + + let (proof_data, leaf_data, ger, _cgi_chain_hash) = data_source.get_data(); + + let token_symbol_a = "AGGA"; + let token_symbol_b = "AGGB"; + let decimals = 8u8; + let max_supply: Felt = FungibleAsset::MAX_AMOUNT.into(); + let scale = 10u8; + + // faucet_A is the bridge's registered faucet for the claim's origin token address. + let faucet_a_seed = builder.rng_mut().draw_word(); + let faucet_a = create_existing_agglayer_faucet( + faucet_a_seed, + token_symbol_a, + decimals, + max_supply, + Felt::ZERO, + bridge_account.id(), + ); + builder.add_account(faucet_a.clone())?; + + // faucet_B is owned by the same bridge but tied to a different origin token. It is the + // attacker's target: the claimant tries to direct faucet_A's MINT note here. + let faucet_b_seed = builder.rng_mut().draw_word(); + // Flip one byte of the address so the new EthAddress is distinct from faucet_A's. + let mut other_bytes = leaf_data.origin_token_address.into_bytes(); + other_bytes[0] ^= 0x01; + let other_token_address = EthAddress::new(other_bytes); + let faucet_b = create_existing_agglayer_faucet( + faucet_b_seed, + token_symbol_b, + decimals, + max_supply, + Felt::ZERO, + bridge_account.id(), + ); + builder.add_account(faucet_b.clone())?; + + assert_ne!(faucet_a.id(), faucet_b.id(), "test setup: faucet IDs must differ"); + + // The destination is also baked into the fixture; create it so the bridge claim succeeds. + let destination_account_id = EthEmbeddedAccountId::try_from(leaf_data.destination_address) + .expect("destination address is not an embedded Miden AccountId") + .into_account_id(); + let dest = Account::mock(u128::from(destination_account_id), IncrNonceAuthComponent); + builder.add_account(dest)?; + + let sender_account_builder = + Account::builder(builder.rng_mut().random()).with_component(BasicWallet); + let sender_account = builder.add_account_from_builder( + Auth::IncrNonce, + sender_account_builder, + AccountState::Exists, + )?; + + let miden_claim_amount = leaf_data + .amount + .scale_to_token_amount(scale as u32) + .expect("amount should scale successfully"); + + let claim_inputs = ClaimNoteStorage { + proof_data, + leaf_data: leaf_data.clone(), + miden_claim_amount, + }; + + let claim_note = ClaimNote::create( + claim_inputs, + bridge_account.id(), + sender_account.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(claim_note.clone())); + + // Register faucet_A in the bridge for the claim's origin token address. + let config_note = ConfigAggBridgeNote::create( + ConversionMetadata { + faucet_account_id: faucet_a.id(), + origin_token_address: leaf_data.origin_token_address, + scale, + origin_network: leaf_data.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())); + + // Register faucet_B too: it is a fully legitimate same-bridge faucet, so the attack below + // fails only because of the faucet bind, not because faucet_B is unknown to the bridge. + let config_note_b = ConfigAggBridgeNote::create( + ConversionMetadata { + faucet_account_id: faucet_b.id(), + origin_token_address: other_token_address, + scale, + origin_network: leaf_data.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_b.clone())); + + let update_ger_note = + UpdateGerNote::create(ger, ger_manager.id(), bridge_account.id(), builder.rng_mut())?; + builder.add_output_note(RawOutputNote::Full(update_ger_note.clone())); + + let mut mock_chain = builder.clone().build()?; + + // TX0: register faucet_A and faucet_B. + let config_executed = mock_chain + .build_tx_context(bridge_account.id(), &[config_note.id(), config_note_b.id()], &[])? + .build()? + .execute() + .await?; + mock_chain.add_pending_executed_transaction(&config_executed)?; + mock_chain.prove_next_block()?; + + // TX1: store GER. + let update_ger_executed = mock_chain + .build_tx_context(bridge_account.id(), &[update_ger_note.id()], &[])? + .build()? + .execute() + .await?; + mock_chain.add_pending_executed_transaction(&update_ger_executed)?; + mock_chain.prove_next_block()?; + + // TX2: claim → produces MINT note bound to faucet_A's asset. + let faucet_a_foreign_inputs = mock_chain.get_foreign_account_inputs(faucet_a.id())?; + let claim_executed = mock_chain + .build_tx_context(bridge_account.id(), &[], &[claim_note])? + .foreign_accounts(vec![faucet_a_foreign_inputs]) + .build()? + .execute() + .await + .context("CLAIM execution should succeed")?; + + assert_eq!(claim_executed.output_notes().num_notes(), 1); + let mint_output_note = claim_executed.output_notes().get_note(0); + + mock_chain.add_pending_executed_transaction(&claim_executed)?; + mock_chain.prove_next_block()?; + + // ATTACK: try to consume the MINT note against faucet_B (wrong faucet). + // + // The MINT note's stored `ASSET_KEY` carries faucet_A's ID. faucet_B's `mint_and_send` + // derives the asset for faucet_B, finds its key differs from the stored one, and rejects + // the consumption. + let attack_tx_context = mock_chain + .build_tx_context(faucet_b.id(), &[mint_output_note.id()], &[])? + .add_note_script(P2idNote::script()) + .build()?; + + let attack_result = attack_tx_context.execute().await; + assert_transaction_executor_error!( + attack_result, + ERR_FUNGIBLE_MINT_NOTE_ASSET_NOT_FROM_THIS_FAUCET + ); + + Ok(()) +} + +/// CLAIM must reject a leaf whose `destination_network` does not match the global Miden +/// AggLayer network ID (`MIDEN_NETWORK_ID` in `constants.masm`), even when the rest of the proof +/// data is unchanged. +#[tokio::test] +async fn test_claim_rejects_wrong_destination_network() -> anyhow::Result<()> { + let data_source = ClaimDataSource::L1ToMiden; + let mut builder = MockChain::builder(); + + // CREATE BRIDGE ADMIN ACCOUNT (sends CONFIG_AGG_BRIDGE notes) + // -------------------------------------------------------------------------------------------- + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // CREATE GER MANAGER ACCOUNT (sends the UPDATE_GER note) + // -------------------------------------------------------------------------------------------- + let ger_manager = 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()); + builder.add_account(bridge_account.clone())?; + + // GET CLAIM DATA FROM JSON + // -------------------------------------------------------------------------------------------- + let (proof_data, mut leaf_data, ger, _cgi) = data_source.get_data(); + + // Override destination_network so it no longer matches the bridge's MIDEN_NETWORK_ID. + // Proof data is unchanged; the bridge should fail before Merkle verification. + // -------------------------------------------------------------------------------------------- + leaf_data.destination_network = AggLayerBridge::MIDEN_NETWORK_ID.saturating_add(1); + + // CREATE AGGLAYER FAUCET ACCOUNT (with agglayer_faucet component) + // Use the origin token address and network from the claim data. + // -------------------------------------------------------------------------------------------- + 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 metadata_hash = leaf_data.metadata_hash; + 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 (targets the bridge) + // -------------------------------------------------------------------------------------------- + let claim_note = ClaimNote::create( + ClaimNoteStorage { + proof_data, + leaf_data, + miden_claim_amount, + }, + bridge_account.id(), + bridge_admin.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(claim_note.clone())); + + // CREATE CONFIG_AGG_BRIDGE NOTE (registers faucet + token address in bridge) + // -------------------------------------------------------------------------------------------- + let config_note = ConfigAggBridgeNote::create( + ConversionMetadata { + faucet_account_id: agglayer_faucet.id(), + origin_token_address, + scale, + origin_network, + is_native: false, + metadata_hash, + }, + bridge_admin.id(), + bridge_account.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(config_note.clone())); + + // CREATE UPDATE_GER NOTE WITH GLOBAL EXIT ROOT + // -------------------------------------------------------------------------------------------- + let update_ger_note = + UpdateGerNote::create(ger, ger_manager.id(), bridge_account.id(), builder.rng_mut())?; + builder.add_output_note(RawOutputNote::Full(update_ger_note.clone())); + + // BUILD MOCK CHAIN WITH ALL ACCOUNTS + // -------------------------------------------------------------------------------------------- + let mut mock_chain = builder.clone().build()?; + + // TX0: EXECUTE CONFIG_AGG_BRIDGE NOTE TO REGISTER FAUCET IN BRIDGE + // -------------------------------------------------------------------------------------------- + let config_tx_context = mock_chain + .build_tx_context(bridge_account.id(), &[config_note.id()], &[])? + .build()?; + mock_chain.add_pending_executed_transaction(&config_tx_context.execute().await?)?; + mock_chain.prove_next_block()?; + + // TX1: EXECUTE UPDATE_GER NOTE TO STORE GER IN BRIDGE ACCOUNT + // -------------------------------------------------------------------------------------------- + let update_ger_tx_context = mock_chain + .build_tx_context(bridge_account.id(), &[update_ger_note.id()], &[])? + .build()?; + mock_chain.add_pending_executed_transaction(&update_ger_tx_context.execute().await?)?; + mock_chain.prove_next_block()?; + + // TX2: EXECUTE CLAIM NOTE AGAINST BRIDGE (must fail: wrong destination_network) + // -------------------------------------------------------------------------------------------- + let faucet_foreign_inputs = mock_chain.get_foreign_account_inputs(agglayer_faucet.id())?; + let claim_tx_context = mock_chain + .build_tx_context(bridge_account.id(), &[], &[claim_note])? + .foreign_accounts(vec![faucet_foreign_inputs]) + .build()?; + let result = claim_tx_context.execute().await; + assert_transaction_executor_error!(result, ERR_CLAIM_LEAF_DESTINATION_NETWORK_MISMATCH); + Ok(()) } @@ -429,7 +754,7 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a /// been spent" #[tokio::test] async fn test_duplicate_claim_note_rejected() -> anyhow::Result<()> { - let data_source = ClaimDataSource::SimulatedL1ToMiden; + let data_source = ClaimDataSource::L1ToMiden; let mut builder = MockChain::builder(); // CREATE BRIDGE ADMIN ACCOUNT @@ -454,7 +779,7 @@ async fn test_duplicate_claim_note_rejected() -> anyhow::Result<()> { // CREATE AGGLAYER FAUCET ACCOUNT let token_symbol = "AGG"; let decimals = 8u8; - let max_supply = Felt::new(FungibleAsset::MAX_AMOUNT); + 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; @@ -468,10 +793,6 @@ async fn test_duplicate_claim_note_rejected() -> anyhow::Result<()> { max_supply, Felt::ZERO, bridge_account.id(), - &origin_token_address, - origin_network, - scale, - leaf_data.metadata_hash, ); builder.add_account(agglayer_faucet.clone())?; @@ -488,7 +809,7 @@ async fn test_duplicate_claim_note_rejected() -> anyhow::Result<()> { miden_claim_amount, }; - let claim_note_1 = create_claim_note( + let claim_note_1 = ClaimNote::create( claim_inputs_1, bridge_account.id(), bridge_admin.id(), @@ -503,7 +824,7 @@ async fn test_duplicate_claim_note_rejected() -> anyhow::Result<()> { miden_claim_amount, }; - let claim_note_2 = create_claim_note( + let claim_note_2 = ClaimNote::create( claim_inputs_2, bridge_account.id(), bridge_admin.id(), @@ -513,8 +834,14 @@ async fn test_duplicate_claim_note_rejected() -> anyhow::Result<()> { // CREATE CONFIG_AGG_BRIDGE NOTE let config_note = ConfigAggBridgeNote::create( - agglayer_faucet.id(), - &origin_token_address, + 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(), @@ -576,26 +903,640 @@ async fn test_duplicate_claim_note_rejected() -> anyhow::Result<()> { 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 +/// the MINT→faucet→P2ID flow. Instead, the bridge removes the asset from its own vault and +/// emits a P2ID note directly to the recipient. +/// +/// Flow: +/// 1. Register a native (non-bridge-owned) faucet with `is_native = true` using the +/// origin_token_address and metadata_hash from a simulated L1→Miden claim vector. +/// 2. Seed the bridge vault by running one lock transaction (bridge-out of a B2AGG note carrying +/// `miden_claim_amount` of the native asset). +/// 3. Store a GER that covers the claim's Merkle proof. +/// 4. Execute the CLAIM against the bridge — the `claim` proc dispatches into `unlock_and_send` +/// because the faucet is registered with `is_native = true`. +/// 5. Assert that exactly one output P2ID note is produced, its asset matches what was locked, the +/// bridge vault is drained to 0, and the destination can consume the P2ID. #[tokio::test] -async fn solidity_verify_merkle_proof_compatibility() -> anyhow::Result<()> { - let merkle_paths = &*SOLIDITY_MERKLE_PROOF_VECTORS; +async fn bridge_in_unlock_native_token() -> anyhow::Result<()> { + let data_source = ClaimDataSource::L1ToMiden; + let mut builder = MockChain::builder(); - assert_eq!(merkle_paths.leaves.len(), merkle_paths.roots.len()); - assert_eq!(merkle_paths.leaves.len() * 32, merkle_paths.merkle_paths.len()); + // Bridge admin / GER manager / bridge account. + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; - for leaf_index in 0..32 { - let source = merkle_proof_verification_code(leaf_index, merkle_paths); + let bridge_seed = builder.rng_mut().draw_word(); + let mut bridge_account = + create_existing_bridge_account(bridge_seed, bridge_admin.id(), ger_manager.id()); + builder.add_account(bridge_account.clone())?; - let tx_script = CodeBuilder::new() - .with_statically_linked_library(&agglayer_library())? - .compile_tx_script(source)?; + // Claim data: leaf data's origin_token_address + metadata_hash must match the registration + // below so the bridge's token-registry lookup resolves to the native faucet. + let (proof_data, leaf_data, ger, _cgi_chain_hash) = data_source.get_data(); + let origin_token_address = leaf_data.origin_token_address; + let origin_network = leaf_data.origin_network; + let metadata_hash = leaf_data.metadata_hash; + let scale = 10u8; - TransactionContextBuilder::with_existing_mock_account() - .tx_script(tx_script.clone()) - .build()? - .execute() + // The amount the claim will attempt to unlock: scaled from the leaf's U256 amount. + let miden_claim_amount = leaf_data + .amount + .scale_to_token_amount(scale as u32) + .expect("amount should scale successfully"); + let miden_claim_amount_u64 = miden_claim_amount.as_canonical_u64(); + + // Native faucet: use the network-faucet pattern (bridge is not the owner). + let faucet_owner_account_id = + AccountId::dummy([3; 15], AccountIdVersion::Version1, AccountType::Private); + let native_faucet = builder.add_existing_network_faucet( + "NATIVE", + miden_claim_amount_u64.saturating_mul(4), + 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, + [], + )?; + + // Destination of the claim (derived from leaf data's destination_address). The mock account + // is built directly from the destination ID encoded in the JSON test vector, since the claim + // note targets this account ID. + let destination_account_id = EthEmbeddedAccountId::try_from(leaf_data.destination_address) + .expect("destination address is not an embedded Miden AccountId") + .into_account_id(); + let destination_account = + Account::mock(u128::from(destination_account_id), IncrNonceAuthComponent); + builder.add_account(destination_account.clone())?; + + // Sender of the CLAIM note (any wallet — just a note creator). + let claim_sender = { + let account_builder = + Account::builder(builder.rng_mut().random()).with_component(BasicWallet); + builder.add_account_from_builder(Auth::IncrNonce, account_builder, AccountState::Exists)? + }; + + // Sender of the B2AGG note used to seed the bridge vault with the native asset. + let lock_sender = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // Register the native faucet with is_native = true. + let config_note = ConfigAggBridgeNote::create( + ConversionMetadata { + faucet_account_id: native_faucet.id(), + origin_token_address, + scale, + origin_network, + is_native: true, + metadata_hash, + }, + bridge_admin.id(), + bridge_account.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(config_note.clone())); + + // 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(); + let b2agg_destination_address = + EthAddress::from_hex("0x1234567890abcdef1122334455667788990011aa") + .expect("valid destination address"); + let b2agg_note = B2AggNote::create( + 1u32, + b2agg_destination_address, + NoteAssets::new(vec![bridge_asset])?, + bridge_account.id(), + lock_sender.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(b2agg_note.clone())); + + // CLAIM note targeting the bridge. + let serial_num = proof_data.to_commitment(); + let claim_inputs = ClaimNoteStorage { + proof_data, + leaf_data, + miden_claim_amount, + }; + let claim_note = + ClaimNote::create(claim_inputs, bridge_account.id(), claim_sender.id(), builder.rng_mut())?; + builder.add_output_note(RawOutputNote::Full(claim_note.clone())); + + // GER for the claim's Merkle proof. + let update_ger_note = + UpdateGerNote::create(ger, ger_manager.id(), bridge_account.id(), builder.rng_mut())?; + builder.add_output_note(RawOutputNote::Full(update_ger_note.clone())); + + let mut mock_chain = builder.clone().build()?; + + // TX0: CONFIG — registers native faucet with is_native = true. + let config_executed = mock_chain + .build_tx_context(bridge_account.id(), &[config_note.id()], &[])? + .build()? + .execute() + .await?; + bridge_account.apply_delta(config_executed.account_delta())?; + mock_chain.add_pending_executed_transaction(&config_executed)?; + mock_chain.prove_next_block()?; + + // TX1: LOCK — bridge consumes the B2AGG note, asset goes into bridge vault. + let lock_executed = mock_chain + .build_tx_context(bridge_account.clone(), &[b2agg_note.id()], &[])? + .build()? + .execute() + .await?; + assert_eq!( + lock_executed.output_notes().num_notes(), + 0, + "Lock transaction should not emit any output note" + ); + bridge_account.apply_delta(lock_executed.account_delta())?; + assert_eq!( + bridge_account.vault().get_balance(bridge_asset.vault_key())?, + AssetAmount::new(miden_claim_amount_u64)?, + "Bridge vault should hold the locked native asset before the claim" + ); + mock_chain.add_pending_executed_transaction(&lock_executed)?; + mock_chain.prove_next_block()?; + + // TX2: UPDATE_GER. + let update_ger_executed = mock_chain + .build_tx_context(bridge_account.id(), &[update_ger_note.id()], &[])? + .build()? + .execute() + .await?; + bridge_account.apply_delta(update_ger_executed.account_delta())?; + mock_chain.add_pending_executed_transaction(&update_ger_executed)?; + mock_chain.prove_next_block()?; + + // TX3: CLAIM — bridge validates the proof, hits the is_native branch, unlocks and emits P2ID. + let claim_executed = mock_chain + .build_tx_context(bridge_account.clone(), &[], &[claim_note])? + .build()? + .execute() + .await + .context("CLAIM execution against bridge failed")?; + + // Exactly one output note — a PUBLIC P2ID carrying the native asset, sent by the bridge. + assert_eq!( + claim_executed.output_notes().num_notes(), + 1, + "Unlock path should emit exactly one P2ID output note" + ); + let output_note = match claim_executed.output_notes().get_note(0) { + RawOutputNote::Full(note) => note.clone(), + other => panic!("expected Full output note, got {other:?}"), + }; + + let expected_asset: Asset = + FungibleAsset::new(native_faucet.id(), miden_claim_amount_u64).unwrap().into(); + + assert_eq!(output_note.metadata().sender(), bridge_account.id()); + assert_eq!(output_note.metadata().note_type(), NoteType::Public); + assert_eq!( + output_note.recipient().script().root(), + P2idNote::script().root(), + "Output note should use the P2ID script" + ); + assert_eq!(output_note.recipient().serial_num(), serial_num); + + let mut assets_iter = output_note.assets().iter_fungible(); + let unlocked_asset = assets_iter + .next() + .expect("P2ID output note should carry exactly one fungible asset"); + assert!(assets_iter.next().is_none(), "P2ID output note should carry only one asset"); + assert_eq!(Felt::from(unlocked_asset.amount()), miden_claim_amount); + assert_eq!(unlocked_asset.faucet_id(), native_faucet.id()); + + // Cross-check storage directly: it should encode the destination account ID the same way + // `P2idNoteStorage::from` does ([suffix, prefix]). + let expected_p2id_note = create_p2id_note_exact( + bridge_account.id(), + destination_account_id, + vec![expected_asset], + NoteType::Public, + serial_num, + ) + .unwrap(); + let actual_storage = output_note.recipient().storage(); + let expected_storage = expected_p2id_note.recipient().storage(); + assert_eq!( + actual_storage, expected_storage, + "P2ID note storage items (encoding the target account ID) should match \ + the standard P2idNoteStorage encoding for destination_account_id={destination_account_id:?}" + ); + assert_eq!( + output_note.recipient().digest(), + expected_p2id_note.recipient().digest(), + "Recipient digest should match an independently constructed P2ID to the destination" + ); + + // Bridge vault is drained after the unlock. + bridge_account.apply_delta(claim_executed.account_delta())?; + assert_eq!( + bridge_account.vault().get_balance(expected_asset.vault_key())?, + AssetAmount::ZERO, + "Bridge vault should be empty after the unlock" + ); + + mock_chain.add_pending_executed_transaction(&claim_executed)?; + mock_chain.prove_next_block()?; + + // TX4: destination consumes the P2ID note and receives the unlocked asset. Pass the account + // directly since the JSON-encoded destination decodes to a private account ID. + let consume_executed = mock_chain + .build_tx_context(destination_account.clone(), &[], slice::from_ref(&expected_p2id_note))? + .build()? + .execute() + .await?; + + let mut destination_account = destination_account; + destination_account.apply_delta(consume_executed.account_delta())?; + assert_eq!( + destination_account.vault().get_balance(expected_asset.vault_key())?, + AssetAmount::new(miden_claim_amount_u64)?, + "Destination account should receive the unlocked asset from the P2ID" + ); + + Ok(()) +} + +/// Tests that a second CLAIM reusing the same leaf against the native unlock path is rejected. +/// +/// The native unlock path in `bridge_in_output::unlock_and_send` uses a deterministic P2ID serial +/// number derived from `CLAIM_PROOF_DATA_KEY`. Replay safety therefore depends on the claim +/// nullifier check in `bridge_in::claim` running before the branch into `unlock_and_send`. This +/// test seeds the bridge vault with enough native supply to serve two unlocks, then confirms the +/// second CLAIM with the same proof data is rejected with `ERR_CLAIM_ALREADY_SPENT` rather than +/// draining the vault a second time. +#[tokio::test] +async fn bridge_in_unlock_native_duplicate_rejected() -> anyhow::Result<()> { + let data_source = ClaimDataSource::L1ToMiden; + let mut builder = MockChain::builder(); + + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_manager = 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()); + builder.add_account(bridge_account.clone())?; + + let (proof_data, leaf_data, ger, _cgi_chain_hash) = data_source.get_data(); + let origin_token_address = leaf_data.origin_token_address; + let origin_network = leaf_data.origin_network; + let metadata_hash = leaf_data.metadata_hash; + let scale = 10u8; + + let miden_claim_amount = leaf_data + .amount + .scale_to_token_amount(scale as u32) + .expect("amount should scale successfully"); + let miden_claim_amount_u64 = miden_claim_amount.as_canonical_u64(); + + // Seed the native faucet and the lock sender with enough supply to cover two unlocks. If the + // nullifier check is ever weakened, the second claim would otherwise succeed and drain the + // vault a second time. + let faucet_owner_account_id = + AccountId::dummy([3; 15], AccountIdVersion::Version1, AccountType::Private); + let native_faucet = builder.add_existing_network_faucet( + "NATIVE", + miden_claim_amount_u64.saturating_mul(4), + faucet_owner_account_id, + Some(miden_claim_amount_u64.saturating_mul(4)), + MintPolicyConfig::OwnerOnly, + [], + )?; + + // Destination of the claim, built directly from the destination ID encoded in the JSON test + // vector so the mock account matches the account ID the claim note targets. + let destination_account_id = EthEmbeddedAccountId::try_from(leaf_data.destination_address) + .expect("destination address is not an embedded Miden AccountId") + .into_account_id(); + let destination_account = + Account::mock(u128::from(destination_account_id), IncrNonceAuthComponent); + builder.add_account(destination_account)?; + + let claim_sender = { + let account_builder = + Account::builder(builder.rng_mut().random()).with_component(BasicWallet); + builder.add_account_from_builder(Auth::IncrNonce, account_builder, AccountState::Exists)? + }; + + let lock_sender = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let config_note = ConfigAggBridgeNote::create( + ConversionMetadata { + faucet_account_id: native_faucet.id(), + origin_token_address, + scale, + origin_network, + is_native: true, + metadata_hash, + }, + bridge_admin.id(), + bridge_account.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(config_note.clone())); + + // 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 b2agg_destination_address = + EthAddress::from_hex("0x1234567890abcdef1122334455667788990011aa") + .expect("valid destination address"); + let b2agg_note = B2AggNote::create( + 1u32, + b2agg_destination_address, + NoteAssets::new(vec![bridge_asset])?, + bridge_account.id(), + lock_sender.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(b2agg_note.clone())); + + let claim_inputs_1 = ClaimNoteStorage { + proof_data: proof_data.clone(), + leaf_data: leaf_data.clone(), + miden_claim_amount, + }; + let claim_note_1 = ClaimNote::create( + claim_inputs_1, + bridge_account.id(), + claim_sender.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(claim_note_1.clone())); + + let claim_inputs_2 = ClaimNoteStorage { + proof_data: proof_data.clone(), + leaf_data: leaf_data.clone(), + miden_claim_amount, + }; + let claim_note_2 = ClaimNote::create( + claim_inputs_2, + bridge_account.id(), + claim_sender.id(), + builder.rng_mut(), + )?; + 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())?; + builder.add_output_note(RawOutputNote::Full(update_ger_note.clone())); + + let mut mock_chain = builder.clone().build()?; + + // TX0: CONFIG — register native faucet. + let config_executed = mock_chain + .build_tx_context(bridge_account.id(), &[config_note.id()], &[])? + .build()? + .execute() + .await?; + bridge_account.apply_delta(config_executed.account_delta())?; + mock_chain.add_pending_executed_transaction(&config_executed)?; + mock_chain.prove_next_block()?; + + // TX1: LOCK — seed bridge vault with 2x miden_claim_amount. + let lock_executed = mock_chain + .build_tx_context(bridge_account.clone(), &[b2agg_note.id()], &[])? + .build()? + .execute() + .await?; + bridge_account.apply_delta(lock_executed.account_delta())?; + assert_eq!( + bridge_account.vault().get_balance(bridge_asset.vault_key())?, + AssetAmount::new(miden_claim_amount_u64.saturating_mul(2))?, + ); + mock_chain.add_pending_executed_transaction(&lock_executed)?; + mock_chain.prove_next_block()?; + + // TX2: UPDATE_GER. + let update_ger_executed = mock_chain + .build_tx_context(bridge_account.id(), &[update_ger_note.id()], &[])? + .build()? + .execute() + .await?; + bridge_account.apply_delta(update_ger_executed.account_delta())?; + mock_chain.add_pending_executed_transaction(&update_ger_executed)?; + mock_chain.prove_next_block()?; + + // TX3: FIRST CLAIM — should succeed and drain half the vault. + let claim_executed_1 = mock_chain + .build_tx_context(bridge_account.clone(), &[], &[claim_note_1])? + .build()? + .execute() + .await?; + assert_eq!(claim_executed_1.output_notes().num_notes(), 1); + bridge_account.apply_delta(claim_executed_1.account_delta())?; + assert_eq!( + bridge_account.vault().get_balance(bridge_asset.vault_key())?, + AssetAmount::new(miden_claim_amount_u64)?, + "Bridge vault should hold exactly the remaining half after the first unlock" + ); + mock_chain.add_pending_executed_transaction(&claim_executed_1)?; + mock_chain.prove_next_block()?; + + // TX4: SECOND CLAIM with same proof data — should fail on the nullifier, before reaching + // `unlock_and_send`. Vault still has enough to serve it, so a pass here would mean the + // nullifier gate is broken. + let result = mock_chain + .build_tx_context(bridge_account, &[], &[claim_note_2])? + .build()? + .execute() + .await; + assert!( + result.is_err(), + "Second native-path claim with the same PROOF_DATA_KEY should fail" + ); + let error_msg = result.unwrap_err().to_string(); + let expected_err_code = ERR_CLAIM_ALREADY_SPENT.code().to_string(); + assert!( + error_msg.contains(&expected_err_code), + "expected error code {expected_err_code} for 'claim note has already been spent', got: {error_msg}" + ); + + Ok(()) +} + +#[tokio::test] +async fn solidity_verify_merkle_proof_compatibility() -> anyhow::Result<()> { + let merkle_paths = &*SOLIDITY_MERKLE_PROOF_VECTORS; + + assert_eq!(merkle_paths.leaves.len(), merkle_paths.roots.len()); + assert_eq!(merkle_paths.leaves.len() * 32, merkle_paths.merkle_paths.len()); + + for leaf_index in 0..32 { + let source = merkle_proof_verification_code(leaf_index, merkle_paths); + + let tx_script = CodeBuilder::new() + .with_statically_linked_library(&agglayer_library())? + .compile_tx_script(source)?; + + TransactionContextBuilder::with_existing_mock_account() + .tx_script(tx_script.clone()) + .build()? + .execute() .await .context(format!("failed to execute transaction with leaf index {leaf_index}"))?; } Ok(()) } + +/// Regression test for issue #2799 (ported from agglayer in #2860). +/// +/// Registers a faucet for `(origin_token_address, registered_origin_network)` then submits a +/// CLAIM whose leaf carries the same `origin_token_address` but a different `origin_network`. +/// Before the fix, `lookup_faucet_by_token_address` was keyed on the address alone and would +/// silently resolve to the registered faucet — letting a deposit on one network mint via a +/// faucet bound to another. With the registry keyed on the (address, network) pair, the lookup +/// now misses and the claim is rejected with `ERR_TOKEN_NOT_REGISTERED`. +#[tokio::test] +async fn test_claim_fails_when_origin_network_unregistered() -> anyhow::Result<()> { + let data_source = ClaimDataSource::L1ToMiden; + let mut builder = MockChain::builder(); + + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let ger_manager = 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()); + builder.add_account(bridge_account.clone())?; + + let (proof_data, leaf_data, ger, _cgi_chain_hash) = data_source.get_data(); + + 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 leaf_origin_network = leaf_data.origin_network; + // The CLAIM's leaf carries `leaf_origin_network`; the bridge is configured to recognize + // this faucet for a *different* origin_network, so token-registry lookup must miss. + let registered_origin_network = leaf_origin_network.wrapping_add(1); + assert_ne!( + registered_origin_network, leaf_origin_network, + "test setup: registered network must differ from the leaf's network" + ); + let scale = 10u8; + let metadata_hash = leaf_data.metadata_hash; + + 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())?; + + let sender_account_builder = + Account::builder(builder.rng_mut().random()).with_component(BasicWallet); + let sender_account = builder.add_account_from_builder( + Auth::IncrNonce, + sender_account_builder, + AccountState::Exists, + )?; + + let miden_claim_amount = leaf_data + .amount + .scale_to_token_amount(scale as u32) + .expect("amount should scale successfully"); + + let claim_inputs = ClaimNoteStorage { + proof_data, + leaf_data, + miden_claim_amount, + }; + let claim_note = ClaimNote::create( + claim_inputs, + bridge_account.id(), + sender_account.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(claim_note.clone())); + + // Register the faucet under the WRONG origin network. The leaf in the CLAIM will carry + // `leaf_origin_network`, so `lookup_faucet_by_token_address` will compute a different key + // and find no entry. + let config_note = ConfigAggBridgeNote::create( + ConversionMetadata { + faucet_account_id: agglayer_faucet.id(), + origin_token_address, + scale, + origin_network: registered_origin_network, + is_native: false, + metadata_hash, + }, + bridge_admin.id(), + bridge_account.id(), + builder.rng_mut(), + )?; + 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())?; + builder.add_output_note(RawOutputNote::Full(update_ger_note.clone())); + + let mut mock_chain = builder.clone().build()?; + + // TX0: register faucet for `registered_origin_network`. + let config_tx = mock_chain + .build_tx_context(bridge_account.id(), &[config_note.id()], &[])? + .build()?; + let config_executed = config_tx.execute().await?; + mock_chain.add_pending_executed_transaction(&config_executed)?; + mock_chain.prove_next_block()?; + + // TX1: store the GER. + let update_ger_tx = mock_chain + .build_tx_context(bridge_account.id(), &[update_ger_note.id()], &[])? + .build()?; + let update_ger_executed = update_ger_tx.execute().await?; + mock_chain.add_pending_executed_transaction(&update_ger_executed)?; + mock_chain.prove_next_block()?; + + // TX2: attempt the CLAIM whose leaf carries `leaf_origin_network`. The lookup must miss. + let faucet_foreign_inputs = mock_chain.get_foreign_account_inputs(agglayer_faucet.id())?; + let claim_tx = mock_chain + .build_tx_context(bridge_account.id(), &[], &[claim_note])? + .foreign_accounts(vec![faucet_foreign_inputs]) + .build()?; + + let result = claim_tx.execute().await; + assert!(result.is_err(), "CLAIM whose origin_network is not registered must fail"); + let error_msg = result.unwrap_err().to_string(); + let expected_err_code = ERR_TOKEN_NOT_REGISTERED.code().to_string(); + assert!( + error_msg.contains(&expected_err_code), + "expected error code {expected_err_code} for cross-network unregistered claim, got: {error_msg}" + ); + + Ok(()) +} diff --git a/crates/miden-testing/tests/agglayer/bridge_out.rs b/crates/miden-testing/tests/agglayer/bridge_out.rs index e0a61d3e47..b7c336e68a 100644 --- a/crates/miden-testing/tests/agglayer/bridge_out.rs +++ b/crates/miden-testing/tests/agglayer/bridge_out.rs @@ -1,29 +1,39 @@ extern crate alloc; -use miden_agglayer::errors::{ERR_B2AGG_TARGET_ACCOUNT_MISMATCH, ERR_FAUCET_NOT_REGISTERED}; +use miden_agglayer::errors::{ + ERR_B2AGG_DESTINATION_NETWORK_IS_MIDEN, + ERR_B2AGG_TARGET_ACCOUNT_MISMATCH, + ERR_FAUCET_NOT_REGISTERED, +}; use miden_agglayer::{ AggLayerBridge, B2AggNote, ConfigAggBridgeNote, + ConversionMetadata, EthAddress, ExitRoot, + Keccak256Output, MetadataHash, create_existing_agglayer_faucet, create_existing_bridge_account, }; +use miden_crypto::hash::keccak::Keccak256Digest; use miden_crypto::rand::FeltRng; -use miden_protocol::Felt; use miden_protocol::account::auth::AuthScheme; -use miden_protocol::account::{AccountId, AccountIdVersion, AccountStorageMode, AccountType}; -use miden_protocol::asset::{Asset, FungibleAsset}; +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::transaction::RawOutputNote; -use miden_standards::account::faucets::TokenMetadata; -use miden_standards::account::mint_policies::OwnerControlledInitConfig; -use miden_standards::note::StandardNote; +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_testing::{Auth, MockChain, assert_transaction_executor_error}; use miden_tx::utils::hex_to_bytes; +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; +use super::merkle_tree_frontier::MerkleTreeFrontier32; use super::test_utils::SOLIDITY_MTF_VECTORS; /// Tests that 32 sequential B2AGG note consumptions match all 32 Solidity MTF roots. @@ -84,7 +94,7 @@ async fn bridge_out_consecutive() -> anyhow::Result<()> { .collect::>(); let total_burned: u64 = expected_amounts.iter().sum(); - // CREATE AGGLAYER FAUCET ACCOUNT (with conversion metadata for FPI) + // CREATE AGGLAYER FAUCET ACCOUNT // -------------------------------------------------------------------------------------------- let origin_token_address = EthAddress::from_hex(&vectors.origin_token_address) .expect("valid shared origin token address"); @@ -99,20 +109,22 @@ async fn bridge_out_consecutive() -> anyhow::Result<()> { builder.rng_mut().draw_word(), &vectors.token_symbol, vectors.token_decimals, - Felt::new(FungibleAsset::MAX_AMOUNT), - Felt::new(total_burned), + FungibleAsset::MAX_AMOUNT.into(), + Felt::new_unchecked(total_burned), bridge_account.id(), - &origin_token_address, - origin_network, - scale, - metadata_hash, ); builder.add_account(faucet.clone())?; // CONFIG_AGG_BRIDGE note to register the faucet in the bridge (sent by bridge admin) let config_note = ConfigAggBridgeNote::create( - faucet.id(), - &origin_token_address, + ConversionMetadata { + faucet_account_id: faucet.id(), + origin_token_address, + scale, + origin_network, + is_native: false, + metadata_hash, + }, bridge_admin.id(), bridge_account.id(), builder.rng_mut(), @@ -159,11 +171,8 @@ async fn bridge_out_consecutive() -> anyhow::Result<()> { let mut burn_note_ids = Vec::with_capacity(note_count); for (i, note) in notes.iter().enumerate() { - let foreign_account_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; - let executed_tx = mock_chain .build_tx_context(bridge_account.clone(), &[note.id()], &[])? - .foreign_accounts(vec![foreign_account_inputs]) .build()? .execute() .await?; @@ -191,8 +200,12 @@ async fn bridge_out_consecutive() -> anyhow::Result<()> { NoteType::Public, "BURN note should be public" ); - let attachment = burn_note.metadata().attachment(); - let network_target = miden_standards::note::NetworkAccountTarget::try_from(attachment) + assert_eq!( + burn_note.attachments().num_attachments(), + 1, + "BURN note should have one attachment" + ); + let network_target = NetworkAccountTarget::try_from(burn_note.attachments()) .expect("BURN note attachment should be a valid NetworkAccountTarget"); assert_eq!( network_target.target_id(), @@ -227,10 +240,10 @@ async fn bridge_out_consecutive() -> anyhow::Result<()> { // STEP 3: CONSUME ALL BURN NOTES WITH THE AGGLAYER FAUCET // -------------------------------------------------------------------------------------------- - let initial_token_supply = TokenMetadata::try_from(faucet.storage())?.token_supply(); + let initial_token_supply = FungibleFaucet::try_from(faucet.storage())?.token_supply(); assert_eq!( initial_token_supply, - Felt::new(total_burned), + AssetAmount::new(total_burned)?, "Initial issuance should match all pending burns" ); @@ -251,16 +264,202 @@ async fn bridge_out_consecutive() -> anyhow::Result<()> { mock_chain.prove_next_block()?; } - let final_token_supply = TokenMetadata::try_from(faucet.storage())?.token_supply(); + let final_token_supply = FungibleFaucet::try_from(faucet.storage())?.token_supply(); assert_eq!( final_token_supply, - Felt::new(initial_token_supply.as_canonical_u64() - total_burned), + AssetAmount::new(initial_token_supply.as_u64() - total_burned)?, "Token supply should decrease by the sum of 32 bridged amounts" ); Ok(()) } +/// Pre-populates the bridge account's LET storage with a chosen `num_leaves` value and 32 +/// frontier digests, so the bridge appears to have already received that many leaves without +/// performing the (potentially billions of) sequential inserts. +/// +/// The lo/hi packing matches what `load_let_frontier_selective` reads from `double_word_array` +/// storage. The masm builds map keys via `loc_load.INDEX_LOC; push.0.0.0` (lo) or +/// `push.1.0.0` (hi), leaving the index at the bottom of the 4-felt key window. Stack top maps +/// to `felt[0]` of the storage `Word`, so the actual key Words are `[0, 0, 0, h]` (lo) and +/// `[0, 0, 1, h]` (hi) in `(felt[0], felt[1], felt[2], felt[3])` order. +fn populate_let_state(bridge: &mut Account, num_leaves: u32, frontier: &[Keccak256Digest; 32]) { + let zero = Felt::ZERO; + + bridge + .storage_mut() + .set_item( + AggLayerBridge::let_num_leaves_slot_name(), + Word::new([Felt::new_unchecked(num_leaves as u64), zero, zero, zero]), + ) + .expect("should set LET num_leaves"); + + for (h, digest) in frontier.iter().enumerate() { + let bytes: [u8; 32] = (*digest).into(); + let [lo, hi] = Keccak256Output::new(bytes).to_words(); + + let h = h as u32; + bridge + .storage_mut() + .set_map_item( + AggLayerBridge::let_frontier_slot_name(), + StorageMapKey::from_array([0, 0, 0, h]), + lo, + ) + .expect("should set frontier word 0"); + bridge + .storage_mut() + .set_map_item( + AggLayerBridge::let_frontier_slot_name(), + StorageMapKey::from_array([0, 0, 1, h]), + hi, + ) + .expect("should set frontier word 1"); + } +} + +/// Verifies frontier correctness across all 32 bit positions using a high `num_leaves`. +/// +/// - `num_leaves = 2^31 - 1` (binary `0111...1`): internally, selectively reads all frontier +/// heights 0..30 from storage, and writes the updated frontier[31] back to storage. +/// - `num_leaves = 2^31` (binary `1000...0`): internally, selectively reads frontier[31] from +/// storage, and writes the updated frontier[0..30] back to storage. +/// +/// Together these cover every height in both roles. Each scenario consumes one B2AGG note +/// against a bridge account that's been pre-populated to the chosen `num_leaves`, then verifies +/// the resulting LER against the Rust `MerkleTreeFrontier32` reference. +/// Note: we don't verify against the Solidity implementation here. +#[rstest::rstest] +#[case::peak_read((1u32 << 31) - 1)] +#[case::peak_write(1u32 << 31)] +#[tokio::test] +async fn bridge_out_at_high_num_leaves(#[case] initial_num_leaves: u32) -> anyhow::Result<()> { + let vectors = &*SOLIDITY_MTF_VECTORS; + + // Random-but-deterministic initial frontier. The masm storage and the Rust reference both + // start from the same digests, so we're verifying that the masm path computes the same root + // as the reference for arbitrary frontier contents — the cryptographic validity of the + // initial digests is irrelevant. A seeded RNG keeps the test reproducible across runs. + let mut rng = StdRng::seed_from_u64(0xa110_1eaf); + let initial_frontier: [Keccak256Digest; 32] = core::array::from_fn(|_| { + let mut bytes = [0u8; 32]; + rng.fill(&mut bytes); + Keccak256Digest::from(bytes) + }); + + let mut mtf = MerkleTreeFrontier32::<32>::from_state(initial_num_leaves, initial_frontier); + + let mut builder = MockChain::builder(); + + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_manager = 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(), + ); + populate_let_state(&mut bridge_account, initial_num_leaves, &initial_frontier); + builder.add_account(bridge_account.clone())?; + + // CREATE AGGLAYER FAUCET ACCOUNT (with conversion metadata for FPI) + let amount = vectors.amounts[0].parse::().expect("valid amount decimal string"); + let origin_token_address = EthAddress::from_hex(&vectors.origin_token_address) + .expect("valid shared origin token address"); + let origin_network = 64u32; + let scale = 0u8; + let metadata_hash = MetadataHash::from_token_info( + &vectors.token_name, + &vectors.token_symbol, + vectors.token_decimals, + ); + let faucet = create_existing_agglayer_faucet( + builder.rng_mut().draw_word(), + &vectors.token_symbol, + vectors.token_decimals, + Felt::from(FungibleAsset::MAX_AMOUNT), + Felt::new_unchecked(amount), + bridge_account.id(), + ); + builder.add_account(faucet.clone())?; + + let config_note = ConfigAggBridgeNote::create( + ConversionMetadata { + faucet_account_id: faucet.id(), + origin_token_address, + scale, + origin_network, + is_native: false, + metadata_hash, + }, + bridge_admin.id(), + bridge_account.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(config_note.clone())); + + 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 b2agg_note = B2AggNote::create( + destination_network, + eth_address, + NoteAssets::new(vec![bridge_asset])?, + bridge_account.id(), + faucet.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(b2agg_note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + // Register the faucet via CONFIG_AGG_BRIDGE. + let config_executed = mock_chain + .build_tx_context(bridge_account.id(), &[config_note.id()], &[])? + .build()? + .execute() + .await?; + bridge_account.apply_delta(config_executed.account_delta())?; + mock_chain.add_pending_executed_transaction(&config_executed)?; + mock_chain.prove_next_block()?; + + // Consume the B2AGG note. With the pre-populated frontier, this single insert hits the + // peak-read configuration (for 2^31 - 1) or peak-write configuration (for 2^31). + let foreign_account_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; + let executed_tx = mock_chain + .build_tx_context(bridge_account.clone(), &[b2agg_note.id()], &[])? + .foreign_accounts(vec![foreign_account_inputs]) + .build()? + .execute() + .await?; + bridge_account.apply_delta(executed_tx.account_delta())?; + + let leaf = Keccak256Digest::try_from(vectors.leaves[0].as_str()) + .expect("valid leaf hex from MTF vectors"); + let expected_root = mtf.append_and_update_frontier(leaf); + + assert_eq!( + AggLayerBridge::read_let_num_leaves(&bridge_account), + initial_num_leaves as u64 + 1, + "LET leaf count should increment by 1", + ); + + let expected_ler = ExitRoot::new(expected_root.into()).to_elements(); + assert_eq!( + AggLayerBridge::read_local_exit_root(&bridge_account)?, + expected_ler, + "Local Exit Root should match the Rust MTF reference", + ); + + Ok(()) +} + /// Tests that bridging out fails when the faucet is not registered in the bridge's registry. /// /// This test verifies the faucet allowlist check in bridge_out's `convert_asset` procedure: @@ -295,29 +494,19 @@ async fn test_bridge_out_fails_with_unregistered_faucet() -> anyhow::Result<()> // CREATE AGGLAYER FAUCET ACCOUNT (NOT registered in the bridge) // -------------------------------------------------------------------------------------------- let vectors = &*SOLIDITY_MTF_VECTORS; - let origin_token_address = EthAddress::new([0u8; 20]); - let metadata_hash = MetadataHash::from_token_info( - &vectors.token_name, - &vectors.token_symbol, - vectors.token_decimals, - ); let faucet = create_existing_agglayer_faucet( builder.rng_mut().draw_word(), &vectors.token_symbol, vectors.token_decimals, - Felt::new(FungibleAsset::MAX_AMOUNT), - Felt::new(100), + FungibleAsset::MAX_AMOUNT.into(), + Felt::new_unchecked(100), bridge_account.id(), - &origin_token_address, - 0, // origin_network - 0, // scale - metadata_hash, ); builder.add_account(faucet.clone())?; // CREATE B2AGG NOTE WITH ASSETS FROM THE UNREGISTERED FAUCET // -------------------------------------------------------------------------------------------- - let amount = Felt::new(100); + let amount = Felt::new_unchecked(100); let bridge_asset: Asset = FungibleAsset::new(faucet.id(), amount.as_canonical_u64()).unwrap().into(); @@ -338,6 +527,122 @@ async fn test_bridge_out_fails_with_unregistered_faucet() -> anyhow::Result<()> // ATTEMPT TO BRIDGE OUT WITHOUT REGISTERING THE FAUCET (SHOULD FAIL) // -------------------------------------------------------------------------------------------- + let result = mock_chain + .build_tx_context(bridge_account.id(), &[b2agg_note.id()], &[])? + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_FAUCET_NOT_REGISTERED); + + 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. +#[tokio::test] +async fn test_bridge_out_fails_when_destination_is_miden_network() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + // CREATE BRIDGE ADMIN ACCOUNT (sends CONFIG_AGG_BRIDGE notes) + // -------------------------------------------------------------------------------------------- + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // CREATE GER MANAGER ACCOUNT (not used for GER in this test, but distinct from admin) + // -------------------------------------------------------------------------------------------- + let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // CREATE BRIDGE ACCOUNT + // -------------------------------------------------------------------------------------------- + let mut bridge_account = create_existing_bridge_account( + builder.rng_mut().draw_word(), + bridge_admin.id(), + ger_manager.id(), + ); + builder.add_account(bridge_account.clone())?; + + // CREATE AGGLAYER FAUCET ACCOUNT (with conversion metadata for FPI) + // Use MTF vector token metadata and a fixed origin network compatible with the vectors. + // -------------------------------------------------------------------------------------------- + let vectors = &*SOLIDITY_MTF_VECTORS; + let origin_token_address = + EthAddress::from_hex(&vectors.origin_token_address).expect("valid origin token address"); + let origin_network = 64u32; + let metadata_hash = MetadataHash::from_token_info( + &vectors.token_name, + &vectors.token_symbol, + vectors.token_decimals, + ); + let faucet = create_existing_agglayer_faucet( + builder.rng_mut().draw_word(), + &vectors.token_symbol, + vectors.token_decimals, + FungibleAsset::MAX_AMOUNT.into(), + Felt::new_unchecked(100), + bridge_account.id(), + ); + builder.add_account(faucet.clone())?; + + // CREATE CONFIG_AGG_BRIDGE NOTE (registers faucet + token address in bridge) + // -------------------------------------------------------------------------------------------- + let config_note = ConfigAggBridgeNote::create( + ConversionMetadata { + faucet_account_id: faucet.id(), + origin_token_address, + scale: 0u8, + origin_network, + is_native: false, + metadata_hash, + }, + bridge_admin.id(), + bridge_account.id(), + builder.rng_mut(), + )?; + 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. + // -------------------------------------------------------------------------------------------- + let amount = Felt::new_unchecked(100); + let bridge_asset: Asset = + FungibleAsset::new(faucet.id(), amount.as_canonical_u64()).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(), + )?; + + builder.add_output_note(RawOutputNote::Full(b2agg_note.clone())); + + // BUILD MOCK CHAIN WITH ALL ACCOUNTS AND PENDING OUTPUT NOTES + // -------------------------------------------------------------------------------------------- + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + // TX0: EXECUTE CONFIG_AGG_BRIDGE NOTE TO REGISTER FAUCET IN BRIDGE + // -------------------------------------------------------------------------------------------- + let config_executed = mock_chain + .build_tx_context(bridge_account.id(), &[config_note.id()], &[])? + .build()? + .execute() + .await?; + bridge_account.apply_delta(config_executed.account_delta())?; + 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) + // -------------------------------------------------------------------------------------------- let foreign_account_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; let result = mock_chain @@ -347,7 +652,7 @@ async fn test_bridge_out_fails_with_unregistered_faucet() -> anyhow::Result<()> .execute() .await; - assert_transaction_executor_error!(result, ERR_FAUCET_NOT_REGISTERED); + assert_transaction_executor_error!(result, ERR_B2AGG_DESTINATION_NETWORK_IS_MIDEN); Ok(()) } @@ -368,12 +673,8 @@ async fn b2agg_note_reclaim_scenario() -> anyhow::Result<()> { let mut builder = MockChain::builder(); // Create a network faucet owner account - let faucet_owner_account_id = AccountId::dummy( - [1; 15], - AccountIdVersion::Version0, - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Private, - ); + let faucet_owner_account_id = + AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Private); // Create a network faucet to provide assets for the B2AGG note let faucet = builder.add_existing_network_faucet( @@ -381,7 +682,8 @@ async fn b2agg_note_reclaim_scenario() -> anyhow::Result<()> { 1000, faucet_owner_account_id, Some(100), - OwnerControlledInitConfig::OwnerOnly, + MintPolicyConfig::OwnerOnly, + [], )?; // Create a bridge admin account @@ -409,9 +711,8 @@ async fn b2agg_note_reclaim_scenario() -> anyhow::Result<()> { // CREATE B2AGG NOTE WITH USER ACCOUNT AS SENDER // -------------------------------------------------------------------------------------------- - let amount = Felt::new(50); - let bridge_asset: Asset = - FungibleAsset::new(faucet.id(), amount.as_canonical_u64()).unwrap().into(); + let amount = AssetAmount::from(50_u32); + let bridge_asset: Asset = FungibleAsset::new(faucet.id(), amount.as_u64()).unwrap().into(); let destination_network = 1u32; let destination_address = "0x1234567890abcdef1122334455667788990011aa"; @@ -433,7 +734,7 @@ async fn b2agg_note_reclaim_scenario() -> anyhow::Result<()> { let mut mock_chain = builder.build()?; // Store the initial asset balance of the user account - let initial_balance = user_account.vault().get_balance(faucet.id()).unwrap_or(0u64); + let initial_balance = user_account.vault().get_balance(bridge_asset.vault_key())?; // EXECUTE B2AGG NOTE WITH THE SAME USER ACCOUNT (RECLAIM SCENARIO) // -------------------------------------------------------------------------------------------- @@ -455,10 +756,10 @@ async fn b2agg_note_reclaim_scenario() -> anyhow::Result<()> { // VERIFY ASSETS WERE ADDED BACK TO THE ACCOUNT // -------------------------------------------------------------------------------------------- - let final_balance = user_account.vault().get_balance(faucet.id()).unwrap_or(0u64); + let final_balance = user_account.vault().get_balance(bridge_asset.vault_key())?; assert_eq!( final_balance, - initial_balance + amount.as_canonical_u64(), + (initial_balance + amount).unwrap(), "User account should have received the assets back from the B2AGG note" ); @@ -486,12 +787,8 @@ async fn b2agg_note_non_target_account_cannot_consume() -> anyhow::Result<()> { let mut builder = MockChain::builder(); // Create a network faucet owner account - let faucet_owner_account_id = AccountId::dummy( - [1; 15], - AccountIdVersion::Version0, - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Private, - ); + let faucet_owner_account_id = + AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Private); // Create a network faucet to provide assets for the B2AGG note let faucet = builder.add_existing_network_faucet( @@ -499,7 +796,8 @@ async fn b2agg_note_non_target_account_cannot_consume() -> anyhow::Result<()> { 1000, faucet_owner_account_id, Some(100), - OwnerControlledInitConfig::OwnerOnly, + MintPolicyConfig::OwnerOnly, + [], )?; // Create a bridge admin account @@ -535,7 +833,7 @@ async fn b2agg_note_non_target_account_cannot_consume() -> anyhow::Result<()> { // CREATE B2AGG NOTE // -------------------------------------------------------------------------------------------- - let amount = Felt::new(50); + let amount = Felt::new_unchecked(50); let bridge_asset: Asset = FungibleAsset::new(faucet.id(), amount.as_canonical_u64()).unwrap().into(); @@ -569,3 +867,141 @@ async fn b2agg_note_non_target_account_cannot_consume() -> anyhow::Result<()> { Ok(()) } + +/// Tests the bridge-out lock path for Miden-native faucets. +/// +/// When a faucet is registered with `is_native = true`, the bridge does not burn the asset on +/// bridge-out; it locks it in its own vault instead. This test verifies: +/// 1. Registration stores the `is_native = true` flag on the bridge. +/// 2. Consuming a B2AGG note carrying a native asset produces **no** output note (no BURN). +/// 3. The asset ends up in the bridge account's vault. +/// 4. The Local Exit Tree is still advanced (the leaf is committed the same way). +#[tokio::test] +async fn bridge_out_lock_native_token() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + // Bridge admin / GER manager / bridge account. + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_manager = 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(), + ); + builder.add_account(bridge_account.clone())?; + + // Native faucet: network-faucet pattern (not bridge-owned). + let faucet_owner_account_id = + AccountId::dummy([2; 15], AccountIdVersion::Version1, AccountType::Private); + let native_faucet = builder.add_existing_network_faucet( + "NATIVE", + 1000, + faucet_owner_account_id, + Some(500), + MintPolicyConfig::OwnerOnly, + [], + )?; + + // Sender of the B2AGG note (any regular wallet). + let sender_account = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // Register the native faucet in the bridge with `is_native = true`. + let origin_token_address = EthAddress::from_hex("0x00000000000000000000000000000000deadbeef") + .expect("valid eth address"); + let origin_network = 7u32; // any stable u32 — Miden's test network id + let scale = 0u8; + let metadata_hash = MetadataHash::from_token_info("Native Token", "NATIVE", 8); + + let config_note = ConfigAggBridgeNote::create( + ConversionMetadata { + faucet_account_id: native_faucet.id(), + origin_token_address, + scale, + origin_network, + is_native: true, + metadata_hash, + }, + bridge_admin.id(), + bridge_account.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(config_note.clone())); + + // B2AGG note carrying a native asset. + let amount = 42u64; + let bridge_asset: Asset = FungibleAsset::new(native_faucet.id(), amount).unwrap().into(); + let destination_network = 1u32; + let destination_address = EthAddress::from_hex("0x1234567890abcdef1122334455667788990011aa") + .expect("valid destination address"); + + let b2agg_note = B2AggNote::create( + destination_network, + destination_address, + NoteAssets::new(vec![bridge_asset])?, + bridge_account.id(), + sender_account.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(b2agg_note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + // TX0: register the faucet. + let config_executed = mock_chain + .build_tx_context(bridge_account.id(), &[config_note.id()], &[])? + .build()? + .execute() + .await?; + bridge_account.apply_delta(config_executed.account_delta())?; + mock_chain.add_pending_executed_transaction(&config_executed)?; + mock_chain.prove_next_block()?; + + // TX1: consume the B2AGG note against the bridge (triggers lock_asset). + let executed_tx = mock_chain + .build_tx_context(bridge_account.clone(), &[b2agg_note.id()], &[])? + .build()? + .execute() + .await?; + + // No BURN note is emitted on the lock path. + assert_eq!( + executed_tx.output_notes().num_notes(), + 0, + "Lock path should not emit any output note" + ); + + bridge_account.apply_delta(executed_tx.account_delta())?; + + // The asset now lives in the bridge's own vault. + let bridge_balance = bridge_account.vault().get_balance(bridge_asset.vault_key())?; + assert_eq!( + bridge_balance, + AssetAmount::new(amount)?, + "Bridge vault should hold the locked asset" + ); + + // Leaf was still committed to the LET; LER is non-zero. + assert_eq!( + AggLayerBridge::read_let_num_leaves(&bridge_account), + 1, + "LET should have exactly one leaf after the lock" + ); + let local_exit_root = AggLayerBridge::read_local_exit_root(&bridge_account)?; + assert!( + local_exit_root.iter().any(|f| f.as_canonical_u64() != 0), + "Local Exit Root should be non-zero after the lock" + ); + + mock_chain.add_pending_executed_transaction(&executed_tx)?; + mock_chain.prove_next_block()?; + + Ok(()) +} diff --git a/crates/miden-testing/tests/agglayer/config_bridge.rs b/crates/miden-testing/tests/agglayer/config_bridge.rs index f4e760ba58..ffe5792be3 100644 --- a/crates/miden-testing/tests/agglayer/config_bridge.rs +++ b/crates/miden-testing/tests/agglayer/config_bridge.rs @@ -1,19 +1,34 @@ extern crate alloc; +use alloc::vec::Vec; + use miden_agglayer::{ AggLayerBridge, ConfigAggBridgeNote, + ConversionMetadata, EthAddress, + MetadataHash, create_existing_bridge_account, }; -use miden_protocol::Felt; use miden_protocol::account::auth::AuthScheme; -use miden_protocol::account::{AccountId, AccountIdVersion, AccountStorageMode, AccountType}; +use miden_protocol::account::{AccountId, AccountIdVersion, AccountType}; use miden_protocol::block::account_tree::AccountIdKey; use miden_protocol::crypto::rand::FeltRng; use miden_protocol::transaction::RawOutputNote; +use miden_protocol::{Felt, Hasher, Word}; use miden_testing::{Auth, MockChain}; +/// Computes the `token_registry_map` key for a given (origin_token_address, origin_network) pair. +/// +/// 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 { + 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) +} + /// Tests that a CONFIG_AGG_BRIDGE note registers a faucet in the bridge's faucet registry. /// /// Flow: @@ -45,12 +60,8 @@ async fn test_config_agg_bridge_registers_faucet() -> anyhow::Result<()> { builder.add_account(bridge_account.clone())?; // Use a dummy faucet ID to register (any valid AccountId will do) - let faucet_to_register = AccountId::dummy( - [42; 15], - AccountIdVersion::Version0, - AccountType::FungibleFaucet, - AccountStorageMode::Network, - ); + let faucet_to_register = + AccountId::dummy([42; 15], AccountIdVersion::Version1, AccountType::Public); // Verify the faucet is NOT in the registry before registration let registry_slot_name = AggLayerBridge::faucet_registry_map_slot_name(); @@ -63,12 +74,20 @@ async fn test_config_agg_bridge_registers_faucet() -> anyhow::Result<()> { ); // CREATE CONFIG_AGG_BRIDGE NOTE - // Use a dummy origin token address for this test let origin_token_address = EthAddress::from_hex("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); + let scale = 0u8; + let origin_network = 1u32; + let metadata_hash = MetadataHash::from_token_info("USD Coin", "USDC", 6); let config_note = ConfigAggBridgeNote::create( - faucet_to_register, - &origin_token_address, + ConversionMetadata { + faucet_account_id: faucet_to_register, + origin_token_address, + scale, + origin_network, + is_native: false, + metadata_hash, + }, bridge_admin.id(), bridge_account.id(), builder.rng_mut(), @@ -78,9 +97,8 @@ async fn test_config_agg_bridge_registers_faucet() -> anyhow::Result<()> { let mock_chain = builder.build()?; // CONSUME THE CONFIG_AGG_BRIDGE NOTE WITH THE BRIDGE ACCOUNT - let tx_context = mock_chain - .build_tx_context(bridge_account.id(), &[config_note.id()], &[])? - .build()?; + let tx_context = + mock_chain.build_tx_context(bridge_account.id(), &[], &[config_note])?.build()?; let executed_transaction = tx_context.execute().await?; // VERIFY FAUCET IS NOW REGISTERED @@ -98,3 +116,125 @@ async fn test_config_agg_bridge_registers_faucet() -> anyhow::Result<()> { Ok(()) } + +/// Regression test for issue #2799. +/// +/// Two faucets registered for the same `origin_token_address` but different `origin_network` +/// values must coexist as independent entries in the bridge's `token_registry_map`. Before the +/// fix, the registry was keyed on `Poseidon2(origin_token_address)` alone, so registering the +/// second faucet would silently overwrite the first and a CLAIM bound to one network could +/// resolve to the faucet of the other. This test confirms each `(origin_token_address, +/// origin_network)` pair maps to its own faucet ID after registration. +#[tokio::test] +async fn test_config_agg_bridge_distinguishes_origin_network() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + // CREATE BRIDGE ADMIN ACCOUNT (note sender) + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // CREATE GER MANAGER ACCOUNT (unused here, but distinct from admin) + let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // CREATE BRIDGE ACCOUNT (starts with empty token registry) + let bridge_account = create_existing_bridge_account( + builder.rng_mut().draw_word(), + bridge_admin.id(), + ger_manager.id(), + ); + builder.add_account(bridge_account.clone())?; + + // Two distinct faucet IDs that both share the same origin token address but live on + // different origin networks. + let faucet_network_1 = + AccountId::dummy([11; 15], AccountIdVersion::Version1, AccountType::Public); + let faucet_network_2 = + AccountId::dummy([22; 15], AccountIdVersion::Version1, AccountType::Public); + + let origin_token_address = + EthAddress::from_hex("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); + let origin_network_1: u32 = 1; + let origin_network_2: u32 = 2; + + let metadata_hash = MetadataHash::from_token_info("USD Coin", "USDC", 6); + let config_note_1 = ConfigAggBridgeNote::create( + ConversionMetadata { + faucet_account_id: faucet_network_1, + origin_token_address, + scale: 0, + origin_network: origin_network_1, + is_native: false, + metadata_hash, + }, + bridge_admin.id(), + bridge_account.id(), + builder.rng_mut(), + )?; + let config_note_2 = ConfigAggBridgeNote::create( + ConversionMetadata { + faucet_account_id: faucet_network_2, + origin_token_address, + scale: 0, + origin_network: origin_network_2, + is_native: false, + metadata_hash, + }, + bridge_admin.id(), + bridge_account.id(), + builder.rng_mut(), + )?; + + builder.add_output_note(RawOutputNote::Full(config_note_1.clone())); + builder.add_output_note(RawOutputNote::Full(config_note_2.clone())); + let mut mock_chain = builder.build()?; + + // Consume the two registration notes in two separate transactions so each one writes its + // own delta to the bridge account. + let tx1 = mock_chain + .build_tx_context(bridge_account.id(), &[config_note_1.id()], &[])? + .build()?; + let executed_1 = tx1.execute().await?; + mock_chain.add_pending_executed_transaction(&executed_1)?; + mock_chain.prove_next_block()?; + + let tx2 = mock_chain + .build_tx_context(bridge_account.id(), &[config_note_2.id()], &[])? + .build()?; + let executed_2 = tx2.execute().await?; + + // 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())?; + + // 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(); + let key_1 = token_registry_key(&origin_token_address, origin_network_1); + let key_2 = token_registry_key(&origin_token_address, origin_network_2); + assert_ne!(key_1, key_2, "registry keys for distinct origin networks must differ"); + + let value_1 = updated_bridge.storage().get_map_item(token_registry_slot, key_1)?; + let value_2 = updated_bridge.storage().get_map_item(token_registry_slot, key_2)?; + + let expected_1: Word = [ + Felt::ZERO, + Felt::ZERO, + faucet_network_1.suffix(), + faucet_network_1.prefix().as_felt(), + ] + .into(); + let expected_2: Word = [ + Felt::ZERO, + Felt::ZERO, + faucet_network_2.suffix(), + faucet_network_2.prefix().as_felt(), + ] + .into(); + assert_eq!(value_1, expected_1, "(addr, network=1) must resolve to faucet_network_1"); + assert_eq!(value_2, expected_2, "(addr, network=2) must resolve to faucet_network_2"); + + Ok(()) +} diff --git a/crates/miden-testing/tests/agglayer/faucet_helpers.rs b/crates/miden-testing/tests/agglayer/faucet_helpers.rs index 84ea5b226c..ee848e2ccf 100644 --- a/crates/miden-testing/tests/agglayer/faucet_helpers.rs +++ b/crates/miden-testing/tests/agglayer/faucet_helpers.rs @@ -2,8 +2,6 @@ extern crate alloc; use miden_agglayer::{ AggLayerFaucet, - EthAddress, - MetadataHash, create_existing_agglayer_faucet, create_existing_bridge_account, }; @@ -33,15 +31,8 @@ fn test_faucet_helper_methods() -> anyhow::Result<()> { let token_symbol = "AGG"; let decimals = 8u8; - let max_supply = Felt::new(FungibleAsset::MAX_AMOUNT); - let token_supply = Felt::new(123_456); - - let origin_token_address = EthAddress::from_hex("0x0102030405060708090a0b0c0d0e0f1011121314") - .expect("invalid token address"); - let origin_network = 42u32; - let scale = 6u8; - - let metadata_hash = MetadataHash::from_token_info(token_symbol, token_symbol, decimals); + let max_supply: Felt = FungibleAsset::MAX_AMOUNT.into(); + let token_supply = Felt::new_unchecked(123_456); let faucet = create_existing_agglayer_faucet( builder.rng_mut().draw_word(), @@ -50,16 +41,9 @@ fn test_faucet_helper_methods() -> anyhow::Result<()> { max_supply, token_supply, bridge_account.id(), - &origin_token_address, - origin_network, - scale, - metadata_hash, ); assert_eq!(AggLayerFaucet::owner_account_id(&faucet)?, bridge_account.id()); - assert_eq!(AggLayerFaucet::origin_token_address(&faucet)?, origin_token_address); - assert_eq!(AggLayerFaucet::origin_network(&faucet)?, origin_network); - assert_eq!(AggLayerFaucet::scale(&faucet)?, scale); Ok(()) } diff --git a/crates/miden-testing/tests/agglayer/merkle_tree_frontier.rs b/crates/miden-testing/tests/agglayer/merkle_tree_frontier.rs index 5c2cf4453b..f04683e850 100644 --- a/crates/miden-testing/tests/agglayer/merkle_tree_frontier.rs +++ b/crates/miden-testing/tests/agglayer/merkle_tree_frontier.rs @@ -28,9 +28,9 @@ static CANONICAL_ZEROS_32: LazyLock> = LazyLock::new(|| { zeros_by_height }); -struct MerkleTreeFrontier32 { - num_leaves: u32, - frontier: [Keccak256Digest; TREE_HEIGHT], +pub(super) struct MerkleTreeFrontier32 { + pub num_leaves: u32, + pub frontier: [Keccak256Digest; TREE_HEIGHT], } impl MerkleTreeFrontier32 { @@ -41,6 +41,14 @@ impl MerkleTreeFrontier32 { } } + /// Constructs an MTF directly from a chosen `num_leaves` and frontier state. + /// + /// Useful for tests that need to start at a high `num_leaves` count without performing the + /// (impractical) number of leaf insertions to organically reach that point. + pub fn from_state(num_leaves: u32, frontier: [Keccak256Digest; TREE_HEIGHT]) -> Self { + Self { num_leaves, frontier } + } + pub fn append_and_update_frontier(&mut self, new_leaf: Keccak256Digest) -> Keccak256Digest { let mut curr_hash = new_leaf; let mut idx = self.num_leaves; diff --git a/crates/miden-testing/tests/agglayer/mod.rs b/crates/miden-testing/tests/agglayer/mod.rs index 6f61b354ee..d6f44ff14a 100644 --- a/crates/miden-testing/tests/agglayer/mod.rs +++ b/crates/miden-testing/tests/agglayer/mod.rs @@ -6,6 +6,7 @@ mod faucet_helpers; mod global_index; mod leaf_utils; mod merkle_tree_frontier; +mod network_account_regression; 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 new file mode 100644 index 0000000000..a3bfa23ac0 --- /dev/null +++ b/crates/miden-testing/tests/agglayer/network_account_regression.rs @@ -0,0 +1,212 @@ +//! Tests that the AggLayer bridge's and AggLayer faucet's [`AuthNetworkAccount`] components +//! enforce both rejection paths required to keep their metadata senders from being attached to +//! attacker-authored output notes: +//! +//! 1. The account rejects any transaction that executes a tx script. +//! 2. The account rejects any input note whose script root is not in its +//! [`allowed_notes`](miden_agglayer::AggLayerBridge::allowed_notes) / +//! [`allowed_notes`](miden_agglayer::AggLayerFaucet::allowed_notes) set. +//! +//! [`AuthNetworkAccount`]: miden_standards::account::auth::AuthNetworkAccount + +use core::slice; + +use miden_agglayer::{ + ExitRoot, + UpdateGerNote, + create_existing_agglayer_faucet, + create_existing_bridge_account, +}; +use miden_crypto::rand::FeltRng; +use miden_protocol::Felt; +use miden_protocol::account::auth::AuthScheme; +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, +}; +use miden_standards::testing::note::NoteBuilder; +use miden_testing::{Auth, MockChain, assert_transaction_executor_error}; + +/// Attack note script: trivial body whose root falls outside the bridge's allowlist. +const ATTACK_NOTE_CODE: &str = "\ +@note_script +pub proc main + push.0 drop +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 +/// 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] +async fn bridge_rejects_tx_script() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_manager = 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(), + ); + builder.add_account(bridge_account.clone())?; + + // Allowlisted UPDATE_GER input note: included so the test exercises the case where a real, + // allowed note is consumed in the same transaction as the rejected tx script. The tx-script + // rejection must still fire — the note's allowlist status is independent of the tx-script + // check. + let ger = ExitRoot::from([0u8; 32]); + let update_ger_note = + UpdateGerNote::create(ger, ger_manager.id(), bridge_account.id(), builder.rng_mut())?; + builder.add_output_note(RawOutputNote::Full(update_ger_note.clone())); + + let mock_chain = builder.build()?; + + let tx_script = CodeBuilder::default().compile_tx_script("begin nop end")?; + + let result = mock_chain + .build_tx_context(bridge_account.id(), &[], slice::from_ref(&update_ger_note))? + .tx_script(tx_script) + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_NOTE_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED); + + Ok(()) +} + +/// Asserts that a transaction consuming an input note whose script root falls outside the +/// bridge's allowlist (CLAIM, B2AGG, CONFIG_AGG_BRIDGE, UPDATE_GER) fails with +/// [`ERR_NOTE_SCRIPT_ALLOWLIST_NOTE_NOT_ALLOWED`]. +#[tokio::test] +async fn bridge_rejects_non_allowlisted_input_note() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_manager = 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(), + ); + builder.add_account(bridge_account.clone())?; + + let attack_note = NoteBuilder::new(bridge_admin.id(), &mut rand::rng()) + .code(ATTACK_NOTE_CODE) + .build() + .expect("failed to build attack note"); + + builder.add_output_note(RawOutputNote::Full(attack_note.clone())); + + let mock_chain = builder.build()?; + + let result = mock_chain + .build_tx_context(bridge_account.id(), &[], slice::from_ref(&attack_note))? + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_NOTE_SCRIPT_ALLOWLIST_NOTE_NOT_ALLOWED); + + Ok(()) +} + +/// 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 +/// [`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. +/// +/// [`AuthNetworkAccount`]: miden_standards::account::auth::AuthNetworkAccount +#[tokio::test] +async fn faucet_rejects_tx_script() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + // The bridge_account_id is wired into the faucet at creation time as the registered owner; + // we never execute against the bridge in this test, so a placeholder admin wallet is enough. + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let faucet = create_existing_agglayer_faucet( + builder.rng_mut().draw_word(), + "TEST", + 8, + Felt::new(1_000_000).unwrap(), + Felt::ZERO, + bridge_admin.id(), + ); + builder.add_account(faucet.clone())?; + + let mock_chain = builder.build()?; + + let tx_script = CodeBuilder::default().compile_tx_script("begin nop end")?; + + let result = mock_chain + .build_tx_context(faucet.id(), &[], &[])? + .tx_script(tx_script) + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_NOTE_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED); + + Ok(()) +} + +/// Asserts that a transaction consuming an input note whose script root falls outside the +/// faucet's allowlist (MINT, BURN) fails with [`ERR_NOTE_SCRIPT_ALLOWLIST_NOTE_NOT_ALLOWED`]. +/// Symmetric to [`bridge_rejects_non_allowlisted_input_note`]: the faucet's +/// [`AuthNetworkAccount`] component must reject any non-MINT/BURN input note. +/// +/// [`AuthNetworkAccount`]: miden_standards::account::auth::AuthNetworkAccount +#[tokio::test] +async fn faucet_rejects_non_allowlisted_input_note() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let faucet = create_existing_agglayer_faucet( + builder.rng_mut().draw_word(), + "TEST", + 8, + Felt::new(1_000_000).unwrap(), + Felt::ZERO, + bridge_admin.id(), + ); + builder.add_account(faucet.clone())?; + + let attack_note = NoteBuilder::new(bridge_admin.id(), &mut rand::rng()) + .code(ATTACK_NOTE_CODE) + .build() + .expect("failed to build attack note"); + + builder.add_output_note(RawOutputNote::Full(attack_note.clone())); + + let mock_chain = builder.build()?; + + let result = mock_chain + .build_tx_context(faucet.id(), &[], slice::from_ref(&attack_note))? + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_NOTE_SCRIPT_ALLOWLIST_NOTE_NOT_ALLOWED); + + Ok(()) +} diff --git a/crates/miden-testing/tests/agglayer/solidity_miden_address_conversion.rs b/crates/miden-testing/tests/agglayer/solidity_miden_address_conversion.rs index 19168fa338..5107fcbf54 100644 --- a/crates/miden-testing/tests/agglayer/solidity_miden_address_conversion.rs +++ b/crates/miden-testing/tests/agglayer/solidity_miden_address_conversion.rs @@ -20,6 +20,8 @@ use miden_protocol::address::NetworkId; use miden_protocol::testing::account_id::{ ACCOUNT_ID_PRIVATE_SENDER, ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1, + ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET, AccountIdBuilder, }; use miden_protocol::transaction::TransactionKernel; @@ -46,8 +48,10 @@ async fn execute_program_with_default_host( let stack_inputs = StackInputs::new(&[]).unwrap(); let advice_inputs = AdviceInputs::default(); - let processor = - FastProcessor::new(stack_inputs).with_advice(advice_inputs).with_debugging(true); + let processor = FastProcessor::new(stack_inputs) + .with_advice(advice_inputs) + .map_err(ExecutionError::advice_error_no_context)? + .with_debugging(true); processor.execute(&program, &mut host).await } @@ -60,29 +64,22 @@ fn test_account_id_to_ethereum_roundtrip() { } #[test] -fn test_bech32_to_ethereum_roundtrip() { - let test_addresses = [ - "mtst1azcw08rget79fqp8ymr0zqkv5v5lj466", - "mtst1arxmxavamh7lqyp79mexktt4vgxv40mp", - "mtst1ar2phe0pa0ln75plsczxr8ryws4s8zyp", +fn test_account_id_to_ethereum_roundtrip2() { + let account_ids = [ + AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET).unwrap(), + AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1).unwrap(), + AccountId::try_from(ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET).unwrap(), ]; - let evm_addresses = [ - "0x00000000b0e79c68cafc54802726c6f102cca300", - "0x00000000cdb3759dddfdf0103e2ef26b2d756200", - "0x00000000d41be5e1ebff3f503f8604619c647400", - ]; - - for (bech32, expected_evm) in test_addresses.iter().zip(evm_addresses.iter()) { - let (network_id, account_id) = AccountId::from_bech32(bech32).unwrap(); - + for account_id in account_ids { let eth = EthEmbeddedAccountId::from_account_id(account_id); let recovered = eth.into_account_id(); - let recovered_bech32 = recovered.to_bech32(network_id); - assert_eq!(&account_id, &recovered); - assert_eq!(*expected_evm, eth.to_string()); - assert_eq!(*bech32, recovered_bech32); + assert_eq!(account_id, recovered); + assert_eq!( + eth.to_string(), + format!("0x00000000{}00", account_id.to_hex().strip_prefix("0x").unwrap()) + ); } } diff --git a/crates/miden-testing/tests/agglayer/test_utils.rs b/crates/miden-testing/tests/agglayer/test_utils.rs index 766cf4a1a5..b50f07491f 100644 --- a/crates/miden-testing/tests/agglayer/test_utils.rs +++ b/crates/miden-testing/tests/agglayer/test_utils.rs @@ -68,8 +68,10 @@ pub async fn execute_program_with_default_host( let stack_inputs = StackInputs::new(&[]).unwrap(); let advice_inputs = advice_inputs.unwrap_or_default(); - let processor = - FastProcessor::new(stack_inputs).with_advice(advice_inputs).with_debugging(true); + let processor = FastProcessor::new(stack_inputs) + .with_advice(advice_inputs) + .map_err(ExecutionError::advice_error_no_context)? + .with_debugging(true); processor.execute(&program, &mut host).await } diff --git a/crates/miden-testing/tests/agglayer/update_ger.rs b/crates/miden-testing/tests/agglayer/update_ger.rs index ed8126a0d0..6e5921c32b 100644 --- a/crates/miden-testing/tests/agglayer/update_ger.rs +++ b/crates/miden-testing/tests/agglayer/update_ger.rs @@ -98,7 +98,7 @@ async fn update_ger_note_updates_storage() -> anyhow::Result<()> { let mut updated_bridge_account = bridge_account.clone(); updated_bridge_account.apply_delta(executed_transaction.account_delta())?; - let is_registered = AggLayerBridge::is_ger_registered(ger, updated_bridge_account)?; + let is_registered = AggLayerBridge::is_ger_registered(ger, &updated_bridge_account)?; assert!(is_registered, "GER was not registered in the bridge account"); Ok(()) diff --git a/crates/miden-testing/tests/asserts.rs b/crates/miden-testing/tests/asserts.rs new file mode 100644 index 0000000000..23d287f3dd --- /dev/null +++ b/crates/miden-testing/tests/asserts.rs @@ -0,0 +1,105 @@ +//! Integration tests for the note-lifecycle assertions + +extern crate alloc; + +use anyhow::Result; +use miden_protocol::account::AccountId; +use miden_protocol::account::auth::AuthScheme; +use miden_protocol::asset::{Asset, FungibleAsset}; +use miden_protocol::note::{Note, NoteType}; +use miden_protocol::transaction::{ExecutedTransaction, RawOutputNote}; +use miden_testing::{Auth, MockChain, assert_note_created}; + +/// Builds a chain and runs a SPAWN tx that emits one P2ID output note with the given assets. +/// The returned chain is still in post-build state — execute doesn't mutate it. +async fn execute_with_output( + output_assets: &[Asset], +) -> Result<(AccountId, Note, MockChain, ExecutedTransaction)> { + let mut builder = MockChain::builder(); + + let sender = builder.add_existing_wallet_with_assets( + Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + }, + output_assets.iter().copied(), + )?; + let target = builder.create_new_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let sender_id = sender.id(); + + let output = builder.add_p2id_note(sender_id, target.id(), output_assets, NoteType::Public)?; + let spawn = builder.add_spawn_note([&output])?; + + let chain = builder.build()?; + + let executed = chain + .build_tx_context(sender, &[spawn.id()], &[])? + .extend_expected_output_notes(vec![RawOutputNote::Full(output)]) + .build()? + .execute() + .await?; + + Ok((sender_id, spawn, chain, executed)) +} + +/// Full lifecycle: build, execute, prove block. +#[tokio::test] +async fn note_lifecycle_full_flow() -> Result<()> { + let asset: Asset = FungibleAsset::mock(7); + let (sender_id, spawn, mut chain, executed) = execute_with_output(&[asset]).await?; + + // post-build: spawn is committed and unspent. + assert!(chain.is_note_committed(&spawn.id())); + assert!(chain.is_note_unspent(&spawn.nullifier())); + + // post-execute: tx-level checks against the executed transaction. + assert!(executed.consumes_note(&spawn.id())); + + assert_note_created!( + executed, + note_type: NoteType::Public, + sender: sender_id, + assets: [asset], + ); + + // post-block: spawn's nullifier is now on-chain. + chain.add_pending_executed_transaction(&executed)?; + chain.prove_next_block()?; + + assert!(chain.is_note_consumed(&spawn.nullifier())); + + Ok(()) +} + +/// Each field can be set on its own; unset fields aren't checked. +#[tokio::test] +async fn assert_note_created_partial_specs_match() -> Result<()> { + let asset: Asset = FungibleAsset::mock(7); + let (sender_id, _spawn, _chain, executed) = execute_with_output(&[asset]).await?; + + assert_note_created!(executed, note_type: NoteType::Public); + assert_note_created!(executed, sender: sender_id); + assert_note_created!(executed, assets: [asset]); + assert_note_created!(executed, note_type: NoteType::Public, assets: [asset]); + Ok(()) +} + +#[tokio::test] +#[should_panic(expected = "no output note matches")] +async fn assert_note_created_panics_on_sender_mismatch() { + let asset: Asset = FungibleAsset::mock(7); + let (_sender_id, _spawn, _chain, executed) = execute_with_output(&[asset]).await.unwrap(); + + // Faucet ID can't be the sender of a wallet-emitted P2ID. + assert_note_created!(executed, sender: FungibleAsset::mock_issuer()); +} + +#[tokio::test] +#[should_panic(expected = "no output note matches")] +async fn assert_note_created_panics_on_asset_count_mismatch() { + let asset: Asset = FungibleAsset::mock(7); + let (_sender_id, _spawn, _chain, executed) = execute_with_output(&[asset]).await.unwrap(); + + assert_note_created!(executed, assets: [asset, asset]); +} diff --git a/crates/miden-testing/tests/auth/guarded_multisig.rs b/crates/miden-testing/tests/auth/guarded_multisig.rs new file mode 100644 index 0000000000..6c995b93d5 --- /dev/null +++ b/crates/miden-testing/tests/auth/guarded_multisig.rs @@ -0,0 +1,746 @@ +use miden_protocol::account::auth::{AuthScheme, AuthSecretKey, PublicKey}; +use miden_protocol::account::{Account, AccountBuilder, AccountProcedureRoot, AccountType}; +use miden_protocol::asset::FungibleAsset; +use miden_protocol::note::{ + Note, + NoteAssets, + NoteRecipient, + NoteStorage, + NoteType, + PartialNoteMetadata, +}; +use miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE; +use miden_protocol::testing::note::DEFAULT_NOTE_SCRIPT; +use miden_protocol::transaction::RawOutputNote; +use miden_protocol::{Felt, Word}; +use miden_standards::account::auth::{ + AuthGuardedMultisig, + AuthGuardedMultisigConfig, + GuardianConfig, +}; +use miden_standards::account::wallets::BasicWallet; +use miden_standards::code_builder::CodeBuilder; +use miden_standards::errors::standards::{ + ERR_AUTH_PROCEDURE_MUST_BE_CALLED_ALONE, + ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_NOTES, + ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_OUTPUT_NOTES, +}; +use miden_testing::{MockChainBuilder, assert_transaction_executor_error}; +use miden_tx::TransactionExecutorError; +use miden_tx::auth::{BasicAuthenticator, SigningInputs, TransactionAuthenticator}; +use rand::SeedableRng; +use rand_chacha::ChaCha20Rng; +use rstest::rstest; + +// ================================================================================================ +// HELPER FUNCTIONS +// ================================================================================================ + +type MultisigTestSetup = + (Vec, Vec, Vec, Vec); + +/// Sets up secret keys, public keys, and authenticators for multisig testing for the given scheme. +fn setup_keys_and_authenticators_with_scheme( + num_approvers: usize, + threshold: usize, + auth_scheme: AuthScheme, +) -> anyhow::Result { + let seed: [u8; 32] = rand::random(); + let mut rng = ChaCha20Rng::from_seed(seed); + + let mut secret_keys = Vec::new(); + let mut auth_schemes = Vec::new(); + let mut public_keys = Vec::new(); + let mut authenticators = Vec::new(); + + for _ in 0..num_approvers { + let sec_key = match auth_scheme { + AuthScheme::EcdsaK256Keccak => AuthSecretKey::new_ecdsa_k256_keccak_with_rng(&mut rng), + AuthScheme::Falcon512Poseidon2 => { + AuthSecretKey::new_falcon512_poseidon2_with_rng(&mut rng) + }, + _ => anyhow::bail!("unsupported auth scheme for this test: {auth_scheme:?}"), + }; + let pub_key = sec_key.public_key(); + + secret_keys.push(sec_key); + auth_schemes.push(auth_scheme); + public_keys.push(pub_key); + } + + // Create authenticators for required signers + for secret_key in secret_keys.iter().take(threshold) { + let authenticator = BasicAuthenticator::new(core::slice::from_ref(secret_key)); + authenticators.push(authenticator); + } + + Ok((secret_keys, auth_schemes, public_keys, authenticators)) +} + +/// Builds the source for a tx-script that calls `update_guardian_public_key`. When `output_note` +/// is `Some`, the script also creates that note before the guardian update so a single call +/// exercises both `assert_no_input_notes` and `assert_no_output_notes` paths. +fn build_update_guardian_script_source( + new_guardian_key_word: Word, + new_guardian_scheme_id: u32, + output_note: Option<&Note>, +) -> String { + match output_note { + Some(out) => { + let recipient = out.recipient().digest(); + let note_type = NoteType::Public as u8; + let tag = Felt::from(out.metadata().tag()); + format!( + " + use miden::protocol::output_note + + begin + push.{recipient} + push.{note_type} + push.{tag} + exec.output_note::create + swapdw + dropw + dropw + push.{new_guardian_key_word} + push.{new_guardian_scheme_id} + call.::miden::standards::components::auth::guarded_multisig::update_guardian_public_key + drop + dropw + end + " + ) + }, + None => format!( + " + begin + push.{new_guardian_key_word} + push.{new_guardian_scheme_id} + call.::miden::standards::components::auth::guarded_multisig::update_guardian_public_key + drop + dropw + end + " + ), + } +} + +/// Creates a guarded multisig account configured with a guardian signer. +fn create_guarded_multisig_account( + threshold: u32, + approvers: &[(PublicKey, AuthScheme)], + guardian: GuardianConfig, + asset_amount: u64, + proc_threshold_map: Vec<(AccountProcedureRoot, u32)>, +) -> anyhow::Result { + let approvers = approvers + .iter() + .map(|(pub_key, auth_scheme)| (pub_key.to_commitment(), *auth_scheme)) + .collect(); + + let config = AuthGuardedMultisigConfig::new(approvers, threshold, guardian)? + .with_proc_thresholds(proc_threshold_map)?; + + let multisig_account = AccountBuilder::new([0; 32]) + .account_type(AccountType::Public) + .with_auth_component(AuthGuardedMultisig::new(config)?) + .with_component(BasicWallet) + .with_assets(vec![FungibleAsset::mock(asset_amount)]) + .build_existing()?; + + Ok(multisig_account) +} + +// ================================================================================================ +// TESTS +// ================================================================================================ + +/// Tests that guarded multisig authentication requires an additional guardian signature when +/// configured. +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_guarded_multisig_signature_required( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let (_secret_keys, auth_schemes, public_keys, authenticators) = + setup_keys_and_authenticators_with_scheme(2, 2, auth_scheme)?; + let approvers = public_keys + .iter() + .zip(auth_schemes.iter()) + .map(|(pk, scheme)| (pk.clone(), *scheme)) + .collect::>(); + + let guardian_secret_key = AuthSecretKey::new_ecdsa_k256_keccak(); + let guardian_public_key = guardian_secret_key.public_key(); + let guardian_authenticator = + BasicAuthenticator::new(core::slice::from_ref(&guardian_secret_key)); + + let mut multisig_account = create_guarded_multisig_account( + 2, + &approvers, + GuardianConfig::new(guardian_public_key.to_commitment(), AuthScheme::EcdsaK256Keccak), + 10, + vec![], + )?; + + let output_note_asset = FungibleAsset::mock(0); + let mut mock_chain_builder = + MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap(); + + let output_note = mock_chain_builder.add_p2id_note( + multisig_account.id(), + ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE.try_into().unwrap(), + &[output_note_asset], + NoteType::Public, + )?; + let input_note = mock_chain_builder.add_spawn_note([&output_note])?; + let mut mock_chain = mock_chain_builder.build().unwrap(); + + let salt = Word::from([Felt::new_unchecked(777); 4]); + let tx_context_init = 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()?; + + 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 msg = tx_summary.as_ref().to_commitment(); + let tx_summary_signing = SigningInputs::TransactionSummary(tx_summary); + + let sig_1 = authenticators[0] + .get_signature(public_keys[0].to_commitment(), &tx_summary_signing) + .await?; + let sig_2 = authenticators[1] + .get_signature(public_keys[1].to_commitment(), &tx_summary_signing) + .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())]) + .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; + assert!(matches!( + without_guardian_result, + Err(TransactionExecutorError::Unauthorized(_)) + )); + + let guardian_signature = guardian_authenticator + .get_signature(guardian_public_key.to_commitment(), &tx_summary_signing) + .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)]) + .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())?; + + mock_chain.add_pending_executed_transaction(&tx_context_execute)?; + mock_chain.prove_next_block()?; + + assert_eq!( + multisig_account.vault().get_balance(output_note_asset.vault_key())?.as_u64(), + 10 - output_note_asset.unwrap_fungible().amount().as_u64() + ); + + Ok(()) +} + +/// Tests that the guardian public key can be updated and then enforced for guarded multisig. +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_guarded_multisig_update_guardian_public_key( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let (_secret_keys, auth_schemes, public_keys, authenticators) = + setup_keys_and_authenticators_with_scheme(2, 2, auth_scheme)?; + let approvers = public_keys + .iter() + .zip(auth_schemes.iter()) + .map(|(pk, scheme)| (pk.clone(), *scheme)) + .collect::>(); + + let old_guardian_secret_key = AuthSecretKey::new_ecdsa_k256_keccak(); + let old_guardian_public_key = old_guardian_secret_key.public_key(); + let old_guardian_authenticator = + BasicAuthenticator::new(core::slice::from_ref(&old_guardian_secret_key)); + + let new_guardian_secret_key = AuthSecretKey::new_falcon512_poseidon2(); + let new_guardian_public_key = new_guardian_secret_key.public_key(); + let new_guardian_auth_scheme = new_guardian_secret_key.auth_scheme(); + let new_guardian_authenticator = + BasicAuthenticator::new(core::slice::from_ref(&new_guardian_secret_key)); + + let multisig_account = create_guarded_multisig_account( + 2, + &approvers, + GuardianConfig::new(old_guardian_public_key.to_commitment(), AuthScheme::EcdsaK256Keccak), + 10, + vec![], + )?; + + let mut mock_chain = MockChainBuilder::with_accounts([multisig_account.clone()]) + .unwrap() + .build() + .unwrap(); + + let new_guardian_key_word: Word = new_guardian_public_key.to_commitment().into(); + let new_guardian_scheme_id = new_guardian_auth_scheme as u32; + let update_guardian_script = CodeBuilder::new() + .with_dynamically_linked_library(AuthGuardedMultisig::code())? + .compile_tx_script(format!( + "begin\n push.{new_guardian_key_word}\n push.{new_guardian_scheme_id}\n call.::miden::standards::components::auth::guarded_multisig::update_guardian_public_key\n drop\n dropw\nend" + ))?; + + let update_salt = Word::from([Felt::new_unchecked(991); 4]); + let tx_context_init = mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .tx_script(update_guardian_script.clone()) + .auth_args(update_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}"), + }; + + let update_msg = tx_summary.as_ref().to_commitment(); + let tx_summary_signing = SigningInputs::TransactionSummary(tx_summary); + let sig_1 = authenticators[0] + .get_signature(public_keys[0].to_commitment(), &tx_summary_signing) + .await?; + let sig_2 = authenticators[1] + .get_signature(public_keys[1].to_commitment(), &tx_summary_signing) + .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) + .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())?; + let updated_guardian_public_key = updated_multisig_account + .storage() + .get_map_item(AuthGuardedMultisig::guardian_public_key_slot(), Word::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]), + )?; + assert_eq!( + updated_guardian_scheme_id, + Word::from([new_guardian_auth_scheme as u32, 0u32, 0u32, 0u32]) + ); + + mock_chain.add_pending_executed_transaction(&update_guardian_tx)?; + mock_chain.prove_next_block()?; + + // 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 + .build_tx_context(updated_multisig_account.id(), &[], &[])? + .auth_args(next_salt) + .build()?; + + 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 next_msg = tx_summary_next.as_ref().to_commitment(); + let tx_summary_next_signing = SigningInputs::TransactionSummary(tx_summary_next); + + let next_sig_1 = authenticators[0] + .get_signature(public_keys[0].to_commitment(), &tx_summary_next_signing) + .await?; + let next_sig_2 = authenticators[1] + .get_signature(public_keys[1].to_commitment(), &tx_summary_next_signing) + .await?; + let old_guardian_sig_next = old_guardian_authenticator + .get_signature(old_guardian_public_key.to_commitment(), &tx_summary_next_signing) + .await?; + let new_guardian_sig_next = new_guardian_authenticator + .get_signature(new_guardian_public_key.to_commitment(), &tx_summary_next_signing) + .await?; + + // Old guardian signature must fail after key update. + let with_old_guardian_result = mock_chain + .build_tx_context(updated_multisig_account.id(), &[], &[])? + .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; + assert!(matches!( + with_old_guardian_result, + Err(TransactionExecutorError::Unauthorized(_)) + )); + + // New guardian signature must pass. + mock_chain + .build_tx_context(updated_multisig_account.id(), &[], &[])? + .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?; + + Ok(()) +} + +/// Tests that `update_guardian_public_key` must be the only account action in the transaction. +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_guarded_multisig_update_guardian_public_key_must_be_called_alone( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let (_secret_keys, auth_schemes, public_keys, authenticators) = + setup_keys_and_authenticators_with_scheme(2, 2, auth_scheme)?; + let approvers = public_keys + .iter() + .zip(auth_schemes.iter()) + .map(|(pk, scheme)| (pk.clone(), *scheme)) + .collect::>(); + + let old_guardian_secret_key = AuthSecretKey::new_ecdsa_k256_keccak(); + let old_guardian_public_key = old_guardian_secret_key.public_key(); + let old_guardian_authenticator = + BasicAuthenticator::new(core::slice::from_ref(&old_guardian_secret_key)); + + let new_guardian_secret_key = AuthSecretKey::new_falcon512_poseidon2(); + let new_guardian_public_key = new_guardian_secret_key.public_key(); + let new_guardian_auth_scheme = new_guardian_secret_key.auth_scheme(); + + let multisig_account = create_guarded_multisig_account( + 2, + &approvers, + GuardianConfig::new(old_guardian_public_key.to_commitment(), AuthScheme::EcdsaK256Keccak), + 10, + vec![], + )?; + + let new_guardian_key_word: Word = new_guardian_public_key.to_commitment().into(); + let new_guardian_scheme_id = new_guardian_auth_scheme as u32; + let update_guardian_script = CodeBuilder::new() + .with_dynamically_linked_library(AuthGuardedMultisig::code())? + .compile_tx_script(format!( + "begin\n push.{new_guardian_key_word}\n push.{new_guardian_scheme_id}\n call.::miden::standards::components::auth::guarded_multisig::update_guardian_public_key\n drop\n dropw\nend" + ))?; + + let mut mock_chain_builder = + MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap(); + let receive_asset_note = mock_chain_builder.add_p2id_note( + multisig_account.id(), + multisig_account.id(), + &[FungibleAsset::mock(1)], + NoteType::Public, + )?; + let mock_chain = mock_chain_builder.build().unwrap(); + + let salt = Word::from([Felt::new_unchecked(993); 4]); + let tx_context_init = mock_chain + .build_tx_context(multisig_account.id(), &[receive_asset_note.id()], &[])? + .tx_script(update_guardian_script.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}"), + }; + + let msg = tx_summary.as_ref().to_commitment(); + let tx_summary_signing = SigningInputs::TransactionSummary(tx_summary); + let sig_1 = authenticators[0] + .get_signature(public_keys[0].to_commitment(), &tx_summary_signing) + .await?; + let sig_2 = authenticators[1] + .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()) + .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; + assert_transaction_executor_error!( + without_guardian_result, + ERR_AUTH_PROCEDURE_MUST_BE_CALLED_ALONE + ); + + let old_guardian_signature = old_guardian_authenticator + .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) + .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; + + assert_transaction_executor_error!( + with_guardian_result, + ERR_AUTH_PROCEDURE_MUST_BE_CALLED_ALONE + ); + + // Also reject rotation transactions that touch notes even when no other account procedure is + // called. + let note_script = CodeBuilder::default().compile_note_script(DEFAULT_NOTE_SCRIPT)?; + let note_serial_num = Word::from([1_u32, 2_u32, 3_u32, 4_u32]); + let note_recipient = + NoteRecipient::new(note_serial_num, note_script.clone(), NoteStorage::default()); + let output_note = Note::new( + NoteAssets::new(vec![])?, + PartialNoteMetadata::new(multisig_account.id(), NoteType::Public), + note_recipient, + ); + + let new_guardian_key_word: Word = new_guardian_public_key.to_commitment().into(); + let new_guardian_scheme_id = new_guardian_auth_scheme as u32; + let update_guardian_with_output_script = CodeBuilder::new() + .with_dynamically_linked_library(AuthGuardedMultisig::code())? + .compile_tx_script(format!( + "use miden::protocol::output_note\nbegin\n push.{recipient}\n push.{note_type}\n push.{tag}\n exec.output_note::create\n swapdw\n dropw\n dropw\n push.{new_guardian_key_word}\n push.{new_guardian_scheme_id}\n call.::miden::standards::components::auth::guarded_multisig::update_guardian_public_key\n drop\n dropw\nend", + recipient = output_note.recipient().digest(), + note_type = NoteType::Public as u8, + tag = Felt::from(output_note.metadata().tag()), + ))?; + + let mock_chain = MockChainBuilder::with_accounts([multisig_account.clone()]) + .unwrap() + .build() + .unwrap(); + + let salt = Word::from([Felt::new_unchecked(994); 4]); + let tx_context_init = 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}"), + }; + + let msg = tx_summary.as_ref().to_commitment(); + let tx_summary_signing = SigningInputs::TransactionSummary(tx_summary); + let sig_1 = authenticators[0] + .get_signature(public_keys[0].to_commitment(), &tx_summary_signing) + .await?; + let sig_2 = authenticators[1] + .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)]) + .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; + + // The transaction creates an output note (no input notes), so after the input check passes + // the output check fires. + assert_transaction_executor_error!(result, ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_OUTPUT_NOTES); + + Ok(()) +} + +/// `update_guardian_public_key` rejects every transaction that consumes input notes or creates +/// output notes. Parametrized over the (input, output) tx layout so each path through the two +/// separate `assert_no_*_notes` calls in `guardian.masm` is exercised — and the input check +/// firing before the output check is verified explicitly via the (true, true) case. +#[rstest] +#[case::no_notes(false, false)] +#[case::input_only(true, false)] +#[case::output_only(false, true)] +#[case::both(true, true)] +#[tokio::test] +async fn test_guarded_multisig_update_guardian_enforces_no_notes( + #[case] include_input_note: bool, + #[case] include_output_note: bool, +) -> anyhow::Result<()> { + let auth_scheme = AuthScheme::EcdsaK256Keccak; + let (_secret_keys, auth_schemes, public_keys, authenticators) = + setup_keys_and_authenticators_with_scheme(2, 2, auth_scheme)?; + let approvers = public_keys + .iter() + .zip(auth_schemes.iter()) + .map(|(pk, scheme)| (pk.clone(), *scheme)) + .collect::>(); + + let old_guardian_secret_key = AuthSecretKey::new_ecdsa_k256_keccak(); + let old_guardian_public_key = old_guardian_secret_key.public_key(); + let old_guardian_authenticator = + BasicAuthenticator::new(core::slice::from_ref(&old_guardian_secret_key)); + + let new_guardian_secret_key = AuthSecretKey::new_falcon512_poseidon2(); + let new_guardian_public_key = new_guardian_secret_key.public_key(); + let new_guardian_auth_scheme = new_guardian_secret_key.auth_scheme(); + + let multisig_account = create_guarded_multisig_account( + 2, + &approvers, + GuardianConfig::new(old_guardian_public_key.to_commitment(), AuthScheme::EcdsaK256Keccak), + 10, + vec![], + )?; + + let new_guardian_key_word: Word = new_guardian_public_key.to_commitment().into(); + let new_guardian_scheme_id = new_guardian_auth_scheme as u32; + + // Optional output note (no-op script — doesn't trigger any account procedure). + let output_note = if include_output_note { + let serial = Word::from([1_u32, 2_u32, 3_u32, 4_u32]); + let recipient = NoteRecipient::new( + serial, + CodeBuilder::default().compile_note_script(DEFAULT_NOTE_SCRIPT)?, + NoteStorage::default(), + ); + Some(Note::new( + NoteAssets::new(vec![])?, + PartialNoteMetadata::new(multisig_account.id(), NoteType::Public), + recipient, + )) + } else { + None + }; + + // Compile the tx-script: bare update_guardian, or one that also creates the output note. + let script_source = build_update_guardian_script_source( + new_guardian_key_word, + new_guardian_scheme_id, + output_note.as_ref(), + ); + let update_guardian_script = CodeBuilder::new() + .with_dynamically_linked_library(AuthGuardedMultisig::code())? + .compile_tx_script(script_source)?; + + // Optional no-op input note seeded into the chain so the multisig account can consume it + // without invoking any non-auth procedure (DEFAULT_NOTE_SCRIPT is a single `nop`). + let mut chain_builder = MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap(); + let input_note = if include_input_note { + let serial = Word::from([5_u32, 6_u32, 7_u32, 8_u32]); + let recipient = NoteRecipient::new( + serial, + CodeBuilder::default().compile_note_script(DEFAULT_NOTE_SCRIPT)?, + NoteStorage::default(), + ); + let note = Note::new( + NoteAssets::new(vec![])?, + PartialNoteMetadata::new(multisig_account.id(), NoteType::Public), + recipient, + ); + chain_builder.add_output_note(RawOutputNote::Full(note.clone())); + Some(note) + } else { + None + }; + let mock_chain = chain_builder.build()?; + + let input_ids: Vec<_> = input_note.as_ref().map(|n| vec![n.id()]).unwrap_or_default(); + 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 + .build_tx_context(multisig_account.id(), &input_ids, &[])? + .tx_script(update_guardian_script.clone()) + .auth_args(salt); + if let Some(ref out) = output_note { + init_ctx = init_ctx.extend_expected_output_notes(vec![RawOutputNote::Full(out.clone())]); + } + 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 msg = tx_summary.as_ref().to_commitment(); + let signing = SigningInputs::TransactionSummary(tx_summary); + let sig_1 = authenticators[0] + .get_signature(public_keys[0].to_commitment(), &signing) + .await?; + let sig_2 = authenticators[1] + .get_signature(public_keys[1].to_commitment(), &signing) + .await?; + let guardian_sig = old_guardian_authenticator + .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) + .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; + + // Input check fires first, output check fires only when no input notes are present. + match (include_input_note, include_output_note) { + (false, false) => { + result.expect("tx must succeed when neither input nor output notes are present"); + }, + (true, _) => assert_transaction_executor_error!( + result, + ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_NOTES + ), + (false, true) => assert_transaction_executor_error!( + result, + ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_OUTPUT_NOTES + ), + } + + Ok(()) +} diff --git a/crates/miden-testing/tests/auth/hybrid_multisig.rs b/crates/miden-testing/tests/auth/hybrid_multisig.rs index 5749235fd3..222cf79dbb 100644 --- a/crates/miden-testing/tests/auth/hybrid_multisig.rs +++ b/crates/miden-testing/tests/auth/hybrid_multisig.rs @@ -1,24 +1,14 @@ 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, - AccountId, - AccountStorageMode, - AccountType, -}; +use miden_protocol::account::{Account, AccountBuilder, AccountProcedureRoot, AccountType}; use miden_protocol::asset::FungibleAsset; use miden_protocol::note::NoteType; -use miden_protocol::testing::account_id::{ - ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, - ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE, -}; +use miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE; use miden_protocol::transaction::RawOutputNote; use miden_protocol::vm::AdviceMap; use miden_protocol::{Felt, Hasher, Word}; use miden_standards::account::auth::AuthMultisig; -use miden_standards::account::components::multisig_library; use miden_standards::account::wallets::BasicWallet; use miden_standards::code_builder::CodeBuilder; use miden_standards::note::P2idNote; @@ -83,7 +73,7 @@ fn create_multisig_account( threshold: u32, approvers: &[(PublicKey, AuthScheme)], asset_amount: u64, - proc_threshold_map: Vec<(Word, u32)>, + proc_threshold_map: Vec<(AccountProcedureRoot, u32)>, ) -> anyhow::Result { let approvers = approvers .iter() @@ -93,8 +83,7 @@ fn create_multisig_account( let multisig_account = AccountBuilder::new([0; 32]) .with_auth_component(Auth::Multisig { threshold, approvers, proc_threshold_map }) .with_component(BasicWallet) - .account_type(AccountType::RegularAccountUpdatableCode) - .storage_mode(AccountStorageMode::Public) + .account_type(AccountType::Public) .with_assets(vec![FungibleAsset::mock(asset_amount)]) .build_existing()?; @@ -149,7 +138,7 @@ async fn test_multisig_2_of_2_with_note_creation() -> anyhow::Result<()> { let mut mock_chain = mock_chain_builder.build().unwrap(); - let salt = Word::from([Felt::new(1); 4]); + let salt = Word::from([Felt::ONE; 4]); // Execute transaction without signatures - should fail let tx_context_init = mock_chain @@ -191,10 +180,8 @@ async fn test_multisig_2_of_2_with_note_creation() -> anyhow::Result<()> { mock_chain.prove_next_block()?; assert_eq!( - multisig_account - .vault() - .get_balance(AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?)?, - multisig_starting_balance - output_note_asset.unwrap_fungible().amount() + multisig_account.vault().get_balance(output_note_asset.vault_key())?.as_u64(), + multisig_starting_balance - output_note_asset.unwrap_fungible().amount().as_u64() ); Ok(()) @@ -239,7 +226,7 @@ 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(10 + i as u64); 4]); + 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 @@ -325,7 +312,7 @@ async fn test_multisig_update_signers() -> anyhow::Result<()> { let mut mock_chain = mock_chain_builder.clone().build().unwrap(); - let salt = Word::from([Felt::new(3); 4]); + let salt = Word::from([3_u32; 4]); // Setup new signers let mut advice_map = AdviceMap::default(); @@ -338,10 +325,10 @@ async fn test_multisig_update_signers() -> anyhow::Result<()> { // Create vector with threshold config and public keys (4 field elements each) let mut config_and_pubkeys_vector = Vec::new(); config_and_pubkeys_vector.extend_from_slice(&[ - Felt::new(threshold), - Felt::new(num_of_approvers), - Felt::new(0), - Felt::new(0), + Felt::new_unchecked(threshold), + Felt::new_unchecked(num_of_approvers), + Felt::ZERO, + Felt::ZERO, ]); for (public_key, auth_scheme) in new_public_keys.iter().rev().zip(new_auth_schemes.iter().rev()) @@ -350,10 +337,10 @@ async fn test_multisig_update_signers() -> anyhow::Result<()> { config_and_pubkeys_vector.extend_from_slice(key_word.as_elements()); config_and_pubkeys_vector.extend_from_slice(&[ - Felt::new(*auth_scheme as u64), - Felt::new(0), - Felt::new(0), - Felt::new(0), + Felt::new_unchecked(*auth_scheme as u64), + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, ]); } @@ -371,7 +358,7 @@ async fn test_multisig_update_signers() -> anyhow::Result<()> { "; let tx_script = CodeBuilder::default() - .with_dynamically_linked_library(multisig_library())? + .with_dynamically_linked_library(AuthMultisig::code())? .compile_tx_script(tx_script_code)?; let advice_inputs = AdviceInputs { @@ -422,7 +409,7 @@ async fn test_multisig_update_signers() -> anyhow::Result<()> { .unwrap(); // Verify the transaction executed successfully - assert_eq!(update_approvers_tx.account_delta().nonce_delta(), Felt::new(1)); + assert_eq!(update_approvers_tx.account_delta().nonce_delta(), Felt::ONE); mock_chain.add_pending_executed_transaction(&update_approvers_tx)?; mock_chain.prove_next_block()?; @@ -433,7 +420,8 @@ async fn test_multisig_update_signers() -> anyhow::Result<()> { // 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(i as u64), Felt::new(0), Felt::new(0), Felt::new(0)].into(); + let storage_key = + [Felt::new_unchecked(i as u64), Felt::ZERO, Felt::ZERO, Felt::ZERO].into(); let storage_item = updated_multisig_account .storage() .get_map_item(AuthMultisig::approver_public_keys_slot(), storage_key) @@ -452,12 +440,12 @@ async fn test_multisig_update_signers() -> anyhow::Result<()> { assert_eq!( threshold_config_storage[0], - Felt::new(threshold), + Felt::new_unchecked(threshold), "Threshold was not updated correctly" ); assert_eq!( threshold_config_storage[1], - Felt::new(num_of_approvers), + Felt::new_unchecked(num_of_approvers), "Num approvers was not updated correctly" ); @@ -511,7 +499,7 @@ async fn test_multisig_update_signers() -> anyhow::Result<()> { // Create a new spawn note for the second transaction let input_note_new = create_spawn_note([&output_note_new])?; - let salt_new = Word::from([Felt::new(4); 4]); + let salt_new = Word::from([Felt::new_unchecked(4); 4]); // Build the new mock chain with the updated account and notes let mut new_mock_chain_builder = @@ -561,7 +549,7 @@ async fn test_multisig_update_signers() -> anyhow::Result<()> { .await?; // Verify the transaction executed successfully with new signers - assert_eq!(tx_context_execute_new.account_delta().nonce_delta(), Felt::new(1)); + assert_eq!(tx_context_execute_new.account_delta().nonce_delta(), Felt::ONE); Ok(()) } @@ -602,8 +590,12 @@ async fn test_multisig_update_signers_remove_owner() -> anyhow::Result<()> { let num_of_approvers = 2u64; // Create multisig config vector - let mut config_and_pubkeys_vector = - vec![Felt::new(threshold), Felt::new(num_of_approvers), Felt::new(0), Felt::new(0)]; + let mut config_and_pubkeys_vector = vec![ + Felt::new_unchecked(threshold), + Felt::new_unchecked(num_of_approvers), + Felt::ZERO, + Felt::ZERO, + ]; // Add each public key to the vector for (public_key, auth_scheme) in new_public_keys.iter().rev().zip(new_auth_schemes.iter().rev()) @@ -612,10 +604,10 @@ async fn test_multisig_update_signers_remove_owner() -> anyhow::Result<()> { config_and_pubkeys_vector.extend_from_slice(key_word.as_elements()); config_and_pubkeys_vector.extend_from_slice(&[ - Felt::new(*auth_scheme as u64), - Felt::new(0), - Felt::new(0), - Felt::new(0), + Felt::new_unchecked(*auth_scheme as u64), + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, ]); } @@ -626,12 +618,12 @@ async fn test_multisig_update_signers_remove_owner() -> anyhow::Result<()> { // Create transaction script let tx_script = CodeBuilder::default() - .with_dynamically_linked_library(multisig_library())? + .with_dynamically_linked_library(AuthMultisig::code())? .compile_tx_script("begin\n call.::miden::standards::components::auth::multisig::update_signers_and_threshold\nend")?; let advice_inputs = AdviceInputs { map: advice_map, ..Default::default() }; - let salt = Word::from([Felt::new(3); 4]); + let salt = Word::from([Felt::new_unchecked(3); 4]); // Execute without signatures to get tx summary let tx_context_init = mock_chain @@ -681,7 +673,7 @@ async fn test_multisig_update_signers_remove_owner() -> anyhow::Result<()> { .unwrap(); // Verify transaction success - assert_eq!(update_approvers_tx.account_delta().nonce_delta(), Felt::new(1)); + assert_eq!(update_approvers_tx.account_delta().nonce_delta(), Felt::ONE); mock_chain.add_pending_executed_transaction(&update_approvers_tx)?; mock_chain.prove_next_block()?; @@ -692,7 +684,8 @@ async fn test_multisig_update_signers_remove_owner() -> anyhow::Result<()> { // Verify public keys were updated for (i, expected_key) in new_public_keys.iter().enumerate() { - let storage_key = [Felt::new(i as u64), Felt::new(0), Felt::new(0), Felt::new(0)].into(); + let storage_key = + [Felt::new_unchecked(i as u64), Felt::ZERO, Felt::ZERO, Felt::ZERO].into(); let storage_item = updated_multisig_account .storage() .get_map_item(AuthMultisig::approver_public_keys_slot(), storage_key) @@ -706,8 +699,12 @@ async fn test_multisig_update_signers_remove_owner() -> anyhow::Result<()> { .storage() .get_item(AuthMultisig::threshold_config_slot()) .unwrap(); - assert_eq!(threshold_config[0], Felt::new(threshold), "Threshold not updated"); - assert_eq!(threshold_config[1], Felt::new(num_of_approvers), "Num approvers not updated"); + assert_eq!(threshold_config[0], Felt::new_unchecked(threshold), "Threshold not updated"); + assert_eq!( + threshold_config[1], + Felt::new_unchecked(num_of_approvers), + "Num approvers not updated" + ); // Verify extracted public keys let extracted_pub_keys = get_public_keys_from_account(&updated_multisig_account); @@ -724,7 +721,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(removed_idx), Felt::new(0), Felt::new(0), Felt::new(0)].into(); + [Felt::new_unchecked(removed_idx), Felt::ZERO, Felt::ZERO, Felt::ZERO].into(); let removed_owner_slot = updated_multisig_account .storage() .get_map_item(AuthMultisig::approver_public_keys_slot(), removed_owner_key) @@ -740,7 +737,8 @@ 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(i as u64), Felt::new(0), Felt::new(0), Felt::new(0)].into(); + let storage_key = + [Felt::new_unchecked(i as u64), Felt::ZERO, Felt::ZERO, Felt::ZERO].into(); let storage_item = updated_multisig_account .storage() .get_map_item(AuthMultisig::approver_public_keys_slot(), storage_key) @@ -793,7 +791,7 @@ async fn test_multisig_new_approvers_cannot_sign_before_update() -> anyhow::Resu .build() .unwrap(); - let salt = Word::from([Felt::new(5); 4]); + let salt = Word::from([Felt::new_unchecked(5); 4]); // SECTION 2: Prepare a signer update transaction with new approvers // ================================================================================ @@ -811,10 +809,10 @@ async fn test_multisig_new_approvers_cannot_sign_before_update() -> anyhow::Resu // Create vector with threshold config and public keys (4 field elements each) let mut config_and_pubkeys_vector = Vec::new(); config_and_pubkeys_vector.extend_from_slice(&[ - Felt::new(threshold), - Felt::new(num_of_approvers), - Felt::new(0), - Felt::new(0), + Felt::new_unchecked(threshold), + Felt::new_unchecked(num_of_approvers), + Felt::ZERO, + Felt::ZERO, ]); // Add each public key to the vector @@ -824,10 +822,10 @@ async fn test_multisig_new_approvers_cannot_sign_before_update() -> anyhow::Resu config_and_pubkeys_vector.extend_from_slice(key_word.as_elements()); config_and_pubkeys_vector.extend_from_slice(&[ - Felt::new(*auth_scheme as u64), - Felt::new(0), - Felt::new(0), - Felt::new(0), + Felt::new_unchecked(*auth_scheme as u64), + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, ]); } @@ -845,7 +843,7 @@ async fn test_multisig_new_approvers_cannot_sign_before_update() -> anyhow::Resu "; let tx_script = CodeBuilder::default() - .with_dynamically_linked_library(multisig_library())? + .with_dynamically_linked_library(AuthMultisig::code())? .compile_tx_script(tx_script_code)?; let advice_inputs = AdviceInputs { diff --git a/crates/miden-testing/tests/auth/mod.rs b/crates/miden-testing/tests/auth/mod.rs index 33d6f35bde..3c800e7919 100644 --- a/crates/miden-testing/tests/auth/mod.rs +++ b/crates/miden-testing/tests/auth/mod.rs @@ -1,7 +1,12 @@ +mod singlesig; mod singlesig_acl; mod multisig; mod hybrid_multisig; -mod multisig_psm; +mod multisig_smart; + +mod guarded_multisig; + +mod network_account; diff --git a/crates/miden-testing/tests/auth/multisig.rs b/crates/miden-testing/tests/auth/multisig.rs index 904bbe1dcf..629bd032dc 100644 --- a/crates/miden-testing/tests/auth/multisig.rs +++ b/crates/miden-testing/tests/auth/multisig.rs @@ -5,10 +5,10 @@ use miden_protocol::account::{ Account, AccountBuilder, AccountId, - AccountStorageMode, + AccountProcedureRoot, AccountType, }; -use miden_protocol::asset::FungibleAsset; +use miden_protocol::asset::{AssetCallbackFlag, AssetVaultKey, FungibleAsset}; use miden_protocol::note::NoteType; use miden_protocol::testing::account_id::{ ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, @@ -18,7 +18,6 @@ use miden_protocol::transaction::RawOutputNote; use miden_protocol::vm::AdviceMap; use miden_protocol::{Felt, Hasher, Word}; use miden_standards::account::auth::AuthMultisig; -use miden_standards::account::components::multisig_library; use miden_standards::account::interface::{AccountInterface, AccountInterfaceExt}; use miden_standards::account::wallets::BasicWallet; use miden_standards::code_builder::CodeBuilder; @@ -40,11 +39,11 @@ use rstest::rstest; // HELPER FUNCTIONS // ================================================================================================ -type MultisigTestSetup = +pub(super) type MultisigTestSetup = (Vec, Vec, Vec, Vec); /// Sets up secret keys, public keys, and authenticators for multisig testing for the given scheme. -fn setup_keys_and_authenticators_with_scheme( +pub(super) fn setup_keys_and_authenticators_with_scheme( num_approvers: usize, threshold: usize, auth_scheme: AuthScheme, @@ -81,12 +80,40 @@ fn setup_keys_and_authenticators_with_scheme( Ok((secret_keys, auth_schemes, public_keys, authenticators)) } +/// Layout expected by `update_signers_and_threshold` when looking up the new multisig config in +/// the advice map: `[threshold, num_approvers, 0, 0, (PUB_KEY, SCHEME_WORD) for each approver]`. +/// Public keys are appended in reverse so the procedure pops them in ascending index order. +pub(super) fn build_update_signers_config_vector( + threshold: u64, + num_of_approvers: u64, + public_keys: &[PublicKey], + auth_scheme: AuthScheme, +) -> Vec { + let mut config_and_pubkeys_vector = Vec::new(); + config_and_pubkeys_vector.extend_from_slice(&[ + Felt::new_unchecked(threshold), + Felt::new_unchecked(num_of_approvers), + Felt::ZERO, + Felt::ZERO, + ]); + + let scheme_word = [Felt::from(auth_scheme.as_u8()), Felt::ZERO, Felt::ZERO, Felt::ZERO]; + + for public_key in public_keys.iter().rev() { + let key_word: Word = public_key.to_commitment().into(); + config_and_pubkeys_vector.extend_from_slice(key_word.as_elements()); + config_and_pubkeys_vector.extend_from_slice(&scheme_word); + } + + config_and_pubkeys_vector +} + /// Creates a multisig account with the specified configuration fn create_multisig_account( threshold: u32, approvers: &[(PublicKey, AuthScheme)], asset_amount: u64, - proc_threshold_map: Vec<(Word, u32)>, + proc_threshold_map: Vec<(AccountProcedureRoot, u32)>, ) -> anyhow::Result { let approvers = approvers .iter() @@ -96,8 +123,7 @@ fn create_multisig_account( let multisig_account = AccountBuilder::new([0; 32]) .with_auth_component(Auth::Multisig { threshold, approvers, proc_threshold_map }) .with_component(BasicWallet) - .account_type(AccountType::RegularAccountUpdatableCode) - .storage_mode(AccountStorageMode::Public) + .account_type(AccountType::Public) .with_assets(vec![FungibleAsset::mock(asset_amount)]) .build_existing()?; @@ -157,7 +183,7 @@ async fn test_multisig_2_of_2_with_note_creation( let mut mock_chain = mock_chain_builder.build().unwrap(); - let salt = Word::from([Felt::new(1); 4]); + let salt = Word::from([Felt::ONE; 4]); // Execute transaction without signatures - should fail let tx_context_init = mock_chain @@ -201,8 +227,12 @@ async fn test_multisig_2_of_2_with_note_creation( assert_eq!( multisig_account .vault() - .get_balance(AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?)?, - multisig_starting_balance - output_note_asset.unwrap_fungible().amount() + .get_balance(AssetVaultKey::new_fungible( + AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?, + AssetCallbackFlag::Disabled, + ))? + .as_u64(), + multisig_starting_balance - output_note_asset.unwrap_fungible().amount().as_u64() ); Ok(()) @@ -252,7 +282,7 @@ 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(10 + i as u64); 4]); + 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 @@ -329,7 +359,7 @@ async fn test_multisig_replay_protection(#[case] auth_scheme: AuthScheme) -> any .build() .unwrap(); - let salt = Word::from([Felt::new(3); 4]); + let salt = Word::from([Felt::new_unchecked(3); 4]); // Execute transaction without signatures first to get tx summary let tx_context_init = mock_chain @@ -428,7 +458,7 @@ async fn test_multisig_update_signers(#[case] auth_scheme: AuthScheme) -> anyhow let mut mock_chain = mock_chain_builder.clone().build().unwrap(); - let salt = Word::from([Felt::new(3); 4]); + let salt = Word::from([Felt::new_unchecked(3); 4]); // Setup new signers let mut advice_map = AdviceMap::default(); @@ -438,32 +468,13 @@ async fn test_multisig_update_signers(#[case] auth_scheme: AuthScheme) -> anyhow let threshold = 3u64; let num_of_approvers = 4u64; - // Create vector with threshold config and public keys (4 field elements each) - let mut config_and_pubkeys_vector = Vec::new(); - config_and_pubkeys_vector.extend_from_slice(&[ - Felt::new(threshold), - Felt::new(num_of_approvers), - Felt::new(0), - Felt::new(0), - ]); - - // Add each public key to the vector - for public_key in new_public_keys.iter().rev() { - let key_word: Word = public_key.to_commitment().into(); - config_and_pubkeys_vector.extend_from_slice(key_word.as_elements()); - - config_and_pubkeys_vector.extend_from_slice(&[ - Felt::new(auth_scheme as u64), - Felt::new(0), - Felt::new(0), - Felt::new(0), - ]); - } - - // Hash the vector to create config hash + let config_and_pubkeys_vector = build_update_signers_config_vector( + threshold, + num_of_approvers, + &new_public_keys, + auth_scheme, + ); let multisig_config_hash = Hasher::hash_elements(&config_and_pubkeys_vector); - - // Insert config and public keys into advice map advice_map.insert(multisig_config_hash, config_and_pubkeys_vector); // Create a transaction script that calls the update_signers procedure @@ -474,7 +485,7 @@ async fn test_multisig_update_signers(#[case] auth_scheme: AuthScheme) -> anyhow "; let tx_script = CodeBuilder::default() - .with_dynamically_linked_library(multisig_library())? + .with_dynamically_linked_library(AuthMultisig::code())? .compile_tx_script(tx_script_code)?; let advice_inputs = AdviceInputs { @@ -524,7 +535,7 @@ async fn test_multisig_update_signers(#[case] auth_scheme: AuthScheme) -> anyhow .await?; // Verify the transaction executed successfully - assert_eq!(update_approvers_tx.account_delta().nonce_delta(), Felt::new(1)); + assert_eq!(update_approvers_tx.account_delta().nonce_delta(), Felt::ONE); mock_chain.add_pending_executed_transaction(&update_approvers_tx)?; mock_chain.prove_next_block()?; @@ -535,7 +546,8 @@ async fn test_multisig_update_signers(#[case] auth_scheme: AuthScheme) -> anyhow // 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(i as u64), Felt::new(0), Felt::new(0), Felt::new(0)].into(); + let storage_key = + [Felt::new_unchecked(i as u64), Felt::ZERO, Felt::ZERO, Felt::ZERO].into(); let storage_item = updated_multisig_account .storage() .get_map_item(AuthMultisig::approver_public_keys_slot(), storage_key) @@ -554,12 +566,12 @@ async fn test_multisig_update_signers(#[case] auth_scheme: AuthScheme) -> anyhow assert_eq!( threshold_config_storage[0], - Felt::new(threshold), + Felt::new_unchecked(threshold), "Threshold was not updated correctly" ); assert_eq!( threshold_config_storage[1], - Felt::new(num_of_approvers), + Felt::new_unchecked(num_of_approvers), "Num approvers was not updated correctly" ); @@ -613,7 +625,7 @@ async fn test_multisig_update_signers(#[case] auth_scheme: AuthScheme) -> anyhow // Create a new spawn note for the second transaction let input_note_new = create_spawn_note([&output_note_new])?; - let salt_new = Word::from([Felt::new(4); 4]); + let salt_new = Word::from([Felt::new_unchecked(4); 4]); // Build the new mock chain with the updated account and notes let mut new_mock_chain_builder = @@ -663,7 +675,7 @@ async fn test_multisig_update_signers(#[case] auth_scheme: AuthScheme) -> anyhow .await?; // Verify the transaction executed successfully with new signers - assert_eq!(tx_context_execute_new.account_delta().nonce_delta(), Felt::new(1)); + assert_eq!(tx_context_execute_new.account_delta().nonce_delta(), Felt::ONE); Ok(()) } @@ -708,36 +720,24 @@ async fn test_multisig_update_signers_remove_owner( let threshold = 1u64; let num_of_approvers = 2u64; - // Create multisig config vector - let mut config_and_pubkeys_vector = - vec![Felt::new(threshold), Felt::new(num_of_approvers), Felt::new(0), Felt::new(0)]; - - // Add each public key to the vector - for public_key in new_public_keys.iter().rev() { - let key_word: Word = public_key.to_commitment().into(); - config_and_pubkeys_vector.extend_from_slice(key_word.as_elements()); - - config_and_pubkeys_vector.extend_from_slice(&[ - Felt::new(auth_scheme as u64), - Felt::new(0), - Felt::new(0), - Felt::new(0), - ]); - } - - // Create config hash and advice map + let config_and_pubkeys_vector = build_update_signers_config_vector( + threshold, + num_of_approvers, + new_public_keys, + auth_scheme, + ); let multisig_config_hash = Hasher::hash_elements(&config_and_pubkeys_vector); let mut advice_map = AdviceMap::default(); advice_map.insert(multisig_config_hash, config_and_pubkeys_vector); // Create transaction script let tx_script = CodeBuilder::default() - .with_dynamically_linked_library(multisig_library())? + .with_dynamically_linked_library(AuthMultisig::code())? .compile_tx_script("begin\n call.::miden::standards::components::auth::multisig::update_signers_and_threshold\nend")?; let advice_inputs = AdviceInputs { map: advice_map, ..Default::default() }; - let salt = Word::from([Felt::new(3); 4]); + let salt = Word::from([Felt::new_unchecked(3); 4]); // Execute without signatures to get tx summary let tx_context_init = mock_chain @@ -786,7 +786,7 @@ async fn test_multisig_update_signers_remove_owner( .await?; // Verify transaction success - assert_eq!(update_approvers_tx.account_delta().nonce_delta(), Felt::new(1)); + assert_eq!(update_approvers_tx.account_delta().nonce_delta(), Felt::ONE); mock_chain.add_pending_executed_transaction(&update_approvers_tx)?; mock_chain.prove_next_block()?; @@ -797,7 +797,8 @@ async fn test_multisig_update_signers_remove_owner( // Verify public keys were updated for (i, expected_key) in new_public_keys.iter().enumerate() { - let storage_key = [Felt::new(i as u64), Felt::new(0), Felt::new(0), Felt::new(0)].into(); + let storage_key = + [Felt::new_unchecked(i as u64), Felt::ZERO, Felt::ZERO, Felt::ZERO].into(); let storage_item = updated_multisig_account .storage() .get_map_item(AuthMultisig::approver_public_keys_slot(), storage_key) @@ -811,8 +812,12 @@ async fn test_multisig_update_signers_remove_owner( .storage() .get_item(AuthMultisig::threshold_config_slot()) .unwrap(); - assert_eq!(threshold_config[0], Felt::new(threshold), "Threshold not updated"); - assert_eq!(threshold_config[1], Felt::new(num_of_approvers), "Num approvers not updated"); + assert_eq!(threshold_config[0], Felt::new_unchecked(threshold), "Threshold not updated"); + assert_eq!( + threshold_config[1], + Felt::new_unchecked(num_of_approvers), + "Num approvers not updated" + ); // Verify extracted public keys let extracted_pub_keys = get_public_keys_from_account(&updated_multisig_account); @@ -829,7 +834,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(removed_idx), Felt::new(0), Felt::new(0), Felt::new(0)].into(); + [Felt::new_unchecked(removed_idx), Felt::ZERO, Felt::ZERO, Felt::ZERO].into(); let removed_owner_slot = updated_multisig_account .storage() .get_map_item(AuthMultisig::approver_public_keys_slot(), removed_owner_key) @@ -845,7 +850,8 @@ 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(i as u64), Felt::new(0), Felt::new(0), Felt::new(0)].into(); + let storage_key = + [Felt::new_unchecked(i as u64), Felt::ZERO, Felt::ZERO, Felt::ZERO].into(); let storage_item = updated_multisig_account .storage() .get_map_item(AuthMultisig::approver_public_keys_slot(), storage_key) @@ -888,7 +894,7 @@ async fn test_multisig_update_signers_rejects_unreachable_proc_thresholds( // Configure a procedure override that is valid for the initial signer set (3-of-3), // but invalid after updating to 2 signers. let multisig_account = - create_multisig_account(2, &approvers, 10, vec![(BasicWallet::receive_asset_digest(), 3)])?; + create_multisig_account(2, &approvers, 10, vec![(BasicWallet::receive_asset_root(), 3)])?; let mock_chain = MockChainBuilder::with_accounts([multisig_account.clone()]) .unwrap() @@ -899,30 +905,22 @@ async fn test_multisig_update_signers_rejects_unreachable_proc_thresholds( let threshold = 2u64; let num_of_approvers = 2u64; - let mut config_and_pubkeys_vector = - vec![Felt::new(threshold), Felt::new(num_of_approvers), Felt::new(0), Felt::new(0)]; - - for public_key in new_public_keys.iter().rev() { - let key_word: Word = public_key.to_commitment().into(); - config_and_pubkeys_vector.extend_from_slice(key_word.as_elements()); - config_and_pubkeys_vector.extend_from_slice(&[ - Felt::new(auth_scheme as u64), - Felt::new(0), - Felt::new(0), - Felt::new(0), - ]); - } - + let config_and_pubkeys_vector = build_update_signers_config_vector( + threshold, + num_of_approvers, + new_public_keys, + auth_scheme, + ); let multisig_config_hash = Hasher::hash_elements(&config_and_pubkeys_vector); let mut advice_map = AdviceMap::default(); advice_map.insert(multisig_config_hash, config_and_pubkeys_vector); let tx_script = CodeBuilder::default() - .with_dynamically_linked_library(multisig_library())? + .with_dynamically_linked_library(AuthMultisig::code())? .compile_tx_script("begin\n call.::miden::standards::components::auth::multisig::update_signers_and_threshold\nend")?; let advice_inputs = AdviceInputs { map: advice_map, ..Default::default() }; - let salt = Word::from([Felt::new(8); 4]); + let salt = Word::from([Felt::new_unchecked(8); 4]); let result = mock_chain .build_tx_context(multisig_account.id(), &[], &[])? @@ -976,7 +974,7 @@ async fn test_multisig_new_approvers_cannot_sign_before_update( .build() .unwrap(); - let salt = Word::from([Felt::new(5); 4]); + let salt = Word::from([Felt::new_unchecked(5); 4]); // SECTION 2: Prepare a signer update transaction with new approvers // ================================================================================ @@ -991,32 +989,13 @@ async fn test_multisig_new_approvers_cannot_sign_before_update( let threshold = 3u64; let num_of_approvers = 4u64; - // Create vector with threshold config and public keys (4 field elements each) - let mut config_and_pubkeys_vector = Vec::new(); - config_and_pubkeys_vector.extend_from_slice(&[ - Felt::new(threshold), - Felt::new(num_of_approvers), - Felt::new(0), - Felt::new(0), - ]); - - // Add each public key to the vector - for public_key in new_public_keys.iter().rev() { - let key_word: Word = public_key.to_commitment().into(); - config_and_pubkeys_vector.extend_from_slice(key_word.as_elements()); - - config_and_pubkeys_vector.extend_from_slice(&[ - Felt::new(auth_scheme as u64), - Felt::new(0), - Felt::new(0), - Felt::new(0), - ]); - } - - // Hash the vector to create config hash + let config_and_pubkeys_vector = build_update_signers_config_vector( + threshold, + num_of_approvers, + &new_public_keys, + auth_scheme, + ); let multisig_config_hash = Hasher::hash_elements(&config_and_pubkeys_vector); - - // Insert config and public keys into advice map advice_map.insert(multisig_config_hash, config_and_pubkeys_vector); // Create a transaction script that calls the update_signers procedure @@ -1027,7 +1006,7 @@ async fn test_multisig_new_approvers_cannot_sign_before_update( "; let tx_script = CodeBuilder::default() - .with_dynamically_linked_library(multisig_library())? + .with_dynamically_linked_library(AuthMultisig::code())? .compile_tx_script(tx_script_code)?; let advice_inputs = AdviceInputs { @@ -1109,7 +1088,7 @@ async fn test_multisig_proc_threshold_overrides( let (_secret_keys, auth_schemes, public_keys, authenticators) = setup_keys_and_authenticators_with_scheme(2, 2, auth_scheme)?; - let proc_threshold_map = vec![(BasicWallet::receive_asset_digest(), 1)]; + let proc_threshold_map = vec![(BasicWallet::receive_asset_root(), 1)]; let approvers = public_keys .iter() @@ -1139,7 +1118,7 @@ async fn test_multisig_proc_threshold_overrides( let mut mock_chain = mock_chain_builder.build()?; // 2. consume without signatures - let salt = Word::from([Felt::new(1); 4]); + let salt = Word::from([Felt::ONE; 4]); let tx_context = mock_chain .build_tx_context(multisig_account.id(), &[note.id()], &[])? .auth_args(salt) @@ -1176,16 +1155,17 @@ async fn test_multisig_proc_threshold_overrides( // SECTION 2: Test note sending requires 2 signatures // ================================================================================ - let salt2 = Word::from([Felt::new(2); 4]); + let salt2 = Word::from([Felt::new_unchecked(2); 4]); // Create output note to send 5 units from the account + let asset = FungibleAsset::mock(5); let output_note = P2idNote::create( multisig_account.id(), ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE.try_into().unwrap(), - vec![FungibleAsset::mock(5)], + vec![asset], NoteType::Public, Default::default(), - &mut RandomCoin::new(Word::from([Felt::new(42); 4])), + &mut RandomCoin::new(Word::from([Felt::new_unchecked(42); 4])), )?; let multisig_account_interface = AccountInterface::from_account(&multisig_account); let send_note_transaction_script = @@ -1257,7 +1237,7 @@ async fn test_multisig_proc_threshold_overrides( mock_chain.add_pending_executed_transaction(&result.unwrap())?; mock_chain.prove_next_block()?; - assert_eq!(multisig_account.vault().get_balance(FungibleAsset::mock_issuer())?, 6); + assert_eq!(multisig_account.vault().get_balance(asset.vault_key())?.as_u64(), 6); Ok(()) } @@ -1295,7 +1275,7 @@ async fn test_multisig_set_procedure_threshold( NoteType::Public, )?; let mut mock_chain = mock_chain_builder.build().unwrap(); - let proc_root = BasicWallet::receive_asset_digest(); + let proc_root = BasicWallet::receive_asset_root().as_word(); let set_script_code = format!( r#" @@ -1309,11 +1289,11 @@ async fn test_multisig_set_procedure_threshold( "# ); let set_script = CodeBuilder::default() - .with_dynamically_linked_library(multisig_library())? + .with_dynamically_linked_library(AuthMultisig::code())? .compile_tx_script(set_script_code)?; // 1) Set override to 1 (requires default 2 signatures). - let set_salt = Word::from([Felt::new(50); 4]); + let set_salt = Word::from([Felt::new_unchecked(50); 4]); let set_init = mock_chain .build_tx_context(multisig_account.id(), &[], &[])? @@ -1348,7 +1328,7 @@ async fn test_multisig_set_procedure_threshold( mock_chain.prove_next_block()?; // 2) Verify receive_asset can now execute with one signature. - let one_sig_salt = Word::from([Felt::new(51); 4]); + let one_sig_salt = Word::from([Felt::new_unchecked(51); 4]); let one_sig_init = mock_chain .build_tx_context(multisig_account.id(), &[one_sig_note.id()], &[])? @@ -1389,9 +1369,9 @@ async fn test_multisig_set_procedure_threshold( "# ); let clear_script = CodeBuilder::default() - .with_dynamically_linked_library(multisig_library())? + .with_dynamically_linked_library(AuthMultisig::code())? .compile_tx_script(clear_script_code)?; - let clear_salt = Word::from([Felt::new(52); 4]); + let clear_salt = Word::from([Felt::new_unchecked(52); 4]); let clear_init = mock_chain .build_tx_context(multisig_account.id(), &[], &[])? @@ -1426,7 +1406,7 @@ async fn test_multisig_set_procedure_threshold( 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(53); 4]); + let clear_check_salt = Word::from([Felt::new_unchecked(53); 4]); let clear_check_init = mock_chain .build_tx_context(multisig_account.id(), &[clear_check_note.id()], &[])? @@ -1476,7 +1456,7 @@ async fn test_multisig_set_procedure_threshold_rejects_exceeding_approvers( .collect::>(); let multisig_account = create_multisig_account(2, &approvers, 10, vec![])?; - let proc_root = BasicWallet::receive_asset_digest(); + let proc_root = BasicWallet::receive_asset_root().as_word(); let script_code = format!( r#" @@ -1488,14 +1468,14 @@ async fn test_multisig_set_procedure_threshold_rejects_exceeding_approvers( "# ); let script = CodeBuilder::default() - .with_dynamically_linked_library(multisig_library())? + .with_dynamically_linked_library(AuthMultisig::code())? .compile_tx_script(script_code)?; let mock_chain = MockChainBuilder::with_accounts([multisig_account.clone()]) .unwrap() .build() .unwrap(); - let salt = Word::from([Felt::new(54); 4]); + let salt = Word::from([Felt::new_unchecked(54); 4]); let tx_context_init = mock_chain .build_tx_context(multisig_account.id(), &[], &[])? @@ -1509,3 +1489,83 @@ async fn test_multisig_set_procedure_threshold_rejects_exceeding_approvers( Ok(()) } + +/// Tests that `set_procedure_threshold` validates against the *current* num_approvers, not the +/// initial one. If `update_signers_and_threshold` reduces num_approvers earlier in the +/// same transaction, a subsequent `set_procedure_threshold` must use the post-update num_approvers +/// for its bound check. +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_set_procedure_threshold_uses_current_num_approvers( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + // Start with 3 approvers, threshold 2. + let (_secret_keys, auth_schemes, public_keys, _authenticators) = + setup_keys_and_authenticators_with_scheme(3, 2, auth_scheme)?; + + let approvers = public_keys + .iter() + .zip(auth_schemes.iter()) + .map(|(pk, scheme)| (pk.clone(), *scheme)) + .collect::>(); + + let multisig_account = create_multisig_account(2, &approvers, 10, vec![])?; + let proc_root = BasicWallet::receive_asset_root().as_word(); + + // Build a new config that reduces num_approvers to 1 (and threshold to 1). + let mut advice_map = AdviceMap::default(); + let (_new_sec, _new_schemes, new_public_keys, _new_auth) = + setup_keys_and_authenticators_with_scheme(1, 1, auth_scheme)?; + + let new_threshold = 1u64; + let new_num_of_approvers = 1u64; + let config_and_pubkeys_vector = build_update_signers_config_vector( + new_threshold, + new_num_of_approvers, + &new_public_keys, + auth_scheme, + ); + let multisig_config_hash = Hasher::hash_elements(&config_and_pubkeys_vector); + advice_map.insert(multisig_config_hash, config_and_pubkeys_vector); + let advice_inputs = AdviceInputs { map: advice_map, ..Default::default() }; + + // Same transaction: first reduce num_approvers to 1, then try to set a per-procedure + // override of 2 — which exceeds the *current* num_approvers and must be rejected. + let script_code = format!( + r#" + begin + call.::miden::standards::components::auth::multisig::update_signers_and_threshold + push.{proc_root} + push.2 + call.::miden::standards::components::auth::multisig::set_procedure_threshold + dropw + drop + end + "# + ); + let script = CodeBuilder::default() + .with_dynamically_linked_library(AuthMultisig::code())? + .compile_tx_script(script_code)?; + + let mock_chain = MockChainBuilder::with_accounts([multisig_account.clone()]) + .unwrap() + .build() + .unwrap(); + let salt = Word::from([Felt::from_u8(55); 4]); + + let tx_context = mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .tx_script(script) + .tx_script_args(multisig_config_hash) + .extend_advice_inputs(advice_inputs) + .auth_args(salt) + .build()?; + + let result = tx_context.execute().await; + + assert_transaction_executor_error!(result, ERR_PROC_THRESHOLD_EXCEEDS_NUM_APPROVERS); + + Ok(()) +} diff --git a/crates/miden-testing/tests/auth/multisig_psm.rs b/crates/miden-testing/tests/auth/multisig_psm.rs deleted file mode 100644 index 39a3dfb2ba..0000000000 --- a/crates/miden-testing/tests/auth/multisig_psm.rs +++ /dev/null @@ -1,532 +0,0 @@ -use miden_protocol::account::auth::{AuthScheme, AuthSecretKey, PublicKey}; -use miden_protocol::account::{ - Account, - AccountBuilder, - AccountId, - AccountStorageMode, - AccountType, -}; -use miden_protocol::asset::FungibleAsset; -use miden_protocol::note::{Note, NoteAssets, NoteMetadata, NoteRecipient, NoteStorage, NoteType}; -use miden_protocol::testing::account_id::{ - ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, - ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE, -}; -use miden_protocol::testing::note::DEFAULT_NOTE_SCRIPT; -use miden_protocol::transaction::RawOutputNote; -use miden_protocol::{Felt, Word}; -use miden_standards::account::auth::{AuthMultisigPsm, AuthMultisigPsmConfig, PsmConfig}; -use miden_standards::account::components::multisig_psm_library; -use miden_standards::account::wallets::BasicWallet; -use miden_standards::code_builder::CodeBuilder; -use miden_standards::errors::standards::{ - ERR_AUTH_PROCEDURE_MUST_BE_CALLED_ALONE, - ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_OR_OUTPUT_NOTES, -}; -use miden_testing::{MockChainBuilder, assert_transaction_executor_error}; -use miden_tx::TransactionExecutorError; -use miden_tx::auth::{BasicAuthenticator, SigningInputs, TransactionAuthenticator}; -use rand::SeedableRng; -use rand_chacha::ChaCha20Rng; -use rstest::rstest; - -// ================================================================================================ -// HELPER FUNCTIONS -// ================================================================================================ - -type MultisigTestSetup = - (Vec, Vec, Vec, Vec); - -/// Sets up secret keys, public keys, and authenticators for multisig testing for the given scheme. -fn setup_keys_and_authenticators_with_scheme( - num_approvers: usize, - threshold: usize, - auth_scheme: AuthScheme, -) -> anyhow::Result { - let seed: [u8; 32] = rand::random(); - let mut rng = ChaCha20Rng::from_seed(seed); - - let mut secret_keys = Vec::new(); - let mut auth_schemes = Vec::new(); - let mut public_keys = Vec::new(); - let mut authenticators = Vec::new(); - - for _ in 0..num_approvers { - let sec_key = match auth_scheme { - AuthScheme::EcdsaK256Keccak => AuthSecretKey::new_ecdsa_k256_keccak_with_rng(&mut rng), - AuthScheme::Falcon512Poseidon2 => { - AuthSecretKey::new_falcon512_poseidon2_with_rng(&mut rng) - }, - _ => anyhow::bail!("unsupported auth scheme for this test: {auth_scheme:?}"), - }; - let pub_key = sec_key.public_key(); - - secret_keys.push(sec_key); - auth_schemes.push(auth_scheme); - public_keys.push(pub_key); - } - - // Create authenticators for required signers - for secret_key in secret_keys.iter().take(threshold) { - let authenticator = BasicAuthenticator::new(core::slice::from_ref(secret_key)); - authenticators.push(authenticator); - } - - Ok((secret_keys, auth_schemes, public_keys, authenticators)) -} - -/// Creates a multisig account configured with a private state manager signer. -fn create_multisig_account_with_psm( - threshold: u32, - approvers: &[(PublicKey, AuthScheme)], - psm: PsmConfig, - asset_amount: u64, - proc_threshold_map: Vec<(Word, u32)>, -) -> anyhow::Result { - let approvers = approvers - .iter() - .map(|(pub_key, auth_scheme)| (pub_key.to_commitment(), *auth_scheme)) - .collect(); - - let config = AuthMultisigPsmConfig::new(approvers, threshold, psm)? - .with_proc_thresholds(proc_threshold_map)?; - - let multisig_account = AccountBuilder::new([0; 32]) - .with_auth_component(AuthMultisigPsm::new(config)?) - .with_component(BasicWallet) - .account_type(AccountType::RegularAccountUpdatableCode) - .storage_mode(AccountStorageMode::Public) - .with_assets(vec![FungibleAsset::mock(asset_amount)]) - .build_existing()?; - - Ok(multisig_account) -} - -// ================================================================================================ -// TESTS -// ================================================================================================ - -/// Tests that multisig authentication requires an additional PSM signature when -/// configured. -#[rstest] -#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] -#[case::falcon(AuthScheme::Falcon512Poseidon2)] -#[tokio::test] -async fn test_multisig_psm_signature_required( - #[case] auth_scheme: AuthScheme, -) -> anyhow::Result<()> { - let (_secret_keys, auth_schemes, public_keys, authenticators) = - setup_keys_and_authenticators_with_scheme(2, 2, auth_scheme)?; - let approvers = public_keys - .iter() - .zip(auth_schemes.iter()) - .map(|(pk, scheme)| (pk.clone(), *scheme)) - .collect::>(); - - let psm_secret_key = AuthSecretKey::new_ecdsa_k256_keccak(); - let psm_public_key = psm_secret_key.public_key(); - let psm_authenticator = BasicAuthenticator::new(core::slice::from_ref(&psm_secret_key)); - - let mut multisig_account = create_multisig_account_with_psm( - 2, - &approvers, - PsmConfig::new(psm_public_key.to_commitment(), AuthScheme::EcdsaK256Keccak), - 10, - vec![], - )?; - - let output_note_asset = FungibleAsset::mock(0); - let mut mock_chain_builder = - MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap(); - - let output_note = mock_chain_builder.add_p2id_note( - multisig_account.id(), - ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE.try_into().unwrap(), - &[output_note_asset], - NoteType::Public, - )?; - let input_note = mock_chain_builder.add_spawn_note([&output_note])?; - let mut mock_chain = mock_chain_builder.build().unwrap(); - - let salt = Word::from([Felt::new(777); 4]); - let tx_context_init = 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()?; - - 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 msg = tx_summary.as_ref().to_commitment(); - let tx_summary_signing = SigningInputs::TransactionSummary(tx_summary); - - let sig_1 = authenticators[0] - .get_signature(public_keys[0].to_commitment(), &tx_summary_signing) - .await?; - let sig_2 = authenticators[1] - .get_signature(public_keys[1].to_commitment(), &tx_summary_signing) - .await?; - - // Missing PSM signature must fail. - let without_psm_result = mock_chain - .build_tx_context(multisig_account.id(), &[input_note.id()], &[])? - .extend_expected_output_notes(vec![RawOutputNote::Full(output_note.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; - assert!(matches!(without_psm_result, Err(TransactionExecutorError::Unauthorized(_)))); - - let psm_signature = psm_authenticator - .get_signature(psm_public_key.to_commitment(), &tx_summary_signing) - .await?; - - // With PSM 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)]) - .add_signature(public_keys[0].to_commitment(), msg, sig_1) - .add_signature(public_keys[1].to_commitment(), msg, sig_2) - .add_signature(psm_public_key.to_commitment(), msg, psm_signature) - .auth_args(salt) - .build()? - .execute() - .await?; - - multisig_account.apply_delta(tx_context_execute.account_delta())?; - - mock_chain.add_pending_executed_transaction(&tx_context_execute)?; - mock_chain.prove_next_block()?; - - assert_eq!( - multisig_account - .vault() - .get_balance(AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?)?, - 10 - output_note_asset.unwrap_fungible().amount() - ); - - Ok(()) -} - -/// Tests that the PSM public key can be updated and then enforced. -#[rstest] -#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] -#[case::falcon(AuthScheme::Falcon512Poseidon2)] -#[tokio::test] -async fn test_multisig_update_psm_public_key( - #[case] auth_scheme: AuthScheme, -) -> anyhow::Result<()> { - let (_secret_keys, auth_schemes, public_keys, authenticators) = - setup_keys_and_authenticators_with_scheme(2, 2, auth_scheme)?; - let approvers = public_keys - .iter() - .zip(auth_schemes.iter()) - .map(|(pk, scheme)| (pk.clone(), *scheme)) - .collect::>(); - - let old_psm_secret_key = AuthSecretKey::new_ecdsa_k256_keccak(); - let old_psm_public_key = old_psm_secret_key.public_key(); - let old_psm_authenticator = BasicAuthenticator::new(core::slice::from_ref(&old_psm_secret_key)); - - let new_psm_secret_key = AuthSecretKey::new_falcon512_poseidon2(); - let new_psm_public_key = new_psm_secret_key.public_key(); - let new_psm_auth_scheme = new_psm_secret_key.auth_scheme(); - let new_psm_authenticator = BasicAuthenticator::new(core::slice::from_ref(&new_psm_secret_key)); - - let multisig_account = create_multisig_account_with_psm( - 2, - &approvers, - PsmConfig::new(old_psm_public_key.to_commitment(), AuthScheme::EcdsaK256Keccak), - 10, - vec![], - )?; - - let mut mock_chain = MockChainBuilder::with_accounts([multisig_account.clone()]) - .unwrap() - .build() - .unwrap(); - - let new_psm_key_word: Word = new_psm_public_key.to_commitment().into(); - let new_psm_scheme_id = new_psm_auth_scheme as u32; - let update_psm_script = CodeBuilder::new() - .with_dynamically_linked_library(multisig_psm_library())? - .compile_tx_script(format!( - "begin\n push.{new_psm_key_word}\n push.{new_psm_scheme_id}\n call.::miden::standards::components::auth::multisig_psm::update_psm_public_key\n drop\n dropw\nend" - ))?; - - let update_salt = Word::from([Felt::new(991); 4]); - let tx_context_init = mock_chain - .build_tx_context(multisig_account.id(), &[], &[])? - .tx_script(update_psm_script.clone()) - .auth_args(update_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}"), - }; - - let update_msg = tx_summary.as_ref().to_commitment(); - let tx_summary_signing = SigningInputs::TransactionSummary(tx_summary); - let sig_1 = authenticators[0] - .get_signature(public_keys[0].to_commitment(), &tx_summary_signing) - .await?; - let sig_2 = authenticators[1] - .get_signature(public_keys[1].to_commitment(), &tx_summary_signing) - .await?; - - // PSM key rotation intentionally skips PSM signature for this update tx. - let update_psm_tx = mock_chain - .build_tx_context(multisig_account.id(), &[], &[])? - .tx_script(update_psm_script) - .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_psm_tx.account_delta())?; - let updated_psm_public_key = updated_multisig_account - .storage() - .get_map_item(AuthMultisigPsm::psm_public_key_slot(), Word::empty())?; - assert_eq!(updated_psm_public_key, Word::from(new_psm_public_key.to_commitment())); - let updated_psm_scheme_id = updated_multisig_account - .storage() - .get_map_item(AuthMultisigPsm::psm_scheme_id_slot(), Word::from([0u32, 0, 0, 0]))?; - assert_eq!( - updated_psm_scheme_id, - Word::from([new_psm_auth_scheme as u32, 0u32, 0u32, 0u32]) - ); - - mock_chain.add_pending_executed_transaction(&update_psm_tx)?; - mock_chain.prove_next_block()?; - - // Build one tx summary after key update. Old PSM must fail and new PSM must pass on this same - // transaction. - let next_salt = Word::from([Felt::new(992); 4]); - let tx_context_init_next = mock_chain - .build_tx_context(updated_multisig_account.id(), &[], &[])? - .auth_args(next_salt) - .build()?; - - 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 next_msg = tx_summary_next.as_ref().to_commitment(); - let tx_summary_next_signing = SigningInputs::TransactionSummary(tx_summary_next); - - let next_sig_1 = authenticators[0] - .get_signature(public_keys[0].to_commitment(), &tx_summary_next_signing) - .await?; - let next_sig_2 = authenticators[1] - .get_signature(public_keys[1].to_commitment(), &tx_summary_next_signing) - .await?; - let old_psm_sig_next = old_psm_authenticator - .get_signature(old_psm_public_key.to_commitment(), &tx_summary_next_signing) - .await?; - let new_psm_sig_next = new_psm_authenticator - .get_signature(new_psm_public_key.to_commitment(), &tx_summary_next_signing) - .await?; - - // Old PSM signature must fail after key update. - let with_old_psm_result = mock_chain - .build_tx_context(updated_multisig_account.id(), &[], &[])? - .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_psm_public_key.to_commitment(), next_msg, old_psm_sig_next) - .auth_args(next_salt) - .build()? - .execute() - .await; - assert!(matches!(with_old_psm_result, Err(TransactionExecutorError::Unauthorized(_)))); - - // New PSM signature must pass. - mock_chain - .build_tx_context(updated_multisig_account.id(), &[], &[])? - .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_psm_public_key.to_commitment(), next_msg, new_psm_sig_next) - .auth_args(next_salt) - .build()? - .execute() - .await?; - - Ok(()) -} - -/// Tests that `update_psm_public_key` must be the only account action in the transaction. -#[rstest] -#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] -#[case::falcon(AuthScheme::Falcon512Poseidon2)] -#[tokio::test] -async fn test_multisig_update_psm_public_key_must_be_called_alone( - #[case] auth_scheme: AuthScheme, -) -> anyhow::Result<()> { - let (_secret_keys, auth_schemes, public_keys, authenticators) = - setup_keys_and_authenticators_with_scheme(2, 2, auth_scheme)?; - let approvers = public_keys - .iter() - .zip(auth_schemes.iter()) - .map(|(pk, scheme)| (pk.clone(), *scheme)) - .collect::>(); - - let old_psm_secret_key = AuthSecretKey::new_ecdsa_k256_keccak(); - let old_psm_public_key = old_psm_secret_key.public_key(); - let old_psm_authenticator = BasicAuthenticator::new(core::slice::from_ref(&old_psm_secret_key)); - - let new_psm_secret_key = AuthSecretKey::new_falcon512_poseidon2(); - let new_psm_public_key = new_psm_secret_key.public_key(); - let new_psm_auth_scheme = new_psm_secret_key.auth_scheme(); - - let multisig_account = create_multisig_account_with_psm( - 2, - &approvers, - PsmConfig::new(old_psm_public_key.to_commitment(), AuthScheme::EcdsaK256Keccak), - 10, - vec![], - )?; - - let new_psm_key_word: Word = new_psm_public_key.to_commitment().into(); - let new_psm_scheme_id = new_psm_auth_scheme as u32; - let update_psm_script = CodeBuilder::new() - .with_dynamically_linked_library(multisig_psm_library())? - .compile_tx_script(format!( - "begin\n push.{new_psm_key_word}\n push.{new_psm_scheme_id}\n call.::miden::standards::components::auth::multisig_psm::update_psm_public_key\n drop\n dropw\nend" - ))?; - - let mut mock_chain_builder = - MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap(); - let receive_asset_note = mock_chain_builder.add_p2id_note( - multisig_account.id(), - multisig_account.id(), - &[FungibleAsset::mock(1)], - NoteType::Public, - )?; - let mock_chain = mock_chain_builder.build().unwrap(); - - let salt = Word::from([Felt::new(993); 4]); - let tx_context_init = mock_chain - .build_tx_context(multisig_account.id(), &[receive_asset_note.id()], &[])? - .tx_script(update_psm_script.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}"), - }; - - let msg = tx_summary.as_ref().to_commitment(); - let tx_summary_signing = SigningInputs::TransactionSummary(tx_summary); - let sig_1 = authenticators[0] - .get_signature(public_keys[0].to_commitment(), &tx_summary_signing) - .await?; - let sig_2 = authenticators[1] - .get_signature(public_keys[1].to_commitment(), &tx_summary_signing) - .await?; - - let without_psm_result = mock_chain - .build_tx_context(multisig_account.id(), &[receive_asset_note.id()], &[])? - .tx_script(update_psm_script.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; - assert_transaction_executor_error!(without_psm_result, ERR_AUTH_PROCEDURE_MUST_BE_CALLED_ALONE); - - let old_psm_signature = old_psm_authenticator - .get_signature(old_psm_public_key.to_commitment(), &tx_summary_signing) - .await?; - - let with_psm_result = mock_chain - .build_tx_context(multisig_account.id(), &[receive_asset_note.id()], &[])? - .tx_script(update_psm_script) - .add_signature(public_keys[0].to_commitment(), msg, sig_1) - .add_signature(public_keys[1].to_commitment(), msg, sig_2) - .add_signature(old_psm_public_key.to_commitment(), msg, old_psm_signature) - .auth_args(salt) - .build()? - .execute() - .await; - - assert_transaction_executor_error!(with_psm_result, ERR_AUTH_PROCEDURE_MUST_BE_CALLED_ALONE); - - // Also reject rotation transactions that touch notes even when no other account procedure is - // called. - let note_script = CodeBuilder::default().compile_note_script(DEFAULT_NOTE_SCRIPT)?; - let note_serial_num = Word::from([Felt::new(1), Felt::new(2), Felt::new(3), Felt::new(4)]); - let note_recipient = - NoteRecipient::new(note_serial_num, note_script.clone(), NoteStorage::default()); - let output_note = Note::new( - NoteAssets::new(vec![])?, - NoteMetadata::new(multisig_account.id(), NoteType::Public), - note_recipient, - ); - - let new_psm_key_word: Word = new_psm_public_key.to_commitment().into(); - let new_psm_scheme_id = new_psm_auth_scheme as u32; - let update_psm_with_output_script = CodeBuilder::new() - .with_dynamically_linked_library(multisig_psm_library())? - .compile_tx_script(format!( - "use miden::protocol::output_note\nbegin\n push.{recipient}\n push.{note_type}\n push.{tag}\n exec.output_note::create\n swapdw\n dropw\n dropw\n push.{new_psm_key_word}\n push.{new_psm_scheme_id}\n call.::miden::standards::components::auth::multisig_psm::update_psm_public_key\n drop\n dropw\nend", - recipient = output_note.recipient().digest(), - note_type = NoteType::Public as u8, - tag = Felt::from(output_note.metadata().tag()), - ))?; - - let mock_chain = MockChainBuilder::with_accounts([multisig_account.clone()]) - .unwrap() - .build() - .unwrap(); - - let salt = Word::from([Felt::new(994); 4]); - let tx_context_init = mock_chain - .build_tx_context(multisig_account.id(), &[], &[])? - .tx_script(update_psm_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}"), - }; - - let msg = tx_summary.as_ref().to_commitment(); - let tx_summary_signing = SigningInputs::TransactionSummary(tx_summary); - let sig_1 = authenticators[0] - .get_signature(public_keys[0].to_commitment(), &tx_summary_signing) - .await?; - let sig_2 = authenticators[1] - .get_signature(public_keys[1].to_commitment(), &tx_summary_signing) - .await?; - - let result = mock_chain - .build_tx_context(multisig_account.id(), &[], &[])? - .tx_script(update_psm_with_output_script) - .add_note_script(note_script) - .extend_expected_output_notes(vec![RawOutputNote::Full(output_note)]) - .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; - - assert_transaction_executor_error!( - result, - ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_OR_OUTPUT_NOTES - ); - - Ok(()) -} diff --git a/crates/miden-testing/tests/auth/multisig_smart.rs b/crates/miden-testing/tests/auth/multisig_smart.rs new file mode 100644 index 0000000000..dbafddee0d --- /dev/null +++ b/crates/miden-testing/tests/auth/multisig_smart.rs @@ -0,0 +1,606 @@ +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::note::NoteType; +use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET; +use miden_protocol::transaction::TransactionScript; +use miden_protocol::vm::AdviceMap; +use miden_protocol::{Felt, Hasher, Word}; +use miden_standards::account::auth::multisig_smart::{ + ProcedurePolicy, + ProcedurePolicyNoteRestriction, +}; +use miden_standards::account::auth::{AuthMultisigSmart, AuthMultisigSmartConfig}; +use miden_standards::account::wallets::BasicWallet; +use miden_standards::code_builder::CodeBuilder; +use miden_standards::errors::standards::{ + ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_NOTES, + 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; + +use super::multisig::{ + build_update_signers_config_vector, + setup_keys_and_authenticators_with_scheme, +}; + +// ================================================================================================ +// HELPER FUNCTIONS +// ================================================================================================ + +/// Builds a multisig smart account with the given approvers, threshold, starting balance, and +/// procedure policy map. Uses `BasicWallet` so the account exposes `receive_asset` and friends. +fn create_multisig_smart_account( + threshold: u32, + public_keys: &[PublicKey], + auth_scheme: AuthScheme, + starting_balance: u64, + proc_policy_map: Vec<(Word, ProcedurePolicy)>, +) -> anyhow::Result { + let approvers: Vec<_> = + public_keys.iter().map(|pk| (pk.to_commitment(), auth_scheme)).collect(); + let config = + AuthMultisigSmartConfig::new(approvers, threshold)?.with_proc_policies(proc_policy_map)?; + + let asset = FungibleAsset::new( + AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?, + starting_balance, + )?; + + let multisig_account = AccountBuilder::new([0; 32]) + .with_auth_component(AuthMultisigSmart::new(config)?) + .with_component(BasicWallet) + .account_type(AccountType::Public) + .with_assets(core::iter::once(asset.into())) + .build_existing()?; + + Ok(multisig_account) +} + +/// Compiles a transaction script that links against the multisig smart library so it can `call.` +/// the wrapper-exported procedures. +fn compile_multisig_smart_tx_script(script: impl AsRef) -> anyhow::Result { + Ok(CodeBuilder::default() + .with_dynamically_linked_library(AuthMultisigSmart::code())? + .compile_tx_script(script.as_ref())?) +} + +// ================================================================================================ +// TESTS +// ================================================================================================ + +/// A 3-of-3 multisig with a `receive_asset` procedure policy that lowers the threshold to 1 +/// should let a single-signature transaction that only calls `receive_asset` succeed. +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_receive_asset_policy_overrides_default_three_of_three_to_one_signature( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let (_secret_keys, _auth_schemes, public_keys, authenticators) = + setup_keys_and_authenticators_with_scheme(3, 3, auth_scheme)?; + + let receive_asset_one_signature_policy = ProcedurePolicy::with_immediate_threshold(1)?; + let proc_policy_map = + vec![(BasicWallet::receive_asset_root().as_word(), receive_asset_one_signature_policy)]; + + let mut multisig_account = + create_multisig_smart_account(3, &public_keys, auth_scheme, 10, proc_policy_map)?; + + let mut mock_chain_builder = + MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap(); + let note = mock_chain_builder.add_p2id_note( + multisig_account.id(), + multisig_account.id(), + &[FungibleAsset::mock(1)], + NoteType::Public, + )?; + let mut mock_chain = mock_chain_builder.build()?; + + let salt = Word::from([Felt::new_unchecked(1); 4]); + let tx_summary = match mock_chain + .build_tx_context(multisig_account.id(), &[note.id()], &[])? + .auth_args(salt) + .build()? + .execute() + .await + .unwrap_err() + { + TransactionExecutorError::Unauthorized(tx_summary) => tx_summary, + error => panic!("expected abort with tx summary: {error:?}"), + }; + + let msg = tx_summary.as_ref().to_commitment(); + let tx_summary_signing = SigningInputs::TransactionSummary(tx_summary); + let one_signature = authenticators[0] + .get_signature(public_keys[0].to_commitment(), &tx_summary_signing) + .await?; + + let tx_result = mock_chain + .build_tx_context(multisig_account.id(), &[note.id()], &[])? + .add_signature(public_keys[0].to_commitment(), msg, one_signature) + .auth_args(salt) + .build()? + .execute() + .await; + + assert!( + tx_result.is_ok(), + "receive_asset policy threshold=1 should override the default 3-of-3 requirement" + ); + + multisig_account.apply_delta(tx_result.as_ref().unwrap().account_delta())?; + mock_chain.add_pending_executed_transaction(&tx_result.unwrap())?; + mock_chain.prove_next_block()?; + + Ok(()) +} + +/// `enforce_note_restrictions` must abort transactions whose note layout violates the configured +/// policy bit set. The receive_asset proc policy carries each restriction variant and the tx +/// consumes a P2ID note (calls receive_asset). The test checks every variant against the +/// "tx has input notes" axis. +#[rstest] +#[case::no_restriction(ProcedurePolicyNoteRestriction::None)] +#[case::no_input_notes(ProcedurePolicyNoteRestriction::NoInputNotes)] +#[case::no_output_notes(ProcedurePolicyNoteRestriction::NoOutputNotes)] +#[case::no_input_or_output_notes(ProcedurePolicyNoteRestriction::NoInputOrOutputNotes)] +#[tokio::test] +async fn test_multisig_smart_enforces_note_restrictions_on_tx_with_input_notes( + #[case] restriction: ProcedurePolicyNoteRestriction, +) -> anyhow::Result<()> { + let (_secret_keys, _auth_schemes, public_keys, _authenticators) = + setup_keys_and_authenticators_with_scheme(2, 2, AuthScheme::EcdsaK256Keccak)?; + + let multisig_account = create_multisig_smart_account( + 2, + &public_keys, + AuthScheme::EcdsaK256Keccak, + 100, + vec![( + BasicWallet::receive_asset_root().as_word(), + ProcedurePolicy::with_immediate_threshold(1)?.with_note_restriction(restriction), + )], + )?; + + let mut mock_chain_builder = + MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap(); + let note = mock_chain_builder.add_p2id_note( + multisig_account.id(), + multisig_account.id(), + &[FungibleAsset::mock(1)], + NoteType::Public, + )?; + let mock_chain = mock_chain_builder.build()?; + + let result = mock_chain + .build_tx_context(multisig_account.id(), &[note.id()], &[])? + .auth_args(Word::from([Felt::new_unchecked(2); 4])) + .build()? + .execute() + .await; + + // For restrictions that include the input bit (1, 3), enforce_note_restrictions panics with + // the input-notes error before signatures are even checked. For the other variants the input + // bit is unset, so the tx falls through to signature verification and aborts there + // (no signatures were provided). The output bit (2) does not trigger because the tx has no + // output notes. + match restriction { + ProcedurePolicyNoteRestriction::NoInputNotes + | ProcedurePolicyNoteRestriction::NoInputOrOutputNotes => { + assert_transaction_executor_error!( + result, + ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_NOTES + ); + }, + ProcedurePolicyNoteRestriction::None | ProcedurePolicyNoteRestriction::NoOutputNotes => { + match result { + Err(TransactionExecutorError::Unauthorized(_)) => {}, + other => panic!("expected Unauthorized (no signatures provided), got: {other:?}"), + } + }, + } + + Ok(()) +} + +/// Mirror of the input-notes test for the output-notes axis. The policy lives on +/// `move_asset_to_note` (the BasicWallet proc invoked when sending notes) and the tx creates a +/// P2ID output note rather than consuming one. +#[rstest] +#[case::no_restriction(ProcedurePolicyNoteRestriction::None)] +#[case::no_input_notes(ProcedurePolicyNoteRestriction::NoInputNotes)] +#[case::no_output_notes(ProcedurePolicyNoteRestriction::NoOutputNotes)] +#[case::no_input_or_output_notes(ProcedurePolicyNoteRestriction::NoInputOrOutputNotes)] +#[tokio::test] +async fn test_multisig_smart_enforces_note_restrictions_on_tx_with_output_notes( + #[case] restriction: ProcedurePolicyNoteRestriction, +) -> 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_standards::note::P2idNote; + + let (_secret_keys, _auth_schemes, public_keys, _authenticators) = + setup_keys_and_authenticators_with_scheme(2, 2, AuthScheme::EcdsaK256Keccak)?; + + let multisig_account = create_multisig_smart_account( + 2, + &public_keys, + AuthScheme::EcdsaK256Keccak, + 100, + vec![( + BasicWallet::move_asset_to_note_root().as_word(), + ProcedurePolicy::with_immediate_threshold(1)?.with_note_restriction(restriction), + )], + )?; + + let output_note = P2idNote::create( + multisig_account.id(), + ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE.try_into().unwrap(), + vec![FungibleAsset::mock(5)], + NoteType::Public, + Default::default(), + &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 mock_chain = + MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; + + let result = mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .extend_expected_output_notes(vec![RawOutputNote::Full(output_note)]) + .tx_script(send_note_script) + .auth_args(Word::from([Felt::new_unchecked(2); 4])) + .build()? + .execute() + .await; + + // For restrictions that include the output bit (2, 3), enforce_note_restrictions panics with + // the output-notes error after the input check passes. For the other variants neither check + // trips and the tx falls through to signature verification (no signatures were provided). + match restriction { + ProcedurePolicyNoteRestriction::NoOutputNotes + | ProcedurePolicyNoteRestriction::NoInputOrOutputNotes => { + assert_transaction_executor_error!( + result, + ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_OUTPUT_NOTES + ); + }, + ProcedurePolicyNoteRestriction::None | ProcedurePolicyNoteRestriction::NoInputNotes => { + match result { + Err(TransactionExecutorError::Unauthorized(_)) => {}, + other => panic!("expected Unauthorized (no signatures provided), got: {other:?}"), + } + }, + } + + Ok(()) +} + +/// Tests `update_signers_and_threshold`: a 2-of-2 multisig is rotated to a 4-of-3 +/// signer set with new public keys. The new threshold and signers are persisted in storage. +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_update_signers_and_thresholds( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let (_secret_keys, _auth_schemes, public_keys, authenticators) = + setup_keys_and_authenticators_with_scheme(2, 2, auth_scheme)?; + + let mut multisig_account = + create_multisig_smart_account(2, &public_keys, auth_scheme, 10, vec![])?; + let account_id = multisig_account.id(); + let mock_chain = + MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; + + // Generate a fresh 4-signer set; rotate the multisig to 4-of-3 (threshold=3, num_approvers=4). + let (_new_secret_keys, _new_auth_schemes, new_public_keys, _new_authenticators) = + setup_keys_and_authenticators_with_scheme(4, 4, auth_scheme)?; + + let new_threshold: u64 = 3; + let new_num_approvers: u64 = 4; + let multisig_config_data = build_update_signers_config_vector( + new_threshold, + new_num_approvers, + &new_public_keys, + auth_scheme, + ); + let multisig_config_hash = Hasher::hash_elements(&multisig_config_data); + + let mut advice_map = AdviceMap::default(); + advice_map.insert(multisig_config_hash, multisig_config_data); + let advice_inputs = AdviceInputs { map: advice_map, ..Default::default() }; + + let update_signers_script = compile_multisig_smart_tx_script( + " + begin + call.::miden::standards::components::auth::multisig_smart::update_signers_and_threshold + end + ", + )?; + + 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 + .build_tx_context(account_id, &[], &[])? + .tx_script(update_signers_script.clone()) + .tx_script_args(multisig_config_hash) + .extend_advice_inputs(advice_inputs.clone()) + .auth_args(salt) + .build()? + .execute() + .await + .unwrap_err() + { + TransactionExecutorError::Unauthorized(tx_summary) => tx_summary, + error => panic!("expected abort with tx summary: {error:?}"), + }; + + let msg = tx_summary.as_ref().to_commitment(); + let signing_inputs = SigningInputs::TransactionSummary(tx_summary); + let sig_0 = authenticators[0] + .get_signature(public_keys[0].to_commitment(), &signing_inputs) + .await?; + let sig_1 = authenticators[1] + .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) + .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())?; + + // Verify the new threshold/num_approvers config is persisted. + let threshold_config = multisig_account + .storage() + .get_item(AuthMultisigSmart::threshold_config_slot()) + .expect("threshold config slot should be present"); + assert_eq!(threshold_config[0], Felt::new_unchecked(new_threshold)); + assert_eq!(threshold_config[1], Felt::new_unchecked(new_num_approvers)); + + // 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 stored_pub_key = multisig_account + .storage() + .get_map_item(AuthMultisigSmart::approver_public_keys_slot(), storage_key) + .expect("approver public key map item should be present"); + let expected_word: Word = expected_key.to_commitment().into(); + assert_eq!(stored_pub_key, expected_word, "public key at index {i} mismatch"); + } + + Ok(()) +} + +/// `set_procedure_policy` invoked from a transaction script must persist the policy to the +/// `procedure_policies` storage map so subsequent transactions see the new policy. +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_set_procedure_policy( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let (_secret_keys, _auth_schemes, public_keys, authenticators) = + setup_keys_and_authenticators_with_scheme(2, 2, auth_scheme)?; + + // Account starts with no procedure policies configured. + let mut multisig_account = + create_multisig_smart_account(2, &public_keys, auth_scheme, 100, vec![])?; + let account_id = multisig_account.id(); + let mock_chain = + MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; + + let receive_asset_root = BasicWallet::receive_asset_root().as_word(); + let immediate_threshold = 1u32; + let delayed_threshold = 0u32; + let note_restrictions = ProcedurePolicyNoteRestriction::NoInputNotes; + // `call.` does not consume operand-stack inputs (the procedure sees a snapshot, the caller's + // stack is preserved across the boundary), so we must manually drop the 7 elements we pushed. + let set_policy_script = compile_multisig_smart_tx_script(format!( + " + begin + push.{root} + push.{note_restrictions} + push.{delayed_threshold} + push.{immediate_threshold} + call.::miden::standards::components::auth::multisig_smart::set_procedure_policy + drop drop drop # immediate, delayed, note_restrictions + dropw # PROC_ROOT + end + ", + root = receive_asset_root, + note_restrictions = note_restrictions as u8, + delayed_threshold = delayed_threshold, + immediate_threshold = immediate_threshold, + ))?; + + 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 + .build_tx_context(account_id, &[], &[])? + .tx_script(set_policy_script.clone()) + .auth_args(salt) + .build()? + .execute() + .await + .unwrap_err() + { + TransactionExecutorError::Unauthorized(tx_summary) => tx_summary, + error => panic!("expected abort with tx summary: {error:?}"), + }; + + let msg = tx_summary.as_ref().to_commitment(); + let signing_inputs = SigningInputs::TransactionSummary(tx_summary); + let sig_0 = authenticators[0] + .get_signature(public_keys[0].to_commitment(), &signing_inputs) + .await?; + let sig_1 = authenticators[1] + .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) + .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())?; + + // Policy word layout: [immediate, delayed, note_restrictions, 0] + let stored_policy = multisig_account + .storage() + .get_map_item(AuthMultisigSmart::procedure_policies_slot(), receive_asset_root) + .expect("procedure policies slot should be present"); + assert_eq!( + stored_policy, + Word::from([immediate_threshold, delayed_threshold, note_restrictions as u32, 0]) + ); + + Ok(()) +} + +/// Regression test for the per-procedure contribution semantic of `compute_called_proc_policy`: +/// a transaction that mixes a low-policy procedure (receive_asset = 1) with an unpolicied +/// procedure (set_procedure_policy) must require `max(policy, default) = default` signatures, +/// not just the low policy threshold. Without per-proc-contribute this is a privilege escalation +/// — the unpolicied call would be silently authorized at the receive_asset threshold of 1. +#[tokio::test] +async fn test_multisig_smart_unpolicied_proc_call_requires_default_threshold() -> anyhow::Result<()> +{ + let auth_scheme = AuthScheme::EcdsaK256Keccak; + let default_threshold = 3u32; + let (_secret_keys, _auth_schemes, public_keys, authenticators) = + setup_keys_and_authenticators_with_scheme( + default_threshold as usize, + default_threshold as usize, + auth_scheme, + )?; + + // receive_asset configured with a low policy (1 sig), update_signers and + // set_procedure_policy intentionally left unpolicied. + let receive_policy = ProcedurePolicy::with_immediate_threshold(1)?; + let proc_policy_map = vec![(BasicWallet::receive_asset_root().as_word(), receive_policy)]; + let multisig_account = create_multisig_smart_account( + default_threshold, + &public_keys, + auth_scheme, + 10, + proc_policy_map, + )?; + + // Tx-script calls the unpolicied `set_procedure_policy` proc. The tx also consumes a P2ID + // note (which calls the policied receive_asset). With per-proc-contribute, set_procedure_policy + // contributes `default_threshold` to the max. + let target_root = BasicWallet::move_asset_to_note_root().as_word(); + let set_policy_script = compile_multisig_smart_tx_script(format!( + " + begin + push.{root} + push.0 # note_restrictions + push.0 # delayed_threshold + push.1 # immediate_threshold + call.::miden::standards::components::auth::multisig_smart::set_procedure_policy + drop drop drop + dropw + end + ", + root = target_root, + ))?; + + let mut chain_builder = MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap(); + let note = chain_builder.add_p2id_note( + multisig_account.id(), + multisig_account.id(), + &[FungibleAsset::mock(1)], + NoteType::Public, + )?; + let mock_chain = chain_builder.build()?; + + let salt = Word::from([Felt::new_unchecked(42); 4]); + + // Dry-run to capture the tx summary. + let tx_summary = match mock_chain + .build_tx_context(multisig_account.id(), &[note.id()], &[])? + .tx_script(set_policy_script.clone()) + .auth_args(salt) + .build()? + .execute() + .await + .unwrap_err() + { + TransactionExecutorError::Unauthorized(tx_summary) => tx_summary, + error => panic!("expected dry-run abort with tx summary: {error:?}"), + }; + + let msg = tx_summary.as_ref().to_commitment(); + let signing = SigningInputs::TransactionSummary(tx_summary); + let sig_0 = authenticators[0] + .get_signature(public_keys[0].to_commitment(), &signing) + .await?; + let sig_1 = authenticators[1] + .get_signature(public_keys[1].to_commitment(), &signing) + .await?; + let sig_2 = authenticators[2] + .get_signature(public_keys[2].to_commitment(), &signing) + .await?; + + // 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) + .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:?}") + }, + } + + // 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) + .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) + .build()? + .execute() + .await; + three_sig_result.expect("3 signatures should satisfy the default-threshold contribution"); + + Ok(()) +} diff --git a/crates/miden-testing/tests/auth/network_account.rs b/crates/miden-testing/tests/auth/network_account.rs new file mode 100644 index 0000000000..e8f0fa8919 --- /dev/null +++ b/crates/miden-testing/tests/auth/network_account.rs @@ -0,0 +1,174 @@ +use core::slice; + +use miden_protocol::Word; +use miden_protocol::account::{Account, AccountBuilder, AccountType}; +use miden_protocol::note::NoteScriptRoot; +use miden_protocol::transaction::RawOutputNote; +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, +}; +use miden_standards::testing::note::NoteBuilder; +use miden_testing::{MockChain, assert_transaction_executor_error}; + +// 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. +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. +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(), + )?; + + Ok(AccountBuilder::new([0; 32]) + .with_auth_component(auth_component) + .with_component(BasicWallet) + .account_type(AccountType::Public) + .build_existing()?) +} + +// TESTS +// ================================================================================================ + +/// A transaction that executes a tx script must be rejected by `AuthNetworkAccount`, even if the +/// allowlist and input notes are otherwise valid. +#[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. + let account = build_allowlist_account(vec![placeholder_script_root()])?; + + let mut builder = MockChain::builder(); + builder.add_account(account.clone())?; + let mock_chain = builder.build()?; + + let tx_script = CodeBuilder::default().compile_tx_script("begin nop end")?; + + let result = mock_chain + .build_tx_context(account.id(), &[], &[])? + .tx_script(tx_script) + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_NOTE_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. +#[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(); + + // Build the real account with only that one root in the allowlist. + let account = build_allowlist_account(vec![allowed_root.into()])?; + + let mut builder = MockChain::builder(); + 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"); + assert_eq!( + note_allowed.script().root(), + allowed_root, + "default-code NoteBuilder should reproduce the allowed script root", + ); + + // Disallowed note: distinct code → distinct script root → not in the allowlist. + let note_disallowed = NoteBuilder::new(account.id(), &mut rand::rng()) + .code( + "\ + @note_script + pub proc main + push.1 drop + end + ", + ) + .build() + .expect("failed to build disallowed input note"); + assert_ne!( + note_disallowed.script().root(), + allowed_root, + "disallowed note must have a different script root than the allowed one", + ); + + builder.add_output_note(RawOutputNote::Full(note_allowed.clone())); + builder.add_output_note(RawOutputNote::Full(note_disallowed.clone())); + + let mock_chain = builder.build()?; + + let input_notes = [note_allowed, note_disallowed]; + let result = mock_chain + .build_tx_context(account.id(), &[], &input_notes)? + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_NOTE_SCRIPT_ALLOWLIST_NOTE_NOT_ALLOWED); + + Ok(()) +} + +/// 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" + ); + builder.add_output_note(RawOutputNote::Full(note.clone())); + + let mock_chain = builder.build()?; + + mock_chain + .build_tx_context(account.id(), &[], slice::from_ref(¬e))? + .build()? + .execute() + .await + .expect("consuming an allowed note should succeed"); + + Ok(()) +} diff --git a/crates/miden-testing/tests/auth/singlesig.rs b/crates/miden-testing/tests/auth/singlesig.rs new file mode 100644 index 0000000000..bdbba33eed --- /dev/null +++ b/crates/miden-testing/tests/auth/singlesig.rs @@ -0,0 +1,184 @@ +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, + AccountBuilder, + AccountComponent, + AccountStorage, + AccountType, +}; +use miden_protocol::errors::MasmError; +use miden_protocol::note::Note; +use miden_protocol::transaction::RawOutputNote; +use miden_standards::account::auth::AuthSingleSig; +use miden_standards::code_builder::CodeBuilder; +use miden_standards::testing::account_component::MockAccountComponent; +use miden_standards::testing::note::NoteBuilder; +use miden_testing::{Auth, MockChain, assert_transaction_executor_error}; +use miden_tx::TransactionExecutorError; +use miden_tx::auth::BasicAuthenticator; +use rand::SeedableRng; +use rand_chacha::ChaCha20Rng; +use rstest::rstest; + +// HELPER FUNCTIONS +// ================================================================================================ + +/// Sets up a singlesig account with a MockAccountComponent (which provides set_item). +/// Returns (account, mock_chain, note, authenticator). +fn setup_singlesig_with_mock_component( + auth_scheme: AuthScheme, +) -> anyhow::Result<(Account, MockChain, Note, Option)> { + let mock_component: AccountComponent = + MockAccountComponent::with_slots(AccountStorage::mock_storage_slots()).into(); + + let (auth_component, authenticator) = Auth::BasicAuth { auth_scheme }.build_component(); + + let account = AccountBuilder::new([0; 32]) + .with_auth_component(auth_component) + .with_component(mock_component) + .account_type(AccountType::Public) + .build_existing()?; + + let mut builder = MockChain::builder(); + builder.add_account(account.clone())?; + + // Create a mock note to consume (needed to make the transaction non-empty) + let note = NoteBuilder::new(account.id(), &mut rand::rng()) + .build() + .expect("failed to create mock note"); + builder.add_output_note(RawOutputNote::Full(note.clone())); + let mock_chain = builder.build()?; + + Ok((account, mock_chain, note, authenticator)) +} + +/// Tests that the singlesig auth procedure reads the initial (pre-rotation) public key +/// when verifying signatures. The transaction script overwrites the public key slot with +/// a bogus value before auth runs; the test verifies that authentication still succeeds +/// because the auth procedure uses `get_initial_item` to retrieve the original key, +/// rather than `get_item` which would return the overwritten (bogus) value. +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_singlesig_auth_uses_initial_public_key( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let (account, mock_chain, note, authenticator) = + setup_singlesig_with_mock_component(auth_scheme)?; + + let pub_key_slot = AuthSingleSig::public_key_slot(); + let tx_script_src = format!( + r#" + use mock::account + + const PUB_KEY_SLOT = word("{pub_key_slot}") + + begin + push.99.98.97.96 + push.PUB_KEY_SLOT[0..2] + call.account::set_item + dropw dropw + end + "#, + ); + + let tx_script = CodeBuilder::with_mock_libraries().compile_tx_script(tx_script_src)?; + let tx_context = mock_chain + .build_tx_context(account.id(), &[], slice::from_ref(¬e))? + .authenticator(authenticator) + .tx_script(tx_script) + .build()?; + + tx_context + .execute() + .await + .expect("singlesig auth should use initial public key, not the rotated one"); + + Ok(()) +} + +/// Rotated-key negative: tx rotates the pub-key slot to key B and the authenticator is set +/// up to sign with sec_b under key A's commitment. Auth reads the initial key (A) via +/// `get_initial_item`, so MASM verify must reject the bogus signature. +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_singlesig_auth_rejects_rotated_key_signature( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let (account, mock_chain, note, _) = setup_singlesig_with_mock_component(auth_scheme)?; + + // Re-derive key A from the seed Auth::BasicAuth uses. + let mut rng_a = ChaCha20Rng::from_seed(Default::default()); + let pub_key_a = AuthSecretKey::with_scheme_and_rng(auth_scheme, &mut rng_a) + .expect("failed to derive original public key") + .public_key(); + + let mut rng_b = ChaCha20Rng::from_seed([1u8; 32]); + let sec_key_b = AuthSecretKey::with_scheme_and_rng(auth_scheme, &mut rng_b) + .expect("failed to create second secret key"); + let pub_key_b_commitment: Word = sec_key_b.public_key().to_commitment().into(); + + // Bind sec_b to key A's commitment so MASM actually receives a signature and runs + // verify against pub A, which must reject it. + let authenticator = BasicAuthenticator::from_key_pairs(&[(sec_key_b, pub_key_a)]); + + let pub_key_slot = AuthSingleSig::public_key_slot(); + let tx_script_src = format!( + r#" + use mock::account + + const PUB_KEY_SLOT = word("{pub_key_slot}") + const NEW_PUB_KEY = word("{new_pub_key}") + + begin + push.NEW_PUB_KEY + push.PUB_KEY_SLOT[0..2] + call.account::set_item + dropw dropw + end + "#, + new_pub_key = pub_key_b_commitment, + ); + + let tx_script = CodeBuilder::with_mock_libraries().compile_tx_script(tx_script_src)?; + let tx_context = mock_chain + .build_tx_context(account.id(), &[], slice::from_ref(¬e))? + .authenticator(Some(authenticator)) + .tx_script(tx_script) + .build()?; + + let result = tx_context.execute().await; + + match auth_scheme { + AuthScheme::EcdsaK256Keccak => { + assert_transaction_executor_error!( + result, + MasmError::from_static_str("invalid public key commitment") + ); + }, + AuthScheme::Falcon512Poseidon2 => { + // Falcon's h-vs-PK check in `load_h_s2_and_product` is a bare `assert_eqw` + // without a named err, so we can only assert the failed-assertion shape. + assert_matches!( + result, + Err(TransactionExecutorError::TransactionProgramExecutionFailed( + ExecutionError::OperationError { + err: miden_processor::operation::OperationError::FailedAssertion { .. }, + .. + } + )) + ); + }, + _ => unreachable!("only the two rstest cases are parameterized"), + } + + Ok(()) +} diff --git a/crates/miden-testing/tests/auth/singlesig_acl.rs b/crates/miden-testing/tests/auth/singlesig_acl.rs index 04d97cd3d2..0b4ab5106c 100644 --- a/crates/miden-testing/tests/auth/singlesig_acl.rs +++ b/crates/miden-testing/tests/auth/singlesig_acl.rs @@ -1,15 +1,16 @@ use core::slice; use assert_matches::assert_matches; -use miden_protocol::account::auth::AuthScheme; +use miden_processor::ExecutionError; +use miden_protocol::account::auth::{AuthScheme, AuthSecretKey}; use miden_protocol::account::{ Account, AccountBuilder, AccountComponent, AccountStorage, - AccountStorageMode, AccountType, }; +use miden_protocol::errors::MasmError; use miden_protocol::note::Note; use miden_protocol::testing::storage::MOCK_VALUE_SLOT0; use miden_protocol::transaction::RawOutputNote; @@ -18,8 +19,11 @@ use miden_standards::account::auth::AuthSingleSigAcl; use miden_standards::code_builder::CodeBuilder; use miden_standards::testing::account_component::MockAccountComponent; use miden_standards::testing::note::NoteBuilder; -use miden_testing::{Auth, MockChain}; +use miden_testing::{Auth, MockChain, assert_transaction_executor_error}; use miden_tx::TransactionExecutorError; +use miden_tx::auth::BasicAuthenticator; +use rand::SeedableRng; +use rand_chacha::ChaCha20Rng; use rstest::rstest; use crate::prove_and_verify_transaction; @@ -67,8 +71,7 @@ fn setup_acl_test( let account = AccountBuilder::new([0; 32]) .with_auth_component(auth_component) .with_component(component) - .account_type(AccountType::RegularAccountUpdatableCode) - .storage_mode(AccountStorageMode::Public) + .account_type(AccountType::Public) .build_existing()?; let mut builder = MockChain::builder(); @@ -291,3 +294,146 @@ async fn test_acl_with_disallow_unauthorized_input_notes( Ok(()) } + +/// Tests that the singlesig ACL auth procedure reads the initial (pre-rotation) public key +/// when verifying signatures. The transaction script overwrites the public key slot with +/// a bogus value via `set_item` (which also triggers authentication); the test verifies +/// that authentication still succeeds because the auth procedure uses `get_initial_item` +/// to retrieve the original key, rather than `get_item` which would return the +/// overwritten (bogus) value. +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_acl_auth_uses_initial_public_key( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let (account, mock_chain, note) = setup_acl_test(false, true, auth_scheme)?; + + // Build the authenticator separately (same seed as Auth::Acl uses) + let component: AccountComponent = + MockAccountComponent::with_slots(AccountStorage::mock_storage_slots()).into(); + + let get_item_proc_root = component + .get_procedure_root_by_path("mock::account::get_item") + .expect("get_item procedure should exist"); + let set_item_proc_root = component + .get_procedure_root_by_path("mock::account::set_item") + .expect("set_item procedure should exist"); + let auth_trigger_procedures = vec![get_item_proc_root, set_item_proc_root]; + + let (_, authenticator) = Auth::Acl { + auth_trigger_procedures, + allow_unauthorized_output_notes: false, + allow_unauthorized_input_notes: true, + auth_scheme, + } + .build_component(); + + let pub_key_slot = AuthSingleSigAcl::public_key_slot(); + let tx_script_src = format!( + r#" + use mock::account + + const PUB_KEY_SLOT = word("{pub_key_slot}") + + begin + push.99.98.97.96 + push.PUB_KEY_SLOT[0..2] + call.account::set_item + dropw dropw + end + "#, + ); + + let tx_script = CodeBuilder::with_mock_libraries().compile_tx_script(tx_script_src)?; + let tx_context = mock_chain + .build_tx_context(account.id(), &[], slice::from_ref(¬e))? + .authenticator(authenticator) + .tx_script(tx_script) + .build()?; + + let executed_tx = tx_context + .execute() + .await + .expect("singlesig_acl auth should use initial public key, not the rotated one"); + + prove_and_verify_transaction(executed_tx).await?; + + Ok(()) +} + +/// Rotated-key negative (ACL): mirrors the singlesig version. `set_item` is a trigger +/// procedure so auth runs; the authenticator signs with sec_b under key A's commitment, and +/// MASM verify must reject the mismatched signature. +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_acl_auth_rejects_rotated_key_signature( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let (account, mock_chain, note) = setup_acl_test(false, true, auth_scheme)?; + + let mut rng_a = ChaCha20Rng::from_seed(Default::default()); + let pub_key_a = AuthSecretKey::with_scheme_and_rng(auth_scheme, &mut rng_a) + .expect("failed to derive original public key") + .public_key(); + + let mut rng_b = ChaCha20Rng::from_seed([1u8; 32]); + let sec_key_b = AuthSecretKey::with_scheme_and_rng(auth_scheme, &mut rng_b) + .expect("failed to create second secret key"); + let pub_key_b_commitment: Word = sec_key_b.public_key().to_commitment().into(); + + let authenticator = BasicAuthenticator::from_key_pairs(&[(sec_key_b, pub_key_a)]); + + let pub_key_slot = AuthSingleSigAcl::public_key_slot(); + let tx_script_src = format!( + r#" + use mock::account + + const PUB_KEY_SLOT = word("{pub_key_slot}") + const NEW_PUB_KEY = word("{new_pub_key}") + + begin + push.NEW_PUB_KEY + push.PUB_KEY_SLOT[0..2] + call.account::set_item + dropw dropw + end + "#, + new_pub_key = pub_key_b_commitment, + ); + + let tx_script = CodeBuilder::with_mock_libraries().compile_tx_script(tx_script_src)?; + let tx_context = mock_chain + .build_tx_context(account.id(), &[], slice::from_ref(¬e))? + .authenticator(Some(authenticator)) + .tx_script(tx_script) + .build()?; + + let result = tx_context.execute().await; + + match auth_scheme { + AuthScheme::EcdsaK256Keccak => { + assert_transaction_executor_error!( + result, + MasmError::from_static_str("invalid public key commitment") + ); + }, + AuthScheme::Falcon512Poseidon2 => { + assert_matches!( + result, + Err(TransactionExecutorError::TransactionProgramExecutionFailed( + ExecutionError::OperationError { + err: miden_processor::operation::OperationError::FailedAssertion { .. }, + .. + } + )) + ); + }, + _ => unreachable!("only the two rstest cases are parameterized"), + } + + Ok(()) +} diff --git a/crates/miden-testing/tests/lib.rs b/crates/miden-testing/tests/lib.rs index b27b9a00d0..f87dfcb1fb 100644 --- a/crates/miden-testing/tests/lib.rs +++ b/crates/miden-testing/tests/lib.rs @@ -9,7 +9,14 @@ use miden_protocol::Word; use miden_protocol::account::AccountId; use miden_protocol::asset::FungibleAsset; use miden_protocol::crypto::utils::Serializable; -use miden_protocol::note::{Note, NoteAssets, NoteMetadata, NoteRecipient, NoteStorage, NoteType}; +use miden_protocol::note::{ + Note, + NoteAssets, + NoteRecipient, + NoteStorage, + NoteType, + PartialNoteMetadata, +}; use miden_protocol::testing::account_id::ACCOUNT_ID_SENDER; use miden_protocol::transaction::{ExecutedTransaction, ProvenTransaction}; use miden_protocol::utils::serde::Deserializable; @@ -62,7 +69,7 @@ pub fn get_note_with_fungible_asset_and_script( let sender_id = AccountId::try_from(ACCOUNT_ID_SENDER).unwrap(); let vault = NoteAssets::new(vec![fungible_asset.into()]).unwrap(); - let metadata = NoteMetadata::new(sender_id, NoteType::Public).with_tag(1.into()); + let metadata = PartialNoteMetadata::new(sender_id, NoteType::Public).with_tag(1.into()); let inputs = NoteStorage::new(vec![]).unwrap(); let recipient = NoteRecipient::new(serial_num, note_script, inputs); diff --git a/crates/miden-testing/tests/scripts/allowlist.rs b/crates/miden-testing/tests/scripts/allowlist.rs new file mode 100644 index 0000000000..a87bfc142e --- /dev/null +++ b/crates/miden-testing/tests/scripts/allowlist.rs @@ -0,0 +1,505 @@ +//! Tests for the [`miden_standards::account::policies::BasicAllowlist`] transfer policy +//! component (storage + `check_policy` predicate) and the +//! [`miden_standards::account::policies::AllowlistOwnerControlled`] owner-controlled admin +//! component, dispatched directly by the protocol callback slots via +//! [`miden_standards::account::policies::TokenPolicyManager`]. + +extern crate alloc; + +use std::sync::Arc; + +use miden_processor::crypto::random::RandomCoin; +use miden_protocol::account::auth::AuthScheme; +use miden_protocol::account::{Account, AccountBuilder, AccountId, AccountIdVersion, AccountType}; +use miden_protocol::assembly::DefaultSourceManager; +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::faucets::{FungibleFaucet, TokenName}; +use miden_standards::account::policies::{ + AllowlistOwnerControlled, + AllowlistStorage, + BurnPolicyConfig, + MintPolicyConfig, + PolicyRegistration, + TokenPolicyManager, + TransferPolicy, +}; +use miden_standards::code_builder::CodeBuilder; +use miden_standards::errors::standards::ERR_ACCOUNT_IS_NOT_ALLOWED; +use miden_standards::testing::note::NoteBuilder; +use miden_testing::{ + AccountState, + Auth, + MockChain, + MockChainBuilder, + assert_transaction_executor_error, +}; + +// HELPERS +// ================================================================================================ + +fn dummy_owner() -> AccountId { + AccountId::dummy([9; 15], AccountIdVersion::Version1, AccountType::Private) +} + +/// Builds a fungible faucet with [`TransferPolicy::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. +/// +/// The faucet starts with an empty allowlist — every transfer (and every mint that emits a +/// note) will fail until the owner calls `allow_account` to add the relevant accounts. +fn add_faucet_with_owner_allowlist_transfer( + builder: &mut MockChainBuilder, + owner_id: AccountId, +) -> anyhow::Result { + add_faucet_with_owner_allowlist_transfer_initialized(builder, owner_id, []) +} + +/// Same as [`add_faucet_with_owner_allowlist_transfer`] but seeds the `allowed_accounts` +/// storage map with the given accounts. +fn add_faucet_with_owner_allowlist_transfer_initialized( + builder: &mut MockChainBuilder, + owner_id: AccountId, + initial_allowed: impl IntoIterator, +) -> 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 allow_list = AllowlistStorage::with_allowed_accounts(initial_allowed); + + 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::Allowlist { allow_list: allow_list.clone() }, + PolicyRegistration::Active, + )? + .with_receive_policy( + TransferPolicy::Allowlist { allow_list }, + PolicyRegistration::Active, + )?, + ) + .with_component(AllowlistOwnerControlled); + + builder.add_account_from_builder( + Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + }, + account_builder, + AccountState::Exists, + ) +} + +fn account_id_felts(account_id: AccountId) -> (Felt, Felt) { + let [prefix, suffix]: [Felt; 2] = account_id.into(); + (prefix, suffix) +} + +/// Builds an owner-authored note whose script invokes +/// `owner_controlled::{allow_account|disallow_account}` on the given target account. +fn build_owner_admin_note( + owner_id: AccountId, + target_id: AccountId, + proc: &str, + rng_seed: u32, +) -> anyhow::Result { + let (prefix, suffix) = account_id_felts(target_id); + let script_code = format!( + r#" + use miden::standards::faucets::policies::transfer::allowlist::owner_controlled + + @note_script + pub proc main + padw padw padw push.0.0 + + push.{prefix} + push.{suffix} + call.owner_controlled::{proc} + + dropw dropw dropw dropw + end + "# + ); + + let mut rng = RandomCoin::new([Felt::from(rng_seed); 4].into()); + NoteBuilder::new(owner_id, &mut rng) + .note_type(NoteType::Private) + .code(script_code.as_str()) + .build() + .map_err(Into::into) +} + +/// Consumes an owner-authored admin note in a faucet transaction. +async fn consume_admin_note( + mock_chain: &mut MockChain, + faucet_id: AccountId, + note: &Note, +) -> anyhow::Result<()> { + let source_manager = Arc::new(DefaultSourceManager::default()); + let executed = mock_chain + .build_tx_context(faucet_id, &[note.id()], &[])? + .with_source_manager(source_manager) + .build()? + .execute() + .await?; + mock_chain.add_pending_executed_transaction(&executed)?; + mock_chain.prove_next_block()?; + Ok(()) +} + +// TESTS +// ================================================================================================ + +/// Seeds [`BasicAllowlist`] with the recipient at deploy time and confirms the asset transfer +/// succeeds — no `allow_account` admin call is needed because the account starts in the +/// `allowed_accounts` map. +#[tokio::test] +async fn allow_receive_asset_succeeds_when_account_pre_allowed() -> anyhow::Result<()> { + let owner_id = dummy_owner(); + let mut builder = MockChain::builder(); + let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; + let faucet = add_faucet_with_owner_allowlist_transfer_initialized( + &mut builder, + owner_id, + [target_account.id()], + )?; + + let asset = FungibleAsset::new(faucet.id(), 100)?.with_callbacks(AssetCallbackFlag::Enabled); + let note = builder.add_p2id_note( + faucet.id(), + target_account.id(), + &[Asset::Fungible(asset)], + NoteType::Public, + )?; + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + let faucet_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; + + mock_chain + .build_tx_context(target_account.id(), &[note.id()], &[])? + .foreign_accounts(vec![faucet_inputs]) + .build()? + .execute() + .await?; + + Ok(()) +} + +#[tokio::test] +async fn allow_receive_asset_fails_when_recipient_not_allowed() -> anyhow::Result<()> { + let owner_id = dummy_owner(); + let mut builder = MockChain::builder(); + 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 p2id_note = builder.add_p2id_note( + faucet.id(), + target_account.id(), + &[Asset::Fungible(asset)], + NoteType::Public, + )?; + + let mock_chain = builder.build()?; + let faucet_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; + + let result = mock_chain + .build_tx_context(target_account.id(), &[p2id_note.id()], &[])? + .foreign_accounts(vec![faucet_inputs]) + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_ACCOUNT_IS_NOT_ALLOWED); + + Ok(()) +} + +#[tokio::test] +async fn allow_then_receive_succeeds() -> anyhow::Result<()> { + let owner_id = dummy_owner(); + let mut builder = MockChain::builder(); + 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 p2id_note = builder.add_p2id_note( + faucet.id(), + target_account.id(), + &[Asset::Fungible(asset)], + NoteType::Public, + )?; + + let allow_note = build_owner_admin_note(owner_id, target_account.id(), "allow_account", 1)?; + builder.add_output_note(RawOutputNote::Full(allow_note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + consume_admin_note(&mut mock_chain, faucet.id(), &allow_note).await?; + + let faucet_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; + + mock_chain + .build_tx_context(target_account.id(), &[p2id_note.id()], &[])? + .foreign_accounts(vec![faucet_inputs]) + .build()? + .execute() + .await?; + + Ok(()) +} + +#[tokio::test] +async fn allow_add_asset_to_note_fails_when_sender_not_allowed() -> anyhow::Result<()> { + let owner_id = dummy_owner(); + let mut builder = MockChain::builder(); + 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 mock_chain = builder.build()?; + + let recipient = Word::from([0u32, 1, 2, 3]); + let script_code = format!( + r#" + use miden::protocol::output_note + + begin + push.{recipient} + push.{note_type} + push.{tag} + exec.output_note::create + + push.{asset_value} + push.{asset_key} + exec.output_note::add_asset + end + "#, + recipient = recipient, + note_type = NoteType::Private as u8, + tag = NoteTag::default(), + asset_value = Asset::Fungible(asset).to_value_word(), + asset_key = Asset::Fungible(asset).to_key_word(), + ); + + let tx_script = CodeBuilder::with_mock_libraries().compile_tx_script(&script_code)?; + + let faucet_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; + + let result = mock_chain + .build_tx_context(target_account.id(), &[], &[])? + .tx_script(tx_script) + .foreign_accounts(vec![faucet_inputs]) + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_ACCOUNT_IS_NOT_ALLOWED); + + Ok(()) +} + +#[tokio::test] +async fn allow_then_disallow_blocks_subsequent_receive() -> anyhow::Result<()> { + let owner_id = dummy_owner(); + let mut builder = MockChain::builder(); + let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; + let faucet = add_faucet_with_owner_allowlist_transfer_initialized( + &mut builder, + owner_id, + [target_account.id()], + )?; + + let amount: u64 = 50; + let fungible_asset = + FungibleAsset::new(faucet.id(), amount)?.with_callbacks(AssetCallbackFlag::Enabled); + let p2id_note = builder.add_p2id_note( + faucet.id(), + target_account.id(), + &[Asset::Fungible(fungible_asset)], + NoteType::Public, + )?; + + let disallow_note = + build_owner_admin_note(owner_id, target_account.id(), "disallow_account", 3)?; + builder.add_output_note(RawOutputNote::Full(disallow_note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + consume_admin_note(&mut mock_chain, faucet.id(), &disallow_note).await?; + + let faucet_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; + + let result = mock_chain + .build_tx_context(target_account.id(), &[p2id_note.id()], &[])? + .foreign_accounts(vec![faucet_inputs]) + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_ACCOUNT_IS_NOT_ALLOWED); + + Ok(()) +} + +#[tokio::test] +async fn allow_already_allowed_is_noop() -> anyhow::Result<()> { + let owner_id = dummy_owner(); + let mut builder = MockChain::builder(); + let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; + let faucet = add_faucet_with_owner_allowlist_transfer(&mut builder, owner_id)?; + + let allow_note_1 = build_owner_admin_note(owner_id, target_account.id(), "allow_account", 5)?; + let allow_note_2 = build_owner_admin_note(owner_id, target_account.id(), "allow_account", 6)?; + builder.add_output_note(RawOutputNote::Full(allow_note_1.clone())); + builder.add_output_note(RawOutputNote::Full(allow_note_2.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + consume_admin_note(&mut mock_chain, faucet.id(), &allow_note_1).await?; + + // Second allow on the same already-allowed user is a noop — succeeds silently. + consume_admin_note(&mut mock_chain, faucet.id(), &allow_note_2).await?; + + Ok(()) +} + +#[tokio::test] +async fn disallow_when_not_allowed_is_noop() -> anyhow::Result<()> { + let owner_id = dummy_owner(); + let mut builder = MockChain::builder(); + let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; + let faucet = add_faucet_with_owner_allowlist_transfer(&mut builder, owner_id)?; + + let disallow_note = + build_owner_admin_note(owner_id, target_account.id(), "disallow_account", 7)?; + builder.add_output_note(RawOutputNote::Full(disallow_note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + // Disallowing a non-allowed account is a noop — succeeds silently. + consume_admin_note(&mut mock_chain, faucet.id(), &disallow_note).await?; + + Ok(()) +} + +#[tokio::test] +async fn allow_does_not_affect_other_accounts() -> anyhow::Result<()> { + let owner_id = dummy_owner(); + let mut builder = MockChain::builder(); + let allowed_account = builder.add_existing_wallet(Auth::IncrNonce)?; + let other_account = builder.add_existing_wallet(Auth::IncrNonce)?; + 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 p2id_note = builder.add_p2id_note( + faucet.id(), + other_account.id(), + &[Asset::Fungible(fungible_asset)], + NoteType::Public, + )?; + + // Allow one account; the other should still be rejected (default-deny). + let allow_note = build_owner_admin_note(owner_id, allowed_account.id(), "allow_account", 8)?; + builder.add_output_note(RawOutputNote::Full(allow_note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + consume_admin_note(&mut mock_chain, faucet.id(), &allow_note).await?; + + let faucet_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; + + let result = mock_chain + .build_tx_context(other_account.id(), &[p2id_note.id()], &[])? + .foreign_accounts(vec![faucet_inputs]) + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_ACCOUNT_IS_NOT_ALLOWED); + + Ok(()) +} + +/// 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. +#[tokio::test] +async fn mint_and_send_on_allowlist_basic_faucet() -> anyhow::Result<()> { + let owner_id = dummy_owner(); + let mut builder = MockChain::builder(); + let faucet = add_faucet_with_owner_allowlist_transfer(&mut builder, owner_id)?; + + // The send policy is invoked from `on_before_asset_added_to_note`, where the native + // account is the note creator (the faucet itself when minting). Seed the faucet's own + // ID into the allowlist via an admin note so the mint can proceed. + let allow_faucet_note = build_owner_admin_note(owner_id, faucet.id(), "allow_account", 9)?; + builder.add_output_note(RawOutputNote::Full(allow_faucet_note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + consume_admin_note(&mut mock_chain, faucet.id(), &allow_faucet_note).await?; + + let recipient = Word::from([0u32, 1, 2, 3]); + let amount: u64 = 100; + let tag = NoteTag::default(); + let note_type = NoteType::Private; + + let tx_script_code = format!( + r#" + begin + push.0 push.0 + + push.{recipient} + push.{note_type} + push.{tag} + push.{amount} + + exec.::miden::protocol::faucet::create_fungible_asset + + call.::miden::standards::faucets::fungible::mint_and_send + + dropw dropw dropw dropw + end + "#, + recipient = recipient, + note_type = note_type as u8, + tag = u32::from(tag), + amount = amount, + ); + + let tx_script = CodeBuilder::default().compile_tx_script(&tx_script_code)?; + let executed = mock_chain + .build_tx_context(faucet.id(), &[], &[])? + .tx_script(tx_script) + .build()? + .execute() + .await?; + + assert_eq!(executed.output_notes().num_notes(), 1); + Ok(()) +} diff --git a/crates/miden-testing/tests/scripts/blocklist.rs b/crates/miden-testing/tests/scripts/blocklist.rs new file mode 100644 index 0000000000..cc1236d7ab --- /dev/null +++ b/crates/miden-testing/tests/scripts/blocklist.rs @@ -0,0 +1,501 @@ +//! Tests for the [`miden_standards::account::policies::BasicBlocklist`] transfer policy +//! component (storage + `check_policy` predicate) and the +//! [`miden_standards::account::policies::BlocklistOwnerControlled`] owner-controlled admin +//! component, dispatched directly by the protocol callback slots via +//! [`miden_standards::account::policies::TokenPolicyManager`]. + +extern crate alloc; + +use std::sync::Arc; + +use miden_processor::crypto::random::RandomCoin; +use miden_protocol::account::auth::AuthScheme; +use miden_protocol::account::{Account, AccountBuilder, AccountId, AccountIdVersion, AccountType}; +use miden_protocol::assembly::DefaultSourceManager; +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::faucets::{FungibleFaucet, TokenName}; +use miden_standards::account::policies::{ + BasicBlocklist, + BlocklistOwnerControlled, + BurnPolicyConfig, + MintPolicyConfig, + PolicyRegistration, + TokenPolicyManager, + TransferPolicy, +}; +use miden_standards::code_builder::CodeBuilder; +use miden_standards::errors::standards::ERR_ACCOUNT_IS_BLOCKED; +use miden_standards::testing::note::NoteBuilder; +use miden_testing::{ + AccountState, + Auth, + MockChain, + MockChainBuilder, + assert_transaction_executor_error, +}; + +// HELPERS +// ================================================================================================ + +fn dummy_owner() -> AccountId { + AccountId::dummy([9; 15], AccountIdVersion::Version1, AccountType::Private) +} + +/// Builds a fungible faucet with [`TransferPolicy::Blocklist`] 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( + builder: &mut MockChainBuilder, + owner_id: AccountId, +) -> anyhow::Result { + add_faucet_with_owner_blocklist_transfer_initialized(builder, owner_id, []) +} + +/// 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). +fn add_faucet_with_owner_blocklist_transfer_initialized( + builder: &mut MockChainBuilder, + owner_id: AccountId, + initial_blocked: impl IntoIterator, +) -> 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 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, + )?, + ) + .with_component(basic_blocklist) + .with_component(BlocklistOwnerControlled); + + builder.add_account_from_builder( + Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + }, + account_builder, + AccountState::Exists, + ) +} + +fn account_id_felts(account_id: AccountId) -> (Felt, Felt) { + let [prefix, suffix]: [Felt; 2] = account_id.into(); + (prefix, suffix) +} + +/// Builds an owner-authored note whose script invokes +/// `owner_controlled::{block_account|unblock_account}` on the given target account. +fn build_owner_admin_note( + owner_id: AccountId, + target_id: AccountId, + proc: &str, + rng_seed: u32, +) -> anyhow::Result { + let (prefix, suffix) = account_id_felts(target_id); + let script_code = format!( + r#" + use miden::standards::faucets::policies::transfer::blocklist::owner_controlled + + @note_script + pub proc main + padw padw padw push.0.0 + + push.{prefix} + push.{suffix} + call.owner_controlled::{proc} + + dropw dropw dropw dropw + end + "# + ); + + let mut rng = RandomCoin::new([Felt::from(rng_seed); 4].into()); + NoteBuilder::new(owner_id, &mut rng) + .note_type(NoteType::Private) + .code(script_code.as_str()) + .build() + .map_err(Into::into) +} + +/// Consumes an owner-authored admin note in a faucet transaction. +async fn consume_admin_note( + mock_chain: &mut MockChain, + faucet_id: AccountId, + note: &Note, +) -> anyhow::Result<()> { + let source_manager = Arc::new(DefaultSourceManager::default()); + let executed = mock_chain + .build_tx_context(faucet_id, &[note.id()], &[])? + .with_source_manager(source_manager) + .build()? + .execute() + .await?; + mock_chain.add_pending_executed_transaction(&executed)?; + mock_chain.prove_next_block()?; + Ok(()) +} + +// TESTS +// ================================================================================================ + +#[tokio::test] +async fn block_receive_asset_succeeds_when_not_blocked() -> anyhow::Result<()> { + let owner_id = dummy_owner(); + let mut builder = MockChain::builder(); + 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 note = builder.add_p2id_note( + faucet.id(), + target_account.id(), + &[Asset::Fungible(asset)], + NoteType::Public, + )?; + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + let faucet_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; + + mock_chain + .build_tx_context(target_account.id(), &[note.id()], &[])? + .foreign_accounts(vec![faucet_inputs]) + .build()? + .execute() + .await?; + + Ok(()) +} + +/// Seeds [`BasicBlocklist`] with the recipient at deploy time and confirms the asset transfer +/// fails immediately — no `block_account` admin call is needed because the account starts in +/// the `blocked_accounts` map. +#[tokio::test] +async fn block_receive_asset_fails_when_account_pre_blocked() -> anyhow::Result<()> { + let owner_id = dummy_owner(); + let mut builder = MockChain::builder(); + let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; + let faucet = add_faucet_with_owner_blocklist_transfer_initialized( + &mut builder, + owner_id, + [target_account.id()], + )?; + + let asset = FungibleAsset::new(faucet.id(), 100)?.with_callbacks(AssetCallbackFlag::Enabled); + let p2id_note = builder.add_p2id_note( + faucet.id(), + target_account.id(), + &[Asset::Fungible(asset)], + NoteType::Public, + )?; + + let mock_chain = builder.build()?; + let faucet_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; + + let result = mock_chain + .build_tx_context(target_account.id(), &[p2id_note.id()], &[])? + .foreign_accounts(vec![faucet_inputs]) + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_ACCOUNT_IS_BLOCKED); + + Ok(()) +} + +#[tokio::test] +async fn block_receive_asset_fails_when_recipient_blocked() -> anyhow::Result<()> { + let owner_id = dummy_owner(); + let mut builder = MockChain::builder(); + 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 p2id_note = builder.add_p2id_note( + faucet.id(), + target_account.id(), + &[Asset::Fungible(asset)], + NoteType::Public, + )?; + + let block_note = build_owner_admin_note(owner_id, target_account.id(), "block_account", 1)?; + builder.add_output_note(RawOutputNote::Full(block_note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + consume_admin_note(&mut mock_chain, faucet.id(), &block_note).await?; + + let faucet_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; + + let result = mock_chain + .build_tx_context(target_account.id(), &[p2id_note.id()], &[])? + .foreign_accounts(vec![faucet_inputs]) + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_ACCOUNT_IS_BLOCKED); + + Ok(()) +} + +#[tokio::test] +async fn block_add_asset_to_note_fails_when_sender_blocked() -> anyhow::Result<()> { + let owner_id = dummy_owner(); + let mut builder = MockChain::builder(); + 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 block_note = build_owner_admin_note(owner_id, target_account.id(), "block_account", 2)?; + builder.add_output_note(RawOutputNote::Full(block_note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + consume_admin_note(&mut mock_chain, faucet.id(), &block_note).await?; + + let recipient = Word::from([0u32, 1, 2, 3]); + let script_code = format!( + r#" + use miden::protocol::output_note + + begin + push.{recipient} + push.{note_type} + push.{tag} + exec.output_note::create + + push.{asset_value} + push.{asset_key} + exec.output_note::add_asset + end + "#, + recipient = recipient, + note_type = NoteType::Private as u8, + tag = NoteTag::default(), + asset_value = Asset::Fungible(asset).to_value_word(), + asset_key = Asset::Fungible(asset).to_key_word(), + ); + + let tx_script = CodeBuilder::with_mock_libraries().compile_tx_script(&script_code)?; + + let faucet_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; + + let result = mock_chain + .build_tx_context(target_account.id(), &[], &[])? + .tx_script(tx_script) + .foreign_accounts(vec![faucet_inputs]) + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_ACCOUNT_IS_BLOCKED); + + Ok(()) +} + +#[tokio::test] +async fn block_then_unblock_then_receive_succeeds() -> anyhow::Result<()> { + let owner_id = dummy_owner(); + let mut builder = MockChain::builder(); + let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; + 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 p2id_note = builder.add_p2id_note( + faucet.id(), + target_account.id(), + &[Asset::Fungible(fungible_asset)], + NoteType::Public, + )?; + + let block_note = build_owner_admin_note(owner_id, target_account.id(), "block_account", 3)?; + let unblock_note = build_owner_admin_note(owner_id, target_account.id(), "unblock_account", 4)?; + builder.add_output_note(RawOutputNote::Full(block_note.clone())); + builder.add_output_note(RawOutputNote::Full(unblock_note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + consume_admin_note(&mut mock_chain, faucet.id(), &block_note).await?; + consume_admin_note(&mut mock_chain, faucet.id(), &unblock_note).await?; + + let faucet_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; + + mock_chain + .build_tx_context(target_account.id(), &[p2id_note.id()], &[])? + .foreign_accounts(vec![faucet_inputs]) + .build()? + .execute() + .await?; + + Ok(()) +} + +#[tokio::test] +async fn block_already_blocked_is_noop() -> anyhow::Result<()> { + let owner_id = dummy_owner(); + let mut builder = MockChain::builder(); + let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; + let faucet = add_faucet_with_owner_blocklist_transfer(&mut builder, owner_id)?; + + let block_note_1 = build_owner_admin_note(owner_id, target_account.id(), "block_account", 5)?; + let block_note_2 = build_owner_admin_note(owner_id, target_account.id(), "block_account", 6)?; + builder.add_output_note(RawOutputNote::Full(block_note_1.clone())); + builder.add_output_note(RawOutputNote::Full(block_note_2.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + consume_admin_note(&mut mock_chain, faucet.id(), &block_note_1).await?; + + // Second block on the same already-blocked user is a noop — succeeds silently. + consume_admin_note(&mut mock_chain, faucet.id(), &block_note_2).await?; + + Ok(()) +} + +#[tokio::test] +async fn unblock_when_not_blocked_is_noop() -> anyhow::Result<()> { + let owner_id = dummy_owner(); + let mut builder = MockChain::builder(); + let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; + let faucet = add_faucet_with_owner_blocklist_transfer(&mut builder, owner_id)?; + + let unblock_note = build_owner_admin_note(owner_id, target_account.id(), "unblock_account", 7)?; + builder.add_output_note(RawOutputNote::Full(unblock_note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + // Unblocking a non-blocked account is a noop — succeeds silently. + consume_admin_note(&mut mock_chain, faucet.id(), &unblock_note).await?; + + Ok(()) +} + +#[tokio::test] +async fn block_does_not_affect_other_accounts() -> anyhow::Result<()> { + let owner_id = dummy_owner(); + let mut builder = MockChain::builder(); + let blocked_account = builder.add_existing_wallet(Auth::IncrNonce)?; + let other_account = builder.add_existing_wallet(Auth::IncrNonce)?; + 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 p2id_note = builder.add_p2id_note( + faucet.id(), + other_account.id(), + &[Asset::Fungible(fungible_asset)], + NoteType::Public, + )?; + + // Block a different account — the non-blocked one should still receive. + let block_note = build_owner_admin_note(owner_id, blocked_account.id(), "block_account", 8)?; + builder.add_output_note(RawOutputNote::Full(block_note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + consume_admin_note(&mut mock_chain, faucet.id(), &block_note).await?; + + let faucet_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; + + mock_chain + .build_tx_context(other_account.id(), &[p2id_note.id()], &[])? + .foreign_accounts(vec![faucet_inputs]) + .build()? + .execute() + .await?; + + Ok(()) +} + +/// Verifies that `mint_and_send` works on a `BasicFungibleFaucet` whose `TokenPolicyManager` +/// installs the asset-callback slots (here via [`TransferPolicy::Blocklist`]). +#[tokio::test] +async fn mint_and_send_on_blocklist_basic_faucet() -> anyhow::Result<()> { + let owner_id = dummy_owner(); + let mut builder = MockChain::builder(); + let faucet = add_faucet_with_owner_blocklist_transfer(&mut builder, owner_id)?; + let mock_chain = builder.build()?; + + let recipient = Word::from([0u32, 1, 2, 3]); + let amount: u64 = 100; + let tag = NoteTag::default(); + 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_key = asset.to_key_word(); + let asset_value = asset.to_value_word(); + + let tx_script_code = format!( + r#" + begin + push.0.0 + + push.{recipient} + push.{note_type} + push.{tag} + push.{asset_value} + push.{asset_key} + + call.::miden::standards::faucets::fungible::mint_and_send + + dropw dropw dropw dropw + end + "#, + recipient = recipient, + note_type = note_type as u8, + tag = u32::from(tag), + asset_value = asset_value, + asset_key = asset_key, + ); + + let tx_script = CodeBuilder::default().compile_tx_script(&tx_script_code)?; + let executed = mock_chain + .build_tx_context(faucet.id(), &[], &[])? + .tx_script(tx_script) + .build()? + .execute() + .await?; + + assert_eq!(executed.output_notes().num_notes(), 1); + Ok(()) +} diff --git a/crates/miden-testing/tests/scripts/faucet.rs b/crates/miden-testing/tests/scripts/faucet.rs index ccfc966861..c15b364d1d 100644 --- a/crates/miden-testing/tests/scripts/faucet.rs +++ b/crates/miden-testing/tests/scripts/faucet.rs @@ -2,41 +2,44 @@ extern crate alloc; use alloc::sync::Arc; use core::slice; +use std::collections::BTreeSet; use miden_processor::crypto::random::RandomCoin; use miden_protocol::account::auth::AuthScheme; -use miden_protocol::account::{ - Account, - AccountId, - AccountIdVersion, - AccountStorageMode, - AccountType, -}; +use miden_protocol::account::{Account, AccountBuilder, AccountId, AccountIdVersion, AccountType}; use miden_protocol::assembly::DefaultSourceManager; -use miden_protocol::asset::{Asset, FungibleAsset}; +use miden_protocol::asset::{Asset, AssetAmount, AssetCallbackFlag, FungibleAsset, TokenSymbol}; use miden_protocol::note::{ Note, NoteAssets, - NoteAttachment, + NoteAttachments, + NoteDetailsCommitment, NoteId, NoteMetadata, NoteRecipient, + NoteScript, NoteStorage, NoteTag, NoteType, + PartialNoteMetadata, }; 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::Ownable2Step; -use miden_standards::account::faucets::{ - BasicFungibleFaucet, - NetworkFungibleFaucet, - TokenMetadata, +use miden_standards::account::access::{Authority, Ownable2Step}; +use miden_standards::account::faucets::{FungibleFaucet, TokenName}; +use miden_standards::account::policies::{ + BurnAllowAll, + BurnOwnerOnly, + BurnPolicyConfig, + MintPolicyConfig, + PolicyRegistration, + TokenPolicyManager, + TransferPolicy, }; -use miden_standards::account::mint_policies::OwnerControlledInitConfig; use miden_standards::code_builder::CodeBuilder; use miden_standards::errors::standards::{ + ERR_BURN_POLICY_ROOT_NOT_ALLOWED, ERR_FAUCET_BURN_AMOUNT_EXCEEDS_TOKEN_SUPPLY, ERR_FUNGIBLE_ASSET_DISTRIBUTE_AMOUNT_EXCEEDS_MAX_SUPPLY, ERR_MINT_POLICY_ROOT_NOT_ALLOWED, @@ -45,7 +48,15 @@ use miden_standards::errors::standards::{ use miden_standards::note::{BurnNote, MintNote, MintNoteStorage, StandardNote}; use miden_standards::testing::note::NoteBuilder; use miden_testing::utils::create_p2id_note_exact; -use miden_testing::{Auth, MockChain, assert_transaction_executor_error}; +use miden_testing::{ + AccountState, + Auth, + MockChain, + MockChainBuilder, + assert_note_created, + assert_transaction_executor_error, +}; +use rand::Rng; use crate::{get_note_with_fungible_asset_and_script, prove_and_verify_transaction}; @@ -61,20 +72,28 @@ pub struct FaucetTestParams { } /// Creates minting script code for fungible asset distribution -pub fn create_mint_script_code(params: &FaucetTestParams) -> String { +pub fn create_mint_script_code(params: &FaucetTestParams, faucet_id: AccountId) -> String { format!( " begin - # pad the stack before call - padw padw push.0 - push.{recipient} push.{note_type} push.{tag} push.{amount} - # => [amount, tag, note_type, RECIPIENT, pad(9)] - - call.::miden::standards::faucets::basic_fungible::mint_and_send + push.{faucet_id_prefix} + push.{faucet_id_suffix} + push.1 + # => [enable_callbacks=1, faucet_id_suffix, faucet_id_prefix, amount, tag, note_type, RECIPIENT, ...] + # `enable_callbacks=1` matches the faucet's storage state under the M-01 fix: + # AllowAll transfer policies register the protocol callback slots, so + # `fungible::mint_and_send` derives the asset with `has_callbacks=true` and + # the input ASSET_KEY must carry the same flag for the binding check (#2911) + # to pass. + + exec.::miden::protocol::asset::create_fungible_asset + # => [ASSET_KEY, ASSET_VALUE, tag, note_type, RECIPIENT, ...] + + call.::miden::standards::faucets::fungible::mint_and_send # => [note_idx, pad(15)] # truncate the stack @@ -85,6 +104,8 @@ pub fn create_mint_script_code(params: &FaucetTestParams) -> String { recipient = params.recipient, tag = u32::from(params.tag), amount = params.amount, + faucet_id_suffix = faucet_id.suffix(), + faucet_id_prefix = faucet_id.prefix().as_felt(), ) } @@ -95,7 +116,7 @@ pub async fn execute_mint_transaction( params: &FaucetTestParams, ) -> anyhow::Result { let source_manager = Arc::new(DefaultSourceManager::default()); - let tx_script_code = create_mint_script_code(params); + let tx_script_code = create_mint_script_code(params, faucet.id()); let tx_script = CodeBuilder::with_source_manager(source_manager.clone()) .compile_tx_script(tx_script_code)?; let tx_context = mock_chain @@ -113,27 +134,36 @@ pub fn verify_minted_output_note( faucet: &Account, params: &FaucetTestParams, ) -> anyhow::Result<()> { - let fungible_asset: Asset = - FungibleAsset::new(faucet.id(), params.amount.as_canonical_u64())?.into(); - 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 assets = NoteAssets::new(vec![fungible_asset])?; - let id = NoteId::new(params.recipient, assets.commitment()); + + let partial_metadata = + PartialNoteMetadata::new(faucet.id(), params.note_type).with_tag(params.tag); + let metadata = NoteMetadata::new(partial_metadata, &NoteAttachments::default()); + let details_commitment = + NoteDetailsCommitment::from_raw_commitments(params.recipient, assets.commitment()); + + let id = NoteId::new(details_commitment, &metadata); assert_eq!(output_note.id(), id); - assert_eq!( - output_note.metadata(), - &NoteMetadata::new(faucet.id(), params.note_type).with_tag(params.tag) - ); + assert_eq!(output_note.metadata().partial_metadata(), &partial_metadata); Ok(()) } +fn compile_note_script(code: &str) -> anyhow::Result { + Ok(CodeBuilder::default().compile_note_script(code)?) +} + async fn execute_faucet_note_script( mock_chain: &MockChain, faucet_id: AccountId, sender_account_id: AccountId, - note_script_code: &str, + note_script: NoteScript, rng_seed: u32, ) -> anyhow::Result> { let source_manager = Arc::new(DefaultSourceManager::default()); @@ -141,7 +171,7 @@ async fn execute_faucet_note_script( let mut rng = RandomCoin::new([Felt::from(rng_seed); 4].into()); let note = NoteBuilder::new(sender_account_id, &mut rng) .note_type(NoteType::Private) - .code(note_script_code) + .script(note_script) .build()?; let tx_context = mock_chain @@ -152,6 +182,65 @@ async fn execute_faucet_note_script( Ok(tx_context.execute().await) } +fn create_set_burn_policy_note_script(policy_root: Word) -> String { + format!( + r#" + use miden::standards::faucets::policies::policy_manager + + @note_script + pub proc main + padw padw padw + push.{policy_root} + call.policy_manager::set_burn_policy + dropw 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 +/// additionally registers `BurnOwnerOnly::root()` in the allowed-policies map; both +/// `BurnAllowAll` and `BurnOwnerOnly` policy components are installed alongside it. This is +/// the explicit setup required for tests that exercise `set_burn_policy` switching. +fn build_network_faucet_with_burn_switching( + builder: &mut MockChainBuilder, + token_symbol: &str, + max_supply: u64, + owner: AccountId, + token_supply: u64, + mint_policy: MintPolicyConfig, +) -> 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 faucet = FungibleFaucet::builder() + .name(name) + .symbol(symbol) + .decimals(10) + .max_supply(max_supply) + .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 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); + + builder.add_account_from_builder(Auth::IncrNonce, account_builder, AccountState::Exists) +} + // TESTS MINT FUNGIBLE ASSET // ================================================================================================ @@ -173,7 +262,7 @@ async fn minting_fungible_asset_on_existing_faucet_succeeds() -> anyhow::Result< recipient: Word::from([0, 1, 2, 3u32]), tag: NoteTag::default(), note_type: NoteType::Private, - amount: Felt::new(100), + amount: Felt::new_unchecked(100), }; let executed_transaction = @@ -200,22 +289,25 @@ async fn faucet_contract_mint_fungible_asset_fails_exceeds_max_supply() -> anyho let mock_chain = builder.build()?; let recipient = Word::from([0, 1, 2, 3u32]); - let tag = Felt::new(4); - let amount = Felt::new(250); + let tag = Felt::new_unchecked(4); + let amount = Felt::new_unchecked(250); let tx_script_code = format!( " begin - # pad the stack before call - padw padw push.0 - push.{recipient} push.{note_type} push.{tag} push.{amount} - # => [amount, tag, note_type, RECIPIENT, pad(9)] + push.{faucet_id_prefix} + push.{faucet_id_suffix} + push.0 + # => [0, faucet_id_suffix, faucet_id_prefix, amount, tag, note_type, RECIPIENT, ...] + + exec.::miden::protocol::asset::create_fungible_asset + # => [ASSET_KEY, ASSET_VALUE, tag, note_type, RECIPIENT, ...] - call.::miden::standards::faucets::basic_fungible::mint_and_send + call.::miden::standards::faucets::fungible::mint_and_send # => [note_idx, pad(15)] # truncate the stack @@ -225,6 +317,8 @@ async fn faucet_contract_mint_fungible_asset_fails_exceeds_max_supply() -> anyho ", note_type = NoteType::Private as u8, recipient = recipient, + faucet_id_suffix = faucet.id().suffix(), + faucet_id_prefix = faucet.id().prefix().as_felt(), ); let tx_script = CodeBuilder::default().compile_tx_script(tx_script_code)?; @@ -259,7 +353,7 @@ async fn minting_fungible_asset_on_new_faucet_succeeds() -> anyhow::Result<()> { recipient: Word::from([0, 1, 2, 3u32]), tag: NoteTag::default(), note_type: NoteType::Private, - amount: Felt::new(100), + amount: Felt::new_unchecked(100), }; let executed_transaction = @@ -298,7 +392,7 @@ async fn prove_burning_fungible_asset_on_existing_faucet_succeeds() -> anyhow::R dropw # => [] - call.::miden::standards::faucets::basic_fungible::burn + call.::miden::standards::faucets::fungible::receive_and_burn # => [pad(16)] end "; @@ -308,15 +402,15 @@ async fn prove_burning_fungible_asset_on_existing_faucet_succeeds() -> anyhow::R builder.add_output_note(RawOutputNote::Full(note.clone())); let mock_chain = builder.build()?; - let token_metadata = TokenMetadata::try_from(faucet.storage())?; + let token_metadata = FungibleFaucet::try_from(faucet.storage())?; // Check that max_supply at the word's index 0 is 200. The remainder of the word is initialized // with the metadata of the faucet which we don't need to check. - assert_eq!(token_metadata.max_supply(), Felt::from(max_supply)); + assert_eq!(token_metadata.max_supply(), AssetAmount::from(max_supply)); // Check that the faucet's token supply has been correctly initialized. // The already issued amount should be 100. - assert_eq!(token_metadata.token_supply(), Felt::from(token_supply)); + assert_eq!(token_metadata.token_supply(), AssetAmount::from(token_supply)); // CONSTRUCT AND EXECUTE TX (Success) // -------------------------------------------------------------------------------------------- @@ -330,7 +424,7 @@ async fn prove_burning_fungible_asset_on_existing_faucet_succeeds() -> anyhow::R // Prove, serialize/deserialize and verify the transaction prove_and_verify_transaction(executed_transaction.clone()).await?; - assert_eq!(executed_transaction.account_delta().nonce_delta(), Felt::new(1)); + assert_eq!(executed_transaction.account_delta().nonce_delta(), Felt::ONE); assert_eq!(executed_transaction.input_notes().get_note(0).id(), note.id()); Ok(()) } @@ -362,7 +456,7 @@ async fn faucet_burn_fungible_asset_fails_amount_exceeds_token_supply() -> anyho dropw # => [] - call.::miden::standards::faucets::basic_fungible::burn + call.::miden::standards::faucets::fungible::receive_and_burn # => [pad(16)] end "; @@ -404,7 +498,7 @@ async fn test_public_note_creation_with_script_from_datastore() -> anyhow::Resul // Parameters for the PUBLIC note that will be created by the faucet let recipient_account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?; - let amount = Felt::new(75); + let amount = Felt::new_unchecked(75); let tag = NoteTag::default(); let note_type = NoteType::Public; @@ -423,11 +517,11 @@ async fn test_public_note_creation_with_script_from_datastore() -> anyhow::Resul let note_storage = NoteStorage::new(vec![ target_account_suffix, target_account_prefix, - Felt::new(0), - Felt::new(0), - Felt::new(0), - Felt::new(1), - Felt::new(0), + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, + Felt::ONE, + Felt::ZERO, ])?; let note_recipient = @@ -435,8 +529,10 @@ async fn test_public_note_creation_with_script_from_datastore() -> anyhow::Resul let output_script_root = note_recipient.script().root(); - let asset = FungibleAsset::new(faucet.id(), amount.as_canonical_u64())?; - let metadata = NoteMetadata::new(faucet.id(), note_type).with_tag(tag); + let callbacks_flag = AssetCallbackFlag::Enabled; + let asset = + FungibleAsset::new(faucet.id(), amount.as_canonical_u64())?.with_callbacks(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); let trigger_note_script_code = format!( @@ -464,16 +560,22 @@ async fn test_public_note_creation_with_script_from_datastore() -> anyhow::Resul push.7 push.0 # => [storage_ptr, num_storage_items = 7, SERIAL_NUM, SCRIPT_ROOT] - exec.note::build_recipient + exec.note::compute_and_store_recipient # => [RECIPIENT] # Now call mint with the computed recipient push.{note_type} push.{tag} push.{amount} - # => [amount, tag, note_type, RECIPIENT] + push.{faucet_id_prefix} + push.{faucet_id_suffix} + push.{callbacks_flag} + # => [callbacks_flag, faucet_id_suffix, faucet_id_prefix, amount, tag, note_type, RECIPIENT] + + exec.::miden::protocol::asset::create_fungible_asset + # => [ASSET_KEY, ASSET_VALUE, tag, note_type, RECIPIENT] - call.::miden::standards::faucets::basic_fungible::mint_and_send + call.::miden::standards::faucets::fungible::mint_and_send # => [note_idx, pad(15)] # Truncate the stack @@ -492,6 +594,9 @@ async fn test_public_note_creation_with_script_from_datastore() -> anyhow::Resul serial_num = serial_num, tag = u32::from(tag), amount = amount, + faucet_id_suffix = faucet.id().suffix(), + faucet_id_prefix = faucet.id().prefix().as_felt(), + callbacks_flag = callbacks_flag as u8, ); // Create the trigger note that will call mint @@ -517,28 +622,21 @@ async fn test_public_note_creation_with_script_from_datastore() -> anyhow::Resul .execute() .await?; - // Verify that a PUBLIC note was created assert_eq!(executed_transaction.output_notes().num_notes(), 1); - let output_note = executed_transaction.output_notes().get_note(0); + assert_note_created!( + executed_transaction, + note_type: NoteType::Public, + sender: faucet.id(), + assets: [FungibleAsset::new(faucet.id(), amount.as_canonical_u64())? + .with_callbacks(AssetCallbackFlag::Enabled)], + ); - // Extract the full note from the OutputNote enum + let output_note = executed_transaction.output_notes().get_note(0); let full_note = match output_note { RawOutputNote::Full(note) => note, _ => panic!("Expected OutputNote::Full variant"), }; - // Verify the output note is public - assert_eq!(full_note.metadata().note_type(), NoteType::Public); - - // Verify the output note contains the minted fungible asset - let expected_asset = FungibleAsset::new(faucet.id(), amount.as_canonical_u64())?; - let expected_asset_obj = Asset::from(expected_asset); - assert!(full_note.assets().iter().any(|asset| asset == &expected_asset_obj)); - - // Verify the note was created by the faucet - assert_eq!(full_note.metadata().sender(), faucet.id()); - - // Verify the note storage commitment matches the expected commitment assert_eq!( full_note.recipient().storage().commitment(), note_storage.commitment(), @@ -554,7 +652,7 @@ 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::new(1)); + assert_eq!(executed_transaction.account_delta().nonce_delta(), Felt::ONE); Ok(()) } @@ -570,57 +668,57 @@ async fn network_faucet_mint() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - let faucet_owner_account_id = AccountId::dummy( - [1; 15], - AccountIdVersion::Version0, - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Private, - ); + let faucet_owner_account_id = + AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Private); let faucet = builder.add_existing_network_faucet( "NET", max_supply, faucet_owner_account_id, Some(token_supply), - OwnerControlledInitConfig::OwnerOnly, + MintPolicyConfig::OwnerOnly, + [], )?; // Create a target account to consume the minted note let mut target_account = builder.add_existing_wallet(Auth::IncrNonce)?; // Check the Network Fungible Faucet's max supply. - let actual_max_supply = TokenMetadata::try_from(faucet.storage())?.max_supply(); - assert_eq!(actual_max_supply.as_canonical_u64(), max_supply); + let actual_max_supply = FungibleFaucet::try_from(faucet.storage())?.max_supply(); + assert_eq!(actual_max_supply.as_u64(), max_supply); // Check that the creator account ID is stored in the ownership slot. // Word: [owner_suffix, owner_prefix, nominated_suffix, nominated_prefix] let stored_owner_id = faucet.storage().get_item(Ownable2Step::slot_name()).unwrap(); assert_eq!( stored_owner_id[0], - Felt::new(faucet_owner_account_id.suffix().as_canonical_u64()) + Felt::new_unchecked(faucet_owner_account_id.suffix().as_canonical_u64()) ); assert_eq!(stored_owner_id[1], faucet_owner_account_id.prefix().as_felt()); - assert_eq!(stored_owner_id[2], Felt::new(0)); // no nominated owner - assert_eq!(stored_owner_id[3], Felt::new(0)); + assert_eq!(stored_owner_id[2], Felt::ZERO); // no nominated owner + assert_eq!(stored_owner_id[3], Felt::ZERO); // Check that the faucet's token supply has been correctly initialized. // The already issued amount should be 50. - let initial_token_supply = TokenMetadata::try_from(faucet.storage())?.token_supply(); - assert_eq!(initial_token_supply.as_canonical_u64(), token_supply); + let initial_token_supply = FungibleFaucet::try_from(faucet.storage())?.token_supply(); + assert_eq!(initial_token_supply.as_u64(), token_supply); // CREATE MINT NOTE USING STANDARD NOTE // -------------------------------------------------------------------------------------------- - let amount = Felt::new(75); - let mint_asset: Asset = - FungibleAsset::new(faucet.id(), amount.as_canonical_u64()).unwrap().into(); + let amount = Felt::new_unchecked(75); + // The faucet has callbacks configured via `TransferPolicy::AllowAll`, 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 serial_num = Word::default(); let output_note_tag = NoteTag::with_account_target(target_account.id()); let p2id_mint_output_note = create_p2id_note_exact( faucet.id(), target_account.id(), - vec![mint_asset], + vec![mint_asset.into()], NoteType::Private, serial_num, ) @@ -628,14 +726,14 @@ async fn network_faucet_mint() -> anyhow::Result<()> { let recipient = p2id_mint_output_note.recipient().digest(); // Create the MINT note using the helper function - let mint_storage = MintNoteStorage::new_private(recipient, amount, output_note_tag.into()); + let mint_storage = MintNoteStorage::new_private(recipient, mint_asset, output_note_tag.into()); let mut rng = RandomCoin::new([Felt::from(42u32); 4].into()); let mint_note = MintNote::create( faucet.id(), faucet_owner_account_id, mint_storage, - NoteAttachment::default(), + NoteAttachments::default(), &mut rng, )?; @@ -653,9 +751,12 @@ 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())?; + let expected_asset = FungibleAsset::new(faucet.id(), amount.as_canonical_u64())? + .with_callbacks(AssetCallbackFlag::Enabled); let assets = NoteAssets::new(vec![expected_asset.into()])?; - let expected_note_id = NoteId::new(recipient, assets.commitment()); + let details_commitment = + NoteDetailsCommitment::from_raw_commitments(recipient, assets.commitment()); + let expected_note_id = NoteId::new(details_commitment, output_note.metadata()); assert_eq!(output_note.id(), expected_note_id); assert_eq!(output_note.metadata().sender(), faucet.id()); @@ -667,8 +768,10 @@ async fn network_faucet_mint() -> anyhow::Result<()> { // CONSUME THE OUTPUT NOTE WITH TARGET ACCOUNT // -------------------------------------------------------------------------------------------- // Execute transaction to consume the output note with the target account + let faucet_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; let consume_tx_context = mock_chain .build_tx_context(target_account.id(), &[], slice::from_ref(&p2id_mint_output_note))? + .foreign_accounts(vec![faucet_inputs]) .build()?; let consume_executed_transaction = consume_tx_context.execute().await?; @@ -676,8 +779,8 @@ async fn network_faucet_mint() -> anyhow::Result<()> { target_account.apply_delta(consume_executed_transaction.account_delta())?; // Verify the account's vault now contains the expected fungible asset - let balance = target_account.vault().get_balance(faucet.id())?; - assert_eq!(balance, expected_asset.amount(),); + let actual_asset = target_account.vault().get(expected_asset.vault_key()).unwrap(); + assert_eq!(actual_asset, Asset::from(expected_asset)); Ok(()) } @@ -690,44 +793,42 @@ async fn network_faucet_mint() -> anyhow::Result<()> { async fn test_network_faucet_owner_can_mint() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - let owner_account_id = AccountId::dummy( - [1; 15], - AccountIdVersion::Version0, - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Private, - ); + let owner_account_id = + AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Private); let faucet = builder.add_existing_network_faucet( "NET", 1000, owner_account_id, Some(50), - OwnerControlledInitConfig::OwnerOnly, + MintPolicyConfig::OwnerOnly, + [], )?; let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; let mock_chain = builder.build()?; - let amount = Felt::new(75); - let mint_asset: Asset = FungibleAsset::new(faucet.id(), amount.as_canonical_u64())?.into(); + let amount = Felt::new_unchecked(75); + let mint_asset = FungibleAsset::new(faucet.id(), amount.as_canonical_u64())? + .with_callbacks(AssetCallbackFlag::Enabled); let output_note_tag = NoteTag::with_account_target(target_account.id()); let p2id_note = create_p2id_note_exact( faucet.id(), target_account.id(), - vec![mint_asset], + vec![mint_asset.into()], NoteType::Private, Word::default(), )?; let recipient = p2id_note.recipient().digest(); - let mint_inputs = MintNoteStorage::new_private(recipient, amount, output_note_tag.into()); + let mint_inputs = MintNoteStorage::new_private(recipient, mint_asset, output_note_tag.into()); let mut rng = RandomCoin::new([Felt::from(42u32); 4].into()); let mint_note = MintNote::create( faucet.id(), owner_account_id, mint_inputs, - NoteAttachment::default(), + NoteAttachments::default(), &mut rng, )?; @@ -744,27 +845,14 @@ async fn test_network_faucet_owner_can_mint() -> anyhow::Result<()> { async fn test_network_faucet_set_policy_rejects_non_allowed_root() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - let owner_account_id = AccountId::dummy( - [1; 15], - AccountIdVersion::Version0, - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Private, - ); - - let faucet = builder.add_existing_network_faucet( - "NET", - 1000, - owner_account_id, - Some(0), - OwnerControlledInitConfig::OwnerOnly, - )?; - let mock_chain = builder.build()?; + let owner_account_id = + AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Private); // This root exists in account code, but is not in the mint policy allowlist. - let invalid_policy_root = NetworkFungibleFaucet::mint_and_send_digest(); - let set_policy_note_script = format!( + let invalid_policy_root = FungibleFaucet::mint_and_send_root().as_word(); + let set_policy_note_script = compile_note_script(&format!( r#" - use miden::standards::mint_policies::policy_manager->policy_manager + use miden::standards::faucets::policies::policy_manager @note_script pub proc main @@ -774,13 +862,23 @@ async fn test_network_faucet_set_policy_rejects_non_allowed_root() -> anyhow::Re dropw dropw dropw dropw end "# - ); + ))?; + + let faucet = builder.add_existing_network_faucet( + "NET", + 1000, + owner_account_id, + Some(0), + MintPolicyConfig::OwnerOnly, + [set_policy_note_script.root()], + )?; + let mock_chain = builder.build()?; let result = execute_faucet_note_script( &mock_chain, faucet.id(), owner_account_id, - &set_policy_note_script, + set_policy_note_script, 400, ) .await?; @@ -790,49 +888,80 @@ async fn test_network_faucet_set_policy_rejects_non_allowed_root() -> anyhow::Re Ok(()) } +/// Tests that set_burn_policy rejects policy roots outside the allowed policy roots map. +#[tokio::test] +async fn test_network_faucet_set_burn_policy_rejects_non_allowed_root() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let owner_account_id = + AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Private); + + // This root exists in account code, but is not in the burn policy allowlist. + let invalid_policy_root = FungibleFaucet::receive_and_burn_root().as_word(); + let set_policy_note_script = + compile_note_script(&create_set_burn_policy_note_script(invalid_policy_root))?; + + let faucet = builder.add_existing_network_faucet( + "NET", + 1000, + owner_account_id, + Some(0), + MintPolicyConfig::OwnerOnly, + [set_policy_note_script.root()], + )?; + let mock_chain = builder.build()?; + + let result = execute_faucet_note_script( + &mock_chain, + faucet.id(), + owner_account_id, + set_policy_note_script, + 401, + ) + .await?; + + assert_transaction_executor_error!(result, ERR_BURN_POLICY_ROOT_NOT_ALLOWED); + + 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<()> { let mut builder = MockChain::builder(); - let owner_account_id = AccountId::dummy( - [1; 15], - AccountIdVersion::Version0, - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Private, - ); + let owner_account_id = + AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Private); - let non_owner_account_id = AccountId::dummy( - [2; 15], - AccountIdVersion::Version0, - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Private, - ); + let non_owner_account_id = + AccountId::dummy([2; 15], AccountIdVersion::Version1, AccountType::Private); let faucet = builder.add_existing_network_faucet( "NET", 1000, owner_account_id, Some(50), - OwnerControlledInitConfig::OwnerOnly, + MintPolicyConfig::OwnerOnly, + [], )?; let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; let mock_chain = builder.build()?; - let amount = Felt::new(75); - let mint_asset: Asset = FungibleAsset::new(faucet.id(), amount.as_canonical_u64())?.into(); + let amount = Felt::new_unchecked(75); + let mint_asset = FungibleAsset::new(faucet.id(), amount.as_canonical_u64())? + .with_callbacks(AssetCallbackFlag::Enabled); let output_note_tag = NoteTag::with_account_target(target_account.id()); let p2id_note = create_p2id_note_exact( faucet.id(), target_account.id(), - vec![mint_asset], + vec![mint_asset.into()], NoteType::Private, Word::default(), )?; let recipient = p2id_note.recipient().digest(); - let mint_inputs = MintNoteStorage::new_private(recipient, amount, output_note_tag.into()); + let mint_inputs = MintNoteStorage::new_private(recipient, mint_asset, output_note_tag.into()); // Create mint note from NON-OWNER let mut rng = RandomCoin::new([Felt::from(42u32); 4].into()); @@ -840,7 +969,7 @@ async fn test_network_faucet_non_owner_cannot_mint() -> anyhow::Result<()> { faucet.id(), non_owner_account_id, mint_inputs, - NoteAttachment::default(), + NoteAttachments::default(), &mut rng, )?; @@ -859,19 +988,16 @@ async fn test_network_faucet_non_owner_cannot_mint() -> anyhow::Result<()> { async fn test_network_faucet_owner_storage() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - let owner_account_id = AccountId::dummy( - [1; 15], - AccountIdVersion::Version0, - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Private, - ); + let owner_account_id = + AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Private); let faucet = builder.add_existing_network_faucet( "NET", 1000, owner_account_id, Some(50), - OwnerControlledInitConfig::OwnerOnly, + MintPolicyConfig::OwnerOnly, + [], )?; let _mock_chain = builder.build()?; @@ -879,10 +1005,13 @@ async fn test_network_faucet_owner_storage() -> anyhow::Result<()> { let stored_owner = faucet.storage().get_item(Ownable2Step::slot_name())?; // Word: [owner_suffix, owner_prefix, nominated_suffix, nominated_prefix] - assert_eq!(stored_owner[0], Felt::new(owner_account_id.suffix().as_canonical_u64())); + assert_eq!( + stored_owner[0], + Felt::new_unchecked(owner_account_id.suffix().as_canonical_u64()) + ); assert_eq!(stored_owner[1], owner_account_id.prefix().as_felt()); - assert_eq!(stored_owner[2], Felt::new(0)); // no nominated owner - assert_eq!(stored_owner[3], Felt::new(0)); + assert_eq!(stored_owner[2], Felt::ZERO); // no nominated owner + assert_eq!(stored_owner[3], Felt::ZERO); Ok(()) } @@ -895,72 +1024,81 @@ async fn test_network_faucet_transfer_ownership() -> anyhow::Result<()> { let mut builder = MockChain::builder(); // Setup: Create initial owner and new owner accounts - let initial_owner_account_id = AccountId::dummy( - [1; 15], - AccountIdVersion::Version0, - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Private, - ); + let initial_owner_account_id = + AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Private); + + let new_owner_account_id = + AccountId::dummy([2; 15], AccountIdVersion::Version1, AccountType::Private); - let new_owner_account_id = AccountId::dummy( - [2; 15], - AccountIdVersion::Version0, - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Private, + // Step 1: Create transfer_ownership note script to nominate new owner + let transfer_note_script_code = format!( + r#" + use miden::standards::access::ownable2step + + @note_script + pub proc main + repeat.14 push.0 end + push.{new_owner_prefix} + push.{new_owner_suffix} + call.ownable2step::transfer_ownership + dropw dropw dropw dropw + end + "#, + new_owner_prefix = new_owner_account_id.prefix().as_felt(), + new_owner_suffix = Felt::new_unchecked(new_owner_account_id.suffix().as_canonical_u64()), ); + // Step 2: Accept ownership as the nominated owner + let accept_note_script_code = r#" + use miden::standards::access::ownable2step + + @note_script + pub proc main + repeat.16 push.0 end + call.ownable2step::accept_ownership + dropw dropw dropw dropw + end + "#; + + let transfer_script = compile_note_script(&transfer_note_script_code)?; + let accept_script = compile_note_script(accept_note_script_code)?; + let faucet = builder.add_existing_network_faucet( "NET", 1000, initial_owner_account_id, Some(50), - OwnerControlledInitConfig::OwnerOnly, + MintPolicyConfig::OwnerOnly, + [transfer_script.root(), accept_script.root()], )?; let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; - let amount = Felt::new(75); - let mint_asset: Asset = FungibleAsset::new(faucet.id(), amount.as_canonical_u64())?.into(); + let amount = Felt::new_unchecked(75); + let mint_asset = FungibleAsset::new(faucet.id(), amount.as_canonical_u64())? + .with_callbacks(AssetCallbackFlag::Enabled); let output_note_tag = NoteTag::with_account_target(target_account.id()); let p2id_note = create_p2id_note_exact( faucet.id(), target_account.id(), - vec![mint_asset], + vec![mint_asset.into()], NoteType::Private, Word::default(), )?; let recipient = p2id_note.recipient().digest(); // Sanity Check: Prove that the initial owner can mint assets - let mint_inputs = MintNoteStorage::new_private(recipient, amount, output_note_tag.into()); + let mint_inputs = MintNoteStorage::new_private(recipient, mint_asset, output_note_tag.into()); let mut rng = RandomCoin::new([Felt::from(42u32); 4].into()); let mint_note = MintNote::create( faucet.id(), initial_owner_account_id, mint_inputs.clone(), - NoteAttachment::default(), + NoteAttachments::default(), &mut rng, )?; - // Step 1: Create transfer_ownership note script to nominate new owner - let transfer_note_script_code = format!( - r#" - use miden::standards::access::ownable2step - - @note_script - pub proc main - repeat.14 push.0 end - push.{new_owner_prefix} - push.{new_owner_suffix} - call.ownable2step::transfer_ownership - dropw dropw dropw dropw - end - "#, - new_owner_prefix = new_owner_account_id.prefix().as_felt(), - new_owner_suffix = Felt::new(new_owner_account_id.suffix().as_canonical_u64()), - ); - let source_manager = Arc::new(DefaultSourceManager::default()); // Create the transfer note and add it to the builder so it exists on-chain @@ -969,7 +1107,7 @@ async fn test_network_faucet_transfer_ownership() -> anyhow::Result<()> { .note_type(NoteType::Private) .tag(NoteTag::default().into()) .serial_number(Word::from([11, 22, 33, 44u32])) - .code(transfer_note_script_code.clone()) + .script(transfer_script) .build()?; // Add the transfer note to the builder before building the chain @@ -998,24 +1136,12 @@ async fn test_network_faucet_transfer_ownership() -> anyhow::Result<()> { let mut updated_faucet = faucet.clone(); updated_faucet.apply_delta(executed_transaction.account_delta())?; - // Step 2: Accept ownership as the nominated owner - let accept_note_script_code = r#" - use miden::standards::access::ownable2step - - @note_script - pub proc main - repeat.16 push.0 end - call.ownable2step::accept_ownership - dropw dropw dropw dropw - end - "#; - let mut rng = RandomCoin::new([Felt::from(400u32); 4].into()); let accept_note = NoteBuilder::new(new_owner_account_id, &mut rng) .note_type(NoteType::Private) .tag(NoteTag::default().into()) .serial_number(Word::from([55, 66, 77, 88u32])) - .code(accept_note_script_code) + .script(accept_script) .build()?; let tx_context = mock_chain @@ -1030,10 +1156,13 @@ async fn test_network_faucet_transfer_ownership() -> anyhow::Result<()> { // Verify that owner changed to new_owner and nominated was cleared // Word: [owner_suffix, owner_prefix, nominated_suffix, nominated_prefix] let stored_owner = final_faucet.storage().get_item(Ownable2Step::slot_name())?; - assert_eq!(stored_owner[0], Felt::new(new_owner_account_id.suffix().as_canonical_u64())); + assert_eq!( + stored_owner[0], + Felt::new_unchecked(new_owner_account_id.suffix().as_canonical_u64()) + ); assert_eq!(stored_owner[1], new_owner_account_id.prefix().as_felt()); - assert_eq!(stored_owner[2], Felt::new(0)); // nominated cleared - assert_eq!(stored_owner[3], Felt::new(0)); + assert_eq!(stored_owner[2], Felt::ZERO); // nominated cleared + assert_eq!(stored_owner[3], Felt::ZERO); Ok(()) } @@ -1043,35 +1172,14 @@ async fn test_network_faucet_transfer_ownership() -> anyhow::Result<()> { async fn test_network_faucet_only_owner_can_transfer() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - let owner_account_id = AccountId::dummy( - [1; 15], - AccountIdVersion::Version0, - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Private, - ); - - let non_owner_account_id = AccountId::dummy( - [2; 15], - AccountIdVersion::Version0, - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Private, - ); + let owner_account_id = + AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Private); - let new_owner_account_id = AccountId::dummy( - [3; 15], - AccountIdVersion::Version0, - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Private, - ); + let non_owner_account_id = + AccountId::dummy([2; 15], AccountIdVersion::Version1, AccountType::Private); - let faucet = builder.add_existing_network_faucet( - "NET", - 1000, - owner_account_id, - Some(50), - OwnerControlledInitConfig::OwnerOnly, - )?; - let mock_chain = builder.build()?; + let new_owner_account_id = + AccountId::dummy([3; 15], AccountIdVersion::Version1, AccountType::Private); // Create transfer ownership note script let transfer_note_script_code = format!( @@ -1088,9 +1196,21 @@ async fn test_network_faucet_only_owner_can_transfer() -> anyhow::Result<()> { end "#, new_owner_prefix = new_owner_account_id.prefix().as_felt(), - new_owner_suffix = Felt::new(new_owner_account_id.suffix().as_canonical_u64()), + new_owner_suffix = Felt::new_unchecked(new_owner_account_id.suffix().as_canonical_u64()), ); + let transfer_script = compile_note_script(&transfer_note_script_code)?; + + let faucet = builder.add_existing_network_faucet( + "NET", + 1000, + owner_account_id, + Some(50), + MintPolicyConfig::OwnerOnly, + [transfer_script.root()], + )?; + let mock_chain = builder.build()?; + let source_manager = Arc::new(DefaultSourceManager::default()); // Create a note from NON-OWNER that tries to transfer ownership @@ -1099,7 +1219,7 @@ async fn test_network_faucet_only_owner_can_transfer() -> anyhow::Result<()> { .note_type(NoteType::Private) .tag(NoteTag::default().into()) .serial_number(Word::from([10, 20, 30, 40u32])) - .code(transfer_note_script_code.clone()) + .script(transfer_script) .build()?; let tx_context = mock_chain @@ -1118,32 +1238,11 @@ async fn test_network_faucet_only_owner_can_transfer() -> anyhow::Result<()> { async fn test_network_faucet_renounce_ownership() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - let owner_account_id = AccountId::dummy( - [1; 15], - AccountIdVersion::Version0, - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Private, - ); + let owner_account_id = + AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Private); - let new_owner_account_id = AccountId::dummy( - [2; 15], - AccountIdVersion::Version0, - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Private, - ); - - let faucet = builder.add_existing_network_faucet( - "NET", - 1000, - owner_account_id, - Some(50), - OwnerControlledInitConfig::OwnerOnly, - )?; - - // Check stored value before renouncing - let stored_owner_before = faucet.storage().get_item(Ownable2Step::slot_name())?; - assert_eq!(stored_owner_before[0], Felt::new(owner_account_id.suffix().as_canonical_u64())); - assert_eq!(stored_owner_before[1], owner_account_id.prefix().as_felt()); + let new_owner_account_id = + AccountId::dummy([2; 15], AccountIdVersion::Version1, AccountType::Private); // Create renounce_ownership note script let renounce_note_script_code = r#" @@ -1157,8 +1256,6 @@ async fn test_network_faucet_renounce_ownership() -> anyhow::Result<()> { end "#; - let source_manager = Arc::new(DefaultSourceManager::default()); - // Create transfer note script (will be used after renounce) let transfer_note_script_code = format!( r#" @@ -1174,15 +1271,34 @@ async fn test_network_faucet_renounce_ownership() -> anyhow::Result<()> { end "#, new_owner_prefix = new_owner_account_id.prefix().as_felt(), - new_owner_suffix = Felt::new(new_owner_account_id.suffix().as_canonical_u64()), + new_owner_suffix = Felt::new_unchecked(new_owner_account_id.suffix().as_canonical_u64()), ); + let renounce_script = compile_note_script(renounce_note_script_code)?; + let transfer_script = compile_note_script(&transfer_note_script_code)?; + + let faucet = builder.add_existing_network_faucet( + "NET", + 1000, + owner_account_id, + Some(50), + MintPolicyConfig::OwnerOnly, + [renounce_script.root(), transfer_script.root()], + )?; + + // Check stored value before renouncing + let stored_owner_before = faucet.storage().get_item(Ownable2Step::slot_name())?; + assert_eq!(stored_owner_before[0], owner_account_id.suffix()); + assert_eq!(stored_owner_before[1], owner_account_id.prefix().as_felt()); + + let source_manager = Arc::new(DefaultSourceManager::default()); + let mut rng = RandomCoin::new([Felt::from(200u32); 4].into()); let renounce_note = NoteBuilder::new(owner_account_id, &mut rng) .note_type(NoteType::Private) .tag(NoteTag::default().into()) .serial_number(Word::from([11, 22, 33, 44u32])) - .code(renounce_note_script_code) + .script(renounce_script) .build()?; let mut rng = RandomCoin::new([Felt::from(300u32); 4].into()); @@ -1190,7 +1306,7 @@ async fn test_network_faucet_renounce_ownership() -> anyhow::Result<()> { .note_type(NoteType::Private) .tag(NoteTag::default().into()) .serial_number(Word::from([50, 60, 70, 80u32])) - .code(transfer_note_script_code.clone()) + .script(transfer_script) .build()?; builder.add_output_note(RawOutputNote::Full(renounce_note.clone())); @@ -1213,10 +1329,10 @@ async fn test_network_faucet_renounce_ownership() -> anyhow::Result<()> { // Check stored value after renouncing - should be zero let stored_owner_after = updated_faucet.storage().get_item(Ownable2Step::slot_name())?; - assert_eq!(stored_owner_after[0], Felt::new(0)); - assert_eq!(stored_owner_after[1], Felt::new(0)); - assert_eq!(stored_owner_after[2], Felt::new(0)); - assert_eq!(stored_owner_after[3], Felt::new(0)); + assert_eq!(stored_owner_after[0], Felt::ZERO); + assert_eq!(stored_owner_after[1], Felt::ZERO); + assert_eq!(stored_owner_after[2], Felt::ZERO); + assert_eq!(stored_owner_after[3], Felt::ZERO); // Try to transfer ownership - should fail because there's no owner mock_chain.prove_next_block()?; @@ -1235,17 +1351,29 @@ async fn test_network_faucet_renounce_ownership() -> anyhow::Result<()> { // TESTS FOR FAUCET PROCEDURE COMPATIBILITY // ================================================================================================ -/// Tests that basic and network fungible faucets have the same burn procedure digest. -/// This is required for BURN notes to work with both faucet types. +/// Tests that the default network faucet burn policy root is exported by the account code. #[test] -fn test_faucet_burn_procedures_are_identical() { - // Both faucet types must export the same burn procedure with identical MAST roots - // so that a single BURN note script can work with either faucet type - assert_eq!( - BasicFungibleFaucet::burn_digest(), - NetworkFungibleFaucet::burn_digest(), - "Basic and network fungible faucets must have the same burn procedure digest" - ); +fn test_network_faucet_contains_default_burn_policy_root() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let owner_account_id = + AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Private); + + let faucet = builder.add_existing_network_faucet( + "NET", + 200, + owner_account_id, + Some(100), + MintPolicyConfig::OwnerOnly, + [], + )?; + + let stored_root = faucet.storage().get_item(TokenPolicyManager::active_burn_policy_slot())?; + + assert_eq!(stored_root, BurnAllowAll::root().as_word()); + assert!(faucet.code().has_procedure(stored_root)); + + Ok(()) } /// Tests burning on network faucet @@ -1253,19 +1381,16 @@ fn test_faucet_burn_procedures_are_identical() { async fn network_faucet_burn() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - let faucet_owner_account_id = AccountId::dummy( - [1; 15], - AccountIdVersion::Version0, - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Private, - ); + let faucet_owner_account_id = + AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Private); let mut faucet = builder.add_existing_network_faucet( "NET", 200, faucet_owner_account_id, Some(100), - OwnerControlledInitConfig::OwnerOnly, + MintPolicyConfig::OwnerOnly, + [], )?; let burn_amount = 100u64; @@ -1278,7 +1403,7 @@ async fn network_faucet_burn() -> anyhow::Result<()> { faucet_owner_account_id, faucet.id(), fungible_asset.into(), - NoteAttachment::default(), + NoteAttachments::default(), &mut rng, )?; @@ -1287,8 +1412,8 @@ async fn network_faucet_burn() -> anyhow::Result<()> { mock_chain.prove_next_block()?; // Check the initial token issuance before burning - let initial_token_supply = TokenMetadata::try_from(faucet.storage())?.token_supply(); - assert_eq!(initial_token_supply, Felt::new(100)); + let initial_token_supply = FungibleFaucet::try_from(faucet.storage())?.token_supply(); + assert_eq!(initial_token_supply, AssetAmount::from(100u32)); // EXECUTE BURN NOTE AGAINST NETWORK FAUCET // -------------------------------------------------------------------------------------------- @@ -1299,20 +1424,135 @@ 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::new(1)); + assert_eq!(executed_transaction.account_delta().nonce_delta(), 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())?; - let final_token_supply = TokenMetadata::try_from(faucet.storage())?.token_supply(); + let final_token_supply = FungibleFaucet::try_from(faucet.storage())?.token_supply(); assert_eq!( final_token_supply, - Felt::new(initial_token_supply.as_canonical_u64() - burn_amount) + AssetAmount::new(initial_token_supply.as_u64() - burn_amount).unwrap() ); Ok(()) } +/// Tests that a non-owner cannot burn assets once burn policy is switched to owner-only. +#[tokio::test] +async fn test_network_faucet_non_owner_cannot_burn_when_owner_only_policy_active() +-> 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_burn_switching( + &mut builder, + "NET", + 200, + owner_account_id, + 100, + MintPolicyConfig::OwnerOnly, + )?; + let set_policy_note_script = + create_set_burn_policy_note_script(BurnOwnerOnly::root().as_word()); + let mut rng = RandomCoin::new([Felt::from(500u32); 4].into()); + let set_policy_note = NoteBuilder::new(owner_account_id, &mut rng) + .note_type(NoteType::Private) + .code(set_policy_note_script.as_str()) + .build()?; + let burn_amount = 10u64; + let fungible_asset = FungibleAsset::new(faucet.id(), burn_amount).unwrap(); + let mut rng = RandomCoin::new([Felt::from(501u32); 4].into()); + let burn_note = BurnNote::create( + non_owner_account_id, + faucet.id(), + fungible_asset.into(), + NoteAttachments::default(), + &mut rng, + )?; + builder.add_output_note(RawOutputNote::Full(set_policy_note.clone())); + builder.add_output_note(RawOutputNote::Full(burn_note.clone())); + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + let source_manager = Arc::new(DefaultSourceManager::default()); + let tx_context = mock_chain + .build_tx_context(faucet.id(), &[set_policy_note.id()], &[])? + .with_source_manager(source_manager.clone()) + .build()?; + let executed_transaction = tx_context.execute().await?; + mock_chain.add_pending_executed_transaction(&executed_transaction)?; + 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_SENDER_NOT_OWNER); + + Ok(()) +} + +/// Tests that the owner can still burn assets once burn policy is switched to owner-only. +#[tokio::test] +async fn test_network_faucet_owner_can_burn_when_owner_only_policy_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_burn_switching( + &mut builder, + "NET", + 200, + owner_account_id, + 100, + MintPolicyConfig::OwnerOnly, + )?; + let set_policy_note_script = + create_set_burn_policy_note_script(BurnOwnerOnly::root().as_word()); + let mut rng = RandomCoin::new([Felt::from(510u32); 4].into()); + let set_policy_note = NoteBuilder::new(owner_account_id, &mut rng) + .note_type(NoteType::Private) + .code(set_policy_note_script.as_str()) + .build()?; + let burn_amount = 10u64; + let fungible_asset = FungibleAsset::new(faucet.id(), burn_amount).unwrap(); + let mut rng = RandomCoin::new([Felt::from(511u32); 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_policy_note.clone())); + builder.add_output_note(RawOutputNote::Full(burn_note.clone())); + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + let source_manager = Arc::new(DefaultSourceManager::default()); + let tx_context = mock_chain + .build_tx_context(faucet.id(), &[set_policy_note.id()], &[])? + .with_source_manager(source_manager.clone()) + .build()?; + let executed_transaction = tx_context.execute().await?; + mock_chain.add_pending_executed_transaction(&executed_transaction)?; + mock_chain.prove_next_block()?; + + let tx_context = mock_chain.build_tx_context(faucet.id(), &[burn_note.id()], &[])?.build()?; + 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); + + Ok(()) +} + // TESTS FOR MINT NOTE WITH PRIVATE AND PUBLIC OUTPUT MODES // ================================================================================================ @@ -1325,32 +1565,32 @@ async fn network_faucet_burn() -> anyhow::Result<()> { async fn test_mint_note_output_note_types(#[case] note_type: NoteType) -> anyhow::Result<()> { let mut builder = MockChain::builder(); - let faucet_owner_account_id = AccountId::dummy( - [1; 15], - AccountIdVersion::Version0, - AccountType::RegularAccountImmutableCode, - AccountStorageMode::Private, - ); + let faucet_owner_account_id = + AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Private); let faucet = builder.add_existing_network_faucet( "NET", 1000, faucet_owner_account_id, Some(50), - OwnerControlledInitConfig::OwnerOnly, + MintPolicyConfig::OwnerOnly, + [], )?; let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; - let amount = Felt::new(75); - let mint_asset: Asset = - FungibleAsset::new(faucet.id(), amount.as_canonical_u64()).unwrap().into(); + let amount = Felt::new_unchecked(75); + // The faucet has callbacks configured via `TransferPolicy::AllowAll`, 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 serial_num = Word::from([1, 2, 3, 4u32]); // Create the expected P2ID output note let p2id_mint_output_note = create_p2id_note_exact( faucet.id(), target_account.id(), - vec![mint_asset], + vec![mint_asset.into()], note_type, serial_num, ) @@ -1361,7 +1601,7 @@ async fn test_mint_note_output_note_types(#[case] note_type: NoteType) -> anyhow NoteType::Private => { let output_note_tag = NoteTag::with_account_target(target_account.id()); let recipient = p2id_mint_output_note.recipient().digest(); - MintNoteStorage::new_private(recipient, amount, output_note_tag.into()) + MintNoteStorage::new_private(recipient, mint_asset, output_note_tag.into()) }, NoteType::Public => { let output_note_tag = NoteTag::with_account_target(target_account.id()); @@ -1370,7 +1610,7 @@ async fn test_mint_note_output_note_types(#[case] note_type: NoteType) -> anyhow vec![target_account.id().suffix(), target_account.id().prefix().as_felt()]; let note_storage = NoteStorage::new(p2id_storage)?; let recipient = NoteRecipient::new(serial_num, p2id_script, note_storage); - MintNoteStorage::new_public(recipient, amount, output_note_tag.into())? + MintNoteStorage::new_public(recipient, mint_asset, output_note_tag.into())? }, }; @@ -1379,7 +1619,7 @@ async fn test_mint_note_output_note_types(#[case] note_type: NoteType) -> anyhow faucet.id(), faucet_owner_account_id, mint_storage.clone(), - NoteAttachment::default(), + NoteAttachments::default(), &mut rng, )?; @@ -1415,15 +1655,18 @@ async fn test_mint_note_output_note_types(#[case] note_type: NoteType) -> anyhow // Consume the output note with target account let mut target_account_mut = target_account.clone(); + let faucet_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; let consume_tx_context = mock_chain .build_tx_context(target_account.id(), &[], slice::from_ref(&p2id_mint_output_note))? + .foreign_accounts(vec![faucet_inputs]) .build()?; let consume_executed_transaction = consume_tx_context.execute().await?; target_account_mut.apply_delta(consume_executed_transaction.account_delta())?; - let expected_asset = FungibleAsset::new(faucet.id(), amount.as_canonical_u64())?; - let balance = target_account_mut.vault().get_balance(faucet.id())?; + let expected_asset = FungibleAsset::new(faucet.id(), amount.as_canonical_u64())? + .with_callbacks(AssetCallbackFlag::Enabled); + let balance = target_account_mut.vault().get_balance(expected_asset.vault_key())?; assert_eq!(balance, expected_asset.amount()); Ok(()) @@ -1455,30 +1698,38 @@ async fn multiple_mints_in_single_tx_produce_correct_amounts() -> anyhow::Result " begin # --- First mint: mint {amount_1} tokens to recipient_1 --- - padw padw push.0 - push.{recipient_1} push.{note_type} push.{tag} push.{amount_1} - # => [amount_1, tag, note_type, RECIPIENT_1, pad(9)] + push.{faucet_id_prefix} + push.{faucet_id_suffix} + push.1 + # => [enable_callbacks=1, faucet_id_suffix, faucet_id_prefix, amount_1, tag, note_type, RECIPIENT_1] - call.::miden::standards::faucets::basic_fungible::mint_and_send + exec.::miden::protocol::asset::create_fungible_asset + # => [ASSET_KEY, ASSET_VALUE, tag, note_type, RECIPIENT_1] + + call.::miden::standards::faucets::fungible::mint_and_send # => [note_idx, pad(15)] # clean up the stack before the second call dropw dropw dropw dropw # --- Second mint: mint {amount_2} tokens to recipient_2 --- - padw padw push.0 - push.{recipient_2} push.{note_type} push.{tag} push.{amount_2} - # => [amount_2, tag, note_type, RECIPIENT_2, pad(9)] + push.{faucet_id_prefix} + push.{faucet_id_suffix} + push.1 + # => [enable_callbacks=1, faucet_id_suffix, faucet_id_prefix, amount_2, tag, note_type, RECIPIENT_2] + + exec.::miden::protocol::asset::create_fungible_asset + # => [ASSET_KEY, ASSET_VALUE, tag, note_type, RECIPIENT_2] - call.::miden::standards::faucets::basic_fungible::mint_and_send + call.::miden::standards::faucets::fungible::mint_and_send # => [note_idx, pad(15)] # truncate the stack @@ -1487,6 +1738,8 @@ async fn multiple_mints_in_single_tx_produce_correct_amounts() -> anyhow::Result ", note_type = note_type as u8, tag = u32::from(tag), + faucet_id_suffix = faucet.id().suffix(), + faucet_id_prefix = faucet.id().prefix().as_felt(), ); let source_manager = Arc::new(DefaultSourceManager::default()); @@ -1504,18 +1757,144 @@ 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)?.into(); + let expected_asset_1: Asset = FungibleAsset::new(faucet.id(), amount_1)? + .with_callbacks(AssetCallbackFlag::Enabled) + .into(); let output_note_1 = executed_transaction.output_notes().get_note(0); let assets_1 = NoteAssets::new(vec![expected_asset_1])?; - let expected_id_1 = NoteId::new(recipient_1, assets_1.commitment()); + let details_commitment_1 = + NoteDetailsCommitment::from_raw_commitments(recipient_1, assets_1.commitment()); + let expected_id_1 = NoteId::new(details_commitment_1, output_note_1.metadata()); 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)?.into(); + let expected_asset_2: Asset = FungibleAsset::new(faucet.id(), amount_2)? + .with_callbacks(AssetCallbackFlag::Enabled) + .into(); let output_note_2 = executed_transaction.output_notes().get_note(1); let assets_2 = NoteAssets::new(vec![expected_asset_2])?; - let expected_id_2 = NoteId::new(recipient_2, assets_2.commitment()); + let details_commitment_2 = + NoteDetailsCommitment::from_raw_commitments(recipient_2, assets_2.commitment()); + let expected_id_2 = NoteId::new(details_commitment_2, output_note_2.metadata()); assert_eq!(output_note_2.id(), expected_id_2); Ok(()) } + +// NetworkFungibleFaucet + TransferPolicy::Blocklist (post-#2879 happy path) +// ================================================================================================ + +/// Builds a network faucet with [`TransferPolicy::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( + builder: &mut MockChainBuilder, + token_symbol: &str, + max_supply: u64, + owner: AccountId, + token_supply: 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 faucet = FungibleFaucet::builder() + .name(name) + .symbol(symbol) + .decimals(10) + .max_supply(max_supply) + .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 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); + + builder.add_account_from_builder( + Auth::NetworkAccount { + allowed_script_roots: BTreeSet::from([MintNote::script_root()]), + }, + account_builder, + AccountState::Exists, + ) +} + +/// Verifies that the network-faucet mint pattern works when `TokenPolicyManager` installs +/// asset-callback slots (here via [`TransferPolicy::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 +/// the native account during the mint-note flow. The fix short-circuits callback dispatch +/// when the issuer equals the native account, so this test now succeeds. +#[tokio::test] +async fn network_faucet_mint_with_blocklist() -> anyhow::Result<()> { + let max_supply = 1000u64; + let token_supply = 50u64; + + let mut builder = MockChain::builder(); + + let faucet_owner_account_id = + AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Private); + + let faucet = build_network_faucet_with_blocklist_transfer( + &mut builder, + "NET", + max_supply, + faucet_owner_account_id, + token_supply, + )?; + + let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; + + let amount = Felt::new_unchecked(75); + // 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 serial_num = Word::default(); + + let output_note_tag = NoteTag::with_account_target(target_account.id()); + let p2id_mint_output_note = create_p2id_note_exact( + faucet.id(), + target_account.id(), + vec![mint_asset.into()], + NoteType::Private, + serial_num, + ) + .unwrap(); + let recipient = p2id_mint_output_note.recipient().digest(); + + let mint_storage = MintNoteStorage::new_private(recipient, mint_asset, output_note_tag.into()); + + let mut rng = RandomCoin::new([Felt::from(42u32); 4].into()); + let mint_note = MintNote::create( + faucet.id(), + faucet_owner_account_id, + mint_storage, + NoteAttachments::default(), + &mut rng, + )?; + + builder.add_output_note(RawOutputNote::Full(mint_note.clone())); + let mock_chain = builder.build()?; + + let executed = mock_chain + .build_tx_context(faucet.id(), &[mint_note.id()], &[])? + .build()? + .execute() + .await?; + + assert_eq!(executed.output_notes().num_notes(), 1); + Ok(()) +} diff --git a/crates/miden-testing/tests/scripts/fee.rs b/crates/miden-testing/tests/scripts/fee.rs index 144f445d08..3ba0eddded 100644 --- a/crates/miden-testing/tests/scripts/fee.rs +++ b/crates/miden-testing/tests/scripts/fee.rs @@ -33,15 +33,15 @@ async fn prove_account_creation_with_fees() -> anyhow::Result<()> { 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.native_asset_id(), amount)?.sub(tx.fee())?; + let added_asset = FungibleAsset::new(chain.fee_faucet_id(), amount)?.sub(tx.fee())?; - assert_eq!(tx.account_delta().nonce_delta(), Felt::new(1)); + 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::new(1)); + 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()); diff --git a/crates/miden-testing/tests/scripts/mod.rs b/crates/miden-testing/tests/scripts/mod.rs index 8d15402744..2b66737105 100644 --- a/crates/miden-testing/tests/scripts/mod.rs +++ b/crates/miden-testing/tests/scripts/mod.rs @@ -1,7 +1,12 @@ +mod allowlist; +mod blocklist; mod faucet; mod fee; mod ownable2step; mod p2id; mod p2ide; +mod pausable; +mod pswap; +mod rbac; mod send_note; mod swap; diff --git a/crates/miden-testing/tests/scripts/ownable2step.rs b/crates/miden-testing/tests/scripts/ownable2step.rs index 0eff56ad95..a08ebbd0e2 100644 --- a/crates/miden-testing/tests/scripts/ownable2step.rs +++ b/crates/miden-testing/tests/scripts/ownable2step.rs @@ -10,7 +10,6 @@ use miden_protocol::account::{ AccountBuilder, AccountComponent, AccountId, - AccountStorageMode, AccountType, StorageSlot, }; @@ -51,10 +50,10 @@ fn create_ownable_account( storage_slots.push(Ownable2Step::new(owner).to_storage_slot()); let account = AccountBuilder::new([1; 32]) - .storage_mode(AccountStorageMode::Public) + .account_type(AccountType::Public) .with_auth_component(Auth::IncrNonce) .with_component({ - let metadata = AccountComponentMetadata::new("test::ownable", AccountType::all()); + let metadata = AccountComponentMetadata::new("test::ownable"); AccountComponent::new(component_code_obj, storage_slots, metadata)? }) .build_existing()?; @@ -90,7 +89,7 @@ fn create_transfer_note( end "#, new_owner_prefix = new_owner.prefix().as_felt(), - new_owner_suffix = Felt::new(new_owner.suffix().as_canonical_u64()), + new_owner_suffix = Felt::new_unchecked(new_owner.suffix().as_canonical_u64()), ); let note = NoteBuilder::new(sender, rng) @@ -124,6 +123,32 @@ fn create_accept_note( Ok(note) } +fn create_cancel_note( + sender: AccountId, + rng: &mut RandomCoin, + source_manager: Arc, +) -> anyhow::Result { + let script = r#" + use miden::standards::access::ownable2step->test_account + @note_script + pub proc main + repeat.14 push.0 end + push.0 + push.0 + # => [new_owner_suffix, new_owner_prefix, pad(14)] + call.test_account::transfer_ownership + dropw dropw dropw dropw + end + "#; + + let note = NoteBuilder::new(sender, rng) + .source_manager(source_manager) + .code(script) + .build()?; + + Ok(note) +} + fn create_renounce_note( sender: AccountId, rng: &mut RandomCoin, @@ -340,9 +365,9 @@ async fn test_cancel_transfer() -> anyhow::Result<()> { mock_chain.add_pending_executed_transaction(&executed)?; mock_chain.prove_next_block()?; - // Step 2: cancel by transferring to self (owner) + // Step 2: cancel by transferring to the zero address let mut rng2 = RandomCoin::new([Felt::from(200u32); 4].into()); - let cancel_note = create_transfer_note(owner, owner, &mut rng2, Arc::clone(&source_manager))?; + let cancel_note = create_cancel_note(owner, &mut rng2, Arc::clone(&source_manager))?; let tx2 = mock_chain .build_tx_context(updated.clone(), &[], std::slice::from_ref(&cancel_note))? @@ -358,10 +383,10 @@ async fn test_cancel_transfer() -> anyhow::Result<()> { Ok(()) } -/// Tests that an owner can transfer to themselves when no nominated transfer exists. -/// This is a no-op but should succeed without errors. +/// Tests that passing the current owner's account ID as the new owner is NOT a +/// cancellation but creates a self-nomination. To cancel, the zero address must be used. #[tokio::test] -async fn test_transfer_to_self_no_nominated() -> anyhow::Result<()> { +async fn test_transfer_to_self_creates_self_nomination() -> anyhow::Result<()> { let owner = AccountIdBuilder::new().build_with_seed([1; 32]); let account = create_ownable_account(owner, vec![])?; @@ -386,18 +411,52 @@ async fn test_transfer_to_self_no_nominated() -> anyhow::Result<()> { updated.apply_delta(executed.account_delta())?; assert_eq!(get_owner_from_storage(&updated)?, Some(owner)); - assert_eq!(get_nominated_owner_from_storage(&updated)?, None); + assert_eq!(get_nominated_owner_from_storage(&updated)?, Some(owner)); Ok(()) } #[tokio::test] async fn test_renounce_ownership() -> anyhow::Result<()> { + let owner = AccountIdBuilder::new().build_with_seed([1; 32]); + + let account = create_ownable_account(owner, vec![])?; + + let mut builder = MockChain::builder(); + builder.add_account(account.clone())?; + + let source_manager: Arc = Arc::new(DefaultSourceManager::default()); + let mut rng = RandomCoin::new([Felt::from(200u32); 4].into()); + let renounce_note = create_renounce_note(owner, &mut rng, Arc::clone(&source_manager))?; + + builder.add_output_note(RawOutputNote::Full(renounce_note.clone())); + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + let tx = mock_chain + .build_tx_context(account.id(), &[renounce_note.id()], &[])? + .with_source_manager(source_manager) + .build()?; + let executed = tx.execute().await?; + + let mut final_account = account.clone(); + final_account.apply_delta(executed.account_delta())?; + + assert_eq!(get_owner_from_storage(&final_account)?, None); + assert_eq!(get_nominated_owner_from_storage(&final_account)?, None); + Ok(()) +} + +/// Tests that renounce_ownership is rejected while a nominated transfer is in progress. +#[tokio::test] +async fn test_renounce_ownership_fails_with_pending_transfer() -> anyhow::Result<()> { + use miden_standards::errors::standards::ERR_OWNERSHIP_TRANSFER_IN_PROGRESS; + let owner = AccountIdBuilder::new().build_with_seed([1; 32]); let new_owner = AccountIdBuilder::new().build_with_seed([2; 32]); let account = create_ownable_account(owner, vec![])?; - // Step 1: transfer (to have nominated) + // Step 1: nominate a new owner (pending transfer). let mut builder = MockChain::builder(); builder.add_account(account.clone())?; @@ -419,11 +478,11 @@ async fn test_renounce_ownership() -> anyhow::Result<()> { let mut updated = account.clone(); updated.apply_delta(executed.account_delta())?; - // Commit step 1 to the chain + // Commit step 1 to the chain. mock_chain.add_pending_executed_transaction(&executed)?; mock_chain.prove_next_block()?; - // Step 2: renounce + // Step 2: try to renounce while a transfer is pending — must fail. let mut rng2 = RandomCoin::new([Felt::from(200u32); 4].into()); let renounce_note = create_renounce_note(owner, &mut rng2, Arc::clone(&source_manager))?; @@ -431,13 +490,9 @@ async fn test_renounce_ownership() -> anyhow::Result<()> { .build_tx_context(updated.clone(), &[], std::slice::from_ref(&renounce_note))? .with_source_manager(source_manager) .build()?; - let executed2 = tx2.execute().await?; - - let mut final_account = updated.clone(); - final_account.apply_delta(executed2.account_delta())?; + let result = tx2.execute().await; - assert_eq!(get_owner_from_storage(&final_account)?, None); - assert_eq!(get_nominated_owner_from_storage(&final_account)?, None); + assert_transaction_executor_error!(result, ERR_OWNERSHIP_TRANSFER_IN_PROGRESS); Ok(()) } @@ -454,7 +509,7 @@ async fn test_transfer_ownership_fails_with_invalid_account_id() -> anyhow::Resu builder.add_account(account.clone())?; let invalid_prefix = owner.prefix().as_felt(); - let invalid_suffix = Felt::new(1); + let invalid_suffix = Felt::ONE; let script = format!( r#" diff --git a/crates/miden-testing/tests/scripts/p2id.rs b/crates/miden-testing/tests/scripts/p2id.rs index a7989a81ef..8c4b376101 100644 --- a/crates/miden-testing/tests/scripts/p2id.rs +++ b/crates/miden-testing/tests/scripts/p2id.rs @@ -2,7 +2,7 @@ use miden_protocol::account::Account; use miden_protocol::account::auth::AuthScheme; use miden_protocol::asset::{Asset, AssetVault, FungibleAsset}; use miden_protocol::crypto::rand::RandomCoin; -use miden_protocol::note::{NoteAttachment, NoteTag, NoteType}; +use miden_protocol::note::{NoteAttachments, NoteTag, NoteType}; use miden_protocol::testing::account_id::{ ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET, ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2, @@ -66,7 +66,7 @@ async fn p2id_script_multiple_assets() -> anyhow::Result<()> { AssetVault::new(&[fungible_asset_1, fungible_asset_2]).unwrap(), target_account.storage().clone(), target_account.code().clone(), - Felt::new(2), + Felt::new_unchecked(2), ); assert_eq!( @@ -132,7 +132,7 @@ async fn prove_consume_note_with_new_account() -> anyhow::Result<()> { AssetVault::new(&[fungible_asset]).unwrap(), target_account.storage().clone(), target_account.code().clone(), - Felt::new(1), + Felt::ONE, ); assert_eq!( @@ -178,7 +178,7 @@ async fn prove_consume_multiple_notes() -> anyhow::Result<()> { account.apply_delta(executed_transaction.account_delta())?; let resulting_asset = account.vault().assets().next().unwrap(); if let Asset::Fungible(asset) = resulting_asset { - assert_eq!(asset.amount(), 123u64); + assert_eq!(asset.amount().as_u64(), 123); } else { panic!("Resulting asset should be fungible"); } @@ -227,7 +227,7 @@ async fn test_create_consume_multiple_notes() -> anyhow::Result<()> { ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2.try_into()?, vec![asset_1], NoteType::Public, - NoteAttachment::default(), + NoteAttachments::default(), &mut RandomCoin::new(Word::from([1, 2, 3, 4u32])), )?; @@ -236,7 +236,7 @@ async fn test_create_consume_multiple_notes() -> anyhow::Result<()> { ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE.try_into()?, vec![asset_2], NoteType::Public, - NoteAttachment::default(), + NoteAttachments::default(), &mut RandomCoin::new(Word::from([4, 3, 2, 1u32])), )?; @@ -294,14 +294,15 @@ async fn test_create_consume_multiple_notes() -> anyhow::Result<()> { account.apply_delta(executed_transaction.account_delta())?; - assert_eq!(account.vault().get_balance(input_note_faucet_id)?, 111); - assert_eq!(account.vault().get_balance(FungibleAsset::mock_issuer())?, 5); + 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); + Ok(()) } /// Tests the P2ID `new` MASM constructor procedure. /// This test verifies that calling `p2id::new` from a transaction script creates an output note -/// with the same recipient as `P2idNote::build_recipient` would create. +/// with the same recipient as `P2idNoteStorage::into_recipient` would create. #[tokio::test] async fn test_p2id_new_constructor() -> anyhow::Result<()> { let mut builder = MockChain::builder(); @@ -370,7 +371,7 @@ async fn test_p2id_new_constructor() -> anyhow::Result<()> { target_account.id(), vec![FungibleAsset::mock(50)], NoteType::Public, - NoteAttachment::default(), + NoteAttachments::default(), &mut RandomCoin::new(serial_num), )?; @@ -393,7 +394,7 @@ async fn test_p2id_new_constructor() -> anyhow::Result<()> { assert_eq!( created_recipient.digest(), expected_recipient.digest(), - "The recipient created by p2id::new should match P2idNote::build_recipient" + "The recipient created by p2id::new should match P2idNoteStorage::into_recipient" ); Ok(()) diff --git a/crates/miden-testing/tests/scripts/p2ide.rs b/crates/miden-testing/tests/scripts/p2ide.rs index 67d1ea41c0..d9b9299340 100644 --- a/crates/miden-testing/tests/scripts/p2ide.rs +++ b/crates/miden-testing/tests/scripts/p2ide.rs @@ -51,7 +51,7 @@ async fn p2ide_script_success_without_reclaim_or_timelock() -> anyhow::Result<() AssetVault::new(&[fungible_asset])?, target_account.storage().clone(), target_account.code().clone(), - Felt::new(2), + Felt::new_unchecked(2), ); assert_eq!( executed_transaction_2.final_account().to_commitment(), @@ -89,7 +89,7 @@ async fn p2ide_script_success_timelock_unlock_before_reclaim_height() -> anyhow: AssetVault::new(&[fungible_asset])?, target_account.storage().clone(), target_account.code().clone(), - Felt::new(2), + Felt::new_unchecked(2), ); assert_eq!( executed_transaction_1.final_account().to_commitment(), @@ -165,7 +165,7 @@ async fn p2ide_script_timelocked_reclaim_disabled() -> anyhow::Result<()> { AssetVault::new(&[fungible_asset])?, target_account.storage().clone(), target_account.code().clone(), - Felt::new(2), + Felt::new_unchecked(2), ); assert_eq!(final_tx.final_account().to_commitment(), target_after.to_commitment()); @@ -216,7 +216,7 @@ async fn p2ide_script_reclaim_fails_before_timelock_expiry() -> anyhow::Result<( AssetVault::new(&[fungible_asset])?, sender_account.storage().clone(), sender_account.code().clone(), - Felt::new(2), + Felt::new_unchecked(2), ); assert_eq!( @@ -300,7 +300,7 @@ async fn p2ide_script_reclaimable_timelockable() -> anyhow::Result<()> { AssetVault::new(&[fungible_asset])?, target_account.storage().clone(), target_account.code().clone(), - Felt::new(2), + Felt::new_unchecked(2), ); assert_eq!(final_tx.final_account().to_commitment(), target_after.to_commitment()); @@ -346,7 +346,7 @@ async fn p2ide_script_reclaim_success_after_timelock() -> anyhow::Result<()> { AssetVault::new(&[fungible_asset])?, sender_account.storage().clone(), sender_account.code().clone(), - Felt::new(2), + Felt::new_unchecked(2), ); assert_eq!(final_tx.final_account().to_commitment(), sender_after.to_commitment()); diff --git a/crates/miden-testing/tests/scripts/pausable.rs b/crates/miden-testing/tests/scripts/pausable.rs new file mode 100644 index 0000000000..e12339036f --- /dev/null +++ b/crates/miden-testing/tests/scripts/pausable.rs @@ -0,0 +1,618 @@ +//! Tests for the `Pausable` storage component, `PausableManager` admin wrapper, and the +//! pause integration (mint / burn / transfer / metadata setters). +//! +//! A single `pause()` call by an authorized sender blocks mint, burn, transfer, and metadata +//! updates uniformly until a matching `unpause()` call. + +extern crate alloc; + +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::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_standards::account::faucets::{FungibleFaucet, TokenName}; +use miden_standards::account::policies::{ + BurnPolicyConfig, + MintPolicyConfig, + PolicyRegistration, + TokenPolicyManager, + TransferPolicy, +}; +use miden_standards::testing::note::NoteBuilder; +use miden_testing::{ + AccountState, + Auth, + MockChain, + MockChainBuilder, + 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)); + +fn test_account_id(seed: u8) -> AccountId { + AccountId::dummy([seed; 15], AccountIdVersion::Version1, AccountType::Private) +} + +// FAUCET BUILDER +// ================================================================================================ + +/// Builds a fungible faucet with `Pausable + PausableManager + Ownable2Step(owner)`. Pause / +/// unpause are gated by the owner via `Authority::OwnerControlled` (installed automatically by +/// `AccessControl::Ownable2Step`). +fn add_faucet_with_pause( + builder: &mut MockChainBuilder, + owner: AccountId, +) -> 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([43u8; 32]) + .account_type(AccountType::Public) + .with_component(faucet) + .with_components(AccessControl::Ownable2Step { owner }) + .with_component(PausableManager); + + builder.add_account_from_builder(Auth::IncrNonce, account_builder, AccountState::Exists) +} + +// NOTE BUILDERS +// ================================================================================================ + +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) + .note_type(NoteType::Private) + .code(code.into()) + .build()?) +} + +/// Builds an owner-authored note that calls `pausable::manager::pause`. +fn build_pause_note(sender: AccountId) -> anyhow::Result { + build_note( + sender, + r#" + use miden::standards::access::pausable::manager + + @note_script + pub proc main + repeat.16 push.0 end + call.manager::pause + dropw dropw dropw dropw + end + "#, + ) +} + +/// Builds an owner-authored note that calls `pausable::manager::unpause`. +fn build_unpause_note(sender: AccountId) -> anyhow::Result { + build_note( + sender, + r#" + use miden::standards::access::pausable::manager + + @note_script + pub proc main + repeat.16 push.0 end + call.manager::unpause + dropw dropw dropw dropw + end + "#, + ) +} + +async fn execute_note_on_faucet( + mock_chain: &mut MockChain, + faucet_id: AccountId, + note: &Note, +) -> anyhow::Result<()> { + let executed = mock_chain + .build_tx_context(faucet_id, &[note.id()], &[])? + .build()? + .execute() + .await?; + mock_chain.add_pending_executed_transaction(&executed)?; + mock_chain.prove_next_block()?; + Ok(()) +} + +// TESTS — PAUSABLE MANAGER (Authority dispatch) +// ================================================================================================ + +#[tokio::test] +async fn pausable_manager_pause_succeeds_when_sender_is_owner() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let faucet = add_faucet_with_pause(&mut builder, *OWNER_ID)?; + + let pause_note = build_pause_note(*OWNER_ID)?; + 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(), &pause_note).await?; + + Ok(()) +} + +#[tokio::test] +async fn pausable_manager_pause_fails_when_sender_not_owner() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let faucet = add_faucet_with_pause(&mut builder, *OWNER_ID)?; + + // Authority::OwnerControlled (installed by AccessControl::Ownable2Step) rejects non-owner + // senders for any procedure that calls `exec.authority::assert_authorized`. + let attacker_note = build_pause_note(*NON_OWNER_ID)?; + builder.add_output_note(RawOutputNote::Full(attacker_note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + 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(()) +} + +#[tokio::test] +async fn pausable_manager_unpause_fails_when_sender_not_owner() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let faucet = add_faucet_with_pause(&mut builder, *OWNER_ID)?; + + // Pre-stage both notes: legitimate owner pause + attacker unpause. + let pause_note = build_pause_note(*OWNER_ID)?; + let attacker_unpause_note = build_unpause_note(*NON_OWNER_ID)?; + builder.add_output_note(RawOutputNote::Full(pause_note.clone())); + builder.add_output_note(RawOutputNote::Full(attacker_unpause_note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + execute_note_on_faucet(&mut mock_chain, faucet.id(), &pause_note).await?; + + let result = mock_chain + .build_tx_context(faucet.id(), &[attacker_unpause_note.id()], &[])? + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_SENDER_NOT_OWNER); + + Ok(()) +} + +#[tokio::test] +async fn pausable_manager_pause_then_unpause_then_pause_again() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let faucet = add_faucet_with_pause(&mut builder, *OWNER_ID)?; + + let pause_note_1 = build_pause_note(*OWNER_ID)?; + let unpause_note = build_unpause_note(*OWNER_ID)?; + let pause_note_2 = build_pause_note(*OWNER_ID)?; + builder.add_output_note(RawOutputNote::Full(pause_note_1.clone())); + builder.add_output_note(RawOutputNote::Full(unpause_note.clone())); + builder.add_output_note(RawOutputNote::Full(pause_note_2.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + execute_note_on_faucet(&mut mock_chain, faucet.id(), &pause_note_1).await?; + execute_note_on_faucet(&mut mock_chain, faucet.id(), &unpause_note).await?; + execute_note_on_faucet(&mut mock_chain, faucet.id(), &pause_note_2).await?; + + Ok(()) +} + +#[tokio::test] +async fn pausable_manager_unpause_while_unpaused_is_noop() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let faucet = add_faucet_with_pause(&mut builder, *OWNER_ID)?; + + let unpause_note = build_unpause_note(*OWNER_ID)?; + builder.add_output_note(RawOutputNote::Full(unpause_note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + execute_note_on_faucet(&mut mock_chain, faucet.id(), &unpause_note).await?; + + Ok(()) +} + +#[tokio::test] +async fn pausable_manager_pause_while_paused_is_noop() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let faucet = add_faucet_with_pause(&mut builder, *OWNER_ID)?; + + let pause_note_1 = build_pause_note(*OWNER_ID)?; + let pause_note_2 = build_pause_note(*OWNER_ID)?; + builder.add_output_note(RawOutputNote::Full(pause_note_1.clone())); + builder.add_output_note(RawOutputNote::Full(pause_note_2.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + execute_note_on_faucet(&mut mock_chain, faucet.id(), &pause_note_1).await?; + execute_note_on_faucet(&mut mock_chain, faucet.id(), &pause_note_2).await?; + + Ok(()) +} + +// SANITY TESTS — PausableStorage helper +// ================================================================================================ + +#[test] +fn pausable_storage_default_is_unpaused() { + let storage = PausableStorage::default(); + assert!(!storage.state()); + assert_eq!(storage.to_word(), Word::default()); +} + +#[test] +fn pausable_storage_paused_writes_canonical_word() { + let storage = PausableStorage::paused(); + assert!(storage.state()); + assert_eq!(storage.to_word(), Word::from([1u32, 0, 0, 0])); +} + +// TESTS — PAUSE (mint / burn / transfer policies) +// ================================================================================================ +fn add_faucet_with_pause_and_policies( + builder: &mut MockChainBuilder, + owner: AccountId, +) -> 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([44u8; 32]) + .account_type(AccountType::Public) + .with_component(faucet) + .with_components(AccessControl::Ownable2Step { owner }) + .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)?, + ); + + builder.add_account_from_builder(Auth::IncrNonce, account_builder, AccountState::Exists) +} + +#[tokio::test] +async fn pausable_transfer_succeeds_when_unpaused() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + 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 note = builder.add_p2id_note( + faucet.id(), + target.id(), + &[Asset::Fungible(asset)], + NoteType::Public, + )?; + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + let faucet_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; + + mock_chain + .build_tx_context(target.id(), &[note.id()], &[])? + .foreign_accounts(vec![faucet_inputs]) + .build()? + .execute() + .await?; + + Ok(()) +} + +#[tokio::test] +async fn pausable_transfer_fails_when_paused() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + 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 note = builder.add_p2id_note( + faucet.id(), + target.id(), + &[Asset::Fungible(asset)], + NoteType::Public, + )?; + + let pause_note = build_pause_note(*OWNER_ID)?; + 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(), &pause_note).await?; + + let faucet_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; + + let result = mock_chain + .build_tx_context(target.id(), &[note.id()], &[])? + .foreign_accounts(vec![faucet_inputs]) + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_PAUSABLE_IS_PAUSED); + + Ok(()) +} + +#[tokio::test] +async fn pausable_transfer_resumes_after_unpause() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + 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 note = builder.add_p2id_note( + faucet.id(), + target.id(), + &[Asset::Fungible(asset)], + NoteType::Public, + )?; + + let pause_note = build_pause_note(*OWNER_ID)?; + let unpause_note = build_unpause_note(*OWNER_ID)?; + builder.add_output_note(RawOutputNote::Full(pause_note.clone())); + builder.add_output_note(RawOutputNote::Full(unpause_note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + execute_note_on_faucet(&mut mock_chain, faucet.id(), &pause_note).await?; + execute_note_on_faucet(&mut mock_chain, faucet.id(), &unpause_note).await?; + + let faucet_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; + + mock_chain + .build_tx_context(target.id(), &[note.id()], &[])? + .foreign_accounts(vec![faucet_inputs]) + .build()? + .execute() + .await?; + + Ok(()) +} + +// TESTS — MINT / BURN / METADATA SETTER PAUSE +// ================================================================================================ + +#[tokio::test] +async fn pausable_mint_fails_when_paused() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let _target = builder.add_existing_wallet(Auth::IncrNonce)?; + let faucet = add_faucet_with_pause_and_policies(&mut builder, *OWNER_ID)?; + + let pause_note = build_pause_note(*OWNER_ID)?; + builder.add_output_note(RawOutputNote::Full(pause_note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + // Pause the faucet first. + execute_note_on_faucet(&mut mock_chain, faucet.id(), &pause_note).await?; + + // Build a mint tx-script targeting the now-paused faucet. + let recipient_word = Word::from([0u32, 1, 2, 3]); + let tx_script_code = format!( + r#" + begin + padw padw push.0 + push.{recipient} + push.{note_type} + push.{tag} + push.{amount} + call.::miden::standards::faucets::fungible::mint_and_send + dropw dropw dropw dropw + end + "#, + recipient = recipient_word, + note_type = NoteType::Private as u8, + tag = u32::from(NoteTag::default()), + amount = 100u64, + ); + let tx_script = + miden_standards::code_builder::CodeBuilder::default().compile_tx_script(&tx_script_code)?; + + let result = mock_chain + .build_tx_context(faucet.id(), &[], &[])? + .tx_script(tx_script) + .build()? + .execute() + .await; + + // execute_mint_policy calls exec.pausable::assert_not_paused before dispatch → panic. + assert_transaction_executor_error!(result, ERR_PAUSABLE_IS_PAUSED); + + Ok(()) +} + +#[tokio::test] +async fn pausable_burn_fails_when_paused() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + 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_note_script_code = r#" + @note_script + pub proc main + dropw + call.::miden::standards::faucets::fungible::receive_and_burn + end + "#; + let burn_note_script = miden_standards::code_builder::CodeBuilder::default() + .compile_note_script(burn_note_script_code)?; + let mut rng = RandomCoin::new(Word::from([1u32, 2, 3, 4])); + let burn_note = NoteBuilder::new(faucet.id(), &mut rng) + .note_type(NoteType::Private) + .add_assets([Asset::Fungible(burn_asset)]) + .script(burn_note_script) + .build()?; + builder.add_output_note(RawOutputNote::Full(burn_note.clone())); + + let pause_note = build_pause_note(*OWNER_ID)?; + 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(), &pause_note).await?; + + let result = mock_chain + .build_tx_context(faucet.id(), &[burn_note.id()], &[])? + .build()? + .execute() + .await; + + // execute_burn_policy → exec.pausable::assert_not_paused → panic. + assert_transaction_executor_error!(result, ERR_PAUSABLE_IS_PAUSED); + + Ok(()) +} + +/// Builds a faucet with mutable `max_supply` so that `set_max_supply` can be called via the +/// metadata setter path (which we want to verify is pause-gated). +fn add_faucet_mutable_max_supply_with_pause( + builder: &mut MockChainBuilder, + owner: AccountId, +) -> 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([45u8; 32]) + .account_type(AccountType::Public) + .with_component(faucet) + .with_components(AccessControl::Ownable2Step { owner }) + .with_component(PausableManager); + + builder.add_account_from_builder(Auth::IncrNonce, account_builder, AccountState::Exists) +} + +#[tokio::test] +async fn pausable_set_max_supply_fails_when_paused() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let faucet = add_faucet_mutable_max_supply_with_pause(&mut builder, *OWNER_ID)?; + + // Owner-authored note that calls set_max_supply. + let set_max_supply_note_code = format!( + r#" + @note_script + pub proc main + push.{new_max_supply} + swap drop + call.::miden::standards::faucets::fungible::set_max_supply + end + "#, + new_max_supply = 500_000u64, + ); + let set_max_supply_note_script = miden_standards::code_builder::CodeBuilder::default() + .compile_note_script(&set_max_supply_note_code)?; + let mut rng = RandomCoin::new(Word::from([9u32, 8, 7, 6])); + let set_max_supply_note = NoteBuilder::new(*OWNER_ID, &mut rng) + .note_type(NoteType::Private) + .script(set_max_supply_note_script) + .build()?; + builder.add_output_note(RawOutputNote::Full(set_max_supply_note.clone())); + + let pause_note = build_pause_note(*OWNER_ID)?; + builder.add_output_note(RawOutputNote::Full(pause_note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + // Pause first. + execute_note_on_faucet(&mut mock_chain, faucet.id(), &pause_note).await?; + + // Now try to update max_supply — should fail because set_max_supply has + // `exec.pausable::assert_not_paused` after the authority check. + let result = mock_chain + .build_tx_context(faucet.id(), &[set_max_supply_note.id()], &[])? + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_PAUSABLE_IS_PAUSED); + + Ok(()) +} + +// TESTS — PAUSABLE MANAGER WITH AUTH-CONTROLLED ACCESS CONTROL +// ================================================================================================ + +/// Same as `add_faucet_with_pause` but uses `AccessControl::AuthControlled`. +fn add_faucet_with_pause_auth_controlled( + builder: &mut MockChainBuilder, +) -> 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([46u8; 32]) + .account_type(AccountType::Public) + .with_component(faucet) + .with_components(AccessControl::AuthControlled) + .with_component(PausableManager); + + builder.add_account_from_builder(Auth::IncrNonce, account_builder, AccountState::Exists) +} + +#[tokio::test] +async fn pausable_manager_works_with_auth_controlled() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let faucet = add_faucet_with_pause_auth_controlled(&mut builder)?; + + // Any sender works because Authority::AuthControlled defers auth entirely to the account's + // own auth scheme (here Auth::IncrNonce, which accepts unconditionally). + let pause_note = build_pause_note(*NON_OWNER_ID)?; + 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(), &pause_note).await?; + + Ok(()) +} diff --git a/crates/miden-testing/tests/scripts/pswap.rs b/crates/miden-testing/tests/scripts/pswap.rs new file mode 100644 index 0000000000..d8beab912b --- /dev/null +++ b/crates/miden-testing/tests/scripts/pswap.rs @@ -0,0 +1,1792 @@ +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::crypto::rand::{FeltRng, RandomCoin}; +use miden_protocol::errors::MasmError; +use miden_protocol::note::{Note, NoteAttachments, NoteType}; +use miden_protocol::transaction::RawOutputNote; +use miden_protocol::{Felt, ONE, Word, ZERO}; +use miden_standards::account::wallets::BasicWallet; +use miden_standards::errors::standards::{ + ERR_PSWAP_FILL_EXCEEDS_REQUESTED, + ERR_PSWAP_FILL_SUM_OVERFLOW, + ERR_PSWAP_NOT_VALID_ASSET_AMOUNT, +}; +use miden_standards::note::{PswapNote, PswapNoteAttachment, PswapNoteStorage}; +use miden_standards::testing::note::NoteBuilder; +use miden_testing::{Auth, MockChain, MockChainBuilder, assert_transaction_executor_error}; +use rand::SeedableRng; +use rand::rngs::SmallRng; +use rstest::rstest; + +// CONSTANTS +// ================================================================================================ + +const BASIC_AUTH: Auth = Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, +}; + +// HELPERS +// ================================================================================================ + +/// Extracts the first attachment's word content from a `NoteAttachments`. +fn first_attachment_word(attachments: &NoteAttachments) -> Word { + let content = attachments.get(0).expect("expected at least one attachment").content(); + assert_eq!(content.num_words(), 1, "expected single word attachment"); + content.as_words()[0] +} + +/// Builds a PswapNote, registers it on the builder as an output note, and returns +/// both the `PswapNote` (for `.execute()`) and the protocol `Note` (for +/// `.id()` / `RawOutputNote::Full`), so callers don't need to round-trip via +/// `PswapNote::try_from(¬e)?`. Serial number is drawn from the builder's rng. +fn build_pswap_note( + builder: &mut MockChainBuilder, + sender: AccountId, + offered_asset: FungibleAsset, + requested_asset: FungibleAsset, + note_type: NoteType, +) -> anyhow::Result<(PswapNote, Note)> { + let serial_number = builder.rng_mut().draw_word(); + let storage = PswapNoteStorage::builder() + .requested_asset(requested_asset) + .creator_account_id(sender) + .build(); + let pswap = PswapNote::builder() + .sender(sender) + .storage(storage) + .serial_number(serial_number) + .note_type(note_type) + .offered_asset(offered_asset) + .build()?; + let note: Note = pswap.clone().into(); + builder.add_output_note(RawOutputNote::Full(note.clone())); + Ok((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, +) { + 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); +} + +// TESTS +// ================================================================================================ + +/// Verifies that Alice can independently reconstruct and consume the P2ID payback note +/// using only her original PSWAP data and the on-chain attachment data from Bob's tx. +/// +/// Flow: +/// 1. Alice creates a PSWAP note (50 USDC for 25 ETH) with a parameterized payback note type. +/// 2. Bob fills it (fully or partially per case) → produces a P2ID payback (+ remainder on +/// partial). +/// 3. Alice reconstructs the payback Note via `PswapNote::payback_note` using only the on-chain +/// attachment data. On partial fills she also reconstructs the remainder via +/// `PswapNote::remainder_note`. +/// 4. Alice consumes the *reconstructed* P2ID payback (fed unauthenticated, the only path available +/// against a real chain for private paybacks where only the commitment is on-chain) and verifies +/// she receives the filled amount. +/// +/// The private case is the headline discovery use case: the chain holds only a commitment, +/// so Alice's only path to consume is to reconstruct the body from her PSWAP + attachment. +#[rstest] +#[case::partial_public(NoteType::Public, 20)] +#[case::full_public(NoteType::Public, 25)] +#[case::partial_private(NoteType::Private, 20)] +#[case::full_private(NoteType::Private, 25)] +#[tokio::test] +async fn pswap_note_alice_reconstructs_and_consumes_p2id( + #[case] payback_note_type: NoteType, + #[case] fill_amount: u64, +) -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + 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 alice = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(usdc_faucet.id(), 50)?.into()], + )?; + let bob = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(eth_faucet.id(), fill_amount)?.into()], + )?; + + let offered_asset = FungibleAsset::new(usdc_faucet.id(), 50)?; + let requested_asset = FungibleAsset::new(eth_faucet.id(), 25)?; + let is_partial = fill_amount < u64::from(requested_asset.amount()); + + let mut rng = RandomCoin::new(Word::default()); + let serial_number = rng.draw_word(); + let storage = PswapNoteStorage::builder() + .requested_asset(requested_asset) + .creator_account_id(alice.id()) + .payback_note_type(payback_note_type) + .build(); + let pswap = PswapNote::builder() + .sender(alice.id()) + .storage(storage) + .serial_number(serial_number) + .note_type(NoteType::Public) + .offered_asset(offered_asset) + .build()?; + let pswap_note: Note = pswap.clone().into(); + builder.add_output_note(RawOutputNote::Full(pswap_note.clone())); + + let mut mock_chain = builder.build()?; + + // --- Step 1: Bob fills the PSWAP note --- + + 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 mut expected_output_notes = vec![RawOutputNote::Full(p2id_note.clone())]; + let predicted_remainder = if is_partial { + let r = remainder_pswap.expect("partial fill should produce remainder"); + let rn = Note::from(r); + expected_output_notes.push(RawOutputNote::Full(rn.clone())); + Some(rn) + } else { + assert!(remainder_pswap.is_none(), "full fill should not produce a remainder"); + None + }; + + let tx_context = mock_chain + .build_tx_context(bob.id(), &[pswap_note.id()], &[])? + .extend_note_args(note_args_map) + .extend_expected_output_notes(expected_output_notes) + .build()?; + + let executed_transaction = tx_context.execute().await?; + mock_chain.add_pending_executed_transaction(&executed_transaction)?; + mock_chain.prove_next_block()?; + + // --- Step 2: Alice reconstructs the P2ID payback from on-chain attachment data --- + + // Read attachments from the executed tx (the body is still here even when the note will + // ultimately land on-chain as a header-only private commitment). + let output_p2id = executed_transaction.output_notes().get_note(0); + let attachment_word = first_attachment_word(output_p2id.attachments()); + let fill_amount_from_aux = attachment_word[0].as_canonical_u64(); + assert_eq!(fill_amount_from_aux, fill_amount, "fill amount from aux should match the case"); + + // Parity check: Rust-predicted P2ID attachment must match the MASM output. + assert_eq!( + first_attachment_word(p2id_note.attachments()), + attachment_word, + "Rust-predicted P2ID attachment does not match the MASM-produced one", + ); + + // Depth = 1 (first fill). Consumer comes from the on-chain payback's metadata sender. + let payback_attachment = + PswapNoteAttachment::new(AssetAmount::new(fill_amount_from_aux)?, pswap.order_id(), 1); + let reconstructed_payback = + pswap.payback_note(output_p2id.metadata().sender(), &payback_attachment)?; + + assert_eq!( + reconstructed_payback.recipient().digest(), + output_p2id.recipient_digest(), + "Alice's reconstructed P2ID recipient does not match the actual output" + ); + + // --- Step 2b: On partial fills, Alice also reconstructs the remainder PSWAP --- + + if is_partial { + let output_remainder = executed_transaction.output_notes().get_note(1); + let remainder_attachment_word = first_attachment_word(output_remainder.attachments()); + let amt_payout_from_attachment = remainder_attachment_word[0].as_canonical_u64(); + + let expected_payout = pswap.calculate_offered_for_requested(fill_amount_from_aux)?; + assert_eq!( + amt_payout_from_attachment, expected_payout, + "remainder aux should carry amt_payout matching the Rust-side calc", + ); + + let remaining_requested = + (requested_asset.amount() - AssetAmount::new(fill_amount_from_aux)?)?; + let remaining_offered = + (pswap.offered_asset().amount() - AssetAmount::new(amt_payout_from_attachment)?)?; + + let remainder_attachment = PswapNoteAttachment::new( + AssetAmount::new(amt_payout_from_attachment)?, + pswap.order_id(), + 1, + ); + let reconstructed_remainder = pswap.remainder_note( + output_remainder.metadata().sender(), + &remainder_attachment, + remaining_offered, + remaining_requested, + )?; + + // Parity: Rust-predicted remainder must match the executed output. + let predicted_remainder = predicted_remainder + .as_ref() + .expect("predicted remainder must exist on partial fill"); + assert_eq!( + predicted_remainder.recipient().digest(), + output_remainder.recipient_digest(), + "Rust-predicted remainder recipient does not match executed output", + ); + + assert_eq!( + reconstructed_remainder.details_commitment(), + output_remainder.details_commitment(), + "reconstructed remainder commitment must match on-chain leaf", + ); + } + + // --- Step 3: Alice consumes the *reconstructed* P2ID payback --- + // + // The note is fed via the unauthenticated path: Alice provides the body herself, and + // the chain validates that the body's commitment matches the one recorded by Bob's tx. + // This is the only path for private paybacks (no body on-chain) and works equally for + // public ones. + + let tx_context = mock_chain + .build_tx_context(alice.id(), &[], slice::from_ref(&reconstructed_payback))? + .build()?; + + 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)?); + + Ok(()) +} + +/// Dedicated regression test for the attachment word layout shared between +/// `create_p2id_note` / `create_remainder_note` in pswap.masm and +/// `create_payback_note` / `create_remainder_pswap_note` in pswap.rs. +/// +/// Both sides agree on: +/// - P2ID payback attachment: `[fill_amount, order_id, depth, 0]`, scheme = PswapAttachment +/// - Remainder PSWAP attachment: `[amt_payout, order_id, depth, 0]`, scheme = PswapAttachment +/// +/// `order_id` is the original creator's `serial[1]` (stable across the lineage), and +/// `depth` is the 1-indexed round number (1 for the first fill of an original PSWAP). +/// If either side drifts (e.g. MASM switches the slots, or one side forgets the scheme), +/// this test fires. +/// +/// Uses a simple partial fill — offered 50 USDC, requested 25 ETH, fill 20 ETH +/// — so both output notes exist and the expected amounts are +/// `fill_amount = 20` and `amt_payout = floor(50 * 20 / 25) = 40`. +#[tokio::test] +async fn pswap_attachment_layout_matches_masm_test() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + 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 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()])?; + + let (pswap, pswap_note) = + build_pswap_note(&mut builder, alice.id(), usdc_50, eth_25, NoteType::Public)?; + + let mock_chain = builder.build()?; + + let fill_amount = 20u64; + let expected_payout = 40u64; // floor(50 * 20 / 25) + let order_id = pswap.order_id(); + let expected_depth = 1u64; // first fill of an original PSWAP + + 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(eth_20), None)?; + let remainder_note = + Note::from(remainder_pswap.expect("partial fill should produce remainder")); + + let tx_context = mock_chain + .build_tx_context(bob.id(), &[pswap_note.id()], &[])? + .extend_note_args(note_args_map) + .extend_expected_output_notes(vec![ + RawOutputNote::Full(p2id_note.clone()), + RawOutputNote::Full(remainder_note.clone()), + ]) + .build()?; + + let executed_transaction = tx_context.execute().await?; + let output_notes = executed_transaction.output_notes(); + assert_eq!(output_notes.num_notes(), 2, "expected P2ID + remainder"); + + let p2id_attachments = output_notes.get_note(0).attachments(); + let remainder_attachments = output_notes.get_note(1).attachments(); + + // Both output notes must carry exactly one attachment under PSWAP_ATTACHMENT_SCHEME. + assert_eq!(p2id_attachments.num_attachments(), 1, "payback expects 1 attachment"); + assert_eq!(remainder_attachments.num_attachments(), 1, "remainder expects 1 attachment"); + + let p2id_att = p2id_attachments.get(0).expect("payback attachment present"); + let remainder_att = remainder_attachments.get(0).expect("remainder attachment present"); + + assert_eq!( + p2id_att.attachment_scheme(), + PswapNote::PSWAP_ATTACHMENT_SCHEME, + "payback must use PSWAP_ATTACHMENT_SCHEME", + ); + assert_eq!( + remainder_att.attachment_scheme(), + PswapNote::PSWAP_ATTACHMENT_SCHEME, + "remainder must use PSWAP_ATTACHMENT_SCHEME", + ); + + // P2ID payback attachment word: [fill_amount, order_id, depth, 0]. + let expected_p2id_word = Word::from([ + Felt::try_from(fill_amount).expect("fill_amount fits in a felt"), + order_id, + Felt::try_from(expected_depth).expect("depth fits in a felt"), + ZERO, + ]); + assert_eq!( + p2id_att.content().as_words()[0], + expected_p2id_word, + "P2ID attachment word mismatch: expected [fill_amount, order_id, depth, 0]", + ); + + // Remainder PSWAP attachment word: [amt_payout, order_id, depth, 0]. + let expected_remainder_word = Word::from([ + Felt::try_from(expected_payout).expect("amt_payout fits in a felt"), + order_id, + Felt::try_from(expected_depth).expect("depth fits in a felt"), + ZERO, + ]); + assert_eq!( + remainder_att.content().as_words()[0], + expected_remainder_word, + "remainder attachment word mismatch: expected [amt_payout, order_id, depth, 0]", + ); + + // Cross-check: the Rust-predicted notes must produce the same attachment + // words as the on-chain executed ones. + assert_eq!( + first_attachment_word(p2id_note.attachments()), + p2id_att.content().as_words()[0], + "Rust-predicted P2ID attachment does not match MASM output", + ); + assert_eq!( + first_attachment_word(remainder_note.attachments()), + remainder_att.content().as_words()[0], + "Rust-predicted remainder attachment does not match MASM output", + ); + + // Sanity: order_id must equal the original PSWAP's serial[1]. + assert_eq!(order_id, pswap.serial_number()[1], "order_id should equal serial[1]"); + + Ok(()) +} + +/// Parameterized fill test covering: +/// - full public fill +/// - full private fill +/// - partial public fill (offered=8 USDC / requested=4 ETH / fill=3 ETH → payout=6 USDC, +/// remainder=2 USDC, all scaled by 10^18) +/// - full fill via a network account (no note_args → script defaults to full fill) +/// +/// Amounts are scaled by `AMOUNT_SCALE = 10^18` so the test exercises realistic +/// 18-decimal token base units (the wei-equivalent of ETH / most ERC-20 tokens). +/// This stresses the MASM payout calculation at operand sizes in the ~10^18 +/// range, verifying `u64::widening_mul` + `u128::div` handle them without +/// overflow. Base values stay below `AssetAmount::MAX ≈ 9.22 × 10^18`. +#[rstest] +#[case::full_public(4, NoteType::Public, false)] +#[case::full_private(4, NoteType::Private, false)] +#[case::partial_public(3, NoteType::Public, false)] +#[case::network_full_fill(4, NoteType::Public, true)] +#[tokio::test] +async fn pswap_fill_test( + #[case] fill_base: u64, + #[case] note_type: NoteType, + #[case] use_network_account: bool, +) -> anyhow::Result<()> { + // 10^18: one whole 18-decimal token (e.g. 1 ETH in wei). + const AMOUNT_SCALE: u64 = 1_000_000_000_000_000_000; + + let fill_amount = fill_base * AMOUNT_SCALE; + let offered_total = 8 * AMOUNT_SCALE; // 8 × 10^18 USDC offered + let requested_total = 4 * AMOUNT_SCALE; // 4 × 10^18 ETH requested + let max_supply = 9 * AMOUNT_SCALE; // just under AssetAmount::MAX + + let mut builder = MockChain::builder(); + + let usdc_faucet = + builder.add_existing_basic_faucet(BASIC_AUTH, "USDC", max_supply, Some(offered_total))?; + let eth_faucet = + builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", max_supply, Some(requested_total))?; + + let alice = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(usdc_faucet.id(), offered_total)?.into()], + )?; + + let consumer_id = if use_network_account { + let seed: [u8; 32] = builder.rng_mut().draw_word().into(); + let network_consumer = builder.add_account_from_builder( + BASIC_AUTH, + Account::builder(seed) + .account_type(AccountType::Public) + .with_component(BasicWallet) + .with_assets([FungibleAsset::new(eth_faucet.id(), fill_amount)?.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()], + )?; + bob.id() + }; + + let offered_asset = FungibleAsset::new(usdc_faucet.id(), offered_total)?; + let requested_asset = FungibleAsset::new(eth_faucet.id(), requested_total)?; + + 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 (p2id_note, remainder_pswap) = if use_network_account { + let p2id = pswap.execute_full_fill(consumer_id)?; + (p2id, None) + } else { + pswap.execute(consumer_id, Some(fill_asset), None)? + }; + + let is_partial = fill_amount < requested_total; + let payout_amount = pswap.calculate_offered_for_requested(fill_amount)?; + + let mut expected_notes = vec![RawOutputNote::Full(p2id_note.clone())]; + if let Some(remainder) = remainder_pswap { + expected_notes.push(RawOutputNote::Full(Note::from(remainder))); + } + + let mut tx_builder = mock_chain + .build_tx_context(consumer_id, &[pswap_note.id()], &[])? + .extend_expected_output_notes(expected_notes); + + if !use_network_account { + let mut note_args_map = BTreeMap::new(); + note_args_map.insert(pswap_note.id(), PswapNote::create_args(fill_amount, 0)?); + tx_builder = tx_builder.extend_note_args(note_args_map); + } + + let tx_context = tx_builder.build()?; + let executed_transaction = tx_context.execute().await?; + + // Verify output note count + let output_notes = executed_transaction.output_notes(); + let expected_count = if is_partial { 2 } else { 1 }; + assert_eq!( + output_notes.num_notes(), + expected_count, + "expected {expected_count} output notes" + ); + + // Verify the P2ID recipient matches our Rust prediction + let actual_recipient = output_notes.get_note(0).recipient_digest(); + let expected_recipient = p2id_note.recipient().digest(); + assert_eq!(actual_recipient, expected_recipient, "RECIPIENT MISMATCH!"); + + // 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)?, + ); + + // 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)?, + ); + } + + // 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)?, + ); + + mock_chain.add_pending_executed_transaction(&executed_transaction)?; + mock_chain.prove_next_block()?; + + Ok(()) +} + +#[tokio::test] +async fn pswap_note_note_fill_cross_swap_test() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + 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))?; + + // 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 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()])?; + let charlie = builder.add_existing_wallet_with_assets(BASIC_AUTH, [])?; + + // Alice's note: offers 50 USDC, requests 25 ETH + let (alice_pswap, alice_pswap_note) = + build_pswap_note(&mut builder, alice.id(), usdc_50, eth_25, NoteType::Public)?; + + // Bob's note: offers 25 ETH, requests 50 USDC + let (bob_pswap, bob_pswap_note) = + build_pswap_note(&mut builder, bob.id(), eth_25, usdc_50, NoteType::Public)?; + + let mock_chain = builder.build()?; + + // Note args: pure note fill (account_fill = 0, note_fill = full amount) + let mut note_args_map = BTreeMap::new(); + note_args_map.insert(alice_pswap_note.id(), PswapNote::create_args(0, 25)?); + note_args_map.insert(bob_pswap_note.id(), PswapNote::create_args(0, 50)?); + + // Expected P2ID notes + let (alice_p2id_note, _) = alice_pswap.execute(charlie.id(), None, Some(eth_25))?; + let (bob_p2id_note, _) = bob_pswap.execute(charlie.id(), None, Some(usdc_50))?; + + let tx_context = mock_chain + .build_tx_context(charlie.id(), &[alice_pswap_note.id(), bob_pswap_note.id()], &[])? + .extend_note_args(note_args_map) + .extend_expected_output_notes(vec![ + RawOutputNote::Full(alice_p2id_note), + RawOutputNote::Full(bob_p2id_note), + ]) + .build()?; + + let executed_transaction = tx_context.execute().await?; + + // Verify: 2 P2ID notes, one carrying Alice's requested (25 ETH), one + // carrying Bob's requested (50 USDC). + let output_notes = executed_transaction.output_notes(); + assert_eq!(output_notes.num_notes(), 2); + + assert!( + output_notes + .iter() + .any(|note| note.assets().iter_fungible().any(|a| a == eth_25)), + "Alice's P2ID note ({eth_25:?}) not found", + ); + assert!( + output_notes + .iter() + .any(|note| note.assets().iter_fungible().any(|a| a == usdc_50)), + "Bob's P2ID note ({usdc_50:?}) not found", + ); + + // 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); + + Ok(()) +} + +/// Integration test for a PSWAP fill that uses **both** `account_fill` and +/// `note_fill` on the same note in the same transaction. +/// +/// Setup: +/// - Alice's pswap: 100 USDC offered for 50 ETH requested (ratio 2:1). +/// - Bob's pswap: 30 ETH offered for 60 USDC requested (ratio 1:2). +/// - Charlie has 20 ETH in vault. +/// +/// Charlie consumes both notes in one tx: +/// - Alice's: `account_fill = 20 ETH` (debited from his vault) +/// + `note_fill = 30 ETH` (sourced from inflight, produced by Bob's pswap) +/// → 50 ETH total (full fill). Payout split: +/// - 40 USDC → Charlie's vault (account_fill path) +/// - 60 USDC → inflight (note_fill path, consumed by Bob's pswap) +/// - Bob's: `note_fill = 60 USDC` (sourced from inflight, produced by Alice's pswap) → 60 USDC +/// total (full fill). Payout: 30 ETH → inflight (matches Alice's note_fill consumption above). +/// +/// Net effect: Charlie -20 ETH / +40 USDC; Alice's P2ID = 50 ETH; Bob's P2ID = 60 USDC. +#[tokio::test] +async fn pswap_note_combined_account_fill_and_note_fill_test() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let usdc_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "USDC", 1000, Some(200))?; + let eth_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", 1000, Some(60))?; + + // Alice's pswap: 100 USDC offered for 50 ETH requested. + // 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 charlie_vault_eth = FungibleAsset::new(eth_faucet.id(), 20)?; + let account_fill_eth = charlie_vault_eth; + let note_fill_eth = bob_offered; + let charlie_payout_usdc = FungibleAsset::new(usdc_faucet.id(), 40)?; + + 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()])?; + let charlie = + builder.add_existing_wallet_with_assets(BASIC_AUTH, [charlie_vault_eth.into()])?; + + let (alice_pswap, alice_pswap_note) = build_pswap_note( + &mut builder, + alice.id(), + alice_offered, + alice_requested, + NoteType::Public, + )?; + let (bob_pswap, bob_pswap_note) = + build_pswap_note(&mut builder, bob.id(), bob_offered, bob_requested, NoteType::Public)?; + + let mock_chain = builder.build()?; + + // Alice's pswap uses a combined fill; Bob's pswap uses pure note_fill. + let mut note_args_map = BTreeMap::new(); + note_args_map.insert(alice_pswap_note.id(), PswapNote::create_args(20, 30)?); + note_args_map.insert(bob_pswap_note.id(), PswapNote::create_args(0, 60)?); + + let (alice_p2id_note, alice_remainder) = + alice_pswap.execute(charlie.id(), Some(account_fill_eth), Some(note_fill_eth))?; + assert!( + alice_remainder.is_none(), + "combined fill hits full fill — no remainder expected" + ); + + let (bob_p2id_note, bob_remainder) = + bob_pswap.execute(charlie.id(), None, Some(bob_requested))?; + assert!(bob_remainder.is_none(), "bob pswap is filled completely via note_fill"); + + let tx_context = mock_chain + .build_tx_context(charlie.id(), &[alice_pswap_note.id(), bob_pswap_note.id()], &[])? + .extend_note_args(note_args_map) + .extend_expected_output_notes(vec![ + RawOutputNote::Full(alice_p2id_note), + RawOutputNote::Full(bob_p2id_note), + ]) + .build()?; + + let executed_transaction = tx_context.execute().await?; + + // Exactly 2 output notes: Alice's P2ID (50 ETH) + Bob's P2ID (60 USDC). + let output_notes = executed_transaction.output_notes(); + assert_eq!(output_notes.num_notes(), 2, "expected exactly 2 P2ID output notes"); + + assert!( + output_notes + .iter() + .any(|note| note.assets().iter_fungible().any(|a| a == alice_requested)), + "Alice's P2ID ({alice_requested:?}) not found", + ); + assert!( + output_notes + .iter() + .any(|note| note.assets().iter_fungible().any(|a| a == bob_requested)), + "Bob's P2ID ({bob_requested:?}) not found", + ); + + // Charlie's vault: -20 ETH (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); + + Ok(()) +} + +#[tokio::test] +async fn pswap_note_creator_reclaim_test() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + 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 (_, pswap_note) = build_pswap_note( + &mut builder, + alice.id(), + FungibleAsset::new(usdc_faucet.id(), 50)?, + FungibleAsset::new(eth_faucet.id(), 25)?, + NoteType::Public, + )?; + + let mock_chain = builder.build()?; + + let tx_context = mock_chain.build_tx_context(alice.id(), &[pswap_note.id()], &[])?.build()?; + + let executed_transaction = tx_context.execute().await?; + + // Verify: 0 output notes, Alice gets 50 USDC back + 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)?); + + Ok(()) +} + +/// The fill sum overflow case uses `1u64 << 63` for each fill: both are valid +/// Felt values (< field modulus), but their sum `2^64` exceeds `u64::MAX`, so +/// the `overflowing_add` check fires before `assert_valid_asset_amount`. +/// +/// The max-asset-amount case uses `FungibleAsset::MAX_AMOUNT` for each fill: +/// the sum `2 * MAX_AMOUNT` fits in u64 but exceeds `MAX_AMOUNT`, so +/// `assert_valid_asset_amount` fires instead. +#[rstest] +#[case::fill_exceeds_requested(30, 0, ERR_PSWAP_FILL_EXCEEDS_REQUESTED)] +#[case::fill_sum_u64_overflow(1u64 << 63, 1u64 << 63, ERR_PSWAP_FILL_SUM_OVERFLOW)] +#[case::fill_sum_exceeds_max_asset_amount( + FungibleAsset::MAX_AMOUNT.as_u64(), + FungibleAsset::MAX_AMOUNT.as_u64(), + ERR_PSWAP_NOT_VALID_ASSET_AMOUNT +)] +#[tokio::test] +async fn pswap_note_invalid_input_test( + #[case] account_fill: u64, + #[case] note_fill: u64, + #[case] expected_err: MasmError, +) -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + 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(30))?; + + let alice = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(usdc_faucet.id(), 50)?.into()], + )?; + let bob = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(eth_faucet.id(), 30)?.into()], + )?; + + let (_, pswap_note) = build_pswap_note( + &mut builder, + alice.id(), + FungibleAsset::new(usdc_faucet.id(), 50)?, + FungibleAsset::new(eth_faucet.id(), 25)?, + NoteType::Public, + )?; + let mock_chain = builder.build()?; + + let mut note_args_map = BTreeMap::new(); + note_args_map.insert(pswap_note.id(), PswapNote::create_args(account_fill, note_fill)?); + + let tx_context = mock_chain + .build_tx_context(bob.id(), &[pswap_note.id()], &[])? + .extend_note_args(note_args_map) + .build()?; + + let result = tx_context.execute().await; + assert_transaction_executor_error!(result, expected_err); + + Ok(()) +} + +/// Regression test for the `note_idx` stack-layout bug in `create_p2id_note`'s +/// `has_account_fill` branch. +/// +/// The buggy frame setup left three stray zeros between `ASSET_VALUE` and the +/// real `note_idx` on the stack, so `move_asset_to_note` read a pad zero as the +/// note index. Every existing pswap test masked this because the PSWAP note +/// was always the only output-note emitter in the transaction, so `note_idx` +/// was 0 and happened to match one of the pad zeros by coincidence. +/// +/// This test consumes a SPAWN note *first*, which emits an (empty) dummy note +/// at `note_idx == 0`. The subsequent PSWAP note therefore creates its P2ID at +/// `note_idx == 1`. If the bug is reintroduced, bob's 25 ETH will be routed to +/// the dummy at idx 0 instead of the P2ID at idx 1, and the asset assertions +/// below will fail. +#[tokio::test] +async fn pswap_note_idx_nonzero_regression_test() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + 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 bob = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(eth_faucet.id(), 25)?.into()], + )?; + + let (pswap, pswap_note) = build_pswap_note( + &mut builder, + alice.id(), + FungibleAsset::new(usdc_faucet.id(), 50)?, + FungibleAsset::new(eth_faucet.id(), 25)?, + NoteType::Public, + )?; + + // Dummy output note to be emitted by the SPAWN note. Sender must equal + // the transaction's native account (bob) per `create_spawn_note`'s check. + // No assets — keeps the spawn script trivial. + let dummy_note = NoteBuilder::new(bob.id(), SmallRng::seed_from_u64(7777)).build()?; + let spawn_note = builder.add_spawn_note([&dummy_note])?; + + let mock_chain = builder.build()?; + + // Full account-fill: 25 ETH out of bob's vault. Exercises the + // `has_account_fill` branch where the `note_idx` bug lives. + 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)?; + + // Consume spawn first so the PSWAP-created P2ID gets note_idx == 1. + let tx_context = mock_chain + .build_tx_context(bob.id(), &[spawn_note.id(), pswap_note.id()], &[])? + .extend_note_args(note_args_map) + .extend_expected_output_notes(vec![ + RawOutputNote::Full(dummy_note.clone()), + RawOutputNote::Full(expected_p2id), + ]) + .build()?; + + let executed = tx_context.execute().await?; + + // Exactly 2 output notes: dummy (from spawn) at idx 0, P2ID (from pswap) at idx 1. + let output_notes = executed.output_notes(); + assert_eq!(output_notes.num_notes(), 2, "expected dummy + p2id"); + + // Dummy at idx 0 must be empty. If the note_idx bug is reintroduced, + // bob's 25 ETH would land here instead of on the P2ID. + let dummy_out = output_notes.get_note(0); + assert_eq!( + dummy_out.assets().num_assets(), + 0, + "SPAWN dummy should be empty; non-empty means `create_p2id_note` \ + wrote its asset to the wrong output note_idx", + ); + + // 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)?, + ); + + // 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)?, + ); + + Ok(()) +} + +#[rstest] +#[case(5)] +#[case(7)] +#[case(10)] +#[case(13)] +#[case(15)] +#[case(19)] +#[case(20)] +#[case(23)] +#[case(25)] +#[tokio::test] +async fn pswap_multiple_partial_fills_test(#[case] fill_amount: u64) -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + 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 alice = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(usdc_faucet.id(), 50)?.into()], + )?; + + let bob = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(eth_faucet.id(), fill_amount)?.into()], + )?; + + let (pswap, pswap_note) = build_pswap_note( + &mut builder, + alice.id(), + FungibleAsset::new(usdc_faucet.id(), 50)?, + FungibleAsset::new(eth_faucet.id(), 25)?, + NoteType::Public, + )?; + + let mock_chain = builder.build()?; + + let mut note_args_map = BTreeMap::new(); + 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 mut expected_notes = vec![RawOutputNote::Full(p2id_note)]; + if let Some(remainder) = remainder_pswap { + expected_notes.push(RawOutputNote::Full(Note::from(remainder))); + } + + let tx_context = mock_chain + .build_tx_context(bob.id(), &[pswap_note.id()], &[])? + .extend_expected_output_notes(expected_notes) + .extend_note_args(note_args_map) + .build()?; + + let executed_transaction = tx_context.execute().await?; + + let output_notes = executed_transaction.output_notes(); + 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)?); + + Ok(()) +} + +/// Runs one full partial-fill scenario for a `(offered, requested, fill)` triple. +/// +/// Shared between the hand-picked `pswap_partial_fill_ratio_test` regression suite and the +/// seeded random `pswap_partial_fill_ratio_fuzz` coverage test. +async fn run_partial_fill_ratio_case( + offered_usdc: u64, + requested_eth: u64, + fill_eth: u64, +) -> anyhow::Result<()> { + let remaining_requested = requested_eth - fill_eth; + + let mut builder = MockChain::builder(); + let max_supply = 100_000u64; + + let usdc_faucet = + builder.add_existing_basic_faucet(BASIC_AUTH, "USDC", max_supply, Some(offered_usdc))?; + let eth_faucet = + builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", max_supply, Some(fill_eth))?; + + let alice = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(usdc_faucet.id(), offered_usdc)?.into()], + )?; + let bob = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(eth_faucet.id(), fill_eth)?.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)?, + NoteType::Public, + )?; + + let mock_chain = builder.build()?; + + let mut note_args_map = BTreeMap::new(); + note_args_map.insert(pswap_note.id(), PswapNote::create_args(fill_eth, 0)?); + + let payout_amount = pswap.calculate_offered_for_requested(fill_eth)?; + let remaining_offered = offered_usdc - payout_amount; + + 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 mut expected_notes = vec![RawOutputNote::Full(p2id_note)]; + if remaining_requested > 0 { + let remainder = Note::from(remainder_pswap.expect("partial fill should produce remainder")); + expected_notes.push(RawOutputNote::Full(remainder)); + } + + let tx_context = mock_chain + .build_tx_context(bob.id(), &[pswap_note.id()], &[])? + .extend_expected_output_notes(expected_notes) + .extend_note_args(note_args_map) + .build()?; + + let executed_tx = tx_context.execute().await?; + + let output_notes = executed_tx.output_notes(); + 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)?, + ); + + assert_eq!(payout_amount + remaining_offered, offered_usdc, "conservation"); + + Ok(()) +} + +#[rstest] +// Single non-exact-ratio partial fill. +#[case(100, 30, 7)] +// Non-integer ratio regression cases. +#[case(23, 20, 7)] +#[case(23, 20, 13)] +#[case(23, 20, 19)] +#[case(17, 13, 5)] +#[case(97, 89, 37)] +#[case(53, 47, 23)] +#[case(7, 5, 3)] +#[case(7, 5, 1)] +#[case(7, 5, 4)] +#[case(89, 55, 21)] +#[case(233, 144, 55)] +#[case(34, 21, 8)] +#[case(50, 97, 30)] +#[case(13, 47, 20)] +#[case(3, 7, 5)] +#[case(101, 100, 50)] +#[case(100, 99, 50)] +#[case(997, 991, 500)] +#[case(1000, 3, 1)] +#[case(1000, 3, 2)] +#[case(3, 1000, 500)] +#[case(9999, 7777, 3333)] +#[case(5000, 3333, 1111)] +#[case(127, 63, 31)] +#[case(255, 127, 63)] +#[case(511, 255, 100)] +#[tokio::test] +async fn pswap_partial_fill_ratio_test( + #[case] offered_usdc: u64, + #[case] requested_eth: u64, + #[case] fill_eth: u64, +) -> anyhow::Result<()> { + run_partial_fill_ratio_case(offered_usdc, requested_eth, fill_eth).await +} + +/// Seeded-random coverage for the `calculate_offered_for_requested` math + full execute path. +/// +/// Each seed draws `FUZZ_ITERATIONS` random `(offered, requested, fill)` triples and runs them +/// through `run_partial_fill_ratio_case`. Seeds are baked into the case names so a failure like +/// `pswap_partial_fill_ratio_fuzz::seed_1337` is reproducible with one command: rerun that case, +/// the error message pinpoints the exact iteration and triple that broke. +#[rstest] +#[case::seed_42(42)] +#[case::seed_1337(1337)] +#[tokio::test] +async fn pswap_partial_fill_ratio_fuzz(#[case] seed: u64) -> anyhow::Result<()> { + use rand::rngs::SmallRng; + use rand::{Rng, SeedableRng}; + + const FUZZ_ITERATIONS: usize = 30; + + let mut rng = SmallRng::seed_from_u64(seed); + for iter in 0..FUZZ_ITERATIONS { + let offered_usdc = rng.random_range(2u64..10_000); + let requested_eth = rng.random_range(2u64..10_000); + let fill_eth = rng.random_range(1u64..=requested_eth); + + run_partial_fill_ratio_case(offered_usdc, requested_eth, fill_eth).await.map_err(|e| { + anyhow::anyhow!( + "seed={seed} iter={iter} (offered={offered_usdc}, requested={requested_eth}, fill={fill_eth}): {e}" + ) + })?; + } + Ok(()) +} + +#[rstest] +#[case(100, 73, vec![17, 23, 19])] +#[case(53, 47, vec![7, 11, 13, 5])] +#[case(200, 137, vec![41, 37, 29])] +#[case(7, 5, vec![2, 1])] +#[case(1000, 777, vec![100, 200, 150, 100])] +#[case(50, 97, vec![20, 30, 15])] +#[case(89, 55, vec![13, 8, 21])] +#[case(23, 20, vec![3, 5, 4, 3])] +#[case(997, 991, vec![300, 300, 200])] +#[case(3, 2, vec![1])] +#[tokio::test] +async fn pswap_chained_partial_fills_test( + #[case] initial_offered: u64, + #[case] initial_requested: u64, + #[case] fills: Vec, +) -> anyhow::Result<()> { + let mut current_offered = initial_offered; + let mut current_requested = initial_requested; + let mut total_usdc_to_bob = 0u64; + let mut total_eth_from_bob = 0u64; + // Track serial for remainder chain + let mut rng = RandomCoin::new(Word::default()); + let mut current_serial = rng.draw_word(); + + for (fill_index, fill_amount) in fills.iter().enumerate() { + let remaining_requested = current_requested - fill_amount; + + let mut builder = MockChain::builder(); + let max_supply = 100_000u64; + + let usdc_faucet = builder.add_existing_basic_faucet( + BASIC_AUTH, + "USDC", + max_supply, + Some(current_offered), + )?; + let eth_faucet = + builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", max_supply, Some(*fill_amount))?; + + let alice = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(usdc_faucet.id(), current_offered)?.into()], + )?; + let bob = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(eth_faucet.id(), *fill_amount)?.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 storage = PswapNoteStorage::builder() + .requested_asset(requested_fungible) + .creator_account_id(alice.id()) + .build(); + let pswap = PswapNote::builder() + .sender(alice.id()) + .storage(storage) + .serial_number(current_serial) + .note_type(NoteType::Public) + .offered_asset(offered_fungible) + .build()?; + let pswap_note: Note = pswap.clone().into(); + + builder.add_output_note(RawOutputNote::Full(pswap_note.clone())); + let mock_chain = builder.build()?; + + let mut note_args_map = BTreeMap::new(); + note_args_map.insert(pswap_note.id(), PswapNote::create_args(*fill_amount, 0)?); + + let payout_amount = pswap.calculate_offered_for_requested(*fill_amount)?; + let remaining_offered = current_offered - payout_amount; + let (p2id_note, remainder_pswap) = pswap.execute( + bob.id(), + Some(FungibleAsset::new(eth_faucet.id(), *fill_amount)?), + None, + )?; + + let mut expected_notes = vec![RawOutputNote::Full(p2id_note)]; + if remaining_requested > 0 { + let remainder = + Note::from(remainder_pswap.expect("partial fill should produce remainder")); + expected_notes.push(RawOutputNote::Full(remainder)); + } + + let tx_context = mock_chain + .build_tx_context(bob.id(), &[pswap_note.id()], &[])? + .extend_expected_output_notes(expected_notes) + .extend_note_args(note_args_map) + .build()?; + + let executed_tx = tx_context.execute().await.map_err(|e| { + anyhow::anyhow!( + "fill {} failed: {} (offered={}, requested={}, fill={})", + fill_index + 1, + e, + current_offered, + current_requested, + fill_amount + ) + })?; + + let output_notes = executed_tx.output_notes(); + 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)?, + ); + + // Update state for next fill + total_usdc_to_bob += payout_amount; + total_eth_from_bob += fill_amount; + current_offered = remaining_offered; + current_requested = remaining_requested; + // Remainder serial: [0] + 1 (matching MASM LE orientation) + current_serial = Word::from([ + current_serial[0] + ONE, + current_serial[1], + current_serial[2], + current_serial[3], + ]); + } + + // Verify conservation + let total_fills: u64 = fills.iter().sum(); + assert_eq!(total_eth_from_bob, total_fills, "ETH conservation"); + assert_eq!(total_usdc_to_bob + current_offered, initial_offered, "USDC conservation"); + + Ok(()) +} + +/// Test that PswapNote builder + try_from + execute roundtrips correctly +#[test] +fn compare_pswap_create_output_notes_vs_test_helper() { + let mut builder = MockChain::builder(); + let usdc_faucet = + builder.add_existing_basic_faucet(BASIC_AUTH, "USDC", 1000, Some(150)).unwrap(); + let eth_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", 1000, Some(50)).unwrap(); + let alice = builder + .add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(usdc_faucet.id(), 50).unwrap().into()], + ) + .unwrap(); + let bob = builder + .add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(eth_faucet.id(), 25).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 storage = PswapNoteStorage::builder() + .requested_asset(requested_asset) + .creator_account_id(alice.id()) + .payback_note_type(NoteType::Public) + .build(); + let pswap_note: Note = PswapNote::builder() + .sender(alice.id()) + .storage(storage) + .serial_number(rng.draw_word()) + .note_type(NoteType::Public) + .offered_asset(FungibleAsset::new(usdc_faucet.id(), 50).unwrap()) + .build() + .unwrap() + .into(); + + // Roundtrip: try_from -> execute -> verify outputs + let pswap = PswapNote::try_from(&pswap_note).unwrap(); + + // Verify roundtripped PswapNote preserves key fields + assert_eq!(pswap.sender(), alice.id(), "Sender mismatch after roundtrip"); + assert_eq!(pswap.note_type(), NoteType::Public, "Note type mismatch after roundtrip"); + assert_eq!(pswap.storage().requested_asset_amount(), 25, "Requested amount mismatch"); + assert_eq!(pswap.storage().creator_account_id(), alice.id(), "Creator ID mismatch"); + + // 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) + .unwrap(); + assert!(remainder.is_none(), "Full fill should not produce remainder"); + + // Verify P2ID note properties + 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(), + ); + + // 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) + .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(), + ); + + // Verify remainder properties + assert_eq!( + remainder_pswap.storage().creator_account_id(), + alice.id(), + "Remainder creator should be Alice" + ); + let remaining_requested = remainder_pswap.storage().requested_asset_amount(); + assert_eq!(remaining_requested, 15, "Remaining requested should be 15"); +} + +/// Test that PswapNote::parse_inputs roundtrips correctly +/// The original PSWAP note must NOT carry the PswapAttachment scheme. Only remainder +/// PSWAPs and payback P2IDs (which are emitted by the on-chain script) carry that +/// scheme. If an original were to carry PswapAttachment, the on-chain `get_current_depth` +/// would (incorrectly) read a non-zero parent_depth from it and corrupt the lineage's +/// depth chain. +#[test] +fn pswap_original_has_no_pswap_scheme() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + 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(50))?; + let alice = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(usdc_faucet.id(), 50)?.into()], + )?; + + let (pswap, _) = build_pswap_note( + &mut builder, + alice.id(), + FungibleAsset::new(usdc_faucet.id(), 50)?, + FungibleAsset::new(eth_faucet.id(), 25)?, + NoteType::Public, + )?; + + if let Some(att) = pswap.attachments() { + assert_ne!( + att.attachment_scheme(), + PswapNote::PSWAP_ATTACHMENT_SCHEME, + "original PSWAP must not carry PswapAttachment — that scheme is reserved for outputs", + ); + } + + assert_eq!(pswap.parent_depth(), 0, "parent_depth must be 0 for an original PSWAP"); + + Ok(()) +} + +/// Regression test for the load-bearing line that sets the `attachment` field on a +/// Rust-built remainder PswapNote. If this is forgotten, the remainder defaults to +/// `attachment = None`, the on-chain `get_current_depth` reads parent_depth = 0 on the +/// *next* round, and the lineage's depth chain silently resets to 1 each round. +#[test] +fn pswap_remainder_carries_pswap_scheme() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + 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(50))?; + let alice = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(usdc_faucet.id(), 50)?.into()], + )?; + let bob = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(eth_faucet.id(), 10)?.into()], + )?; + + let (pswap, _) = build_pswap_note( + &mut builder, + alice.id(), + FungibleAsset::new(usdc_faucet.id(), 50)?, + FungibleAsset::new(eth_faucet.id(), 25)?, + NoteType::Public, + )?; + + let account_fill = FungibleAsset::new(eth_faucet.id(), 10)?; + let (_, remainder_pswap) = pswap.execute(bob.id(), Some(account_fill), None)?; + let remainder_pswap = remainder_pswap.expect("partial fill should produce a remainder"); + + let att = remainder_pswap.attachments().expect("remainder must carry an attachment"); + assert_eq!( + att.attachment_scheme(), + PswapNote::PSWAP_ATTACHMENT_SCHEME, + "remainder PSWAP must carry PswapAttachment so on-chain depth derivation works", + ); + + assert_eq!( + remainder_pswap.parent_depth(), + 1, + "remainder built from an original PSWAP must carry depth = 1", + ); + + Ok(()) +} + +/// Headline discovery test: Alice creates a PSWAP, Bob consumes it across three partial +/// fills (a 3-round lineage), and at every round Alice reconstructs the payback's +/// `NoteRecipient` from the on-chain attachment word and *consumes the reconstructed note* +/// against the chain — proving end-to-end that the body Alice rebuilds from +/// `(order_id, depth, fill_amount)` matches the commitment Bob's tx recorded. +/// +/// Each round's remainder recipient is also derived and cross-checked against the on-chain +/// digest, since remainder threading carries the parent `PswapAttachment` forward (the +/// on-chain `get_current_depth` reads it to stamp the next round's depth). +#[tokio::test] +async fn pswap_creator_reconstructs_lineage_from_attachments() -> anyhow::Result<()> { + // Three partial fills: 5, 8, 7 (sum = 20 of requested 25, so a 5-unit remainder survives). + let fills = [5u64, 8u64, 7u64]; + let initial_offered = 50u64; + let initial_requested = 25u64; + let total_fill: u64 = fills.iter().sum(); + + let mut builder = MockChain::builder(); + let max_supply = 100_000u64; + let usdc_faucet = + builder.add_existing_basic_faucet(BASIC_AUTH, "USDC", max_supply, Some(initial_offered))?; + let eth_faucet = + builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", max_supply, Some(total_fill))?; + 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()], + )?; + + let original_pswap = PswapNote::builder() + .sender(alice.id()) + .storage( + PswapNoteStorage::builder() + .requested_asset(FungibleAsset::new(eth_faucet.id(), initial_requested)?) + .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)?) + .build()?; + let original_pswap_note: Note = original_pswap.clone().into(); + builder.add_output_note(RawOutputNote::Full(original_pswap_note.clone())); + + let mut mock_chain = builder.build()?; + + // Threaded across rounds: round 1 consumes the original PSWAP, rounds 2+ consume the + // previous round's remainder (which carries the right `PswapAttachment` so the on-chain + // depth derivation stamps the next round correctly). + let mut current_pswap = original_pswap.clone(); + let mut current_pswap_note = original_pswap_note; + let mut current_offered = initial_offered; + let mut current_requested = initial_requested; + + for (idx, fill_amount) in fills.iter().copied().enumerate() { + let depth = (idx + 1) as u32; + + // --- Bob fills the current PSWAP --- + let payout_amount = current_pswap.calculate_offered_for_requested(fill_amount)?; + let remaining_offered = current_offered - payout_amount; + let remaining_requested = current_requested - fill_amount; + + let (predicted_payback_note, predicted_remainder_pswap) = current_pswap.execute( + bob.id(), + Some(FungibleAsset::new(eth_faucet.id(), fill_amount)?), + None, + )?; + + let mut expected_notes = vec![RawOutputNote::Full(predicted_payback_note.clone())]; + let next_pswap_opt = if remaining_requested > 0 { + let predicted_remainder = + predicted_remainder_pswap.expect("partial fill should produce remainder"); + expected_notes.push(RawOutputNote::Full(Note::from(predicted_remainder.clone()))); + Some(predicted_remainder) + } else { + None + }; + + let mut note_args_map = BTreeMap::new(); + note_args_map.insert(current_pswap_note.id(), PswapNote::create_args(fill_amount, 0)?); + + let bob_tx = mock_chain + .build_tx_context(bob.id(), &[current_pswap_note.id()], &[])? + .extend_expected_output_notes(expected_notes) + .extend_note_args(note_args_map) + .build()? + .execute() + .await?; + mock_chain.add_pending_executed_transaction(&bob_tx)?; + mock_chain.prove_next_block()?; + + let on_chain_payback = bob_tx.output_notes().get_note(0); + + // --- Alice reconstructs the payback from the on-chain attachment word --- + let attachment_word = first_attachment_word(on_chain_payback.attachments()); + let fill_from_attachment = attachment_word[0].as_canonical_u64(); + assert_eq!( + fill_from_attachment, fill_amount, + "round {depth}: attachment fill amount mismatch", + ); + + let payback_attachment = PswapNoteAttachment::new( + AssetAmount::new(fill_from_attachment)?, + original_pswap.order_id(), + depth, + ); + let reconstructed_payback = original_pswap + .payback_note(on_chain_payback.metadata().sender(), &payback_attachment)?; + assert_eq!( + reconstructed_payback.details_commitment(), + on_chain_payback.details_commitment(), + "round {depth}: reconstructed payback commitment must match on-chain leaf", + ); + + // --- Alice reconstructs the remainder (when partial) from on-chain data alone --- + if next_pswap_opt.is_some() { + let on_chain_remainder = bob_tx.output_notes().get_note(1); + let remainder_attachment_word = first_attachment_word(on_chain_remainder.attachments()); + let payout_from_attachment = remainder_attachment_word[0].as_canonical_u64(); + + let remainder_attachment = PswapNoteAttachment::new( + AssetAmount::new(payout_from_attachment)?, + original_pswap.order_id(), + depth, + ); + let reconstructed_remainder = original_pswap.remainder_note( + on_chain_remainder.metadata().sender(), + &remainder_attachment, + AssetAmount::new(remaining_offered)?, + AssetAmount::new(remaining_requested)?, + )?; + assert_eq!( + reconstructed_remainder.details_commitment(), + on_chain_remainder.details_commitment(), + "round {depth}: reconstructed remainder commitment must match on-chain leaf", + ); + } + + // --- Alice consumes the reconstructed payback (unauthenticated path) --- + let alice_tx = mock_chain + .build_tx_context(alice.id(), &[], slice::from_ref(&reconstructed_payback))? + .build()? + .execute() + .await?; + assert_vault_single_added( + alice_tx.account_delta().vault(), + FungibleAsset::new(eth_faucet.id(), fill_amount)?, + ); + mock_chain.add_pending_executed_transaction(&alice_tx)?; + mock_chain.prove_next_block()?; + + // Advance state for the next round. + if let Some(next) = next_pswap_opt { + current_pswap_note = Note::from(next.clone()); + current_pswap = next; + current_offered = remaining_offered; + current_requested = remaining_requested; + } + } + + Ok(()) +} + +/// When multiple PSWAP notes from the same creator are consumed in the same transaction, +/// the on-chain payback tag is identical (it derives from the creator's account ID), so +/// tag alone cannot distinguish which payback came from which PSWAP. This test exercises +/// the `order_id` disambiguation: different PSWAPs have different `serial[1]`s, and the +/// MASM stamps each round's output notes with the parent's `serial[1]`, letting the +/// creator sort outputs back to their originating lineage purely by `order_id`. +#[tokio::test] +async fn pswap_disambiguates_multiple_creator_pswaps_in_same_tx() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let usdc_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "USDC", 1_000, Some(100))?; + let eth_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", 1_000, Some(50))?; + + let alice = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(usdc_faucet.id(), 100)?.into()], + )?; + let bob = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(eth_faucet.id(), 30)?.into()], + )?; + + // Two PSWAPs from Alice, both USDC → ETH, but distinct serials → distinct order_ids. + let pswap_a = { + let mut rng = RandomCoin::new(Word::default()); + let serial = rng.draw_word(); + let storage = PswapNoteStorage::builder() + .requested_asset(FungibleAsset::new(eth_faucet.id(), 20)?) + .creator_account_id(alice.id()) + .build(); + + PswapNote::builder() + .sender(alice.id()) + .storage(storage) + .serial_number(serial) + .note_type(NoteType::Public) + .offered_asset(FungibleAsset::new(usdc_faucet.id(), 40)?) + .build()? + }; + let pswap_b = { + // Distinct seed → distinct serial → distinct order_id. + 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)?) + .creator_account_id(alice.id()) + .build(); + + PswapNote::builder() + .sender(alice.id()) + .storage(storage) + .serial_number(serial) + .note_type(NoteType::Public) + .offered_asset(FungibleAsset::new(usdc_faucet.id(), 60)?) + .build()? + }; + + assert_ne!(pswap_a.order_id(), pswap_b.order_id(), "test setup: order_ids must differ"); + + let note_a: Note = pswap_a.clone().into(); + let note_b: Note = pswap_b.clone().into(); + builder.add_output_note(RawOutputNote::Full(note_a.clone())); + builder.add_output_note(RawOutputNote::Full(note_b.clone())); + let mock_chain = builder.build()?; + + // Bob partially fills BOTH PSWAPs in the same tx — 10 ETH from each. + let fill_each = 10u64; + let mut note_args = BTreeMap::new(); + 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 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")); + + let tx_context = mock_chain + .build_tx_context(bob.id(), &[note_a.id(), note_b.id()], &[])? + .extend_note_args(note_args) + .extend_expected_output_notes(vec![ + RawOutputNote::Full(payback_a.clone()), + RawOutputNote::Full(remainder_a_note.clone()), + RawOutputNote::Full(payback_b.clone()), + RawOutputNote::Full(remainder_b_note.clone()), + ]) + .build()?; + let executed_tx = tx_context.execute().await?; + + let outputs = executed_tx.output_notes(); + assert_eq!(outputs.num_notes(), 4, "expected 2 paybacks + 2 remainders in same tx"); + + // Alice's discovery: she scans the tx's 4 output notes and sorts by order_id + // (`Word[1]` of each attachment), without inspecting tags or recipient digests. + let order_id_a = pswap_a.order_id(); + let order_id_b = pswap_b.order_id(); + + // Each lineage should yield 2 notes (payback + remainder) → preallocate. + let mut from_a: Vec = Vec::with_capacity(2); + let mut from_b: Vec = Vec::with_capacity(2); + // PswapAttachment word layout is [amount, order_id, depth, 0]; order_id sits at index 1. + const ORDER_ID_INDEX_IN_PSWAP_ATTACHMENT: usize = 1; + for i in 0..outputs.num_notes() { + let att_word = first_attachment_word(outputs.get_note(i).attachments()); + let oid = att_word[ORDER_ID_INDEX_IN_PSWAP_ATTACHMENT]; + let digest = outputs.get_note(i).recipient_digest(); + if oid == order_id_a { + from_a.push(digest); + } else if oid == order_id_b { + from_b.push(digest); + } else { + panic!("output note's order_id matches neither lineage"); + } + } + assert_eq!(from_a.len(), 2, "lineage A should yield 2 notes (payback + remainder)"); + assert_eq!(from_b.len(), 2, "lineage B should yield 2 notes (payback + remainder)"); + + // Sanity: the digests Alice sorted into each lineage match the Rust-predicted ones. + assert!( + from_a.contains(&payback_a.recipient().digest()) + && from_a.contains(&remainder_a_note.recipient().digest()), + "lineage A's notes must include both Rust-predicted output digests", + ); + assert!( + from_b.contains(&payback_b.recipient().digest()) + && from_b.contains(&remainder_b_note.recipient().digest()), + "lineage B's notes must include both Rust-predicted output digests", + ); + + Ok(()) +} + +#[test] +fn pswap_parse_inputs_roundtrip() { + let mut builder = MockChain::builder(); + let usdc_faucet = + builder.add_existing_basic_faucet(BASIC_AUTH, "USDC", 1000, Some(150)).unwrap(); + let eth_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", 1000, Some(50)).unwrap(); + let alice = builder + .add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(usdc_faucet.id(), 50).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(), + NoteType::Public, + ) + .unwrap(); + + let storage = pswap_note.recipient().storage(); + let items = storage.items(); + + let parsed = PswapNoteStorage::try_from(items).unwrap(); + + assert_eq!(parsed.creator_account_id(), alice.id(), "Creator ID roundtrip failed!"); + + // Verify requested amount from value word + assert_eq!(parsed.requested_asset_amount(), 25, "Requested amount should be 25"); +} diff --git a/crates/miden-testing/tests/scripts/rbac.rs b/crates/miden-testing/tests/scripts/rbac.rs new file mode 100644 index 0000000000..5ec76d81a2 --- /dev/null +++ b/crates/miden-testing/tests/scripts/rbac.rs @@ -0,0 +1,801 @@ +extern crate alloc; + +use alloc::string::String; +use core::slice; + +use miden_processor::crypto::random::RandomCoin; +use miden_protocol::account::{ + Account, + AccountBuilder, + AccountId, + AccountIdVersion, + AccountType, + RoleSymbol, +}; +use miden_protocol::errors::AccountIdError; +use miden_protocol::note::{Note, NoteType}; +use miden_protocol::{Felt, Word}; +use miden_standards::account::access::{AccessControl, Ownable2Step, RoleBasedAccessControl}; +use miden_standards::errors::standards::{ + ERR_ACCOUNT_NOT_IN_ROLE, + ERR_ROLE_SYMBOL_ZERO, + ERR_SENDER_NOT_OWNER, + ERR_SENDER_NOT_OWNER_OR_ROLE_ADMIN, +}; +use miden_standards::testing::note::NoteBuilder; +use miden_testing::{Auth, MockChain, assert_transaction_executor_error}; + +// HELPERS +// ================================================================================================ + +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 }) + .build_existing()?; + + Ok(account) +} + +fn create_rbac_chain(owner: AccountId) -> anyhow::Result<(Account, MockChain)> { + let account = create_rbac_account_with_owner(owner)?; + let mut builder = MockChain::builder(); + builder.add_account(account.clone())?; + + Ok((account, builder.build()?)) +} + +fn test_account_id(seed: u8) -> AccountId { + AccountId::dummy([seed; 15], AccountIdVersion::Version1, AccountType::Private) +} + +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_membership_key(role: &RoleSymbol, account_id: AccountId) -> Word { + Word::from([Felt::ZERO, Felt::from(role), account_id.suffix(), account_id.prefix().as_felt()]) +} + +fn account_id_from_felt_pair( + suffix: Felt, + prefix: Felt, +) -> Result, AccountIdError> { + if suffix == Felt::ZERO && prefix == Felt::ZERO { + Ok(None) + } else { + AccountId::try_from_elements(suffix, prefix).map(Some) + } +} + +fn get_owner(account: &Account) -> anyhow::Result> { + let word = account.storage().get_item(Ownable2Step::slot_name())?; + Ok(account_id_from_felt_pair(word[0], word[1])?) +} + +/// Returns the role's `(member_count, admin_role_symbol)` from on-chain storage. +fn get_role_config(account: &Account, role: &RoleSymbol) -> anyhow::Result<(Felt, Felt)> { + let word = account + .storage() + .get_map_item(RoleBasedAccessControl::role_config_slot(), role_config_key(role))?; + Ok((word[0], word[1])) +} + +fn is_role_member( + account: &Account, + role: &RoleSymbol, + account_id: AccountId, +) -> anyhow::Result { + let word = account.storage().get_map_item( + RoleBasedAccessControl::role_membership_slot(), + role_membership_key(role, account_id), + )?; + Ok(word[0].as_canonical_u64() != 0) +} + +fn build_note(sender: AccountId, code: impl Into) -> anyhow::Result { + let seed: [u64; 4] = rand::random(); + let mut rng = RandomCoin::new(Word::from(seed.map(Felt::new_unchecked))); + Ok(NoteBuilder::new(sender, &mut rng) + .note_type(NoteType::Private) + .code(code.into()) + .build()?) +} + +async fn execute_note_and_apply( + mock_chain: &MockChain, + account: &Account, + note: &Note, +) -> anyhow::Result { + let tx = mock_chain + .build_tx_context(account.clone(), &[], slice::from_ref(note))? + .build()?; + let executed = tx.execute().await?; + + let mut updated = account.clone(); + updated.apply_delta(executed.account_delta())?; + + Ok(updated) +} + +// SCRIPTS +// ================================================================================================ + +fn renounce_ownership_script() -> &'static str { + r#" + use miden::standards::access::ownable2step + + @note_script + pub proc main + repeat.16 push.0 end + call.ownable2step::renounce_ownership + dropw dropw dropw dropw + end + "# +} + +fn set_role_admin_script(role: &RoleSymbol, admin_role: Option<&RoleSymbol>) -> String { + let admin_role = admin_role.map(Felt::from).unwrap_or(Felt::ZERO); + format!( + r#" + use miden::standards::access::rbac + + @note_script + pub proc main + repeat.14 push.0 end + push.{admin_role} + push.{role} + call.rbac::set_role_admin + dropw dropw dropw dropw + end + "#, + role = Felt::from(role), + ) +} + +fn grant_role_script(role: &RoleSymbol, account_id: AccountId) -> String { + 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), + ) +} + +fn revoke_role_script(role: &RoleSymbol, account_id: AccountId) -> String { + 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::revoke_role + dropw dropw dropw dropw + end + "#, + account_prefix = account_id.prefix().as_felt(), + account_suffix = account_id.suffix(), + role = Felt::from(role), + ) +} + +fn renounce_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::renounce_role + dropw dropw dropw dropw + end + "#, + role = Felt::from(role), + ) +} + +fn assert_role_member_count_script(role: &RoleSymbol, expected_count: u64) -> String { + format!( + r#" + use miden::standards::access::rbac + + @note_script + pub proc main + repeat.15 push.0 end + push.{role} + call.rbac::get_role_member_count + eq.{expected_count} assert.err="role member count mismatch" + dropw dropw dropw + drop drop drop + end + "#, + role = Felt::from(role), + ) +} + +fn assert_role_has_members_script(role: &RoleSymbol, expected_has_members: bool) -> String { + let expected_has_members = u8::from(expected_has_members); + + format!( + r#" + use miden::standards::access::rbac + + @note_script + pub proc main + repeat.15 push.0 end + push.{role} + call.rbac::get_role_member_count + neq.0 + eq.{expected_has_members} assert.err="role population mismatch" + dropw dropw dropw + drop drop drop + end + "#, + role = Felt::from(role), + ) +} + +fn assert_role_admin_script(role: &RoleSymbol, expected_admin_role: Option<&RoleSymbol>) -> String { + let expected_admin_role = expected_admin_role.map(Felt::from).unwrap_or(Felt::ZERO); + + format!( + r#" + use miden::standards::access::rbac + + @note_script + pub proc main + repeat.15 push.0 end + push.{role} + call.rbac::get_role_admin + eq.{expected_admin_role} assert.err="role admin mismatch" + dropw dropw dropw + drop drop drop + end + "#, + role = Felt::from(role), + ) +} + +fn assert_has_role_script( + role: &RoleSymbol, + account_id: AccountId, + expected_has_role: bool, +) -> String { + let expected_has_role = u8::from(expected_has_role); + + 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::has_role + eq.{expected_has_role} assert.err="account role membership mismatch" + dropw dropw dropw + drop drop drop + end + "#, + account_prefix = account_id.prefix().as_felt(), + account_suffix = account_id.suffix(), + role = Felt::from(role), + ) +} + +fn set_role_admin_raw_script(role: Felt, admin_role: Felt) -> String { + format!( + r#" + use miden::standards::access::rbac + + @note_script + pub proc main + repeat.14 push.0 end + push.{admin_role} + push.{role} + call.rbac::set_role_admin + dropw dropw dropw dropw + end + "#, + ) +} + +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 +// ================================================================================================ + +#[tokio::test] +async fn test_rbac_owner_role_management_and_lookup() -> anyhow::Result<()> { + let owner = test_account_id(11); + let member = test_account_id(12); + + let minter = role("MINTER"); + let minter_admin = role("MINTER_ADMIN"); + + let (account, mock_chain) = create_rbac_chain(owner)?; + + let set_role_admin_note = + build_note(owner, set_role_admin_script(&minter, Some(&minter_admin)))?; + let updated = execute_note_and_apply(&mock_chain, &account, &set_role_admin_note).await?; + + let (member_count, admin_role) = get_role_config(&updated, &minter)?; + assert_eq!(member_count, Felt::from(0u32)); + assert_eq!(admin_role, Felt::from(&minter_admin)); + + let grant_role_note = build_note(owner, grant_role_script(&minter, member))?; + let granted = execute_note_and_apply(&mock_chain, &updated, &grant_role_note).await?; + + let (member_count, admin_role) = get_role_config(&granted, &minter)?; + assert_eq!(member_count, Felt::from(1u32)); + assert_eq!(admin_role, Felt::from(&minter_admin)); + assert!(is_role_member(&granted, &minter, member)?); + + let revoke_role_note = build_note(owner, revoke_role_script(&minter, member))?; + let revoked = execute_note_and_apply(&mock_chain, &granted, &revoke_role_note).await?; + + let (member_count, admin_role) = get_role_config(&revoked, &minter)?; + assert_eq!(member_count, Felt::from(0u32)); + assert_eq!(admin_role, Felt::from(&minter_admin)); + assert!(!is_role_member(&revoked, &minter, member)?); + + Ok(()) +} + +#[tokio::test] +async fn test_rbac_renounce_role_and_permission_checks() -> anyhow::Result<()> { + let owner = test_account_id(31); + let member = test_account_id(32); + let outsider = test_account_id(33); + + let pauser = role("PAUSER"); + + let (account, mock_chain) = create_rbac_chain(owner)?; + + let grant_pauser_to_member = grant_role_script(&pauser, member); + + let non_owner_grant_note = build_note(outsider, grant_pauser_to_member.clone())?; + let tx = mock_chain + .build_tx_context(account.clone(), &[], slice::from_ref(&non_owner_grant_note))? + .build()?; + let result = tx.execute().await; + assert_transaction_executor_error!(result, ERR_SENDER_NOT_OWNER_OR_ROLE_ADMIN); + + let owner_grant_note = build_note(owner, grant_pauser_to_member)?; + let updated = execute_note_and_apply(&mock_chain, &account, &owner_grant_note).await?; + assert!(is_role_member(&updated, &pauser, member)?); + + let renounce_note = build_note(member, renounce_role_script(&pauser))?; + let renounced = execute_note_and_apply(&mock_chain, &updated, &renounce_note).await?; + assert!(!is_role_member(&renounced, &pauser, member)?); + + let bad_revoke_note = build_note(owner, revoke_role_script(&pauser, member))?; + let tx = mock_chain + .build_tx_context(renounced, &[], slice::from_ref(&bad_revoke_note))? + .build()?; + let result = tx.execute().await; + assert_transaction_executor_error!(result, ERR_ACCOUNT_NOT_IN_ROLE); + + Ok(()) +} + +#[tokio::test] +async fn test_rbac_grant_role_sets_membership() -> anyhow::Result<()> { + let owner = test_account_id(41); + let member = test_account_id(42); + + let minter = role("MINTER"); + + let (account, mock_chain) = create_rbac_chain(owner)?; + + let grant_note = build_note(owner, grant_role_script(&minter, member))?; + let granted = execute_note_and_apply(&mock_chain, &account, &grant_note).await?; + + assert!(is_role_member(&granted, &minter, member)?); + let (member_count, _) = get_role_config(&granted, &minter)?; + assert_eq!(member_count, Felt::ONE); + + Ok(()) +} + +#[tokio::test] +async fn test_rbac_grant_existing_member_is_noop() -> anyhow::Result<()> { + let owner = test_account_id(43); + let member = test_account_id(44); + + let minter = role("MINTER"); + + let (account, mock_chain) = create_rbac_chain(owner)?; + + let grant_minter_to_member = grant_role_script(&minter, member); + + let grant_note = build_note(owner, grant_minter_to_member.clone())?; + let granted = execute_note_and_apply(&mock_chain, &account, &grant_note).await?; + + let regrant_note = build_note(owner, grant_minter_to_member)?; + let regranted = execute_note_and_apply(&mock_chain, &granted, ®rant_note).await?; + + let (member_count, _) = get_role_config(®ranted, &minter)?; + assert_eq!(member_count, Felt::from(1u32)); + assert!(is_role_member(®ranted, &minter, member)?); + + Ok(()) +} + +#[tokio::test] +async fn test_rbac_member_count_tracks_grants_and_revokes() -> anyhow::Result<()> { + let owner = test_account_id(45); + let alice = test_account_id(46); + let bob = test_account_id(47); + + let pauser = role("PAUSER"); + + let (account, mock_chain) = create_rbac_chain(owner)?; + + let first_grant = build_note(owner, grant_role_script(&pauser, alice))?; + let updated = execute_note_and_apply(&mock_chain, &account, &first_grant).await?; + assert_eq!(get_role_config(&updated, &pauser)?.0, Felt::from(1u32)); + + let second_grant = build_note(owner, grant_role_script(&pauser, bob))?; + let updated = execute_note_and_apply(&mock_chain, &updated, &second_grant).await?; + assert_eq!(get_role_config(&updated, &pauser)?.0, Felt::from(2u32)); + + let revoke_alice = build_note(owner, revoke_role_script(&pauser, alice))?; + let updated = execute_note_and_apply(&mock_chain, &updated, &revoke_alice).await?; + assert_eq!(get_role_config(&updated, &pauser)?.0, Felt::from(1u32)); + assert!(!is_role_member(&updated, &pauser, alice)?); + assert!(is_role_member(&updated, &pauser, bob)?); + + let revoke_bob = build_note(owner, revoke_role_script(&pauser, bob))?; + let updated = execute_note_and_apply(&mock_chain, &updated, &revoke_bob).await?; + assert_eq!(get_role_config(&updated, &pauser)?.0, Felt::from(0u32)); + assert!(!is_role_member(&updated, &pauser, bob)?); + + Ok(()) +} + +#[tokio::test] +async fn test_rbac_get_role_member_count_returns_zero_for_missing_role() -> anyhow::Result<()> { + let owner = test_account_id(48); + + let missing_role = role("MISSING"); + + let (account, mock_chain) = create_rbac_chain(owner)?; + + let query_note = build_note(owner, assert_role_member_count_script(&missing_role, 0))?; + let _ = execute_note_and_apply(&mock_chain, &account, &query_note).await?; + + Ok(()) +} + +#[tokio::test] +async fn test_rbac_get_role_admin_returns_zero_when_unset() -> anyhow::Result<()> { + let owner = test_account_id(49); + + let owner_managed_role = role("OWNER_MGD"); + + let (account, mock_chain) = create_rbac_chain(owner)?; + + let query_note = build_note(owner, assert_role_admin_script(&owner_managed_role, None))?; + let _ = execute_note_and_apply(&mock_chain, &account, &query_note).await?; + + Ok(()) +} + +#[tokio::test] +async fn test_rbac_non_owner_cannot_revoke_role() -> anyhow::Result<()> { + let owner = test_account_id(54); + let outsider = test_account_id(55); + let member = test_account_id(56); + + let minter = role("MINTER"); + + let (account, mock_chain) = create_rbac_chain(owner)?; + + let grant_note = build_note(owner, grant_role_script(&minter, member))?; + let granted = execute_note_and_apply(&mock_chain, &account, &grant_note).await?; + + let revoke_note = build_note(outsider, revoke_role_script(&minter, member))?; + let tx = mock_chain + .build_tx_context(granted, &[], slice::from_ref(&revoke_note))? + .build()?; + let result = tx.execute().await; + assert_transaction_executor_error!(result, ERR_SENDER_NOT_OWNER_OR_ROLE_ADMIN); + + Ok(()) +} + +#[tokio::test] +async fn test_rbac_non_member_cannot_renounce_role() -> anyhow::Result<()> { + let owner = test_account_id(57); + let outsider = test_account_id(58); + + let pauser = role("PAUSER"); + + let (account, mock_chain) = create_rbac_chain(owner)?; + + let renounce_note = build_note(outsider, renounce_role_script(&pauser))?; + let tx = mock_chain + .build_tx_context(account, &[], slice::from_ref(&renounce_note))? + .build()?; + let result = tx.execute().await; + assert_transaction_executor_error!(result, ERR_ACCOUNT_NOT_IN_ROLE); + + Ok(()) +} + +#[tokio::test] +async fn test_rbac_revoke_role_clears_membership() -> anyhow::Result<()> { + let owner = test_account_id(59); + let member = test_account_id(60); + + let burner = role("BURNER"); + + let (account, mock_chain) = create_rbac_chain(owner)?; + + let grant_note = build_note(owner, grant_role_script(&burner, member))?; + let granted = execute_note_and_apply(&mock_chain, &account, &grant_note).await?; + assert!(is_role_member(&granted, &burner, member)?); + + let revoke_note = build_note(owner, revoke_role_script(&burner, member))?; + let revoked = execute_note_and_apply(&mock_chain, &granted, &revoke_note).await?; + assert!(!is_role_member(&revoked, &burner, member)?); + assert_eq!(get_role_config(&revoked, &burner)?.0, Felt::from(0u32)); + + Ok(()) +} + +#[tokio::test] +async fn test_rbac_get_role_admin_returns_set_role() -> anyhow::Result<()> { + let owner = test_account_id(75); + + let minter = role("MINTER"); + let minter_admin = role("MINTER_ADMIN"); + + let (account, mock_chain) = create_rbac_chain(owner)?; + + let set_role_admin_note = + build_note(owner, set_role_admin_script(&minter, Some(&minter_admin)))?; + let updated = execute_note_and_apply(&mock_chain, &account, &set_role_admin_note).await?; + + let query_note = build_note(owner, assert_role_admin_script(&minter, Some(&minter_admin)))?; + let _ = execute_note_and_apply(&mock_chain, &updated, &query_note).await?; + + Ok(()) +} + +/// After the owner renounces, role admins should still be able to manage their delegated +/// roles. +#[tokio::test] +async fn test_rbac_role_admin_can_manage_role_after_owner_renounces() -> anyhow::Result<()> { + let owner = test_account_id(83); + let manager = test_account_id(84); + let user = test_account_id(85); + + let user_role = role("USER"); + let manager_role = role("MANAGER"); + + let (account, mock_chain) = create_rbac_chain(owner)?; + + let set_role_admin_note = + build_note(owner, set_role_admin_script(&user_role, Some(&manager_role)))?; + let updated = execute_note_and_apply(&mock_chain, &account, &set_role_admin_note).await?; + + let grant_manager_note = build_note(owner, grant_role_script(&manager_role, manager))?; + let updated = execute_note_and_apply(&mock_chain, &updated, &grant_manager_note).await?; + + let renounce_note = build_note(owner, renounce_ownership_script())?; + let updated = execute_note_and_apply(&mock_chain, &updated, &renounce_note).await?; + + assert_eq!(get_owner(&updated)?, None); + + let grant_user_note = build_note(manager, grant_role_script(&user_role, user))?; + let updated = execute_note_and_apply(&mock_chain, &updated, &grant_user_note).await?; + assert!(is_role_member(&updated, &user_role, user)?); + + let revoke_user_note = build_note(manager, revoke_role_script(&user_role, user))?; + let updated = execute_note_and_apply(&mock_chain, &updated, &revoke_user_note).await?; + assert!(!is_role_member(&updated, &user_role, user)?); + + Ok(()) +} + +#[tokio::test] +async fn test_rbac_member_count_and_has_role_queries() -> anyhow::Result<()> { + let owner = test_account_id(86); + let member = test_account_id(87); + let outsider = test_account_id(88); + + let user_role = role("USER"); + + let (account, mock_chain) = create_rbac_chain(owner)?; + + let role_missing_note = build_note(owner, assert_role_has_members_script(&user_role, false))?; + let _ = execute_note_and_apply(&mock_chain, &account, &role_missing_note).await?; + + let non_member_note = build_note(owner, assert_has_role_script(&user_role, member, false))?; + let _ = execute_note_and_apply(&mock_chain, &account, &non_member_note).await?; + + let grant_note = build_note(owner, grant_role_script(&user_role, member))?; + let updated = execute_note_and_apply(&mock_chain, &account, &grant_note).await?; + + let role_populated_note = build_note(owner, assert_role_has_members_script(&user_role, true))?; + let _ = execute_note_and_apply(&mock_chain, &updated, &role_populated_note).await?; + + let member_note = build_note(owner, assert_has_role_script(&user_role, member, true))?; + let _ = execute_note_and_apply(&mock_chain, &updated, &member_note).await?; + + let outsider_note = build_note(owner, assert_has_role_script(&user_role, outsider, false))?; + let _ = execute_note_and_apply(&mock_chain, &updated, &outsider_note).await?; + + 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); + let outsider = test_account_id(90); + + let user_role = role("USER"); + let manager_role = role("MANAGER"); + + let (account, mock_chain) = create_rbac_chain(owner)?; + + let note = build_note(outsider, set_role_admin_script(&user_role, Some(&manager_role)))?; + let tx = mock_chain.build_tx_context(account, &[], slice::from_ref(¬e))?.build()?; + let result = tx.execute().await; + assert_transaction_executor_error!(result, ERR_SENDER_NOT_OWNER); + + Ok(()) +} + +#[tokio::test] +async fn test_rbac_set_role_admin_can_clear_delegated_admin_to_owner() -> anyhow::Result<()> { + let owner = test_account_id(91); + + let user_role = role("USER"); + let manager_role = role("MANAGER"); + + let (account, mock_chain) = create_rbac_chain(owner)?; + + let set_admin_note = build_note(owner, set_role_admin_script(&user_role, Some(&manager_role)))?; + let updated = execute_note_and_apply(&mock_chain, &account, &set_admin_note).await?; + + let clear_admin_note = build_note(owner, set_role_admin_script(&user_role, None))?; + let updated = execute_note_and_apply(&mock_chain, &updated, &clear_admin_note).await?; + + let query_note = build_note(owner, assert_role_admin_script(&user_role, None))?; + let _ = execute_note_and_apply(&mock_chain, &updated, &query_note).await?; + + Ok(()) +} + +#[tokio::test] +async fn test_rbac_set_role_admin_rejects_zero_role_symbol() -> anyhow::Result<()> { + let owner = test_account_id(92); + + let manager_role = role("MANAGER"); + + let (account, mock_chain) = create_rbac_chain(owner)?; + + let note = build_note(owner, set_role_admin_raw_script(Felt::ZERO, Felt::from(&manager_role)))?; + let tx = mock_chain.build_tx_context(account, &[], slice::from_ref(¬e))?.build()?; + let result = tx.execute().await; + assert_transaction_executor_error!(result, ERR_ROLE_SYMBOL_ZERO); + + Ok(()) +} + +#[tokio::test] +async fn test_rbac_set_role_admin_does_not_create_role() -> anyhow::Result<()> { + let owner = test_account_id(93); + + let user_role = role("USER"); + let manager_role = role("MANAGER"); + + let (account, mock_chain) = create_rbac_chain(owner)?; + + let note = build_note(owner, set_role_admin_script(&user_role, Some(&manager_role)))?; + let updated = execute_note_and_apply(&mock_chain, &account, ¬e).await?; + + let (user_count, user_admin) = get_role_config(&updated, &user_role)?; + assert_eq!(user_count, Felt::from(0u32)); + assert_eq!(user_admin, Felt::from(&manager_role)); + let (manager_count, _) = get_role_config(&updated, &manager_role)?; + assert_eq!(manager_count, Felt::from(0u32)); + + Ok(()) +} + +#[tokio::test] +async fn test_rbac_granting_admin_role_does_not_change_target_role_admin_config() +-> anyhow::Result<()> { + let owner = test_account_id(96); + let delegate = test_account_id(97); + + let user_role = role("USER"); + let manager_role = role("MANAGER"); + + let (account, mock_chain) = create_rbac_chain(owner)?; + + let set_admin_note = build_note(owner, set_role_admin_script(&user_role, Some(&manager_role)))?; + let updated = execute_note_and_apply(&mock_chain, &account, &set_admin_note).await?; + assert_eq!(get_role_config(&updated, &user_role)?.1, Felt::from(&manager_role)); + + let grant_manager_note = build_note(owner, grant_role_script(&manager_role, delegate))?; + let updated = execute_note_and_apply(&mock_chain, &updated, &grant_manager_note).await?; + + let (user_count, user_admin) = get_role_config(&updated, &user_role)?; + assert_eq!(user_admin, Felt::from(&manager_role)); + assert_eq!(user_count, Felt::from(0u32)); + + Ok(()) +} diff --git a/crates/miden-testing/tests/scripts/send_note.rs b/crates/miden-testing/tests/scripts/send_note.rs index 6ca7c04e43..e1565146ea 100644 --- a/crates/miden-testing/tests/scripts/send_note.rs +++ b/crates/miden-testing/tests/scripts/send_note.rs @@ -1,26 +1,28 @@ use core::slice; use std::collections::BTreeMap; +use miden_protocol::Word; use miden_protocol::account::auth::AuthScheme; -use miden_protocol::asset::{Asset, FungibleAsset, NonFungibleAsset}; +use miden_protocol::asset::{Asset, AssetCallbackFlag, FungibleAsset, NonFungibleAsset}; use miden_protocol::crypto::rand::{FeltRng, RandomCoin}; use miden_protocol::note::{ Note, NoteAssets, NoteAttachment, NoteAttachmentScheme, - NoteMetadata, + NoteAttachments, NoteRecipient, NoteStorage, NoteTag, NoteType, PartialNote, + PartialNoteMetadata, }; use miden_protocol::testing::note::DEFAULT_NOTE_SCRIPT; use miden_protocol::transaction::RawOutputNote; -use miden_protocol::{Felt, Word}; use miden_standards::account::interface::{AccountInterface, AccountInterfaceExt}; use miden_standards::code_builder::CodeBuilder; +use miden_standards::note::P2idNote; use miden_testing::utils::create_p2any_note; use miden_testing::{Auth, MockChain}; @@ -50,30 +52,47 @@ async fn test_send_note_script_basic_wallet() -> anyhow::Result<()> { }, [sent_asset0, total_asset], )?; + let mut rng = RandomCoin::new(Word::from([1, 2, 3, 4u32])); let p2any_note = create_p2any_note( sender_basic_wallet_account.id(), NoteType::Private, - [sent_asset2], - &mut RandomCoin::new(Word::from([1, 2, 3, 4u32])), + [sent_asset1], + &mut rng, ); 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 tag = NoteTag::with_account_target(sender_basic_wallet_account.id()); - let elements = [9, 8, 7, 6, 5u32].map(Felt::from).to_vec(); - let attachment = NoteAttachment::new_array(NoteAttachmentScheme::new(42), elements.clone())?; - let metadata = NoteMetadata::new(sender_basic_wallet_account.id(), NoteType::Public) - .with_tag(tag) - .with_attachment(attachment.clone()); - let assets = NoteAssets::new(vec![sent_asset0, sent_asset1]).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(); - let recipient = NoteRecipient::new(serial_num, note_script, NoteStorage::default()); + let attachment_0 = NoteAttachment::with_words( + NoteAttachmentScheme::new(42)?, + vec![Word::from([9, 8, 7, 6u32]), Word::from([5, 4, 3, 2u32])], + )?; + let attachment_1 = + NoteAttachment::with_word(NoteAttachmentScheme::new(43)?, Word::from([1, 2, 3, 4u32])); + let attachment_2 = NoteAttachment::with_words( + NoteAttachmentScheme::new(44)?, + vec![Word::from([10, 11, 12, 13u32])], + )?; + let attachment_3 = + NoteAttachment::with_word(NoteAttachmentScheme::new(45)?, Word::from([20, 21, 22, 23u32])); + let attachments = + NoteAttachments::new(vec![attachment_0, attachment_1, attachment_2, attachment_3])?; + assert_eq!( + attachments.num_attachments() as usize, + NoteAttachments::MAX_COUNT, + "test should use max num of attachments" + ); - let note = Note::new(assets.clone(), metadata, recipient); - let partial_note: PartialNote = note.clone().into(); + let p2id_note = P2idNote::create( + sender_basic_wallet_account.id(), + sender_basic_wallet_account.id(), + vec![sent_asset0, sent_asset2], + NoteType::Public, + attachments, + &mut rng, + )?; + let partial_note = PartialNote::from(p2id_note.clone()); let expiration_delta = 10u16; let send_note_transaction_script = sender_account_interface @@ -83,7 +102,7 @@ async fn test_send_note_script_basic_wallet() -> anyhow::Result<()> { .build_tx_context(sender_basic_wallet_account.id(), &[spawn_note.id()], &[]) .expect("failed to build tx context") .tx_script(send_note_transaction_script) - .extend_expected_output_notes(vec![RawOutputNote::Full(note.clone())]) + .extend_expected_output_notes(vec![RawOutputNote::Full(p2id_note.clone())]) .build()? .execute() .await?; @@ -110,19 +129,19 @@ async fn test_send_note_script_basic_wallet() -> anyhow::Result<()> { executed_transaction.output_notes().get_note(0), &RawOutputNote::Partial(p2any_note.into()) ); - assert_eq!(executed_transaction.output_notes().get_note(1), &RawOutputNote::Full(note)); + assert_eq!(executed_transaction.output_notes().get_note(1), &RawOutputNote::Full(p2id_note)); Ok(()) } /// Tests the execution of the generated send_note transaction script in case the sending account -/// has the [`BasicFungibleFaucet`][faucet] interface. +/// has the [`FungibleFaucet`][faucet] interface. /// -/// [faucet]: miden_standards::account::interface::AccountComponentInterface::BasicFungibleFaucet +/// [faucet]: miden_standards::account::interface::AccountComponentInterface::FungibleFaucet #[tokio::test] -async fn test_send_note_script_basic_fungible_faucet() -> anyhow::Result<()> { +async fn test_send_note_script_fungible_faucet() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - let sender_basic_fungible_faucet_account = builder.add_existing_basic_faucet( + let sender_fungible_faucet_account = builder.add_existing_basic_faucet( Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, }, @@ -132,22 +151,23 @@ async fn test_send_note_script_basic_fungible_faucet() -> anyhow::Result<()> { )?; let mock_chain = builder.build()?; - let sender_account_interface = - AccountInterface::from_account(&sender_basic_fungible_faucet_account); + let sender_account_interface = AccountInterface::from_account(&sender_fungible_faucet_account); - let tag = NoteTag::with_account_target(sender_basic_fungible_faucet_account.id()); - let attachment = NoteAttachment::new_word(NoteAttachmentScheme::new(100), Word::empty()); - let metadata = NoteMetadata::new(sender_basic_fungible_faucet_account.id(), NoteType::Public) - .with_tag(tag) - .with_attachment(attachment); + 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_basic_fungible_faucet_account.id(), 10).unwrap(), + FungibleAsset::new(sender_fungible_faucet_account.id(), 10) + .unwrap() + .with_callbacks(AssetCallbackFlag::Enabled), )])?; 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(); let recipient = NoteRecipient::new(serial_num, note_script, NoteStorage::default()); + let attachments = NoteAttachments::from(attachment); - let note = Note::new(assets.clone(), metadata, recipient); + let note = Note::with_attachments(assets.clone(), metadata, recipient, attachments); let partial_note: PartialNote = note.clone().into(); let expiration_delta = 10u16; @@ -155,7 +175,7 @@ async fn test_send_note_script_basic_fungible_faucet() -> anyhow::Result<()> { .build_send_notes_script(slice::from_ref(&partial_note), Some(expiration_delta))?; let executed_transaction = mock_chain - .build_tx_context(sender_basic_fungible_faucet_account.id(), &[], &[]) + .build_tx_context(sender_fungible_faucet_account.id(), &[], &[]) .expect("failed to build tx context") .tx_script(send_note_transaction_script) .extend_expected_output_notes(vec![RawOutputNote::Full(note.clone())]) diff --git a/crates/miden-testing/tests/scripts/swap.rs b/crates/miden-testing/tests/scripts/swap.rs index 0cd95695a9..20c3d3fcfb 100644 --- a/crates/miden-testing/tests/scripts/swap.rs +++ b/crates/miden-testing/tests/scripts/swap.rs @@ -1,9 +1,9 @@ use anyhow::Context; use miden_protocol::Felt; use miden_protocol::account::auth::AuthScheme; -use miden_protocol::account::{Account, AccountId, AccountStorageMode, AccountType}; +use miden_protocol::account::{Account, AccountId, AccountType}; use miden_protocol::asset::{Asset, FungibleAsset, NonFungibleAsset}; -use miden_protocol::note::{Note, NoteDetails, NoteType}; +use miden_protocol::note::{Note, NoteDetails, NoteId, NoteType}; use miden_protocol::testing::account_id::{ ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1, @@ -72,7 +72,7 @@ pub async fn prove_send_swap_note() -> anyhow::Result<()> { create_swap_note_tx .output_notes() .iter() - .any(|n| n.commitment() == swap_note.commitment()) + .any(|n| n.details_commitment() == swap_note.details_commitment()) ); assert_eq!( sender_account.vault().assets().count(), @@ -119,7 +119,10 @@ async fn consume_swap_note_private_payback_note() -> anyhow::Result<()> { .context("failed to apply delta to target account")?; let output_payback_note = consume_swap_note_tx.output_notes().iter().next().unwrap().clone(); - assert!(output_payback_note.id() == payback_note.id()); + assert_eq!( + output_payback_note.id(), + NoteId::new(payback_note.commitment(), output_payback_note.metadata()) + ); assert_eq!(output_payback_note.assets().iter().next().unwrap(), &requested_asset); assert!(target_account.vault().assets().count() == 1); @@ -130,7 +133,7 @@ async fn consume_swap_note_private_payback_note() -> anyhow::Result<()> { let full_payback_note = Note::new( payback_note.assets().clone(), - output_payback_note.metadata().clone(), + *output_payback_note.metadata().partial_metadata(), payback_note.recipient().clone(), ); @@ -199,7 +202,10 @@ async fn consume_swap_note_public_payback_note() -> anyhow::Result<()> { target_account.apply_delta(consume_swap_note_tx.account_delta())?; let output_payback_note = consume_swap_note_tx.output_notes().iter().next().unwrap().clone(); - assert!(output_payback_note.id() == payback_note.id()); + assert_eq!( + output_payback_note.id(), + NoteId::new(payback_note.commitment(), output_payback_note.metadata()) + ); assert_eq!(output_payback_note.assets().iter().next().unwrap(), &requested_asset); assert!(target_account.vault().assets().count() == 1); @@ -210,7 +216,7 @@ async fn consume_swap_note_public_payback_note() -> anyhow::Result<()> { let full_payback_note = Note::new( payback_note.assets().clone(), - output_payback_note.metadata().clone(), + *output_payback_note.metadata().partial_metadata(), payback_note.recipient().clone(), ); @@ -294,11 +300,11 @@ async fn settle_coincidence_of_wants() -> anyhow::Result<()> { // Find payback notes by matching their IDs let output_payback_1 = output_notes .iter() - .find(|note| note.id() == payback_note_1.id()) + .find(|note| note.id() == NoteId::new(payback_note_1.commitment(), note.metadata())) .expect("Payback note 1 not found"); let output_payback_2 = output_notes .iter() - .find(|note| note.id() == payback_note_2.id()) + .find(|note| note.id() == NoteId::new(payback_note_2.commitment(), note.metadata())) .expect("Payback note 2 not found"); // Verify payback note 1 contains exactly the initially requested asset B for account 1 @@ -322,8 +328,7 @@ struct SwapTestSetup { fn setup_swap_test(payback_note_type: NoteType) -> anyhow::Result { let faucet_id = AccountIdBuilder::new() - .account_type(AccountType::FungibleFaucet) - .storage_mode(AccountStorageMode::Private) + .account_type(AccountType::Private) .build_with_seed([5; 32]); let offered_asset = FungibleAsset::new(faucet_id, 2000)?.into(); diff --git a/crates/miden-testing/tests/wallet/mod.rs b/crates/miden-testing/tests/wallet/mod.rs index 0fff293ddf..f2a71098f6 100644 --- a/crates/miden-testing/tests/wallet/mod.rs +++ b/crates/miden-testing/tests/wallet/mod.rs @@ -8,7 +8,7 @@ use rand_chacha::rand_core::SeedableRng; #[cfg(not(target_arch = "wasm32"))] #[test] fn wallet_creation() { - use miden_protocol::account::{AccountCode, AccountStorageMode, AccountType, auth}; + use miden_protocol::account::{AccountCode, AccountType, auth}; use miden_standards::account::auth::AuthSingleSig; use miden_standards::account::wallets::BasicWallet; @@ -27,19 +27,17 @@ fn wallet_creation() { 204, 149, 90, 166, 68, 100, 73, 106, 168, 125, 237, 138, 16, ]; - let account_type = AccountType::RegularAccountImmutableCode; - let storage_mode = AccountStorageMode::Private; + let account_type = AccountType::Private; - let wallet = create_basic_wallet(init_seed, auth_method, account_type, storage_mode).unwrap(); + let wallet = create_basic_wallet(init_seed, auth_method, account_type).unwrap(); - let expected_code = AccountCode::from_components( - &[AuthSingleSig::new(pub_key, auth_scheme).into(), BasicWallet.into()], - AccountType::RegularAccountUpdatableCode, - ) + let expected_code = AccountCode::from_components(&[ + AuthSingleSig::new(pub_key, auth_scheme).into(), + BasicWallet.into(), + ]) .unwrap(); let expected_code_commitment = expected_code.commitment(); - assert!(wallet.is_regular_account()); assert_eq!(wallet.code().commitment(), expected_code_commitment); assert_eq!( wallet.storage().get_item(AuthSingleSig::public_key_slot()).unwrap(), @@ -50,7 +48,7 @@ fn wallet_creation() { #[cfg(not(target_arch = "wasm32"))] #[test] fn wallet_creation_2() { - use miden_protocol::account::{AccountCode, AccountStorageMode, AccountType, auth}; + use miden_protocol::account::{AccountCode, AccountType, auth}; use miden_standards::account::auth::AuthSingleSig; use miden_standards::account::wallets::BasicWallet; @@ -68,19 +66,17 @@ fn wallet_creation_2() { 204, 149, 90, 166, 68, 100, 73, 106, 168, 125, 237, 138, 16, ]; - let account_type = AccountType::RegularAccountImmutableCode; - let storage_mode = AccountStorageMode::Private; + let account_type = AccountType::Private; - let wallet = create_basic_wallet(init_seed, auth_method, account_type, storage_mode).unwrap(); + let wallet = create_basic_wallet(init_seed, auth_method, account_type).unwrap(); - let expected_code = AccountCode::from_components( - &[AuthSingleSig::new(pub_key, auth_scheme).into(), BasicWallet.into()], - AccountType::RegularAccountUpdatableCode, - ) + let expected_code = AccountCode::from_components(&[ + AuthSingleSig::new(pub_key, auth_scheme).into(), + BasicWallet.into(), + ]) .unwrap(); let expected_code_commitment = expected_code.commitment(); - assert!(wallet.is_regular_account()); assert_eq!(wallet.code().commitment(), expected_code_commitment); assert_eq!( wallet.storage().get_item(AuthSingleSig::public_key_slot()).unwrap(), diff --git a/crates/miden-tx-batch-prover/Cargo.toml b/crates/miden-tx-batch-prover/Cargo.toml index d664da1d37..df138ae0e2 100644 --- a/crates/miden-tx-batch-prover/Cargo.toml +++ b/crates/miden-tx-batch-prover/Cargo.toml @@ -13,7 +13,8 @@ rust-version.workspace = true version.workspace = true [lib] -bench = false +bench = false +doctest = false [features] default = ["std"] diff --git a/crates/miden-tx-batch-prover/src/local_batch_prover.rs b/crates/miden-tx-batch-prover/src/local_batch_prover.rs index 4e7ccfffc7..09a5dc3fce 100644 --- a/crates/miden-tx-batch-prover/src/local_batch_prover.rs +++ b/crates/miden-tx-batch-prover/src/local_batch_prover.rs @@ -74,7 +74,7 @@ impl LocalBatchProver { batch_expiration_block_num, ) = proposed_batch.into_parts(); - ProvenBatch::new( + ProvenBatch::new_unchecked( id, block_header.commitment(), block_header.block_num(), diff --git a/crates/miden-tx/Cargo.toml b/crates/miden-tx/Cargo.toml index e78138760f..629d794521 100644 --- a/crates/miden-tx/Cargo.toml +++ b/crates/miden-tx/Cargo.toml @@ -12,11 +12,21 @@ repository.workspace = true rust-version.workspace = true version.workspace = true +[lib] +doctest = false + [features] concurrent = ["miden-prover/concurrent", "std"] -default = ["std"] -std = ["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", + "miden-verifier/std", +] +testing = ["miden-processor/testing", "miden-protocol/testing", "miden-standards/testing"] [dependencies] # Workspace dependencies @@ -32,8 +42,5 @@ miden-verifier = { workspace = true } thiserror = { workspace = true } [dev-dependencies] -anyhow = { features = ["backtrace", "std"], workspace = true } -assert_matches = { workspace = true } -miden-assembly = { workspace = true } -miden-tx = { features = ["testing"], path = "." } -rstest = { workspace = true } +miden-protocol = { features = ["testing"], workspace = true } +rand_chacha = { workspace = true } diff --git a/crates/miden-tx/src/auth/tx_authenticator.rs b/crates/miden-tx/src/auth/tx_authenticator.rs index 877d29aa61..883b7be980 100644 --- a/crates/miden-tx/src/auth/tx_authenticator.rs +++ b/crates/miden-tx/src/auth/tx_authenticator.rs @@ -292,12 +292,15 @@ mod test { use miden_protocol::account::auth::AuthSecretKey; use miden_protocol::utils::serde::{Deserializable, Serializable}; use miden_protocol::{Felt, Word}; + use rand_chacha::ChaCha20Rng; + use rand_chacha::rand_core::SeedableRng; use super::SigningInputs; #[test] fn serialize_auth_key() { - let auth_key = AuthSecretKey::new_falcon512_poseidon2(); + let mut rng = ChaCha20Rng::from_seed([0_u8; 32]); + let auth_key = AuthSecretKey::new_falcon512_poseidon2_with_rng(&mut rng); let serialized = auth_key.to_bytes(); let deserialized = AuthSecretKey::read_from_bytes(&serialized).unwrap(); @@ -307,14 +310,14 @@ mod test { #[test] fn serialize_deserialize_signing_inputs_arbitrary() { let elements = vec![ - Felt::new(0), - Felt::new(1), - Felt::new(2), - Felt::new(3), - Felt::new(4), - Felt::new(5), - Felt::new(6), - Felt::new(7), + Felt::ZERO, + Felt::ONE, + Felt::new_unchecked(2), + Felt::new_unchecked(3), + Felt::new_unchecked(4), + Felt::new_unchecked(5), + Felt::new_unchecked(6), + Felt::new_unchecked(7), ]; let inputs = SigningInputs::Arbitrary(elements.clone()); let bytes = inputs.to_bytes(); @@ -330,7 +333,7 @@ mod test { #[test] fn serialize_deserialize_signing_inputs_blind() { - let word = Word::from([Felt::new(10), Felt::new(20), Felt::new(30), Felt::new(40)]); + let word = Word::from([10_u32, 20_u32, 30_u32, 40_u32]); let inputs = SigningInputs::Blind(word); let bytes = inputs.to_bytes(); let decoded = SigningInputs::read_from_bytes(&bytes).unwrap(); diff --git a/crates/miden-tx/src/errors/mod.rs b/crates/miden-tx/src/errors/mod.rs index f9727fcae2..f56aea7131 100644 --- a/crates/miden-tx/src/errors/mod.rs +++ b/crates/miden-tx/src/errors/mod.rs @@ -22,7 +22,7 @@ use miden_protocol::errors::{ TransactionInputsExtractionError, TransactionOutputError, }; -use miden_protocol::note::{NoteId, NoteMetadata}; +use miden_protocol::note::{NoteId, PartialNoteMetadata}; use miden_protocol::transaction::TransactionSummary; use miden_protocol::{Felt, Word}; use miden_verifier::VerificationError; @@ -50,12 +50,23 @@ pub(crate) enum TransactionCheckerError { TransactionPreparation(#[source] TransactionExecutorError), #[error("transaction execution prologue failed: {0}")] PrologueExecution(#[source] TransactionExecutorError), - #[error("transaction execution epilogue failed: {0}")] - EpilogueExecution(#[source] TransactionExecutorError), + #[error("transaction execution epilogue failed: {error}")] + EpilogueExecution { + error: TransactionExecutorError, + /// Cycle counts for notes that executed successfully before the epilogue failed. + successful_notes_cycle_counts: Vec, + }, #[error("transaction note execution failed on note index {failed_note_index}: {error}")] NoteExecution { failed_note_index: usize, error: TransactionExecutorError, + /// Cycle counts for notes that executed successfully before the failed note. + successful_notes_cycle_counts: Vec, + /// The number of cycles consumed by the failed note before it errored. + /// + /// This is `Some` when the failure was due to exceeding the cycle limit, and `None` + /// for other error types where the cycle count is not meaningful. + failed_note_cycle_count: Option, }, } @@ -64,7 +75,7 @@ impl From for TransactionExecutorError { match error { TransactionCheckerError::TransactionPreparation(error) => error, TransactionCheckerError::PrologueExecution(error) => error, - TransactionCheckerError::EpilogueExecution(error) => error, + TransactionCheckerError::EpilogueExecution { error, .. } => error, TransactionCheckerError::NoteExecution { error, .. } => error, } } @@ -114,7 +125,7 @@ pub enum TransactionExecutorError { #[error("expected account nonce delta to be {expected}, found {actual}")] InconsistentAccountNonceDelta { expected: Felt, actual: Felt }, #[error( - "native asset amount {account_balance} in the account vault is not sufficient to cover the transaction fee of {tx_fee}" + "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")] @@ -246,13 +257,11 @@ pub enum TransactionKernelError { #[error( "public note with metadata {0:?} and recipient digest {1} is missing details in the advice provider" )] - PublicNoteMissingDetails(NoteMetadata, Word), - #[error("attachment provided to set_attachment must be empty when attachment kind is None")] - NoteAttachmentNoneIsNotEmpty, + PublicNoteMissingDetails(PartialNoteMetadata, Word), #[error( - "commitment of note attachment {actual} does not match attachment {provided} provided to set_attachment" + "commitment of note attachment advice data is {actual} which does not match commitment {provided} provided to add_attachment" )] - NoteAttachmentArrayMismatch { actual: Word, provided: Word }, + NoteAttachmentCommitmentMismatch { actual: Word, provided: Word }, #[error( "note storage in advice provider contains fewer items ({actual}) than specified ({specified}) by its number of storage items" )] @@ -295,7 +304,7 @@ pub enum TransactionKernelError { source: DataStoreError, }, #[error( - "native asset amount {account_balance} in the account vault is not sufficient to cover the transaction fee of {tx_fee}" + "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 diff --git a/crates/miden-tx/src/executor/data_store.rs b/crates/miden-tx/src/executor/data_store.rs index e0525d4914..e2fbc6ffae 100644 --- a/crates/miden-tx/src/executor/data_store.rs +++ b/crates/miden-tx/src/executor/data_store.rs @@ -5,7 +5,7 @@ use miden_processor::{FutureMaybeSend, MastForestStore, Word}; use miden_protocol::account::{AccountId, PartialAccount, StorageMapKey, StorageMapWitness}; use miden_protocol::asset::{AssetVaultKey, AssetWitness}; use miden_protocol::block::{BlockHeader, BlockNumber}; -use miden_protocol::note::NoteScript; +use miden_protocol::note::{NoteScript, NoteScriptRoot}; use miden_protocol::transaction::{AccountInputs, PartialBlockchain}; use crate::DataStoreError; @@ -84,6 +84,6 @@ pub trait DataStore: MastForestStore { /// retrieve the script. fn get_note_script( &self, - script_root: Word, + script_root: NoteScriptRoot, ) -> impl FutureMaybeSend, DataStoreError>>; } diff --git a/crates/miden-tx/src/executor/exec_host.rs b/crates/miden-tx/src/executor/exec_host.rs index a277eb8f11..68fd5e1f1f 100644 --- a/crates/miden-tx/src/executor/exec_host.rs +++ b/crates/miden-tx/src/executor/exec_host.rs @@ -6,7 +6,7 @@ use alloc::vec::Vec; use miden_processor::advice::AdviceMutation; use miden_processor::event::EventError; use miden_processor::mast::MastForest; -use miden_processor::{FutureMaybeSend, Host, ProcessorState}; +use miden_processor::{BaseHost, FutureMaybeSend, Host, ProcessorState}; use miden_protocol::account::auth::PublicKeyCommitment; use miden_protocol::account::{ AccountCode, @@ -22,7 +22,13 @@ use miden_protocol::assembly::{SourceFile, SourceManagerSync, SourceSpan}; use miden_protocol::asset::{AssetVaultKey, AssetWitness, FungibleAsset}; use miden_protocol::block::BlockNumber; use miden_protocol::crypto::merkle::smt::SmtProof; -use miden_protocol::note::{NoteMetadata, NoteRecipient, NoteScript, NoteStorage}; +use miden_protocol::note::{ + NoteRecipient, + NoteScript, + NoteScriptRoot, + NoteStorage, + PartialNoteMetadata, +}; use miden_protocol::transaction::{ InputNote, InputNotes, @@ -228,7 +234,7 @@ where 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 native asset in the account based on the initial value + // 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 @@ -239,7 +245,7 @@ where .amount(&initial_fee_asset.vault_key()) .unwrap_or(0); - // SAFETY: Initial native asset faucet ID should be a fungible faucet and amount should + // 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(), @@ -263,8 +269,8 @@ where // 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(), - tx_fee: fee_asset.amount(), + account_balance: current_fee_asset.amount().as_u64(), + tx_fee: fee_asset.amount().as_u64(), }); } @@ -382,11 +388,12 @@ where note_idx: usize, recipient_digest: Word, script_root: Word, - metadata: NoteMetadata, + metadata: PartialNoteMetadata, note_storage: NoteStorage, serial_num: Word, ) -> Result, TransactionKernelError> { // Resolve standard note scripts directly, avoiding a data store round-trip. + let script_root = NoteScriptRoot::from_raw(script_root); let note_script: Option = if let Some(standard_note) = StandardNote::from_script_root(script_root) { Some(standard_note.script()) @@ -414,7 +421,7 @@ where self.base_host.output_note_from_recipient(note_idx, metadata, recipient)?; Ok(vec![AdviceMutation::extend_map(AdviceMap::from_iter([( - script_root, + Word::from(script_root), script_felts, )]))]) }, @@ -428,7 +435,8 @@ where Ok(Vec::new()) }, None => Err(TransactionKernelError::other(format!( - "note script with root {script_root} not found in data store for public note" + "note script with root {} not found in data store for public note", + Word::from(script_root), ))), } } @@ -464,7 +472,7 @@ where // HOST IMPLEMENTATION // ================================================================================================ -impl Host for TransactionExecutorHost<'_, '_, STORE, AUTH> +impl BaseHost for TransactionExecutorHost<'_, '_, STORE, AUTH> where STORE: DataStore + Sync, AUTH: TransactionAuthenticator + Sync, @@ -479,6 +487,16 @@ where (span, maybe_file) } + fn resolve_event(&self, event_id: EventId) -> Option<&EventName> { + self.base_host.resolve_event(event_id) + } +} + +impl Host for TransactionExecutorHost<'_, '_, STORE, AUTH> +where + STORE: DataStore + Sync, + AUTH: TransactionAuthenticator + Sync, +{ fn get_mast_forest(&self, node_digest: &Word) -> impl FutureMaybeSend>> { let mast_forest = self.base_host.get_mast_forest(node_digest); async move { mast_forest } @@ -609,9 +627,9 @@ where self.base_host.on_note_before_add_asset(note_idx, asset) }, - TransactionEvent::NoteBeforeSetAttachment { note_idx, attachment } => self + TransactionEvent::NoteBeforeAddAttachment { note_idx, attachment } => self .base_host - .on_note_before_set_attachment(note_idx, attachment) + .on_note_before_add_attachment(note_idx, attachment) .map(|_| Vec::new()), TransactionEvent::AuthRequest { pub_key_hash, tx_summary, signature } => { @@ -692,10 +710,6 @@ where result.map_err(EventError::from) } } - - fn resolve_event(&self, event_id: EventId) -> Option<&EventName> { - self.base_host.resolve_event(event_id) - } } // HELPER FUNCTIONS diff --git a/crates/miden-tx/src/executor/mod.rs b/crates/miden-tx/src/executor/mod.rs index 279e5d8330..5bed519c61 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, AssetVaultKey}; +use miden_protocol::asset::{Asset, AssetCallbackFlag, AssetVaultKey}; use miden_protocol::block::BlockNumber; use miden_protocol::transaction::{ ExecutedTransaction, @@ -39,11 +39,19 @@ pub use notes_checker::{ MAX_NUM_CHECKER_NOTES, NoteConsumptionChecker, NoteConsumptionInfo, + SuccessfulNote, }; 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 // ================================================================================================ @@ -306,9 +314,10 @@ 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().native_asset_id()) - .expect("fee asset should be a fungible asset"); + 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)? @@ -363,15 +372,17 @@ where let initial_fee_asset_balance = { let vault_root = tx_inputs.account().vault().root(); - let native_asset_id = tx_inputs.block_header().fee_parameters().native_asset_id(); - let fee_asset_vault_key = AssetVaultKey::new_fungible(native_asset_id) - .expect("fee asset should be a fungible asset"); + 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(), + Some(Asset::Fungible(fee_asset)) => fee_asset.amount().as_u64(), Some(Asset::NonFungible(_)) => { return Err(TransactionExecutorError::FeeAssetMustBeFungible); }, diff --git a/crates/miden-tx/src/executor/notes_checker.rs b/crates/miden-tx/src/executor/notes_checker.rs index 69b71869e8..54e1bbf490 100644 --- a/crates/miden-tx/src/executor/notes_checker.rs +++ b/crates/miden-tx/src/executor/notes_checker.rs @@ -1,6 +1,7 @@ use alloc::collections::BTreeMap; use alloc::vec::Vec; +use miden_processor::ExecutionError; use miden_processor::advice::AdviceInputs; use miden_protocol::account::AccountId; use miden_protocol::block::BlockNumber; @@ -31,37 +32,99 @@ pub const MAX_NUM_CHECKER_NOTES: usize = 20; // NOTE CONSUMPTION INFO // ================================================================================================ +/// Represents a successfully consumed note along with the number of cycles it took to execute. +#[derive(Debug)] +pub struct SuccessfulNote { + note: Note, + num_cycles: usize, +} + +impl SuccessfulNote { + /// Constructs a new `SuccessfulNote`. + pub fn new(note: Note, num_cycles: usize) -> Self { + Self { note, num_cycles } + } + + /// Returns a reference to the note. + pub fn note(&self) -> &Note { + &self.note + } + + /// Returns the number of cycles consumed during execution. + pub fn num_cycles(&self) -> usize { + self.num_cycles + } +} + /// Represents a failed note consumption. #[derive(Debug)] pub struct FailedNote { - pub note: Note, - pub error: TransactionExecutorError, + note: Note, + error: TransactionExecutorError, + /// The number of cycles consumed by the note before it failed. + /// + /// This is `Some` when the failure was due to exceeding the cycle limit, and `None` + /// for other error types where the cycle count is not meaningful. + num_cycles: Option, } impl FailedNote { /// Constructs a new `FailedNote`. - pub fn new(note: Note, error: TransactionExecutorError) -> Self { - Self { note, error } + pub fn new(note: Note, error: TransactionExecutorError, num_cycles: Option) -> Self { + Self { note, error, num_cycles } + } + + /// Returns a reference to the note. + pub fn note(&self) -> &Note { + &self.note + } + + /// Returns a reference to the error. + pub fn error(&self) -> &TransactionExecutorError { + &self.error + } + + /// Returns the number of cycles consumed before failure, if available. + /// + /// This is `Some` when the failure was due to exceeding the cycle limit, and `None` + /// for other error types where the cycle count is not meaningful. + pub fn num_cycles(&self) -> Option { + self.num_cycles } } /// Contains information about the successful and failed consumption of notes. #[derive(Default, Debug)] pub struct NoteConsumptionInfo { - pub successful: Vec, - pub failed: Vec, + successful: Vec, + failed: Vec, } impl NoteConsumptionInfo { /// Creates a new [`NoteConsumptionInfo`] instance with the given successful notes. - pub fn new_successful(successful: Vec) -> Self { + pub fn new_successful(successful: Vec) -> Self { Self { successful, ..Default::default() } } /// Creates a new [`NoteConsumptionInfo`] instance with the given successful and failed notes. - pub fn new(successful: Vec, failed: Vec) -> Self { + pub fn new(successful: Vec, failed: Vec) -> Self { Self { successful, failed } } + + /// Returns a reference to the successfully consumed notes. + pub fn successful(&self) -> &[SuccessfulNote] { + &self.successful + } + + /// Returns a reference to the failed notes. + pub fn failed(&self) -> &[FailedNote] { + &self.failed + } + + /// Consumes the struct and returns the successful and failed notes. + pub fn into_parts(self) -> (Vec, Vec) { + (self.successful, self.failed) + } } // NOTE CONSUMPTION CHECKER @@ -179,7 +242,7 @@ where // try to consume the provided note match self.try_execute_notes(&mut tx_inputs).await { // execution succeeded - Ok(()) => Ok(NoteConsumptionStatus::Consumable), + Ok(_cycle_counts) => Ok(NoteConsumptionStatus::Consumable), Err(tx_checker_error) => { match tx_checker_error { // execution failed on the preparation stage, before we actually executed the tx @@ -195,9 +258,9 @@ where Ok(NoteConsumptionStatus::UnconsumableConditions) }, // execution failed during the epilogue - TransactionCheckerError::EpilogueExecution(epilogue_error) => { - Ok(handle_epilogue_error(epilogue_error)) - }, + TransactionCheckerError::EpilogueExecution { + error: epilogue_error, .. + } => Ok(handle_epilogue_error(epilogue_error)), } }, } @@ -228,15 +291,24 @@ where // Execute the candidate notes. tx_inputs.set_input_notes(candidate_notes.clone()); match self.try_execute_notes(&mut tx_inputs).await { - Ok(()) => { + Ok(cycle_counts) => { // A full set of successful notes has been found. - let successful = candidate_notes; + let successful = candidate_notes + .into_iter() + .zip(cycle_counts) + .map(|(note, num_cycles)| SuccessfulNote::new(note, num_cycles)) + .collect(); return Ok(NoteConsumptionInfo::new(successful, failed_notes)); }, - Err(TransactionCheckerError::NoteExecution { failed_note_index, error }) => { + Err(TransactionCheckerError::NoteExecution { + failed_note_index, + error, + failed_note_cycle_count, + .. + }) => { // SAFETY: Failed note index is in bounds of the candidate notes. let failed_note = candidate_notes.remove(failed_note_index); - failed_notes.push(FailedNote::new(failed_note, error)); + failed_notes.push(FailedNote::new(failed_note, error, failed_note_cycle_count)); // All possible candidate combinations have been attempted. if candidate_notes.is_empty() { @@ -244,7 +316,7 @@ where } // Continue and process the next set of candidates. }, - Err(TransactionCheckerError::EpilogueExecution(_)) => { + Err(TransactionCheckerError::EpilogueExecution { .. }) => { let consumption_info = self .find_largest_executable_combination( candidate_notes, @@ -277,6 +349,7 @@ where mut tx_inputs: TransactionInputs, ) -> NoteConsumptionInfo { let mut successful_notes = Vec::new(); + let mut successful_cycle_counts = Vec::new(); let mut failed_note_index = BTreeMap::new(); // Iterate by note count: try 1 note, then 2, then 3, etc. @@ -292,10 +365,12 @@ where tx_inputs.set_input_notes(successful_notes.clone()); match self.try_execute_notes(&mut tx_inputs).await { - Ok(()) => { + Ok(cycle_counts) => { // The successfully added note might have failed earlier. Remove it from the // failed list. failed_note_index.remove(¬e.id()); + // Store the cycle counts from the latest successful execution. + successful_cycle_counts = cycle_counts; // This combination succeeded; remove the most recently added note from // the remaining set. remaining_notes.remove(idx); @@ -306,31 +381,52 @@ where // continue to next note. let failed_note = successful_notes.pop().expect("successful notes should not be empty"); + + // Extract the failed note's cycle count if available. + let num_cycles = match &error { + TransactionCheckerError::NoteExecution { + failed_note_cycle_count, + .. + } => *failed_note_cycle_count, + _ => None, + }; + // Record the failed note (overwrite previous failures for the relevant // note). - failed_note_index - .insert(failed_note.id(), FailedNote::new(failed_note, error.into())); + failed_note_index.insert( + failed_note.id(), + FailedNote::new(failed_note, error.into(), num_cycles), + ); }, } } } + // Pair successful notes with their cycle counts from the last successful execution. + let successful = successful_notes + .into_iter() + .zip(successful_cycle_counts) + .map(|(note, num_cycles)| SuccessfulNote::new(note, num_cycles)) + .collect(); + // Append failed notes to the list of failed notes provided as input. failed_notes.extend(failed_note_index.into_values()); - NoteConsumptionInfo::new(successful_notes, failed_notes) + NoteConsumptionInfo::new(successful, failed_notes) } /// Attempts to execute a transaction with the provided input notes. /// /// This method executes the full transaction pipeline including prologue, note execution, - /// and epilogue phases. It returns `Ok(())` if all notes are successfully consumed, - /// or a specific [`NoteExecutionError`] indicating where and why the execution failed. + /// and epilogue phases. It returns `Ok(cycle_counts)` if all notes are successfully consumed + /// (where `cycle_counts` contains the number of cycles for each note), or a specific + /// [`TransactionCheckerError`] indicating where and why the execution failed. The order of the + /// returned `cycle_counts` is guaranteed to match the order of the input notes. async fn try_execute_notes( &self, tx_inputs: &mut TransactionInputs, - ) -> Result<(), TransactionCheckerError> { + ) -> Result, TransactionCheckerError> { if tx_inputs.input_notes().is_empty() { - return Ok(()); + return Ok(Vec::new()); } let (mut host, stack_inputs, advice_inputs) = @@ -347,6 +443,13 @@ where match result { Ok(execution_output) => { + let cycle_counts = host + .tx_progress() + .note_execution() + .iter() + .map(|(_, interval)| interval.len()) + .collect(); + // Set the advice inputs from the successful execution as advice inputs for // reexecution. This avoids calls to the data store (to load data lazily) that have // already been done as part of this execution. @@ -357,7 +460,7 @@ where ..Default::default() }; tx_inputs.set_advice_inputs(advice_inputs); - Ok(()) + Ok(cycle_counts) }, Err(error) => { let notes = host.tx_progress().note_execution(); @@ -372,13 +475,38 @@ where notes.split_last().expect("notes vector is not empty because of earlier check"); // If the interval end of the last note is specified, then an error occurred after - // notes processing. + // notes processing. All notes executed successfully in this case. if last_note_interval.end().is_some() { - Err(TransactionCheckerError::EpilogueExecution(error)) + let successful_notes_cycle_counts = + notes.iter().map(|(_, interval)| interval.len()).collect(); + Err(TransactionCheckerError::EpilogueExecution { + error, + successful_notes_cycle_counts, + }) } else { // Return the index of the failed note. let failed_note_index = success_notes.len(); - Err(TransactionCheckerError::NoteExecution { failed_note_index, error }) + let successful_notes_cycle_counts = + success_notes.iter().map(|(_, interval)| interval.len()).collect(); + + // Compute the failed note's cycle count when the failure was due to + // exceeding the cycle limit. In this case, the note's interval has a + // start but no end, and the total cycles consumed equals the max allowed. + let failed_note_cycle_count = match &error { + TransactionExecutorError::TransactionProgramExecutionFailed( + ExecutionError::CycleLimitExceeded(max_cycles), + ) => last_note_interval + .start() + .map(|start| *max_cycles as usize - usize::from(start)), + _ => None, + }; + + Err(TransactionCheckerError::NoteExecution { + failed_note_index, + error, + successful_notes_cycle_counts, + failed_note_cycle_count, + }) } }, } diff --git a/crates/miden-tx/src/executor/program_executor.rs b/crates/miden-tx/src/executor/program_executor.rs index f4dc8eaa1d..91e28ae4a4 100644 --- a/crates/miden-tx/src/executor/program_executor.rs +++ b/crates/miden-tx/src/executor/program_executor.rs @@ -40,6 +40,7 @@ impl ProgramExecutor for FastProcessor { options: ExecutionOptions, ) -> Self { FastProcessor::new_with_options(stack_inputs, advice_inputs, options) + .expect("constructing FastProcessor failed due to invalid advice inputs") } fn execute( diff --git a/crates/miden-tx/src/host/mod.rs b/crates/miden-tx/src/host/mod.rs index b0f40dac3b..dd007bb615 100644 --- a/crates/miden-tx/src/host/mod.rs +++ b/crates/miden-tx/src/host/mod.rs @@ -48,7 +48,7 @@ use miden_protocol::account::{ StorageSlotName, }; use miden_protocol::asset::Asset; -use miden_protocol::note::{NoteAttachment, NoteId, NoteMetadata, NoteRecipient}; +use miden_protocol::note::{NoteAttachment, NoteId, NoteRecipient, PartialNoteMetadata}; use miden_protocol::transaction::{ InputNote, InputNotes, @@ -229,7 +229,7 @@ impl<'store, STORE> TransactionBaseHost<'store, STORE> { pub(super) fn output_note_from_recipient_digest( &mut self, note_idx: usize, - metadata: NoteMetadata, + metadata: PartialNoteMetadata, recipient_digest: Word, ) -> Result, TransactionKernelError> { let note_builder = OutputNoteBuilder::from_recipient_digest(metadata, recipient_digest)?; @@ -243,7 +243,7 @@ impl<'store, STORE> TransactionBaseHost<'store, STORE> { pub(super) fn output_note_from_recipient( &mut self, note_idx: usize, - metadata: NoteMetadata, + metadata: PartialNoteMetadata, recipient: NoteRecipient, ) -> Result, TransactionKernelError> { let note_builder = OutputNoteBuilder::from_recipient(metadata, recipient); @@ -311,8 +311,8 @@ impl<'store, STORE> TransactionBaseHost<'store, STORE> { Ok(Vec::new()) } - /// Sets the attachment on the output note identified by the note index. - pub fn on_note_before_set_attachment( + /// Appends an attachment to the output note identified by the note index. + pub fn on_note_before_add_attachment( &mut self, note_idx: usize, attachment: NoteAttachment, @@ -321,7 +321,7 @@ impl<'store, STORE> TransactionBaseHost<'store, STORE> { TransactionKernelError::other(format!("failed to find output note {note_idx}")) })?; - note_builder.set_attachment(attachment); + note_builder.add_attachment(attachment)?; Ok(Vec::new()) } diff --git a/crates/miden-tx/src/host/note_builder.rs b/crates/miden-tx/src/host/note_builder.rs index d392c16b51..0e6d8359d1 100644 --- a/crates/miden-tx/src/host/note_builder.rs +++ b/crates/miden-tx/src/host/note_builder.rs @@ -1,3 +1,4 @@ +use alloc::string::ToString; use alloc::vec::Vec; use miden_protocol::asset::Asset; @@ -6,9 +7,10 @@ use miden_protocol::note::{ Note, NoteAssets, NoteAttachment, - NoteMetadata, + NoteAttachments, NoteRecipient, PartialNote, + PartialNoteMetadata, }; use super::{RawOutputNote, Word}; @@ -24,8 +26,9 @@ use crate::errors::TransactionKernelError; /// addition. #[derive(Debug, Clone)] pub struct OutputNoteBuilder { - metadata: NoteMetadata, + metadata: PartialNoteMetadata, assets: Vec, + attachments: NoteAttachments, recipient_digest: Word, recipient: Option, } @@ -42,11 +45,11 @@ impl OutputNoteBuilder { /// Returns an error if: /// - the note is public. pub fn from_recipient_digest( - metadata: NoteMetadata, + metadata: PartialNoteMetadata, recipient_digest: Word, ) -> Result { // For public notes, we must have a recipient. - if !metadata.is_private() { + if metadata.is_public() { return Err(TransactionKernelError::PublicNoteMissingDetails( metadata, recipient_digest, @@ -58,16 +61,18 @@ impl OutputNoteBuilder { recipient_digest, recipient: None, assets: Vec::new(), + attachments: NoteAttachments::empty(), }) } /// Returns a new [`OutputNoteBuilder`] from the provided metadata and recipient. - pub fn from_recipient(metadata: NoteMetadata, recipient: NoteRecipient) -> Self { + pub fn from_recipient(metadata: PartialNoteMetadata, recipient: NoteRecipient) -> Self { Self { metadata, recipient_digest: recipient.digest(), recipient: Some(recipient), assets: Vec::new(), + attachments: NoteAttachments::empty(), } } @@ -116,26 +121,44 @@ impl OutputNoteBuilder { Ok(()) } - /// Overwrites the attachment in the note's metadata. - pub fn set_attachment(&mut self, attachment: NoteAttachment) { - self.metadata.set_attachment(attachment); + /// Appends an attachment to the note. + /// + /// # Errors + /// Returns an error if the note already has the maximum number of attachments, or if the + /// total number of words across all attachments exceeds the maximum. + pub fn add_attachment( + &mut self, + attachment: NoteAttachment, + ) -> Result<(), TransactionKernelError> { + let mut attachments = core::mem::take(&mut self.attachments).into_vec(); + attachments.push(attachment); + self.attachments = NoteAttachments::new(attachments) + .map_err(|err| TransactionKernelError::other(err.to_string()))?; + + Ok(()) } - /// Converts this builder to an [OutputNote]. + /// Converts this builder to an [RawOutputNote]. /// - /// Depending on the available information, this may result in [`OutputNote::Full`] or - /// [`OutputNote::Partial`] notes. + /// Depending on the available information, this may result in [`RawOutputNote::Full`] or + /// [`RawOutputNote::Partial`] notes. pub fn build(self) -> RawOutputNote { let assets = NoteAssets::new(self.assets) .expect("assets should be valid since add_asset validates them"); match self.recipient { Some(recipient) => { - let note = Note::new(assets, self.metadata, recipient); + let note = + Note::with_attachments(assets, self.metadata, recipient, self.attachments); RawOutputNote::Full(note) }, None => { - let note = PartialNote::new(self.metadata, self.recipient_digest, assets); + let note = PartialNote::new( + self.metadata, + self.recipient_digest, + assets, + self.attachments, + ); RawOutputNote::Partial(note) }, } diff --git a/crates/miden-tx/src/host/tx_event.rs b/crates/miden-tx/src/host/tx_event.rs index 93aab405c2..7e6ef7ec6d 100644 --- a/crates/miden-tx/src/host/tx_event.rs +++ b/crates/miden-tx/src/host/tx_event.rs @@ -13,22 +13,20 @@ use miden_protocol::account::{ use miden_protocol::asset::{Asset, AssetVault, AssetVaultKey, FungibleAsset}; use miden_protocol::note::{ NoteAttachment, - NoteAttachmentArray, NoteAttachmentContent, - NoteAttachmentKind, NoteAttachmentScheme, NoteId, - NoteMetadata, NoteRecipient, NoteScript, NoteStorage, NoteTag, NoteType, + PartialNoteMetadata, }; use miden_protocol::transaction::memory::{NOTE_MEM_SIZE, OUTPUT_NOTE_SECTION_OFFSET}; use miden_protocol::transaction::{TransactionEventId, TransactionSummary}; use miden_protocol::vm::EventId; -use miden_protocol::{Felt, Hasher, Word}; +use miden_protocol::{Felt, Hasher, WORD_SIZE, Word}; use crate::host::{TransactionBaseHost, TransactionKernelProcess}; use crate::{LinkMap, TransactionKernelError}; @@ -123,7 +121,7 @@ pub(crate) enum TransactionEvent { /// The note index extracted from the stack. note_idx: usize, /// The note metadata extracted from the stack. - metadata: NoteMetadata, + metadata: PartialNoteMetadata, /// The recipient data extracted from the advice inputs. recipient_data: RecipientData, }, @@ -135,10 +133,10 @@ pub(crate) enum TransactionEvent { asset: Asset, }, - NoteBeforeSetAttachment { - /// The note index on which the attachment is set. + NoteBeforeAddAttachment { + /// The note index to which the attachment is appended. note_idx: usize, - /// The attachment that is set. + /// The attachment that is appended to the output note. attachment: NoteAttachment, }, @@ -425,26 +423,23 @@ impl TransactionEvent { TransactionEventId::NoteAfterAddAsset => None, - TransactionEventId::NoteBeforeSetAttachment => { + TransactionEventId::NoteBeforeAddAttachment => { // Expected stack state: [ - // event, attachment_scheme, attachment_kind, - // note_ptr, note_ptr, ATTACHMENT + // event, num_attachments, note_ptr, attachment_scheme, ATTACHMENT_COMMITMENT // ] - let attachment_scheme = process.get_stack_item(1); - let attachment_kind = process.get_stack_item(2); - let note_ptr = process.get_stack_item(3); - let attachment = process.get_stack_word(5); + let note_ptr = process.get_stack_item(2); + let attachment_scheme = process.get_stack_item(3); + let attachment_commitment = process.get_stack_word(4); let (note_idx, attachment) = extract_note_attachment( attachment_scheme, - attachment_kind, - attachment, + attachment_commitment, note_ptr, process.advice_provider(), )?; - Some(TransactionEvent::NoteBeforeSetAttachment { note_idx, attachment }) + Some(TransactionEvent::NoteBeforeAddAttachment { note_idx, attachment }) }, TransactionEventId::AuthRequest => { @@ -716,7 +711,7 @@ fn build_note_metadata( sender: AccountId, note_type: Felt, tag: Felt, -) -> Result { +) -> Result { let note_type = u8::try_from(note_type.as_canonical_u64()) .map_err(|_| TransactionKernelError::other("failed to decode note_type into u8")) .and_then(|note_type_byte| { @@ -732,71 +727,69 @@ fn build_note_metadata( .map_err(|_| TransactionKernelError::other("failed to decode note tag into u32")) .map(NoteTag::new)?; - Ok(NoteMetadata::new(sender, note_type).with_tag(tag)) + Ok(PartialNoteMetadata::new(sender, note_type).with_tag(tag)) } fn extract_note_attachment( attachment_scheme: Felt, - attachment_kind: Felt, - attachment: Word, + attachment_commitment: Word, note_ptr: Felt, advice_provider: &AdviceProvider, ) -> Result<(usize, NoteAttachment), TransactionKernelError> { let note_idx = note_ptr_to_idx(note_ptr)?; - let attachment_kind = u8::try_from(attachment_kind.as_canonical_u64()) - .map_err(|_| TransactionKernelError::other("failed to convert attachment kind to u8")) - .and_then(|attachment_kind| { - NoteAttachmentKind::try_from(attachment_kind).map_err(|source| { + let attachment_scheme = u16::try_from(attachment_scheme.as_canonical_u64()) + .map_err(|_| TransactionKernelError::other("failed to convert attachment scheme to u16")) + .and_then(|scheme| { + NoteAttachmentScheme::try_from(scheme).map_err(|source| { TransactionKernelError::other_with_source( - "failed to convert u8 to attachment kind", + "failed to convert u16 to attachment scheme", source, ) }) })?; - let attachment_scheme = u32::try_from(attachment_scheme.as_canonical_u64()) - .map_err(|_| TransactionKernelError::other("failed to convert attachment scheme to u32")) - .map(NoteAttachmentScheme::new)?; - - let attachment_content = match attachment_kind { - NoteAttachmentKind::None => { - if !attachment.is_empty() { - return Err(TransactionKernelError::NoteAttachmentNoneIsNotEmpty); - } - NoteAttachmentContent::None - }, - NoteAttachmentKind::Word => NoteAttachmentContent::Word(attachment), - NoteAttachmentKind::Array => { - let elements = advice_provider.get_mapped_values(&attachment).ok_or_else(|| { - TransactionKernelError::other( - "elements of a note attachment commitment must be present in the advice provider", - ) - })?; - - let commitment_attachment = - NoteAttachmentArray::new(elements.to_vec()).map_err(|source| { - TransactionKernelError::other_with_source( - "failed to construct note attachment commitment", - source, - ) - })?; + // Fetch the raw elements from the advice provider. + let elements = advice_provider.get_mapped_values(&attachment_commitment).ok_or_else(|| { + TransactionKernelError::other( + "elements of a note attachment commitment must be present in the advice provider", + ) + })?; - if commitment_attachment.commitment() != attachment { - return Err(TransactionKernelError::NoteAttachmentArrayMismatch { - actual: commitment_attachment.commitment(), - provided: attachment, - }); - } + if elements.is_empty() { + return Err(TransactionKernelError::other( + "num elements in attachment advice map value must not be empty", + )); + } - NoteAttachmentContent::Array(commitment_attachment) - }, - }; + if !elements.len().is_multiple_of(WORD_SIZE) { + return Err(TransactionKernelError::other( + "num elements in attachment advice map value must be multiple of word size", + )); + } - let attachment = - NoteAttachment::new(attachment_scheme, attachment_content).map_err(|source| { - TransactionKernelError::other_with_source("failed to extract note attachment", source) - })?; + let words: Vec = elements + .chunks_exact(WORD_SIZE) + .map(|chunk| Word::from([chunk[0], chunk[1], chunk[2], chunk[3]])) + .collect(); + + let content = NoteAttachmentContent::new(words).map_err(|source| { + TransactionKernelError::other_with_source( + "failed to construct note attachment content", + source, + ) + })?; + let attachment = NoteAttachment::new(attachment_scheme, content); + + let actual_commitment = attachment.to_commitment(); + + // Check the actual commitment of the advice data matches the declared commitment. + if actual_commitment != attachment_commitment { + return Err(TransactionKernelError::NoteAttachmentCommitmentMismatch { + actual: actual_commitment, + provided: attachment_commitment, + }); + } Ok((note_idx as usize, attachment)) } diff --git a/crates/miden-tx/src/lib.rs b/crates/miden-tx/src/lib.rs index 3dd06bdcc5..e5c670d6f4 100644 --- a/crates/miden-tx/src/lib.rs +++ b/crates/miden-tx/src/lib.rs @@ -16,6 +16,7 @@ pub use executor::{ NoteConsumptionChecker, NoteConsumptionInfo, ProgramExecutor, + SuccessfulNote, TransactionExecutor, TransactionExecutorHost, }; diff --git a/crates/miden-tx/src/prover/mast_store.rs b/crates/miden-tx/src/prover/mast_store.rs index e92984cc66..0a30ae883c 100644 --- a/crates/miden-tx/src/prover/mast_store.rs +++ b/crates/miden-tx/src/prover/mast_store.rs @@ -79,3 +79,50 @@ impl MastForestStore for TransactionMastStore { self.mast_forests.read().get(procedure_root).cloned() } } + +#[cfg(test)] +impl TransactionMastStore { + /// Returns the number of procedure entries in the store (for testing only). + #[allow(clippy::len_without_is_empty)] + pub fn len(&self) -> usize { + self.mast_forests.read().len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fresh_stores_have_same_baseline_size() { + // Two independently created stores should have the same number of entries + // (only the default libraries). This verifies that creating per-call stores + // in LocalTransactionProver::prove() doesn't accumulate state — each fresh + // store starts from the same baseline. + let store1 = TransactionMastStore::new(); + let store2 = TransactionMastStore::new(); + assert_eq!(store1.len(), store2.len()); + assert!(store1.len() > 0, "default libraries should populate the store"); + } + + #[test] + fn insert_does_not_affect_other_stores() { + // Inserting into one store must not affect another. This models the + // per-call store approach in LocalTransactionProver::prove() — each call + // creates a fresh store and loads only the current transaction's code. + // A second prove() call should start from the same baseline, not carry + // over entries from the first. + let store1 = TransactionMastStore::new(); + let baseline = store1.len(); + + // Simulate loading account code by inserting the kernel forest again + // (it adds no new entries since they already exist, but this exercises + // the insert path without needing to construct a custom MastForest) + let kernel_forest = TransactionKernel::kernel().mast_forest().clone(); + store1.insert(kernel_forest); + + // A fresh store should be at exactly the same baseline + let store2 = TransactionMastStore::new(); + assert_eq!(store2.len(), baseline, "new store must not inherit entries from others"); + } +} diff --git a/crates/miden-tx/src/prover/mod.rs b/crates/miden-tx/src/prover/mod.rs index 1d33b08154..c8cb298dc5 100644 --- a/crates/miden-tx/src/prover/mod.rs +++ b/crates/miden-tx/src/prover/mod.rs @@ -1,6 +1,6 @@ -use alloc::sync::Arc; 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; @@ -30,18 +30,19 @@ pub use mast_store::TransactionMastStore; // ------------------------------------------------------------------------------------------------ /// Local Transaction prover is a stateless component which is responsible for proving transactions. +/// +/// Each `prove()` call creates a fresh [`TransactionMastStore`] loaded with only the current +/// transaction's account code, ensuring no state accumulates across calls. This is important +/// in WASM environments where accumulated MAST forests fragment the linear memory. +#[derive(Default)] pub struct LocalTransactionProver { - mast_store: Arc, proof_options: ProvingOptions, } impl LocalTransactionProver { /// Creates a new [LocalTransactionProver] instance. pub fn new(proof_options: ProvingOptions) -> Self { - Self { - mast_store: Arc::new(TransactionMastStore::new()), - proof_options, - } + Self { proof_options } } fn build_proven_transaction( @@ -76,7 +77,7 @@ impl LocalTransactionProver { .remove_asset(Asset::from(fee)) .map_err(TransactionProverError::RemoveFeeAssetFromDelta)?; - let account_update_details = if account.has_public_state() { + let account_update_details = if account.id().is_public() { AccountUpdateDetails::Delta(post_fee_account_delta) } else { AccountUpdateDetails::Private @@ -111,9 +112,16 @@ impl LocalTransactionProver { let tx_inputs = tx_inputs.into(); let (stack_inputs, advice_inputs) = TransactionKernel::prepare_inputs(&tx_inputs); - self.mast_store.load_account_code(tx_inputs.account().code()); + // Create a per-call MAST store to avoid accumulating forests across prove + // calls. Using the shared self.mast_store would grow monotonically (each + // call adds account code that is never removed), fragmenting WASM linear + // memory and eventually causing capacity_overflow panics. A per-call store + // also avoids races: prove() takes &self, so concurrent calls would + // conflict on a shared mutable store. + let mast_store = TransactionMastStore::new(); + mast_store.load_account_code(tx_inputs.account().code()); for account_code in tx_inputs.foreign_account_code() { - self.mast_store.load_account_code(account_code); + mast_store.load_account_code(account_code); } let script_mast_store = ScriptMastForestStore::new( @@ -129,7 +137,7 @@ impl LocalTransactionProver { let mut host = TransactionProverHost::new( &partial_account, input_notes, - self.mast_store.as_ref(), + &mast_store, script_mast_store, account_procedure_index_map, ); @@ -141,6 +149,7 @@ impl LocalTransactionProver { stack_inputs, advice_inputs.clone(), &mut host, + ExecutionOptions::default(), self.proof_options.clone(), ) .await @@ -166,15 +175,6 @@ impl LocalTransactionProver { } } -impl Default for LocalTransactionProver { - fn default() -> Self { - Self { - mast_store: Arc::new(TransactionMastStore::new()), - proof_options: Default::default(), - } - } -} - #[cfg(any(feature = "testing", test))] impl LocalTransactionProver { pub fn prove_dummy( diff --git a/crates/miden-tx/src/prover/prover_host.rs b/crates/miden-tx/src/prover/prover_host.rs index b6b4156678..99743eefbd 100644 --- a/crates/miden-tx/src/prover/prover_host.rs +++ b/crates/miden-tx/src/prover/prover_host.rs @@ -4,7 +4,7 @@ use alloc::vec::Vec; use miden_processor::advice::AdviceMutation; use miden_processor::event::EventError; use miden_processor::mast::MastForest; -use miden_processor::{FutureMaybeSend, Host, MastForestStore, ProcessorState}; +use miden_processor::{BaseHost, FutureMaybeSend, Host, MastForestStore, ProcessorState}; use miden_protocol::Word; use miden_protocol::account::{AccountDelta, PartialAccount}; use miden_protocol::assembly::debuginfo::Location; @@ -63,7 +63,7 @@ where // HOST IMPLEMENTATION // ================================================================================================ -impl Host for TransactionProverHost<'_, STORE> +impl BaseHost for TransactionProverHost<'_, STORE> where STORE: MastForestStore, { @@ -77,6 +77,15 @@ where (SourceSpan::UNKNOWN, None) } + fn resolve_event(&self, event_id: EventId) -> Option<&EventName> { + self.base_host.resolve_event(event_id) + } +} + +impl Host for TransactionProverHost<'_, STORE> +where + STORE: MastForestStore, +{ fn get_mast_forest(&self, node_digest: &Word) -> impl FutureMaybeSend>> { let result = self.base_host.get_mast_forest(node_digest); async move { result } @@ -89,10 +98,6 @@ where let result = self.on_event_sync(process); async move { result } } - - fn resolve_event(&self, event_id: EventId) -> Option<&EventName> { - self.base_host.resolve_event(event_id) - } } impl TransactionProverHost<'_, STORE> @@ -170,9 +175,9 @@ where self.base_host.on_note_before_add_asset(note_idx, asset).map(|_| Vec::new()) }, - TransactionEvent::NoteBeforeSetAttachment { note_idx, attachment } => self + TransactionEvent::NoteBeforeAddAttachment { note_idx, attachment } => self .base_host - .on_note_before_set_attachment(note_idx, attachment) + .on_note_before_add_attachment(note_idx, attachment) .map(|_| Vec::new()), TransactionEvent::AuthRequest { signature, .. } => { diff --git a/deny.toml b/deny.toml index 55e597b9dd..8d1652a202 100644 --- a/deny.toml +++ b/deny.toml @@ -10,8 +10,6 @@ db-path = "~/.cargo/advisory-db" db-urls = ["https://github.com/rustsec/advisory-db"] ignore = [ "RUSTSEC-2024-0436", # paste is unmaintained but no alternative available - "RUSTSEC-2025-0055", # tracing-subscriber vulnerability - will be fixed by upgrade - "RUSTSEC-2025-0056", # adler is unmaintained but used by miniz_oxide "RUSTSEC-2025-0141", # bincode is unmaintained, replace with wincode (https://github.com/0xMiden/miden-vm/issues/2550) ] yanked = "warn" @@ -45,8 +43,13 @@ deny = [ highlight = "all" multiple-versions = "deny" skip = [ + { name = "block-buffer" }, #{ name = "ansi_term", version = "=0.11.0" }, { name = "cpufeatures" }, + { name = "crypto-common" }, + { name = "digest" }, + { name = "keccak" }, + { name = "sha3" }, # Allow duplicate rand versions - miden-field uses 0.10, miden-vm uses 0.9 { name = "rand" }, { name = "rand_core" }, diff --git a/docs/src/account/code.md b/docs/src/account/code.md index ed6d60ab1e..fb2f10132d 100644 --- a/docs/src/account/code.md +++ b/docs/src/account/code.md @@ -38,3 +38,11 @@ Recall that an [account's nonce](index.md#nonce) must be incremented whenever it ### Procedure invocation checks The authentication procedure can base its authentication decision on whether a specific account procedure was called during the transaction. A procedure invocation is tracked by the kernel only if it invokes account-restricted kernel APIs (procedures that are only allowed to be called from the account context, e.g. `exec.faucet::mint`). Invocation of procedures that execute only local instructions (e.g., a noop `push.0 drop`) will not be tracked by the kernel. + +### Reentrancy + +The transaction kernel ensures that an authentication procedure cannot be called by note scripts or transaction scripts before the epilogue. However, it is theoretically possible for an authentication procedure to re-enter itself. + +In practice, most authentication procedures call `native_account::incr_nonce` on all successful execution paths. Since `incr_nonce` can only be called once per transaction, a re-entrant call would abort when attempting to increment the nonce a second time, effectively preventing reentrancy as a side effect. + +If an authentication procedure does not call `incr_nonce` on all successful execution paths, the author should ensure that the procedure does not re-enter itself if this would result in unintended behavior, as the transaction kernel does not enforce this. diff --git a/docs/src/account/components.md b/docs/src/account/components.md index 769e0c5925..9dd6240060 100644 --- a/docs/src/account/components.md +++ b/docs/src/account/components.md @@ -40,7 +40,6 @@ The component metadata can be defined using TOML. Below is an example specificat name = "Fungible Faucet" description = "This component showcases the component schema format, and the different ways of providing valid values to it." version = "1.0.0" -supported-types = ["FungibleFaucet"] [[storage.slots]] name = "demo::token_metadata" @@ -79,12 +78,11 @@ type = { key = "word", value = "u16" } #### Header -The metadata header specifies four fields: +The metadata header specifies three fields: - `name`: The component schema's name - `description` (optional): A brief description of the component schema and its functionality - `version`: A semantic version of this component schema -- `supported-types`: Specifies the types of accounts on which the component can be used. Valid values are `FungibleFaucet`, `NonFungibleFaucet`, `RegularAccountUpdatableCode` and `RegularAccountImmutableCode` #### Storage entries diff --git a/docs/src/account/id.md b/docs/src/account/id.md index ee8362b429..a9e4f9a3e3 100644 --- a/docs/src/account/id.md +++ b/docs/src/account/id.md @@ -9,40 +9,22 @@ title: "ID" An immutable and unique identifier for the `Account`. ::: -The `Account` ID is a 120-bit long number. This identifier is designed to contain the metadata of an account. The metadata includes the [account type](#account-type), [account storage mode](#account-storage-mode) and the version of the `Account`. This metadata is included in the ID to ensure it can be read without needing the full account state. +The `Account` ID is a 120-bit long number. This identifier is designed to contain the metadata of an account. The metadata includes the [account type](#account-type) and the version of the `Account`. This metadata is included in the ID to ensure it can be read without needing the full account state. -The ID is generated by hashing a randomly generated seed together with commitments to the initial code and storage of the `Account` until the resulting ID has the desired account type and storage mode. This process requires a small amount of Proof-of-Work (9 bits) that can be done even by low-powered devices. The resulting 256-bit hash is shortened to 120 bits. +The ID is generated by hashing a randomly generated seed together with commitments to the initial code and storage of the `Account` until the resulting ID has the desired account type. This process requires a small amount of Proof-of-Work (6 bits) that can be done even by low-powered devices. The resulting 256-bit hash is shortened to 120 bits. -Account type and storage mode are chosen at account creation time and cannot be changed later. +The account type is chosen at account creation time and cannot be changed later. ### Account type -There are two main categories of accounts in Miden: **basic accounts** and **faucets**. +Users can choose whether their accounts are stored publicly or privately. The preference is encoded in the account's ID: -- **Basic Accounts:** - Basic Accounts may be either mutable or immutable: - - - _Mutable:_ Code can be changed after deployment. - - _Immutable:_ Code cannot be changed once deployed. - -- **Faucets:** - Faucets are always immutable and can be specialized by the type of assets they issue: - - _Fungible Faucet:_ Can issue fungible [assets](../asset). - - _Non-fungible Faucet:_ Can issue non-fungible [assets](../asset). - -### Account storage mode - -Users can choose whether their accounts are stored publicly or privately. The preference is encoded in the third and fourth most significant bits of the account's ID: +- **Private Accounts:** + Only a commitment (hash) to the account's state is stored on-chain. This mode is suitable for users who prioritize privacy or plan to store a large amount of data in their `Account`. To interact with a private `Account`, a user must have knowledge of its interface. - **Public Accounts:** The account's state is stored on-chain, similar to how accounts are stored in public blockchains like Ethereum. -- **Network `Account`s:** - The account's state is stored on-chain, just like **public** accounts. Additionally, the network will monitor this account for any public notes targeted at it and attempt to create network transactions against the account, which consume the notes. Contracts that rely on a shared, publicly accessible state (e.g., an AMM) should be network accounts. - -- **Private Accounts:** - Only a commitment (hash) to the account's state is stored on-chain. This mode is suitable for users who prioritize privacy or plan to store a large amount of data in their `Account`. To interact with a private `Account`, a user must have knowledge of its interface. - ## Encoding :::info @@ -52,7 +34,7 @@ Bech32 is the preferred encoding format and should be used for user-facing appli An `Account` ID can be encoded in different formats: 1. [**Bech32**](https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki) (user-facing): - - Example: `mm1apk5f8jqxnadegr46xtklmm78qhdgkwc` + - Example: `mm1ap86qhrsrs4gcy2ntrerfkwylure4ly5` - **Benefits**: - Built-in error detection via checksum algorithm - Human-readable prefix indicates network ID @@ -67,6 +49,6 @@ An `Account` ID can be encoded in different formats: - Data part with integrated checksum 2. **Hexadecimal**: - - Example: `0xd7585ada5ab5d2b01c77fad88c0ae4` + - Example: `0x4fa05c701c2a8c115358f234d9c4ff` - Frequently used encoding for blockchain addresses - Used to identify accounts in command-line interfaces or explorers. diff --git a/docs/src/asset.md b/docs/src/asset.md index 8cd17a0299..345d377826 100644 --- a/docs/src/asset.md +++ b/docs/src/asset.md @@ -28,33 +28,122 @@ In Miden, assets serve as the primary means of expressing and transferring value All data structures following the Miden asset model that can be exchanged. ::: -Native assets adhere to the Miden `Asset` model (encoding, issuance, storage). Every native `Asset` is encoded using 32 bytes, including both the [ID](./account/id) of the issuing account and the `Asset` details. +Native assets adhere to the Miden `Asset` model (encoding, issuance, storage). Every native `Asset` is encoded using 64 bytes (vault key and value), including both the [ID](./account/id) of the issuing account and the `Asset` details. ### Issuance -:::note -Only [faucet](./account/id#account-type) accounts can issue assets. -::: - -Faucets can issue either fungible or non-fungible assets as defined at account creation. The faucet's code specifies the `Asset` minting conditions: i.e., how, when, and by whom these assets can be minted. Once minted, they can be transferred to other accounts using notes. +Accounts that issue assets are referred to as faucets. They can issue either fungible or non-fungible assets as defined at asset creation. The faucet's code specifies the `Asset` minting conditions: i.e., how, when, and by whom these assets can be minted. Once minted, they can be transferred to other accounts using notes.

Asset issuance

-### Type +:::tip +An account can technically issue different types of assets simultaneously, for example, both a fungible asset with [callbacks](#callbacks) disabled and a non-fungible asset with callbacks enabled. It is highly recommended that accounts issue only one type of asset, in order to have a simple 1-to-1 relationship between faucets and asset types. +::: + +### Encoding + +Every asset is stored as a key-value pair of two `Word`s: The vault key and the asset value. + +While the asset value is unique to each type of asset, the vault key has a common structure for all types of assets: + +```text +[ + asset_id_suffix (64 bits), + asset_id_prefix (64 bits), + [faucet_id_suffix (56 bits) | reserved (5 bits) | callback_flag (1 bit) | composition (2 bits)], + faucet_id_prefix (64 bits) +] +``` + +- `faucet_id_suffix` and `faucet_id_prefix` is the ID of the faucet which issues the asset. The transaction kernel ensures that a given account can only issue assets when the faucet ID matches its own ID. +- `asset_id_suffix` and `asset_id_prefix` is an ID that determines if two assets issued by the same faucet are considered to be the same asset. It is set by the asset creator arbitrarily - see [identity](#identity) for more. +- `callback_flag` is the flag that determines whether callbacks are enabled (see also [callbacks](#callbacks)). +- `composition` describes how assets compose. Read on for more details. +- `reserved` bits are reserved for future use and should be assumed to be undefined and therefore not relied upon. + +:::note +The `callback_flag` and `composition` are also referred to as "asset metadata". +::: + +### Composition + +Assets can compose in two ways: They can be merged or split. This is automatically done by the transaction kernel when assets are added to an account's vault or to the assets in a note. + +Example: If an account has 10 USDC in its vault and 20 are added, the transaction kernel merges these two instances into one instance with amount 30. + +The transaction kernel needs two pieces of information to work with assets: +1. _Whether_ an asset need to be merged or split with another instance. This comes down to whether two assets have the same _identity_. +2. If so, _how_ do these two instances compose, if at all? This comes down to the `composition` defined by the asset. + +When an asset is added or removed from an account's vault or added to a note, the transaction kernel may have to compose assets: +- If 10 USDC are added to an account vault that already contains 20 USDC, then these two instances must be _merged_. +- If 10 USDC are removed from an account vault that contains 20 USDC, then 10 USDC must be _split_ off the 20 USDC. +- If 10 USDC are added to an empty account vault, then the asset can be written directly into the vault without needing to merge or split anything. + +#### Identity + +Note that for example's sake, we use "USDC" as the _identifier_ of an asset, and so 10 USDC and 20 USDC are instances of the same type of asset. In practice, the identity of an asset is determined by its [vault key](#encoding). + +:::info +Two assets are of the same type whenever their vault keys match. +::: + +The transaction kernel relies on this rule and so creators of assets need to ensure that: +- Instances of assets that should compose, should have identical vault keys. +- Instances of assets that should _not_ compose, should have different vault keys. + +The asset ID can be used by asset creators to ensure this. Let's look at the native fungible and non-fungible assets: +- Fungible assets should _always_ compose and so by construction, their asset ID limbs are set to zero. This ensures two instances of a fungible asset have the same vault key. +- Non-fungible assets should _never_ compose and so by construction, their asset ID limbs are set to parts of their hash value. In practice, this ensures that two instances of non-fungible assets have unique vault keys. The transaction kernel never attempts to compose these. + +#### Composition + +Now that the transaction kernel knows _whether_ two assets need to compose, it also needs to know _how_ these instances compose. This is where the `composition` flag comes into play. It can fall into one of three categories: + +- `None`: Instances do not compose. Used by non-fungible assets. +- `Fungible`: Instances compose according to the native fungible asset, by summing their amounts, up to the maximum supply. +- `Custom`: Instances compose according to faucet-defined logic. Currently disabled and reserved for future use. + +:::danger +If the transaction kernel encounters two assets that need to be merged and their composition is set to `None`, it will abort. It is therefore important to ensure that assets that do not compose have unique [_identities_](#identity). +::: + +The `Fungible` composition is a specialization of the transaction kernel for native fungible assets. The advantage of this built-in way of composing assets is that the issuing faucet does not need to be called. + +On the other hand, `Custom` would involve invoking `merge` and `split` implementations defined by the issuing faucet via a callback. + +### Fungible Assets + +The native fungible asset has the following vault key and value layout: + +- Vault key: `[0, 0, faucet_id_suffix | callback_flag | composition, faucet_id_prefix]`. + - Its `callback_flag` can be disabled or enabled. + - Its `composition` must be set to `Fungible`. +- Value: `[amount, 0, 0, 0]`. + - The amount is always $2^{63}-2^{31}$ or smaller, representing the maximum supply for any fungible `Asset`. + +Note how the `Fungible` composition variant together with the asset ID limbs set to zero, ensure that instances of fungible assets can always be merged and split. + +Examples of such assets include ETH and various stablecoins (e.g. DAI, USDT, USDC). + +### Non-Fungible Assets -#### Fungible asset +The native non-fungible asset is encoded by hashing arbitrary data into 32 bytes, which results in the asset value. -Fungible assets are encoded with the amount and the `faucet_id` of the issuing faucet. The amount is always $2^{63}-1$ or smaller, representing the maximum supply for any fungible `Asset`. Examples include ETH and various stablecoins (e.g., DAI, USDT, USDC). +- Vault key: `[hash0, hash1, faucet_id_suffix | callback_flag | composition, faucet_id_prefix]`. + - Its `callback_flag` can be disabled or enabled. + - Its `composition` must be set to `None`. +- Value: `[hash0, hash1, hash2, hash3]`. -#### Non-fungible asset +Note how the `None` composition variant together with the asset ID limbs set to hashes from the asset value, ensure that instances of non-fungible assets are never attempted to be merged or split by the transaction kernel. -Non-fungible assets are encoded by hashing the `Asset` data into 32 bytes and placing the `faucet_id` as the second element. Examples include NFTs like a DevCon ticket. +Examples of such assets include NFTs like a DevCon ticket. ### Storage -[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 255 assets. +[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 storage diff --git a/docs/src/note.md b/docs/src/note.md index cef4107b3b..c0a4b7cda1 100644 --- a/docs/src/note.md +++ b/docs/src/note.md @@ -32,7 +32,7 @@ These components are: An [asset](asset) container for a `Note`. ::: -A `Note` can contain from 0 up to 256 different assets. These assets represent fungible or non-fungible tokens, enabling flexible asset transfers. +A `Note` can contain from 0 up to 64 different assets. These assets represent fungible or non-fungible tokens, enabling flexible asset transfers. ### Script @@ -68,21 +68,23 @@ Every note includes metadata: - the account ID of the sender, i.e. the creator of the note. - its note type, i.e. private or public. - the [note tag](#note-discovery) that aids in discovery of the note. -- an optional [note attachment](#attachment). +- optional [note attachments](#attachments) (up to 4). -Regardless of [storage mode](#note-storage-mode), these metadata fields are always public. +Regardless of [note type](#note-type), these metadata fields are always public. -### Attachment +### Attachments -An attachment is a variable-size part of a note's metadata: -- It can either be absent (`None`), store a single `Word` or an `Array` of field elements. These are the three _kinds_ of attachments. -- The _scheme_ of an attachment is an optional, 32-bit user-defined value that can be used to detect the presence of certain standardized attachments. +A note can have up to 4 attachments. Each attachment is a variable-size, _public_ extension to the note's metadata consisting of: +- **Content**: Between 1 and 256 words of data (up to 8 KB per attachment, 16 KB total across all attachments). The content of an individual attachment is committed to via a sequential hash over its field elements. The full attachment contents are publicly stored on-chain, even for private notes. +- **Scheme**: A 16-bit (limited to 65534) user-defined value that identifies the kind of attachment. This allows consumers to detect the presence of certain standardized attachments. For untyped attachments, the `none = 1` scheme can be used. + +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. 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 - consume the note. This is a standardized attachment scheme in miden-standards called `NetworkAccountTarget`. -- Communicate the details of a _private_ note to the receiver so they can derive the note. For example, the payback note of a partially fillable swap note can be private and the receiver already knows a few details: It is a P2ID note, the serial number is derived from the SWAP note's serial number and the note storage is the account ID of the receiver. The receiver only needs to now the exact amount that was filled to derive the full note for consumption. This amount can be encoded in the public attachment of the payback note, which allows this use case to work with private notes and still not require a side-channel. + consume the note. This is a standardized attachment scheme in `miden-standards` called `NetworkAccountTarget`. +- Communicate the details of a _private_ note to the receiver so they can derive the note. For example, the payback note of a partially fillable swap note can be private and the receiver already knows a few details: It is a P2ID note, the serial number is derived from the SWAP note's serial number and the note storage is the account ID of the receiver. The receiver only needs to know the exact amount that was filled to derive the full note for consumption. This amount can be encoded in a public attachment of the payback note, which allows this use case to work with private notes and still not require a side-channel. ## Note Lifecycle @@ -99,7 +101,7 @@ Accounts can create notes in a transaction. The `Note` exists if it is included - **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. -#### Note storage mode +#### Note Type As with [accounts](account/index.md), notes can be stored either publicly or privately: @@ -148,31 +150,29 @@ Upon successful verification of the transaction: #### Note recipient restricting consumption -Consumption of a `Note` can be restricted to certain accounts or entities. For instance, the P2ID and P2IDE `Note` scripts target a specific account ID. Alternatively, Miden defines a RECIPIENT (represented as 32 bytes) computed as: +Every `Note` has a RECIPIENT, represented as 32 bytes, that commits to the data defining the conditions under which the note can be consumed. The RECIPIENT is computed as: ```arduino hash(hash(hash(serial_num, [0; 4]), script_root), storage_commitment) ``` -Only those who know the RECIPIENT’s pre-image can consume the `Note`. For private notes, this ensures an additional layer of control and privacy, as only parties with the correct data can claim the `Note`. - -The [transaction prologue](transaction) requires all necessary data to compute the `Note` hash. This setup allows scenario-specific restrictions on who may consume a `Note`. +The RECIPIENT is not necessarily just an account address. Its pre-image consists of the note's serial number, script, and storage. The consumer of the note must provide this data so the [transaction prologue](transaction) can recompute the RECIPIENT and verify that it matches the committed note details. -For a practical example, refer to the [SWAP note script](https://github.com/0xMiden/protocol/blob/next/crates/miden-standards/asm/standards/notes/swap.masm), where the RECIPIENT ensures that only a defined target can consume the swapped asset. +The note script and storage determine the actual consumption conditions. For example, the [P2ID](https://github.com/0xMiden/protocol/blob/next/crates/miden-standards/asm/standards/notes/p2id.masm) and [P2IDE](https://github.com/0xMiden/protocol/blob/next/crates/miden-standards/asm/standards/notes/p2ide.masm) note scripts specify the target account ID as part of the note's storage. In a [SWAP](https://github.com/0xMiden/protocol/blob/next/crates/miden-standards/asm/standards/notes/swap.masm) note, consumption is only possible if the consumer provides the asset expected in return for the asset being offered. For private notes, keeping the RECIPIENT pre-image private ensures that only parties with the required note data can attempt to consume the note. #### Note nullifier ensuring private consumption The `Note` nullifier, computed as: ```arduino -hash(serial_num, script_root, storage_commitment, vault_hash) +hash(SERIAL_NUM, SCRIPT_ROOT, STORAGE_COMMITMENT, ASSET_COMMITMENT, METADATA, ATTACHMENTS_COMMITMENT) ``` This achieves the following properties: - Every `Note` can be reduced to a single unique nullifier. -- One cannot derive a note's hash from its nullifier. -- To compute the nullifier, one must know all components of the `Note`: serial_num, script_root, storage_commitment, and vault_hash. +- One cannot derive a note's ID from its nullifier. +- To compute the nullifier, one must know all components of the `Note`: serial_num, script_root, storage_commitment, assets_commitment, metadata, and attachments_commitment. That means if a `Note` is private and the operator stores only the note's hash, only those with the `Note` details know if this `Note` has been consumed already. Zcash first [introduced](https://zcash.github.io/orchard/design/nullifiers.html#nullifiers) this approach. diff --git a/docs/src/protocol_library.md b/docs/src/protocol_library.md index 7981bba1d7..050dffe634 100644 --- a/docs/src/protocol_library.md +++ b/docs/src/protocol_library.md @@ -27,6 +27,16 @@ Most procedures in the Miden protocol library are implemented as wrappers around The procedures maintain the same security and context restrictions as the underlying kernel procedures. When invoking these procedures, ensure that the calling context matches the requirements. +## Account ID Procedures (`miden::protocol::account_id`) + +Account ID procedures can be used to validate and compare account IDs. + +| Procedure | Description | Context | +| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| `is_equal` | Returns whether two account IDs are equal.

**Inputs:** `[account_id_suffix, account_id_prefix, other_account_id_suffix, other_account_id_prefix]`
**Outputs:** `[is_id_equal]` | Any | +| `validate` | Validates the provided account ID.

**Inputs:** `[account_id_suffix, account_id_prefix]`
**Outputs:** `[]` | Any | +| `shape_suffix` | Shapes a digest suffix into an account ID suffix by clearing the lower 8 bits.

**Inputs:** `[seed_digest_suffix]`
**Outputs:** `[account_id_suffix]` | Any | + ## Active account Procedures (`miden::protocol::active_account`) Active account procedures can be used to read from storage, fetch or compute commitments or obtain other internal data of the active account. @@ -83,6 +93,10 @@ Active note procedures can be used to fetch data from the note that is currently | `get_sender` | Returns the sender of the active note.

**Inputs:** `[]`
**Outputs:** `[sender_id_suffix, sender_id_prefix]` | Note | | `get_serial_number` | Returns the [serial number](note.md#serial-number) of the active note.

**Inputs:** `[]`
**Outputs:** `[SERIAL_NUMBER]` | Note | | `get_script_root` | Returns the [script root](note.md#script) of the active note.

**Inputs:** `[]`
**Outputs:** `[SCRIPT_ROOT]` | Note | +| `get_attachments_commitment` | Returns the commitment over all attachments of the active note.

**Inputs:** `[]`
**Outputs:** `[ATTACHMENTS_COMMITMENT]` | Note | +| `write_attachment_commitments_to_memory` | Writes the attachment commitments of the active note to the specified memory address.

**Inputs:** `[dest_ptr]`
**Outputs:** `[num_attachments]` | Note | +| `write_attachment_to_memory` | Writes the attachment with the provided index from the active note to the specified memory address.

**Inputs:** `[dest_ptr, attachment_idx]`
**Outputs:** `[num_words]` | Note | +| `find_attachment` | Searches the metadata of the active note for the specified attachment scheme and returns the index of the first matching slot.

**Inputs:** `[attachment_scheme]`
**Outputs:** `[is_found, attachment_idx]` | Note | ## Input Note Procedures (`miden::protocol::input_note`) @@ -98,6 +112,10 @@ Input note procedures can be used to fetch data on input notes consumed by the t | `get_storage_info` | Returns the [inputs](note.md#inputs) commitment and length of the input note with the specified index.

**Inputs:** `[note_index]`
**Outputs:** `[NOTE_STORAGE_COMMITMENT, num_storage_items]` | Any | | `get_script_root` | Returns the [script root](note.md#script) of the input note with the specified index.

**Inputs:** `[note_index]`
**Outputs:** `[SCRIPT_ROOT]` | Any | | `get_serial_number` | Returns the [serial number](note.md#serial-number) of the input note with the specified index.

**Inputs:** `[note_index]`
**Outputs:** `[SERIAL_NUMBER]` | Any | +| `get_attachments_commitment` | Returns the commitment over all attachments of the input note with the specified index.

**Inputs:** `[note_index]`
**Outputs:** `[ATTACHMENTS_COMMITMENT]` | Any | +| `write_attachment_commitments_to_memory` | Writes the attachment commitments of the input note with the specified index to the specified memory address.

**Inputs:** `[dest_ptr, note_index]`
**Outputs:** `[num_attachments]` | Any | +| `write_attachment_to_memory` | Writes the attachment with the provided index from the input note with the specified index to the specified memory address.

**Inputs:** `[dest_ptr, attachment_idx, note_index]`
**Outputs:** `[num_words]` | Any | +| `find_attachment` | Searches the metadata of the input note for the specified attachment scheme and returns the index of the first matching slot.

**Inputs:** `[attachment_scheme, note_index]`
**Outputs:** `[is_found, attachment_idx]` | Any | ## Output Note Procedures (`miden::protocol::output_note`) @@ -109,9 +127,13 @@ Output note procedures can be used to fetch data on output notes created by the | `get_assets_info` | Returns the information about assets in the output note with the specified index.

**Inputs:** `[note_index]`
**Outputs:** `[ASSETS_COMMITMENT, num_assets]` | Any | | `get_assets` | Writes the assets of the output note with the specified index into memory starting at the specified address.

**Inputs:** `[dest_ptr, note_index]`
**Outputs:** `[num_assets, dest_ptr, note_index]` | Any | | `add_asset` | Adds the asset to the output note specified by the index.

**Inputs:** `[ASSET_KEY, ASSET_VALUE, note_idx]`
**Outputs:** `[]` | Native | -| `set_attachment` | Sets the attachment of the note specified by the index.

If attachment_kind == Array, there must be an advice map entry for ATTACHMENT.

**Inputs:**
`Operand Stack: [note_idx, attachment_scheme, attachment_kind, ATTACHMENT]`
`Advice map: { ATTACHMENT?: [[ATTACHMENT_ELEMENTS]] }`
**Outputs:** `[]` | Native | -| `set_array_attachment` | Sets the attachment of the note specified by the note index to the provided ATTACHMENT which commits to an array of felts.

**Inputs:**
`Operand Stack: [note_idx, attachment_scheme, ATTACHMENT]`
`Advice map: { ATTACHMENT: [[ATTACHMENT_ELEMENTS]] }`
**Outputs:** `[]` | Native | -| `set_word_attachment` | Sets the attachment of the note specified by the note index to the provided word.

**Inputs:** `[note_idx, attachment_scheme, ATTACHMENT]`
**Outputs:** `[]` | +| `add_attachment` | Adds an attachment to the note specified by the index. There must be an advice map entry for ATTACHMENT_COMMITMENT that maps to the raw attachment elements.

**Inputs:**
`Operand Stack: [attachment_scheme, ATTACHMENT_COMMITMENT, note_idx]`
`Advice map: { ATTACHMENT_COMMITMENT: [[ATTACHMENT_ELEMENTS]] }`
**Outputs:** `[]` | Native | +| `add_word_attachment` | Adds a single-word attachment to the note specified by the note index. Hashes the raw attachment word to produce the commitment, inserts the raw elements into the advice map, then delegates to `add_attachment`.

**Inputs:** `[attachment_scheme, ATTACHMENT, note_idx]`
**Outputs:** `[]` | Native | +| `add_attachment_from_memory` | Adds a multi-word attachment to the note specified by the note index. Hashes the raw attachment words to produce the commitment, inserts the raw elements into the advice map, then delegates to `add_attachment`.

**Inputs:** `[attachment_scheme, num_words, attachment_ptr, note_idx]`
**Outputs:** `[]` | Native | +| `get_attachments_commitment` | Returns the commitment over all attachments of the output note with the specified index.

**Inputs:** `[note_index]`
**Outputs:** `[ATTACHMENTS_COMMITMENT]` | Any | +| `write_attachment_commitments_to_memory` | Writes the attachment commitments of the output note with the specified index to the specified memory address.

**Inputs:** `[dest_ptr, note_index]`
**Outputs:** `[num_attachments]` | Any | +| `write_attachment_to_memory` | Writes the attachment with the provided index from the output note with the specified index to the specified memory address.

**Inputs:** `[dest_ptr, attachment_idx, note_index]`
**Outputs:** `[num_words]` | Any | +| `find_attachment` | Searches the metadata of the output note for the specified attachment scheme and returns the index of the first matching slot.

**Inputs:** `[attachment_scheme, note_index]`
**Outputs:** `[is_found, attachment_idx]` | Any | | `get_recipient` | Returns the [recipient](note#note-recipient-restricting-consumption) of the output note with the specified index.

**Inputs:** `[note_index]`
**Outputs:** `[RECIPIENT]` | Any | | `get_metadata` | Returns the [metadata](note#metadata) of the output note with the specified index.

**Inputs:** `[note_index]`
**Outputs:** `[METADATA]` | Any | @@ -123,9 +145,15 @@ Note utility procedures can be used to compute the required utility data or writ | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | `compute_storage_commitment` | Computes the commitment to the output note storage starting at the specified memory address.

**Inputs:** `[storage_ptr, num_storage_items]`
**Outputs:** `[STORAGE_COMMITMENT]` | Any | | `write_assets_to_memory` | Writes the assets data stored in the advice map to the memory specified by the provided destination pointer.

**Inputs:** `[ASSETS_COMMITMENT, num_assets, dest_ptr]`
**Outputs:** `[num_assets, dest_ptr]` | Any | -| `build_recipient_hash` | Returns the `RECIPIENT` for a specified `SERIAL_NUM`, `SCRIPT_ROOT`, and storage commitment.

**Inputs:** `[SERIAL_NUM, SCRIPT_ROOT, STORAGE_COMMITMENT]`
**Outputs:** `[RECIPIENT]` | Any | -| `build_recipient` | Builds the recipient hash from note storage, script root, and serial number.

**Inputs:** `[storage_ptr, num_storage_items, SERIAL_NUM, SCRIPT_ROOT]`
**Outputs:** `[RECIPIENT]` | Any | -| `extract_sender_from_metadata` | Extracts the sender ID from the provided metadata word.

**Inputs:** `[METADATA]`
**Outputs:** `[sender_id_suffix, sender_id_prefix]` | Any | +| `write_attachments_to_memory` | Writes the attachment commitments stored in the advice map to the memory specified by the provided destination pointer.

**Inputs:**
`Operand Stack: [ATTACHMENTS_COMMITMENT, dest_ptr]`
`Advice map: { ATTACHMENTS_COMMITMENT: [[ATTACHMENT_COMMITMENT]] }`
**Outputs:** `[num_attachments]` | Any | +| `write_attachment_to_memory` | Writes a single attachment's data stored in the advice map to the memory specified by the provided destination pointer.

**Inputs:**
`Operand Stack: [ATTACHMENT_COMMITMENT, dest_ptr]`
`Advice map: { ATTACHMENT_COMMITMENT: [[ATTACHMENT_ELEMENTS]] }`
**Outputs:** `[num_words]` | Any | +| `write_indexed_attachment_to_memory` | Writes the attachment with the provided index from the provided attachment commitments to the specified memory address.

**Inputs:** `[num_attachments, attachment_commitments_ptr, attachment_idx, dest_ptr]`
**Outputs:** `[num_words]` | Any | +| `find_attachment_idx` | Searches the metadata for the specified attachment scheme and returns the index of the first matching slot.

**Inputs:** `[attachment_scheme, METADATA]`
**Outputs:** `[is_found, attachment_idx]` | Any | +| `compute_recipient` | Returns the `RECIPIENT` for a specified `SERIAL_NUM`, `SCRIPT_ROOT`, and storage commitment.

**Inputs:** `[SERIAL_NUM, SCRIPT_ROOT, STORAGE_COMMITMENT]`
**Outputs:** `[RECIPIENT]` | Any | +| `compute_and_store_recipient` | Computes the recipient from note storage, script root, and serial number, and stores the recipient preimages in advice inputs.

**Inputs:** `[storage_ptr, num_storage_items, SERIAL_NUM, SCRIPT_ROOT]`
**Outputs:** `[RECIPIENT]` | Any | +| `metadata_into_sender` | Extracts the sender ID from the provided metadata word.

**Inputs:** `[METADATA]`
**Outputs:** `[sender_id_suffix, sender_id_prefix]` | Any | +| `metadata_into_attachment_schemes` | Extracts the attachment schemes from the provided metadata.

**Inputs:** `[METADATA]`
**Outputs:** `[attachment_0_scheme, attachment_1_scheme, attachment_2_scheme, attachment_3_scheme]` | Any | +| `metadata_into_note_type` | Extracts the note type from the provided metadata. The note type is encoded as a single bit (0 = Private, 1 = Public).

**Inputs:** `[METADATA]`
**Outputs:** `[note_type]` | Any | ## Transaction Procedures (`miden::protocol::tx`) @@ -163,4 +191,4 @@ Asset procedures provide utilities for creating fungible and non-fungible assets | Procedure | Description | Context | | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | `create_fungible_asset` | Builds a fungible asset for the specified fungible faucet and amount.

**Inputs:** `[enable_callbacks, faucet_id_suffix, faucet_id_prefix, amount]`
**Outputs:** `[ASSET_KEY, ASSET_VALUE]` | Any | -| `create_non_fungible_asset` | Builds a non-fungible asset for the specified non-fungible faucet and data hash.

**Inputs:** `[faucet_id_suffix, faucet_id_prefix, DATA_HASH]`
**Outputs:** `[ASSET_KEY, ASSET_VALUE]` | Any | +| `create_non_fungible_asset` | Builds a non-fungible asset for the specified non-fungible faucet and data hash.

**Inputs:** `[enable_callbacks, faucet_id_suffix, faucet_id_prefix, DATA_HASH]`
**Outputs:** `[ASSET_KEY, ASSET_VALUE]` | Any | diff --git a/docs/src/state.md b/docs/src/state.md index 02233cf6d3..e290577d51 100644 --- a/docs/src/state.md +++ b/docs/src/state.md @@ -46,9 +46,9 @@ This is done using an authenticated data structure, a sparse Merkle tree. Account DB

-As described in the [account ID section](account/id#account-storage-mode), accounts can have different storage modes: +As described in the [account ID section](account/id#account-type), accounts can have different account types: -- **Public & Network accounts:** where all account data is stored on-chain. +- **Public accounts:** where all account data is stored on-chain. - **Private accounts:** where only the commitments to the account are stored on-chain. Private accounts significantly reduce storage overhead. A private account contributes only 40 bytes to the global `State` (15 bytes for the account ID + 32 bytes for the account commitment + 4 bytes for the block number). For example, 1 billion private accounts take up only 47.47 GB of `State`. diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 6744e56e15..a37cb5745c 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,5 +1,5 @@ [toolchain] -channel = "1.90" +channel = "1.94" components = ["clippy", "rust-src", "rustfmt"] profile = "minimal" targets = ["wasm32-unknown-unknown"] diff --git a/zizmor.yml b/zizmor.yml new file mode 100644 index 0000000000..5dc62940b9 --- /dev/null +++ b/zizmor.yml @@ -0,0 +1,4 @@ +rules: + secrets-outside-env: + ignore: + - trigger-deploy-docs.yml