Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,35 @@
All notable changes to the Mintlayer Rust SDK are documented here.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added

- `node::Error::OutcomeUnknown` / `wallet::Error::OutcomeUnknown`: the
request was delivered but its outcome could not be confirmed (lost,
oversized, undecodable or mismatched response, or a timeout while
waiting for the response), so a mutating call may already have taken
effect. `IdMismatch`, `Json`, `ResponseTooLarge` and ambiguous
transport errors now arrive wrapped in this variant; only failures that
never established a connection (e.g. connection refused, DNS resolution
failure) surface as the plain transport variant.

### Changed

- **Breaking:** post-delivery transport failures surface as
`OutcomeUnknown(...)` instead of the bare variant, so a retry on `Err`
can no longer silently double a fund-moving mutation; update matches on
`IdMismatch` / `Json` / `ResponseTooLarge` to unwrap the new variant.

### Fixed

- Daemon-controlled JSON-RPC error messages are sanitized before they
reach the public error `Display`: control characters are stripped and
the message is capped at 8 KiB (matching the indexer client), defusing
forged multi-line log entries and terminal escape injection. The limit
and sanitizer live in the shared `limits` module so the indexer and
JSON-RPC transports cannot drift.

## [0.1.0] - 2026-09-17

### Added
Expand Down
8 changes: 8 additions & 0 deletions docs/node.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ Errors are returned as `node::Error`:
- `Error::Json(serde_json::Error)` — the response could not be decoded.
- `Error::IdMismatch { expected, actual }` — response id mismatch.
- `Error::ResponseTooLarge { limit }` — the 64 MiB cap was exceeded.
- `Error::OutcomeUnknown(Error)` — the request was delivered but its outcome
could not be confirmed (lost, oversized, undecodable or mismatched
response; also a timeout while waiting for the response); for mutating
calls the daemon may already have acted, so check for side effects before
retrying. `IdMismatch`, `Json`, `ResponseTooLarge` and ambiguous
transport errors arrive wrapped in this variant; only failures that
never established a connection (e.g. connection refused, DNS resolution
failure) surface as the plain variant.

---

Expand Down
32 changes: 29 additions & 3 deletions docs/wallet.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ let c = wallet::Client::builder("http://127.0.0.1:3034")

Errors are returned as `wallet::Error` —
`Error::Rpc { code, message }` for daemon errors, plus `Http`, `Json`,
`IdMismatch` and `ResponseTooLarge` transport variants. Basic-auth
credentials are redacted from `Debug` output; response bodies are capped
at 64 MiB.
`IdMismatch`, `ResponseTooLarge` and `OutcomeUnknown` transport variants.
Basic-auth credentials are redacted from `Debug` output; response bodies
are capped at 64 MiB, and daemon error messages are sanitized (control
characters stripped, 8 KiB cap) before they reach the error value.

---

Expand Down Expand Up @@ -94,6 +95,31 @@ Most send methods take `TxOptions { in_top_x_mb, broadcast_to_mempool }`:
`broadcast_to_mempool: Some(false)` builds and signs without broadcasting
while still returning the transaction hex.

## Response loss

**Never retry a fund-moving call (`send`, `send_token`,
`sweep_spendable`, `spend_utxo`, `deposit_data`, and the staking, token
and order mutators) on `Err` without checking the outcome first.** The
daemon builds, signs and broadcasts the transaction as soon as the
request arrives — before the response is read. If the response is then
lost, oversized, undecodable, or carries a mismatched id, the call
returns `Error::OutcomeUnknown` (wrapping the underlying transport
error): the daemon may already have broadcast the transaction, while a
pre-dispatch failure such as a connection refusal surfaces as the plain
transport variant.

On `OutcomeUnknown`, check `list_pending_transactions` /
`transaction_get` before re-issuing the request, or use the retry-safe
flow: `TxOptions { broadcast_to_mempool: Some(false) }` followed by
`submit_transaction` — resubmitting the same transaction hex converges
on one transaction, so retries are safe.

A timeout while waiting for the response is inherently ambiguous: the
request was likely fully written, so the client also classifies it as
`OutcomeUnknown`. Only failures that never established a connection
(e.g. connection refused, DNS resolution failure) are known to be
pre-dispatch and surface as the plain transport variant.

## Staking

