Error codes
Every validation and execution failure carries a stable machine-readable
code in addition to a human-readable message. Downstream consumers branch on
the code, not on the message text: crucible-scenarios asserts that a transfer
from an unregistered account fails with not_registered, and the regression
suite pins the exact code a fixed bug used to produce.
Two properties this document exists to guarantee:
- Every code the code can return is listed here. A consumer can look up any code it observes without reading this repository’s source.
- Every code listed here exists. A stale table is worse than no table, because consumers trust it.
Both are enforced by scripts/check-error-codes.py, which extracts the codes
returned by every code() method and compares them against the tables below.
It runs in CI, so documentation drift is a build failure rather than something
a reader discovers.
Why strings, not numbers
Codes are short snake_case strings rather than numeric constants such as
E1042. A string is self-describing in a log line, a fixture, or a JUnit
report, and it survives a reordering of the enum it comes from — with numbers,
adding a variant in the middle of an enum silently renumbers every code after
it. The trade is that codes are not compact; that has not mattered here.
The stability contract
A code is a public interface. Treat it as one:
- Never rename a code. Renaming breaks every consumer’s
match. If a code’s meaning has to change, add a new code and keep the old one until consumers migrate. - Never reuse a retired code for a different condition.
- Adding a new code is not a breaking change; consumers should treat an unrecognised code as “some failure I do not specifically handle” rather than as a panic or an unreachable state.
Codes
Core validation
Raised while checking that an operation is well-formed, before any state is touched. A failure here means the request itself is wrong.
| Code | Category | Retryable | Raised when |
|---|---|---|---|
invalid_operation | validation | no | The operation is malformed or structurally invalid. |
invalid_account | validation | no | A referenced account does not exist. |
invalid_token | validation | no | A referenced token does not exist. |
zero_amount | validation | no | An amount is present but not strictly positive. |
Authorization
The operation is well-formed and may be cryptographically valid, yet the actor is not entitled to perform it. Kept distinct from validation on purpose: the fix is a different actor, not a different request.
| Code | Category | Retryable | Raised when |
|---|---|---|---|
permission_denied | authorization | no | An account transacts on a token it has no permission for. |
unauthorized | authorization | no | The actor is not authorized for this operation. |
wrong_owner | authorization | no | A commitment belongs to a different owner than the one claiming it. |
Registration and accounts
| Code | Category | Retryable | Raised when |
|---|---|---|---|
not_registered | state | no | An account is not registered for the token it is transacting on. |
duplicate_registration | conflict | no | An account registers twice for the same token. |
Value and commitments
The state preconditions that make a confidential transfer sound.
| Code | Category | Retryable | Raised when |
|---|---|---|---|
insufficient_balance | state | no | Available confidential value is less than the requested amount. The message carries both figures. |
unknown_commitment | state | no | A referenced commitment does not exist. |
inactive_commitment | state | no | A commitment is referenced but is not in an active state. |
consumed_commitment | state | no | A commitment was already consumed — a double-spend attempt. Also the code a replayed deposit produces. |
invalid_proof | cryptographic | no | A proof reference is missing, unverifiable, or invalid. |
invalid_state | state | no | The operation would move the system into a state that violates its invariants. |
Internal
| Code | Category | Retryable | Raised when |
|---|---|---|---|
state_corruption | internal | no | A recorded transition disagrees with the state it describes, or another internal invariant is violated. This should never happen; it indicates a bug in the simulator rather than a bad request. |
Snapshots
| Code | Category | Retryable | Raised when |
|---|---|---|---|
unknown_snapshot | snapshot | no | A named snapshot does not exist. |
duplicate_snapshot | snapshot | no | A named snapshot already exists. |
Fixtures
| Code | Category | Retryable | Raised when |
|---|---|---|---|
invalid_fixture | fixture | no | A fixture failed to parse or failed its version check. |
Soroban adapter
Raised at the execution boundary when an operation is translated into a contract call. The adapter also passes through every core code it receives from the simulator rather than re-coding it, so a consumer sees one code for one condition regardless of where it was raised.
| Code | Category | Retryable | Raised when |
|---|---|---|---|
missing_amount | adapter | no | The adapter needs an amount but none was supplied. |
bad_arguments | adapter | no | Arguments did not match the call’s expected arity or types. |
unimplemented | adapter | no | The operation has no contract-call translation. |
Command-line interface
Raised by the crucible binary. The CLI also passes through the simulator’s
codes, so crucible --dir /tmp/x transfer ... on an unregistered account
reports not_registered, not a CLI-specific wrapper.
| Code | Category | Retryable | Raised when |
|---|---|---|---|
io | environment | yes | Reading or writing the persisted environment or a fixture failed. The only code in this table worth retrying: the cause is the filesystem, not the request. |
corrupt_environment | environment | no | The persisted environment exists but is unreadable or does not parse. |
usage | usage | no | The command line was wrong: an unknown identity, an unknown fixture, a missing argument. |
Using codes
#![allow(unused)]
fn main() {
use crucible_core::errors::Error;
match error {
// Branch on the code when what matters is the condition, not the payload.
e if e.code() == "consumed_commitment" => report_double_spend(),
e if e.code() == "insufficient_balance" => report_underfunded(),
other => return Err(other.into()),
}
}
The typed variants remain the better choice inside Rust, because a match on
the enum is exhaustive and the compiler checks it. Codes exist for the places a
type cannot go: a fixture file, a JSON report, a CLI exit path, or another
language.