Developer Guide
Welcome to the developer guide for the miden node :)
This is intended to serve as a basic introduction to the codebase as well as covering relevant concepts and recording architectural decisions.
This is not intended for dApp developers or users of the node, but for development of the node itself.
It is also a good idea to familiarise yourself with the operator manual.
Living documents go stale - the code is the final arbitrator of truth.
If you encounter any outdated, incorrect or misleading information, please open an issue.
Contributing to Miden Node
Thanks for taking the time to contribute. We want to make contributing to this project as easy and transparent as possible.
Before you begin
Start by commenting your interest in the issue you want to address. This lets us assign the issue to you, prevents multiple people from repeating the same work, and gives us a place to add any context you may need.
Most contributions should branch from and target next. Maintenance work may instead target a release branch; see the
branching policy for details. If you are unsure which branch to use, confirm the target on the
issue before starting work.
Typos and low-effort contributions
We don't accept PRs for typo fixes as these are often scanned for AI "contributors". If you find typos please open an issue instead.
Commits
Try keep your commit names and messages related to the content. This provides reviewers with context if they need to step through your changes by commit.
This does not need to be perfect because we generally squash merge a PR - the commit naming is therefore only relevant for the review process.
Pre-PR checklist
Before submitting a PR, ensure that you're up to date by rebasing onto the branch your pull request targets, and that tests and lints pass by running:
# Runs the various lints
make lint
# Runs the test suite
make test
Post-PR
Please don't rebase your branch once the PR has been opened. In other words - only append new commits. This lets reviewers have a consistent view of your changes for follow-up reviews. Reviewers may request a rebase once they're ready in order to merge your changes in.
Any contributions you make will be under the MIT Software License
In short, when you submit code changes, your submissions are understood to be under the same MIT License that covers the project. Feel free to contact the maintainers if that's a concern.
Branching policy
The repository has two kinds of protected branches:
nextis the active development branch and the default target for pull requests.release/vX.Yis a maintained branch for a specific minor release line, for examplerelease/v0.16.
Creating a release branch
next may continue to represent the latest stable release line until development requires a breaking change. Before
that change is merged, maintainers create the corresponding release/vX.Y branch from next. The release branch
preserves the compatible release line while breaking development continues on next.
The version portion of a release branch contains only the major and minor version. Each component is a non-negative integer without leading zeroes. Only repository administrators create new release branches.
Applying changes
After a release branch is created, it permanently diverges from next. Releases are not promoted by merging next into
a release branch or by merging a release branch back into next.
Changes should target the branch where they are required:
- New development targets
next. - Maintenance changes target the applicable
release/vX.Ybranch. - A change needed on multiple branches is applied to each branch through its own pull request, normally by backporting the original change.
Branch protection
The Protected branches ruleset applies to next and all release/vX.Y branches. It prevents deletion and
non-fast-forward updates, requires pull requests, and requires the repository's test status check.
Administrators can bypass reviews and required checks through a pull request when necessary, but cannot bypass the pull request requirement. This preserves an audit trail for every protected branch change.
Release process
A release is created by tagging the commit to release. It does not require a merge between next and a release branch.
The commit may be on next while it still represents that release line, or on the applicable release/vX.Y branch
after the branches have diverged.
Release tags
Release tags use one of these forms:
vX.Y.Zfor a stable release, for examplev0.16.2.vX.Y.Z-suffixfor a prerelease, for examplev0.17.0-rc.1.
X, Y, and Z are non-negative integers without leading zeroes. A prerelease suffix must start with - and contain
at least one character after it.
The Release tags ruleset is the source of truth for the accepted tag format and restricts who can create, update, or
delete release tags.
Creating a release
- Ensure the target commit is in a publishable state. Every publishable workspace package must use the same version.
- Create the release tag on that commit and push it to GitHub. The tag without its leading
vmust exactly match the workspace package version. - The
Releaseworkflow validates the tag and package versions. It checks that the Rust toolchain, workspace MSRV, crate Rust versions, and Docker Rust version match. It checks crate builds and dry-runs crate, Docker image, and Compose publishing. It then publishes the Docker images and Compose application. - After those checks pass, the workflow creates the GitHub release and release notes. It then starts the crates.io publishing workflow.
The root docker-compose.yml includes the component models under compose/, keeping direct local Compose commands and
profiles independent of additional -f arguments. Local includes cannot be published directly, so the
.github/actions/publish-compose action first renders the complete, all-profile model without interpolating its
variables or normalizing project resource names. It then either dry-runs or publishes that flattened model.
The workflow uses a broad v* trigger because GitHub Actions does not use the same pattern language as repository
rulesets. Its preflight step verifies that this trigger still matches the target of the Release tags ruleset, while
the ruleset itself enforces the release-tag format.
Navigating the codebase
The code is organised using a Rust workspace with separate crates for the node and remote prover binaries, a crate for each node component, a couple of gRPC-related codegen crates, and a catch-all utilities crate.
The primary execution artifacts are the node and remote prover binaries. The library crates are not intended for external usage, but instead simply serve to enforce code organisation and decoupling.
We have a top-level proto crate, which contains the external and internal gRPC and protobuf schemas. It also exposes the
tonic/prost file descriptors for each gRPC service for convenience. We then have an internal proto crate in ./crates,
which uses the above file descriptors to generate the actual service traits, and also defines some domain objects and other gRPC
shared utilities and definitions.
[!NOTE] >
miden-protocolis an important dependency which contains the core Miden protocol definitions e.g. accounts, notes, transactions etc.
Monitoring
Developer level overview of how we aim to use tracing and open-telemetry to provide monitoring and telemetry for the
node.
Please begin by reading through the monitoring operator guide as this will provide some much needed context.
Approach and philosophy
We want to trace important information such that we can quickly recognise issues (monitoring & alerting) and identify the cause. Conventionally this has been achieved via metrics and logs respectively, however a more modern approach is using wide-events/traces and post-processing these instead. We're using the OpenTelemetry standard for this, however we are only using the trace pillar and avoid metrics and logs.
We wish to emit these traces without compromising on code quality and readability. This is also a downside to including
metrics - these are usually emitted inline with the code, causing noise and obscuring the business logic. Ideally we
want to rely almost entirely on tracing::#[instrument] to create spans as these live outide the function body.
There are of course exceptions to the rule - usually the root span itself is created manually e.g. a new root span for
each block building iteration. Inner spans should ideally keep to #[instrument] where possible.
Local Compose routing
The local Compose model always runs an OpenTelemetry Collector, and every Miden service exports to its stable in-stack address. This indirection is necessary because a Compose profile can start Tempo but cannot conditionally change the environment of the already-running Miden services.
The collector fans each trace out through two independent failover connectors. One prefers the Tempo service and the
other prefers the caller-defined OTEL_EXPORTER_OTLP_ENDPOINT. An unavailable destination falls back to a /dev/null
file exporter, and the collector periodically probes the preferred destination so it can resume forwarding when the
destination becomes available. The caller branch defaults to a secondary loopback OTLP receiver that feeds the same null
exporter, avoiding connection errors when no external endpoint is configured.
This setup deliberately favors isolation over durable delivery. Traces are dropped while a destination is unavailable, so neither an optional Compose profile nor an external collector can interfere with the node services.
Relevant crates
We've attempted to lock most of the OpenTelemetry crates behind our own abstractions in the utils crate. There are a
lot of these crates and it can be difficult to keep them all separate when writing new code. We also hope this will
provide a more consistent result as we build out our monitoring.
tracing is the defacto standard for logging and tracing within the Rust ecosystem. OpenTelemetry has decided to avoid
fracturing the ecosystem and instead attempts to bridge between tracing and the OpenTelemetry standard in-so-far as is
possible. All this to say that there are some rough edges where the two combine - this should improve over time.
| crate | description |
|---|---|
tracing | Emits tracing spans and events. |
tracing-subscriber | Provides the conventional tracing stdout logger (no interaction with OpenTelemetry). |
tracing-forest | Logs span trees to stdout. Useful to visualize span relations, but cannot trace across RPC boundaries as it doesn't understand remote tracing context. |
tracing-opentelemetry | Bridges the gaps between tracing and the OpenTelemetry standard. |
opentelemetry | Defines core types and concepts for OpenTelemetry. |
opentelemetry-otlp | gRPC exporter for OpenTelemetry traces. |
opentelemetry_sdk | Provides the OpenTelemetry abstractions for metrics, logs and traces. |
opentelemetry-semantic-conventions | Constants for naming conventions as per OpenTelemetry standard. |
Important concepts
OpenTelemetry standards & documentation
There is a lot. You don't need all of it - look things up as and when you stumble into confusion.
It is probably worth reading through the naming conventions to get a sense of style.
Footguns and common issues
tracing requires data to be known statically e.g. you cannot add span attributes dynamically. tracing-opentelemetry
provides a span extension trait which works around this limitation - however this dynamic information is only visible
to the OpenTelemetry processing i.e. tracing_subscriber won't see this at all.
In general, you'll find that tracing subscribers are blind to any extensions or OpenTelemetry specific concepts. The
reverse is of course not true because OpenTelemetry is integrating with tracing.
Another pain point is error stacks - or rather lack thereof. #[tracing::instrument(err)] correctly marks the span as
an error, however unfortunately the macro only uses the Display or Debug implementation of the error. This means you
are missing the error reports entirely. tracing_opentelemetry reuses the stringified error data provided by tracing
so currently there is no work-around for this. Using Debug via ?err at least shows some information but one still
misses the actual error messages which is quite bad.
Manually instrumenting code (i.e. without #[instrument]) can be rather error prone because async calls must be
manually instrumented each time. And non-async code also requires holding the span.
Distributed context
We track traces across our components by injecting the parent span ID into the gRPC client's request metadata. The server side then extracts this and uses this as the parent span ID for its processing.
This is an OpenTelemetry concept - conventional tracing cannot follow these relations.
Read more in the official OpenTelemetry documentation.
Choosing spans
A root span should represent a set of operations that belong together. It also shouldn't live forever as span information is usually only sent once the span closes i.e. a root span around the entire node makes no sense as the operation runs forever.
A good convention to follow is creating child spans for timing information you may want when debugging a failure or slow operation. As an example, it may make sense to instrument a mutex locking function to visualize the contention on it. Or separating the database file IO from the sqlite statement creation. Essentially operations which you would otherwise consider logging the timings for should be separate spans. While you may find this changes the code you might otherwise create, we've found this actually results in fairly good structure since it follows your business logic sense.
Inclusions and naming conventions
Where possible, attempt to find and use the naming conventions specified by the standard, ideally via the
opentelemetry-semantic-conventions crate.
Include information you'd want to see when debugging - make life easy for your future self looking at data at 3AM on a Saturday. Also consider what information may be useful when correlating data e.g. client IP.
Node components
The node is split into five distinct components that communicate via gRPC. See the operator architecture guide for an overview of each component.
The following sections will describe the inner architecture of each component.
RPC Component
This is by far the simplest component. Essentially this is a thin gRPC server which proxies all requests to the store and block-producer components.
Its main function is to pre-validate all requests before sending them on. This means malformed or non-sensical requests get rejected before reaching the store and block-producer, reducing their load. Notably this also includes verifying the proofs of submitting transactions. This allows the block-producer to skip proof verification (it trusts the RPC component), reducing the load in this critical component.
RPC Versioning and the HTTP ACCEPT header
The RPC component allows clients to negotiate their desired Miden RPC version using the well-known HTTP ACCEPT header, using the following format:
application/vnd.miden; version=<version-req>; genesis=<genesis-commitment>
The version lets the client specify their supported version and the server will attempt to comply if it can. At this early stage, only client versions which are semver compatible with the
server version are likely to be accepted i.e. the server in all likely only supports a single version.
The genesis property is intended to let the client confirm they are on the correct network, by specifying the network's genesis commitment. This guards against operating on the wrong network,
as well as against network resets.
Query limits (GetLimits)
The RPC service exposes a GetLimits endpoint which returns the query parameter limits enforced by the server for
multi-value parameters (e.g. number of nullifiers, note tags, note IDs, account IDs).
These limits are defined centrally in miden_node_utils::limiter and are enforced at the RPC boundary (and also inside
the store) to keep database queries bounded and to keep response payloads within the ~4 MB budget.
GENERAL_REQUEST_LIMIT is currently 1000, and endpoint-specific limits are:
| Endpoint | Parameter | Limit | Rationale |
|---|---|---|---|
GetAccount | storage_map_key | 64 | SMT proof generation for storage map keys is comparatively expensive |
GetNotesById | note_id | 100 | Notes can be large (~32 KiB), so this is intentionally tighter |
SyncNotes | note_tag | 1000 | Keeps note sync responses within payload budget |
SyncNullifiers | nullifier_prefix | 1000 | Bounds prefix-based nullifier scans |
SyncTransactions | account_id | 1000 | Bounds account filter fan-out and response size |
Additional internal-only limits in miden_node_utils::limiter (not surfaced by GetLimits) include:
| Parameter | Limit | Used by |
|---|---|---|
note_commitment | 1000 | Internal note proof lookups |
block_header | 1000 | Internal batch/block header operations |
Error Handling
The RPC boundary maps errors to gRPC status codes and method-specific detail bytes. The first byte of Status.details
identifies the application error. These values are part of the client API and must remain stable.
The read and sync handlers use explicit codes from crates/rpc/src/server/api/error_codes.rs. The mempool uses the
GrpcError derive macro. This macro assigns codes in variant order and maps internal errors to code 0. Add new
client-error variants at the end of the mempool error enum to preserve existing codes.
Clients must also accept statuses without details and unknown detail codes.
Store component
This component persists the chain state in a sqlite database. It also stores each block's raw data as a file.
Merkle data structures are kept in-memory and are rebuilt on startup. Other data like account, note and nullifier information is always read from disk. We will need to revisit this in the future but for now this is performant enough.
Migrations
We have database migration support in place but don't actively use it yet. There is only the latest schema, and we reset chain state (aka nuke the existing database) on each release.
RocksDB tree storage
The account and nullifier trees are persisted in separate RocksDB instances under
<data-directory>/accounttree and <data-directory>/nullifiertree, managed by
RocksDbStorage from miden-crypto. Column families: leaves, st16–st56 (subtrees at each
depth), metadata (root/counts), depth16 (cached depth-16 hashes for fast startup).
Compaction parallelism and background jobs are set to rayon::current_num_threads() automatically.
WAL sync per write is disabled for throughput; a 512 MiB WAL cap bounds recovery time. Bloom filter
bits vary by depth (8.0–12.0) and memtables are 128 MiB per column family. See RocksDbStorage::open for the
full fixed configuration. Runtime-tuneable parameters are documented in the
operator usage guide.
Architecture
The store consists mainly of state management plus a gRPC server which answers requests from the RPC component. In sequencer mode, the block-producer uses the store state in-process for block inputs and block application.
Block Producer Component
The block-producer is responsible for ordering transactions into batches, and batches into blocks, and creating the proofs for batches. Proving is usually outsourced to a remote prover but can be done locally if throughput isn't essential, e.g. for test purposes on a local node.
The core of the block-producer revolves around the mempool which forms a DAG of all in-flight transactions and batches. It also ensures all invariants of the transactions are upheld e.g. account's current state matches the transaction's initial state, that all input notes are valid and unconsumed and that the transaction hasn't expired.
Batch production
Transactions are selected from the mempool periodically to form batches. This batch is then proven and submitted back to the mempool where it can be included in a block.
Block production
Proven batches are selected from the mempool periodically to form the next block. The block is then built and sent to the validator for verification and signing. This signed block is then submitted to the store where it gets proven and committed. Proof generation in production is typically outsourced to a remote machine with appropriate resources. For convenience, it is also possible to perform proving in-process. This is useful when running a local node for test purposes.
Once the block is committed, all transactions and batches in the block are marked in the mempool as committed.
Mempool data pruning
The mempool keeps the N most recent blocks locally, to allow incoming transactions a grace period so we can verify their
state against the store, and the local state deltas in the mempool. Without this overlap, we would constantly be racing
transaction check against the store with newly committed blocks.
After each now block, the N+1th oldest block and its batches and transactionsa are pruned from the mempool state.
Transaction lifecycle
- Transaction arrives at RPC component
- Transaction proof is verified
- Transaction arrives at block-producer
- Transaction delta is verified
- Does the account state match
- Do all input notes exist and are unconsumed
- Output notes are unique
- Transaction is not expired
- Wait until all parent transactions are in a batch
- Be selected as part of a batch
- Proven as part of a batch
- Wait until all parent batches are in a block
- Be selected as part of a block
- Committed
Note that its possible for transactions to be rejected/dropped even after they've been accepted, at any point in the above lifecycle (which effectively shows the happy path). This can occur if:
- The transaction expires before being included in a block.
- Any parent transaction is dropped (which will revert the state, invalidating child transactions).
- It causes proving or any part of block/batch creation to fail repeatedly. This is a fail-safe against unforeseen bugs, removing problematic (but potentially valid) transactions from the mempool to prevent outages.
Network Transaction Builder Component
The network transaction builder (NTB) is responsible for driving the state of network accounts.
What is a network account
Network accounts are a special type of fully public account which contains no authentication and whose state can therefore be updated by anyone (in theory). Such accounts are required when publicly mutable state is needed.
An issue with publicly mutable state is that transactions against an account must be sequential and require the previous account commitment in order to create the transaction proof. This conflicts with Miden's client side proving and concurrency model since users would race each other to submit transactions against such an account.
Instead our solution is to have the network be responsible for driving the account state forward, and users can interact with the account only indirectly using notes. Notes don't require a specific ordering and can be created concurrently without worrying about conflicts. We call these network notes and they always target a specific network account.
A network transaction is a transaction which consumes and applies a set of network notes to a network account. There is nothing special about the transaction itself - it can only be identified by the fact that it updates the state of a network account.
Limitations
At present, we artificially limit this such that only this component may create transactions against network accounts. This is enforced at the RPC layer by disallowing network transactions entirely in that component. The NTB skirts around this by submitting its transactions directly to the block-producer.
This limitation is there to prevent complicating the NTBs implementation while the protocol and definitions of network accounts, notes and transactions mature.
Implementation
The NTB uses an actor-per-account model managed by a central Coordinator. On startup the
coordinator syncs all known network accounts and their unconsumed notes from the store. It then
follows the committed block stream from the RPC service for updates which would impact network
account state.
For each network account, the coordinator spawns a dedicated AccountActor. Each actor runs in
its own async task and is responsible for creating transactions that consume network notes targeting
its account. On startup, each actor waits until its account has been committed to the chain before
producing any transactions. This means newly created network accounts will idle until their creation
transaction is included in a block. Once the committed state is available, the actor reads its state
from the database and re-evaluates whenever notified by the coordinator.
Actors that have been idle (no available notes to consume) for longer than the idle timeout
will be deactivated. The idle timeout is configurable via the --ntx-builder.idle-timeout CLI
argument (default: 5 minutes).
Deactivated actors are re-spawned when committed-chain processing detects new notes targeting their account.
Each actors crash count is tracked, and once the count reaches a configurable threshold, the account is
deactivated and no new actor will be spawned for it. This prevents resource exhaustion from a persistently
failing account. The threshold is configurable via the --ntx-builder.max-account-crashes CLI
argument (default: 10).
The block-producer remains blissfully unaware of network transactions. From its perspective a network transaction is simply the same as any other.
gRPC Server
The NTX exposes an internal gRPC server for querying its state. The RPC component proxies public
requests to this server. In bundled mode the server is started automatically on a random port and
wired to the RPC; in distributed mode operators must pass the NTB's address to the RPC via
--ntx-builder.url (or MIDEN_NODE_NTX_BUILDER_URL).
Currently the only endpoint is GetNetworkNoteStatus(note_id) which returns the lifecycle status
of a network note (pending, processed, or discarded), along with the latest execution error,
attempt count, and block number of the last attempt. This is useful for debugging notes that fail
to be consumed.
Validator Component
The validator is responsible for verifying each new block and signing it if correct.
This signature is required before a block may be committed on chain, and thus acts as an independent safe guard.
The validator is therefore run completely separate from the main node operations, and is operated
by a separate entity. The validator's public key is published (or at least will be for mainnet).
Dual purpose: training wheels
The validator has a 2nd purpose while Miden is maturing. To prevent private state from being lost, and to guard from potential bugs in the VM/cryptography primitives, Miden will launch with training wheels. Notably, we require users to include the private input data along with their transactions. This means users will have privacy on the network but not from the validator set.
As part of the transaction submission process, each transaction, its proof, and private inputs, are sent to the validator, which re-executes the transaction, thereby verifying it and its proof are correct. This also lets us store the private data as part of our training wheels.
Block verification
The validator ensures that each new block is sequential with the previously signed block. i.e. header.parent_commitment == last_block.commitment.
It also checks that the block contains only transactions that it has previously seen and verified.
Once verified, the block is signed and returned to the sender.
Transaction encryption key
In addition to its per-validator signing key, every validator is provisioned with the same shared transaction encryption keypair, an Ed25519 key that miden-crypto uses for X25519 key agreement in its IES scheme. Clients use it to encrypt the private transaction inputs they submit, so that any validator in the set can decrypt them.
The GetTransactionEncryptionKey endpoint returns the shared public key together with an IES
scheme identifier, an opaque key ID, and a list of validator attestations, currently holding one
signature from this validator's own signing key over an attestation commitment (the
TransactionEncryptionKey proto message documents the exact payload). The commitment carries a domain tag that separates attestations from block header
signatures, and the genesis commitment so an attestation cannot replay across networks. The
signature proves to clients that a chain-recognized validator vouches for the key, so the key can
be served through an untrusted RPC.
This scheme does not protect the inputs from parties holding the shared secret and has no forward secrecy. It is the first phase of the transaction input encryption design: later phases move the key material to threshold and TEE-managed setups.
Submission path
SubmitProvenTransaction carries a SealedTransactionInputs envelope: a key_id in the clear plus
the ciphertext of a serialized SealedMessage. The validator rebuilds the associated data from its
own scheme, key id and genesis commitment, plus the transaction id it parses from the accompanying
plaintext ProvenTransaction. Nothing the submitter controls enters the associated data, so a
mismatched key_id cannot influence which key is tried: it only lets the validator answer
failed_precondition ("re-fetch the key") instead of an indistinguishable authentication failure.
The unseal happens before the serve lock is taken, so a slow or hung decrypt backend cannot starve the exclusive lock that a backup block subscription needs. The cost is that an already-validated resubmission pays for the unseal before being short-circuited.
After the proof, re-execution, and header checks pass, the validator encrypts the effects of the re-executed transaction under a fresh content key. Golden EHTDH1 protects that content key with the validators' threshold key. The validator stores only the transaction ID and the protected record. It does not store the client envelope or plaintext. A rejected transaction never creates a record.
The record holds a TransactionEffects message in its canonical Protobuf encoding. The effects
state what the transaction did: the account state commitments, the account patch, the input and
output notes, the reference block, and the expiration block. They omit the partial account, the
partial blockchain, the advice witness, and the transaction arguments, so a record is evidence of a
transaction rather than a means to execute it again. A record names its own format in
format_version, and both encryption layers authenticate that version.
Only format version 1 is supported. Validator databases created with the transaction inputs schema must be recreated. There is no migration for those databases.
Funding Service Component
The operator documentation covers the configuration and the API. This page covers the design.
The funding account
The service owns one wallet account. The account is created at genesis from a named [[wallet]] entry, which prefunds it and writes its account file to a fixed path.
The account is public, and the service stores no account state of its own. It reads the account from the node before every transaction and holds only the account ID, the signing key, and the code commitment from the account file. The node stores the full state of a public account, which makes that read possible.
This removes a class of failure which a service holding its own copy of the account would have. If a service crashes between submitting a transaction and seeing it commit, its copy of the account is behind the chain, and every later transaction it builds is rejected for a stale nonce until it re-synchronizes. Reading the account each time means there is no local copy to fall behind.
The request handler builds the note
The HTTP handler builds the public pay-to-ID note itself. A note needs only the sender, the target, the asset and a serial number, none of which depend on the chain, so the handler needs no call to the node. It answers with the note and puts it on the worker's queue.
The answer is therefore optimistic: it names a note which no transaction has created yet. A requester which needs the note on chain either waits for it at the node or consumes it as an unauthenticated input note, which the node authenticates when it builds the block.
The service keeps accepted requests and pending transactions in memory. A restart can lose an accepted request before its transaction reaches the node. Accepted requests do not have a durable delivery guarantee.
One worker, one transaction in flight
A single worker owns the account. It keeps at most one batch of requests outside the bounded request channel. The batch contains at most --max-notes-per-tx notes. Requests stay in the channel while the batch is full or a transaction remains pending. A full channel causes the HTTP handler to return 429.
The worker records the committed chain tip at startup. It scans every note page from genesis through that fixed tip before it processes requests. Failed pages are retried without advancing the cursor. The worker then repeats these steps in order:
- Wait for the poll interval. Scan for new deposits if the scan interval has elapsed.
- Fill the active batch from the request channel.
- Read the chain state. Select at most one deposit and affordable payouts, then execute and prove one transaction.
- Submit the transaction. Handle node rejections immediately. For accepted transactions or uncertain transport failures, poll
SyncTransactionsuntil commitment or expiration. - On commitment, remove the selected deposit and the queued payout prefix. On expiration, retain the notes for retry.
A node rejection means that the transaction was not submitted. A rejection with state-conflict error byte 2 discards the selected deposit and retains all payouts. Other rejections retain all notes for retry. A transport failure can leave the outcome unknown, so the worker waits for commitment or expiration before it prepares another transaction. Failed transaction polls are retried within that wait. Each poll checks every page in the block range before it reports expiration. The worker reads the full account state only when it prepares a transaction.
Selected notes stay in the deposit pool and request batch while the outcome is unknown. Preparation failures and expiration leave those notes available for retry. The output notes keep their IDs across retries. A failed RPC submission clears the cached encryption key so the next submission fetches a fresh key.
Deposits and payouts in one transaction
The worker selects at most one deposit and queued funding notes together. It uses the account balance plus that deposit to admit payouts. A transaction can consume a deposit, create funding notes, or do both. The worker also takes queued requests when a scan finds deposits, up to the batch limit.
Input assets enter the vault before the transaction creates funding notes and the kernel withdraws the fee. Deposits can therefore fund payouts and the fee in the same transaction when the account balance is zero. The combined transaction pays one fee.
The fee faucet is a foreign account
The native asset is callback-enabled: the kernel loads the issuing faucet in a foreign context whenever the asset enters or leaves a vault. Every funding transaction moves the asset, so the faucet must be in the transaction's data store together with its account-tree witness at the reference block. This holds even on a chain which does not charge fees, because the callback belongs to the asset and not to the fee.
Admission
The worker adds the selected deposit to the account balance and holds back the worst-case fee of one transaction. It then admits queued notes in order and stops at the first note which does not fit, which keeps the queue first-come-first-served and stops a stream of small notes from starving a large one.
A note which does not fit stays in the active batch. The worker polls the balance while that batch waits for funds. It can fill unused space in the batch from the request channel while no transaction is pending. Deposit scans continue on their own interval.
The handler checks the amount against the balance the service last read, and refuses a request the account plainly cannot serve. That check is best effort: the balance is the one of an earlier block and does not account for the notes already queued.
Deposits
A deposit is found by synchronizing notes by the funding account's tag. A filter keeps only the notes the account can consume: public, pay-to-ID, targeting the funding account, and holding the native asset and nothing else.
The worker indexes deposits by nullifier. Duplicate deposits in the pool occupy one entry. At startup, it collects all deposits through a fixed chain tip, then checks the recovered pool against the nullifier history. The nullifier lookup follows every response page and filters prefix matches by the exact nullifier. Spent deposits are discarded before the worker processes funding requests.
The scan cursor advances after note retrieval succeeds. A failed note page retries the same range. A failed startup nullifier check retries against the recovered pool without scanning the notes again. Periodic scans capture a new target tip and resume from the saved cursor. Each restart scans from genesis to recover unspent deposits, including those created while the service was stopped.
Periodic scans do not check nullifiers. A note ID can appear on chain again after the note was spent. The worker expects the node to reject this transaction with INVALID_ARGUMENT and error byte 2. The transaction has at most one deposit, so the worker discards that deposit and retains the payouts. This relies on one writer for the funding account and outputs constructed by the service. P2ID deposits have no reclaim path.
One transaction consumes at most one deposit. The worker selects the largest available deposit.
A transaction which only consumes deposits is submitted only when the deposits are worth more than the fee. Anyone can send a note which holds a single base unit, and consuming it on its own would cost the account more than it brings in.
Oddities and FAQs
Common questions and head scratchers.
Chain MMR
The chain MMR always lags behind the blockchain by one block because otherwise there would be a cyclic dependency between the chain MMR and the block hash:
- chain MMR contains each block's hash as a leaf
- block hash calculation includes the chain MMR's root
To work-around this the inclusion of a block hash in the chain MMR is delayed by one block. Or put differently, block
N is responsible for inserting block N-1 into the chain MMR. This does not break blockchain linkage because
the block header (and therefore hash) still includes the previous block's hash.