0xmiden/compiler/rust-sdk-pitfalls

Claude Code and agent skill by 0xmiden/compiler

Trust tier 2
  • community
LLM review pending
Source check pending

Checking that the SKILL.md file is still in the repository.

Critical pitfalls and safety rules for Miden Rust SDK development. Covers felt arithmetic security, comparison operators, argument limits, storage naming, no-std setup, asset layout, P2ID roots, NoteType construction, note-to-component call boundaries, and note input immutability. Use when reviewing, debugging, or writing Miden contract code.

Install

npx vskill@latest install 0xmiden/compiler/rust-sdk-pitfalls

SKILL.md

Miden SDK Pitfalls

The project-template uses guest SDK miden and miden-sdk-build-script-support 0.15.0 on nightly-2026-09-01, compiler / cargo-miden 0.11.0 in the current checkout, protocol/standards/testing 0.18.0, and VM/package crates in the 0.36 series. The client crates use the released 0.18.0 versions from crates.io; Cargo.lock records the resolved versions.

P1: Felt Arithmetic is Modular (SECURITY CRITICAL)

Severity: Critical — can cause loss of funds

Felt subtraction wraps around the prime field modulus (p = 2^64 - 2^32 + 1) instead of panicking. Subtracting more than available silently produces a huge positive number.

rust
// DANGEROUS — no check before subtraction
let new_balance = current_balance - withdraw_amount;
// If withdraw_amount > current_balance, new_balance ≈ 2^64 (wraps!)
// SAFE — always validate first
assert!(
current_balance.as_canonical_u64() >= withdraw_amount.as_canonical_u64(),
"Insufficient balance"
);
let new_balance = current_balance - withdraw_amount;

Rule: ALWAYS check .as_canonical_u64() values before any Felt subtraction.

Max Felt value: The maximum valid Felt is p - 1 = 18446744069414584320, not u64::MAX (18446744073709551615). Using u64::MAX as a sentinel or boundary value causes silent wraparound.

Prefer `AssetAmount` for token quantities. miden::AssetAmount is a validated newtype over Felt (AssetAmount::MAX_U64 = (1 << 63) - (1 << 31)) whose Add / Sub impls panic on overflow / underflow rather than wrapping, and whose ordering is canonical-integer ordering. Constructors and accessors: AssetAmount::new(u64) -> Result<AssetAmount, AssetAmountError>, AssetAmount::max(), AssetAmount::ZERO, as_u64(), as_felt(). It is a valid WordKey and WordValue, so it can be stored directly in StorageValue / StorageMap and used in exported signatures.

P2: Felt Comparison Operators Are Misleading for Quantity Logic

Severity: High — silently produces incorrect results

<, >, <=, >= on Felt values compare field elements, which differs from natural number ordering. In protocol-level code working with field elements, these comparisons may be intentional. For business logic (balances, amounts, counts), the results are misleading.

rust
// MISLEADING for business logic — compares field elements
if balance > threshold { ... }
// CORRECT for business logic — compare as integers
if balance.as_canonical_u64() > threshold.as_canonical_u64() { ... }

Rule: For quantity/business logic, ALWAYS convert to .as_canonical_u64() before using comparison operators.

Exception — typed scalars already compare as integers. BlockNumber, Nonce and AssetAmount derive integer ordering, so no conversion is needed. The p2ide-note example relies on this directly:

rust
let block_number = tx::get_reference_block_number(); // BlockNumber
let timelock_height = BlockNumber::try_from(inputs[3]).unwrap();
assert!(block_number >= timelock_height); // integer comparison, correct as written

P3: The 16-Felt Cross-Context Boundary — Two Limits, and Exports Differ From FPI Imports

Severity: High — the wrong mental model makes you refactor a signature that would have compiled, and miss the one that will not

A cross-context call passes its parameters on the MASM operand stack, whose addressable window is 16 felts (4 Words, counting the canonical-ABI result pointer when present). Two different limits govern that boundary, and conflating them is the actual pitfall:

  • `MAX_FLAT_PARAMS = 16` — a count of canonical-ABI flat values.
  • `MAX_DIRECT_STACK_FELTS = 16` — a felt budget, measured after u64 values expand to two

felts each and any result pointer is added.

Both live in compiler:sdk/v0.14.0:frontend/wasm/src/component/types/mod.rs:44-62, whose own doc comment spells out the distinction: the felt budget "is a Miden VM constraint, distinct from the spec's count-based MAX_FLAT_PARAMS: a signature can stay within 16 flat values while 64-bit values expand it past 16 stack felts."