See [staking.md](staking.md) for the full guide.
Expand Down
11 changes: 2 additions & 9 deletions src/indexer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,6 @@ pub use types::{

use crate::limits::DEFAULT_TIMEOUT;

/// Upper bound for the characters kept from a daemon error body.
const MAX_ERROR_BODY_CHARS: usize = 8 * 1024;

/// Client for the Mintlayer indexer REST API (api-web-server).
#[derive(Debug, Clone)]
pub struct Client {
Expand Down Expand Up @@ -106,14 +103,10 @@ impl Client {
let status = response.status();
if status.is_client_error() || status.is_server_error() {
let bytes = Self::read_capped(response).await.unwrap_or_default();
let body: String = String::from_utf8_lossy(&bytes)
.chars()
.filter(|c| !c.is_control())
.take(MAX_ERROR_BODY_CHARS)
.collect();
let body = crate::limits::sanitize_daemon_text(&String::from_utf8_lossy(&bytes));
return Err(Error::Http {
status_code: status.as_u16(),
body: body.trim().to_owned(),
body,
});
}
let bytes = Self::read_capped(response).await?;
Expand Down
70 changes: 58 additions & 12 deletions src/jsonrpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,15 @@ macro_rules! define_error {
/// The limit that was exceeded.
limit: usize,
},
/// The request was delivered, but its outcome could not be
/// confirmed: the response was lost, oversized, undecodable, or
/// did not carry the matching id. For mutating calls the daemon
/// may already have acted; check for side effects before
/// retrying.
#[error(
"request was delivered but its outcome is unknown (the daemon may have acted): {0}"
)]
OutcomeUnknown(Box<Self>),
}

impl From<crate::jsonrpc::RequestError> for $name {
Expand All @@ -83,6 +92,9 @@ macro_rules! define_error {
crate::jsonrpc::RequestError::ResponseTooLarge { limit } => {
Self::ResponseTooLarge { limit }
}
crate::jsonrpc::RequestError::AfterDispatch(inner) => {
Self::OutcomeUnknown(Box::new(Self::from(*inner)))
}
}
}
}
Expand Down Expand Up @@ -114,6 +126,13 @@ pub(crate) enum RequestError {
},
#[error("daemon response exceeds the maximum accepted size of {limit} bytes")]
ResponseTooLarge { limit: usize },
/// Marks every failure observed after the HTTP request was delivered:
/// the daemon may have acted before the response became unreadable, so
/// a caller cannot distinguish "the call never happened" from "the
/// call may have succeeded". For mutating calls this must be checked
/// before a retry.
#[error("request was delivered but its outcome is unknown (the daemon may have acted): {0}")]
AfterDispatch(Box<RequestError>),
}

#[derive(Serialize)]
Expand Down Expand Up @@ -177,30 +196,57 @@ impl Transport {
// The HTTP status code is intentionally not inspected: the daemon may
// answer with a valid JSON-RPC envelope on a non-2xx status (parity
// with the go-sdk client).
let http_response = builder.send().await?;
let response: Response = read_json_body(http_response).await?;
let http_response = builder.send().await.map_err(|err| {
// A failure while establishing the connection cannot have
// reached the daemon, so it is a genuine pre-dispatch failure.
// Every other send-phase failure (a timeout while waiting for
// the response headers, a connection reset mid-write) is
// inherently ambiguous: the request may have been fully written
// and the daemon may have acted, so it is classified as
// outcome-unknown.
if err.is_connect() {
RequestError::Http(err)
} else {
RequestError::AfterDispatch(Box::new(RequestError::Http(err)))
}
})?;
// From here on the request has been delivered: any failure means the
// daemon may have acted before the response became unreadable, which
// callers must be able to distinguish from a pre-dispatch failure.
let response: Response = read_json_body(http_response)
.await
.map_err(|err| RequestError::AfterDispatch(Box::new(err)))?;
match response.id {
Some(actual) if actual.as_u64() == Some(id) => {}
Some(actual) => {
return Err(RequestError::IdMismatch {
expected: id,
actual,
});
return Err(RequestError::AfterDispatch(Box::new(
RequestError::IdMismatch {
expected: id,
actual,
},
)));
}
None => {
return Err(RequestError::IdMismatch {
expected: id,
actual: serde_json::Value::Null,
});
return Err(RequestError::AfterDispatch(Box::new(
RequestError::IdMismatch {
expected: id,
actual: serde_json::Value::Null,
},
)));
}
}
// An error envelope carrying the matching id is a definitive answer
// from the daemon (it refused the request), not an unknown outcome.
// The daemon-controlled message is sanitized before it reaches the
// public error value.
if let Some(err) = response.error {
return Err(RequestError::Rpc {
code: err.code,
message: err.message,
message: crate::limits::sanitize_daemon_text(&err.message),
});
}
Ok(serde_json::from_value(response.result)?)
serde_json::from_value(response.result)
.map_err(|err| RequestError::AfterDispatch(Box::new(RequestError::Json(err))))
}
}

Expand Down
18 changes: 18 additions & 0 deletions src/limits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,24 @@ use std::time::Duration;
/// exhaustion from a misconfigured or hostile endpoint.
pub(crate) const MAX_RESPONSE_BYTES: usize = 64 * 1024 * 1024;