Exceeding either limit makes canonical-ABI flattening replace the whole parameter list with a single pointer to a tuple in linear memory — flat_params_need_tuple is an OR (compiler:sdk/v0.14.0:frontend/wasm/src/component/flat.rs:212-217,263-270):

rust
flat_params.len() > MAX_FLAT_PARAMS
|| flat_params.iter().map(|param| param.ty.size_in_felts()).sum::<usize>()
> MAX_DIRECT_STACK_FELTS

What happens to that pointer is where the two sides of the boundary part ways.

FPI imports: the count decides the path, the felt budget can still reject it

The critical subtlety: plan_fpi_call does not reuse the OR above. It re-derives the call shape from the flat-value count alone (compiler:sdk/v0.14.0:frontend/wasm/src/component/lower_imports.rs:330-351):

rust
let has_arg_ptr = flattened_params.len() > MAX_FLAT_PARAMS;

So the two disagree in exactly one band — count ≤ 16 but felts > 16. Flattening has tupled such a signature, but plan_fpi_call still believes it is a direct call, so it takes the !has_arg_ptr path, sums the operand felts, and rejects:

FPI import `{path}` lowers to {n} operand stack felts after expanding 64-bit
values and result pointers, but direct FPI calls support at most 16

The source says this check is deliberately ordered first, because "over-budget direct shapes are tupled by canonical ABI flattening, which would otherwise surface as a confusing shape mismatch."

rust
// REJECTED — 13 flat values, so the count-based `has_arg_ptr` is false, but six
// `u64`s expand to two felts each: 6 prefix felts + 12 + 1 + 1 result pointer
// = 20 operand felts.
struct SixU64Record { a: u64, b: u64, c: u64, d: u64, e: u64, f: u64, tag: Felt }
fn echo_six_u64_record(&self, input: SixU64Record) -> SixU64Record;

That is the compiler's own negative test, compiler:sdk/v0.14.0:tests/integration-network/src/mockchain/fpi/note/six_u64_struct.rs:5-13 (#[should_panic(expected = "direct FPI calls support at most 16")]).

Above 16 flat values the indirect path is supported — has_arg_ptr is true, the felt-budget check is skipped entirely, and the wrapper reloads the tuple so the backend still sees a direct, felt-only call (compiler:sdk/v0.14.0:frontend/wasm/src/component/lower_imports.rs:439-508). A 22-flat-parameter FPI import is a passing test (compiler:sdk/v0.14.0:tests/integration-network/src/mockchain/fpi/note/sixteen_flattened_params_struct.rs).

Indirect is not unbounded, though. The FPI executor imposes its own caps, checked after the shape is settled (compiler:sdk/v0.14.0:frontend/wasm/src/component/lower_imports.rs:381-399), with values from ExecFpi (compiler:sdk/v0.14.0:dialects/hir/src/ops/invoke.rs:160-169):

boundvaluediagnostic
PREFIX_FELTS — account id + procedure root, subtracted before the input check6must pass account id and procedure root
MAX_INPUT_FELTS — flattened procedure input felts, after the prefix16`passes {n} flattened procedure input felts, but execute_foreign_procedure supports at most 16`
EXECUTOR_RESULT_FELTS — result felts16`returns {n} result felts, but execute_foreign_procedure supports at most 16`

So the tuple pointer buys you past the stack window, not past the protocol: the payload the foreign procedure actually receives is still capped at 16 felts.

Do not "fix" the felt-budget rejection by padding the signature until the count exceeds 16 just to trigger the indirect path. It does flip has_arg_ptr and skip the stack-window check, but the executor's 16-felt input cap then rejects the same payload — you have only moved which diagnostic fires. Reduce what crosses the boundary instead: split the call, or hand over an identifier (a storage key, a note index, a commitment) and let the callee load the rest itself.

Component exports: indirect parameters are not implemented yet

On the export side the tuple pointer is produced the same way but then refused, so either an over-16 flat-value count or an over-16 felt budget fails (compiler:sdk/v0.14.0:frontend/wasm/src/component/lift_exports.rs:68-74):

component export lifting for '{path}' is not yet implemented for passing the
parameters using the advice provider in the cross-context `call`;
rust
// REJECTED as a component export — flattens to 20 felt params.
fn process(a: Word, b: Word, c: Word, d: Word, e: Word) { ... }
// STILL REJECTED — a wrapper struct compresses nothing. Flattening recurses
// into struct fields and concatenates them, so a WordBatch holding those same
// five Words is still 20 felts.
fn process(batch: WordBatch) { ... }
// OK — reduce what actually crosses the boundary.
fn process(batch_commitment: Word) { ... }