/// Upper bound for the characters kept from daemon-controlled error text
/// before it is embedded in a public error value.
pub(crate) const MAX_ERROR_BODY_CHARS: usize = 8 * 1024;

/// Neutralizes daemon-controlled error text before it is embedded in a
/// public error value: control characters are stripped (defusing forged
/// multi-line log entries and terminal escape sequences), the length is
/// capped at [`MAX_ERROR_BODY_CHARS`], and surrounding whitespace is
/// trimmed.
pub(crate) fn sanitize_daemon_text(raw: &str) -> String {
raw.chars()
.filter(|c| !c.is_control())
.take(MAX_ERROR_BODY_CHARS)
.collect::<String>()
.trim()
.to_owned()
}

/// Default request timeout for the daemon HTTP clients.
pub(crate) const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);

Expand Down
82 changes: 73 additions & 9 deletions tests/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,14 @@ async fn missing_response_id_is_rejected() {

let client = Client::new(server.url("/"));
match client.best_block_height().await {
Err(Error::IdMismatch { expected, actual }) => {
assert_eq!(expected, 1);
assert_eq!(actual, serde_json::Value::Null);
}
other => panic!("expected IdMismatch, got: {other:?}"),
Err(Error::OutcomeUnknown(inner)) => match *inner {
Error::IdMismatch { expected, actual } => {
assert_eq!(expected, 1);
assert_eq!(actual, serde_json::Value::Null);
}
other => panic!("expected IdMismatch, got: {other:?}"),
},
other => panic!("expected OutcomeUnknown(IdMismatch), got: {other:?}"),
}
}

Expand All @@ -148,6 +151,64 @@ async fn rpc_error_is_surfaced() {
assert_eq!(display, "RPC error -32601: Method not found");
}

#[tokio::test]
async fn rpc_error_message_is_sanitized() {
let server = MockServer::start();
// A hostile daemon forges a multi-line audit-log entry containing an
// ANSI escape sequence and pads it past the 8 KiB cap; the sanitized
// message must be a single line, escape-free, and length-capped.
let message = "rejected\n2026-09-19T10:00:00Z ERROR audit: wallet drained for \
operator=admin\u{1b}[31mINJECTED\u{1b}[0m"
.to_string()
+ &"x".repeat(9 * 1024);
mock_rpc(
&server,
"\"jsonrpc\"".to_string(),
rpc_error(1, -32600, &message),
);

let client = Client::new(server.url("/"));
let err = client.best_block_height().await.unwrap_err();
let display = err.to_string();
match err {
Error::Rpc { code, message } => {
assert_eq!(code, -32600);
// The newline was stripped and the payload preserved, not emptied.
assert!(message.starts_with("rejected2026-"));
}
other => panic!("expected Error::Rpc, got {other:?}"),
}
// No forged multi-line output.
assert_eq!(display.lines().count(), 1);
// No terminal escape sequence survived.
assert!(!display.contains('\u{1b}'));
// 8 KiB message cap plus slack for the "RPC error -32600: " prefix.
assert!(display.chars().count() <= 8 * 1024 + 64);
}

// The result-decode failure happens after the request was delivered, so it must surface as outcome-unknown.
#[tokio::test]
async fn undecodable_result_is_outcome_unknown() {
let server = MockServer::start();
let mock = mock_rpc(
&server,
"\"jsonrpc\"".to_string(),
rpc_ok(1, json!("not-a-number")),
);

let client = Client::new(server.url("/"));
match client.best_block_height().await {
Err(Error::OutcomeUnknown(inner)) => match *inner {
Error::Json(_) => {}
other => panic!("expected Json, got: {other:?}"),
},
other => panic!("expected OutcomeUnknown(Json), got: {other:?}"),
}
// The mock was hit: the request really was delivered before the decode
// failed, which is what makes the outcome unknown.
assert_eq!(mock.hits(), 1);
}

#[tokio::test]
async fn basic_auth_header_is_sent() {
let server = MockServer::start();
Expand Down Expand Up @@ -435,10 +496,13 @@ async fn oversized_responses_are_rejected() {
});
let client = Client::new(server.url("/"));
match client.best_block_height().await {
Err(Error::ResponseTooLarge { limit }) => {
assert_eq!(limit, 64 * 1024 * 1024);
}
other => panic!("expected ResponseTooLarge, got: {other:?}"),
Err(Error::OutcomeUnknown(inner)) => match *inner {
Error::ResponseTooLarge { limit } => {
assert_eq!(limit, 64 * 1024 * 1024);
}
other => panic!("expected ResponseTooLarge, got: {other:?}"),
},
other => panic!("expected OutcomeUnknown(ResponseTooLarge), got: {other:?}"),
}
}

Expand Down
Loading
Loading