Export return values are capped separately, at 16 loaded values (a count, with no felt-budget check at all — a record of nine u64 fields is 9 values but 18 felts and is not caught): compiler:sdk/v0.14.0:frontend/wasm/src/component/lift_exports.rs:281-286.

Unrelated, but adjacent

&T parameters are refused before any of this, by the #[component] macro rather than the compiler frontend: references are not supported in component interfaces or exported types (compiler:sdk/v0.14.0:sdk/base-macros/src/types.rs:102-106). It applies to exported method parameters, return types, and exported struct/enum fields alike — &self receivers are fine.

P4: Storage API Is Typed, and a Component Is Three Parts

Severity: Medium — the wrong component shape does not compile

Account storage uses typed slots:

  • StorageValue<T> for a single typed slot
  • StorageMap<K, V> for typed maps
  • get() / set() methods instead of .read() / .write()
  • K: WordKey, T: WordValue, V: WordValue

WordValue is implemented for Word, Felt, AssetAmount, Digest, AccountId, Recipient, Tag, NoteIdx, NoteType. WordKey is implemented for Word, Felt, AssetAmount, AccountId, Tag, NoteIdx, NoteType.

An account component is written in three parts: annotate the storage struct with #[component_storage], the API trait with #[component], and the impl Trait for Storage block with #[component]. Every trait method that must be callable from outside the component also needs #[account_procedure] (see P13):

rust
// 1. Storage struct — annotated #[component_storage], NOT #[component].
// Applying #[component] to a struct is a hard compile error.
#[component_storage]
struct CounterContractStorage {
#[storage(description = "counter contract storage map")]
count_map: StorageMap<Word, Felt>,
}
// 2. API trait — defines the exported interface.
#[component]
trait CounterContract {
#[account_procedure]
fn get_count(&self) -> Felt;
#[account_procedure]
fn increment_count(&mut self) -> Felt;
}
// 3. Implementation — the behavior, wired to the storage struct.
// #[account_procedure] goes on the trait declaration only, never here.
#[component]
impl CounterContract for CounterContractStorage {
fn get_count(&self) -> Felt {
let key = Word::new([felt!(0), felt!(0), felt!(0), felt!(1)]);
self.count_map.get(key)
}
fn increment_count(&mut self) -> Felt {
let key = Word::new([felt!(0), felt!(0), felt!(0), felt!(1)]);
let current_value: Felt = self.count_map.get(key);
let new_value = current_value + felt!(1);
self.count_map.set(key, new_value);
new_value
}
}

#[component_storage] accepts only unit structs and structs with named fields — a tuple struct fails with ` #[component_storage] only supports unit structs or structs with named fields. ` Generic storage structs are rejected.

This is the start of the file. Read the full SKILL.md on GitHub.

Security

  • Security scanPassed, 100 of 100Pattern checks on the skill's files.
  • Model reviewNot runA model review is scheduled for the next rescan.

Quality evaluation

  • Task evaluationNot runNo evaluation yet. Run one in Skill Studio.

Source

Popularity

Repository stars
116
Repository forks
83
7-day trend score
29

Stars and forks are for the whole GitHub repository, not this skill alone. Not refreshed yet.

Works with

Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.

  • Claude Code
  • Cursor
  • GitHub Copilot
  • Windsurf
  • Codex
  • Gemini CLI
  • Cline
  • Amp
  • Roo Code
  • Goose
  • Continue
  • Kilo Code
  • Junie
  • Trae
  • OpenHands
  • Qwen Code
  • AI Agent
  • Tabnine Chat
  • Sourcegraph Cody
  • Amazon Q
  • Firebase Genkit
  • Sweep
  • Devin
  • Phind
  • Mentat
  • Claude Desktop
  • Aider
  • GPT Pilot
  • Plandex
  • bolt.new
  • v0
  • Replit Agent
  • Anse
  • TypingMind
  • BetterChatGPT
  • LibreChat
  • Warp AI
  • Pieces
  • Fig AI
Embed badge
vskill VERIFIED badge
[![vskill VERIFIED](https://verified-skill.com/api/v1/skills/0xmiden/compiler/rust-sdk-pitfalls/badge)](https://verified-skill.com/skills/0xmiden/compiler/rust-sdk-pitfalls)

Something wrong with this skill or its page? Tell us.